Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2c7789007 | ||
|
|
7c44740d9f | ||
|
|
4f31123b0c | ||
|
|
adf5d6cdd9 | ||
|
|
9d35addd86 | ||
|
|
3d6d69ec90 | ||
|
|
16f3fb41a3 | ||
|
|
8b3abdb787 | ||
|
|
a87139b838 | ||
|
|
936490880b | ||
|
|
d8b9b5a4bb | ||
|
|
ac0aea70ec | ||
|
|
5d175cc668 | ||
|
|
76ad667fd6 | ||
|
|
c045702a47 | ||
|
|
193b35a249 | ||
|
|
404fea47a8 | ||
|
|
d920716a2b | ||
|
|
2d878c2117 | ||
|
|
4c217c9be6 | ||
|
|
e44cb8cbe0 | ||
|
|
0a5eab0487 | ||
|
|
dd14243dba | ||
|
|
5f0b11c1f4 | ||
|
|
30468c4c69 | ||
|
|
428d8c4a51 | ||
|
|
2361b2d4ef | ||
|
|
de32c04a68 | ||
|
|
e13d3a54fc | ||
|
|
df741a8a49 | ||
|
|
9ede23073d | ||
|
|
1e171c6d31 | ||
|
|
577bbd82b0 | ||
|
|
bac5801618 | ||
|
|
af89433aee | ||
|
|
2cd49d1cb7 | ||
|
|
3dc7c184ee | ||
|
|
3f6dbce2aa | ||
|
|
124b2d99c6 | ||
|
|
3994caba12 | ||
|
|
901f51e6c4 | ||
|
|
2584b9ecbc | ||
|
|
9b674a88d8 | ||
|
|
43652bd613 | ||
|
|
1c5c55b4c9 | ||
|
|
5fa4c0772b | ||
|
|
68ebf5d78b | ||
|
|
81b1ae8c42 | ||
|
|
7c3e1d2c54 | ||
|
|
efa9d84aad | ||
|
|
59917c5183 | ||
|
|
e235e99cce | ||
|
|
6a97fdf6f9 | ||
|
|
474dcf0bf6 | ||
|
|
1ad35e2b87 | ||
|
|
ca2a135e28 | ||
|
|
601c984894 | ||
|
|
98fcf8e98e | ||
|
|
a56baa9042 | ||
|
|
f0ee53ec09 | ||
|
|
2841eaf9a1 | ||
|
|
c4ddcbe0f7 | ||
|
|
a1165ef182 | ||
|
|
e17b909d41 | ||
|
|
8e7b1350bf | ||
|
|
95e854b5a2 | ||
|
|
c1069ce07e | ||
|
|
dabf63ed6d | ||
|
|
f6c9343bcc | ||
|
|
710fcbc1bd | ||
|
|
89c4ff1820 | ||
|
|
6acc98baad | ||
|
|
353382c07f | ||
|
|
d44bffa4a8 | ||
|
|
8bf5e13faf | ||
|
|
0ff1e01ec3 | ||
|
|
53dc2d171e | ||
|
|
eed27eb36d | ||
|
|
4dc954f01a | ||
|
|
3d248cfb79 | ||
|
|
a1e361e35a | ||
|
|
4af3b93dfa | ||
|
|
9ddc1273c0 | ||
|
|
e7b526c550 | ||
|
|
144d2e4e84 | ||
|
|
41c655c176 | ||
|
|
d0ebc9f571 | ||
|
|
6783d0c723 | ||
|
|
0a6509de93 | ||
|
|
1d800d56b0 | ||
|
|
7981718d83 | ||
|
|
d48e9f6033 | ||
|
|
1c5849ebe8 | ||
|
|
4efab496c2 | ||
|
|
f87bf6ebc0 | ||
|
|
4f4abd335f | ||
|
|
8131706394 | ||
|
|
bfdb6d5e4b | ||
|
|
d7b851f622 | ||
|
|
f26f1ab694 | ||
|
|
e8b072c815 | ||
|
|
c62edd3317 | ||
|
|
23c3155128 | ||
|
|
0845d4ee20 | ||
|
|
94b1b9e0cc | ||
|
|
19b56ad85c | ||
|
|
328fda6f7c | ||
|
|
327b8c7839 | ||
|
|
df06240b2d | ||
|
|
86260b5028 | ||
|
|
80f0a0eeac | ||
|
|
677146883f | ||
|
|
9994bb4ce7 | ||
|
|
7f350e8c4c | ||
|
|
763dfd353a | ||
|
|
485297f0c6 | ||
|
|
509c3a3e96 | ||
|
|
d70dcf78d1 | ||
|
|
c48896bd95 | ||
|
|
700e592b48 | ||
|
|
b34e786f01 | ||
|
|
c7e1822f77 | ||
|
|
f4fbabf18b | ||
|
|
18063152f9 | ||
|
|
745f3a98c7 | ||
|
|
a8008feaf3 | ||
|
|
8510a2f6a7 | ||
|
|
7b5f62019b | ||
|
|
f86438ac0b | ||
|
|
158642d4ce | ||
|
|
458025c607 | ||
|
|
6974604e84 | ||
|
|
8e4e24ad09 | ||
|
|
1a59fb33c1 | ||
|
|
7312aceded | ||
|
|
8df6000e4b | ||
|
|
465f5a7ffa | ||
|
|
e8e96eed40 | ||
|
|
1ff52102b6 | ||
|
|
8848bbcff3 | ||
|
|
35954dfa1d | ||
|
|
8eeb9ba0f6 | ||
|
|
258aae71d6 | ||
|
|
677b047235 | ||
|
|
53b04dc561 | ||
|
|
bddd30fba2 | ||
|
|
46706fe622 | ||
|
|
80298403f7 | ||
|
|
b63ce0290b | ||
|
|
7409cb7767 | ||
|
|
6ec6c5cbd6 | ||
|
|
f1b0634bd1 | ||
|
|
8865743db6 | ||
|
|
8e4befa727 | ||
|
|
e0da93c59a | ||
|
|
11e515a295 | ||
|
|
99f9b5b7c5 | ||
|
|
99563b33f7 | ||
|
|
e22945a62d | ||
|
|
62e585844a | ||
|
|
421a47f1eb | ||
|
|
0906c3983f | ||
|
|
9d9abb227f | ||
|
|
126beaf7ed | ||
|
|
79b7f54e04 | ||
|
|
9a2b39b8e5 | ||
|
|
2e09906a08 | ||
|
|
e26f71d15d | ||
|
|
ef07e6ca3e | ||
|
|
9e519eb8a6 | ||
|
|
b120ce48ec | ||
|
|
ea88216008 | ||
|
|
3b2bb58fa4 | ||
|
|
49787414fb | ||
|
|
f41edc03ff | ||
|
|
091306f736 | ||
|
|
1cd5ab6815 | ||
|
|
04203b4a82 | ||
|
|
4b212089d2 | ||
|
|
964e72bca2 | ||
|
|
e38b730aa6 | ||
|
|
12812452e8 | ||
|
|
df90dd95a2 | ||
|
|
31a3580f9d | ||
|
|
20c368d3fc | ||
|
|
c57fd8ec5a | ||
|
|
da3b1487f9 | ||
|
|
758436cc28 | ||
|
|
4b6c110816 | ||
|
|
09dddde594 | ||
|
|
846a249156 | ||
|
|
d63872e5a9 | ||
|
|
d70f5c14e0 | ||
|
|
2955cad654 | ||
|
|
7418b040da | ||
|
|
085ab766b3 | ||
|
|
55cb9b5b30 | ||
|
|
e5db849f04 | ||
|
|
5226f7223b | ||
|
|
d0b671d4db | ||
|
|
24bf5be574 | ||
|
|
e465ff0c80 | ||
|
|
6d5de21fb7 | ||
|
|
e40f2bfee9 | ||
|
|
f5edd49b6b | ||
|
|
9d11554c71 | ||
|
|
d1f3b97245 | ||
|
|
a2a0131693 | ||
|
|
b9891e2c04 | ||
|
|
23c74d7063 | ||
|
|
a4b9deaf96 | ||
|
|
2403f355d6 | ||
|
|
bbbc23f376 | ||
|
|
e027d34b65 | ||
|
|
82d9d077b0 | ||
|
|
75fd5619ca | ||
|
|
2b812ebe21 | ||
|
|
6246a2e346 | ||
|
|
fc4157a1e0 | ||
|
|
91fe6cf300 | ||
|
|
754ab91347 | ||
|
|
36ea53cceb | ||
|
|
3e19628324 | ||
|
|
4a04496c78 | ||
|
|
2168cdd1c4 | ||
|
|
7ad75dd905 | ||
|
|
b52be080ff | ||
|
|
4d0ad0601c | ||
|
|
115653a262 | ||
|
|
bb35665bd2 | ||
|
|
d3dadbd725 | ||
|
|
8d5dc423ea | ||
|
|
1cd58bcaaa | ||
|
|
ab49280614 | ||
|
|
0d9f802f88 | ||
|
|
88ef7148d6 | ||
|
|
8165619065 | ||
|
|
ab0ef6a26d | ||
|
|
a56ac87e2c | ||
|
|
47a40d1afa | ||
|
|
95847e3a31 | ||
|
|
eb39599f12 | ||
|
|
5a8327824f | ||
|
|
574107bc39 | ||
|
|
6d25d85f18 | ||
|
|
9ac1447139 | ||
|
|
5133e53bc8 | ||
|
|
ff6c313bab | ||
|
|
bbd26f05f9 | ||
|
|
bd33487054 | ||
|
|
c38df01bf7 | ||
|
|
2c8768ac29 | ||
|
|
52c655e6cf | ||
|
|
3bb68cff69 | ||
|
|
d9eb8faffd | ||
|
|
e6ad906bc1 | ||
|
|
dbaf5358d1 | ||
|
|
480c46c41a | ||
|
|
30162e80df | ||
|
|
8b251b84a0 | ||
|
|
bff95e25ad | ||
|
|
3b5d564495 | ||
|
|
0407fb8d2d | ||
|
|
3944e17aa5 | ||
|
|
39c34d4e44 | ||
|
|
da20d42d33 | ||
|
|
b2250cc460 | ||
|
|
f4395bd17c | ||
|
|
8f596262b8 | ||
|
|
a67402961d | ||
|
|
b4d39ba33a | ||
|
|
e596eb0657 | ||
|
|
ebb7d3cf5c | ||
|
|
21d599b420 | ||
|
|
4168d67cfa | ||
|
|
702d83c218 | ||
|
|
5768100816 | ||
|
|
c102ba9df2 | ||
|
|
6c363cee97 | ||
|
|
64462e9fd3 | ||
|
|
cbe5c4fcde | ||
|
|
f12aece07e | ||
|
|
c8e05831f4 | ||
|
|
1b8b7998a2 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Model weights live in LFS.
|
||||
#
|
||||
# `core/dr-segment/models/*.onnx` is ~11 MB of binary that changes wholesale
|
||||
# `models/**/*.onnx` is tens of MB of binary that changes wholesale
|
||||
# when it changes at all. In ordinary git objects every future revision of it
|
||||
# would be stored in full, in every clone, forever — and the one thing nobody
|
||||
# can do with it is a useful diff.
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
name: Benchmarks
|
||||
|
||||
# The suite docs/requirements.md §8 has been promising since it was written:
|
||||
# "an automated benchmark suite against a synthetic 50k catalog, run per-commit
|
||||
# … A regression beyond stated tolerance fails the build."
|
||||
#
|
||||
# Its own workflow rather than a step inside build-and-test.yml, and the reason
|
||||
# is what a failure here means. A red `Build and test` says the code is wrong; a
|
||||
# red `Benchmarks` says the code is slower than it was, which is a different
|
||||
# conversation, is read by different people, and must not be reachable by
|
||||
# retrying a flaky compile.
|
||||
#
|
||||
# # Why this is split in two
|
||||
#
|
||||
# §8 names "the reference desktop", not CI, and it is right to. So:
|
||||
#
|
||||
# cpu — runs on every push. It needs no adapter and no display, and the
|
||||
# budgets it asserts (a 50k catalog opening inside two seconds) have
|
||||
# two orders of magnitude of headroom, so a modest runner can be held
|
||||
# to them honestly. Machine-sensitive budgets — throughput targets
|
||||
# written for a 24-thread desktop — are reported here rather than
|
||||
# asserted; `dr-bench` decides that per metric and says so in its
|
||||
# report. Asserting them on a two-core container would produce exactly
|
||||
# what core/dr-gpu/tests/frame_budget.rs refused to produce: a red gate
|
||||
# everybody learns to ignore.
|
||||
#
|
||||
# gpu — the frame budget, which already exists and already skips itself where
|
||||
# there is no adapter. Not on push: it would build wgpu and naga on
|
||||
# every commit to establish, every time, that this runner has no GPU. It
|
||||
# runs on demand (Actions → Run workflow) so that a runner that *does*
|
||||
# have one can be pointed at it, and the numbers it produces belong in
|
||||
# docs/frame-budget.md by hand, as they already are.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop]
|
||||
pull_request:
|
||||
branches: [main, master, develop]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cpu:
|
||||
runs-on: linux/amd64
|
||||
name: CPU and I/O (per commit)
|
||||
# Node for actions/checkout and actions/cache, which the bare runner image
|
||||
# cannot execute. Rust is installed below.
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
env:
|
||||
# Same reasoning as the desktop job in build-and-test.yml: incremental
|
||||
# state exists to make the *second* build in a working tree fast, which is
|
||||
# not a thing a fresh checkout has, and it fills the runner's disk.
|
||||
CARGO_INCREMENTAL: 0
|
||||
# The fixture, out of the workspace so actions/cache never picks it up.
|
||||
# A 14 MB synthetic catalog is two seconds to regenerate and would
|
||||
# otherwise be uploaded and downloaded on every push to save them.
|
||||
DR_BENCH_DIR: /tmp/darkroom-bench
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# No `git lfs pull` here, deliberately. `dr-bench` depends on the catalog,
|
||||
# the decoder, the thumbnail store and the encoder, and on nothing that
|
||||
# reaches `dr-segment` — so the model this repository keeps in LFS is not
|
||||
# part of this job's dependency graph and fetching it would be a minute
|
||||
# spent on a file nothing opens.
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: bench-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
# Pinned to the workspace rust-version, as every other job here is: a
|
||||
# floating toolchain turns an unrelated push into a mystery failure, and
|
||||
# for a benchmark it would turn one into a mystery *regression*.
|
||||
#
|
||||
# rust-analyzer is named for the reason build-and-test.yml gives: rustup
|
||||
# reconciles rust-toolchain.toml on the first cargo call whatever this
|
||||
# step asks for, so naming it keeps the download inside the step that says
|
||||
# it is installing things.
|
||||
- name: Install Rust 1.92.0
|
||||
run: |
|
||||
set -e
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- \
|
||||
-y --no-modify-path --profile minimal --default-toolchain 1.92.0 \
|
||||
--component rust-analyzer
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# `-p dr-bench`, not `--workspace`. The whole point of that crate having
|
||||
# no GPU and no UI dependency is that this job resolves the catalog, the
|
||||
# decoder and the encoders and stops there — a few minutes rather than the
|
||||
# release build of Slint and wgpu the desktop job pays for.
|
||||
#
|
||||
# Release, and it is not optional: the workspace builds its own crates at
|
||||
# opt-level = 0 in dev, and every figure this produces is dominated by
|
||||
# this workspace's own code. A debug run would measure rustc.
|
||||
- name: Build the suite
|
||||
run: cargo build --release -p dr-bench
|
||||
|
||||
# Exit 1 is a violated budget or a regression past tolerance; exit 2 is
|
||||
# the harness failing to run at all. Both fail the job, and the report
|
||||
# above the failure says which.
|
||||
- name: Measure, and gate
|
||||
run: cargo run --release -p dr-bench -- check
|
||||
|
||||
- name: Disk after
|
||||
if: always()
|
||||
run: df -h /workspace 2>/dev/null || df -h .
|
||||
|
||||
gpu:
|
||||
# On demand only — see the header. A runner with a Vulkan device can be
|
||||
# pointed at this; one without will skip the measurement and say so, which
|
||||
# is the same posture the rest of this repository's device tests take.
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: linux/amd64
|
||||
name: Frame budget (on demand)
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# `dr-gpu` depends on `dr-segment` for the watershed's pixel passes. Its
|
||||
# default features are off, so no weights are compiled in — but the fetch
|
||||
# is cheap insurance and its failure is not fatal. The header of the same
|
||||
# step in build-and-test.yml explains why the extraheader is stripped
|
||||
# rather than reused: two Authorization headers is a 400 from Gitea, one
|
||||
# step after the batch call that had just succeeded.
|
||||
- name: Fetch the segmentation model
|
||||
continue-on-error: true
|
||||
env:
|
||||
LFS_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:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: bench-gpu-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Build dependencies
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq pkg-config libfontconfig1-dev libxkbcommon-dev
|
||||
|
||||
- name: Install Rust 1.92.0
|
||||
run: |
|
||||
set -e
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- \
|
||||
-y --no-modify-path --profile minimal --default-toolchain 1.92.0 \
|
||||
--component rust-analyzer
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The guard, in release. Its own module documentation is explicit that a
|
||||
# release run checks strictly more than a dev one: the CPU half of a frame
|
||||
# is shader-string assembly, which is several times slower unoptimised, so
|
||||
# it is folded into the assertion only when debug_assertions is off.
|
||||
#
|
||||
# With no adapter this prints "skipping: no GPU adapter" and passes. A
|
||||
# test that cannot run is not evidence either way, and turning that into a
|
||||
# failure would make the job useless on the runner it usually lands on.
|
||||
- name: Frame budget (FR-DSP-3)
|
||||
run: cargo test --release -p dr-gpu --test frame_budget -- --nocapture
|
||||
|
||||
# The instrument behind docs/frame-budget.md. It exits non-zero with no
|
||||
# adapter, which is right for a tool a person runs deliberately and wrong
|
||||
# for a job that usually has none — hence continue-on-error. Its table is
|
||||
# in the log for whoever asked for this run; the committed numbers are
|
||||
# still updated by hand, as that file says.
|
||||
- name: Frame budget table
|
||||
continue-on-error: true
|
||||
run: cargo run --release -p dr-gpu --example frame_budget
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# The model, which is in LFS and is not optional.
|
||||
#
|
||||
# `core/dr-segment/models/*.onnx` is tracked in LFS (.gitattributes), so a
|
||||
# `models/**/*.onnx` is tracked in LFS (.gitattributes), so a
|
||||
# plain checkout writes a ~130-byte pointer where 11 MB should be, and
|
||||
# `dr-segment`'s build script panics by design rather than embedding a
|
||||
# pointer and failing at inference. That failure reads like a broken build
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull
|
||||
ls -l core/dr-segment/models/
|
||||
ls -lR models/
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -117,12 +117,20 @@ jobs:
|
||||
# The act image ships Node but no Rust. Pinned to the workspace
|
||||
# rust-version so CI, the Android image, and local builds agree — a
|
||||
# floating toolchain turns an unrelated push into a mystery failure.
|
||||
#
|
||||
# The component list mirrors rust-toolchain.toml's, rust-analyzer
|
||||
# included, even though nothing in this job runs it. rustup reconciles
|
||||
# that file against the installed toolchain on the first cargo call in
|
||||
# the work tree and fetches whatever is missing — so leaving it out does
|
||||
# not save the download, it only moves it into the middle of a build
|
||||
# step where it is nobody's line item. Naming it here keeps every fetch
|
||||
# inside the step whose name says it is installing things.
|
||||
- name: Install Rust 1.92.0
|
||||
run: |
|
||||
set -e
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- \
|
||||
-y --no-modify-path --profile minimal \
|
||||
--default-toolchain 1.92.0 --component rustfmt,clippy
|
||||
--default-toolchain 1.92.0 --component rustfmt,clippy,rust-analyzer
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# Free space before and after the expensive steps, so a repeat of the
|
||||
@@ -166,7 +174,7 @@ jobs:
|
||||
|
||||
# The model, which is in LFS and is not optional.
|
||||
#
|
||||
# `core/dr-segment/models/*.onnx` is tracked in LFS (.gitattributes), so a
|
||||
# `models/**/*.onnx` is tracked in LFS (.gitattributes), so a
|
||||
# plain checkout writes a ~130-byte pointer where 11 MB should be, and
|
||||
# `dr-segment`'s build script panics by design rather than embedding a
|
||||
# pointer and failing at inference. That failure reads like a broken build
|
||||
@@ -206,7 +214,7 @@ jobs:
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull
|
||||
ls -l core/dr-segment/models/
|
||||
ls -lR models/
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -370,11 +378,17 @@ jobs:
|
||||
|
||||
# `cargo tree` resolves the dependency graph, so it needs the registry
|
||||
# index but no system libraries — this job builds nothing.
|
||||
#
|
||||
# rust-analyzer is named for the reason given in the desktop job: rustup
|
||||
# installs rust-toolchain.toml's components on the first cargo call
|
||||
# whether or not this step asks for them, and an unasked-for download is
|
||||
# the one nobody can find in the log.
|
||||
- name: Install Rust 1.92.0
|
||||
run: |
|
||||
set -e
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- \
|
||||
-y --no-modify-path --profile minimal --default-toolchain 1.92.0
|
||||
-y --no-modify-path --profile minimal --default-toolchain 1.92.0 \
|
||||
--component rust-analyzer
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# ARCH §6.5a: no core/ crate may depend on the UI toolkit. One stray
|
||||
|
||||
@@ -44,12 +44,17 @@ jobs:
|
||||
key: traces-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
# Source-comment and markdown parsing only, so the minimal profile is
|
||||
# enough — no system libraries and no components beyond cargo itself.
|
||||
# enough — no system libraries and nothing this job itself needs beyond
|
||||
# cargo. rust-analyzer is here anyway because rust-toolchain.toml lists
|
||||
# it: rustup installs that file's components on the first cargo call in
|
||||
# the work tree regardless, and a download named in the install step
|
||||
# beats the same download appearing unannounced inside the gate.
|
||||
- name: Install Rust 1.92.0
|
||||
run: |
|
||||
set -e
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- \
|
||||
-y --no-modify-path --profile minimal --default-toolchain 1.92.0
|
||||
-y --no-modify-path --profile minimal --default-toolchain 1.92.0 \
|
||||
--component rust-analyzer
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The gate's own arithmetic is the thing being trusted, so its tests run
|
||||
@@ -77,6 +82,19 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The gesture vocabulary, from the same scanner and under the same rule.
|
||||
#
|
||||
# Blocking, and for a sharper reason than the matrix: these two artefacts
|
||||
# are not only read, one of them is *shown to the user*. A stale
|
||||
# `gesture_book.rs` is a help sheet in the application telling somebody to
|
||||
# perform a gesture that was removed — worse than no help sheet, because
|
||||
# they will conclude the application is broken rather than the page.
|
||||
#
|
||||
# This also fails on a malformed tag, so a typo costs a gesture its
|
||||
# desktop half loudly rather than silently.
|
||||
- name: Regenerate the gesture vocabulary and check it is committed
|
||||
run: cargo run -q -p traceability -- gestures-check
|
||||
|
||||
# Advisory, not blocking: not every file implements a requirement, and a
|
||||
# tag on every function is noise that rots faster than it helps. Tag the
|
||||
# unit that decides.
|
||||
|
||||
+33
-6
@@ -1,5 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Keep docs/traceability.md in step with the tags in the tree.
|
||||
# Keep the generated artefacts in step with the tags in the tree.
|
||||
#
|
||||
# Two of them now, from the same scanner: the requirements matrix and the
|
||||
# gesture vocabulary. Both are generated *from* the tree and cite line numbers
|
||||
# in it, so both go stale on any commit that moves a line — a `cargo fmt` sweep
|
||||
# above all, but equally a commit that merely adds a paragraph above a tag.
|
||||
#
|
||||
# The gate regenerates the matrix in CI and fails if the result differs from
|
||||
# what is committed. That is the right check — a matrix that disagrees with the
|
||||
@@ -18,11 +23,13 @@ staged="$(git diff --cached --name-only --diff-filter=ACMR)"
|
||||
if ! grep -qE '\.(rs|slint|yaml|md)$' <<< "${staged}"; then
|
||||
exit 0
|
||||
fi
|
||||
# The matrix is generated from the tree, so regenerating it because it was
|
||||
# itself edited would be circular.
|
||||
if [ "$(tr -d '[:space:]' <<< "${staged}")" = "docs/traceability.md" ]; then
|
||||
exit 0
|
||||
fi
|
||||
# The artefacts are generated from the tree, so regenerating them because one
|
||||
# was itself edited would be circular.
|
||||
case "$(tr -d '[:space:]' <<< "${staged}")" in
|
||||
docs/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
repo="$(git rev-parse --show-toplevel)"
|
||||
cd "${repo}"
|
||||
@@ -38,3 +45,23 @@ if ! git diff --quiet -- docs/traceability.md; then
|
||||
git add docs/traceability.md
|
||||
echo "pre-commit: regenerated docs/traceability.md and staged it"
|
||||
fi
|
||||
|
||||
# The gesture vocabulary, same discipline.
|
||||
#
|
||||
# **Failure here is reported and not swallowed**, unlike the matrix above. A
|
||||
# matrix that will not build leaves the previous one in place, which is merely
|
||||
# stale; a malformed `GESTURE:` block means a gesture the user is about to be
|
||||
# told about in the wrong words, or not at all. The gate would catch it in CI
|
||||
# either way — this is only about catching it a push earlier.
|
||||
if ! out="$(cargo run -q -p traceability -- gestures 2>&1)"; then
|
||||
echo "pre-commit: the gesture scan failed — the tags below need fixing" >&2
|
||||
echo "${out}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for f in docs/gestures.md ui/dr-ui/src/gesture_book.rs; do
|
||||
if ! git diff --quiet -- "${f}"; then
|
||||
git add "${f}"
|
||||
echo "pre-commit: regenerated ${f} and staged it"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -16,3 +16,10 @@ Cargo.lock.bak
|
||||
# tools/film-profiles/convert.py --fetch. Not source: the converted
|
||||
# profiles in core/dr-film/profiles are.
|
||||
tools/film-profiles/upstream/
|
||||
|
||||
# flatpak-builder's cache and its output tree. `packaging/flatpak/` holds the
|
||||
# manifest, which is source; everything a build derives from it is not — and
|
||||
# `.flatpak-builder/` in particular caches an unpacked copy of the whole
|
||||
# checkout, so it is larger than the repository it sits in.
|
||||
/.flatpak-builder/
|
||||
/build/
|
||||
|
||||
@@ -83,6 +83,21 @@ GPU tests skip themselves where there is no adapter rather than failing — a
|
||||
test that cannot run is not evidence either way — so a green run on a machine
|
||||
without a GPU is expected, and does not mean the GPU paths were exercised.
|
||||
|
||||
There is a fifth check, and it is not in that list because you are unlikely to
|
||||
break it by accident:
|
||||
|
||||
```bash
|
||||
cargo run --release -p dr-bench -- check
|
||||
```
|
||||
|
||||
That is the benchmark suite (`docs/requirements.md` §8), which builds a
|
||||
synthetic 50,000-image catalog and fails the build if a performance target is
|
||||
missed or a measurement has drifted past its tolerance. It runs on every push in
|
||||
its own workflow. [`docs/benchmarks.md`](docs/benchmarks.md) says what it
|
||||
measures, what it deliberately does not, and how to read a failure. If you have
|
||||
touched the catalog, the decoder, the thumbnail store or the exporter, run it
|
||||
before you send.
|
||||
|
||||
## Requirements and traceability
|
||||
|
||||
[`requirements.md`](docs/requirements.md) is the register of record.
|
||||
@@ -150,7 +165,9 @@ One commit per change. If you fixed two things, that is two commits.
|
||||
| [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) | Adding or changing a develop operation — start here regardless |
|
||||
| [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync |
|
||||
| [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs |
|
||||
| [`docs/benchmarks.md`](docs/benchmarks.md) | A change that could plausibly cost time or memory |
|
||||
| [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen |
|
||||
| [`docs/distribution.md`](docs/distribution.md) | Packaging a build, or adding a permission to one |
|
||||
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading |
|
||||
|
||||
`technical-debt.md` is the one to check before "fixing" anything surprising.
|
||||
|
||||
Generated
+80
-20
@@ -1221,20 +1221,23 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-android"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-sync-nextcloud",
|
||||
"dr-plat",
|
||||
"dr-sync",
|
||||
"dr-ui",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"slint",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-desktop"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-plat",
|
||||
"dr-ui",
|
||||
"env_logger",
|
||||
"log",
|
||||
@@ -1402,9 +1405,26 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dr-bench"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-catalog",
|
||||
"dr-decode",
|
||||
"dr-export",
|
||||
"dr-thumbs",
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
"log",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-catalog"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"dr-face",
|
||||
"dr-plat",
|
||||
@@ -1419,7 +1439,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-decode"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
@@ -1433,7 +1453,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-export"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-gpu",
|
||||
@@ -1451,7 +1471,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-face"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"log",
|
||||
@@ -1464,7 +1484,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-film"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1473,7 +1493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-gpu"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dr-decode",
|
||||
@@ -1490,7 +1510,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ingest"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
@@ -1502,7 +1522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-lens"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"lensfun",
|
||||
"log",
|
||||
@@ -1510,7 +1530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pipeline"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -1519,7 +1539,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-plat"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"android-native-keyring-store",
|
||||
"dr-types",
|
||||
@@ -1533,9 +1553,19 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-preset-xmp"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"dr-pipeline",
|
||||
"log",
|
||||
"quick-xml",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"log",
|
||||
@@ -1548,9 +1578,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-folder"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-sync",
|
||||
"dr-types",
|
||||
"log",
|
||||
"thiserror 2.0.20",
|
||||
@@ -1559,12 +1604,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-nextcloud"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-decode",
|
||||
"dr-plat",
|
||||
"dr-sync",
|
||||
"dr-sync-folder",
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
"log",
|
||||
@@ -1580,7 +1626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-thumbs"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"jpeg-encoder",
|
||||
@@ -1592,7 +1638,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-types"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1601,9 +1647,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ui"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"dr-catalog",
|
||||
"dr-decode",
|
||||
"dr-export",
|
||||
@@ -1611,10 +1658,13 @@ dependencies = [
|
||||
"dr-film",
|
||||
"dr-gpu",
|
||||
"dr-ingest",
|
||||
"dr-lens",
|
||||
"dr-pipeline",
|
||||
"dr-plat",
|
||||
"dr-preset-xmp",
|
||||
"dr-segment",
|
||||
"dr-sync",
|
||||
"dr-sync-folder",
|
||||
"dr-sync-nextcloud",
|
||||
"dr-thumbs",
|
||||
"dr-types",
|
||||
@@ -1634,6 +1684,16 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-xmp"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
"quick-xml",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drm"
|
||||
version = "0.14.1"
|
||||
@@ -6927,7 +6987,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "traceability"
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
|
||||
+25
-6
@@ -12,18 +12,22 @@ members = [
|
||||
"core/dr-gpu",
|
||||
"core/dr-lens",
|
||||
"core/dr-pipeline",
|
||||
"core/dr-preset-xmp",
|
||||
"core/dr-segment",
|
||||
"core/dr-sync",
|
||||
"core/dr-sync-folder",
|
||||
"core/dr-sync-nextcloud",
|
||||
"core/dr-xmp",
|
||||
"platform/dr-plat",
|
||||
"ui/dr-ui",
|
||||
"apps/darkroom-desktop",
|
||||
"apps/darkroom-android",
|
||||
"tools/bench",
|
||||
"tools/traceability",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.12.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -45,6 +49,7 @@ dr-ingest = { path = "core/dr-ingest" }
|
||||
dr-gpu = { path = "core/dr-gpu" }
|
||||
dr-lens = { path = "core/dr-lens" }
|
||||
dr-pipeline = { path = "core/dr-pipeline" }
|
||||
dr-preset-xmp = { path = "core/dr-preset-xmp" }
|
||||
# `default-features = false` belongs *here*, not on each dependant: a member
|
||||
# inheriting a workspace dependency cannot turn its default features off, so
|
||||
# writing it below would silently do nothing and every crate touching
|
||||
@@ -53,7 +58,9 @@ dr-pipeline = { path = "core/dr-pipeline" }
|
||||
dr-segment = { path = "core/dr-segment", default-features = false }
|
||||
dr-plat = { path = "platform/dr-plat" }
|
||||
dr-sync = { path = "core/dr-sync" }
|
||||
dr-sync-folder = { path = "core/dr-sync-folder" }
|
||||
dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" }
|
||||
dr-xmp = { path = "core/dr-xmp" }
|
||||
dr-ui = { path = "ui/dr-ui" }
|
||||
|
||||
# GPU + UI
|
||||
@@ -226,13 +233,25 @@ ort-tract = "0.4"
|
||||
ndarray = "0.17"
|
||||
|
||||
[profile.dev]
|
||||
# Dependencies optimised even in dev builds — wgpu and image decoding are
|
||||
# unusably slow otherwise, and they rarely need debugging.
|
||||
# Dev builds are tuned for how fast they *compile*, not for how fast they run.
|
||||
# Optimisation is a release concern; `[profile.release]` below is where it
|
||||
# belongs.
|
||||
#
|
||||
# This deliberately reverses an earlier choice. Dependencies used to be built
|
||||
# at `opt-level = 2` here, because wgpu and image decoding are slow without it.
|
||||
# That is still true, and it is the price: a debug run of the app, and the
|
||||
# decode- and GPU-heavy tests, are slower than they were. What it buys is that
|
||||
# nothing has to be optimised before it can be compiled — which is the cost
|
||||
# paid on every edit, by every worktree, rather than only when something is
|
||||
# actually run.
|
||||
#
|
||||
# If a particular crate turns out to be the one that makes a test unbearable,
|
||||
# raise it alone rather than restoring the blanket rule:
|
||||
#
|
||||
# [profile.dev.package.zune-jpeg]
|
||||
# opt-level = 2
|
||||
opt-level = 0
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -2,17 +2,25 @@
|
||||
|
||||
A cross-platform, non-destructive RAW photo editor for Linux and Android.
|
||||
|
||||
**Status:** early. v0.1 is a remote library viewer — see
|
||||
[docs/milestone-v0.1.md](docs/milestone-v0.1.md).
|
||||
**Status:** 0.9.0, and no longer a spike. A library opens, culls, develops and
|
||||
exports on both platforms, across eight tagged releases. What is *not*
|
||||
built is written down rather than merely absent — see
|
||||
[docs/outstanding.md](docs/outstanding.md) for the requirements that have no
|
||||
implementation and why, and [docs/technical-debt.md](docs/technical-debt.md)
|
||||
for the compromises that were chosen.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Contents |
|
||||
|---|---|
|
||||
| [requirements.md](docs/requirements.md) | What the software must do — 122 numbered requirements |
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
|
||||
| [requirements.md](docs/requirements.md) | What the software must do — 179 numbered requirements |
|
||||
| [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync |
|
||||
| [milestone-v0.1.md](docs/milestone-v0.1.md) | The first buildable milestone |
|
||||
| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measures |
|
||||
| [technical-debt.md](docs/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
|
||||
| [outstanding.md](docs/outstanding.md) | What is not built, and whether that is a decision or a gap |
|
||||
| [code-health.md](docs/code-health.md) | What a contribution costs, per seam, measured |
|
||||
| [traceability.md](docs/traceability.md) | Generated: which requirement is claimed by which file |
|
||||
| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measured |
|
||||
|
||||
## Building
|
||||
|
||||
@@ -28,21 +36,48 @@ Android (containerised toolchain, see [docker/android](docker/android/README.md)
|
||||
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
|
||||
```
|
||||
|
||||
Git LFS is required for the model weights, and the toolchain pins itself.
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) has the details and the four commands CI
|
||||
will run against what you send.
|
||||
|
||||
## Current state
|
||||
|
||||
Working: workspace, GPU context and compute pass, adaptive Slint shell, Android
|
||||
cross-compilation of the core crates.
|
||||
**Working.** A catalog over a local folder, a Nextcloud account, or a folder a
|
||||
sync client keeps in virtual-files mode — where a placeholder is treated as the
|
||||
photograph rather than as a one-byte file. A virtualised library grid with a
|
||||
capture-time timeline, ratings, labels, keywords, collections and a trash that
|
||||
survives a crash mid-operation. Card ingest. Face detection and identity, with
|
||||
the index syncing between devices. A develop pipeline of fifteen declared
|
||||
operations fused into a single compute dispatch, plus the neighbourhood
|
||||
operations that cannot be — clarity, texture, capture sharpening, noise
|
||||
reduction, lens correction, spectral film simulation. Crop, straighten, spot
|
||||
removal, gradient and subject-segmentation masks, named presets, and a
|
||||
generated panel that no operation in `ui/` is allowed to name. Export to JPEG,
|
||||
PNG and 8- or 16-bit TIFF with resize and output sharpening.
|
||||
|
||||
**Not yet working:** the zero-copy display path. The build currently uploads
|
||||
frames through the CPU, which is exactly what
|
||||
[ARCH §6.1](docs/architecture.md) forbids — measured at 96% of frame time at
|
||||
4K. Replacing it is spike S1, the project's highest priority.
|
||||
**The zero-copy display path works on desktop.** The compute pass writes a
|
||||
texture that Slint composites directly, which is what
|
||||
[ARCH §6.1](docs/architecture.md) requires; the readback it forbids costs 96%
|
||||
of frame time at 4K, and
|
||||
|
||||
```
|
||||
```bash
|
||||
cargo run -p dr-gpu --example bench --features readback
|
||||
```
|
||||
|
||||
reproduces that measurement.
|
||||
still reproduces that measurement. **The one exception is the Android develop
|
||||
view**, which reads the frame back through the CPU because zero-copy there
|
||||
needs wgpu's Vulkan swapchain, and that tears a portrait window on a tablet
|
||||
whose panel is mounted landscape. It is debt, not a revision of the rule: the
|
||||
reasoning, the on-device measurements that forced it, and the three separate
|
||||
things any one of which would remove it are in
|
||||
[technical-debt.md TD-1](docs/technical-debt.md).
|
||||
|
||||
**Not built.** Plugins, compare and survey culling, focus peaking, burst
|
||||
grouping, AI denoise, tiled and progressive rendering, and most of the Android
|
||||
platform integration beyond running. The performance targets in §4.1 are
|
||||
unverified rather than unmet — the per-commit benchmark suite §8 requires does
|
||||
not exist, so nothing fails a build on a regression.
|
||||
[docs/outstanding.md](docs/outstanding.md) is the list, with the reasoning.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -16,9 +16,14 @@ crate-type = ["cdylib"]
|
||||
# No backend feature to select: dr-ui picks its Slint backend from the target,
|
||||
# so building for aarch64-linux-android gets android-activity automatically.
|
||||
dr-ui.workspace = true
|
||||
# For `session::set_data_dir`: only the platform entry point knows where Android
|
||||
# For `account::set_data_dir`: only the platform entry point knows where Android
|
||||
# lets this app keep files, and it must be set before any store is opened.
|
||||
dr-sync-nextcloud.workspace = true
|
||||
dr-sync.workspace = true
|
||||
# For the panic hook, for `state::set_state_dir` and for
|
||||
# `diagnostics::install`. Android has no XDG directories, so the entry point is
|
||||
# the only place that knows where a crash record or a log file may be written,
|
||||
# and both have to be in place before anything can fail.
|
||||
dr-plat.workspace = true
|
||||
# Directly, not just through dr-ui: `android_main` takes an `AndroidApp` and
|
||||
# calls `slint::android::init`, both of which come from this crate. The backend
|
||||
# feature comes from dr-ui's target-specific dependency.
|
||||
@@ -26,6 +31,21 @@ slint.workspace = true
|
||||
log.workspace = true
|
||||
android_logger = "0.15"
|
||||
|
||||
# The launch Intent and the share sheet are Java-only surfaces — see `intents`
|
||||
# — and JNI is the only way to reach them.
|
||||
#
|
||||
# Target-gated because the crate still has to compile on the host: it is a
|
||||
# workspace member, `cargo test --workspace` builds it, and the manifest tests
|
||||
# in `lib.rs` are the one part of it that runs there.
|
||||
#
|
||||
# 0.21 rather than the 0.22 that android-activity 0.6 uses. Both are already in
|
||||
# the lock — Slint's Android backend depends on two major versions of
|
||||
# android-activity and pulls both — so this adds nothing to the build either
|
||||
# way, and every object here comes from a raw pointer rather than from a type
|
||||
# android-activity handed over, so the two never have to agree.
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.21"
|
||||
|
||||
[features]
|
||||
# Mirrors darkroom-desktop: the CPU readback path is gone since S1 landed
|
||||
# zero-copy. It mattered more here than on desktop — the same wrong path with
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
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).
|
||||
|
||||
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
|
||||
declared: an intent filter is how the system learns this app is worth
|
||||
offering for a photograph, and a provider is how it learns the class exists
|
||||
at all. Neither can be moved into code (FR-PLAT-AND-6).
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="paris.tourolle.darkroom">
|
||||
@@ -51,10 +57,30 @@
|
||||
|
||||
<!-- 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. -->
|
||||
is how it learns which library to load, and must match [lib].name.
|
||||
|
||||
`singleTask` because a second instance of this activity is not
|
||||
survivable. The intent filters below mean another app can now
|
||||
launch it while it is already running, and under the default
|
||||
launch mode that starts a *second* NativeActivity — in the
|
||||
caller's task, in this same process, calling android_main again.
|
||||
Two Slint backends and two wgpu devices in one process is not a
|
||||
degraded experience, it is a failed second launch on top of a
|
||||
working first one.
|
||||
|
||||
What it costs, stated plainly: a share that arrives while DarkRoom
|
||||
is already running brings it forward without opening the image.
|
||||
The Intent goes to `onNewIntent`, and android-activity's event
|
||||
stream has no variant for it (MainEvent in 0.6 stops at Destroy),
|
||||
so nothing native ever sees it. Reading it would mean a Java
|
||||
Activity subclass forwarding it across JNI — the same shape of
|
||||
change FR-PLAT-AND-5 declined for onTrimMemory, and for the same
|
||||
reason. Launched from cold, which is the ordinary case for "open
|
||||
this photograph", the Intent is on getIntent() and is read. -->
|
||||
<activity
|
||||
android:name="android.app.NativeActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|density|uiMode"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
@@ -66,6 +92,80 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- FR-PLAT-AND-6, inbound. The traceability tool reads .rs,
|
||||
.slint, .wgsl and .yaml, so this is a reference and not a
|
||||
tag; the tag that counts is on the test in lib.rs that
|
||||
asserts these declarations are still here.
|
||||
|
||||
Opening a photograph from a gallery, a file manager or a
|
||||
download. `android_main` reads the launch Intent through
|
||||
`Intents.receive` and the named images become the browsing
|
||||
list, exactly as paths on the desktop command line do.
|
||||
|
||||
`image/*` and not a wider match, even though it misses raws:
|
||||
a provider that does not recognise `.CR3` reports it as
|
||||
`application/octet-stream`, and claiming that type would put
|
||||
DarkRoom in the chooser for every unidentified binary on the
|
||||
device — an APK, a database, a partial download. Being absent
|
||||
from one gallery's menu is a smaller failure than being
|
||||
present in all of them. DNG, which providers do know as
|
||||
`image/x-adobe-dng`, matches here already.
|
||||
|
||||
BROWSABLE is what lets a browser's finished download and a
|
||||
link hand the file over; without it those routes silently do
|
||||
not list the app. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- The share sheet, one photograph or a selection of them.
|
||||
SEND_MULTIPLE is declared because the sheet offers this app
|
||||
for a multi-selection only if it says it accepts one, and a
|
||||
culling tool that can be sent a single frame and not a burst
|
||||
is the wrong way round.
|
||||
|
||||
ACTION_EDIT is deliberately not here. It is a promise to write
|
||||
the result back to the URI it was handed, and nothing in this
|
||||
app does: an edit lands in a sidecar beside the original
|
||||
(FR-CAT-8). Registering for it would put DarkRoom in the "edit
|
||||
with" menu and lose the user's work every time. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
|
||||
between apps since API 24 — handing one out raises
|
||||
FileUriExposedException in *this* process — so an exported JPEG
|
||||
reaches the share sheet as a content:// URI or not at all.
|
||||
|
||||
Not AndroidX's FileProvider: that is a Maven artefact, and this
|
||||
build has no Gradle and no dependency resolver (docker/android/
|
||||
README.md). ExportProvider does the same hundred lines against one
|
||||
fixed root.
|
||||
|
||||
`exported="false"` with `grantUriPermissions="true"` is the whole
|
||||
security model, and the two halves are not redundant. Exported
|
||||
false means no app may address the provider on its own account;
|
||||
the grant flag means a URI this app puts in an Intent carries a
|
||||
read permission for that one file, for the lifetime of the
|
||||
receiving task. Without the grant flag the share sheet opens and
|
||||
every target fails with SecurityException; with `exported="true"`
|
||||
instead, every app on the device could read the app's private
|
||||
directory. The authority must equal ExportProvider.AUTHORITY — a
|
||||
mismatch is a SecurityException in somebody else's app, so a test
|
||||
in lib.rs compares the two strings. -->
|
||||
<provider
|
||||
android:name="paris.tourolle.darkroom.ExportProvider"
|
||||
android:authorities="paris.tourolle.darkroom.exports"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package paris.tourolle.darkroom;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Log;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Hands an exported file to another app, and hands out nothing else.
|
||||
*
|
||||
* <p>FR-PLAT-AND-6's outbound half. Android has refused {@code file://} URIs
|
||||
* between apps since API 24 — passing one raises {@code FileUriExposedException}
|
||||
* in the *sending* process — so the only way to give a photo to the share sheet
|
||||
* is a {@code content://} URI backed by a provider, plus a per-Intent read
|
||||
* grant that expires with the task that received it.
|
||||
*
|
||||
* <h2>Why this is not AndroidX's FileProvider</h2>
|
||||
*
|
||||
* <p>Because AndroidX is a Maven artefact and this build has no Gradle and no
|
||||
* dependency resolver (see docker/android/README.md). Pulling in the one class
|
||||
* would mean adopting the whole mechanism that fetches it. What
|
||||
* {@code FileProvider} does is a hundred lines — map a request path onto a
|
||||
* directory, refuse anything outside it, answer the two columns the share sheet
|
||||
* reads — and those lines are below. The configuration it takes as an XML
|
||||
* {@code <meta-data>} resource is a constant here instead, because there is
|
||||
* exactly one directory worth serving and a second place to state it is a
|
||||
* second place for it to be wrong.
|
||||
*
|
||||
* <h2>The one directory</h2>
|
||||
*
|
||||
* <p>{@code getFilesDir()}, which is the same directory the Rust side calls
|
||||
* {@code internal_data_path} and passes to {@code dr_sync::account::set_data_dir}
|
||||
* — {@code ANativeActivity.internalDataPath} and {@code Context.getFilesDir()}
|
||||
* are the same path. Everything the app writes for itself, the export outbox
|
||||
* included, is under it. Nothing else is reachable: a request is resolved
|
||||
* against the real filesystem with {@link File#getCanonicalFile()} and then
|
||||
* checked to be *inside* that root, so {@code ../} and a symlink planted in the
|
||||
* outbox are refused by the same test. Serving a path the caller composed,
|
||||
* unchecked, would turn a share button into a reader for every file this app
|
||||
* can see, which on Android includes credentials and the whole catalog.
|
||||
*
|
||||
* <p>{@code android:exported="false"} in the manifest is the outer half of the
|
||||
* same rule: no app can address this provider at all except through a URI this
|
||||
* app handed it with a read grant attached.
|
||||
*/
|
||||
public final class ExportProvider extends ContentProvider {
|
||||
private static final String TAG = "DarkRoom";
|
||||
|
||||
/**
|
||||
* Must equal {@code android:authorities} in AndroidManifest.xml.
|
||||
*
|
||||
* <p>A mismatch is not a build error and not a runtime error here: it is a
|
||||
* {@code SecurityException} in whichever app opened the share sheet, naming
|
||||
* an authority that does not exist. A test in {@code lib.rs} asserts the
|
||||
* two strings are the same for that reason.
|
||||
*/
|
||||
public static final String AUTHORITY = "paris.tourolle.darkroom.exports";
|
||||
|
||||
/** Nothing to set up; the root is resolved per request against the context. */
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@code content://} URI for a file, or null if it is not one this
|
||||
* provider may serve.
|
||||
*
|
||||
* <p>Returning null rather than an unusable URI keeps the refusal at the
|
||||
* point where the path is known. A URI for a file outside the root would be
|
||||
* rejected later by {@link #openFile}, in the *receiving* app's stack trace,
|
||||
* where nothing says which of our files was asked for.
|
||||
*/
|
||||
public static Uri uriFor(Context context, File file) {
|
||||
try {
|
||||
File root = root(context);
|
||||
File target = file.getCanonicalFile();
|
||||
String relative = within(root, target);
|
||||
if (relative == null) {
|
||||
Log.w(TAG, "not shareable, outside " + root + ": " + target);
|
||||
return null;
|
||||
}
|
||||
// Built segment by segment rather than with a composed path
|
||||
// string: appendPath percent-encodes, and getPathSegments below
|
||||
// decodes symmetrically. A file called "Rue d'Alésia.jpg" survives
|
||||
// the round trip only because both halves agree.
|
||||
Uri.Builder builder = new Uri.Builder().scheme("content").authority(AUTHORITY);
|
||||
for (String segment : relative.split("/")) {
|
||||
if (!segment.isEmpty()) {
|
||||
builder.appendPath(segment);
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "cannot resolve " + file + " for sharing: " + e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two columns a share target actually reads.
|
||||
*
|
||||
* <p>Without {@code _display_name} the receiving app shows the URI's last
|
||||
* segment, and without {@code _size} a mail client cannot tell whether the
|
||||
* attachment fits before it starts reading. Both are optional in the sense
|
||||
* that the transfer still works; both are the difference between "DSC_4471
|
||||
* final.jpg, 8.2 MB" and an unnamed blob.
|
||||
*/
|
||||
@Override
|
||||
public Cursor query(Uri uri, String[] projection, String selection,
|
||||
String[] selectionArgs, String sortOrder) {
|
||||
File file = resolve(uri);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
String[] columns = projection != null
|
||||
? projection
|
||||
: new String[] {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
|
||||
MatrixCursor cursor = new MatrixCursor(columns, 1);
|
||||
MatrixCursor.RowBuilder row = cursor.newRow();
|
||||
for (String column : columns) {
|
||||
if (OpenableColumns.DISPLAY_NAME.equals(column)) {
|
||||
row.add(file.getName());
|
||||
} else if (OpenableColumns.SIZE.equals(column)) {
|
||||
row.add(file.length());
|
||||
} else {
|
||||
// A column we do not have. Null rather than omitted: a cursor
|
||||
// whose row is shorter than its projection throws in the
|
||||
// caller, which is a crash in someone else's app.
|
||||
row.add(null);
|
||||
}
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* From the extension, because that is all there is.
|
||||
*
|
||||
* <p>The type decides which apps the chooser offers, so guessing wrong
|
||||
* narrows the sheet rather than breaking the transfer. Exports are JPEG,
|
||||
* PNG or TIFF and {@code MimeTypeMap} knows all three.
|
||||
*/
|
||||
@Override
|
||||
public String getType(Uri uri) {
|
||||
File file = resolve(uri);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
String name = file.getName();
|
||||
int dot = name.lastIndexOf('.');
|
||||
if (dot >= 0 && dot < name.length() - 1) {
|
||||
String extension = name.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||
String type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
|
||||
if (type != null) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only, always.
|
||||
*
|
||||
* <p>A write mode is refused rather than quietly downgraded: a caller that
|
||||
* asked for "rw" intends to save something back, and letting it open the
|
||||
* file read-only would fail at its first write with an error about a
|
||||
* descriptor rather than about permission. Nothing this app shares is meant
|
||||
* to be edited in place by the app it was shared with.
|
||||
*/
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
|
||||
if (!"r".equals(mode)) {
|
||||
throw new SecurityException("this provider is read-only, asked for '" + mode + "'");
|
||||
}
|
||||
File file = resolve(uri);
|
||||
if (file == null) {
|
||||
throw new FileNotFoundException("no such export: " + uri);
|
||||
}
|
||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values) {
|
||||
throw new UnsupportedOperationException("exports are written by the app, not through it");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("exports are written by the app, not through it");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("exports are deleted by the app, not through it");
|
||||
}
|
||||
|
||||
/** The served root, resolved through the filesystem so the check below is real. */
|
||||
private static File root(Context context) throws IOException {
|
||||
return context.getFilesDir().getCanonicalFile();
|
||||
}
|
||||
|
||||
/** The file a request names, or null if it names anything else. */
|
||||
private File resolve(Uri uri) {
|
||||
Context context = getContext();
|
||||
if (context == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> segments = uri.getPathSegments();
|
||||
if (segments.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
File root = root(context);
|
||||
File candidate = root;
|
||||
for (String segment : segments) {
|
||||
candidate = new File(candidate, segment);
|
||||
}
|
||||
candidate = candidate.getCanonicalFile();
|
||||
if (within(root, candidate) == null || !candidate.isFile()) {
|
||||
Log.w(TAG, "refused " + uri);
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "refused " + uri + ": " + e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code target}'s path relative to {@code root}, or null if it is not
|
||||
* under it.
|
||||
*
|
||||
* <p>Both sides are canonical by the time they get here, which is what
|
||||
* makes one string comparison enough for {@code ../} and for a symlink
|
||||
* alike. The trailing separator matters: without it a sibling directory
|
||||
* whose name merely starts with the root's — {@code /data/.../files.old} —
|
||||
* passes.
|
||||
*/
|
||||
private static String within(File root, File target) {
|
||||
String rootPath = root.getPath() + File.separator;
|
||||
String targetPath = target.getPath();
|
||||
if (!targetPath.startsWith(rootPath)) {
|
||||
return null;
|
||||
}
|
||||
return targetPath.substring(rootPath.length());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package paris.tourolle.darkroom;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The two directions of FR-PLAT-AND-6: what the app was opened *with*, and
|
||||
* handing a finished export to somebody else.
|
||||
*
|
||||
* <h2>Why this is Java and not JNI in lib.rs</h2>
|
||||
*
|
||||
* <p>Every call below is reachable over JNI, and doing it that way would be
|
||||
* roughly forty {@code call_method} invocations with their signatures written
|
||||
* out as strings — each one a name Java checks at run time and nothing checks
|
||||
* at build time. The Rust side would then hold the exact logic that is here,
|
||||
* expressed less clearly, and a typo in {@code "()Landroid/content/Intent;"}
|
||||
* would surface on a device as a {@code NoSuchMethodError} rather than at the
|
||||
* compiler. So the platform work stays on the platform's side and the JNI
|
||||
* surface is two calls, both taking and returning strings.
|
||||
*
|
||||
* <p>The class is only reachable because the APK now compiles Java at all; see
|
||||
* docker/android/assemble-apk.sh.
|
||||
*/
|
||||
public final class Intents {
|
||||
private static final String TAG = "DarkRoom";
|
||||
|
||||
/**
|
||||
* Where incoming images are copied, under {@code getCacheDir()}.
|
||||
*
|
||||
* <p>The cache and not the data directory, deliberately: these are copies
|
||||
* of somebody else's file, the app has no claim on them once the session
|
||||
* ends, and the cache is the one place Android may reclaim under storage
|
||||
* pressure without the user being asked. Putting them in the data
|
||||
* directory would grow the app's footprint by a RAW file per share, for
|
||||
* ever, with nothing that ever deletes them.
|
||||
*/
|
||||
private static final String INBOX = "incoming";
|
||||
|
||||
private Intents() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The images this launch was asked to open, as paths the decoder can read.
|
||||
*
|
||||
* <p>Empty for an ordinary launch from the launcher, which is the common
|
||||
* case and not a failure.
|
||||
*
|
||||
* <h3>Why the bytes are copied</h3>
|
||||
*
|
||||
* <p>A share arrives as a {@code content://} URI, which is a handle into
|
||||
* another app's provider and not a path — there is no filename behind it to
|
||||
* open, and the grant that makes it readable belongs to this task and dies
|
||||
* with it. DarkRoom's decoders take paths (ARCH §6.9 is the note that
|
||||
* Android has no paths to give), so the choice is to copy or to teach the
|
||||
* whole read path about URIs, and the second is FR-PLAT-AND-1's SAF
|
||||
* connector, which is not built.
|
||||
*
|
||||
* <p>So it is a copy, and the cost is honest: a 60 MB raw file is written
|
||||
* once, to the cache, before the viewer opens. It is bounded by the share
|
||||
* being a deliberate act — a person picked these files — rather than by
|
||||
* anything this code does.
|
||||
*
|
||||
* <p>The inbox is emptied first. Without that, every share ever received
|
||||
* accumulates until the platform decides the cache is too large, and the
|
||||
* files are indistinguishable from each other by then.
|
||||
*/
|
||||
public static String[] receive(Activity activity) {
|
||||
List<Uri> uris = incoming(activity.getIntent());
|
||||
if (uris.isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
File inbox = new File(activity.getCacheDir(), INBOX);
|
||||
empty(inbox);
|
||||
if (!inbox.mkdirs() && !inbox.isDirectory()) {
|
||||
Log.e(TAG, "cannot create " + inbox + "; the launch intent is dropped");
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
List<String> paths = new ArrayList<String>();
|
||||
for (Uri uri : uris) {
|
||||
String path = localise(activity, uri, inbox, paths.size());
|
||||
if (path != null) {
|
||||
paths.add(path);
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "launch intent carried " + paths.size() + " of " + uris.size() + " image(s)");
|
||||
return paths.toArray(new String[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer a file this app produced to whatever else is installed.
|
||||
*
|
||||
* <p>Returns false when there is nothing to offer it to, or when the file
|
||||
* is not one {@link ExportProvider} may serve — both of which the caller
|
||||
* has to be able to say out loud, because from the user's side a share
|
||||
* button that does nothing is indistinguishable from one that failed.
|
||||
*
|
||||
* <p>{@code FLAG_GRANT_READ_URI_PERMISSION} is the whole security model:
|
||||
* the provider is not exported, so the receiving app can reach this one
|
||||
* file, for as long as its task lives, and nothing else ever.
|
||||
*/
|
||||
public static boolean share(Activity activity, String path, String mimeType) {
|
||||
Uri uri = ExportProvider.uriFor(activity, new File(path));
|
||||
if (uri == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Intent send = new Intent(Intent.ACTION_SEND);
|
||||
send.setType(mimeType != null && !mimeType.isEmpty() ? mimeType : "image/*");
|
||||
send.putExtra(Intent.EXTRA_STREAM, uri);
|
||||
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
// Always a chooser, never a direct start. Android's "remembered
|
||||
// default" for ACTION_SEND is a per-user setting this app has no
|
||||
// business consuming: the app a photograph should go to differs every
|
||||
// time, and the one time it does not, the sheet is one extra tap.
|
||||
Intent chooser = Intent.createChooser(send, null);
|
||||
try {
|
||||
activity.startActivity(chooser);
|
||||
return true;
|
||||
} catch (ActivityNotFoundException e) {
|
||||
Log.w(TAG, "nothing installed accepts " + mimeType + ": " + e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The URIs an Intent carries, by the action that carried them.
|
||||
*
|
||||
* <p>Only the actions the manifest registers for. An action we did not
|
||||
* declare cannot arrive, so handling one here would be code that reads as
|
||||
* support for something the launcher will never offer.
|
||||
*/
|
||||
@SuppressWarnings("deprecation")
|
||||
private static List<Uri> incoming(Intent intent) {
|
||||
List<Uri> uris = new ArrayList<Uri>();
|
||||
if (intent == null) {
|
||||
return uris;
|
||||
}
|
||||
String action = intent.getAction();
|
||||
if (Intent.ACTION_VIEW.equals(action)) {
|
||||
add(uris, intent.getData());
|
||||
} else if (Intent.ACTION_SEND.equals(action)) {
|
||||
// The typed getParcelableExtra(String, Class) overload is API 33,
|
||||
// and minSdk is 28. The deprecated form is the only one that exists
|
||||
// on every device this APK installs on.
|
||||
add(uris, (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM));
|
||||
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
|
||||
ArrayList<Uri> many = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
|
||||
if (many != null) {
|
||||
for (Uri uri : many) {
|
||||
add(uris, uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
return uris;
|
||||
}
|
||||
|
||||
private static void add(List<Uri> uris, Uri uri) {
|
||||
if (uri != null) {
|
||||
uris.add(uri);
|
||||
}
|
||||
}
|
||||
|
||||
/** A URI as a readable path, copying it into the inbox if it is not one already. */
|
||||
private static String localise(Context context, Uri uri, File inbox, int index) {
|
||||
// A file:// URI is already a path, and copying it would double a raw
|
||||
// file on disk to no end. Rare — the platform has refused file:// URIs
|
||||
// between apps since API 24 — but it is what a shell `am start -d
|
||||
// file:///sdcard/…` produces, which is how this path gets tested
|
||||
// without a second app installed.
|
||||
if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
|
||||
String path = uri.getPath();
|
||||
if (path != null && new File(path).canRead()) {
|
||||
return path;
|
||||
}
|
||||
Log.w(TAG, "cannot read " + uri);
|
||||
return null;
|
||||
}
|
||||
|
||||
File dest = new File(inbox, unique(inbox, displayName(context, uri), index));
|
||||
InputStream in = null;
|
||||
OutputStream out = null;
|
||||
try {
|
||||
in = context.getContentResolver().openInputStream(uri);
|
||||
if (in == null) {
|
||||
Log.w(TAG, "no stream behind " + uri);
|
||||
return null;
|
||||
}
|
||||
out = new FileOutputStream(dest);
|
||||
byte[] buffer = new byte[64 * 1024];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) > 0) {
|
||||
out.write(buffer, 0, read);
|
||||
}
|
||||
out.flush();
|
||||
return dest.getAbsolutePath();
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "cannot copy " + uri + ": " + e);
|
||||
// The partial copy is removed rather than left: it has the name and
|
||||
// the extension of a photograph and none of the bytes, and the
|
||||
// decoder would report it as a corrupt file rather than a failed
|
||||
// transfer.
|
||||
dest.delete();
|
||||
return null;
|
||||
} catch (SecurityException e) {
|
||||
// The grant on a shared URI dies with the task that received it.
|
||||
// A process resumed from a saved state can find itself holding a
|
||||
// URI it may no longer read (FR-PLAT-AND-3), and that is a lost
|
||||
// permission rather than a broken file.
|
||||
Log.w(TAG, "no longer permitted to read " + uri + ": " + e);
|
||||
dest.delete();
|
||||
return null;
|
||||
} finally {
|
||||
close(in);
|
||||
close(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sending app calls the file, reduced to something safe to write.
|
||||
*
|
||||
* <p>The name is chosen by another application and lands in a path this one
|
||||
* composes, so it is filtered rather than trusted: a name containing a
|
||||
* separator would place the copy outside the inbox, and one beginning with
|
||||
* a dot would hide it from everything that lists the directory. What
|
||||
* survives is the part a photographer recognises — {@code DSC_4471.NEF} —
|
||||
* which is the only reason to use the sender's name at all.
|
||||
*/
|
||||
private static String displayName(Context context, Uri uri) {
|
||||
String name = null;
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = context.getContentResolver().query(
|
||||
uri, new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null);
|
||||
if (cursor != null && cursor.moveToFirst() && !cursor.isNull(0)) {
|
||||
name = cursor.getString(0);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Providers are other people's code and any of them may throw.
|
||||
// A name is a convenience; failing the whole open over it is not.
|
||||
Log.d(TAG, "no display name for " + uri + ": " + e);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
if (name == null) {
|
||||
name = uri.getLastPathSegment();
|
||||
}
|
||||
if (name == null) {
|
||||
return "shared";
|
||||
}
|
||||
StringBuilder safe = new StringBuilder(name.length());
|
||||
for (int i = 0; i < name.length(); i++) {
|
||||
char c = name.charAt(i);
|
||||
boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
|| (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_';
|
||||
safe.append(ok ? c : '_');
|
||||
}
|
||||
while (safe.length() > 0 && safe.charAt(0) == '.') {
|
||||
safe.deleteCharAt(0);
|
||||
}
|
||||
return safe.length() > 0 ? safe.toString() : "shared";
|
||||
}
|
||||
|
||||
/**
|
||||
* A name nothing in the inbox has yet.
|
||||
*
|
||||
* <p>A multi-image share of a burst arrives as several files a camera named
|
||||
* the same thing in different folders, and the second one silently
|
||||
* overwriting the first would show the user one photograph where they
|
||||
* picked four.
|
||||
*/
|
||||
private static String unique(File inbox, String name, int index) {
|
||||
if (!new File(inbox, name).exists()) {
|
||||
return name;
|
||||
}
|
||||
return index + "-" + name;
|
||||
}
|
||||
|
||||
private static void close(java.io.Closeable stream) {
|
||||
if (stream != null) {
|
||||
try {
|
||||
stream.close();
|
||||
} catch (IOException e) {
|
||||
Log.d(TAG, "close failed: " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete the inbox's contents, one level deep, which is all it ever has. */
|
||||
private static void empty(File inbox) {
|
||||
File[] stale = inbox.listFiles();
|
||||
if (stale == null) {
|
||||
return;
|
||||
}
|
||||
for (File file : stale) {
|
||||
if (!file.delete()) {
|
||||
Log.d(TAG, "could not remove stale " + file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! What the app was launched with, and handing a finished export back out.
|
||||
//!
|
||||
//! FR-PLAT-AND-6's Rust side, which is deliberately the thin side. Both
|
||||
//! directions are implemented in `android/java/paris/tourolle/darkroom/` and
|
||||
//! everything here is the two calls that reach them; `Intents.java` carries the
|
||||
//! reasoning for the split. The short version is that a JNI method signature is
|
||||
//! a string Java resolves at run time and nothing checks at build time, so
|
||||
//! forty of them is forty ways for a rename to become a `NoSuchMethodError` on
|
||||
//! somebody's tablet. Two is two.
|
||||
//!
|
||||
//! # Nothing here fails loudly
|
||||
//!
|
||||
//! A class the loader cannot see, a pending Java exception, a shared URI whose
|
||||
//! grant died with the task that received it: each ends as a log line and an
|
||||
//! empty result. This runs on the way to [`dr_ui::run`], before a window
|
||||
//! exists, and the alternative to opening with an empty browsing list is not
|
||||
//! opening at all.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use jni::errors::Result as JniResult;
|
||||
use jni::objects::{JClass, JObject, JObjectArray, JString, JValue};
|
||||
use jni::{JNIEnv, JavaVM};
|
||||
|
||||
/// The class both directions live in, named the way `loadClass` wants it —
|
||||
/// dots, not slashes. `find_class` takes the other form, and this code calls
|
||||
/// neither by accident; see [`load_class`].
|
||||
const INTENTS: &str = "paris.tourolle.darkroom.Intents";
|
||||
|
||||
/// The images this launch was asked to open, already local and readable.
|
||||
///
|
||||
/// Empty for an ordinary launch from the launcher, which is the common case
|
||||
/// and not a failure. What comes back is passed to `dr_ui::run` exactly as
|
||||
/// command-line paths are on the desktop, so a shared photograph becomes the
|
||||
/// browsing list and `startup_action` shows it rather than the launch screen.
|
||||
pub fn launch_images(app: &slint::android::AndroidApp) -> Vec<PathBuf> {
|
||||
with_activity(app, "reading the launch intent", |env, activity| {
|
||||
let class = load_class(env, activity, INTENTS)?;
|
||||
let returned = env
|
||||
.call_static_method(
|
||||
&class,
|
||||
"receive",
|
||||
"(Landroid/app/Activity;)[Ljava/lang/String;",
|
||||
&[JValue::Object(activity)],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
let array = JObjectArray::from(returned);
|
||||
let count = env.get_array_length(&array)?;
|
||||
let mut paths = Vec::with_capacity(count as usize);
|
||||
for i in 0..count {
|
||||
let element = env.get_object_array_element(&array, i)?;
|
||||
let text: String = env.get_string(&JString::from(element))?.into();
|
||||
paths.push(PathBuf::from(text));
|
||||
}
|
||||
Ok(paths)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Offer a file this app produced to whatever else is installed.
|
||||
///
|
||||
/// `false` means the sheet did not open — the file is not under the directory
|
||||
/// [`ExportProvider`] serves, or nothing installed accepts the type. Both are
|
||||
/// answers a caller has to be able to give the user, because a share control
|
||||
/// that silently does nothing is indistinguishable from one that failed.
|
||||
///
|
||||
/// **This half has no caller yet, and that is the honest state of it.** The
|
||||
/// provider, the URI grant and the chooser are all here and are what
|
||||
/// FR-PLAT-AND-6 asks for; what is missing is a share control in the interface,
|
||||
/// which lives in `ui/dr-ui` and needs one thing this signature shows: an
|
||||
/// `AndroidApp` to call through. Wiring it means keeping a clone of the app —
|
||||
/// it is `Clone` and cheap — somewhere `ui/` can reach, which is a change to
|
||||
/// how the platform entry point talks to the interface rather than a change
|
||||
/// here. Until that exists this function is reachable and untested, and it is
|
||||
/// deliberately not tagged as covering the requirement.
|
||||
///
|
||||
/// `mime` decides which applications the chooser offers; the empty string
|
||||
/// falls back to `image/*` on the Java side.
|
||||
pub fn share(app: &slint::android::AndroidApp, file: &Path, mime: &str) -> bool {
|
||||
with_activity(app, "opening the share sheet", |env, activity| {
|
||||
let class = load_class(env, activity, INTENTS)?;
|
||||
let path = env.new_string(file.to_string_lossy().as_ref())?;
|
||||
let mime = env.new_string(mime)?;
|
||||
env.call_static_method(
|
||||
&class,
|
||||
"share",
|
||||
"(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)Z",
|
||||
&[
|
||||
JValue::Object(activity),
|
||||
JValue::Object(&path),
|
||||
JValue::Object(&mime),
|
||||
],
|
||||
)?
|
||||
.z()
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Attach to the JVM, borrow the activity, and run `body` against both.
|
||||
///
|
||||
/// Shared by the two entry points because the three steps before the
|
||||
/// interesting one are identical and each has its own way of failing. `body`
|
||||
/// returning `Err` is reported here, once, in the one place that can also clear
|
||||
/// a pending Java exception — see [`report`].
|
||||
fn with_activity<T>(
|
||||
app: &slint::android::AndroidApp,
|
||||
doing: &str,
|
||||
body: impl FnOnce(&mut JNIEnv, &JObject) -> JniResult<T>,
|
||||
) -> Option<T> {
|
||||
let vm = match unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) } {
|
||||
Ok(vm) => vm,
|
||||
Err(e) => {
|
||||
log::error!("no JVM handle, so {doing} is skipped: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// Cheap when the thread is already attached, which it is: the glue
|
||||
// attached it before it called `android_main`. The guard exists for the
|
||||
// case where it is not, and costs a lookup where it is.
|
||||
let mut env = match vm.attach_current_thread() {
|
||||
Ok(env) => env,
|
||||
Err(e) => {
|
||||
log::error!("cannot attach to the JVM, so {doing} is skipped: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// SAFETY: `activity_as_ptr` documents this as an unowned JNI *global*
|
||||
// reference to the Activity, valid for as long as the `AndroidApp` it came
|
||||
// from. `JObject` in jni 0.21 is a plain wrapper with no `Drop`, so
|
||||
// borrowing it here cannot delete a reference this code does not own — the
|
||||
// one way to get this wrong is `AutoLocal` or a `GlobalRef`, both of which
|
||||
// would free it out from under android-activity.
|
||||
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
|
||||
|
||||
match body(&mut env, &activity) {
|
||||
Ok(value) => Some(value),
|
||||
Err(e) => {
|
||||
report(&mut env, doing, &e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Look an app class up through the *activity's* class loader.
|
||||
///
|
||||
/// `find_class` is the obvious call and the wrong one. JNI resolves a class
|
||||
/// against the loader belonging to the Java frame beneath the call, and on this
|
||||
/// thread there is no such frame: `android_main` runs on a thread the native
|
||||
/// glue created and attached itself, so the loader in scope is the system one.
|
||||
/// It knows every class in the platform and nothing at all from this APK, and
|
||||
/// says so as a `ClassNotFoundException` naming a class that is plainly in the
|
||||
/// dex — which reads as a broken build rather than as the wrong loader.
|
||||
///
|
||||
/// The activity is a Java object, so its loader is the app's.
|
||||
fn load_class<'local>(
|
||||
env: &mut JNIEnv<'local>,
|
||||
activity: &JObject,
|
||||
name: &str,
|
||||
) -> JniResult<JClass<'local>> {
|
||||
let loader = env
|
||||
.call_method(activity, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])?
|
||||
.l()?;
|
||||
let name = env.new_string(name)?;
|
||||
let class = env
|
||||
.call_method(
|
||||
&loader,
|
||||
"loadClass",
|
||||
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||
&[JValue::Object(&name)],
|
||||
)?
|
||||
.l()?;
|
||||
Ok(JClass::from(class))
|
||||
}
|
||||
|
||||
/// Log a JNI failure, and clear the exception behind it if there is one.
|
||||
///
|
||||
/// The clearing is not tidiness. A Java exception raised through JNI stays
|
||||
/// *pending* on the thread, and the next JNI call made while one is pending
|
||||
/// aborts the process — so a swallowed exception here would come back as a
|
||||
/// crash somewhere unrelated, most likely inside Slint. `exception_describe`
|
||||
/// first, because the trace it prints to logcat is the only place the Java
|
||||
/// class and line survive; `jni::errors::Error::JavaException` on its own says
|
||||
/// neither.
|
||||
fn report(env: &mut JNIEnv, doing: &str, e: &jni::errors::Error) {
|
||||
log::error!("{doing} failed: {e}");
|
||||
if let Ok(true) = env.exception_check() {
|
||||
let _ = env.exception_describe();
|
||||
let _ = env.exception_clear();
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,21 @@
|
||||
//! * There are no command-line paths. Android's SAF hands out document URIs,
|
||||
//! not filesystem paths (ARCH §6.9), so the viewer opens with an empty
|
||||
//! browsing list and the library grid is the only way in.
|
||||
//! * Logging goes to logcat. `env_logger` writes to stderr, which Android
|
||||
//! discards.
|
||||
//! * Logging goes to logcat *and* to a file. `env_logger` writes to stderr,
|
||||
//! which Android discards; logcat replaces it, and a rotating file beside it
|
||||
//! replaces the thing logcat cannot be — a record that outlives the session
|
||||
//! and can be sent to somebody (NFR-OPS-1, [`dr_plat::diagnostics`]).
|
||||
//!
|
||||
//! The first of those has one exception, and it is the launch `Intent`: a
|
||||
//! gallery, a file manager or the share sheet can name images to open, and
|
||||
//! those arrive as URIs on an `Intent` rather than as words on a command line.
|
||||
//! [`intents`] turns them into paths, and from there they are the same list
|
||||
//! the desktop builds from `argv` (FR-PLAT-AND-6).
|
||||
|
||||
// The whole module is JNI against classes that exist only in the APK, so it
|
||||
// is gated with everything else that cannot compile off-device.
|
||||
#[cfg(target_os = "android")]
|
||||
mod intents;
|
||||
|
||||
// `slint::android` exists only when compiling for Android, so the whole entry
|
||||
// point is gated on the target rather than on a feature. Without this the
|
||||
@@ -18,22 +31,109 @@
|
||||
/// Android application entry point, called by android-activity's glue.
|
||||
#[no_mangle]
|
||||
fn android_main(app: slint::android::AndroidApp) {
|
||||
android_logger::init_once(
|
||||
// **The first statement in the process, and it has to be.** Everything
|
||||
// between here and `install` returning runs with no logger installed at
|
||||
// all: asking the activity for its external directory, `create_dir_all`
|
||||
// and an `open` on a FUSE-backed volume the system may still be mounting.
|
||||
// A failure or a stall in any of it is invisible on every surface there
|
||||
// is — no file yet, and nothing in logcat either — which is precisely the
|
||||
// kind of launch logcat exists to debug.
|
||||
//
|
||||
// `AndroidLogger` rather than `init_once`, so logcat can be *teed* rather
|
||||
// than replaced: `init_once` installs itself as the global logger and
|
||||
// there is only one of those. Everything that reached logcat before the
|
||||
// file existed still reaches it, at the same level and under the same tag;
|
||||
// the file is strictly additional.
|
||||
let console = android_logger::AndroidLogger::new(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Info)
|
||||
.with_tag("DarkRoom"),
|
||||
);
|
||||
|
||||
// Handed to the logger directly, and **not** written as `log::info!`,
|
||||
// which here would compile and emit nothing: the facade's maximum level is
|
||||
// `Off` until `diagnostics::install` sets it, and the macro tests that
|
||||
// before it reaches any logger at all. This call skips the facade and
|
||||
// reaches `__android_log_write` with nothing in between.
|
||||
//
|
||||
// That independence is the second reason for it. When the log is silent,
|
||||
// this line is what says which half is at fault: present here and absent
|
||||
// below means the `log` wiring, absent in both means liblog is not
|
||||
// delivering this process's records — a question about the device, which
|
||||
// no amount of reading this file can answer.
|
||||
log::Log::log(
|
||||
&console,
|
||||
&log::Record::builder()
|
||||
.level(log::Level::Info)
|
||||
.target(module_path!())
|
||||
.module_path(Some(module_path!()))
|
||||
.args(format_args!(
|
||||
"DarkRoom v{} starting; logcat only until the log file opens",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.build(),
|
||||
);
|
||||
|
||||
// Before the file logger, because it needs somewhere to write.
|
||||
//
|
||||
// **The external directory, not the internal one, and the difference is
|
||||
// the entire point of the file.** Both are app-private and both survive
|
||||
// backgrounding — the volatile one is the *cache* directory, which is not
|
||||
// in play here. What separates them is retrieval:
|
||||
// `/data/data/<pkg>/files` needs `run-as` against a debuggable build or
|
||||
// root to read, and `/sdcard/Android/data/<pkg>/files` is a plain
|
||||
// `adb pull` from any build, needing no permission since API 19. A log
|
||||
// nobody can get off the device does not do the job NFR-OPS-1 describes.
|
||||
//
|
||||
// The consequence is that anyone holding the tablet can read it, which is
|
||||
// why `dr_plat::diagnostics` redacts at the sink and why configuration —
|
||||
// the account list, and the credential reference beside it — stays on
|
||||
// `internal_data_path` below rather than moving here (NFR-SEC-2).
|
||||
let external = app.external_data_path();
|
||||
if let Some(dir) = external.clone().or_else(|| app.internal_data_path()) {
|
||||
dr_plat::set_state_dir(dir);
|
||||
}
|
||||
|
||||
let logging = dr_plat::diagnostics::install(Box::new(console), log::LevelFilter::Info);
|
||||
|
||||
// Panics go to stderr, and Android discards stderr. Without this hook a
|
||||
// worker thread that panics is invisible: the process survives, the
|
||||
// channel it was writing to closes, and the UI reports only that
|
||||
// something "failed unexpectedly" with no way to find out what.
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
log::error!("panic: {info}");
|
||||
}));
|
||||
//
|
||||
// This used to be one `log::error!` of the raw panic, which had two
|
||||
// problems: logcat is a ring buffer that is gone by the time a user
|
||||
// reports anything, and the raw message can carry a document URI naming
|
||||
// their library or a credential a library interpolated into an error
|
||||
// (NFR-SEC-2). `dr_plat::crash` writes a redacted record to disk and logs
|
||||
// the redacted form. Nothing uploads it.
|
||||
//
|
||||
// Before `set_state_dir` on purpose: the hook resolves the directory when
|
||||
// it fires, so installing it first covers the startup below rather than
|
||||
// leaving it uncovered.
|
||||
dr_plat::crash::install(env!("CARGO_PKG_VERSION"));
|
||||
|
||||
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Said in logcat as well as in the file, because the first thing anybody
|
||||
// asked for a log needs is where it is — and on a device that is a path
|
||||
// nobody can guess and a command nobody remembers.
|
||||
match &logging {
|
||||
dr_plat::Installed::ToFile(path) => {
|
||||
log::info!("logging to {}", path.display());
|
||||
if external.is_none() {
|
||||
log::warn!(
|
||||
"no external storage; the log is app-private and needs \
|
||||
`adb shell run-as paris.tourolle.darkroom cat files/darkroom.log` \
|
||||
on a debuggable build"
|
||||
);
|
||||
}
|
||||
}
|
||||
dr_plat::Installed::ConsoleOnly(why) => {
|
||||
log::warn!("no log file this session, only logcat: {why}");
|
||||
}
|
||||
}
|
||||
|
||||
// Before anything opens a store: Android has no $HOME and no XDG
|
||||
// directories, so the default guess resolves to a path the app cannot
|
||||
// write. Nothing failed loudly — the session list went to a doomed path, so
|
||||
@@ -43,75 +143,217 @@ fn android_main(app: slint::android::AndroidApp) {
|
||||
match app.internal_data_path() {
|
||||
Some(dir) => {
|
||||
log::info!("data dir: {}", dir.display());
|
||||
dr_sync_nextcloud::session::set_data_dir(dir);
|
||||
// Crash records go beside the account data rather than under it:
|
||||
// both are app-private and neither is a cache, which is the whole
|
||||
// distinction that matters here (see `dr_plat::crash::state_dir`).
|
||||
dr_plat::crash::set_state_dir(dir.join("state"));
|
||||
dr_sync::account::set_data_dir(dir);
|
||||
}
|
||||
None => log::error!("no internal data path; settings will not persist"),
|
||||
}
|
||||
|
||||
// After the data dir and before anything asks whether a model is present.
|
||||
install_bundled_face_models(&app);
|
||||
// After the data dir, because it writes beside the catalog. **Not** before
|
||||
// the first frame any more — it starts a worker and returns; see the
|
||||
// function for what it used to cost the launch.
|
||||
install_bundled_models(app.clone());
|
||||
|
||||
if let Err(e) = slint::android::init(app) {
|
||||
// Before `init_with_event_listener`, which takes `app` by value and is the
|
||||
// last moment anything can ask the activity a question. Not an ordering
|
||||
// preference — after that line there is no `app` left to read the Intent
|
||||
// through.
|
||||
let opened_with = intents::launch_images(&app);
|
||||
|
||||
// TRACES: FR-PLAT-AND-5
|
||||
// The listener is the whole reason this is not the one-line
|
||||
// `slint::android::init(app)`. Slint owns the event loop on Android, so
|
||||
// the platform's lifecycle and memory events reach the application only if
|
||||
// it asks for them here — and it must ask *before* the loop starts, which
|
||||
// is why this sits between the data directory and `dr_ui::run`.
|
||||
//
|
||||
// The listener runs inside `poll_events`, on the same thread the event
|
||||
// loop and every interface cache live on, which is what lets
|
||||
// `dr_ui::memory` be a thread-local registry of plain `Fn()` rather than a
|
||||
// cross-thread channel (see its module documentation).
|
||||
//
|
||||
// # Why two events and not eight
|
||||
//
|
||||
// FR-PLAT-AND-5 names `onTrimMemory`, whose `TRIM_MEMORY_*` levels grade
|
||||
// how badly the system wants the memory back. Those levels do not exist
|
||||
// here: `ComponentCallbacks2` is a Java interface implemented by an
|
||||
// `Activity` or `Application`, and this app has neither — it is a bare
|
||||
// `NativeActivity`, whose native callback table offers only the ungraded
|
||||
// `onLowMemory`. android-activity surfaces exactly that as `LowMemory`.
|
||||
// Reading the grades would mean shipping a Java subclass to forward them,
|
||||
// which is a distribution-manifest change and not this one.
|
||||
//
|
||||
// `Stop` recovers the one grade that matters most anyway, and for free.
|
||||
// It is the moment the activity stops being visible — `TRIM_MEMORY_UI_HIDDEN`
|
||||
// in all but name — and it is the cheapest possible time to give memory
|
||||
// back, because nothing that is freed has to be drawn again before anyone
|
||||
// sees it. `Pause` deliberately does not qualify: a permission dialog or
|
||||
// the share sheet pauses an activity that is still on screen behind it,
|
||||
// and throwing away its render pipeline would make every such interruption
|
||||
// cost a full re-render.
|
||||
use slint::android::android_activity::{MainEvent, PollEvent};
|
||||
if let Err(e) = slint::android::init_with_event_listener(app, |event| match event {
|
||||
PollEvent::Main(MainEvent::LowMemory) => {
|
||||
dr_ui::memory::relieve(dr_ui::memory::Level::Critical);
|
||||
}
|
||||
PollEvent::Main(MainEvent::Stop) => {
|
||||
dr_ui::memory::relieve(dr_ui::memory::Level::UiHidden);
|
||||
}
|
||||
_ => {}
|
||||
}) {
|
||||
log::error!("Slint Android backend failed to initialise: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty rather than the desktop's argv: see the module note above.
|
||||
// The launch Intent's images, where there were any, standing in for the
|
||||
// desktop's argv — `launch::startup_action` treats a non-empty list as
|
||||
// "the user asked for these specifically", which is exactly what a share
|
||||
// or a tap in a gallery is. Empty for an ordinary launch, and the library
|
||||
// opens as before.
|
||||
//
|
||||
// Returning from `android_main` ends the process, so a failure here is
|
||||
// logged rather than propagated — there is no shell to show `Err` to.
|
||||
if let Err(e) = dr_ui::run(Vec::new()) {
|
||||
if let Err(e) = dr_ui::run(opened_with) {
|
||||
log::error!("DarkRoom exited with error: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Unpack the face models the APK carries, if it carries any.
|
||||
/// Unpack the models the APK carries, if it carries any.
|
||||
///
|
||||
/// # Why Android needs this and no other platform does
|
||||
///
|
||||
/// The weights are not a build input and are not in the repository — the
|
||||
/// InsightFace grant is research-only and incompatible with this project's
|
||||
/// licence (docs/faces.md §2), so a desktop user fetches them, runs
|
||||
/// `tools/fix-face-model-shapes.sh` over them, and drops the result into
|
||||
/// `~/.local/share/darkroom/models/`. **That gesture does not exist on
|
||||
/// Android.** `internal_data_path` is app-private, `run-as` needs a debuggable
|
||||
/// build, and there is no picker and no fetch in the app, so a phone had no way
|
||||
/// to acquire a model at all and face indexing reported itself permanently off.
|
||||
/// A desktop build reads its models from a path — the account's directory, the
|
||||
/// shared one, or `$XDG_DATA_DIRS` where a package put them. **Android has no
|
||||
/// such path.** `internal_data_path` is app-private, `run-as` needs a
|
||||
/// debuggable build, and an asset inside a package is not a path anything can
|
||||
/// open (ARCH §6.9), so a phone had no way to reach a model at all.
|
||||
///
|
||||
/// So a locally-built APK may carry the pair in `assets/models/`, which
|
||||
/// `assemble-apk.sh` includes when the tree has them and omits when it does
|
||||
/// not. Nothing changes about what the repository holds or what a published
|
||||
/// build could redistribute; this only gives a self-built APK the same route a
|
||||
/// desktop build has always had.
|
||||
/// So the APK carries them in `assets/models/` and this copies them out, once,
|
||||
/// into the same shared directory a desktop install uses. After that every
|
||||
/// lookup in `dr_ui::library` finds them exactly where it finds a desktop
|
||||
/// user's.
|
||||
///
|
||||
/// Absent assets are the ordinary case, not an error — the same quiet "no model
|
||||
/// installed" state a fresh desktop install is in.
|
||||
/// # The two sets are not the same kind of thing
|
||||
///
|
||||
/// **Face weights are absent from the repository by design.** The InsightFace
|
||||
/// grant is research-only and incompatible with this project's licence
|
||||
/// (docs/faces.md §2), so a desktop user fetches them, runs
|
||||
/// `tools/fix-face-model-shapes.sh` over them, and drops the result in. A build
|
||||
/// that carries none is the ordinary case and face indexing simply stays off.
|
||||
///
|
||||
/// **The scene model is committed** (AGPL, compatible — `models/LICENCE.md`),
|
||||
/// so a build carrying none means a checkout without `git lfs pull` rather than
|
||||
/// a deliberate omission. It is still not an error here: the scene tab reports
|
||||
/// itself unavailable the same way face indexing does, because a photo editor
|
||||
/// that refuses to start over a missing grading feature is worse than one that
|
||||
/// starts without it.
|
||||
///
|
||||
/// # Why it is not `include_bytes!` like the instance model
|
||||
///
|
||||
/// Size. The instance model is 11 MB and compiled in; the scene model is 24 MB
|
||||
/// on top of that, and a 35 MB constant in the binary is paid by every install
|
||||
/// whether or not the tab is opened. Assets are also *stored* rather than
|
||||
/// deflated in the APK (see `assemble-apk.sh`), so unpacking is a copy rather
|
||||
/// than an inflate.
|
||||
///
|
||||
/// # Why it returns before it has done anything
|
||||
///
|
||||
/// **`android_main` runs with the input channel unserviced.** Nothing drains
|
||||
/// it until Slint reaches `poll_events`, and Slint does not reach `poll_events`
|
||||
/// until `dr_ui::run` calls `window.run()`, which is the last line of it. So
|
||||
/// every millisecond spent between the top of `android_main` and that line is a
|
||||
/// millisecond in which Android's input dispatcher gets no answer, and five
|
||||
/// thousand of them is an ANR by definition — the system puts "DarkRoom isn't
|
||||
/// responding" over a window that has never painted, and offers to kill it.
|
||||
///
|
||||
/// This copied **41 MB** on the first launch after an install: 24.9 MB of scene
|
||||
/// model, 13.6 MB of embedder, 2.5 MB of detector, each read whole out of the
|
||||
/// APK and written to `/data`. v0.10.0 added the scene model, which was 60% of
|
||||
/// that total; v0.10.0 is the release the ANR appeared in, and the 8,010 minor
|
||||
/// faults in its report are what 41 MB of freshly touched pages looks like.
|
||||
/// The two further detectors the settings page offers since have made it
|
||||
/// 61 MB, which is the same argument with a larger number.
|
||||
///
|
||||
/// So it runs on a worker (NFR-ARCH-1: nothing blocking on the UI executor) and
|
||||
/// this function returns as soon as the thread is running. Nothing on the
|
||||
/// launch path waits for it, and no other startup step needs its result.
|
||||
///
|
||||
/// # The window in which a model looks absent, and why that is honest enough
|
||||
///
|
||||
/// Until the copy finishes, `library::face_models` and `library::scene_model`
|
||||
/// answer `is_file()` about files that are not written yet, so both report
|
||||
/// their feature unavailable — the same answer they give a build carrying no
|
||||
/// weights at all, which is the ordinary case this whole path was written
|
||||
/// around. It is briefly pessimistic rather than wrong, it lasts about as long
|
||||
/// as it takes to read one screenful of the grid, and the temporary name
|
||||
/// [`unpack_bundled_models`] writes under is what stops it being worse than
|
||||
/// pessimistic: a lookup never sees a half-written file, only an absent one.
|
||||
#[cfg(target_os = "android")]
|
||||
fn install_bundled_face_models(app: &slint::android::AndroidApp) {
|
||||
fn install_bundled_models(app: slint::android::AndroidApp) {
|
||||
// Detached rather than joined: there is no later moment on the launch path
|
||||
// that wants the answer, and a handle nobody joins is a handle nobody can
|
||||
// forget to. `AndroidApp` is documented `Send` and `Sync` and is an `Arc`
|
||||
// internally, so the clone costs a refcount; `asset_manager` is asked for
|
||||
// on the worker because `AAssetManager` is thread-safe by contract and
|
||||
// reading the pointer takes only the app's read lock, which `poll_events`
|
||||
// also only ever holds shared.
|
||||
std::thread::spawn(move || unpack_bundled_models(&app));
|
||||
}
|
||||
|
||||
/// The copy itself, on the worker [`install_bundled_models`] starts.
|
||||
#[cfg(target_os = "android")]
|
||||
fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
use std::io::Read;
|
||||
|
||||
// The **shape-fixed** names, matching what `library::face_models` looks
|
||||
// for: tract cannot parse either InsightFace graph with its dynamic input
|
||||
// dimension, so what ships here has already been through
|
||||
// `tools/fix-face-model-shapes.sh`.
|
||||
const BUNDLED: [(&std::ffi::CStr, &str); 2] = [
|
||||
let started = std::time::Instant::now();
|
||||
|
||||
// The face names are the **shape-fixed** exports, matching what
|
||||
// `library::face_models` looks for: tract cannot parse either InsightFace
|
||||
// graph with its dynamic input dimension, so what ships here has already
|
||||
// been through `tools/fix-face-model-shapes.sh`.
|
||||
//
|
||||
// The scene entries are three files rather than one because the graph alone
|
||||
// decodes to 150 anonymous channels — `library::scene_model` wants the
|
||||
// vocabulary and the category descriptor beside it, and requires all three
|
||||
// before it reports the tab available.
|
||||
//
|
||||
// Three detectors, because which one runs is a setting
|
||||
// (`FaceDetector`, docs/faces.md §12.3) and a tablet has no other way to
|
||||
// obtain the one it was not shipped with. Twenty megabytes of APK for
|
||||
// the choice; the embedder is the same for all three.
|
||||
const BUNDLED: [(&std::ffi::CStr, &str); 7] = [
|
||||
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
|
||||
(c"models/scrfd_2.5g_640.onnx", "scrfd_2.5g_640.onnx"),
|
||||
(c"models/scrfd_10g_640.onnx", "scrfd_10g_640.onnx"),
|
||||
(c"models/arcface_mbf_b1.onnx", "arcface_mbf_b1.onnx"),
|
||||
(c"models/yolo26s-sem-ade20k.onnx", "yolo26s-sem-ade20k.onnx"),
|
||||
(
|
||||
c"models/yolo26s-sem-ade20k.classes.json",
|
||||
"yolo26s-sem-ade20k.classes.json",
|
||||
),
|
||||
(c"models/categories.txt", "categories.txt"),
|
||||
];
|
||||
|
||||
let dir = dr_ui::shared_face_models_dir();
|
||||
let assets = app.asset_manager();
|
||||
let mut copied = 0u64;
|
||||
|
||||
for (asset_path, name) in BUNDLED {
|
||||
let dest = dir.join(name);
|
||||
// Already unpacked. Not re-read on every launch: this is 15 MB through
|
||||
// a decompressor on the startup path, and the file does not change
|
||||
// without the APK changing, at which point the install wiped it anyway.
|
||||
// Already unpacked. Not re-read on every launch: this is 61 MB of
|
||||
// copying across the seven entries, and the file does not change without
|
||||
// the APK changing, at which point the install wiped it anyway. It
|
||||
// matters more now than it did — a launch that skips every entry here
|
||||
// costs nothing at all, which is what makes the second launch after an
|
||||
// install cheap even though the first one is not.
|
||||
if dest.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(mut asset) = assets.open(asset_path) else {
|
||||
log::info!("no bundled {name} in this APK; face indexing stays off");
|
||||
log::info!("no bundled {name} in this APK; the feature needing it stays off");
|
||||
continue;
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
@@ -124,18 +366,193 @@ fn install_bundled_face_models(app: &slint::android::AndroidApp) {
|
||||
return;
|
||||
}
|
||||
// Written under a temporary name and renamed, because
|
||||
// `library::face_models` decides face indexing is available on
|
||||
// `is_file()` alone. A truncated write — the process backgrounded and
|
||||
// `library::face_models` and `library::scene_model` both decide a
|
||||
// feature is available on `is_file()` alone. A truncated write — the process backgrounded and
|
||||
// killed mid-copy — would otherwise leave a file that passes that test
|
||||
// and fails inside tract, reported to the user as a broken model rather
|
||||
// than a missing one.
|
||||
let part = dir.join(format!("{name}.part"));
|
||||
match std::fs::write(&part, &bytes).and_then(|()| std::fs::rename(&part, &dest)) {
|
||||
Ok(()) => log::info!("installed bundled {name} ({} bytes)", bytes.len()),
|
||||
Ok(()) => {
|
||||
copied += bytes.len() as u64;
|
||||
log::info!("installed bundled {name} ({} bytes)", bytes.len());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("cannot install {name}: {e}");
|
||||
let _ = std::fs::remove_file(&part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The figure this whole function is about. Said even when it is zero, so a
|
||||
// launch that ANRs anyway can be told apart from one that spent its six
|
||||
// seconds here — on a second launch there is nothing left to copy and the
|
||||
// line reads `0 bytes`.
|
||||
log::info!(
|
||||
"bundled models ready: {copied} bytes copied in {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLAT-AND-6
|
||||
/// The declarations that make this app a receiver, held to on the host.
|
||||
///
|
||||
/// Everything FR-PLAT-AND-6 does on a device is unreachable from `cargo test`:
|
||||
/// there is no `Intent` off-device and no `ContentProvider` to instantiate. But
|
||||
/// the requirement is not only behaviour — half of it is *declaration*, and a
|
||||
/// declaration can be wrong in ways that compile perfectly and fail silently.
|
||||
/// An intent filter that is deleted takes the app out of every gallery's "open
|
||||
/// with" menu with nothing to notice; an authority that stops matching the
|
||||
/// class it names raises a `SecurityException` in whichever other app opened
|
||||
/// the share sheet, which is the last place anybody would look for it.
|
||||
///
|
||||
/// The manifest is read by aapt2 and the Java by javac, so a Rust build sees
|
||||
/// neither. `include_str!` is what puts them where a test can reach them, and
|
||||
/// this is the only place in the workspace that does.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The manifest with its comments removed and its whitespace flattened, so
|
||||
/// a match is about the declaration and not about how it is indented.
|
||||
fn manifest() -> String {
|
||||
let xml = include_str!("../android/AndroidManifest.xml");
|
||||
let mut out = String::with_capacity(xml.len());
|
||||
let mut rest = xml;
|
||||
// Comments first, and not by regex over the whole file: several of them
|
||||
// quote the very attribute names the assertions below look for, so a
|
||||
// test that read them would pass on the strength of the prose
|
||||
// explaining an entry that had been deleted.
|
||||
while let Some(start) = rest.find("<!--") {
|
||||
out.push_str(&rest[..start]);
|
||||
match rest[start..].find("-->") {
|
||||
Some(end) => rest = &rest[start + end + 3..],
|
||||
None => {
|
||||
rest = "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// The body of each `<intent-filter>`, so an action and a MIME type are
|
||||
/// checked to be in the *same* filter. Two filters, one naming the action
|
||||
/// and one naming the type, register for neither.
|
||||
fn intent_filters(manifest: &str) -> Vec<&str> {
|
||||
manifest
|
||||
.split("<intent-filter>")
|
||||
.skip(1)
|
||||
.filter_map(|filter| filter.split("</intent-filter>").next())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The single `<provider>` element, attributes and all.
|
||||
fn provider(manifest: &str) -> String {
|
||||
let start = manifest
|
||||
.find("<provider")
|
||||
.expect("no <provider> in the manifest");
|
||||
let rest = &manifest[start..];
|
||||
let end = rest.find("/>").expect("unterminated <provider> element");
|
||||
rest[..end + 2].to_string()
|
||||
}
|
||||
|
||||
fn attribute(element: &str, name: &str) -> Option<String> {
|
||||
let key = format!("{name}=\"");
|
||||
let start = element.find(&key)? + key.len();
|
||||
let value = element[start..].split('"').next()?;
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gallery_can_open_a_photograph_in_this_app() {
|
||||
let manifest = manifest();
|
||||
let registered = intent_filters(&manifest).iter().any(|filter| {
|
||||
filter.contains("android.intent.action.VIEW")
|
||||
&& filter.contains("android.intent.category.DEFAULT")
|
||||
&& filter.contains(r#"android:mimeType="image/*""#)
|
||||
});
|
||||
assert!(
|
||||
registered,
|
||||
"no VIEW filter for image/*: nothing will offer DarkRoom for a photograph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_share_sheet_can_send_one_image_or_several() {
|
||||
let manifest = manifest();
|
||||
let registered = intent_filters(&manifest).iter().any(|filter| {
|
||||
// The closing quote matters: SEND is a prefix of SEND_MULTIPLE, so
|
||||
// a bare substring test passes on a filter that declares only the
|
||||
// second and would not be offered for a single photograph.
|
||||
filter.contains(r#"android.intent.action.SEND""#)
|
||||
&& filter.contains(r#"android.intent.action.SEND_MULTIPLE""#)
|
||||
&& filter.contains("android.intent.category.DEFAULT")
|
||||
&& filter.contains(r#"android:mimeType="image/*""#)
|
||||
});
|
||||
assert!(
|
||||
registered,
|
||||
"no SEND/SEND_MULTIPLE filter for image/*, so the share sheet will not list DarkRoom"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_activity_ever_so_a_second_launch_cannot_start_a_second_one() {
|
||||
// Not style. Another app can now launch this activity while it is
|
||||
// already running, and the default launch mode answers that by
|
||||
// creating a second NativeActivity in this process — a second
|
||||
// android_main, a second Slint backend, a second wgpu device.
|
||||
assert!(
|
||||
manifest().contains(r#"android:launchMode="singleTask""#),
|
||||
"the activity must be singleTask; see the manifest comment"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_provider_authority_is_the_one_the_class_answers_to() {
|
||||
let manifest = manifest();
|
||||
let element = provider(&manifest);
|
||||
|
||||
let declared =
|
||||
attribute(&element, "android:authorities").expect("the provider declares no authority");
|
||||
let java = include_str!("../android/java/paris/tourolle/darkroom/ExportProvider.java");
|
||||
let constant = java
|
||||
.split("AUTHORITY = \"")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('"').next())
|
||||
.expect("ExportProvider declares no AUTHORITY constant");
|
||||
|
||||
assert_eq!(
|
||||
declared, constant,
|
||||
"the manifest and ExportProvider disagree about the authority; \
|
||||
a share would fail as a SecurityException inside the receiving app"
|
||||
);
|
||||
|
||||
let class = attribute(&element, "android:name").expect("the provider declares no class");
|
||||
let (package, _) = class
|
||||
.rsplit_once('.')
|
||||
.expect("the provider class is unqualified");
|
||||
assert!(
|
||||
java.contains(&format!("package {package};")),
|
||||
"the manifest names {class}, which is not the class in ExportProvider.java"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() {
|
||||
let manifest = manifest();
|
||||
let element = provider(&manifest);
|
||||
// The two halves are not redundant. Without the grant, every share
|
||||
// target fails; exported, every app on the device could read this
|
||||
// app's private directory.
|
||||
assert_eq!(
|
||||
attribute(&element, "android:exported").as_deref(),
|
||||
Some("false"),
|
||||
"an exported provider would serve the app's private directory to anything installed"
|
||||
);
|
||||
assert_eq!(
|
||||
attribute(&element, "android:grantUriPermissions").as_deref(),
|
||||
Some("true"),
|
||||
"without URI grants the share sheet opens and every target fails to read the file"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-ui.workspace = true
|
||||
dr-ui = { workspace = true, features = ["scene-model"] }
|
||||
# For the panic hook and the log sink, directly rather than through dr-ui:
|
||||
# both have to be installed before `dr_ui::run`, because a panic during startup
|
||||
# is exactly the one they exist to catch and record (NFR-OPS-1, NFR-OPS-2).
|
||||
dr-plat.workspace = true
|
||||
anyhow.workspace = true
|
||||
env_logger.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
@@ -4,13 +4,37 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dr_plat::diagnostics::Installed;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
|
||||
// Built rather than `init`ed, so the same logger can be handed to the
|
||||
// diagnostics tee: `env_logger` keeps writing to stderr exactly as before,
|
||||
// and every record it accepts is also appended to the on-disk log
|
||||
// (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",
|
||||
))
|
||||
.init();
|
||||
.build();
|
||||
let level = console.filter();
|
||||
let logging = dr_plat::diagnostics::install(Box::new(console), level);
|
||||
|
||||
// Immediately after the logger and before anything that could fail. Until
|
||||
// now a panic on desktop went to stderr and died with the terminal, which
|
||||
// means every panic a user has ever hit was unreportable: the process
|
||||
// survives (the panicking worker does not), a control goes dead, and there
|
||||
// is nothing on disk to say why. The record is local and stays local —
|
||||
// there is no upload path, by design; see `dr_plat::crash`.
|
||||
dr_plat::crash::install(env!("CARGO_PKG_VERSION"));
|
||||
|
||||
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
|
||||
// First thing in the file, so a user asked for "the log" can find it
|
||||
// without being told a path over the phone.
|
||||
match &logging {
|
||||
Installed::ToFile(path) => log::info!("logging to {}", path.display()),
|
||||
Installed::ConsoleOnly(why) => log::warn!("no log file this session: {why}"),
|
||||
}
|
||||
|
||||
let paths: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
|
||||
if paths.is_empty() {
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
//! What the suggestion confidence would say about a real library.
|
||||
//!
|
||||
//! cargo run --release -p dr-catalog --example face_confidence -- CATALOG.sqlite [--full]
|
||||
//!
|
||||
//! Read-only: it writes nothing to the catalog, so it can be pointed at a copy
|
||||
//! of a live library and re-run at will.
|
||||
//!
|
||||
//! # What it measures
|
||||
//!
|
||||
//! The user's own confirmations are the only ground truth a library has, so
|
||||
//! the evaluation is leave-one-out over them: hide one confirmed face, ask the
|
||||
//! scorer which of the confirmed identities it belongs to, and compare with
|
||||
//! what the user said. Faces from the same photograph are excluded exactly as
|
||||
//! the clusterer excludes them, so nothing is scored against a co-occurrence
|
||||
//! that would never have been allowed to merge.
|
||||
//!
|
||||
//! Two numbers are compared on that task: the share (`dr_face::assign`) and the
|
||||
//! mean-within-group figure it replaced. Accuracy says which one picks the
|
||||
//! right person; the reliability table says whether the percentage the user is
|
||||
//! shown means what it claims — which is the question FR-CULL-9 exists for.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use dr_catalog::faces::{self, PersonId};
|
||||
use dr_catalog::Catalog;
|
||||
|
||||
const MODEL_ID: &str = "w600k_mbf";
|
||||
const TOP: usize = 10;
|
||||
|
||||
struct Known {
|
||||
image: u64,
|
||||
person: PersonId,
|
||||
embedding: Vec<f32>,
|
||||
crop_px: f32,
|
||||
}
|
||||
|
||||
/// What the catalog holds per face, decoded: photograph, vector, size,
|
||||
/// quality.
|
||||
type Decoded = (u64, Vec<f32>, f32, Option<f32>);
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let Some(path) = args.first() else {
|
||||
eprintln!("usage: face_confidence CATALOG.sqlite [--full]");
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
||||
let catalog = Catalog::open(std::path::Path::new(path)).expect("open catalog");
|
||||
let conn = catalog.connection();
|
||||
|
||||
let cal = match faces::calibration(conn, MODEL_ID) {
|
||||
Ok(Some((c, _))) => c,
|
||||
_ => dr_face::Calibration::default(),
|
||||
};
|
||||
println!(
|
||||
"calibration: a={:.2} b={:.2} w_size={:.3} valid={} (P=0.5 at cosine {:.3})",
|
||||
cal.a,
|
||||
cal.b,
|
||||
cal.w_size,
|
||||
cal.valid,
|
||||
cal.boundary_at(0.5, 150.0, 0.0)
|
||||
);
|
||||
|
||||
let model = dr_face::ModelId::new(MODEL_ID.to_string());
|
||||
let stored = faces::embeddings(conn, MODEL_ID).expect("embeddings");
|
||||
let mut embedding_of: HashMap<faces::FaceId, Decoded> = HashMap::new();
|
||||
for f in stored {
|
||||
if let Some(e) = dr_face::Embedding::from_f16_bytes(model.clone(), &f.embedding) {
|
||||
embedding_of.insert(f.face, (f.image.0, e.v.to_vec(), f.crop_px, f.quality));
|
||||
}
|
||||
}
|
||||
println!("faces with embeddings: {}", embedding_of.len());
|
||||
|
||||
// The ground truth: every confirmed face, under the person the user put it
|
||||
// on. Identities with a single confirmation are dropped — leaving one out
|
||||
// leaves that identity with no evidence at all, so they measure nothing.
|
||||
let people = faces::people(conn).expect("people");
|
||||
let mut known: Vec<Known> = Vec::new();
|
||||
let mut identities = 0usize;
|
||||
for p in &people {
|
||||
if p.confirmed_faces < 2 {
|
||||
continue;
|
||||
}
|
||||
let mut mine = Vec::new();
|
||||
for f in faces::for_person(conn, p.id, false).expect("faces") {
|
||||
if !f.confirmed {
|
||||
continue;
|
||||
}
|
||||
if let Some((image, embedding, crop_px, _)) = embedding_of.get(&f.id) {
|
||||
mine.push(Known {
|
||||
image: *image,
|
||||
person: p.id,
|
||||
embedding: embedding.clone(),
|
||||
crop_px: *crop_px,
|
||||
});
|
||||
}
|
||||
}
|
||||
if mine.len() >= 2 {
|
||||
identities += 1;
|
||||
known.extend(mine);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"ground truth: {} confirmed faces across {identities} identities\n",
|
||||
known.len()
|
||||
);
|
||||
if known.len() < 2 {
|
||||
println!("not enough confirmations to evaluate.");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut share_right = 0usize;
|
||||
let mut mean_right = 0usize;
|
||||
// (share of the winner, was the winner correct)
|
||||
let mut reliability: Vec<(f32, bool)> = Vec::with_capacity(known.len());
|
||||
// What each scorer would have *displayed* for the correct answer.
|
||||
let mut shown_share = Vec::with_capacity(known.len());
|
||||
let mut shown_mean = Vec::with_capacity(known.len());
|
||||
|
||||
for (i, me) in known.iter().enumerate() {
|
||||
let mut per_person: HashMap<PersonId, Vec<f32>> = HashMap::new();
|
||||
// The mean baseline is the old code's, which had no floor: it averaged
|
||||
// over every member of the group.
|
||||
let mut all_person: HashMap<PersonId, Vec<f32>> = HashMap::new();
|
||||
for (j, them) in known.iter().enumerate() {
|
||||
if i == j || me.image == them.image {
|
||||
continue;
|
||||
}
|
||||
let cos: f32 = me
|
||||
.embedding
|
||||
.iter()
|
||||
.zip(&them.embedding)
|
||||
.map(|(a, b)| a * b)
|
||||
.sum();
|
||||
// The floor the real scorer sees: `cluster_scored` scans at
|
||||
// `RIVAL_FLOOR` and `identity_shares` never learns about a pair
|
||||
// below it. Summing the near-orthogonal ones here instead of
|
||||
// dropping them is not a stricter test, it is a different
|
||||
// function — fifty identities contributing their *upper tail* of
|
||||
// noise outweigh one contributing a real match.
|
||||
let probability = cal.probability(cos, me.crop_px.min(them.crop_px), 0.0);
|
||||
if probability >= dr_face::RIVAL_FLOOR {
|
||||
per_person.entry(them.person).or_default().push(probability);
|
||||
}
|
||||
all_person.entry(them.person).or_default().push(probability);
|
||||
}
|
||||
|
||||
// The share: sum of the best TOP matches per identity, normalised.
|
||||
// Evidence, and the coherence that goes with it: the sum of the best
|
||||
// TOP matches, and their mean. dr_face::assign shows the product of
|
||||
// that mean and the identity's share of the total.
|
||||
let mut evidence: Vec<(PersonId, f32, f32)> = per_person
|
||||
.iter()
|
||||
.map(|(&p, probabilities)| {
|
||||
let mut v = probabilities.clone();
|
||||
v.sort_by(|a, b| b.total_cmp(a));
|
||||
let counted = v.len().min(TOP);
|
||||
let sum = v.iter().take(TOP).sum::<f32>();
|
||||
(p, sum, sum / counted as f32)
|
||||
})
|
||||
.collect();
|
||||
let total: f32 = evidence.iter().map(|(_, s, _)| *s).sum();
|
||||
evidence.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
|
||||
// The number it replaced: the mean over every member of the identity.
|
||||
let mut means: Vec<(PersonId, f32)> = all_person
|
||||
.iter()
|
||||
.map(|(&p, probabilities)| {
|
||||
(
|
||||
p,
|
||||
probabilities.iter().sum::<f32>() / probabilities.len() as f32,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
means.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
|
||||
if let (Some(&(winner, score, coherence)), true) = (evidence.first(), total > 0.0) {
|
||||
let correct = winner == me.person;
|
||||
share_right += correct as usize;
|
||||
// What the screen would say about the identity it picked.
|
||||
reliability.push((coherence * score / total, correct));
|
||||
let ours = evidence
|
||||
.iter()
|
||||
.find(|(p, _, _)| *p == me.person)
|
||||
.map(|(_, s, c)| c * s / total)
|
||||
.unwrap_or(0.0);
|
||||
shown_share.push(ours);
|
||||
}
|
||||
if let Some(&(winner, _)) = means.first() {
|
||||
mean_right += (winner == me.person) as usize;
|
||||
shown_mean.push(
|
||||
means
|
||||
.iter()
|
||||
.find(|(p, _)| *p == me.person)
|
||||
.map(|(_, s)| *s)
|
||||
.unwrap_or(0.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let n = known.len() as f64;
|
||||
println!("which identity does this face belong to? (leave-one-out, top-1)");
|
||||
println!(
|
||||
" share of evidence {:>6.2}% ({share_right}/{})",
|
||||
100.0 * share_right as f64 / n,
|
||||
known.len()
|
||||
);
|
||||
println!(
|
||||
" mean within group {:>6.2}% ({mean_right}/{})\n",
|
||||
100.0 * mean_right as f64 / n,
|
||||
known.len()
|
||||
);
|
||||
|
||||
println!("what the screen would show for the answer the user gave:");
|
||||
band(" share ", &shown_share);
|
||||
band(" mean ", &shown_mean);
|
||||
|
||||
println!("\nreliability of the share — is a stated {{n}}% right {{n}}% of the time?");
|
||||
println!(
|
||||
" {:>12} {:>7} {:>9} {:>8}",
|
||||
"stated", "faces", "correct", "gap"
|
||||
);
|
||||
for (lo, hi) in [
|
||||
(0.0, 0.5),
|
||||
(0.5, 0.6),
|
||||
(0.6, 0.7),
|
||||
(0.7, 0.8),
|
||||
(0.8, 0.9),
|
||||
(0.9, 0.95),
|
||||
(0.95, 1.001),
|
||||
] {
|
||||
let bucket: Vec<bool> = reliability
|
||||
.iter()
|
||||
.filter(|(s, _)| *s >= lo && *s < hi)
|
||||
.map(|(_, c)| *c)
|
||||
.collect();
|
||||
if bucket.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let observed = bucket.iter().filter(|c| **c).count() as f64 / bucket.len() as f64;
|
||||
let stated = reliability
|
||||
.iter()
|
||||
.filter(|(s, _)| *s >= lo && *s < hi)
|
||||
.map(|(s, _)| *s as f64)
|
||||
.sum::<f64>()
|
||||
/ bucket.len() as f64;
|
||||
println!(
|
||||
" {:>5.0}–{:>3.0}% {:>9} {:>8.1}% {:>+7.1}",
|
||||
lo * 100.0,
|
||||
hi.min(1.0) * 100.0,
|
||||
bucket.len(),
|
||||
100.0 * observed,
|
||||
100.0 * (observed - stated)
|
||||
);
|
||||
}
|
||||
|
||||
if args.iter().any(|a| a == "--full") {
|
||||
// The confirmations go in as anchors, exactly as `recluster` sends
|
||||
// them: they are what makes a group a named identity, and therefore
|
||||
// what makes it a rival.
|
||||
let mut confirmed = HashMap::new();
|
||||
for p in &people {
|
||||
for f in faces::for_person(conn, p.id, false).expect("faces") {
|
||||
if f.confirmed {
|
||||
confirmed.insert(f.id, p.id.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
full_library(&embedding_of, &confirmed, &cal);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a set of confidences actually falls.
|
||||
fn band(label: &str, v: &[f32]) {
|
||||
if v.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut s = v.to_vec();
|
||||
s.sort_by(|a, b| a.total_cmp(b));
|
||||
let pct = |q: f64| s[((s.len() - 1) as f64 * q) as usize];
|
||||
let mean = s.iter().sum::<f32>() / s.len() as f32;
|
||||
println!(
|
||||
"{label} median {:>5.1}% mean {:>5.1}% p10 {:>5.1}% p90 {:>5.1}% under 50%: {:>5.1}%",
|
||||
100.0 * pct(0.5),
|
||||
100.0 * mean,
|
||||
100.0 * pct(0.10),
|
||||
100.0 * pct(0.90),
|
||||
100.0 * s.iter().filter(|x| **x < 0.5).count() as f32 / s.len() as f32
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole library through the real clusterer, for the numbers it would
|
||||
/// actually write.
|
||||
fn full_library(
|
||||
embedding_of: &HashMap<faces::FaceId, Decoded>,
|
||||
confirmed: &HashMap<faces::FaceId, u64>,
|
||||
cal: &dr_face::Calibration,
|
||||
) {
|
||||
let mut candidates: Vec<dr_face::Candidate> = embedding_of
|
||||
.iter()
|
||||
.map(
|
||||
|(id, (image, embedding, crop_px, quality))| dr_face::Candidate {
|
||||
face: id.0,
|
||||
image: *image,
|
||||
embedding: embedding.clone(),
|
||||
crop_px: *crop_px,
|
||||
quality: *quality,
|
||||
confirmed_person: confirmed.get(id).copied(),
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
candidates.sort_by_key(|c| c.face);
|
||||
|
||||
println!("\nthe whole library, at the default merge probability:");
|
||||
|
||||
// The three phases, separately, because "a regroup takes n seconds" does
|
||||
// not tell anyone which half to optimise — and the answer differs between
|
||||
// a desktop and a tablet (docs/faces.md §9).
|
||||
{
|
||||
let dim = candidates.first().map(|c| c.embedding.len()).unwrap_or(0);
|
||||
let flat: Vec<f32> = candidates
|
||||
.iter()
|
||||
.flat_map(|c| c.embedding.clone())
|
||||
.collect();
|
||||
let crop_px: Vec<f32> = candidates.iter().map(|c| c.crop_px).collect();
|
||||
let images: Vec<u64> = candidates.iter().map(|c| c.image).collect();
|
||||
let gallery: Vec<bool> = candidates.iter().map(|c| c.in_gallery()).collect();
|
||||
let view = dr_face::neighbours::Faces {
|
||||
embeddings: &flat,
|
||||
dim,
|
||||
crop_px: &crop_px,
|
||||
images: &images,
|
||||
gallery: &gallery,
|
||||
};
|
||||
|
||||
let t = std::time::Instant::now();
|
||||
let evidence = dr_face::neighbours::above_threshold(&view, cal, dr_face::RIVAL_FLOOR);
|
||||
let scan = t.elapsed().as_secs_f64();
|
||||
|
||||
// `cluster` runs its own scan at the merge threshold, so the
|
||||
// agglomeration is what is left after taking one scan off the total.
|
||||
let t = std::time::Instant::now();
|
||||
let clusters = dr_face::cluster(&candidates, cal, dr_face::DEFAULT_MERGE_PROBABILITY);
|
||||
let agglomerate = t.elapsed().as_secs_f64() - scan;
|
||||
|
||||
let t = std::time::Instant::now();
|
||||
let _ = dr_face::identity_shares(&gallery, &clusters, &evidence, dr_face::TOP_MATCHES);
|
||||
println!(
|
||||
" scan {scan:.2}s ({} evidence pairs) · agglomerate {agglomerate:.2}s · score {:.2}s",
|
||||
evidence.len(),
|
||||
t.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let grouping = dr_face::cluster_scored(&candidates, cal, dr_face::DEFAULT_MERGE_PROBABILITY);
|
||||
let real: Vec<_> = grouping
|
||||
.clusters
|
||||
.iter()
|
||||
.filter(|c| c.members.len() >= 2)
|
||||
.collect();
|
||||
let grouped: usize = real.iter().map(|c| c.members.len()).sum();
|
||||
println!(
|
||||
" {} face(s) → {} group(s) of two or more, holding {grouped} faces ({:.0}%), in {:.1}s",
|
||||
candidates.len(),
|
||||
real.len(),
|
||||
100.0 * grouped as f64 / candidates.len() as f64,
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
let named: Vec<_> = real.iter().filter(|c| c.person.is_some()).collect();
|
||||
println!(
|
||||
" {} of those group(s) carry a confirmation, holding {} faces",
|
||||
named.len(),
|
||||
named.iter().map(|c| c.members.len()).sum::<usize>()
|
||||
);
|
||||
|
||||
let shown: Vec<f32> = real
|
||||
.iter()
|
||||
.flat_map(|c| c.members.iter().map(|&m| grouping.confidence[m]))
|
||||
.collect();
|
||||
band(" new, all groups ", &shown);
|
||||
let onto_people: Vec<f32> = named
|
||||
.iter()
|
||||
.flat_map(|c| c.members.iter().map(|&m| grouping.confidence[m]))
|
||||
.collect();
|
||||
band(" new, onto a person", &onto_people);
|
||||
|
||||
// The number the old code would have written for the same grouping.
|
||||
let means: Vec<f32> = real
|
||||
.iter()
|
||||
.flat_map(|c| {
|
||||
c.members.iter().map(|&m| {
|
||||
let me = &candidates[m];
|
||||
let mut sum = 0.0;
|
||||
let mut n = 0.0;
|
||||
for &other in &c.members {
|
||||
if other == m {
|
||||
continue;
|
||||
}
|
||||
let them = &candidates[other];
|
||||
let cos: f32 = me
|
||||
.embedding
|
||||
.iter()
|
||||
.zip(&them.embedding)
|
||||
.map(|(a, b)| a * b)
|
||||
.sum();
|
||||
sum += cal.probability(cos, me.crop_px.min(them.crop_px), 0.0);
|
||||
n += 1.0;
|
||||
}
|
||||
if n == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
sum / n
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
band(" old, all groups ", &means);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -222,6 +222,56 @@ impl Cache {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6c | FR-NC-6a
|
||||
/// Record an original this cache does **not** own the bytes of.
|
||||
///
|
||||
/// The virtual-filesystem case. On a library kept by a sync client the
|
||||
/// original is materialised *in the library folder itself*, so copying it
|
||||
/// under `originals/` would hold two copies of every pinned photograph —
|
||||
/// and the copy would be the one the budget could evict while the real
|
||||
/// disk cost stayed.
|
||||
///
|
||||
/// So the bytes are left where they are and only the bookkeeping is kept.
|
||||
/// `path` is deliberately `NULL`, which is what makes this safe:
|
||||
/// [`release`](Self::release) deletes the file a row names, and a row that
|
||||
/// names none deletes nothing. **That matters more than it sounds.**
|
||||
/// Deleting a materialised file inside a synced folder does not free a
|
||||
/// cache — it deletes the photograph, and the client propagates that to
|
||||
/// the server and to every other device. Handing the disk back is the
|
||||
/// backend's job (`RemoteBackend::dematerialise`), not this one's.
|
||||
///
|
||||
/// `bytes` is what the original occupies where it lies, for the budget and
|
||||
/// for reporting; pass 0 where it is not known.
|
||||
pub fn record_in_place(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
image: ImageId,
|
||||
bytes: u64,
|
||||
pinned: bool,
|
||||
now: i64,
|
||||
) -> Result<(), CatalogError> {
|
||||
conn.execute(
|
||||
"INSERT INTO image_cache
|
||||
(image_id, tier_actual, tier_desired, bytes, last_used, pinned, path)
|
||||
VALUES (?1, ?2, ?2, ?3, ?4, ?5, NULL)
|
||||
ON CONFLICT(image_id) DO UPDATE SET
|
||||
tier_actual = ?2,
|
||||
tier_desired = max(tier_desired, ?2),
|
||||
bytes = ?3,
|
||||
last_used = ?4,
|
||||
pinned = max(pinned, ?5),
|
||||
path = NULL",
|
||||
rusqlite::params![
|
||||
image.0 as i64,
|
||||
Tier::Original.stored(),
|
||||
bytes as i64,
|
||||
now,
|
||||
i64::from(pinned),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a cached original back, if it is here.
|
||||
///
|
||||
/// Touches `last_used`, which is what makes the eviction order reflect
|
||||
|
||||
@@ -376,6 +376,64 @@ pub fn set_order(
|
||||
touch(conn, id)
|
||||
}
|
||||
|
||||
/// Whether this collection has a manual order worth reading.
|
||||
///
|
||||
/// The grid asks before choosing an `ORDER BY`, and the answer is narrower
|
||||
/// than "is it manual". Two things have to be true.
|
||||
///
|
||||
/// It must be **manual**: a saved filter's contents are whatever its selector
|
||||
/// matches now, in whatever order the query returns them, and there are no
|
||||
/// member rows to carry a position.
|
||||
///
|
||||
/// And it must have **no children**. A parent's contents are the union of its
|
||||
/// own members and its descendants' ([`descendants`]), and positions are only
|
||||
/// ever assigned within one collection — so two children's positions are
|
||||
/// unrelated integers, and interleaving them by value would order the grid by
|
||||
/// a coincidence. Capture time is the honest answer for a set.
|
||||
///
|
||||
/// Kept here rather than assembled at the call site, so the rule has one name
|
||||
/// and one test, and the grid does not have to know that "no children" is part
|
||||
/// of it.
|
||||
pub fn orders_manually(conn: &Connection, id: CollectionId) -> Result<bool, CatalogError> {
|
||||
if kind_of(conn, id)? != CollectionKind::Manual {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let has_child: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM collections
|
||||
WHERE parent_id = ?1 AND deleted = 0 LIMIT 1",
|
||||
[id.0 as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
Ok(has_child.is_none())
|
||||
}
|
||||
|
||||
/// Every image in a manual collection, in manual order.
|
||||
///
|
||||
/// The whole membership, not the filtered view the grid happens to be showing.
|
||||
/// [`set_order`] renumbers exactly the images it is handed, so a caller that
|
||||
/// reordered a filtered list would renumber those and leave every hidden image
|
||||
/// on a stale position — two images sharing a position, and a grid that
|
||||
/// rearranges itself when the filter comes off.
|
||||
///
|
||||
/// `image_id` breaks ties. Positions are dense in practice, but a merge can
|
||||
/// deliver two devices' rows at the same position, and an order that is not
|
||||
/// total is one the grid draws differently each time it reads it.
|
||||
pub fn members_in_order(conn: &Connection, id: CollectionId) -> Result<Vec<ImageId>, CatalogError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT image_id FROM collection_members
|
||||
WHERE collection_id = ?1
|
||||
ORDER BY position, image_id",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([id.0 as i64], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Save a smart collection's selector.
|
||||
///
|
||||
/// Refuses a selector that references this collection, directly or through
|
||||
@@ -477,6 +535,160 @@ pub fn collections_for_image(
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// One collection a set of images is filed in, and how much of that set is in
|
||||
/// it.
|
||||
///
|
||||
/// `holding` is what makes a removal honest: with forty photographs selected
|
||||
/// and three of them in "Iceland", the row has to say "3 of 40" or the user
|
||||
/// reads it as "this selection is in Iceland" and takes all forty out of a
|
||||
/// collection thirty-seven of them were never in.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Membership {
|
||||
pub id: CollectionId,
|
||||
pub name: String,
|
||||
/// How many of the images asked about are members. Never zero — a
|
||||
/// collection holding none of them is not returned at all.
|
||||
pub holding: usize,
|
||||
}
|
||||
|
||||
/// Every collection the given images are filed in, with how many of them each
|
||||
/// holds.
|
||||
///
|
||||
/// The read behind "which collections is this selection in, and take it out of
|
||||
/// one" — the counterpart to [`collections_for_image`], which answers the same
|
||||
/// question for a single photograph and does not need the counts.
|
||||
///
|
||||
/// Smart collections never appear: they have no `collection_members` rows, so
|
||||
/// there is nothing to remove and offering it would be a button that does
|
||||
/// nothing. Sorted by name, matching the sidebar.
|
||||
///
|
||||
/// # Why this is chunked
|
||||
///
|
||||
/// The image list is a *selection*, which a select-all makes as large as the
|
||||
/// library. SQLite caps the number of bound parameters in one statement, so a
|
||||
/// single `IN (...)` over every selected id fails outright on exactly the
|
||||
/// gesture most likely to produce it. The counts are summed across chunks
|
||||
/// rather than re-queried, so the result is the same as the unchunked query
|
||||
/// would have given.
|
||||
pub fn membership_of(
|
||||
conn: &Connection,
|
||||
images: &[ImageId],
|
||||
) -> Result<Vec<Membership>, CatalogError> {
|
||||
if images.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Well under SQLite's default parameter cap, and large enough that an
|
||||
// ordinary selection is one round trip.
|
||||
const CHUNK: usize = 400;
|
||||
|
||||
let mut totals: std::collections::HashMap<CollectionId, (String, usize)> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for chunk in images.chunks(CHUNK) {
|
||||
let placeholders = std::iter::repeat_n("?", chunk.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
// The placeholder list is built from the id *count*, never from user
|
||||
// text — the same construction `deep_count` uses.
|
||||
let sql = format!(
|
||||
"SELECT c.id, c.name, count(*)
|
||||
FROM collection_members m
|
||||
JOIN collections c ON c.id = m.collection_id
|
||||
WHERE m.image_id IN ({placeholders}) AND c.deleted = 0
|
||||
GROUP BY c.id, c.name"
|
||||
);
|
||||
let params: Vec<rusqlite::types::Value> = chunk
|
||||
.iter()
|
||||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||||
.collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok((
|
||||
CollectionId(r.get::<_, i64>(0)? as u64),
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)? as usize,
|
||||
))
|
||||
})?;
|
||||
|
||||
for row in rows {
|
||||
let (id, name, n) = row?;
|
||||
let entry = totals.entry(id).or_insert((name, 0));
|
||||
entry.1 += n;
|
||||
}
|
||||
}
|
||||
|
||||
let mut out: Vec<Membership> = totals
|
||||
.into_iter()
|
||||
.map(|(id, (name, holding))| Membership { id, name, holding })
|
||||
.collect();
|
||||
// By name, then by id, so two collections sharing a name have a stable
|
||||
// order rather than the hash map's.
|
||||
out.sort_by(|a, b| {
|
||||
a.name
|
||||
.to_lowercase()
|
||||
.cmp(&b.name.to_lowercase())
|
||||
.then(a.id.0.cmp(&b.id.0))
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What kind of collection `id` is, or `None` if there is no such collection.
|
||||
///
|
||||
/// Cheaper than reading the whole [`Collection`] where the caller only needs to
|
||||
/// know whether member rows exist — the grid asks this to decide whether manual
|
||||
/// position is a thing it can order by, and a smart collection has no
|
||||
/// `collection_members` rows to carry one.
|
||||
pub fn kind(conn: &Connection, id: CollectionId) -> Result<Option<CollectionKind>, CatalogError> {
|
||||
let found = conn
|
||||
.query_row(
|
||||
"SELECT kind FROM collections WHERE id = ?1 AND deleted = 0",
|
||||
[id.0 as i64],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(found.map(CollectionKind::from_i64))
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-8
|
||||
/// The device-independent name of a collection, from its local id.
|
||||
///
|
||||
/// The pair to [`id_for_uuid`], and the reason both exist: `collections.id` is
|
||||
/// an autoincrement local to one catalog, so anything that travels between
|
||||
/// devices — a place, a merge — has to say which collection it means in the
|
||||
/// only vocabulary they share.
|
||||
///
|
||||
/// `None` for a collection that is not there, or has been tombstoned. A caller
|
||||
/// writing down a scope treats that as "the whole library", which is the
|
||||
/// harmless direction: the alternative is recording a name nothing can resolve.
|
||||
pub fn uuid_of(conn: &Connection, id: CollectionId) -> Result<Option<String>, CatalogError> {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT uuid FROM collections WHERE id = ?1 AND deleted = 0",
|
||||
[id.0 as i64],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-8
|
||||
/// The local id of a collection, from the name every device knows it by.
|
||||
///
|
||||
/// `None` where this device has never heard of it, or has deleted it — a place
|
||||
/// recorded on the tablet inside a collection this machine has not yet merged.
|
||||
/// The caller falls back to the whole library rather than to an empty grid.
|
||||
pub fn id_for_uuid(conn: &Connection, uuid: &str) -> Result<Option<CollectionId>, CatalogError> {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT id FROM collections WHERE uuid = ?1 AND deleted = 0",
|
||||
[uuid],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.optional()?
|
||||
.map(|id| CollectionId(id as u64)))
|
||||
}
|
||||
|
||||
/// A collection and everything beneath it, including itself.
|
||||
///
|
||||
/// Used for cycle checks and for scoping the grid to a parent: selecting a
|
||||
@@ -772,6 +984,106 @@ mod tests {
|
||||
ImageId(i)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_manual_leaf_orders_manually() {
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
|
||||
assert!(orders_manually(c, id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_filter_has_no_manual_order() {
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let id = create(c, "Picks", None, CollectionKind::Smart).unwrap();
|
||||
assert!(
|
||||
!orders_manually(c, id).unwrap(),
|
||||
"a smart collection has no member rows to carry a position"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parent_has_no_manual_order_of_its_own() {
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let parent = create(c, "2026", None, CollectionKind::Manual).unwrap();
|
||||
let child = create(c, "Iceland", Some(parent), CollectionKind::Manual).unwrap();
|
||||
|
||||
assert!(
|
||||
!orders_manually(c, parent).unwrap(),
|
||||
"a set shows its descendants' images, whose positions are unrelated"
|
||||
);
|
||||
assert!(
|
||||
orders_manually(c, child).unwrap(),
|
||||
"the child is still a leaf and still orders manually"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_last_child_gives_the_parent_its_order_back() {
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let parent = create(c, "2026", None, CollectionKind::Manual).unwrap();
|
||||
let child = create(c, "Iceland", Some(parent), CollectionKind::Manual).unwrap();
|
||||
assert!(!orders_manually(c, parent).unwrap());
|
||||
|
||||
delete(c, child).unwrap();
|
||||
assert!(
|
||||
orders_manually(c, parent).unwrap(),
|
||||
"a tombstoned child must not keep counting as a child"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_come_back_in_the_order_they_were_put_in() {
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
|
||||
add_images(c, id, &[img(3), img(1), img(2)]).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
members_in_order(c, id).unwrap(),
|
||||
vec![img(3), img(1), img(2)],
|
||||
"adding assigns position in the order given, and reading returns it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_come_back_in_the_order_set_order_wrote() {
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
|
||||
add_images(c, id, &[img(1), img(2), img(3)]).unwrap();
|
||||
|
||||
set_order(c, id, &[img(3), img(2), img(1)]).unwrap();
|
||||
assert_eq!(
|
||||
members_in_order(c, id).unwrap(),
|
||||
vec![img(3), img(2), img(1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_in_order_is_total_even_where_positions_collide() {
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
|
||||
add_images(c, id, &[img(1), img(2), img(3)]).unwrap();
|
||||
|
||||
// What a merge can deliver: two devices' rows landing on one position.
|
||||
c.execute(
|
||||
"UPDATE collection_members SET position = 0 WHERE collection_id = ?1",
|
||||
[id.0 as i64],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
members_in_order(c, id).unwrap(),
|
||||
vec![img(1), img(2), img(3)],
|
||||
"image_id breaks the tie, so two reads cannot disagree"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_created_collection_appears_in_the_tree() {
|
||||
let cat = seeded();
|
||||
@@ -1225,4 +1537,85 @@ mod tests {
|
||||
let d = descendants(c, a).unwrap();
|
||||
assert!(d.len() <= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_says_how_many_of_the_selection_each_collection_holds() {
|
||||
// The count is the whole point: "3 of 40" is what stops a user taking
|
||||
// forty photographs out of a collection thirty-seven were never in.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let iceland = create(c, "Iceland", None, CollectionKind::Manual).unwrap();
|
||||
let best = create(c, "Best", None, CollectionKind::Manual).unwrap();
|
||||
add_images(c, iceland, &[img(1), img(2), img(3)]).unwrap();
|
||||
add_images(c, best, &[img(1)]).unwrap();
|
||||
|
||||
let m = membership_of(c, &[img(1), img(2), img(3), img(4)]).unwrap();
|
||||
assert_eq!(m.len(), 2);
|
||||
// Sorted by name, so "Best" comes before "Iceland".
|
||||
assert_eq!(m[0].id, best);
|
||||
assert_eq!(m[0].holding, 1);
|
||||
assert_eq!(m[1].id, iceland);
|
||||
assert_eq!(m[1].holding, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_omits_collections_holding_none_of_them() {
|
||||
// A row offering to remove images that are not there would be a button
|
||||
// that does nothing, which is worse than an absent one.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
||||
create(c, "Empty", None, CollectionKind::Manual).unwrap();
|
||||
add_images(c, a, &[img(1)]).unwrap();
|
||||
|
||||
let m = membership_of(c, &[img(1)]).unwrap();
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].id, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_of_nothing_is_nothing() {
|
||||
let cat = seeded();
|
||||
assert!(membership_of(cat.connection(), &[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_skips_a_deleted_collection() {
|
||||
// The tombstone survives the delete, and its member rows are dropped —
|
||||
// but a merge can leave rows behind, and the sheet must not offer a
|
||||
// collection the sidebar does not draw.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
||||
add_images(c, a, &[img(1)]).unwrap();
|
||||
delete(c, a).unwrap();
|
||||
|
||||
assert!(membership_of(c, &[img(1)]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_sums_across_chunks() {
|
||||
// The chunking exists for a select-all, which is exactly the gesture
|
||||
// that would otherwise exceed SQLite's parameter cap. A count that was
|
||||
// per-chunk rather than summed would under-report on the one selection
|
||||
// large enough to need it.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
||||
// Past the fixture's six, so the member rows have images to point at.
|
||||
for i in 7..=900i64 {
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (?1, 1, ?2, 0)",
|
||||
rusqlite::params![i, format!("img{i}.CR3")],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let ids: Vec<ImageId> = (1..=900).map(img).collect();
|
||||
add_images(c, a, &ids).unwrap();
|
||||
|
||||
let m = membership_of(c, &ids).unwrap();
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].holding, 900, "counted across every chunk");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
//! TRACES: NFR-ARCH-4 | NFR-R5
|
||||
//! TRACES: NFR-ARCH-4 | NFR-R5 | NFR-R6
|
||||
//! Catalog errors.
|
||||
//!
|
||||
//! Typed and attached to the affected subject rather than panicking — a
|
||||
//! corrupt row or a failed job marks one image and lets the batch continue.
|
||||
//!
|
||||
//! # Why `From<rusqlite::Error>` is written by hand
|
||||
//!
|
||||
//! One class of SQLite failure is not about the statement that hit it: when
|
||||
//! the file itself is damaged, *every* query fails, and which one the user
|
||||
//! happened to trigger first says nothing. Before this, corruption reached the
|
||||
//! interface as whatever `Sqlite(...)` the first failing query produced —
|
||||
//! "database disk image is malformed" attached to a thumbnail refresh — and
|
||||
//! there was nowhere to hang a recovery offer.
|
||||
//!
|
||||
//! So the conversion classifies rather than wraps: `SQLITE_CORRUPT` and
|
||||
//! `SQLITE_NOTADB` become [`CatalogError::Corrupt`] wherever they arise, which
|
||||
//! means a background job that trips over the damage reports the same thing
|
||||
//! the startup check does (see [`crate::recovery`]).
|
||||
|
||||
/// Something went wrong talking to the catalog.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CatalogError {
|
||||
#[error("sqlite: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
Sqlite(#[source] rusqlite::Error),
|
||||
|
||||
/// The catalog file is damaged.
|
||||
///
|
||||
/// Its own variant because it is the one error with a *user-facing
|
||||
/// remedy*: restore the NFR-R2 backup, or discard the index and rebuild it
|
||||
/// from sources plus sidecars (NFR-R6, invariant §5.2.4). Every other
|
||||
/// variant here is either a caller's mistake or a fact about one row.
|
||||
#[error("the catalog file is damaged: {detail}")]
|
||||
Corrupt { detail: String },
|
||||
|
||||
/// The catalog was written by a newer build.
|
||||
///
|
||||
@@ -74,3 +97,38 @@ pub enum CatalogError {
|
||||
#[error("io: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for CatalogError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
if is_corruption(&e) {
|
||||
// `to_string` rather than keeping the error: the detail is going
|
||||
// into a dialog and into a log line, and the recovery path has no
|
||||
// use for the rusqlite type once it knows the file is damaged.
|
||||
CatalogError::Corrupt {
|
||||
detail: e.to_string(),
|
||||
}
|
||||
} else {
|
||||
CatalogError::Sqlite(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a SQLite failure means the *file* is damaged rather than the
|
||||
/// statement wrong.
|
||||
///
|
||||
/// `SQLITE_NOTADB` is included because it is what a truncated or overwritten
|
||||
/// catalog produces — SQLite cannot read the header, so it declines to call it
|
||||
/// a database at all. To a user those are the same accident, and the same two
|
||||
/// offers answer both.
|
||||
///
|
||||
/// Deliberately *not* included: `SQLITE_CANTOPEN` (a missing file, which
|
||||
/// `Connection::open` fixes by creating one), `SQLITE_BUSY`, and
|
||||
/// `SQLITE_IOERR` — a failing disk or a dropped network mount is a different
|
||||
/// problem, and telling the user to rebuild their index would be a wrong
|
||||
/// answer delivered confidently.
|
||||
fn is_corruption(e: &rusqlite::Error) -> bool {
|
||||
matches!(
|
||||
e.sqlite_error_code(),
|
||||
Some(rusqlite::ErrorCode::DatabaseCorrupt) | Some(rusqlite::ErrorCode::NotADatabase)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ pub struct SharedFace {
|
||||
pub confidence: f32,
|
||||
pub embedding: Vec<u8>,
|
||||
pub crop_px: f32,
|
||||
/// See `faces::DetectedFace::quality`. `None` from a shard written before
|
||||
/// the number was kept.
|
||||
pub quality: Option<f32>,
|
||||
/// The face cut out and encoded, or empty where none was kept.
|
||||
///
|
||||
/// Travels with the face rather than in the catalog snapshot, which is the
|
||||
@@ -224,8 +227,8 @@ impl FaceShardStore {
|
||||
tx.execute(
|
||||
"INSERT INTO faces
|
||||
(file_id, model_id, x, y, w, h, landmarks, confidence,
|
||||
embedding, crop_px, crop)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
embedding, crop_px, crop, quality)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
rusqlite::params![
|
||||
f.file_id as i64,
|
||||
f.model_id,
|
||||
@@ -238,6 +241,7 @@ impl FaceShardStore {
|
||||
f.embedding,
|
||||
f.crop_px as f64,
|
||||
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
|
||||
f.quality.map(f64::from),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -442,9 +446,10 @@ impl FaceShardStore {
|
||||
}
|
||||
let mut fq = src.prepare(&format!(
|
||||
"SELECT f.file_id, f.model_id, f.x, f.y, f.w, f.h, f.landmarks,
|
||||
f.confidence, f.embedding, f.crop_px, {}
|
||||
f.confidence, f.embedding, f.crop_px, {}, {}
|
||||
FROM faces f WHERE f.file_id = ?1 AND f.model_id = ?2",
|
||||
crop_column(&src)
|
||||
column_or_null(&src, "crop"),
|
||||
column_or_null(&src, "quality"),
|
||||
))?;
|
||||
let faces: Vec<SharedFace> = fq
|
||||
.query_map(rusqlite::params![file_id, &model_id], read_shared_face)?
|
||||
@@ -484,7 +489,8 @@ impl FaceShardStore {
|
||||
let Some(edge) = edge else { return Ok(None) };
|
||||
|
||||
let mut q = conn.prepare(
|
||||
"SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px, crop
|
||||
"SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px,
|
||||
crop, quality
|
||||
FROM faces WHERE file_id = ?1 AND model_id = ?2",
|
||||
)?;
|
||||
let faces: Vec<SharedFace> = q
|
||||
@@ -541,6 +547,7 @@ fn upgrade_shard(conn: &Connection) -> Result<(), CatalogError> {
|
||||
for (table, column, decl) in [
|
||||
("faces", "crop", "BLOB"),
|
||||
("indexed", "indexed_at", "INTEGER"),
|
||||
("faces", "quality", "REAL"),
|
||||
] {
|
||||
if !has_column(conn, table, column)? {
|
||||
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {decl}"))?;
|
||||
@@ -555,16 +562,19 @@ fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool, Cata
|
||||
Ok(stmt.exists(rusqlite::params![table, column])?)
|
||||
}
|
||||
|
||||
/// `f.crop`, or a `NULL` standing in for it.
|
||||
/// `f.<column>`, or a `NULL` standing in for it.
|
||||
///
|
||||
/// A shard downloaded from a peer is opened **read-only** and cannot be
|
||||
/// upgraded, so one written before crops existed has to be read as it is rather
|
||||
/// than repaired. Selecting a literal keeps the column count the same, which is
|
||||
/// what lets [`read_shared_face`] stay a single function.
|
||||
fn crop_column(conn: &Connection) -> &'static str {
|
||||
match has_column(conn, "faces", "crop") {
|
||||
Ok(true) => "f.crop",
|
||||
_ => "NULL",
|
||||
/// upgraded, so one written before a column existed has to be read as it is
|
||||
/// rather than repaired. Selecting a literal keeps the column count the same,
|
||||
/// which is what lets [`read_shared_face`] stay a single function.
|
||||
///
|
||||
/// `column` is one of this module's own names, never anything read from
|
||||
/// outside, which is what makes formatting it into SQL acceptable.
|
||||
fn column_or_null(conn: &Connection, column: &'static str) -> String {
|
||||
match has_column(conn, "faces", column) {
|
||||
Ok(true) => format!("f.{column}"),
|
||||
_ => "NULL".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,7 +647,8 @@ pub fn export_to_shards_reporting(
|
||||
continue;
|
||||
}
|
||||
let mut fq = conn.prepare(
|
||||
"SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px, crop
|
||||
"SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px, crop,
|
||||
quality
|
||||
FROM faces WHERE image_id = ?1 AND model_id = ?2",
|
||||
)?;
|
||||
let faces: Vec<SharedFace> = fq
|
||||
@@ -654,6 +665,7 @@ pub fn export_to_shards_reporting(
|
||||
embedding: r.get(6)?,
|
||||
crop_px: r.get::<_, f64>(7)? as f32,
|
||||
crop: r.get::<_, Option<Vec<u8>>>(8)?.unwrap_or_default(),
|
||||
quality: r.get::<_, Option<f64>>(9)?.map(|q| q as f32),
|
||||
})
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
@@ -710,6 +722,14 @@ pub fn import_from_shards(
|
||||
let Some((faces, edge)) = store.get_image(file_id as u64, model_id)? else {
|
||||
continue;
|
||||
};
|
||||
// A peer that embedded before the quality was kept has done work this
|
||||
// device cannot finish: the number exists only at embedding time, and
|
||||
// adopting the faces would write the run marker that keeps them from
|
||||
// ever being measured (schema V14). Left for this device's own pass —
|
||||
// or for the peer's, whose re-export replaces these.
|
||||
if faces.iter().any(|f| f.quality.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let local: Vec<crate::faces::DetectedFace> = faces
|
||||
.into_iter()
|
||||
.map(|f| crate::faces::DetectedFace {
|
||||
@@ -721,6 +741,7 @@ pub fn import_from_shards(
|
||||
confidence: f.confidence,
|
||||
embedding: f.embedding,
|
||||
crop_px: f.crop_px,
|
||||
quality: f.quality,
|
||||
model_id: f.model_id,
|
||||
// A peer that indexed before crops existed sends none, and the
|
||||
// reader falls back to the proxy exactly as it does for a face
|
||||
@@ -766,6 +787,7 @@ fn read_shared_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<SharedFace> {
|
||||
embedding: r.get(8)?,
|
||||
crop_px: r.get::<_, f64>(9)? as f32,
|
||||
crop: r.get::<_, Option<Vec<u8>>>(10)?.unwrap_or_default(),
|
||||
quality: r.get::<_, Option<f64>>(11)?.map(|q| q as f32),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -849,7 +871,11 @@ CREATE TABLE IF NOT EXISTS faces (
|
||||
crop_px REAL NOT NULL,
|
||||
-- The face, cut out. NULL where the face was found before crops were kept,
|
||||
-- or adopted from a peer that did not have one.
|
||||
crop BLOB
|
||||
crop BLOB,
|
||||
-- Length of the raw embedding (`faces::DetectedFace::quality`). NULL from
|
||||
-- a build that did not keep it, and a face the receiving device will not
|
||||
-- adopt -- see `import_from_shards`.
|
||||
quality REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS faces_file ON faces(file_id, model_id);
|
||||
|
||||
@@ -908,6 +934,7 @@ mod tests {
|
||||
confidence: 0.87,
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(17.5),
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
}
|
||||
@@ -925,6 +952,7 @@ mod tests {
|
||||
assert_eq!(edge, 1024);
|
||||
assert_eq!(faces[0].embedding.len(), 1024);
|
||||
assert!((faces[0].crop_px - 180.0).abs() < 1e-3);
|
||||
assert_eq!(faces[0].quality, Some(17.5));
|
||||
}
|
||||
|
||||
/// The case the run marker exists for, carried across the wire: an image
|
||||
@@ -1115,6 +1143,7 @@ mod catalog_round_trip {
|
||||
confidence: 0.9,
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(20.0),
|
||||
model_id: "w600k_mbf".into(),
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
@@ -1162,9 +1191,47 @@ mod catalog_round_trip {
|
||||
let got = faces::for_image(&b, dr_types::ImageId(90)).unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert!((got[0].crop_px - 180.0).abs() < 1e-3);
|
||||
assert_eq!(got[0].quality, Some(20.0));
|
||||
assert!((got[0].landmarks[2].0 - 0.15).abs() < 1e-5);
|
||||
let emb = faces::embeddings(&b, "w600k_mbf").unwrap();
|
||||
assert!(emb.iter().any(|(_, _, blob, _)| blob[0] == 1));
|
||||
assert!(emb.iter().any(|e| e.embedding[0] == 1));
|
||||
}
|
||||
|
||||
/// A face a peer embedded without measuring it is work this device
|
||||
/// cannot finish, and adopting it would write the marker that stops it
|
||||
/// ever being measured. The image stays outstanding instead.
|
||||
#[test]
|
||||
fn a_peers_unmeasured_faces_are_left_for_this_device_to_index() {
|
||||
let b = device(&[(90, 5001), (91, 5002)]);
|
||||
let mut store = FaceShardStore::open(&tempdir("unmeasured")).unwrap();
|
||||
let shared = |file_id: u64, quality: Option<f32>| SharedFace {
|
||||
file_id,
|
||||
model_id: "w600k_mbf".into(),
|
||||
x: 0.1,
|
||||
y: 0.2,
|
||||
w: 0.15,
|
||||
h: 0.2,
|
||||
landmarks: vec![1; 40],
|
||||
confidence: 0.87,
|
||||
embedding: vec![1; 1024],
|
||||
crop_px: 180.0,
|
||||
quality,
|
||||
crop: Vec::new(),
|
||||
};
|
||||
store
|
||||
.put_image(5001, "w600k_mbf", 2560, &[shared(5001, None)])
|
||||
.unwrap();
|
||||
store
|
||||
.put_image(5002, "w600k_mbf", 2560, &[shared(5002, Some(19.0))])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(import_from_shards(&b, &store, "w600k_mbf").unwrap(), 1);
|
||||
let cov = faces::coverage(&b, "w600k_mbf").unwrap();
|
||||
assert_eq!(cov.indexed, 1);
|
||||
assert_eq!(cov.outstanding(), 1, "the unmeasured image was adopted");
|
||||
assert!(faces::for_image(&b, dr_types::ImageId(90))
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1187,7 +1254,7 @@ mod catalog_round_trip {
|
||||
"a peer's copy replaced work this device had already done"
|
||||
);
|
||||
let emb = faces::embeddings(&b, "w600k_mbf").unwrap();
|
||||
assert_eq!(emb[0].2[0], 9, "B's own embedding was overwritten");
|
||||
assert_eq!(emb[0].embedding[0], 9, "B's own embedding was overwritten");
|
||||
}
|
||||
|
||||
/// A device holding a subset of the library takes only its own part.
|
||||
@@ -1281,6 +1348,7 @@ mod catalog_round_trip {
|
||||
confidence: 0.87,
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: None,
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
}
|
||||
@@ -1430,6 +1498,10 @@ mod catalog_round_trip {
|
||||
let (faces, _) = store.get_image(77, "w600k_mbf").unwrap().unwrap();
|
||||
assert_eq!(faces.len(), 1);
|
||||
assert!(faces[0].crop.is_empty(), "a crop was invented from nowhere");
|
||||
assert_eq!(
|
||||
faces[0].quality, None,
|
||||
"a quality was invented from nowhere"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
+298
-22
@@ -61,10 +61,22 @@ pub struct DetectedFace {
|
||||
/// Five `(x, y)` pairs, normalised the same way.
|
||||
pub landmarks: [(f32, f32); 5],
|
||||
pub confidence: f32,
|
||||
/// 512 × f16, L2-normalised — `dr_face::Embedding::to_f16_bytes`.
|
||||
/// 512 × f16, the raw model output — `dr_face::Embedded::to_f16_bytes`.
|
||||
///
|
||||
/// Raw rather than unit length, so the length ([`Self::quality`]) is in
|
||||
/// the blob and not only beside it. Readers re-normalise on load.
|
||||
pub embedding: Vec<u8>,
|
||||
/// Source pixels across the aligned crop (docs/faces.md §7).
|
||||
pub crop_px: f32,
|
||||
/// Length of the raw embedding before normalisation — the model's own
|
||||
/// reading of how recognisable the crop was, and the gate on whether
|
||||
/// this face may be compared *against* (`dr_face::MIN_GALLERY_QUALITY`).
|
||||
///
|
||||
/// `None` where it was never measured: a face indexed, here or by a peer,
|
||||
/// before raw vectors were stored. The unit vector those builds kept has
|
||||
/// no length left to read, so the only way to measure one is to embed it
|
||||
/// again (schema V14).
|
||||
pub quality: Option<f32>,
|
||||
/// Which model produced the embedding. Comparing across models is the one
|
||||
/// mistake that yields plausible garbage rather than an error.
|
||||
pub model_id: String,
|
||||
@@ -94,6 +106,9 @@ pub struct Face {
|
||||
pub landmarks: [(f32, f32); 5],
|
||||
pub confidence: f32,
|
||||
pub crop_px: f32,
|
||||
/// See [`DetectedFace::quality`]. `None` for a face indexed before it was
|
||||
/// recorded.
|
||||
pub quality: Option<f32>,
|
||||
pub model_id: String,
|
||||
/// `None` when the face belongs to no one yet.
|
||||
pub person: Option<PersonId>,
|
||||
@@ -143,6 +158,16 @@ pub use dr_face::Calibration;
|
||||
/// part of the frame: a re-index with a better model must not discard the
|
||||
/// user's labelling (FR-CULL-10). Matching is by box overlap, since the face is
|
||||
/// in the same place even when the box moves a little.
|
||||
///
|
||||
/// **Every model's faces are replaced, and every other model's marker goes
|
||||
/// with them.** An image holds the faces of whichever pipeline looked at it
|
||||
/// last, never a mixture — two detectors drawing boxes over the same face is
|
||||
/// not two opinions but a duplicate. So the replacement is unconditional on
|
||||
/// `model_id`, and the run markers of the pipelines whose faces were just
|
||||
/// removed are dropped too: a marker that says "done" over an image with
|
||||
/// none of that model's faces is exactly the state that made the V12 repair
|
||||
/// necessary, and a user who switches their detector back would otherwise
|
||||
/// find those photographs permanently empty.
|
||||
pub fn record_detections(
|
||||
conn: &Connection,
|
||||
image_id: ImageId,
|
||||
@@ -177,6 +202,10 @@ pub fn record_detections(
|
||||
}
|
||||
|
||||
tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?;
|
||||
tx.execute(
|
||||
"DELETE FROM face_index WHERE image_id = ?1 AND model_id != ?2",
|
||||
rusqlite::params![image_id.0 as i64, model_id],
|
||||
)?;
|
||||
|
||||
let now = now_secs();
|
||||
let mut ids = Vec::with_capacity(faces.len());
|
||||
@@ -184,8 +213,8 @@ pub fn record_detections(
|
||||
tx.execute(
|
||||
"INSERT INTO faces
|
||||
(image_id, x, y, w, h, landmarks, detector_confidence,
|
||||
embedding, crop_px, model_id, detected_at, crop)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
embedding, crop_px, model_id, detected_at, crop, quality)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
rusqlite::params![
|
||||
image_id.0 as i64,
|
||||
f.x as f64,
|
||||
@@ -202,6 +231,7 @@ pub fn record_detections(
|
||||
// the database rather than two the readers each have to know
|
||||
// about.
|
||||
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
|
||||
f.quality.map(f64::from),
|
||||
],
|
||||
)?;
|
||||
let id = FaceId(tx.last_insert_rowid() as u64);
|
||||
@@ -251,10 +281,113 @@ pub fn record_detections(
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// A face embedded again from its stored landmarks: the new vector and its
|
||||
/// length. What the measuring pass hands back per face.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Measurement {
|
||||
pub face: FaceId,
|
||||
/// 512 × f16, raw — `dr_face::Embedded::to_f16_bytes`.
|
||||
pub embedding: Vec<u8>,
|
||||
pub quality: f32,
|
||||
}
|
||||
|
||||
/// Write fresh embeddings over faces that were found before their quality was
|
||||
/// kept, and re-mark the image as indexed.
|
||||
///
|
||||
/// The cheaper half of what `record_detections` does, for the case schema V14
|
||||
/// created: the boxes and landmarks are right, the identities are the user's
|
||||
/// work, and only the vector needs doing again. Updating in place is what
|
||||
/// keeps `face_person` and the face ids exactly as they were -- a
|
||||
/// re-detection would carry confirmations across by box overlap and lose
|
||||
/// every suggestion, for no gain.
|
||||
///
|
||||
/// `dropped` are faces whose landmarks turned out to be degenerate -- the warp
|
||||
/// could not be built from them. Deleted here, as detection would have refused
|
||||
/// to store them (`dr_ui::faces::index_proxy`), and because a face left with
|
||||
/// no reading would put its image back on the measuring pass's list on every
|
||||
/// sweep, at the cost of an original each time.
|
||||
///
|
||||
/// The run marker is re-written with a fresh time, and that is not
|
||||
/// bookkeeping: `face_shard::export_to_shards` re-exports an image whose
|
||||
/// marker is newer than the store's copy, which is how the measured vectors
|
||||
/// reach the other devices.
|
||||
pub fn record_measurements(
|
||||
conn: &Connection,
|
||||
image_id: ImageId,
|
||||
model_id: &str,
|
||||
source_edge: u32,
|
||||
measured: &[Measurement],
|
||||
dropped: &[FaceId],
|
||||
) -> Result<(), CatalogError> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for m in measured {
|
||||
tx.execute(
|
||||
"UPDATE faces SET embedding = ?2, quality = ?3 WHERE id = ?1",
|
||||
rusqlite::params![m.face.0 as i64, m.embedding, f64::from(m.quality)],
|
||||
)?;
|
||||
}
|
||||
for f in dropped {
|
||||
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
|
||||
}
|
||||
let remaining: i64 = tx.query_row(
|
||||
"SELECT COUNT(*) FROM faces WHERE image_id = ?1 AND model_id = ?2",
|
||||
rusqlite::params![image_id.0 as i64, model_id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO face_index (image_id, model_id, indexed_at, faces_found, source_edge)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(image_id, model_id) DO UPDATE SET
|
||||
indexed_at = excluded.indexed_at,
|
||||
faces_found = excluded.faces_found,
|
||||
source_edge = excluded.source_edge",
|
||||
rusqlite::params![
|
||||
image_id.0 as i64,
|
||||
model_id,
|
||||
now_secs(),
|
||||
remaining,
|
||||
source_edge as i64,
|
||||
],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The faces on one image that have no quality reading yet.
|
||||
///
|
||||
/// The measuring pass's per-image work: every face this model found whose
|
||||
/// vector was stored as a unit one (schema V14), with the landmarks the
|
||||
/// warp is rebuilt from.
|
||||
pub fn unmeasured_on_image(
|
||||
conn: &Connection,
|
||||
image_id: ImageId,
|
||||
model_id: &str,
|
||||
) -> Result<Vec<Face>, CatalogError> {
|
||||
Ok(for_image(conn, image_id)?
|
||||
.into_iter()
|
||||
.filter(|f| f.model_id == model_id && f.quality.is_none())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// How many of a model's faces have no quality reading.
|
||||
///
|
||||
/// What the measuring pass has left to do, for a screen that wants to say so.
|
||||
pub fn faces_unmeasured(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM faces WHERE model_id = ?1 AND quality IS NULL",
|
||||
[model_id],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.map(|n| n as u64)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// How much of the library has been through face detection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Coverage {
|
||||
/// Images that are candidates at all — present, not trashed.
|
||||
/// Images that are candidates at all — present, not trashed, and not the
|
||||
/// shadowed half of a RAW+JPEG pair. The same population every sweep's
|
||||
/// work list is drawn from; see [`coverage`] for why that matters.
|
||||
pub images: u64,
|
||||
/// Images this model has actually looked at.
|
||||
pub indexed: u64,
|
||||
@@ -292,9 +425,21 @@ impl Coverage {
|
||||
/// progress figure. Answerable only because [`record_detections`] writes a run
|
||||
/// marker: counting `faces` rows would report how many faces exist, which is a
|
||||
/// different number and never reaches the image count.
|
||||
///
|
||||
/// # Shadowed images are not candidates, and the denominator has to agree
|
||||
///
|
||||
/// A shadowed image is the JPEG half of a RAW+JPEG pair. It is not a separate
|
||||
/// photograph — the grid does not show it, and every sweep that builds a work
|
||||
/// list excludes it. Counting it here anyway is not a rounding error: on the
|
||||
/// reference library it put 4,424 images into the denominator that no pass is
|
||||
/// permitted to touch, so "4,593 outstanding" had a floor of 4,424 that no
|
||||
/// amount of indexing could ever bring down, and [`Coverage::is_complete`]
|
||||
/// could never once return true. A progress figure that cannot reach its own
|
||||
/// target reads exactly like a stuck job, which is what it was taken for.
|
||||
pub fn coverage(conn: &Connection, model_id: &str) -> Result<Coverage, CatalogError> {
|
||||
let images: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM images WHERE trashed_at IS NULL",
|
||||
"SELECT COUNT(*) FROM images
|
||||
WHERE trashed_at IS NULL AND shadowed_by IS NULL",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
@@ -302,7 +447,8 @@ pub fn coverage(conn: &Connection, model_id: &str) -> Result<Coverage, CatalogEr
|
||||
"SELECT COUNT(*), COALESCE(SUM(faces_found = 0), 0), COALESCE(SUM(faces_found), 0)
|
||||
FROM face_index fi
|
||||
JOIN images i ON i.id = fi.image_id
|
||||
WHERE fi.model_id = ?1 AND i.trashed_at IS NULL",
|
||||
WHERE fi.model_id = ?1
|
||||
AND i.trashed_at IS NULL AND i.shadowed_by IS NULL",
|
||||
[model_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)?;
|
||||
@@ -351,7 +497,7 @@ pub fn for_image(conn: &Connection, image_id: ImageId) -> Result<Vec<Face>, Cata
|
||||
let mut q = conn.prepare(
|
||||
"SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks,
|
||||
f.detector_confidence, f.crop_px, f.model_id,
|
||||
fp.person_id, fp.probability, fp.confirmed
|
||||
fp.person_id, fp.probability, fp.confirmed, f.quality
|
||||
FROM faces f
|
||||
LEFT JOIN face_person fp ON fp.face_id = f.id
|
||||
WHERE f.image_id = ?1
|
||||
@@ -378,9 +524,18 @@ pub fn unassigned(conn: &Connection, model_id: &str) -> Result<Vec<FaceId>, Cata
|
||||
|
||||
/// One face's stored embedding, as the clustering pass consumes it.
|
||||
///
|
||||
/// A named type rather than a tuple because it crosses a crate boundary and
|
||||
/// "the third element" is not a thing anyone should have to remember.
|
||||
pub type StoredEmbedding = (FaceId, ImageId, Vec<u8>, f32);
|
||||
/// A struct rather than a tuple because it crosses a crate boundary and "the
|
||||
/// fourth element" is not a thing anyone should have to remember.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct StoredEmbedding {
|
||||
pub face: FaceId,
|
||||
pub image: ImageId,
|
||||
/// 512 × f16 — `dr_face::Embedding::from_f16_bytes` reads it.
|
||||
pub embedding: Vec<u8>,
|
||||
pub crop_px: f32,
|
||||
/// See [`DetectedFace::quality`].
|
||||
pub quality: Option<f32>,
|
||||
}
|
||||
|
||||
/// Embeddings for clustering, oldest first so the pass is deterministic.
|
||||
///
|
||||
@@ -389,16 +544,17 @@ pub type StoredEmbedding = (FaceId, ImageId, Vec<u8>, f32);
|
||||
/// would double the memory of the one operation that holds them all at once.
|
||||
pub fn embeddings(conn: &Connection, model_id: &str) -> Result<Vec<StoredEmbedding>, CatalogError> {
|
||||
let mut q = conn.prepare(
|
||||
"SELECT id, image_id, embedding, crop_px FROM faces
|
||||
"SELECT id, image_id, embedding, crop_px, quality FROM faces
|
||||
WHERE model_id = ?1 ORDER BY id",
|
||||
)?;
|
||||
let rows = q.query_map([model_id], |r| {
|
||||
Ok((
|
||||
FaceId(r.get::<_, i64>(0)? as u64),
|
||||
ImageId(r.get::<_, i64>(1)? as u64),
|
||||
r.get::<_, Vec<u8>>(2)?,
|
||||
r.get::<_, f64>(3)? as f32,
|
||||
))
|
||||
Ok(StoredEmbedding {
|
||||
face: FaceId(r.get::<_, i64>(0)? as u64),
|
||||
image: ImageId(r.get::<_, i64>(1)? as u64),
|
||||
embedding: r.get::<_, Vec<u8>>(2)?,
|
||||
crop_px: r.get::<_, f64>(3)? as f32,
|
||||
quality: r.get::<_, Option<f64>>(4)?.map(|q| q as f32),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||||
}
|
||||
@@ -628,7 +784,7 @@ pub fn for_person(
|
||||
let mut q = conn.prepare(
|
||||
"SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks,
|
||||
f.detector_confidence, f.crop_px, f.model_id,
|
||||
fp.person_id, fp.probability, fp.confirmed
|
||||
fp.person_id, fp.probability, fp.confirmed, f.quality
|
||||
FROM faces f
|
||||
JOIN face_person fp ON fp.face_id = f.id
|
||||
WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1)
|
||||
@@ -879,6 +1035,7 @@ fn read_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<Face> {
|
||||
landmarks: blob_to_landmarks(&r.get::<_, Vec<u8>>(6)?),
|
||||
confidence: r.get::<_, f64>(7)? as f32,
|
||||
crop_px: r.get::<_, f64>(8)? as f32,
|
||||
quality: r.get::<_, Option<f64>>(13)?.map(|q| q as f32),
|
||||
model_id: r.get(9)?,
|
||||
person: person.map(|p| PersonId(p as u64)),
|
||||
probability: r.get::<_, Option<f64>>(11)?.unwrap_or(0.0) as f32,
|
||||
@@ -1001,6 +1158,7 @@ mod tests {
|
||||
confidence: 0.9,
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(10.0 + f32::from(seed)),
|
||||
model_id: "w600k_mbf".into(),
|
||||
crop: Vec::new(),
|
||||
}
|
||||
@@ -1026,6 +1184,80 @@ mod tests {
|
||||
assert!((got[0].crop_px - 180.0).abs() < 1e-3);
|
||||
assert!((got[0].landmarks[2].1 - 0.2).abs() < 1e-5);
|
||||
assert!(got[0].person.is_none());
|
||||
// Both readers carry the quality, and the one for the grouping pass
|
||||
// carries it as the option it is.
|
||||
let mut qualities: Vec<Option<f32>> = got.iter().map(|f| f.quality).collect();
|
||||
qualities.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
assert_eq!(qualities, vec![Some(11.0), Some(12.0)]);
|
||||
let stored = embeddings(&c, "w600k_mbf").unwrap();
|
||||
assert_eq!(stored.len(), 2);
|
||||
assert_eq!(stored[0].quality, Some(11.0), "oldest first");
|
||||
assert_eq!(stored[1].quality, Some(12.0));
|
||||
}
|
||||
|
||||
/// The measuring pass writes over the vector and nothing else: the face
|
||||
/// keeps its id, its box and whoever the user said it was.
|
||||
#[test]
|
||||
fn measuring_replaces_the_vector_and_keeps_the_identity() {
|
||||
let c = db();
|
||||
let img = image(&c, 1);
|
||||
let unmeasured = DetectedFace {
|
||||
quality: None,
|
||||
..face(1)
|
||||
};
|
||||
let ids = record_detections(
|
||||
&c,
|
||||
img,
|
||||
"w600k_mbf",
|
||||
1024,
|
||||
&[unmeasured.clone(), unmeasured],
|
||||
)
|
||||
.unwrap();
|
||||
let person = create_person(&c, "Anna").unwrap();
|
||||
confirm(&c, ids[0], person).unwrap();
|
||||
assert_eq!(faces_unmeasured(&c, "w600k_mbf").unwrap(), 2);
|
||||
assert_eq!(unmeasured_on_image(&c, img, "w600k_mbf").unwrap().len(), 2);
|
||||
let marked_at: i64 = c
|
||||
.query_row("SELECT indexed_at FROM face_index", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
c.execute("UPDATE face_index SET indexed_at = indexed_at - 100", [])
|
||||
.unwrap();
|
||||
|
||||
record_measurements(
|
||||
&c,
|
||||
img,
|
||||
"w600k_mbf",
|
||||
6000,
|
||||
&[Measurement {
|
||||
face: ids[0],
|
||||
embedding: vec![9; 1024],
|
||||
quality: 21.5,
|
||||
}],
|
||||
&[ids[1]],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let got = for_image(&c, img).unwrap();
|
||||
assert_eq!(got.len(), 1, "the degenerate face was kept");
|
||||
assert_eq!(got[0].id, ids[0]);
|
||||
assert_eq!(got[0].quality, Some(21.5));
|
||||
assert_eq!(got[0].person, Some(person));
|
||||
assert!(got[0].confirmed);
|
||||
let e = embeddings(&c, "w600k_mbf").unwrap();
|
||||
assert_eq!(e[0].embedding[0], 9);
|
||||
assert_eq!(faces_unmeasured(&c, "w600k_mbf").unwrap(), 0);
|
||||
|
||||
// The marker says one face at the native edge, and is fresh — which
|
||||
// is what makes the sync export it again.
|
||||
let (found, edge, at): (i64, i64, i64) = c
|
||||
.query_row(
|
||||
"SELECT faces_found, source_edge, indexed_at FROM face_index",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((found, edge), (1, 6000));
|
||||
assert!(at >= marked_at, "the marker was not refreshed");
|
||||
}
|
||||
|
||||
/// Re-detection is coalesced per image, so it must replace rather than
|
||||
@@ -1345,6 +1577,30 @@ mod tests {
|
||||
assert!((cov.fraction() - 2.0 / 3.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_leaves_out_the_shadowed_half_of_a_raw_jpeg_pair() {
|
||||
let c = db();
|
||||
let raw = image(&c, 1);
|
||||
let jpeg = image(&c, 2);
|
||||
// The JPEG beside a RAW is not a separate photograph, and no sweep
|
||||
// will ever index one.
|
||||
c.execute(
|
||||
"UPDATE images SET shadowed_by = ?1 WHERE id = ?2",
|
||||
rusqlite::params![raw.0 as i64, jpeg.0 as i64],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
record_detections(&c, raw, "w600k_mbf", 2560, &[face(1)]).unwrap();
|
||||
|
||||
let cov = coverage(&c, "w600k_mbf").unwrap();
|
||||
// Counting the shadowed one would leave a permanent remainder that no
|
||||
// amount of indexing could bring down -- 4,424 of them on the
|
||||
// reference library, which read as a stuck job.
|
||||
assert_eq!(cov.images, 1);
|
||||
assert_eq!(cov.outstanding(), 0);
|
||||
assert!(cov.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_indexing_one_image_updates_its_marker_rather_than_adding_a_second() {
|
||||
let c = db();
|
||||
@@ -1366,6 +1622,25 @@ mod tests {
|
||||
assert_eq!(edge, 2048, "the marker should record the newer proxy");
|
||||
}
|
||||
|
||||
/// A user who switches detector and back must not find the images the
|
||||
/// second pipeline visited reported as done under the first with no
|
||||
/// faces behind the marker.
|
||||
#[test]
|
||||
fn re_indexing_under_another_model_drops_the_first_models_marker() {
|
||||
let c = db();
|
||||
let img = image(&c, 1);
|
||||
record_detections(&c, img, "w600k_mbf", 2048, &[face(1)]).unwrap();
|
||||
record_detections(&c, img, "scrfd_2.5g+w600k_mbf", 2048, &[face(2), face(3)]).unwrap();
|
||||
|
||||
assert!(is_indexed(&c, img, "scrfd_2.5g+w600k_mbf").unwrap());
|
||||
assert!(
|
||||
!is_indexed(&c, img, "w600k_mbf").unwrap(),
|
||||
"the first pipeline's marker outlived its faces"
|
||||
);
|
||||
assert_eq!(for_image(&c, img).unwrap().len(), 2);
|
||||
assert_eq!(coverage(&c, "w600k_mbf").unwrap().outstanding(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_marker_puts_that_image_back_in_the_queue() {
|
||||
let c = db();
|
||||
@@ -1411,10 +1686,11 @@ mod tests {
|
||||
record_detections(&c, img, "w600k_mbf", 1024, &[face(7)]).unwrap();
|
||||
let e = embeddings(&c, "w600k_mbf").unwrap();
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].1, img);
|
||||
assert_eq!(e[0].2.len(), 1024);
|
||||
assert_eq!(e[0].2[0], 7);
|
||||
assert!((e[0].3 - 180.0).abs() < 1e-3);
|
||||
assert_eq!(e[0].image, img);
|
||||
assert_eq!(e[0].embedding.len(), 1024);
|
||||
assert_eq!(e[0].embedding[0], 7);
|
||||
assert!((e[0].crop_px - 180.0).abs() < 1e-3);
|
||||
assert_eq!(e[0].quality, Some(17.0));
|
||||
}
|
||||
|
||||
// ── stored crops ──────────────────────────────────────────────────────
|
||||
|
||||
+509
-41
@@ -10,8 +10,14 @@
|
||||
//! table absorb the redundancy.
|
||||
//! - **Priority shared with the GPU scheduler** (ARCH §5.3), so one notion of
|
||||
//! urgency governs the whole app and visible work always preempts bulk work.
|
||||
//!
|
||||
//! Nothing in this file runs a job. [`crate::runner`] is the other half — the
|
||||
//! one that claims from this table, does the work through a handler, and
|
||||
//! reports back. Worth knowing because for a long time it did not exist: every
|
||||
//! producer called [`enqueue`] and nothing ever called [`claim_next`], so the
|
||||
//! table only ever grew.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
|
||||
use crate::error::CatalogError;
|
||||
|
||||
@@ -48,6 +54,21 @@ pub enum JobKind {
|
||||
}
|
||||
|
||||
impl JobKind {
|
||||
/// Every kind, so code that has to enumerate them cannot quietly miss one
|
||||
/// that was added later. A `match` would catch that; a hand-written array
|
||||
/// at each call site would not.
|
||||
pub const ALL: [JobKind; 9] = [
|
||||
JobKind::ScanFolder,
|
||||
JobKind::ExtractMetadata,
|
||||
JobKind::Thumbnail,
|
||||
JobKind::ReadSidecar,
|
||||
JobKind::WriteSidecar,
|
||||
JobKind::ContentHash,
|
||||
JobKind::FetchPreview,
|
||||
JobKind::FetchOriginal,
|
||||
JobKind::DetectFaces,
|
||||
];
|
||||
|
||||
fn from_i64(v: i64) -> Option<Self> {
|
||||
Some(match v {
|
||||
0 => JobKind::ScanFolder,
|
||||
@@ -68,6 +89,16 @@ impl JobKind {
|
||||
pub fn is_network(self) -> bool {
|
||||
matches!(self, JobKind::FetchPreview | JobKind::FetchOriginal)
|
||||
}
|
||||
|
||||
/// Whether `subject_id` names a row in `images`.
|
||||
///
|
||||
/// Every kind but one is per-photograph. `ScanFolder`'s subject is a
|
||||
/// *folder*, and the two id spaces are unrelated — so anything that joins
|
||||
/// `subject_id` against `images` has to exclude it, or it will read one
|
||||
/// table's ids as another's and act on the answer.
|
||||
pub fn subject_is_image(self) -> bool {
|
||||
!matches!(self, JobKind::ScanFolder)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scheduling class, matching the GPU tile scheduler (ARCH §5.3).
|
||||
@@ -86,6 +117,22 @@ pub enum Priority {
|
||||
Interactive = 2,
|
||||
}
|
||||
|
||||
impl Priority {
|
||||
/// Read back from the stored column.
|
||||
///
|
||||
/// An unrecognised value reads as `Background` rather than failing: a
|
||||
/// priority is a hint about ordering, and refusing to run a job because
|
||||
/// its urgency is spelled oddly would be a worse answer than running it
|
||||
/// last.
|
||||
fn from_i64(v: i64) -> Self {
|
||||
match v {
|
||||
2 => Priority::Interactive,
|
||||
1 => Priority::Prefetch,
|
||||
_ => Priority::Background,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifecycle state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i64)]
|
||||
@@ -156,52 +203,115 @@ pub fn enqueue(
|
||||
/// Claim the next runnable job, highest priority first.
|
||||
///
|
||||
/// `now` is passed rather than read from the clock so backoff is testable.
|
||||
/// Claiming marks the row `Running` in the same transaction as the read, so
|
||||
/// two workers cannot take the same job.
|
||||
pub fn claim_next(conn: &Connection, now: i64) -> Result<Option<Job>, CatalogError> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
claim(conn, now, None)
|
||||
}
|
||||
|
||||
let job = tx
|
||||
.query_row(
|
||||
"SELECT id, kind, subject_id, priority, attempts, payload
|
||||
FROM jobs
|
||||
WHERE state = 0 AND not_before <= ?1
|
||||
ORDER BY priority DESC, id ASC
|
||||
LIMIT 1",
|
||||
[now],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, Option<i64>>(2)?,
|
||||
r.get::<_, i64>(3)?,
|
||||
r.get::<_, i64>(4)?,
|
||||
r.get::<_, Option<String>>(5)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
/// Claim the next runnable job of one of `kinds`.
|
||||
///
|
||||
/// What lets a runner take only the work it can actually do. A device with no
|
||||
/// connector must leave `FetchOriginal` rows alone rather than claim them and
|
||||
/// fail them five times each with backoff; a runner gated onto an unmetered
|
||||
/// network (FR-NC-6) passes the local kinds only and leaves the transfers
|
||||
/// where they are. Neither is expressible by filtering *after* a claim,
|
||||
/// because the claim has already marked the row `Running`.
|
||||
///
|
||||
/// An empty list claims nothing, which is the honest reading of "there is
|
||||
/// nothing this worker can do".
|
||||
pub fn claim_next_matching(
|
||||
conn: &Connection,
|
||||
now: i64,
|
||||
kinds: &[JobKind],
|
||||
) -> Result<Option<Job>, CatalogError> {
|
||||
if kinds.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
claim(conn, now, Some(kinds))
|
||||
}
|
||||
|
||||
let Some((id, kind, subject_id, priority, attempts, payload)) = job else {
|
||||
/// The claim, as one statement.
|
||||
///
|
||||
/// # Why this is not a transaction around a read and a write
|
||||
///
|
||||
/// It used to be, and under two connections that is not safe in the way it
|
||||
/// looks. A deferred transaction takes a read lock for the `SELECT` and only
|
||||
/// tries to upgrade at the `UPDATE`; with WAL, a second worker that read the
|
||||
/// same snapshot gets `SQLITE_BUSY_SNAPSHOT` on its write — an error a busy
|
||||
/// handler cannot retry away, because the fix is to roll back and start over.
|
||||
/// So the queue was correct only in the sense that the loser failed loudly.
|
||||
///
|
||||
/// `UPDATE ... WHERE id = (SELECT ...) RETURNING` is one statement, so it is
|
||||
/// one implicit transaction that takes the write lock immediately. Two workers
|
||||
/// serialise, the loser waits out its busy timeout rather than erroring, and
|
||||
/// neither can see a row the other is already holding.
|
||||
fn claim(
|
||||
conn: &Connection,
|
||||
now: i64,
|
||||
kinds: Option<&[JobKind]>,
|
||||
) -> Result<Option<Job>, CatalogError> {
|
||||
// `now` first, then the kinds, matching the order the placeholders appear
|
||||
// in the text below.
|
||||
let mut args: Vec<i64> = vec![now];
|
||||
let filter = match kinds {
|
||||
None => String::new(),
|
||||
Some(kinds) => {
|
||||
// Built from the kind *count*, never from anything a user typed —
|
||||
// the same discipline `collections::descendants` uses, since
|
||||
// `carray` is not compiled in.
|
||||
let placeholders = std::iter::repeat_n("?", kinds.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
args.extend(kinds.iter().map(|k| *k as i64));
|
||||
format!(" AND kind IN ({placeholders})")
|
||||
}
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE jobs
|
||||
SET state = 1, attempts = attempts + 1
|
||||
WHERE id = (SELECT id
|
||||
FROM jobs
|
||||
WHERE state = 0 AND not_before <= ?{filter}
|
||||
ORDER BY priority DESC, id ASC
|
||||
LIMIT 1)
|
||||
RETURNING id, kind, subject_id, priority, attempts, payload"
|
||||
);
|
||||
|
||||
let claimed = conn
|
||||
.query_row(&sql, rusqlite::params_from_iter(args.iter()), |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, Option<i64>>(2)?,
|
||||
r.get::<_, i64>(3)?,
|
||||
r.get::<_, i64>(4)?,
|
||||
r.get::<_, Option<String>>(5)?,
|
||||
))
|
||||
})
|
||||
.optional()?;
|
||||
|
||||
let Some((id, kind, subject_id, priority, attempts, payload)) = claimed else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
tx.execute(
|
||||
"UPDATE jobs SET state = 1, attempts = attempts + 1 WHERE id = ?1",
|
||||
[id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
let Some(kind) = JobKind::from_i64(kind) else {
|
||||
// A row written by a build that knows a kind this one does not — a
|
||||
// downgrade, or a catalog synced from a newer device. Running it as
|
||||
// some other kind would be worse than not running it, so it is parked
|
||||
// where the next claim will not see it again.
|
||||
//
|
||||
// Answering `None` understates what is queued for one pass. The
|
||||
// alternative is a loop that keeps claiming the same unreadable row.
|
||||
abandon(conn, id, &format!("unknown job kind {kind}"))?;
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(Job {
|
||||
id,
|
||||
kind: JobKind::from_i64(kind).unwrap_or(JobKind::ExtractMetadata),
|
||||
kind,
|
||||
subject_id,
|
||||
priority: match priority {
|
||||
2 => Priority::Interactive,
|
||||
1 => Priority::Prefetch,
|
||||
_ => Priority::Background,
|
||||
},
|
||||
attempts: attempts + 1,
|
||||
priority: Priority::from_i64(priority),
|
||||
attempts,
|
||||
payload,
|
||||
}))
|
||||
}
|
||||
@@ -215,16 +325,53 @@ pub fn complete(conn: &Connection, id: i64) -> Result<(), CatalogError> {
|
||||
/// Job failed. Reschedules with backoff, or gives up past [`MAX_ATTEMPTS`].
|
||||
pub fn fail(conn: &Connection, job: &Job, now: i64, err: &str) -> Result<(), CatalogError> {
|
||||
if job.attempts >= MAX_ATTEMPTS {
|
||||
conn.execute(
|
||||
"UPDATE jobs SET state = 2, last_error = ?2 WHERE id = ?1",
|
||||
rusqlite::params![job.id, err],
|
||||
)?;
|
||||
abandon(conn, job.id, err)
|
||||
} else {
|
||||
conn.execute(
|
||||
"UPDATE jobs SET state = 0, not_before = ?2, last_error = ?3 WHERE id = ?1",
|
||||
rusqlite::params![job.id, now + backoff_seconds(job.attempts), err],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Give up on a job now, with no further retries.
|
||||
///
|
||||
/// For failures a retry cannot fix — the subject is gone, the payload is
|
||||
/// unreadable, the format is one this build does not know. Walking the whole
|
||||
/// retry ladder to reach a conclusion the first attempt already reached costs
|
||||
/// five wakeups and five backoffs per photograph, which on a phone is the
|
||||
/// difference the user notices.
|
||||
///
|
||||
/// The row is kept rather than deleted, because "this file failed and here is
|
||||
/// why" is something the user is entitled to see (NFR-ARCH-4).
|
||||
pub fn abandon(conn: &Connection, id: i64, err: &str) -> Result<(), CatalogError> {
|
||||
conn.execute(
|
||||
"UPDATE jobs SET state = 2, last_error = ?2 WHERE id = ?1",
|
||||
rusqlite::params![id, err],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put a claimed job back exactly as it was found.
|
||||
///
|
||||
/// For a worker that is being stopped rather than a job that is going wrong:
|
||||
/// the platform revoked the slot, the user left the screen. The attempt the
|
||||
/// claim consumed is given back, because nothing was learned about the file —
|
||||
/// without that, five backgroundings in a row would mark good work as failed.
|
||||
///
|
||||
/// Guarded on the claim still being *this* claim. There is no owner column, so
|
||||
/// `attempts` stands in for one: it is bumped by every claim, so the row only
|
||||
/// still reads `state = 1` with the caller's own attempt number while nobody
|
||||
/// else has taken it since. A worker that comes back after the recovery pass
|
||||
/// handed its job to someone else therefore changes nothing, rather than
|
||||
/// releasing a job another worker is in the middle of.
|
||||
pub fn release(conn: &Connection, job: &Job) -> Result<(), CatalogError> {
|
||||
conn.execute(
|
||||
"UPDATE jobs SET state = 0, attempts = max(0, attempts - 1)
|
||||
WHERE id = ?1 AND state = 1 AND attempts = ?2",
|
||||
rusqlite::params![job.id, job.attempts],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -232,11 +379,97 @@ pub fn fail(conn: &Connection, job: &Job, now: i64, err: &str) -> Result<(), Cat
|
||||
///
|
||||
/// A row left `Running` has no owner — the process that claimed it is gone.
|
||||
/// Called at startup, before any worker begins (FR-PLAT-AND-3).
|
||||
///
|
||||
/// The attempt the dead claim consumed is deliberately *not* refunded. A job
|
||||
/// that takes the process down with it is indistinguishable from one that
|
||||
/// fails, and the attempt counter is the only evidence that survives a death —
|
||||
/// without it a poison-pill job is reclaimed and re-run forever.
|
||||
pub fn recover_orphaned(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
let n = conn.execute("UPDATE jobs SET state = 0 WHERE state = 1", [])?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Delete jobs whose photograph is gone.
|
||||
///
|
||||
/// Coalescing keeps the table one row per unit of work, but nothing shrinks it
|
||||
/// when the work stops existing: a library that has been culled carries a
|
||||
/// thumbnail job for every photograph deleted since the last time anything
|
||||
/// looked. Each one would be claimed, run, and failed five times.
|
||||
///
|
||||
/// Only kinds whose subject really is an image ([`JobKind::subject_is_image`])
|
||||
/// are considered — `ScanFolder`'s subject is a folder id, and joining it
|
||||
/// against `images` would delete jobs by coincidence of numbering.
|
||||
///
|
||||
/// There is no foreign key to do this instead. `jobs.subject_id` deliberately
|
||||
/// references nothing: it means different tables for different kinds, and a
|
||||
/// constraint that is right for eight of nine kinds is not a constraint.
|
||||
pub fn reap_orphan_subjects(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
let kinds: Vec<i64> = JobKind::ALL
|
||||
.iter()
|
||||
.filter(|k| k.subject_is_image())
|
||||
.map(|k| *k as i64)
|
||||
.collect();
|
||||
let placeholders = std::iter::repeat_n("?", kinds.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
let n = conn.execute(
|
||||
&format!(
|
||||
"DELETE FROM jobs
|
||||
WHERE subject_id IS NOT NULL
|
||||
AND kind IN ({placeholders})
|
||||
AND NOT EXISTS (SELECT 1 FROM images WHERE images.id = jobs.subject_id)"
|
||||
),
|
||||
rusqlite::params_from_iter(kinds.iter()),
|
||||
)?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// How much is left, by state.
|
||||
///
|
||||
/// One query rather than a listing, because the caller is a progress line: a
|
||||
/// foreground service's notification has to say how much remains without
|
||||
/// reading a hundred thousand rows to find out (FR-PLAT-AND-4).
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Counts {
|
||||
/// Claimable now or after a backoff.
|
||||
pub pending: usize,
|
||||
/// Claimed by someone. After a clean start that is a live worker; before
|
||||
/// [`recover_orphaned`] it is a dead one.
|
||||
pub running: usize,
|
||||
/// Given up on, and kept so the user can see what failed and why.
|
||||
pub failed: usize,
|
||||
}
|
||||
|
||||
impl Counts {
|
||||
/// Work that is still going to happen.
|
||||
pub fn outstanding(&self) -> usize {
|
||||
self.pending + self.running
|
||||
}
|
||||
}
|
||||
|
||||
/// Count the queue by state.
|
||||
pub fn counts(conn: &Connection) -> Result<Counts, CatalogError> {
|
||||
// `sum` over no rows is NULL, not 0 — an empty queue would otherwise fail
|
||||
// to convert rather than counting nothing.
|
||||
let (pending, running, failed) = conn.query_row(
|
||||
"SELECT sum(state = 0), sum(state = 1), sum(state = 2) FROM jobs",
|
||||
[],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get::<_, Option<i64>>(0)?,
|
||||
r.get::<_, Option<i64>>(1)?,
|
||||
r.get::<_, Option<i64>>(2)?,
|
||||
))
|
||||
},
|
||||
)?;
|
||||
Ok(Counts {
|
||||
pending: pending.unwrap_or(0) as usize,
|
||||
running: running.unwrap_or(0) as usize,
|
||||
failed: failed.unwrap_or(0) as usize,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -391,6 +624,241 @@ mod tests {
|
||||
assert_eq!(backoff_seconds(100), 300);
|
||||
}
|
||||
|
||||
/// An image row, so a job has a subject that exists.
|
||||
fn image(c: &Connection, id: i64) {
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', '/lib')
|
||||
ON CONFLICT DO NOTHING",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)",
|
||||
rusqlite::params![id, format!("/lib/{id}.CR3")],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_runner_claims_only_the_kinds_it_names() {
|
||||
// The property a filtered claim exists for: work this worker cannot do
|
||||
// is left untouched — not claimed, not attempted, not failed.
|
||||
let c = db();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
enqueue(
|
||||
&c,
|
||||
JobKind::FetchOriginal,
|
||||
Some(1),
|
||||
Priority::Interactive,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// `FetchOriginal` is the higher priority and would be claimed first by
|
||||
// an unfiltered claim. It is not this worker's to take.
|
||||
let job = claim_next_matching(&c, 0, &[JobKind::Thumbnail])
|
||||
.unwrap()
|
||||
.expect("the thumbnail is claimable");
|
||||
assert_eq!(job.kind, JobKind::Thumbnail);
|
||||
assert!(claim_next_matching(&c, 0, &[JobKind::Thumbnail])
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
let (state, attempts): (i64, i64) = c
|
||||
.query_row(
|
||||
"SELECT state, attempts FROM jobs WHERE kind = ?1",
|
||||
[JobKind::FetchOriginal as i64],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state, JobState::Pending as i64);
|
||||
assert_eq!(attempts, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_worker_that_can_do_nothing_claims_nothing() {
|
||||
let c = db();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
assert!(claim_next_matching(&c, 0, &[]).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_a_claim_gives_the_attempt_back() {
|
||||
// A stopped worker has learned nothing about the file, so the claim it
|
||||
// is handing back must cost nothing.
|
||||
let c = db();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
|
||||
let job = claim_next(&c, 0).unwrap().unwrap();
|
||||
assert_eq!(job.attempts, 1);
|
||||
release(&c, &job).unwrap();
|
||||
|
||||
let again = claim_next(&c, 0).unwrap().expect("claimable again at once");
|
||||
assert_eq!(again.attempts, 1, "the release refunded the first attempt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_a_job_someone_else_has_reclaimed_does_nothing() {
|
||||
// `attempts` standing in for an owner column. A worker that comes back
|
||||
// after the recovery pass handed its job to someone else must not
|
||||
// release a claim that is no longer its to release — which would drop
|
||||
// the live worker's job back into the queue to be run twice.
|
||||
let c = db();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
|
||||
let stale = claim_next(&c, 0).unwrap().unwrap();
|
||||
recover_orphaned(&c).unwrap();
|
||||
let live = claim_next(&c, 0).unwrap().unwrap();
|
||||
assert_eq!(live.attempts, 2);
|
||||
|
||||
release(&c, &stale).unwrap();
|
||||
|
||||
let (state, attempts): (i64, i64) = c
|
||||
.query_row("SELECT state, attempts FROM jobs", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
state,
|
||||
JobState::Running as i64,
|
||||
"the live claim still holds"
|
||||
);
|
||||
assert_eq!(attempts, 2, "and its attempt was not refunded for it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoning_skips_the_whole_retry_ladder() {
|
||||
let c = db();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
let job = claim_next(&c, 0).unwrap().unwrap();
|
||||
|
||||
abandon(&c, job.id, "not an image this build can read").unwrap();
|
||||
|
||||
assert!(claim_next(&c, 1_000_000).unwrap().is_none());
|
||||
let (state, attempts, err): (i64, i64, String) = c
|
||||
.query_row("SELECT state, attempts, last_error FROM jobs", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(state, JobState::Failed as i64);
|
||||
assert_eq!(attempts, 1, "one attempt, not MAX_ATTEMPTS");
|
||||
// Kept, not deleted: the user is entitled to see what failed and why.
|
||||
assert!(err.contains("this build can read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jobs_for_a_deleted_photograph_are_reaped() {
|
||||
// Coalescing keeps the table one row per unit of work; nothing shrank
|
||||
// it when the work stopped existing.
|
||||
let c = db();
|
||||
image(&c, 1);
|
||||
image(&c, 2);
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(2), Priority::Background, None).unwrap();
|
||||
enqueue(
|
||||
&c,
|
||||
JobKind::ExtractMetadata,
|
||||
Some(2),
|
||||
Priority::Background,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
c.execute("DELETE FROM images WHERE id = 2", []).unwrap();
|
||||
assert_eq!(reap_orphan_subjects(&c).unwrap(), 2);
|
||||
|
||||
let left: i64 = c
|
||||
.query_row("SELECT subject_id FROM jobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(left, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_scan_is_not_reaped_by_image_ids() {
|
||||
// `ScanFolder`'s subject is a folder. Joining it against `images`
|
||||
// would delete it whenever the numbering happened not to collide —
|
||||
// which, on a fresh library, is almost always.
|
||||
let c = db();
|
||||
enqueue(
|
||||
&c,
|
||||
JobKind::ScanFolder,
|
||||
Some(1),
|
||||
Priority::Background,
|
||||
Some("/lib/2024"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(reap_orphan_subjects(&c).unwrap(), 0);
|
||||
assert!(claim_next(&c, 0).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_kind_from_a_newer_build_is_parked_rather_than_guessed_at() {
|
||||
// A catalog synced from a device running a later build. Running an
|
||||
// unknown kind as some arbitrary known one is worse than not running
|
||||
// it, and the old code silently read every unknown kind as
|
||||
// `ExtractMetadata`.
|
||||
let c = db();
|
||||
c.execute(
|
||||
"INSERT INTO jobs(kind, subject_id, priority, state) VALUES (99, 1, 0, 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(claim_next(&c, 0).unwrap().is_none());
|
||||
|
||||
let (state, err): (i64, String) = c
|
||||
.query_row("SELECT state, last_error FROM jobs", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(state, JobState::Failed as i64);
|
||||
assert!(err.contains("99"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_say_what_is_left() {
|
||||
// What a foreground service's notification is built from: a number,
|
||||
// without reading a hundred thousand rows to find it.
|
||||
let c = db();
|
||||
assert_eq!(counts(&c).unwrap(), Counts::default());
|
||||
|
||||
for id in 1..=3 {
|
||||
enqueue(&c, JobKind::Thumbnail, Some(id), Priority::Background, None).unwrap();
|
||||
}
|
||||
let job = claim_next(&c, 0).unwrap().unwrap();
|
||||
abandon(&c, job.id, "nope").unwrap();
|
||||
claim_next(&c, 0).unwrap().unwrap();
|
||||
|
||||
let n = counts(&c).unwrap();
|
||||
assert_eq!(n.pending, 1);
|
||||
assert_eq!(n.running, 1);
|
||||
assert_eq!(n.failed, 1);
|
||||
assert_eq!(n.outstanding(), 2, "failed work is not outstanding work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_is_in_all() {
|
||||
// `ALL` is what the reap builds its kind filter from, so a kind added
|
||||
// to the enum and forgotten here would quietly stop being reaped.
|
||||
for (i, kind) in JobKind::ALL.iter().enumerate() {
|
||||
assert_eq!(
|
||||
JobKind::from_i64(i as i64),
|
||||
Some(*kind),
|
||||
"ALL is out of step with the discriminants at {i}"
|
||||
);
|
||||
}
|
||||
assert!(JobKind::from_i64(JobKind::ALL.len() as i64).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_folder_scan_has_a_non_image_subject() {
|
||||
assert!(!JobKind::ScanFolder.subject_is_image());
|
||||
for kind in JobKind::ALL.iter().filter(|k| **k != JobKind::ScanFolder) {
|
||||
assert!(kind.subject_is_image(), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_jobs_are_identifiable_for_metered_gating() {
|
||||
// FR-NC-6: transfers respect unmetered-network and charging
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! TRACES: FR-CAT-5 | FR-CAT-6 | FR-CAT-13 | NFR-R5
|
||||
//! TRACES: FR-CAT-5 | FR-CAT-6 | NFR-R5
|
||||
//! Keywords: the vocabulary, the assignments, and the edits the UI performs.
|
||||
//!
|
||||
//! The read half of this shipped with the catalog and the write half did not.
|
||||
|
||||
@@ -16,9 +16,12 @@
|
||||
//! - [`collections`] — the collection tree and membership the UI edits
|
||||
//! - [`keywords`] — the keyword vocabulary and what it is assigned to
|
||||
//! - [`faces`] — detected faces, the people they belong to, and who said so
|
||||
//! - [`bursts`] — frames that are one moment, grouped so they judge as one
|
||||
//! - [`jobs`] — the durable background work queue
|
||||
//! - [`runner`] — the thing that drains it, driven by whoever owns the thread
|
||||
//! - [`trash`] — soft delete to a folder, then permanent delete
|
||||
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
|
||||
//! - [`recovery`] — backups, and the two offers made when this file is damaged
|
||||
//!
|
||||
//! # The one thing everything is designed around
|
||||
//!
|
||||
@@ -33,6 +36,7 @@ use std::path::Path;
|
||||
use dr_types::{Availability, ImageId};
|
||||
use rusqlite::Connection;
|
||||
|
||||
pub mod bursts;
|
||||
pub mod cache;
|
||||
pub mod collections;
|
||||
pub mod dedup;
|
||||
@@ -44,6 +48,8 @@ pub mod keywords;
|
||||
pub mod merge;
|
||||
pub mod query;
|
||||
pub mod rating;
|
||||
pub mod recovery;
|
||||
pub mod runner;
|
||||
pub mod scan;
|
||||
pub mod schema;
|
||||
pub mod sync;
|
||||
@@ -55,15 +61,20 @@ pub use collections::{Collection, CollectionKind, TreeRow};
|
||||
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
|
||||
pub use error::CatalogError;
|
||||
pub use face_shard::{FaceShardStore, SharedFace};
|
||||
pub use faces::{Calibration, DetectedFace, Face, FaceId, Person, PersonId};
|
||||
pub use faces::{Calibration, DetectedFace, Face, FaceId, Measurement, Person, PersonId};
|
||||
pub use jobs::{Job, JobKind, Priority};
|
||||
pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword};
|
||||
pub use merge::MergeReport;
|
||||
pub use query::{Query, Sort};
|
||||
pub use rating::{Judgement, MAX_RATING};
|
||||
pub use recovery::Backup;
|
||||
// Not `runner::Budget`: `cache::Budget` already owns that name here and
|
||||
// means something else entirely (bytes on disk, not jobs in a slot).
|
||||
// Callers spell the work budget `runner::Budget`, where it is unambiguous.
|
||||
pub use runner::{DrainReport, JobHandler, Outcome, Runner};
|
||||
pub use scan::{DirAction, DirState, EntryAction, ScanOutcome};
|
||||
pub use trash::{TrashedImage, TRASH_DIR};
|
||||
pub use walk::{ensure_root, scan_root, RootKind, ScanProgress, ScanReport};
|
||||
pub use walk::{ensure_root, mark_root_offline, scan_root, RootKind, ScanProgress, ScanReport};
|
||||
|
||||
/// One row of the library grid.
|
||||
///
|
||||
@@ -214,9 +225,23 @@ pub struct Catalog {
|
||||
|
||||
impl Catalog {
|
||||
/// Open or create a catalog, migrating it forward if needed.
|
||||
///
|
||||
/// Does **not** verify the file — see [`Self::open_verified`], and
|
||||
/// [`recovery`] for why the check is bound to startup rather than to every
|
||||
/// open. Damage this trips over on the way past is still reported as
|
||||
/// [`CatalogError::Corrupt`] rather than as a stray SQLite error.
|
||||
pub fn open(path: &Path) -> Result<Self, CatalogError> {
|
||||
let conn = Connection::open(path)?;
|
||||
schema::configure(&conn)?;
|
||||
// NFR-R2, and the reason it is *here*: a migration is the one routine
|
||||
// operation that rewrites table structure, so it is the likeliest way
|
||||
// this file becomes unreadable — and afterwards there is no
|
||||
// pre-migration state left to copy. A failure to take the copy is
|
||||
// logged rather than raised: a full disk must not be the thing that
|
||||
// makes a library unopenable.
|
||||
if let Err(e) = recovery::backup_before_migration(&conn, path) {
|
||||
log::warn!("could not back up before migrating: {e}");
|
||||
}
|
||||
let from = schema::migrate(&conn)?;
|
||||
// A migration adds a column; it cannot know what the value should be
|
||||
// for rows that already existed. Backfilling on open is what stops
|
||||
@@ -227,6 +252,26 @@ impl Catalog {
|
||||
Ok(Catalog { conn })
|
||||
}
|
||||
|
||||
/// TRACES: NFR-R6
|
||||
/// Open a catalog, checking the file first.
|
||||
///
|
||||
/// What startup calls. On [`CatalogError::Corrupt`] the caller has a user
|
||||
/// in front of it and must make the two offers [`recovery`] describes,
|
||||
/// rather than reporting a SQLite message on a banner and carrying on into
|
||||
/// a scan that would write into the damage.
|
||||
///
|
||||
/// Checked *before* opening rather than after, because opening runs
|
||||
/// migrations: a damaged catalog that happens to have an intact header
|
||||
/// would otherwise be migrated — rewriting structure on top of structure
|
||||
/// that is already wrong — before anybody asked whether it was sound.
|
||||
pub fn open_verified(path: &Path) -> Result<Self, CatalogError> {
|
||||
// A catalog that is not there yet is not damaged; `open` creates it.
|
||||
if path.is_file() {
|
||||
recovery::check_file(path)?;
|
||||
}
|
||||
Self::open(path)
|
||||
}
|
||||
|
||||
/// An in-memory catalog, for tests and for a throwaway import preview.
|
||||
pub fn in_memory() -> Result<Self, CatalogError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
|
||||
+398
-12
@@ -63,6 +63,59 @@ impl Judgement {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-8 | FR-NC-9
|
||||
/// The default version's uuid for a photograph the server knows by `file_id`.
|
||||
///
|
||||
/// # Why this is derived and not generated
|
||||
///
|
||||
/// A version's uuid is the identity a cross-device merge keys on. It used to
|
||||
/// be minted at random per row, and the comment above this function used to
|
||||
/// say that made it unique — which it did, and that was precisely the bug.
|
||||
/// Two devices indexing the same library minted *different* uuids for the same
|
||||
/// photograph, so the sidecar they shared ended up with two `default = 1`
|
||||
/// blocks, `Version::merge` never saw a matching pair to reconcile, and a
|
||||
/// rating made on one device was invisible on the other. `crate::merge` has
|
||||
/// documented the consequence for keywords for as long as it has existed: a
|
||||
/// uuid-keyed join across two catalogs unions nothing at all.
|
||||
///
|
||||
/// `oc:fileid` is the identity that *is* shared. The server assigns it, every
|
||||
/// client pointed at that library sees the same integer, and it survives a
|
||||
/// server-side rename and move — the same three properties that made
|
||||
/// `crate::merge::ASSIGN_BY_FILE_ID` prefer it to a content hash.
|
||||
///
|
||||
/// # The layout
|
||||
///
|
||||
/// A UUIDv8 (RFC 9562: an application-defined layout) carrying the file id
|
||||
/// verbatim across the four variable fields, with a fixed tag in the node
|
||||
/// field saying what minted it. Verbatim rather than hashed so the mapping is
|
||||
/// injective by construction: two file ids cannot collide, which a truncated
|
||||
/// hash could, and a uuid read out of a sidecar can be traced back to the file
|
||||
/// it belongs to by eye.
|
||||
///
|
||||
/// Every device computes this identically from the same integer, which is the
|
||||
/// whole point — there is no negotiation and no first-writer-wins.
|
||||
pub fn derived_version_uuid(file_id: i64) -> String {
|
||||
let id = file_id as u64;
|
||||
format!(
|
||||
// 32 + 16 + 12 + 4 = 64 bits of file id, then the tag.
|
||||
"{:08x}-{:04x}-8{:03x}-{:04x}-{:012x}",
|
||||
(id >> 32) as u32,
|
||||
(id >> 16) as u16,
|
||||
(id >> 4) as u16 & 0x0FFF,
|
||||
// The two high bits are the RFC's variant field and must be `0b10`;
|
||||
// the remaining fourteen carry the file id's last four bits.
|
||||
0x8000u16 | ((id as u16 & 0x000F) << 10),
|
||||
DERIVED_VERSION_TAG,
|
||||
)
|
||||
}
|
||||
|
||||
/// The node field of a [`derived_version_uuid`], identifying what minted it.
|
||||
///
|
||||
/// Fixed and arbitrary. Its only job is to keep a derived uuid from colliding
|
||||
/// with a randomly minted one and to make it recognisable in a sidecar read by
|
||||
/// eye — `…-d0c5ec0de001` is visibly not a v4.
|
||||
const DERIVED_VERSION_TAG: u64 = 0xd0c5_ec0d_e001;
|
||||
|
||||
/// Give every image without one a default version.
|
||||
///
|
||||
/// Idempotent, and cheap on the common path: the `NOT EXISTS` sub-select is
|
||||
@@ -72,8 +125,9 @@ impl Judgement {
|
||||
/// Returns how many were created, so a scan can log the backfill rather than
|
||||
/// silently doing thousands of inserts.
|
||||
///
|
||||
/// The UUID is per row and generated here — it is the merge identity across
|
||||
/// devices (FR-NC-8), so two images must never share one.
|
||||
/// The uuid comes from [`derived_version_uuid`] where the server has named the
|
||||
/// file, so every device computes the same one; only a library with no server
|
||||
/// behind it falls back to a generated id.
|
||||
pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
// One transaction for the batch. A backfill over a 24k-image library is
|
||||
// 24k inserts, and per-statement commits would make it minutes rather
|
||||
@@ -93,13 +147,17 @@ pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError>
|
||||
/// transaction within a transaction". The same split, for the same reason, as
|
||||
/// `collections::add_within`.
|
||||
pub fn ensure_default_versions_within(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
let ids: Vec<i64> = {
|
||||
// The remote id travels with the image so the uuid can be derived from it.
|
||||
// A `LEFT JOIN`, because a library on a folder or a card has no `remote`
|
||||
// row at all and still needs its versions.
|
||||
let ids: Vec<(i64, Option<i64>)> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT i.id FROM images i
|
||||
"SELECT i.id, r.file_id FROM images i
|
||||
LEFT JOIN remote r ON r.image_id = i.id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM versions v WHERE v.image_id = i.id)",
|
||||
)?;
|
||||
let found = stmt
|
||||
.query_map([], |r| r.get(0))?
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
found
|
||||
};
|
||||
@@ -112,14 +170,103 @@ pub fn ensure_default_versions_within(conn: &Connection) -> Result<usize, Catalo
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (?1, ?2, ?3, 1, 0, 0)",
|
||||
)?;
|
||||
for id in &ids {
|
||||
insert.execute(rusqlite::params![id, new_uuid(), DEFAULT_VERSION_NAME])?;
|
||||
for (id, file_id) in &ids {
|
||||
insert.execute(rusqlite::params![
|
||||
id,
|
||||
version_uuid(*file_id),
|
||||
DEFAULT_VERSION_NAME
|
||||
])?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ids.len())
|
||||
}
|
||||
|
||||
/// The uuid to mint for a new default version.
|
||||
///
|
||||
/// Derived from the server's file id where there is one, so two devices agree
|
||||
/// (FR-NC-8); generated where there is not.
|
||||
///
|
||||
/// # What the fallback costs
|
||||
///
|
||||
/// A library with no server behind it — a folder, a card — has no identity two
|
||||
/// devices could both compute, so the split this derivation prevents is still
|
||||
/// reachable there if that folder is synced by something else. That case is
|
||||
/// repaired rather than prevented: `Sidecar::fuse_default_versions` folds the
|
||||
/// rival defaults together the next time either device reads the file.
|
||||
fn version_uuid(file_id: Option<i64>) -> String {
|
||||
match file_id {
|
||||
Some(id) => derived_version_uuid(id),
|
||||
None => new_uuid(),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-8 | FR-NC-9
|
||||
/// Move default versions minted before [`derived_version_uuid`] onto it.
|
||||
///
|
||||
/// Every catalog written by an earlier build holds a randomly minted uuid per
|
||||
/// image, and its peers hold different ones for the same photographs. Deriving
|
||||
/// the uuid only for *new* rows would leave every image already indexed —
|
||||
/// which is all of them, on a library anybody has used — writing to the same
|
||||
/// rival identity it always did.
|
||||
///
|
||||
/// Safe to run repeatedly: it selects only rows whose uuid is not already the
|
||||
/// derived one, so a realigned catalog matches nothing and writes nothing.
|
||||
///
|
||||
/// # Why this cannot collide
|
||||
///
|
||||
/// `versions.uuid` is `UNIQUE`, and `remote.file_id` has a unique index of its
|
||||
/// own, so two images cannot derive the same uuid. The one row that could
|
||||
/// stand in the way is a *virtual copy* (FR-CAT-12) that already holds the
|
||||
/// target — impossible to mint but not impossible to receive from a merge — so
|
||||
/// the update is skipped where the target is taken rather than failing the
|
||||
/// backfill and, with it, the catalog open.
|
||||
///
|
||||
/// # The sidecar side is not this function's business
|
||||
///
|
||||
/// Moving the catalog's uuid alone would leave the file's default under the
|
||||
/// old one and the next write would add a rival rather than amend it. What
|
||||
/// stops that is `library::amend` fusing onto the write's uuid before it looks
|
||||
/// anything up, which renames the file's default to match. This end and that
|
||||
/// one have to land together, and they do.
|
||||
///
|
||||
/// Returns how many rows moved.
|
||||
pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
// Filtered in SQL rather than in the loop: every derived uuid ends in the
|
||||
// tag, so a catalog that has already been realigned selects no rows at all
|
||||
// and this costs one indexed pass instead of twenty-four thousand reads.
|
||||
let already = format!("%-{DERIVED_VERSION_TAG:012x}");
|
||||
let stale: Vec<(i64, i64)> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT v.id, r.file_id
|
||||
FROM versions v
|
||||
JOIN remote r ON r.image_id = v.image_id
|
||||
WHERE v.is_default = 1 AND v.uuid NOT LIKE ?1",
|
||||
)?;
|
||||
let found = stmt
|
||||
.query_map([&already], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
found
|
||||
};
|
||||
if stale.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut moved = 0usize;
|
||||
{
|
||||
// `OR IGNORE` covers the taken-target case described above: the row
|
||||
// keeps the uuid it has, which is the state this build has always
|
||||
// coped with, rather than aborting the transaction.
|
||||
let mut update = tx.prepare("UPDATE OR IGNORE versions SET uuid = ?2 WHERE id = ?1")?;
|
||||
for (row, file_id) in &stale {
|
||||
moved += update.execute(rusqlite::params![row, derived_version_uuid(*file_id)])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// The default version's row id for an image, creating one if it has none.
|
||||
///
|
||||
/// Every write path goes through this rather than assuming a version exists.
|
||||
@@ -144,10 +291,21 @@ pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, Cata
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
// Derived from the server's file id where there is one, so the version
|
||||
// this mints is the same one the photographer's other device will mint
|
||||
// (FR-NC-8). A miss here is a library with no server behind it.
|
||||
let file_id: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT file_id FROM remote WHERE image_id = ?1",
|
||||
[image.0 as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (?1, ?2, ?3, 1, 0, 0)",
|
||||
rusqlite::params![image.0 as i64, new_uuid(), DEFAULT_VERSION_NAME],
|
||||
rusqlite::params![image.0 as i64, version_uuid(file_id), DEFAULT_VERSION_NAME],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
@@ -356,15 +514,22 @@ fn flag_from_code(v: i64) -> FlagState {
|
||||
}
|
||||
}
|
||||
|
||||
/// A version UUID.
|
||||
/// A generated version UUID, for a photograph no server has named.
|
||||
///
|
||||
/// Hand-rolled rather than pulling in the `uuid` crate for one function — the
|
||||
/// same reasoning as the date maths in `library_ui`. This needs to be unique
|
||||
/// across devices, not cryptographically unguessable: it keys a merge, and an
|
||||
/// attacker who can write to the sidecar has already won.
|
||||
/// same reasoning as the date maths in `library_ui`. It needs to be unique,
|
||||
/// not cryptographically unguessable: it keys a merge, and an attacker who can
|
||||
/// write to the sidecar has already won.
|
||||
///
|
||||
/// Seeded from the system clock and a per-process counter, so two versions
|
||||
/// created inside the same nanosecond tick still differ.
|
||||
///
|
||||
/// **Unique is not the same as agreed**, which is the distinction that cost a
|
||||
/// photographer a day of culling. Two devices calling this for the same
|
||||
/// photograph get two different answers, and a merge keyed on the result then
|
||||
/// has no pair to reconcile. Anything with a `file_id` behind it must use
|
||||
/// [`derived_version_uuid`]; this is the fallback for libraries that have no
|
||||
/// server to supply one.
|
||||
fn new_uuid() -> String {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -731,3 +896,224 @@ mod tests {
|
||||
assert_eq!(cat.count(&unrated, 0).unwrap(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-8 | FR-NC-9
|
||||
/// The identity two devices have to agree on without talking to each other.
|
||||
#[cfg(test)]
|
||||
mod derived_identity {
|
||||
use super::*;
|
||||
use crate::Catalog;
|
||||
|
||||
/// A catalog whose images the server has named, as a remote scan leaves it.
|
||||
fn with_remote_images(file_ids: &[i64]) -> Catalog {
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
for (i, file_id) in file_ids.iter().enumerate() {
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
|
||||
[format!("img{i:03}.CR3")],
|
||||
)
|
||||
.unwrap();
|
||||
let image = c.last_insert_rowid();
|
||||
c.execute(
|
||||
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
|
||||
rusqlite::params![image, file_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
cat
|
||||
}
|
||||
|
||||
fn default_uuids(cat: &Catalog) -> Vec<String> {
|
||||
let mut stmt = cat
|
||||
.connection()
|
||||
.prepare("SELECT uuid FROM versions WHERE is_default = 1 ORDER BY image_id")
|
||||
.unwrap();
|
||||
stmt.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The whole point: the same photograph, indexed independently on two
|
||||
/// devices, gets one identity. This used to be two.
|
||||
#[test]
|
||||
fn two_devices_derive_the_same_uuid_for_one_photograph() {
|
||||
let laptop = with_remote_images(&[4_812]);
|
||||
let tablet = with_remote_images(&[4_812]);
|
||||
ensure_default_versions(laptop.connection()).unwrap();
|
||||
ensure_default_versions(tablet.connection()).unwrap();
|
||||
|
||||
assert_eq!(default_uuids(&laptop), default_uuids(&tablet));
|
||||
}
|
||||
|
||||
/// And different photographs must still be told apart — the property the
|
||||
/// random uuid did have, which this must not give up to gain agreement.
|
||||
#[test]
|
||||
fn different_photographs_keep_different_uuids() {
|
||||
let cat = with_remote_images(&[1, 2, 3, 0x7FFF_FFFF_FFFF_FFFF]);
|
||||
ensure_default_versions(cat.connection()).unwrap();
|
||||
|
||||
let mut uuids = default_uuids(&cat);
|
||||
let before = uuids.len();
|
||||
uuids.sort();
|
||||
uuids.dedup();
|
||||
assert_eq!(uuids.len(), before, "two photographs share an identity");
|
||||
}
|
||||
|
||||
/// The file id has to survive the layout intact, or two ids that differ
|
||||
/// only in the bits it drops would collide.
|
||||
#[test]
|
||||
fn the_whole_file_id_is_carried() {
|
||||
// A pair differing only in the low four bits, and a pair differing
|
||||
// only in the high thirty-two — the two places a sloppy layout loses
|
||||
// information.
|
||||
assert_ne!(derived_version_uuid(0x10), derived_version_uuid(0x1F));
|
||||
assert_ne!(
|
||||
derived_version_uuid(0x0000_0001_0000_0000),
|
||||
derived_version_uuid(0x0000_0002_0000_0000)
|
||||
);
|
||||
assert_ne!(derived_version_uuid(0), derived_version_uuid(-1));
|
||||
}
|
||||
|
||||
/// Well-formed, and recognisably not a generated one.
|
||||
#[test]
|
||||
fn a_derived_uuid_is_a_well_formed_v8() {
|
||||
let uuid = derived_version_uuid(4_812);
|
||||
let fields: Vec<&str> = uuid.split('-').collect();
|
||||
assert_eq!(fields.len(), 5);
|
||||
assert_eq!(
|
||||
fields.iter().map(|f| f.len()).collect::<Vec<_>>(),
|
||||
vec![8, 4, 4, 4, 12]
|
||||
);
|
||||
assert!(fields[2].starts_with('8'), "version nibble: {uuid}");
|
||||
// The RFC's variant field is the two high bits of the fourth group,
|
||||
// and must read `0b10` — so the first hex digit is 8, 9, a or b.
|
||||
assert!(
|
||||
matches!(fields[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'),
|
||||
"variant: {uuid}"
|
||||
);
|
||||
assert!(uuid.ends_with("d0c5ec0de001"), "tag: {uuid}");
|
||||
}
|
||||
|
||||
/// A library with no server behind it has no shared identity to derive,
|
||||
/// and must still get a version rather than failing.
|
||||
#[test]
|
||||
fn a_library_with_no_server_still_gets_its_versions() {
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, 'a.CR3', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(ensure_default_versions(c).unwrap(), 1);
|
||||
assert_eq!(default_uuids(&cat).len(), 1);
|
||||
}
|
||||
|
||||
/// The repair. A catalog written by an earlier build holds randomly minted
|
||||
/// uuids, and leaving them there would mean every image already indexed —
|
||||
/// which is all of them — kept writing to its own rival identity.
|
||||
#[test]
|
||||
fn a_catalog_from_an_earlier_build_is_realigned() {
|
||||
let cat = with_remote_images(&[4_812, 4_813]);
|
||||
let c = cat.connection();
|
||||
// As the old code left it.
|
||||
for (i, image) in [1i64, 2].iter().enumerate() {
|
||||
c.execute(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (?1, ?2, 'Default', 1, ?3, 0)",
|
||||
rusqlite::params![image, format!("random-{i}"), (i + 1) as i64],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(align_default_version_uuids(c).unwrap(), 2);
|
||||
assert_eq!(
|
||||
default_uuids(&cat),
|
||||
vec![derived_version_uuid(4_812), derived_version_uuid(4_813)]
|
||||
);
|
||||
|
||||
// The judgement travels with the row — a realignment that dropped the
|
||||
// ratings would be a worse bug than the one it fixes.
|
||||
let ratings: Vec<i64> = {
|
||||
let mut stmt = c
|
||||
.prepare("SELECT rating FROM versions ORDER BY image_id")
|
||||
.unwrap();
|
||||
let v = stmt
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
v
|
||||
};
|
||||
assert_eq!(ratings, vec![1, 2]);
|
||||
}
|
||||
|
||||
/// Runs on every catalog open, so a second pass must select nothing and
|
||||
/// write nothing.
|
||||
#[test]
|
||||
fn realigning_twice_changes_nothing_the_second_time() {
|
||||
let cat = with_remote_images(&[4_812]);
|
||||
ensure_default_versions(cat.connection()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
align_default_version_uuids(cat.connection()).unwrap(),
|
||||
0,
|
||||
"a freshly derived catalog must match nothing"
|
||||
);
|
||||
let before = default_uuids(&cat);
|
||||
align_default_version_uuids(cat.connection()).unwrap();
|
||||
assert_eq!(default_uuids(&cat), before);
|
||||
}
|
||||
|
||||
/// A virtual copy (FR-CAT-12) that already holds the target uuid must not
|
||||
/// take the backfill — and with it the catalog open — down with it.
|
||||
#[test]
|
||||
fn a_taken_target_leaves_the_row_where_it_is() {
|
||||
let cat = with_remote_images(&[4_812]);
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (1, 'random', 'Default', 1, 0, 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (1, ?1, 'For print', 0, 0, 0)",
|
||||
[derived_version_uuid(4_812)],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(align_default_version_uuids(c).unwrap(), 0);
|
||||
assert_eq!(default_uuids(&cat), vec!["random".to_string()]);
|
||||
}
|
||||
|
||||
/// The backfill is what actually runs this, so it has to be wired in.
|
||||
#[test]
|
||||
fn opening_a_catalog_realigns_it() {
|
||||
let cat = with_remote_images(&[4_812]);
|
||||
cat.connection()
|
||||
.execute(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (1, 'random', 'Default', 1, 3, 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
crate::schema::backfill(cat.connection()).unwrap();
|
||||
assert_eq!(default_uuids(&cat), vec![derived_version_uuid(4_812)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
//! TRACES: NFR-R2 | NFR-R6
|
||||
//! What to do once the index is already damaged.
|
||||
//!
|
||||
//! # Why this can be a small module
|
||||
//!
|
||||
//! Because of a property the rest of the catalog was built to keep: the
|
||||
//! catalog is an *index*, not a source of truth (ARCH §6.12, invariant
|
||||
//! §5.2.4). Sidecars beside the images hold the authoritative ratings,
|
||||
//! keywords and edit graphs, for every catalogued image and whether or not a
|
||||
//! remote account exists (FR-CAT-8). So the worst outcome available here is a
|
||||
//! rescan — expensive, but not a loss.
|
||||
//!
|
||||
//! That is the second offer. The first is cheaper and loses nothing at all: a
|
||||
//! backup, restored.
|
||||
//!
|
||||
//! # The one thing a rebuild does not recover
|
||||
//!
|
||||
//! **Collections.** A manual collection is a set of images the user assembled
|
||||
//! by hand and nothing in the filesystem records it (`docs/catalog.md` §8.1) —
|
||||
//! which is the whole reason the catalog file itself syncs. So the two offers
|
||||
//! are not interchangeable, and the interface must not present them as if they
|
||||
//! were: a restore keeps the user's collections, a rebuild does not.
|
||||
//!
|
||||
//! # When the check runs, and when it does not
|
||||
//!
|
||||
//! [`integrity_check`] reads every page of the database. That is affordable
|
||||
//! once, at startup, where a failure has a user in front of it who can answer
|
||||
//! a question — and it is *not* affordable on every [`Catalog::open`], which
|
||||
//! this application does per background task, dozens of times a session. So
|
||||
//! the check is bound to [`Catalog::open_verified`] rather than to `open`,
|
||||
//! and the cheap half of the story — classifying `SQLITE_CORRUPT` and
|
||||
//! `SQLITE_NOTADB` as [`CatalogError::Corrupt`] — happens for free on every
|
||||
//! query through [`crate::error`]'s conversion. A background job that trips
|
||||
//! over the damage first therefore reports the same thing the startup check
|
||||
//! would have.
|
||||
//!
|
||||
//! [`Catalog::open`]: crate::Catalog::open
|
||||
//! [`Catalog::open_verified`]: crate::Catalog::open_verified
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::CatalogError;
|
||||
use crate::schema;
|
||||
|
||||
/// Directory backups live in, relative to the catalog file.
|
||||
///
|
||||
/// Beside the catalog rather than in the cache directory, and that is the
|
||||
/// point of the choice: this is the copy the user falls back on, and a cache
|
||||
/// is a place the operating system is entitled to empty without asking
|
||||
/// (see `library::data_root` for the same reasoning about sidecars).
|
||||
const BACKUP_DIR: &str = "backups";
|
||||
|
||||
/// How many backups are kept.
|
||||
///
|
||||
/// Small on purpose. A backup is a full copy of a catalog that is tens of
|
||||
/// megabytes at 50k images, and the value of the third-oldest one is close to
|
||||
/// zero: corruption is noticed at the next launch, not months later. What the
|
||||
/// depth buys is protection against backing *up* the damage — if a corrupt
|
||||
/// catalog is copied before anyone notices, the generation behind it is still
|
||||
/// clean.
|
||||
pub const KEEP_BACKUPS: usize = 3;
|
||||
|
||||
/// Suffix given to a catalog that has been set aside as damaged.
|
||||
///
|
||||
/// Kept rather than deleted. It costs disk this application would rather not
|
||||
/// spend, and it is still the right call: `.sqlite` files have been recovered
|
||||
/// by hand before, the user has not consented to a deletion, and NFR-R4's
|
||||
/// instinct — never destroy what the user did not ask you to destroy — does
|
||||
/// not stop applying at the catalog's edge.
|
||||
const DAMAGED_SUFFIX: &str = "damaged";
|
||||
|
||||
/// One kept backup.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Backup {
|
||||
pub path: PathBuf,
|
||||
/// UTC seconds at which it was taken, read from the filename rather than
|
||||
/// from the filesystem: a copy, a restore or a sync can rewrite an mtime,
|
||||
/// and then the newest backup is not the one that looks newest.
|
||||
pub taken_at: i64,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// Where backups for `catalog` are kept.
|
||||
pub fn backup_dir(catalog: &Path) -> PathBuf {
|
||||
catalog
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(BACKUP_DIR)
|
||||
}
|
||||
|
||||
/// Check the database this connection is attached to.
|
||||
///
|
||||
/// `quick_check` rather than `integrity_check`: the difference is that
|
||||
/// `quick_check` skips verifying that every index agrees with its table, which
|
||||
/// is the expensive half and the half this application least needs — every
|
||||
/// index here is derivable, and `REINDEX` fixes one without anybody being
|
||||
/// asked a question. What is left still reads every page, and catches the
|
||||
/// damage that matters: torn b-trees, a bad freelist, a truncated file.
|
||||
///
|
||||
/// Returns [`CatalogError::Corrupt`] carrying what SQLite said, so the message
|
||||
/// the user sees is the diagnosis rather than a paraphrase of it.
|
||||
pub fn integrity_check(conn: &Connection) -> Result<(), CatalogError> {
|
||||
// The argument caps how many problems are reported. One is enough: the
|
||||
// answer is the same whether the file has one damaged page or nine
|
||||
// hundred, and an unbounded check on a badly damaged file can run for a
|
||||
// very long time producing a list nobody will read.
|
||||
let mut stmt = conn.prepare("PRAGMA quick_check(1)")?;
|
||||
let rows: Vec<String> = stmt
|
||||
.query_map([], |r| r.get(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// A healthy database answers with the single row "ok".
|
||||
if rows.len() == 1 && rows[0] == "ok" {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CatalogError::Corrupt {
|
||||
detail: rows.join("; "),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check a catalog file that is not currently open.
|
||||
///
|
||||
/// Used before a restore: a backup is only worth swapping in if it is sound,
|
||||
/// and swapping in a second damaged file — leaving the user with no catalog
|
||||
/// and no offer left — is the failure this exists to prevent.
|
||||
pub fn check_file(path: &Path) -> Result<(), CatalogError> {
|
||||
if !path.is_file() {
|
||||
return Err(CatalogError::Io(format!("{} is missing", path.display())));
|
||||
}
|
||||
// Read-write rather than read-only, which reads oddly for a check. A
|
||||
// backup carries the WAL journal mode in its header because it was copied
|
||||
// page-for-page from a WAL database, and SQLite cannot open one read-only
|
||||
// without a shared-memory file it is then not allowed to create. Nothing
|
||||
// here writes; the connection is opened, read, and dropped.
|
||||
let conn = Connection::open(path)?;
|
||||
integrity_check(&conn)
|
||||
}
|
||||
|
||||
/// Take a backup of the open catalog.
|
||||
///
|
||||
/// Returns the file written. Older generations beyond [`KEEP_BACKUPS`] are
|
||||
/// pruned, newest kept.
|
||||
///
|
||||
/// Goes through `crate::sync::copy_to` — SQLite's own backup API after a
|
||||
/// TRUNCATE checkpoint — rather than copying the file. A WAL database is not
|
||||
/// one file, and `fs::copy` of the main file alone would silently back up a
|
||||
/// state that is older than the catalog and possibly torn, which is the one
|
||||
/// failure mode a backup cannot afford.
|
||||
pub fn backup(conn: &Connection, catalog: &Path) -> Result<PathBuf, CatalogError> {
|
||||
let dir = backup_dir(catalog);
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| CatalogError::Io(format!("creating {}: {e}", dir.display())))?;
|
||||
|
||||
let dest = dir.join(format!("catalog-{}.sqlite", now()));
|
||||
// A second backup within the same second would otherwise land on the first
|
||||
// one's name. Rare, and only reachable from tests and a retry, but the
|
||||
// result would be a half-overwritten backup rather than two.
|
||||
if dest.exists() {
|
||||
std::fs::remove_file(&dest)
|
||||
.map_err(|e| CatalogError::Io(format!("replacing {}: {e}", dest.display())))?;
|
||||
}
|
||||
|
||||
// Dropped immediately: the copy is complete when `copy_to` returns, and
|
||||
// holding the connection open would leave a `-wal` beside a file whose
|
||||
// whole purpose is to be a single self-contained artefact.
|
||||
drop(crate::sync::copy_to(conn, &dest)?);
|
||||
|
||||
prune(catalog);
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Back up before a migration, if there is anything to back up.
|
||||
///
|
||||
/// Called from [`Catalog::open`](crate::Catalog::open) between `configure` and
|
||||
/// `migrate`. NFR-R2 asks for this and the reasoning is narrower than "backups
|
||||
/// are prudent": a migration is the one routine operation that rewrites table
|
||||
/// structure, so it is the likeliest way a catalog becomes unreadable, and it
|
||||
/// is the one moment where the pre-change state is still on disk to be copied.
|
||||
/// Afterwards there is nothing left to take a copy *of*.
|
||||
///
|
||||
/// A no-op in the two cases where it would cost without buying anything: a
|
||||
/// catalog already at [`schema::SCHEMA_VERSION`], and a brand-new file at
|
||||
/// version 0 with no tables in it yet.
|
||||
pub fn backup_before_migration(conn: &Connection, catalog: &Path) -> Result<(), CatalogError> {
|
||||
let from: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
if from == 0 || from >= schema::SCHEMA_VERSION {
|
||||
return Ok(());
|
||||
}
|
||||
let path = backup(conn, catalog)?;
|
||||
log::info!(
|
||||
"backed up catalog at v{from} to {} before migrating to v{}",
|
||||
path.display(),
|
||||
schema::SCHEMA_VERSION
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The backups available for `catalog`, newest first.
|
||||
///
|
||||
/// Never fails: an unreadable or absent backup directory means there are no
|
||||
/// backups, which is a fact about the offer to make rather than an error to
|
||||
/// report on top of the corruption the user is already looking at.
|
||||
pub fn backups(catalog: &Path) -> Vec<Backup> {
|
||||
let dir = backup_dir(catalog);
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut out: Vec<Backup> = entries
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
let taken_at = timestamp_of(&path)?;
|
||||
let bytes = e.metadata().ok()?.len();
|
||||
Some(Backup {
|
||||
path,
|
||||
taken_at,
|
||||
bytes,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|b| std::cmp::Reverse(b.taken_at));
|
||||
out
|
||||
}
|
||||
|
||||
/// Put a backup back in place of the damaged catalog.
|
||||
///
|
||||
/// **Every connection to `catalog` must be closed first.** This replaces the
|
||||
/// file underneath anything still holding it open, which on a live connection
|
||||
/// is how a *second* corrupt catalog gets made.
|
||||
///
|
||||
/// The order is deliberate:
|
||||
///
|
||||
/// 1. The backup is checked. A restore that installs a second damaged file
|
||||
/// leaves the user with nothing to try next.
|
||||
/// 2. The damaged catalog is renamed aside, and its `-wal` and `-shm` are
|
||||
/// **deleted**. This is the step that is easy to leave out and fatal to
|
||||
/// leave out: a journal belonging to the old file, sitting beside the new
|
||||
/// one under the same name, is replayed into it on the next open. That is
|
||||
/// not a restore, it is a fresh corruption with the evidence gone.
|
||||
/// 3. The backup is *copied* into place, not moved, so a failure here can be
|
||||
/// retried against the same backup.
|
||||
pub fn restore(catalog: &Path, backup: &Path) -> Result<(), CatalogError> {
|
||||
check_file(backup)?;
|
||||
set_aside(catalog)?;
|
||||
std::fs::copy(backup, catalog).map_err(|e| {
|
||||
CatalogError::Io(format!(
|
||||
"restoring {} from {}: {e}",
|
||||
catalog.display(),
|
||||
backup.display()
|
||||
))
|
||||
})?;
|
||||
log::info!(
|
||||
"restored {} from backup {}",
|
||||
catalog.display(),
|
||||
backup.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move a damaged catalog out of the way so the next open builds a fresh one.
|
||||
///
|
||||
/// This is the rebuild path (NFR-R6's second offer) and also the first half of
|
||||
/// a [`restore`]. Nothing else is needed to rebuild: the next
|
||||
/// [`Catalog::open`](crate::Catalog::open) creates an empty catalog at the
|
||||
/// current schema, and the ordinary scan repopulates it from sources and
|
||||
/// sidecars — which is precisely invariant §5.2.4 being spent rather than
|
||||
/// merely asserted.
|
||||
///
|
||||
/// Returns where the damaged file was put, or `None` if there was no catalog
|
||||
/// to move — a caller may be recovering from a file SQLite could not open
|
||||
/// because it was never created.
|
||||
pub fn set_aside(catalog: &Path) -> Result<Option<PathBuf>, CatalogError> {
|
||||
let moved = if catalog.exists() {
|
||||
let dest = with_suffix(catalog, DAMAGED_SUFFIX);
|
||||
// An earlier damaged copy is replaced rather than accumulating: two of
|
||||
// these is two full-size catalogs on the user's disk, and the older
|
||||
// one has already been superseded by a recovery the user completed.
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
// The rename first, so that a failure here leaves the journals with
|
||||
// the file they belong to rather than orphaned beside a catalog that
|
||||
// is still in use.
|
||||
std::fs::rename(catalog, &dest).map_err(|e| {
|
||||
CatalogError::Io(format!(
|
||||
"setting aside {} as {}: {e}",
|
||||
catalog.display(),
|
||||
dest.display()
|
||||
))
|
||||
})?;
|
||||
log::warn!(
|
||||
"catalog {} was damaged; kept as {}",
|
||||
catalog.display(),
|
||||
dest.display()
|
||||
);
|
||||
Some(dest)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Then the journals, whether or not there was a catalog to move: a `-wal`
|
||||
// orphaned beside a missing database is replayed into whatever takes that
|
||||
// name next, which would not be a restore but a fresh corruption with the
|
||||
// evidence gone.
|
||||
for sidecar in journals(catalog) {
|
||||
if let Err(e) = std::fs::remove_file(&sidecar) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
return Err(CatalogError::Io(format!(
|
||||
"removing stale journal {}: {e}",
|
||||
sidecar.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Delete backups beyond [`KEEP_BACKUPS`].
|
||||
///
|
||||
/// Best-effort and silent about individual failures: failing to delete an old
|
||||
/// backup is not a reason to fail the new one, which is already written.
|
||||
fn prune(catalog: &Path) {
|
||||
for old in backups(catalog).into_iter().skip(KEEP_BACKUPS) {
|
||||
if let Err(e) = std::fs::remove_file(&old.path) {
|
||||
log::warn!("could not prune backup {}: {e}", old.path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The WAL and shared-memory files SQLite keeps beside a database.
|
||||
fn journals(catalog: &Path) -> [PathBuf; 2] {
|
||||
[with_suffix(catalog, "wal"), with_suffix(catalog, "shm")]
|
||||
}
|
||||
|
||||
/// `catalog.sqlite` plus `-suffix`, the way SQLite names its own sidecars.
|
||||
///
|
||||
/// Appended to the whole filename rather than replacing the extension, so
|
||||
/// `catalog.sqlite-wal` is what SQLite would look for and `catalog.sqlite-
|
||||
/// damaged` sorts next to the catalog it came from.
|
||||
fn with_suffix(catalog: &Path, suffix: &str) -> PathBuf {
|
||||
let mut s = catalog.as_os_str().to_os_string();
|
||||
s.push("-");
|
||||
s.push(suffix);
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Read the timestamp out of a backup's filename, or `None` if this is not one.
|
||||
///
|
||||
/// Doubles as the filter that keeps [`backups`] from offering the user
|
||||
/// something that is not a catalog — a stray file in the directory, or a `-wal`
|
||||
/// left by a crash mid-backup.
|
||||
fn timestamp_of(path: &Path) -> Option<i64> {
|
||||
let name = path.file_name()?.to_str()?;
|
||||
name.strip_prefix("catalog-")?
|
||||
.strip_suffix(".sqlite")?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Seconds since the epoch, or 0 if the clock is before it.
|
||||
fn now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Catalog;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
/// A scratch directory that cleans up with the test.
|
||||
fn tempdir(tag: &str) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"dr-recovery-{tag}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
base
|
||||
}
|
||||
|
||||
/// A catalog on disk with enough rows to span several pages, closed.
|
||||
///
|
||||
/// Closed matters: WAL means the rows are in `catalog.sqlite-wal` until
|
||||
/// something checkpoints, and a test that corrupted the main file while
|
||||
/// the data was still in the journal would be corrupting empty space.
|
||||
fn fixture(path: &Path, images: i64) {
|
||||
let cat = Catalog::open(path).unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
|
||||
VALUES ('u1', 'Iceland', 0, 0, 1, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
for i in 1..=images {
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (?1, 1, ?2, 0)",
|
||||
rusqlite::params![i, format!("DCIM/IMG_{i:05}.CR3")],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
crate::sync::checkpoint(c).unwrap();
|
||||
drop(cat);
|
||||
}
|
||||
|
||||
/// Scribble over everything past the first two pages.
|
||||
///
|
||||
/// Past them rather than over them so that page 1 — the header and the
|
||||
/// schema — survives: this produces a file SQLite is willing to open and
|
||||
/// then finds damaged, which is the case `quick_check` exists for. Wiping
|
||||
/// the header instead produces `SQLITE_NOTADB` at the first pragma, which
|
||||
/// is a different branch and has its own test.
|
||||
fn corrupt(path: &Path) {
|
||||
let mut f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
|
||||
let len = f.metadata().unwrap().len();
|
||||
assert!(
|
||||
len > 8192,
|
||||
"fixture is only {len} bytes; corrupting past page 2 would be a no-op"
|
||||
);
|
||||
let junk = vec![0x5a_u8; (len - 8192) as usize];
|
||||
f.seek(SeekFrom::Start(8192)).unwrap();
|
||||
f.write_all(&junk).unwrap();
|
||||
f.sync_all().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_healthy_catalog_passes() {
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
integrity_check(cat.connection()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_catalog_is_reported_as_corrupt_not_as_sqlite() {
|
||||
// The whole point of the variant: this used to arrive as whatever
|
||||
// rusqlite error the first failing query produced, with nowhere to
|
||||
// hang a recovery offer.
|
||||
let dir = tempdir("detect");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
fixture(&path, 500);
|
||||
corrupt(&path);
|
||||
|
||||
assert!(matches!(
|
||||
Catalog::open_verified(&path),
|
||||
Err(CatalogError::Corrupt { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_a_database_is_also_corrupt() {
|
||||
// A truncated or overwritten catalog never reaches `quick_check`: the
|
||||
// first pragma fails with SQLITE_NOTADB. Same accident to the user,
|
||||
// same two offers, so it must classify the same way.
|
||||
let dir = tempdir("notadb");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
std::fs::write(&path, b"this is not a catalog, it is a text file\n").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
Catalog::open(&path),
|
||||
Err(CatalogError::Corrupt { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_a_backup_recovers_the_collections_a_rebuild_would_lose() {
|
||||
// The first NFR-R6 branch, asserted on the thing that distinguishes it
|
||||
// from the second: a collection exists nowhere but the catalog, so it
|
||||
// is the evidence that the *contents* came back and not merely a
|
||||
// readable file (docs/catalog.md §8.1).
|
||||
let dir = tempdir("restore");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
fixture(&path, 500);
|
||||
|
||||
{
|
||||
let cat = Catalog::open(&path).unwrap();
|
||||
backup(cat.connection(), &path).unwrap();
|
||||
}
|
||||
corrupt(&path);
|
||||
assert!(matches!(
|
||||
Catalog::open_verified(&path),
|
||||
Err(CatalogError::Corrupt { .. })
|
||||
));
|
||||
|
||||
let newest = backups(&path).into_iter().next().expect("a backup exists");
|
||||
restore(&path, &newest.path).unwrap();
|
||||
|
||||
let cat = Catalog::open_verified(&path).unwrap();
|
||||
let name: String = cat
|
||||
.connection()
|
||||
.query_row("SELECT name FROM collections", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(name, "Iceland");
|
||||
let images: i64 = cat
|
||||
.connection()
|
||||
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(images, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_backup_is_refused_rather_than_installed() {
|
||||
let dir = tempdir("badbackup");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
fixture(&path, 500);
|
||||
{
|
||||
let cat = Catalog::open(&path).unwrap();
|
||||
backup(cat.connection(), &path).unwrap();
|
||||
}
|
||||
let newest = backups(&path).into_iter().next().unwrap();
|
||||
corrupt(&newest.path);
|
||||
corrupt(&path);
|
||||
|
||||
assert!(matches!(
|
||||
restore(&path, &newest.path),
|
||||
Err(CatalogError::Corrupt { .. })
|
||||
));
|
||||
// And the damaged catalog is still where it was, so the second offer
|
||||
// is still available.
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_aside_leaves_a_fresh_catalog_to_rebuild_into() {
|
||||
// The second NFR-R6 branch. What makes it a rebuild rather than a data
|
||||
// loss is invariant §5.2.4, which lives outside this crate — what is
|
||||
// testable here is that the damaged file is out of the way, kept, and
|
||||
// that the next open succeeds on an empty catalog at the current
|
||||
// schema, which is what a scan then fills.
|
||||
let dir = tempdir("rebuild");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
fixture(&path, 500);
|
||||
corrupt(&path);
|
||||
|
||||
let kept = set_aside(&path).unwrap().expect("the catalog was there");
|
||||
assert!(kept.exists(), "the damaged catalog was deleted, not kept");
|
||||
assert!(!path.exists());
|
||||
|
||||
let cat = Catalog::open_verified(&path).unwrap();
|
||||
let images: i64 = cat
|
||||
.connection()
|
||||
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(images, 0);
|
||||
let v: i64 = cat
|
||||
.connection()
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, schema::SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_journal_does_not_follow_the_catalog_into_recovery() {
|
||||
// The step that is easy to omit: a `-wal` belonging to the damaged
|
||||
// file is replayed into whatever takes its name next.
|
||||
let dir = tempdir("journal");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
fixture(&path, 500);
|
||||
std::fs::write(with_suffix(&path, "wal"), b"stale").unwrap();
|
||||
|
||||
set_aside(&path).unwrap();
|
||||
assert!(!with_suffix(&path, "wal").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_migration_is_backed_up_before_it_runs() {
|
||||
// NFR-R2's second clause, against a real v1 catalog rather than a
|
||||
// faked version number: the point is not that *a* file appears but
|
||||
// that it holds the state from before the migration, which is the only
|
||||
// state that is any use if the migration is what breaks it.
|
||||
let dir = tempdir("premigrate");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
{
|
||||
let c = Connection::open(&path).unwrap();
|
||||
schema::configure(&c).unwrap();
|
||||
// `v1_for_attached` names the schema it targets, and "main" is a
|
||||
// schema like any other — so this is the real v1, without needing
|
||||
// `V1` itself to become visible outside its module.
|
||||
c.execute_batch(&schema::v1_for_attached("main")).unwrap();
|
||||
c.pragma_update(None, "user_version", 1).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
crate::sync::checkpoint(&c).unwrap();
|
||||
}
|
||||
assert!(backups(&path).is_empty());
|
||||
|
||||
Catalog::open(&path).unwrap();
|
||||
|
||||
let taken = backups(&path);
|
||||
assert_eq!(taken.len(), 1, "no backup was taken before the migration");
|
||||
check_file(&taken[0].path).unwrap();
|
||||
let kept = Connection::open(&taken[0].path).unwrap();
|
||||
let v: i64 = kept
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, 1, "the backup was taken after the migration, not before");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_an_up_to_date_catalog_takes_no_backup() {
|
||||
// Or every background task that opens the catalog would copy it.
|
||||
let dir = tempdir("nobackup");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
fixture(&path, 10);
|
||||
Catalog::open(&path).unwrap();
|
||||
assert!(backups(&path).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_newest_generations_are_kept() {
|
||||
let dir = tempdir("prune");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
fixture(&path, 10);
|
||||
let cat = Catalog::open(&path).unwrap();
|
||||
|
||||
// Written by hand rather than by calling `backup` in a loop: the
|
||||
// filename carries whole seconds, so real calls would collide.
|
||||
std::fs::create_dir_all(backup_dir(&path)).unwrap();
|
||||
for t in 1..=KEEP_BACKUPS as i64 + 2 {
|
||||
drop(
|
||||
crate::sync::copy_to(
|
||||
cat.connection(),
|
||||
&backup_dir(&path).join(format!("catalog-{t}.sqlite")),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
prune(&path);
|
||||
|
||||
let kept = backups(&path);
|
||||
assert_eq!(kept.len(), KEEP_BACKUPS);
|
||||
// Newest first, and the newest is the highest timestamp.
|
||||
assert_eq!(kept[0].taken_at, KEEP_BACKUPS as i64 + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stray_file_in_the_backup_directory_is_not_offered_as_one() {
|
||||
let dir = tempdir("stray");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
fixture(&path, 10);
|
||||
std::fs::create_dir_all(backup_dir(&path)).unwrap();
|
||||
std::fs::write(backup_dir(&path).join("notes.txt"), b"hello").unwrap();
|
||||
std::fs::write(backup_dir(&path).join("catalog-7.sqlite-wal"), b"x").unwrap();
|
||||
|
||||
assert!(backups(&path).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,935 @@
|
||||
//! TRACES: FR-PLAT-AND-4 | FR-PLAT-AND-3
|
||||
//! The thing that drains the queue.
|
||||
//!
|
||||
//! [`crate::jobs`] has been a complete, durable, coalescing work queue since
|
||||
//! the catalog was written, and nothing has ever taken a job out of it. Every
|
||||
//! producer — the local walk, the remote scan — called `enqueue` and no one
|
||||
//! called `claim_next`, so the table grew one row per photograph and stayed
|
||||
//! that size forever. This module is the missing half.
|
||||
//!
|
||||
//! # Why the runner is driven rather than self-owning
|
||||
//!
|
||||
//! The obvious shape is a thread that loops until the queue is empty, and it
|
||||
//! is the wrong one. On Android the process does not decide when background
|
||||
//! work may run: `WorkManager` does, subject to Doze, battery saver and the
|
||||
//! metered-network constraints in FR-NC-6, and it revokes permission mid-job
|
||||
//! by calling `onStopped()` (FR-PLAT-AND-4). A foreground service for a
|
||||
//! user-initiated export gets a longer leash but still not an unbounded one.
|
||||
//!
|
||||
//! So the runner owns no thread, no clock and no policy. It exposes
|
||||
//! [`Runner::run_one`] — claim one job, run it, record what happened — and
|
||||
//! [`Runner::drain`], which repeats that against a [`Budget`] and a
|
||||
//! cancellation flag the host owns. A `Worker.doWork()` that must return
|
||||
//! within ten minutes calls `drain` with a deadline; a desktop idle loop calls
|
||||
//! it with none. Neither has to reach inside.
|
||||
//!
|
||||
//! Everything the host supplies is passed in for the same reason `jobs` takes
|
||||
//! `now` rather than reading the clock: a scheduler is exactly the thing that
|
||||
//! has to be testable without waiting.
|
||||
//!
|
||||
//! # Why interruption is not failure
|
||||
//!
|
||||
//! Four things can happen to a claimed job, and only two of them are the job's
|
||||
//! fault:
|
||||
//!
|
||||
//! - [`Outcome::Done`] — the row is deleted.
|
||||
//! - [`Outcome::Retry`] — the work failed and might succeed later. Backoff,
|
||||
//! and eventually [`crate::jobs::MAX_ATTEMPTS`] gives up on it.
|
||||
//! - [`Outcome::Abandon`] — the work cannot succeed, ever. Failing five times
|
||||
//! over five minutes to learn that is five minutes of a phone's battery.
|
||||
//! - [`Outcome::Interrupted`] — the *host* stopped, not the job. The claim is
|
||||
//! released and the attempt it consumed is given back, because a user who
|
||||
//! pulled the app off the screen has not told us anything about the file.
|
||||
//!
|
||||
//! Process death is the fifth case and the one that cannot report itself: the
|
||||
//! row simply stays `Running` with no owner. [`Runner::recover`] is what
|
||||
//! reclaims it, and it is why an interrupted job is resumable rather than lost
|
||||
//! (FR-PLAT-AND-3). It must run **before** any worker starts against a
|
||||
//! catalog, or it will steal a job another runner is holding — there is no
|
||||
//! owner column to tell them apart.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::CatalogError;
|
||||
use crate::jobs::{self, Job, JobKind};
|
||||
|
||||
/// What running a job turned out to be.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
/// The work is done. The row goes away.
|
||||
Done,
|
||||
/// It failed, and trying again later is reasonable. Backoff applies, and
|
||||
/// [`crate::jobs::MAX_ATTEMPTS`] eventually stops it.
|
||||
Retry(String),
|
||||
/// It failed in a way no retry can fix — the subject is gone, the payload
|
||||
/// is unreadable, the format is one this build does not know. Marked
|
||||
/// failed at once rather than burning the whole retry ladder to reach the
|
||||
/// same answer.
|
||||
Abandon(String),
|
||||
/// The host is stopping, and the job never really ran.
|
||||
///
|
||||
/// Distinct from `Retry` because it costs no attempt: `onStopped()` five
|
||||
/// times in a row would otherwise mark a perfectly good job as failed.
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
/// Something that can actually do the work a job describes.
|
||||
///
|
||||
/// The catalog knows what needs doing and nothing about how — a thumbnail
|
||||
/// needs a decoder, a fetch needs a network stack, and neither belongs under
|
||||
/// `core/dr-catalog` (ARCH §4.1: calls go downward). So the queue lives here
|
||||
/// and the handlers are supplied from above.
|
||||
pub trait JobHandler {
|
||||
/// The kinds this handler will accept.
|
||||
///
|
||||
/// Load-bearing, not documentation: the runner claims **only** kinds some
|
||||
/// handler declares. A queue holding `FetchOriginal` rows on a device with
|
||||
/// no connector must leave them alone rather than claim them and fail
|
||||
/// them, and a runner that claimed everything would do exactly that — five
|
||||
/// times each, with backoff, on battery.
|
||||
fn kinds(&self) -> &[JobKind];
|
||||
|
||||
/// Do the work.
|
||||
///
|
||||
/// The connection is offered because most handlers write their result back
|
||||
/// into the catalog; one that does not is free to ignore it. It is the
|
||||
/// runner's own connection, so a handler must not hold a transaction open
|
||||
/// across a network call — the runner needs it back to record the outcome.
|
||||
fn run(&mut self, conn: &Connection, job: &Job) -> Outcome;
|
||||
}
|
||||
|
||||
/// How much work a host is willing to let one drain do.
|
||||
///
|
||||
/// Both limits are checked *before* a job is claimed, never during one: a
|
||||
/// handler is opaque and may be halfway through writing a sidecar. Overrunning
|
||||
/// a deadline by one job is survivable; being killed mid-write is the thing
|
||||
/// [`Runner::recover`] exists to clean up after.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Budget {
|
||||
/// Stop after this many jobs. `None` means "until the queue is empty".
|
||||
pub max_jobs: Option<usize>,
|
||||
/// Stop once the clock reaches this second. Same clock the drain is given.
|
||||
pub deadline: Option<i64>,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
/// Run until nothing is left. What a desktop idle pass wants.
|
||||
pub const UNLIMITED: Self = Self {
|
||||
max_jobs: None,
|
||||
deadline: None,
|
||||
};
|
||||
|
||||
/// At most `n` jobs. A slice small enough to stay responsive.
|
||||
pub fn jobs(n: usize) -> Self {
|
||||
Self {
|
||||
max_jobs: Some(n),
|
||||
..Self::UNLIMITED
|
||||
}
|
||||
}
|
||||
|
||||
/// Until the clock reaches `deadline`. What a `WorkManager` slot wants.
|
||||
pub fn until(deadline: i64) -> Self {
|
||||
Self {
|
||||
deadline: Some(deadline),
|
||||
..Self::UNLIMITED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a drain stopped.
|
||||
///
|
||||
/// Worth distinguishing because the host's next move differs: `Drained` means
|
||||
/// there is nothing to reschedule for, and the other three all mean "there is
|
||||
/// more, ask again".
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Stopped {
|
||||
/// Nothing claimable is left.
|
||||
#[default]
|
||||
Drained,
|
||||
/// The job count ran out.
|
||||
Budget,
|
||||
/// The clock ran out.
|
||||
Deadline,
|
||||
/// The host asked it to stop, or a handler reported itself interrupted.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl Stopped {
|
||||
/// Whether the queue may still hold claimable work.
|
||||
pub fn more_to_do(self) -> bool {
|
||||
self != Stopped::Drained
|
||||
}
|
||||
}
|
||||
|
||||
/// What one drain did.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DrainReport {
|
||||
pub completed: usize,
|
||||
pub retried: usize,
|
||||
pub abandoned: usize,
|
||||
pub interrupted: usize,
|
||||
pub stopped: Stopped,
|
||||
}
|
||||
|
||||
impl DrainReport {
|
||||
/// Jobs claimed, whatever became of them. This is what a budget counts.
|
||||
pub fn ran(&self) -> usize {
|
||||
self.completed + self.retried + self.abandoned + self.interrupted
|
||||
}
|
||||
}
|
||||
|
||||
/// What a recovery pass found waiting from the last run.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Recovered {
|
||||
/// Jobs a dead process was holding. These are the resumed ones.
|
||||
pub reclaimed: usize,
|
||||
/// Jobs deleted because the photograph they name no longer exists.
|
||||
pub reaped: usize,
|
||||
}
|
||||
|
||||
impl Recovered {
|
||||
pub fn did_anything(&self) -> bool {
|
||||
self.reclaimed > 0 || self.reaped > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Ready the queue for a fresh run, before any worker touches it.
|
||||
///
|
||||
/// Two distinct cleanups, and both are startup-only:
|
||||
///
|
||||
/// - **Reclaim.** A `Running` row has no owner; the process that claimed it is
|
||||
/// gone. On Android that is a routine morning, not a crash (FR-PLAT-AND-3).
|
||||
/// The attempt it consumed is *kept*, deliberately: a job that takes the
|
||||
/// process down with it three times running should not be retried forever,
|
||||
/// and the attempt counter is the only evidence of that we have.
|
||||
/// - **Reap.** Jobs naming an image the catalog no longer has. A library that
|
||||
/// has been culled leaves thumbnail jobs for photographs that were deleted
|
||||
/// months ago, and every one of them would be claimed, run and failed.
|
||||
///
|
||||
/// Reclaim runs first so its count is the honest number of interrupted jobs,
|
||||
/// before reaping removes whichever of them pointed at nothing.
|
||||
///
|
||||
/// **Call this exactly once per catalog, at startup.** It cannot distinguish a
|
||||
/// job a dead process was holding from one a live runner is holding right now,
|
||||
/// because there is no owner column — the queue is durable, not distributed.
|
||||
pub fn recover(conn: &Connection) -> Result<Recovered, CatalogError> {
|
||||
Ok(Recovered {
|
||||
reclaimed: jobs::recover_orphaned(conn)?,
|
||||
reaped: jobs::reap_orphan_subjects(conn)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Claims work, runs it, and records what happened.
|
||||
///
|
||||
/// Borrows its connection rather than owning one so a host can drive it from
|
||||
/// the same handle it already has open. Nothing here spawns a thread; several
|
||||
/// runners on several threads, each with its own connection to the same
|
||||
/// catalog, are safe because the claim is a single atomic statement (see
|
||||
/// [`crate::jobs::claim_next`]).
|
||||
pub struct Runner<'a> {
|
||||
conn: &'a Connection,
|
||||
handlers: Vec<Box<dyn JobHandler + 'a>>,
|
||||
/// The union of every handler's kinds, cached because it is passed to
|
||||
/// every claim. This is what stops the runner claiming work it cannot do.
|
||||
claimable: Vec<JobKind>,
|
||||
}
|
||||
|
||||
impl<'a> Runner<'a> {
|
||||
/// A runner with no handlers. It can recover, and it can claim nothing.
|
||||
pub fn new(conn: &'a Connection) -> Self {
|
||||
Self {
|
||||
conn,
|
||||
handlers: Vec::new(),
|
||||
claimable: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a handler.
|
||||
pub fn with(self, handler: impl JobHandler + 'a) -> Self {
|
||||
self.with_boxed(Box::new(handler))
|
||||
}
|
||||
|
||||
/// Add a handler chosen at runtime — a network one only where there is a
|
||||
/// connector, a decoding one only where there is a decoder.
|
||||
pub fn with_boxed(mut self, handler: Box<dyn JobHandler + 'a>) -> Self {
|
||||
for kind in handler.kinds() {
|
||||
if !self.claimable.contains(kind) {
|
||||
self.claimable.push(*kind);
|
||||
}
|
||||
}
|
||||
self.handlers.push(handler);
|
||||
self
|
||||
}
|
||||
|
||||
/// The kinds this runner will claim. Useful to a host deciding whether
|
||||
/// starting it is worth waking the radio for.
|
||||
pub fn claimable(&self) -> &[JobKind] {
|
||||
&self.claimable
|
||||
}
|
||||
|
||||
/// See [`recover`]. Offered here too so a host has one thing to hold.
|
||||
pub fn recover(&self) -> Result<Recovered, CatalogError> {
|
||||
recover(self.conn)
|
||||
}
|
||||
|
||||
/// Claim one job, run it, and record the outcome.
|
||||
///
|
||||
/// `Ok(None)` means nothing this runner can do is claimable *now* — the
|
||||
/// queue may still hold work of other kinds, or work still backing off.
|
||||
pub fn run_one(&mut self, now: i64) -> Result<Option<Ran>, CatalogError> {
|
||||
// Copied out before `self.handlers` is borrowed mutably below. Both
|
||||
// are fields of `self`, but the copy is what lets the two borrows
|
||||
// coexist without the connection being reborrowed through `self`.
|
||||
let conn = self.conn;
|
||||
|
||||
let Some(job) = jobs::claim_next_matching(conn, now, &self.claimable)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let outcome = match self
|
||||
.handlers
|
||||
.iter_mut()
|
||||
.find(|h| h.kinds().contains(&job.kind))
|
||||
{
|
||||
Some(handler) => handler.run(conn, &job),
|
||||
// Unreachable by construction: `claimable` is exactly the union of
|
||||
// the handlers' kinds. Parked rather than released, because
|
||||
// releasing it would put it straight back where the next turn of
|
||||
// the drain loop would claim it again, forever.
|
||||
None => Outcome::Abandon(format!("no handler for {:?}", job.kind)),
|
||||
};
|
||||
|
||||
match &outcome {
|
||||
Outcome::Done => jobs::complete(conn, job.id)?,
|
||||
Outcome::Retry(why) => jobs::fail(conn, &job, now, why)?,
|
||||
Outcome::Abandon(why) => jobs::abandon(conn, job.id, why)?,
|
||||
Outcome::Interrupted => jobs::release(conn, &job)?,
|
||||
}
|
||||
|
||||
Ok(Some(Ran { job, outcome }))
|
||||
}
|
||||
|
||||
/// Run jobs until the budget, the flag or the queue says stop.
|
||||
///
|
||||
/// `clock` is called once per iteration rather than sampled once, because
|
||||
/// the two things it feeds both move during a long drain: the deadline
|
||||
/// check, and the `now` a failing job's backoff is measured from.
|
||||
///
|
||||
/// Cancellation is checked between jobs only. A handler that wants to bail
|
||||
/// out of work already started says so with [`Outcome::Interrupted`],
|
||||
/// which also ends the drain — otherwise a handler that always interrupts
|
||||
/// would release its job and be handed it straight back.
|
||||
pub fn drain(
|
||||
&mut self,
|
||||
clock: &dyn Fn() -> i64,
|
||||
budget: Budget,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<DrainReport, CatalogError> {
|
||||
let mut report = DrainReport::default();
|
||||
|
||||
loop {
|
||||
// Relaxed: the flag is a one-way latch set by another thread and
|
||||
// the only thing ordered against it is our own next claim. Missing
|
||||
// one turn of the loop costs a job, not correctness.
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
report.stopped = Stopped::Cancelled;
|
||||
break;
|
||||
}
|
||||
if budget.max_jobs.is_some_and(|max| report.ran() >= max) {
|
||||
report.stopped = Stopped::Budget;
|
||||
break;
|
||||
}
|
||||
|
||||
let now = clock();
|
||||
if budget.deadline.is_some_and(|end| now >= end) {
|
||||
report.stopped = Stopped::Deadline;
|
||||
break;
|
||||
}
|
||||
|
||||
let Some(ran) = self.run_one(now)? else {
|
||||
report.stopped = Stopped::Drained;
|
||||
break;
|
||||
};
|
||||
|
||||
match ran.outcome {
|
||||
Outcome::Done => report.completed += 1,
|
||||
Outcome::Retry(_) => report.retried += 1,
|
||||
Outcome::Abandon(_) => report.abandoned += 1,
|
||||
Outcome::Interrupted => {
|
||||
report.interrupted += 1;
|
||||
report.stopped = Stopped::Cancelled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Drain with no budget and no cancellation, at a fixed instant.
|
||||
///
|
||||
/// Terminates because a job that fails is pushed past `now` by its backoff
|
||||
/// and stops being claimable at this instant.
|
||||
pub fn drain_all(&mut self, now: i64) -> Result<DrainReport, CatalogError> {
|
||||
static NEVER: AtomicBool = AtomicBool::new(false);
|
||||
self.drain(&|| now, Budget::UNLIMITED, &NEVER)
|
||||
}
|
||||
}
|
||||
|
||||
/// One job and what became of it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Ran {
|
||||
pub job: Job,
|
||||
pub outcome: Outcome,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::*;
|
||||
use crate::jobs::{enqueue, JobState, Priority, MAX_ATTEMPTS};
|
||||
use crate::schema;
|
||||
|
||||
/// A handler built from a closure, so each test states its own behaviour.
|
||||
struct Fake<F> {
|
||||
kinds: Vec<JobKind>,
|
||||
act: F,
|
||||
}
|
||||
|
||||
impl<F: FnMut(&Job) -> Outcome> JobHandler for Fake<F> {
|
||||
fn kinds(&self) -> &[JobKind] {
|
||||
&self.kinds
|
||||
}
|
||||
fn run(&mut self, _conn: &Connection, job: &Job) -> Outcome {
|
||||
(self.act)(job)
|
||||
}
|
||||
}
|
||||
|
||||
fn handler<F: FnMut(&Job) -> Outcome>(kinds: &[JobKind], act: F) -> Fake<F> {
|
||||
Fake {
|
||||
kinds: kinds.to_vec(),
|
||||
act,
|
||||
}
|
||||
}
|
||||
|
||||
/// A handler that records which subjects it saw and always succeeds.
|
||||
///
|
||||
/// Takes its kinds by value and borrows nothing, so the returned handler is
|
||||
/// `Send + 'static` and can be moved into a worker thread — which the
|
||||
/// contention test needs.
|
||||
fn recording(kinds: Vec<JobKind>, seen: Arc<Mutex<Vec<i64>>>) -> impl JobHandler + Send {
|
||||
Fake {
|
||||
kinds,
|
||||
act: move |job: &Job| {
|
||||
seen.lock().unwrap().push(job.subject_id.unwrap_or(-1));
|
||||
Outcome::Done
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn db() -> Connection {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
schema::configure(&c).unwrap();
|
||||
schema::migrate(&c).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
/// An image row, so a job has a subject that exists.
|
||||
fn image(c: &Connection, id: i64) {
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', '/lib')
|
||||
ON CONFLICT DO NOTHING",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)",
|
||||
rusqlite::params![id, format!("/lib/{id}.CR3")],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn queued(c: &Connection, kind: JobKind, subject: i64) {
|
||||
image(c, subject);
|
||||
enqueue(c, kind, Some(subject), Priority::Background, None).unwrap();
|
||||
}
|
||||
|
||||
fn rows(c: &Connection) -> i64 {
|
||||
c.query_row("SELECT count(*) FROM jobs", [], |r| r.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A catalog on disk, so more than one connection can open it.
|
||||
fn temp_catalog(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"dr-runner-{name}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join("catalog.db")
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Connection {
|
||||
let c = Connection::open(path).unwrap();
|
||||
schema::configure(&c).unwrap();
|
||||
schema::migrate(&c).unwrap();
|
||||
// Several connections write to this file at once in the contention
|
||||
// tests. Without a busy handler the loser of a race gets an error
|
||||
// instead of a turn.
|
||||
c.busy_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_completed_job_leaves_the_queue() {
|
||||
// The whole finding in one assertion: before this module, the row
|
||||
// stayed forever because nothing ever claimed it.
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let report = Runner::new(&c)
|
||||
.with(recording(vec![JobKind::Thumbnail], seen.clone()))
|
||||
.drain_all(0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.completed, 1);
|
||||
assert_eq!(report.stopped, Stopped::Drained);
|
||||
assert_eq!(*seen.lock().unwrap(), vec![1]);
|
||||
assert_eq!(rows(&c), 0, "a completed job leaves no row behind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_job_backs_off_and_is_claimed_again_later() {
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
|
||||
// A `Cell` rather than a captured `bool`, so the closure's mutability
|
||||
// is its own business and the test reads the same either way.
|
||||
let failed_once = std::cell::Cell::new(false);
|
||||
let mut runner = Runner::new(&c).with(handler(&[JobKind::Thumbnail], |_| {
|
||||
if failed_once.replace(true) {
|
||||
Outcome::Done
|
||||
} else {
|
||||
Outcome::Retry("decoder said no".into())
|
||||
}
|
||||
}));
|
||||
|
||||
let first = runner.drain_all(100).unwrap();
|
||||
assert_eq!(first.retried, 1);
|
||||
assert_eq!(rows(&c), 1, "a retryable failure keeps its row");
|
||||
|
||||
// Still inside the backoff window: nothing claimable, so the drain
|
||||
// reports itself drained rather than spinning on the same job.
|
||||
assert_eq!(runner.drain_all(100).unwrap().ran(), 0);
|
||||
|
||||
let later = runner.drain_all(100 + jobs::backoff_seconds(1)).unwrap();
|
||||
assert_eq!(later.completed, 1);
|
||||
assert_eq!(rows(&c), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_that_keeps_failing_is_given_up_on() {
|
||||
// FR-RAW-4: one corrupt file must not stall the queue behind endless
|
||||
// retries. Driven through the runner rather than by hand, because the
|
||||
// runner is what a corrupt file will actually meet.
|
||||
let c = db();
|
||||
queued(&c, JobKind::ExtractMetadata, 1);
|
||||
|
||||
let mut runner = Runner::new(&c).with(handler(&[JobKind::ExtractMetadata], |_| {
|
||||
Outcome::Retry("corrupt file".into())
|
||||
}));
|
||||
|
||||
let mut now = 0;
|
||||
for _ in 0..MAX_ATTEMPTS {
|
||||
assert_eq!(runner.drain_all(now).unwrap().retried, 1);
|
||||
now += jobs::backoff_seconds(MAX_ATTEMPTS);
|
||||
}
|
||||
|
||||
assert_eq!(runner.drain_all(now + 100_000).unwrap().ran(), 0);
|
||||
let state: i64 = c
|
||||
.query_row("SELECT state FROM jobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(state, JobState::Failed as i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abandoned_job_is_not_retried_at_all() {
|
||||
// The difference that matters on battery: five failures spread over
|
||||
// five minutes to learn what the first one already said.
|
||||
let c = db();
|
||||
queued(&c, JobKind::FetchOriginal, 1);
|
||||
|
||||
let mut runner = Runner::new(&c).with(handler(&[JobKind::FetchOriginal], |_| {
|
||||
Outcome::Abandon("no connector on this device".into())
|
||||
}));
|
||||
|
||||
assert_eq!(runner.drain_all(0).unwrap().abandoned, 1);
|
||||
// One attempt, not MAX_ATTEMPTS, and never claimable again.
|
||||
assert_eq!(runner.drain_all(1_000_000).unwrap().ran(), 0);
|
||||
let (state, attempts): (i64, i64) = c
|
||||
.query_row("SELECT state, attempts FROM jobs", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(state, JobState::Failed as i64);
|
||||
assert_eq!(attempts, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interrupted_job_costs_no_attempt_and_ends_the_drain() {
|
||||
// `onStopped()` says nothing about the file. Charging it an attempt
|
||||
// would let five backgroundings mark good work as failed.
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
queued(&c, JobKind::Thumbnail, 2);
|
||||
|
||||
let report = Runner::new(&c)
|
||||
.with(handler(&[JobKind::Thumbnail], |_| Outcome::Interrupted))
|
||||
.drain_all(0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.interrupted, 1);
|
||||
assert_eq!(
|
||||
report.stopped,
|
||||
Stopped::Cancelled,
|
||||
"an interrupted job must end the drain, or releasing it hands it \
|
||||
straight back and the loop never ends"
|
||||
);
|
||||
let (state, attempts): (i64, i64) = c
|
||||
.query_row(
|
||||
"SELECT state, attempts FROM jobs WHERE subject_id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state, JobState::Pending as i64);
|
||||
assert_eq!(attempts, 0, "the claim's speculative attempt is given back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_kinds_a_handler_covers_are_claimed() {
|
||||
// A device with no connector must leave `FetchOriginal` where it is.
|
||||
// Claiming it to fail it would cost five attempts and five backoffs
|
||||
// per photograph, on battery, to reach a conclusion known in advance.
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
queued(&c, JobKind::FetchOriginal, 2);
|
||||
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let report = Runner::new(&c)
|
||||
.with(recording(vec![JobKind::Thumbnail], seen.clone()))
|
||||
.drain_all(0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.completed, 1);
|
||||
assert_eq!(*seen.lock().unwrap(), vec![1]);
|
||||
|
||||
let (state, attempts): (i64, i64) = c
|
||||
.query_row(
|
||||
"SELECT state, attempts FROM jobs WHERE subject_id = 2",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state, JobState::Pending as i64);
|
||||
assert_eq!(attempts, 0, "an unhandled job is untouched, not failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_runner_with_no_handlers_claims_nothing() {
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
|
||||
assert_eq!(Runner::new(&c).drain_all(0).unwrap().ran(), 0);
|
||||
assert_eq!(rows(&c), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drain_stops_at_its_job_budget() {
|
||||
let c = db();
|
||||
for id in 1..=5 {
|
||||
queued(&c, JobKind::Thumbnail, id);
|
||||
}
|
||||
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut runner = Runner::new(&c).with(recording(vec![JobKind::Thumbnail], seen.clone()));
|
||||
let report = runner
|
||||
.drain(&|| 0, Budget::jobs(2), &AtomicBool::new(false))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.completed, 2);
|
||||
assert_eq!(report.stopped, Stopped::Budget);
|
||||
assert!(report.stopped.more_to_do());
|
||||
assert_eq!(rows(&c), 3, "the rest is still queued for the next slot");
|
||||
assert_eq!(seen.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drain_stops_at_its_deadline() {
|
||||
// What a `WorkManager` slot does: a fixed window, and whatever did not
|
||||
// fit stays queued for the next one.
|
||||
let c = db();
|
||||
for id in 1..=5 {
|
||||
queued(&c, JobKind::Thumbnail, id);
|
||||
}
|
||||
|
||||
// A clock that advances a second per reading, so the deadline arrives
|
||||
// without the test sleeping.
|
||||
let tick = std::cell::Cell::new(0i64);
|
||||
let clock = || {
|
||||
let t = tick.get();
|
||||
tick.set(t + 1);
|
||||
t
|
||||
};
|
||||
|
||||
let report = Runner::new(&c)
|
||||
.with(handler(&[JobKind::Thumbnail], |_| Outcome::Done))
|
||||
.drain(&clock, Budget::until(3), &AtomicBool::new(false))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.stopped, Stopped::Deadline);
|
||||
assert_eq!(report.completed, 3, "one job per second up to the deadline");
|
||||
assert_eq!(rows(&c), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_drain_stops_between_jobs() {
|
||||
let c = db();
|
||||
for id in 1..=5 {
|
||||
queued(&c, JobKind::Thumbnail, id);
|
||||
}
|
||||
|
||||
let cancel = AtomicBool::new(false);
|
||||
// Cancelled from inside the handler, standing in for the host thread
|
||||
// setting the flag while a job is in flight: the job in hand finishes,
|
||||
// and nothing further is claimed.
|
||||
let report = Runner::new(&c)
|
||||
.with(handler(&[JobKind::Thumbnail], |_| {
|
||||
cancel.store(true, Ordering::Relaxed);
|
||||
Outcome::Done
|
||||
}))
|
||||
.drain(&|| 0, Budget::UNLIMITED, &cancel)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.completed, 1);
|
||||
assert_eq!(report.stopped, Stopped::Cancelled);
|
||||
assert_eq!(rows(&c), 4, "the work is kept, not lost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_interrupted_by_process_death_is_reclaimed_and_run_once() {
|
||||
// FR-PLAT-AND-3. The kill happens between the claim and the outcome,
|
||||
// which is the window a durable queue exists to survive: no `complete`,
|
||||
// no `fail`, just a row marked `Running` with nobody holding it.
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
|
||||
// The dead process. It claimed the job and never came back.
|
||||
let claimed = jobs::claim_next(&c, 0).unwrap().expect("claimable");
|
||||
assert_eq!(claimed.subject_id, Some(1));
|
||||
|
||||
// A fresh runner, before it starts, finds the queue empty — the row is
|
||||
// `Running` and no claim will touch it.
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut runner = Runner::new(&c).with(recording(vec![JobKind::Thumbnail], seen.clone()));
|
||||
assert_eq!(
|
||||
runner.drain_all(0).unwrap().ran(),
|
||||
0,
|
||||
"an orphan is invisible until it is recovered — which is exactly \
|
||||
why recovery has to happen at startup"
|
||||
);
|
||||
|
||||
let recovered = runner.recover().unwrap();
|
||||
assert_eq!(recovered.reclaimed, 1);
|
||||
|
||||
let report = runner.drain_all(0).unwrap();
|
||||
assert_eq!(report.completed, 1);
|
||||
assert_eq!(
|
||||
*seen.lock().unwrap(),
|
||||
vec![1],
|
||||
"resumed, not repeated and not lost"
|
||||
);
|
||||
assert_eq!(rows(&c), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crash_still_costs_an_attempt() {
|
||||
// Deliberate: a job that takes the process down with it every time is
|
||||
// indistinguishable from one that fails, and the attempt counter is
|
||||
// the only evidence we keep across a death. Without this a poison-pill
|
||||
// job would be reclaimed and re-run forever.
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
|
||||
for _ in 0..MAX_ATTEMPTS {
|
||||
jobs::claim_next(&c, 0).unwrap().expect("claimable");
|
||||
recover(&c).unwrap();
|
||||
}
|
||||
|
||||
let job = jobs::claim_next(&c, 0).unwrap().unwrap();
|
||||
assert!(job.attempts > MAX_ATTEMPTS);
|
||||
jobs::fail(&c, &job, 0, "died again").unwrap();
|
||||
let state: i64 = c
|
||||
.query_row("SELECT state FROM jobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(state, JobState::Failed as i64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_drops_jobs_whose_photograph_is_gone() {
|
||||
// A culled library leaves thumbnail jobs for images deleted months
|
||||
// ago. Every one would be claimed, run and failed.
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
queued(&c, JobKind::Thumbnail, 2);
|
||||
c.execute("DELETE FROM images WHERE id = 2", []).unwrap();
|
||||
|
||||
let recovered = recover(&c).unwrap();
|
||||
assert_eq!(recovered.reaped, 1);
|
||||
assert!(recovered.did_anything());
|
||||
|
||||
let left: i64 = c
|
||||
.query_row("SELECT subject_id FROM jobs", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(left, 1, "only the job whose subject survives is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quiet_startup_recovers_nothing() {
|
||||
let c = db();
|
||||
queued(&c, JobKind::Thumbnail, 1);
|
||||
assert_eq!(recover(&c).unwrap(), Recovered::default());
|
||||
assert!(!recover(&c).unwrap().did_anything());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_runners_on_one_catalog_never_take_the_same_job() {
|
||||
// Sequential rather than threaded, so the property is asserted without
|
||||
// depending on the scheduler: whatever the second connection claims,
|
||||
// it is not what the first one is holding.
|
||||
let path = temp_catalog("contention-pair");
|
||||
let a = open(&path);
|
||||
let b = open(&path);
|
||||
|
||||
for id in 1..=2 {
|
||||
queued(&a, JobKind::Thumbnail, id);
|
||||
}
|
||||
|
||||
let first = jobs::claim_next(&a, 0).unwrap().expect("one for A");
|
||||
let second = jobs::claim_next(&b, 0).unwrap().expect("one for B");
|
||||
|
||||
assert_ne!(first.id, second.id);
|
||||
assert!(
|
||||
jobs::claim_next(&a, 0).unwrap().is_none(),
|
||||
"a claimed job is invisible to every connection, not just its own"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_runners_share_the_queue_without_repeating_work() {
|
||||
// The claim is one atomic statement precisely so this holds: four
|
||||
// threads, four connections, and every job run exactly once.
|
||||
const THREADS: usize = 4;
|
||||
const JOBS: i64 = 24;
|
||||
|
||||
let path = temp_catalog("contention-threads");
|
||||
let seeder = open(&path);
|
||||
for id in 1..=JOBS {
|
||||
queued(&seeder, JobKind::Thumbnail, id);
|
||||
}
|
||||
drop(seeder);
|
||||
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut threads = Vec::new();
|
||||
for _ in 0..THREADS {
|
||||
let path = path.clone();
|
||||
let seen = seen.clone();
|
||||
threads.push(std::thread::spawn(move || {
|
||||
let conn = open(&path);
|
||||
// Bound to a local rather than left as the block's tail: the
|
||||
// `Runner` borrows `conn`, and a tail expression's temporaries
|
||||
// are dropped *after* the block's locals, so the borrow would
|
||||
// outlive what it borrows.
|
||||
let completed = Runner::new(&conn)
|
||||
.with(recording(vec![JobKind::Thumbnail], seen))
|
||||
.drain_all(0)
|
||||
.unwrap()
|
||||
.completed;
|
||||
completed
|
||||
}));
|
||||
}
|
||||
|
||||
let completed: usize = threads.into_iter().map(|t| t.join().unwrap()).sum();
|
||||
assert_eq!(completed, JOBS as usize);
|
||||
|
||||
let mut ran = seen.lock().unwrap().clone();
|
||||
ran.sort_unstable();
|
||||
assert_eq!(
|
||||
ran,
|
||||
(1..=JOBS).collect::<Vec<_>>(),
|
||||
"every job exactly once — no duplicate claim, nothing dropped"
|
||||
);
|
||||
|
||||
let leftover = open(&path);
|
||||
assert_eq!(rows(&leftover), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_survives_the_runner() {
|
||||
// NFR-ARCH-2: visible work strictly preempts bulk work, and it has to
|
||||
// still be true when the queue is drained through a handler rather
|
||||
// than by hand.
|
||||
let c = db();
|
||||
image(&c, 1);
|
||||
image(&c, 2);
|
||||
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
|
||||
enqueue(&c, JobKind::Thumbnail, Some(2), Priority::Interactive, None).unwrap();
|
||||
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
Runner::new(&c)
|
||||
.with(recording(vec![JobKind::Thumbnail], seen.clone()))
|
||||
.drain_all(0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(*seen.lock().unwrap(), vec![2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_handler_sees_the_payload_and_the_attempt_count() {
|
||||
// Both are how a handler decides what to do: the payload is the only
|
||||
// thing that survives from the enqueue site, and the attempt count is
|
||||
// how it can tell a first try from a last one.
|
||||
let c = db();
|
||||
image(&c, 1);
|
||||
enqueue(
|
||||
&c,
|
||||
JobKind::ScanFolder,
|
||||
Some(1),
|
||||
Priority::Background,
|
||||
Some("/lib/2024"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let payload = Arc::new(Mutex::new(None));
|
||||
let recorded = payload.clone();
|
||||
Runner::new(&c)
|
||||
.with(handler(&[JobKind::ScanFolder], move |job| {
|
||||
*recorded.lock().unwrap() = Some((job.payload.clone(), job.attempts));
|
||||
Outcome::Done
|
||||
}))
|
||||
.drain_all(0)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
*payload.lock().unwrap(),
|
||||
Some((Some("/lib/2024".to_string()), 1))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// Schema version this build writes and understands.
|
||||
pub const SCHEMA_VERSION: i64 = 10;
|
||||
pub const SCHEMA_VERSION: i64 = 14;
|
||||
|
||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
@@ -98,6 +98,44 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 11 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V11)?;
|
||||
tx.pragma_update(None, "user_version", 11)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 12 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V12)?;
|
||||
tx.pragma_update(None, "user_version", 12)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 13 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V13)?;
|
||||
tx.pragma_update(None, "user_version", 13)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 14 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
// `ALTER TABLE ... ADD COLUMN` has no `IF NOT EXISTS`, and NFR-R5
|
||||
// wants this re-enterable: a catalog whose `user_version` was rewound
|
||||
// by a rollback already has the column, and would otherwise fail its
|
||||
// next open on it.
|
||||
let has_quality: bool = tx
|
||||
.prepare("SELECT 1 FROM pragma_table_info('faces') WHERE name = 'quality'")?
|
||||
.exists([])?;
|
||||
if !has_quality {
|
||||
tx.execute_batch("ALTER TABLE faces ADD COLUMN quality REAL;")?;
|
||||
}
|
||||
tx.execute_batch(V14)?;
|
||||
tx.pragma_update(None, "user_version", 14)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(from)
|
||||
}
|
||||
|
||||
@@ -125,15 +163,27 @@ pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, Catalog
|
||||
|
||||
// v3: every image needs a default version to carry its rating and flag.
|
||||
// Libraries scanned before ratings existed have images and no versions at
|
||||
// all, so there was nowhere for a judgement to go — see
|
||||
// [`crate::rating`]. Backfilled rather than migrated in SQL because the
|
||||
// UUID per row is the cross-device merge identity and must be generated,
|
||||
// not derived.
|
||||
// all, so there was nowhere for a judgement to go — see [`crate::rating`].
|
||||
let n = crate::rating::ensure_default_versions(conn)?;
|
||||
if n > 0 {
|
||||
out.push(("default_versions", n));
|
||||
}
|
||||
|
||||
// TRACES: FR-NC-8 | FR-NC-9
|
||||
// The uuid on those rows is the cross-device merge identity, and it used
|
||||
// to be generated rather than derived. This comment said so, and said it
|
||||
// as though generating it were the point — it was the bug. Two devices
|
||||
// minted different uuids for one photograph, so the sidecar they shared
|
||||
// grew a `default = 1` block each and neither ever saw the other's work.
|
||||
//
|
||||
// Runs after the pass above so a row created a moment ago is already
|
||||
// derived and matches nothing here. Ordering the other way would be
|
||||
// correct too, just wasteful.
|
||||
let n = crate::rating::align_default_version_uuids(conn)?;
|
||||
if n > 0 {
|
||||
out.push(("derived_version_uuids", n));
|
||||
}
|
||||
|
||||
// v6: a vocabulary row for every word some image already carries.
|
||||
//
|
||||
// Three ways a catalog arrives holding assignments with no term behind
|
||||
@@ -205,6 +255,11 @@ pub fn v1_for_attached(schema_name: &str) -> String {
|
||||
/// creating it over there would fail on columns that are not there. Nothing is
|
||||
/// lost by its absence — it exists to make the *grid* page quickly, and the
|
||||
/// grid never reads across an attachment.
|
||||
///
|
||||
/// V11 is excluded on the same grounds and for the plainer reason that a merge
|
||||
/// has nothing to do with it: burst grouping is rebuilt locally from local
|
||||
/// signatures, and no code reads a remote catalog's `burst_*` tables. Its
|
||||
/// `ALTER TABLE` would fail here anyway, being unqualifiable by the rewrite.
|
||||
pub fn for_attached(schema_name: &str) -> String {
|
||||
// V10 is `ALTER TABLE`, which the textual rewrite cannot qualify, so its
|
||||
// columns are spelled out. A remote genuinely older than V10 is a real
|
||||
@@ -213,7 +268,8 @@ pub fn for_attached(schema_name: &str) -> String {
|
||||
format!(
|
||||
"{}\n{}\n{}\n\
|
||||
ALTER TABLE {schema_name}.people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN crop BLOB;",
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN crop BLOB;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN quality REAL;",
|
||||
rewrite_for_attached(V1, schema_name),
|
||||
rewrite_for_attached(V6, schema_name),
|
||||
rewrite_for_attached(V8, schema_name),
|
||||
@@ -397,6 +453,195 @@ ALTER TABLE people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE faces ADD COLUMN crop BLOB;
|
||||
"#;
|
||||
|
||||
/// TRACES: FR-CULL-5
|
||||
/// Burst grouping: which frames are one moment, and which one stands for it.
|
||||
///
|
||||
/// The reasoning behind the grouping itself is in [`crate::bursts`]; what
|
||||
/// belongs here is why it is stored in three pieces rather than one.
|
||||
///
|
||||
/// **`images.perceptual_hash` is a column, not a table**, for the same reason
|
||||
/// `content_hash` is: it is one number per image, NULL until something has had
|
||||
/// the pixels in hand, and every query that wants it is already reading the
|
||||
/// image row. It is local derived state — a rebuilt catalog recomputes it from
|
||||
/// thumbnails — which is also why it is absent from [`for_attached`], alongside
|
||||
/// the shadowing and trashing columns V2 through V5 add.
|
||||
///
|
||||
/// **`burst_members` is rewritten whole by every pass.** No id of its own: the
|
||||
/// group is named by the image id of its earliest frame, so a burst that has not
|
||||
/// changed keeps its name across a regroup and the interface can remember that
|
||||
/// this one is open. There is no `bursts` table to go with it because a group
|
||||
/// has no properties beyond its members — inventing a row for it would create an
|
||||
/// identity that survives the grouping being rebuilt, which is precisely what
|
||||
/// must not happen.
|
||||
///
|
||||
/// **`burst_pick` is the one thing here that is not derived**, and it is a
|
||||
/// separate table so that rewriting the grouping cannot erase it. A
|
||||
/// representative stored on `burst_members` would be forgotten every time a
|
||||
/// frame arrived; the user would be asked the same question after every scan.
|
||||
/// The same argument `people.ignored` makes in V10, one subsystem over.
|
||||
///
|
||||
/// **`burst_expanded` is view state in the catalog**, which is unusual enough to
|
||||
/// justify. The grid is a window over an ordered query — `LIMIT n OFFSET k` —
|
||||
/// so what a collapsed burst hides has to be decided by the query, or the row
|
||||
/// count stops agreeing with the scrollbar and the ordinals a scrub resolves to.
|
||||
/// Once SQL has to see it, this is where it lives. Nothing else reads it, and it
|
||||
/// is emptied of stale groups by every pass.
|
||||
const V11: &str = r#"
|
||||
-- A 64-bit perceptual signature. Local derived state: NULL until something has
|
||||
-- decoded the image, recomputed from thumbnails if the catalog is rebuilt, and
|
||||
-- comparable only to signatures produced by the same build (`bursts`).
|
||||
ALTER TABLE images ADD COLUMN perceptual_hash INTEGER;
|
||||
|
||||
CREATE TABLE burst_members (
|
||||
-- One burst at most per image: a frame belongs to the moment it was taken
|
||||
-- in, and nothing else.
|
||||
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
|
||||
-- The image id of the burst's earliest frame. Not a foreign key by
|
||||
-- accident: the leader is itself a member, so this genuinely references
|
||||
-- images(id), and cascading its deletion is right.
|
||||
burst_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
-- The frame the group collapses to. Exactly one per burst.
|
||||
representative INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
-- Counting a burst's frames and listing them are what the grid asks for, once
|
||||
-- per window; without this both are a scan of every grouped frame in the
|
||||
-- library.
|
||||
CREATE INDEX burst_members_burst ON burst_members(burst_id);
|
||||
|
||||
-- The user's own choice of representative. User data, never rewritten by a
|
||||
-- grouping pass -- see the module doc above.
|
||||
CREATE TABLE burst_pick (
|
||||
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Bursts the grid is currently showing in full.
|
||||
CREATE TABLE burst_expanded (
|
||||
burst_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE
|
||||
);
|
||||
"#;
|
||||
|
||||
const V12: &str = r#"
|
||||
-- TRACES: FR-CULL-8
|
||||
-- Forget the runs that were made against a proxy too small to find a face on.
|
||||
--
|
||||
-- Detection used to accept any proxy, and one of the two sweeps detected on
|
||||
-- the stored 1024px tier. On the reference library that produced 0.078 faces
|
||||
-- per image against 1.82 for the same photographs at 2048 or better -- and
|
||||
-- every one of those runs left a `face_index` row behind saying the image had
|
||||
-- been examined. That row is what makes the damage permanent: the work list is
|
||||
-- "images with no row", so a photograph examined badly is indistinguishable
|
||||
-- from one examined well, and is never offered to a later pass.
|
||||
--
|
||||
-- Deleting the marker is the whole repair, and it is deliberately not a
|
||||
-- deletion of anything else. The `faces` rows those runs found stay exactly
|
||||
-- where they are and keep drawing the People screen until a better pass
|
||||
-- replaces them, and `record_detections` carries the user's confirmed names
|
||||
-- across that replacement by box overlap. So this costs a re-fetch of the
|
||||
-- affected images and loses no work the user has done.
|
||||
--
|
||||
-- The threshold is written out rather than taken from `dr_face::MIN_CROP_EDGE`
|
||||
-- on purpose. A migration has to keep meaning what it meant on the day it ran;
|
||||
-- binding it to a constant someone may raise later would silently change what
|
||||
-- an old catalog gets migrated to.
|
||||
DELETE FROM face_index WHERE source_edge <= 1024;
|
||||
"#;
|
||||
|
||||
const V13: &str = r#"
|
||||
-- TRACES: FR-CAT-8 | FR-NC-9
|
||||
-- Which sidecars this device has read, and at what ETag.
|
||||
--
|
||||
-- The sidecar is the authoritative store for a rating and an edit, and until
|
||||
-- this table existed nothing ever read one back into the catalog: judgements
|
||||
-- travelled outward only. A cull done on a tablet reached the server and
|
||||
-- stopped there, because the scan indexes photographs, the derived sync moves
|
||||
-- thumbnails and collections, and the one reader that existed ran when a single
|
||||
-- photograph was opened in develop and fed only the develop graph. The grid
|
||||
-- draws `versions.rating`, so another device's afternoon of culling was
|
||||
-- invisible on this one -- permanently, by every path the app had.
|
||||
--
|
||||
-- What this holds is the ETag, not the content. It is the record of what has
|
||||
-- already been taken in, so a pull fetches only what changed: `dr_sync::scan`
|
||||
-- reports every sidecar it saw in listings it was making anyway, and this
|
||||
-- decides which of them are worth a GET.
|
||||
--
|
||||
-- Keyed on the sidecar's own remote path rather than on an image id. One
|
||||
-- sidecar can describe two images -- a RAW and the JPEG beside it are one
|
||||
-- photograph (FR-CAT-11) and share a document -- and a path is what the scan
|
||||
-- reports and what a fetch addresses, so keying on anything else would mean
|
||||
-- deriving one from the other in two places.
|
||||
--
|
||||
-- Rebuildable like the rest of the catalog: losing this table costs one pass
|
||||
-- that re-reads every sidecar and reaches exactly the same state.
|
||||
--
|
||||
-- `IF NOT EXISTS` because NFR-R5 asks for migrations that are idempotent on
|
||||
-- retry, and this one can genuinely be re-entered: a catalog whose
|
||||
-- `user_version` was rewound -- by a rollback to an older build, or by a
|
||||
-- recovery -- would otherwise fail its next open on a table it already has.
|
||||
CREATE TABLE IF NOT EXISTS sidecars (
|
||||
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
etag TEXT,
|
||||
-- Unix seconds, for diagnosing a pull that is not making progress.
|
||||
read_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(root_id, path)
|
||||
);
|
||||
"#;
|
||||
|
||||
const V14: &str = r#"
|
||||
-- TRACES: FR-CULL-9 | FR-CULL-10
|
||||
-- How recognisable the model found each face, and a second look at the faces
|
||||
-- it was never asked about.
|
||||
--
|
||||
-- The embedder's raw output has a length, and the length is a quality
|
||||
-- reading: it grows with how much of a face the model could make out, and a
|
||||
-- blur, an occlusion or a hard profile comes out short (dr_face::embedding,
|
||||
-- `MIN_GALLERY_QUALITY`). Normalising threw it away. A short vector sits
|
||||
-- near the middle of the sphere and matches a little of everyone, which is
|
||||
-- how one bad crop bridges two people in a grouping pass -- so a face below
|
||||
-- the floor is compared against the others and never compared *against*.
|
||||
--
|
||||
-- Nullable, and NULL means "never measured": every face indexed before this
|
||||
-- version stored the unit vector, whose length is one whatever the crop was.
|
||||
-- A face with no reading is admitted to the gallery, because a rule that
|
||||
-- cannot be checked should admit rather than exclude -- but it is also a
|
||||
-- face this rule is not yet protecting anyone from, and the only way to
|
||||
-- measure it is to embed it again.
|
||||
--
|
||||
-- The sweep's measuring pass is what does that: `dr_ui::library::
|
||||
-- faces_unmeasured` lists every image holding a face with no reading, and
|
||||
-- each face is embedded again from the native render with the landmarks it
|
||||
-- already has, the raw vector written over the old one (`record_measurements`)
|
||||
-- and nothing else touched -- not the id, not the box, not who the user said
|
||||
-- it was. The faces keep drawing the People screen throughout.
|
||||
--
|
||||
-- The run markers of those images are forgotten too, exactly as V12 forgot
|
||||
-- the runs made against too small a proxy. The build this shipped in had no
|
||||
-- measuring pass yet, and a marker is the one thing that stops a face ever
|
||||
-- being looked at again; with the pass in place `faces_unindexed` leaves
|
||||
-- these images to it rather than detecting them from scratch, so the
|
||||
-- deletion costs nothing -- and an image that was examined and found empty
|
||||
-- keeps its marker, since there is nothing on it to measure.
|
||||
--
|
||||
-- The cost is a re-fetch of every image with a face on it, on the next pass
|
||||
-- the user starts. That is a whole-library transfer (FR-NC-6), and it starts
|
||||
-- when they say so, not here.
|
||||
--
|
||||
-- From this version the `embedding` blob is the **raw** model output rather
|
||||
-- than the unit vector V8 describes -- the length is the quality, and a store
|
||||
-- that kept only the direction had thrown it away. Readers re-normalise on
|
||||
-- load, so a unit blob from before and a raw blob from now compare alike;
|
||||
-- `quality` is that length kept beside the blob for the readers that never
|
||||
-- load the vector, and NULL rather than 1.0 for the old rows, because a unit
|
||||
-- vector reads as a length of one and one is not "unmeasured".
|
||||
--
|
||||
-- The column itself is added in `migrate`, guarded, because ALTER has no
|
||||
-- IF NOT EXISTS and this step has to be re-enterable (NFR-R5).
|
||||
DELETE FROM face_index
|
||||
WHERE EXISTS (SELECT 1 FROM faces f
|
||||
WHERE f.image_id = face_index.image_id
|
||||
AND f.model_id = face_index.model_id);
|
||||
"#;
|
||||
|
||||
const V9: &str = r#"
|
||||
-- TRACES: FR-CULL-8
|
||||
-- A record that face detection has *run* on an image, distinct from what it
|
||||
@@ -476,7 +721,7 @@ CREATE TABLE faces (
|
||||
x REAL NOT NULL, y REAL NOT NULL, w REAL NOT NULL, h REAL NOT NULL,
|
||||
landmarks BLOB NOT NULL, -- 5 x (x, y) f32, normalised likewise
|
||||
detector_confidence REAL NOT NULL,
|
||||
embedding BLOB NOT NULL, -- 512 x f16, L2-normalised
|
||||
embedding BLOB NOT NULL, -- 512 x f16; unit length until V14, raw since
|
||||
-- Source pixels across the aligned 112x112 crop (docs/faces.md §7).
|
||||
--
|
||||
-- Not cosmetic: it is the honest quality signal for the UI, a feature in
|
||||
@@ -1189,6 +1434,108 @@ mod tests {
|
||||
assert_eq!(n, 0, "images must not outlive their root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v12_forgets_runs_made_on_a_proxy_too_small_to_see_a_face() {
|
||||
let c = mem();
|
||||
// Migrate to 11, then seed the state V12 exists to repair: markers
|
||||
// written at the 1024 store tier beside ones written on a real
|
||||
// preview.
|
||||
c.pragma_update(None, "user_version", 0).unwrap();
|
||||
migrate(&c).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'test')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (1,1,'a',0),(2,1,'b',0),(3,1,'c',0),(4,1,'d',0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
for (image, edge) in [(1, 896), (2, 1024), (3, 1025), (4, 2560)] {
|
||||
c.execute(
|
||||
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
|
||||
VALUES (?1, 'm', 0, 0, ?2)",
|
||||
rusqlite::params![image, edge],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
c.pragma_update(None, "user_version", 11).unwrap();
|
||||
|
||||
migrate(&c).unwrap();
|
||||
|
||||
let kept: Vec<i64> = c
|
||||
.prepare("SELECT image_id FROM face_index ORDER BY image_id")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
// 1024 goes: it is exactly ThumbSize::Large, the tier that produced
|
||||
// the bad runs. 1025 stays, or the floor and the repair disagree
|
||||
// about the same boundary.
|
||||
assert_eq!(kept, vec![3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v14_forgets_runs_that_found_faces_but_never_measured_them() {
|
||||
let c = mem();
|
||||
c.pragma_update(None, "user_version", 0).unwrap();
|
||||
migrate(&c).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'test')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (1,1,'a',0),(2,1,'b',0),(3,1,'c',0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Image 1 was examined and holds a face; 2 was examined and found
|
||||
// empty; 3 holds a face found by a different model.
|
||||
for (image, model) in [(1, "m"), (2, "m"), (3, "m")] {
|
||||
c.execute(
|
||||
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
|
||||
VALUES (?1, ?2, 0, 0, 2560)",
|
||||
rusqlite::params![image, model],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for (image, model) in [(1, "m"), (3, "other")] {
|
||||
c.execute(
|
||||
"INSERT INTO faces
|
||||
(image_id, x, y, w, h, landmarks, detector_confidence, embedding,
|
||||
crop_px, model_id, detected_at)
|
||||
VALUES (?1, 0.1, 0.1, 0.2, 0.2, X'00', 0.9, X'00', 180.0, ?2, 0)",
|
||||
rusqlite::params![image, model],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
c.pragma_update(None, "user_version", 13).unwrap();
|
||||
|
||||
migrate(&c).unwrap();
|
||||
|
||||
let kept: Vec<i64> = c
|
||||
.prepare("SELECT image_id FROM face_index ORDER BY image_id")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
// 1 goes: it has a face with no quality. 2 stays: nothing on it to
|
||||
// measure. 3 stays: its face belongs to a run this marker does not
|
||||
// describe.
|
||||
assert_eq!(kept, vec![2, 3]);
|
||||
// And the faces themselves are untouched.
|
||||
let faces: i64 = c
|
||||
.query_row("SELECT count(*) FROM faces", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(faces, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_uniqueness_coalesces_rather_than_duplicating() {
|
||||
let c = mem();
|
||||
|
||||
@@ -52,6 +52,21 @@ pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> {
|
||||
/// coherent even with writers active. Callers should still prefer a quiet
|
||||
/// moment — this competes with background jobs for the write lock.
|
||||
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
|
||||
let out = copy_to(conn, dest)?;
|
||||
strip_face_crops(&out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checkpoint, then copy the whole database to `dest`, and hand back the
|
||||
/// connection to the copy.
|
||||
///
|
||||
/// Split out from [`snapshot_for_upload`] because [`crate::recovery`] wants
|
||||
/// exactly this and none of what follows it there: an NFR-R2 backup is the
|
||||
/// file the user may have to *live on*, so it keeps the face crops that an
|
||||
/// upload strips. Sharing the copy rather than reimplementing it is what keeps
|
||||
/// the WAL discipline in one place — a backup taken with `fs::copy` would be
|
||||
/// the torn snapshot this module's header exists to warn about.
|
||||
pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result<Connection, CatalogError> {
|
||||
checkpoint(conn)?;
|
||||
|
||||
let mut out = Connection::open(dest)?;
|
||||
@@ -63,8 +78,7 @@ pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), Catalog
|
||||
backup.run_to_completion(i32::MAX, std::time::Duration::ZERO, None)?;
|
||||
drop(backup);
|
||||
|
||||
strip_face_crops(&out)?;
|
||||
Ok(())
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Drop the stored face crops from a snapshot before it is uploaded.
|
||||
|
||||
@@ -432,7 +432,7 @@ fn bump_generation(conn: &Connection, root: RootId, now: i64) -> Result<i64, Cat
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-9
|
||||
/// TRACES: FR-CAT-9 | FR-PLAT-AND-2
|
||||
/// Mark every image under a root as unreachable.
|
||||
///
|
||||
/// The other half of FR-CAT-9's distinction: a source *proven absent* may leave
|
||||
@@ -445,14 +445,38 @@ fn bump_generation(conn: &Connection, root: RootId, now: i64) -> Result<i64, Cat
|
||||
/// the root now claims something about the files that is no longer known to be
|
||||
/// true, and only reading the directories again can settle it. Pruning would
|
||||
/// skip them all and leave a plugged-in library showing as offline forever.
|
||||
fn mark_root_offline(conn: &Connection, root: RootId) -> Result<(), CatalogError> {
|
||||
///
|
||||
/// # Why the ETag goes with the mtime
|
||||
///
|
||||
/// The three columns are the same fact told by three kinds of storage: a local
|
||||
/// directory proves it is unchanged with its mtime and entry count, and a
|
||||
/// remote one proves it with a propagating ETag (ARCH §6.6). Clearing two of
|
||||
/// them and leaving the third would disarm the re-listing on exactly the
|
||||
/// libraries this is most likely to be called for — a remote scan prunes on
|
||||
/// the ETag alone, so a root that came back would be walked, found unchanged
|
||||
/// at every level, pruned whole, and left with every row still marked offline
|
||||
/// and nothing that would ever clear the mark.
|
||||
///
|
||||
/// # Public, because losing a root is not only the local walk's business
|
||||
///
|
||||
/// This began as the private end of [`scan_root`]'s root-failure branches,
|
||||
/// which is the only route a library reached through [`Storage`] can take.
|
||||
/// The application does not currently take that route at all: it opens
|
||||
/// libraries through `dr-sync`'s connectors, so the discovery happens in a
|
||||
/// crate that cannot see this one's internals, and the correct response is
|
||||
/// identical (FR-PLAT-AND-2). Exported rather than reimplemented beside the
|
||||
/// caller that found out — a second copy would be a second thing to remember
|
||||
/// when the ETag rule below changes.
|
||||
///
|
||||
/// [`Storage`]: dr_plat::Storage
|
||||
pub fn mark_root_offline(conn: &Connection, root: RootId) -> Result<(), CatalogError> {
|
||||
let root_id = root.0 as i64;
|
||||
conn.execute(
|
||||
"UPDATE images SET availability = ?1 WHERE root_id = ?2 AND availability != ?1",
|
||||
rusqlite::params![availability_code(Availability::Offline), root_id],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE folders SET mtime = NULL, entry_count = NULL WHERE root_id = ?1",
|
||||
"UPDATE folders SET mtime = NULL, entry_count = NULL, etag = NULL WHERE root_id = ?1",
|
||||
[root_id],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -995,6 +1019,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||
#[test]
|
||||
fn marking_a_root_offline_forgets_the_remote_validator_too() {
|
||||
// The half of the marking that only a remote library can notice, and
|
||||
// the reason it has to be here rather than beside the connector: a
|
||||
// remote scan prunes on the propagating ETag alone (ARCH §6.6). Clear
|
||||
// the local mtime and leave the ETag standing and a library that came
|
||||
// back would be walked, found unchanged at every level, pruned whole,
|
||||
// and left with every row still marked offline — with nothing that
|
||||
// would ever clear the mark, because clearing it is something only a
|
||||
// listing can do.
|
||||
//
|
||||
// Written directly because this module never writes an ETag; it is
|
||||
// `ui/dr-ui/src/library.rs`'s scan that does, against the same table.
|
||||
let lib = Library::new("etag-forgotten");
|
||||
lib.file("2026/IMG.CR3", b"raw");
|
||||
lib.scan();
|
||||
lib.conn()
|
||||
.execute(
|
||||
"UPDATE folders SET etag = 'e1' WHERE root_id = ?1",
|
||||
[lib.root.0 as i64],
|
||||
)
|
||||
.expect("etag");
|
||||
assert!(lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL") > 0);
|
||||
|
||||
mark_root_offline(lib.conn(), lib.root).expect("mark");
|
||||
|
||||
assert_eq!(
|
||||
lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL"),
|
||||
0,
|
||||
"an unreachable library must be re-listed, not pruned as unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_that_comes_back_is_available_again() {
|
||||
// The other half: a drive plugged back in must return the library to
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9
|
||||
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9 | R3
|
||||
//! Turning a rendered frame into a file's worth of bytes.
|
||||
//!
|
||||
//! # What this crate is, and is not
|
||||
@@ -179,7 +179,15 @@ pub fn export(
|
||||
settings.allow_upscaling,
|
||||
);
|
||||
|
||||
let resized = size::resample(frame, width, height);
|
||||
// TRACES: FR-EXP-3
|
||||
// One mode promises exact dimensions rather than a bound on them, and it
|
||||
// is the only place the fit/fill distinction survives: `target_size` has
|
||||
// already reported what the file will be either way.
|
||||
let resized = if settings.sizing.crops_to_fill() {
|
||||
size::resample_filling(frame, width, height)
|
||||
} else {
|
||||
size::resample(frame, width, height)
|
||||
};
|
||||
|
||||
// Scaled by how much the image actually shrank: a full-size export needs
|
||||
// no compensation, and a thumbnail needs a great deal.
|
||||
|
||||
+202
-6
@@ -25,8 +25,11 @@ const A: f32 = 3.0;
|
||||
/// TRACES: FR-EXP-3
|
||||
/// Resolve the requested sizing against a source, honouring the upscale rule.
|
||||
///
|
||||
/// Aspect is preserved in every mode, so only one dimension is ever the
|
||||
/// requested one.
|
||||
/// Aspect is preserved in every mode. In all but one that means only a single
|
||||
/// dimension is ever the requested one; [`SizingMode::FillBox`] is the
|
||||
/// exception, and it keeps the aspect by *discarding* the overhang rather than
|
||||
/// by settling for a smaller box — see [`resample_filling`], which does the
|
||||
/// discarding.
|
||||
///
|
||||
/// **Upscaling is refused by clamping, never by failing.** FR-EXP-3 makes
|
||||
/// upscaling opt-in, and a batch of mixed frames must not abort because one
|
||||
@@ -45,21 +48,56 @@ pub fn target_size(
|
||||
SizingMode::Original => (src_w, src_h),
|
||||
SizingMode::LongEdge(n) => scale_to(src_w, src_h, n, src_w >= src_h),
|
||||
SizingMode::ShortEdge(n) => scale_to(src_w, src_h, n, src_w < src_h),
|
||||
// Fit is a ceiling on both axes, so the smaller factor wins and the
|
||||
// result touches the box on one axis only.
|
||||
SizingMode::FitBox(bw, bh) => {
|
||||
scale_by(src_w, src_h, box_factor(src_w, src_h, bw, bh, f64::min))
|
||||
}
|
||||
// Fill is the box, exactly. The scale that covers it is the larger
|
||||
// factor, and the overhang is taken off in `resample_filling` — this
|
||||
// reports what the file will be, which is the whole reason the mode
|
||||
// exists.
|
||||
SizingMode::FillBox(bw, bh) => (bw.max(1), bh.max(1)),
|
||||
SizingMode::Percentage(p) => {
|
||||
let f = f64::from(p) / 100.0;
|
||||
(
|
||||
((f64::from(src_w) * f).round() as u32).max(1),
|
||||
((f64::from(src_h) * f).round() as u32).max(1),
|
||||
)
|
||||
scale_by(src_w, src_h, f)
|
||||
}
|
||||
};
|
||||
|
||||
if !allow_upscaling && (w > src_w || h > src_h) {
|
||||
// A fill box has to keep its shape even when it cannot keep its size:
|
||||
// the mode's promise is an exact aspect ratio at exact dimensions, and
|
||||
// falling back to the source's own shape would quietly export a 3:2
|
||||
// file where a 16:9 one was asked for. So the *box* is scaled down to
|
||||
// what the source can cover, rather than abandoned.
|
||||
if let SizingMode::FillBox(bw, bh) = sizing {
|
||||
let cover = box_factor(src_w, src_h, bw, bh, f64::max);
|
||||
if cover > 1.0 {
|
||||
return scale_by(bw.max(1), bh.max(1), 1.0 / cover);
|
||||
}
|
||||
}
|
||||
return (src_w, src_h);
|
||||
}
|
||||
(w.max(1), h.max(1))
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-3
|
||||
/// The scale that puts `src` against a `bw × bh` box, `choose` deciding which
|
||||
/// axis governs: `f64::min` fits inside it, `f64::max` covers it.
|
||||
fn box_factor(src_w: u32, src_h: u32, bw: u32, bh: u32, choose: fn(f64, f64) -> f64) -> f64 {
|
||||
let fw = f64::from(bw.max(1)) / f64::from(src_w.max(1));
|
||||
let fh = f64::from(bh.max(1)) / f64::from(src_h.max(1));
|
||||
choose(fw, fh)
|
||||
}
|
||||
|
||||
/// Both axes by one factor, never rounding away to nothing.
|
||||
fn scale_by(w: u32, h: u32, factor: f64) -> (u32, u32) {
|
||||
(
|
||||
((f64::from(w) * factor).round() as u32).max(1),
|
||||
((f64::from(h) * factor).round() as u32).max(1),
|
||||
)
|
||||
}
|
||||
|
||||
/// Scale so that the chosen edge lands on `n`.
|
||||
fn scale_to(src_w: u32, src_h: u32, n: u32, width_is_the_edge: bool) -> (u32, u32) {
|
||||
let n = n.max(1);
|
||||
@@ -95,6 +133,46 @@ pub fn resample(frame: &Frame, dst_w: u32, dst_h: u32) -> Vec<u8> {
|
||||
pass(&horizontal, dst_w, frame.height, dst_w, dst_h, false)
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-3
|
||||
/// Resample onto exactly `(dst_w, dst_h)`, covering the box and cutting the
|
||||
/// overhang off the middle.
|
||||
///
|
||||
/// The other half of [`SizingMode::FillBox`]. [`resample`] alone would do the
|
||||
/// job by *stretching* the frame onto the box, which is the one outcome a
|
||||
/// photographer would never accept — a 3:2 photograph squeezed onto a 16:9
|
||||
/// panel is visibly wrong in a way no amount of resolution fixes.
|
||||
///
|
||||
/// So the frame is scaled until it covers the box, on whichever axis needs the
|
||||
/// most, and the surplus is taken symmetrically off the other. Centred rather
|
||||
/// than anchored: the crop tool is where a photographer decides *which* part
|
||||
/// of the frame survives, and this stage guessing differently would fight it.
|
||||
/// Locking the crop to the export's ratio leaves nothing here to cut.
|
||||
pub fn resample_filling(frame: &Frame, dst_w: u32, dst_h: u32) -> Vec<u8> {
|
||||
let (dst_w, dst_h) = (dst_w.max(1), dst_h.max(1));
|
||||
|
||||
// Rounded *up*, and floored at the destination: a cover scale that rounds
|
||||
// down leaves the box a pixel short on one axis, and the crop below would
|
||||
// then read past the end of the buffer.
|
||||
let cover = box_factor(frame.width, frame.height, dst_w, dst_h, f64::max);
|
||||
let cw = (((f64::from(frame.width) * cover).ceil()) as u32).max(dst_w);
|
||||
let ch = (((f64::from(frame.height) * cover).ceil()) as u32).max(dst_h);
|
||||
|
||||
let covered = resample(frame, cw, ch);
|
||||
if cw == dst_w && ch == dst_h {
|
||||
return covered;
|
||||
}
|
||||
|
||||
let (x0, y0) = ((cw - dst_w) / 2, (ch - dst_h) / 2);
|
||||
let mut out = vec![0u8; (dst_w as usize) * (dst_h as usize) * 4];
|
||||
for y in 0..dst_h as usize {
|
||||
let src = ((y + y0 as usize) * cw as usize + x0 as usize) * 4;
|
||||
let dst = y * dst_w as usize * 4;
|
||||
let run = dst_w as usize * 4;
|
||||
out[dst..dst + run].copy_from_slice(&covered[src..src + run]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One separable pass. `horizontal` picks the axis being resampled.
|
||||
fn pass(src: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32, horizontal: bool) -> Vec<u8> {
|
||||
let (src_len, dst_len) = if horizontal {
|
||||
@@ -219,6 +297,124 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fit_box_stays_inside_the_box_and_keeps_its_shape() {
|
||||
// Fit is a ceiling on both axes, so a 3:2 frame in a 16:9 box comes
|
||||
// back short of the box's width, never past its height.
|
||||
assert_eq!(
|
||||
target_size(6000, 4000, SizingMode::FitBox(3840, 2160), false),
|
||||
(3240, 2160)
|
||||
);
|
||||
// Portrait into the same box: now the height governs nothing and the
|
||||
// width does.
|
||||
assert_eq!(
|
||||
target_size(4000, 6000, SizingMode::FitBox(3840, 2160), false),
|
||||
(1440, 2160)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_box_is_the_box_exactly() {
|
||||
// The whole point of the mode. A display that accepts one resolution
|
||||
// and rejects everything else has to get that resolution whatever the
|
||||
// photograph's own shape is.
|
||||
for (w, h) in [(6000u32, 4000u32), (4000, 6000), (5000, 5000)] {
|
||||
assert_eq!(
|
||||
target_size(w, h, SizingMode::FillBox(3840, 2160), false),
|
||||
(3840, 2160),
|
||||
"{w}x{h} did not fill the box"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_box_too_large_for_the_source_keeps_its_shape_not_the_sources() {
|
||||
// Upscaling off, and the source cannot cover 4K. Falling back to the
|
||||
// source's own size would export a 3:2 file where 16:9 was asked
|
||||
// for — silently wrong in exactly the way the mode exists to prevent.
|
||||
// The box shrinks instead.
|
||||
let (w, h) = target_size(1600, 1200, SizingMode::FillBox(3840, 2160), false);
|
||||
assert!(w <= 1600 && h <= 1200, "upscaled to {w}x{h}");
|
||||
let want = 3840.0 / 2160.0;
|
||||
assert!(
|
||||
((w as f64 / h as f64) / want - 1.0).abs() < 0.01,
|
||||
"{w}x{h} is not the box's shape"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_box_within_the_source_is_honoured_with_upscaling_off() {
|
||||
// The ordinary case: a 24 MP frame has pixels to spare for a 4K panel,
|
||||
// so nothing is being enlarged and the clamp must not fire.
|
||||
assert_eq!(
|
||||
target_size(6000, 4000, SizingMode::FillBox(3840, 2160), false),
|
||||
(3840, 2160)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filled_frame_comes_back_at_exactly_the_box() {
|
||||
let f = frame(128, 64);
|
||||
// Wider than the source's 2:1, so the crop comes off the width.
|
||||
assert_eq!(resample_filling(&f, 40, 40).len(), 40 * 40 * 4);
|
||||
assert_eq!(resample_filling(&f, 100, 25).len(), 100 * 25 * 4);
|
||||
// Already the box: no work, and no drift.
|
||||
assert_eq!(resample_filling(&f, 128, 64), f.rgba);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_crops_rather_than_stretching() {
|
||||
// The property that separates fill from handing the box straight to
|
||||
// `resample`. The test frame ramps red left to right, so a 2:1 source
|
||||
// squeezed into a square would compress that ramp into the full
|
||||
// width — where a centre crop keeps its middle, and therefore starts
|
||||
// and ends well inside the source's own range.
|
||||
let f = frame(128, 128);
|
||||
let square = resample(&f, 64, 64);
|
||||
let filled = resample_filling(&f, 32, 64);
|
||||
|
||||
let left = |b: &[u8]| b[0];
|
||||
let right = |b: &[u8], w: usize| b[(w - 1) * 4];
|
||||
|
||||
assert!(
|
||||
left(&filled) > left(&square),
|
||||
"a centre crop must start further into the ramp"
|
||||
);
|
||||
assert!(
|
||||
right(&filled, 32) < right(&square, 64),
|
||||
"a centre crop must end further from the ramp's end"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_takes_the_overhang_evenly_off_both_sides() {
|
||||
// Centred, not anchored: the crop tool is where a photographer decides
|
||||
// which part of the frame survives, and this stage must not have an
|
||||
// opinion of its own.
|
||||
let f = frame(128, 128);
|
||||
let filled = resample_filling(&f, 32, 64);
|
||||
let px = |x: usize| filled[x * 4];
|
||||
// The ramp is horizontal, so a centred crop is symmetric about the
|
||||
// frame's own midpoint: the two ends should sit equally far from it.
|
||||
let mid = i32::from(resample(&f, 128, 128)[64 * 4]);
|
||||
let lo = i32::from(px(0));
|
||||
let hi = i32::from(px(31));
|
||||
assert!(
|
||||
((mid - lo) - (hi - mid)).abs() < 8,
|
||||
"not centred: {lo} .. {mid} .. {hi}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_field_survives_a_fill_unchanged() {
|
||||
// Same guard as the fit path: any deviation means the cover scale and
|
||||
// the crop disagree about where the pixels are.
|
||||
let flat = Frame::new(64, 48, vec![200; 64 * 48 * 4]).unwrap();
|
||||
for byte in resample_filling(&flat, 30, 30) {
|
||||
assert_eq!(byte, 200);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upscaling_is_refused_by_clamping_rather_than_failing() {
|
||||
// FR-EXP-3: opt-in, and a batch must not abort over one small frame.
|
||||
|
||||
@@ -63,15 +63,16 @@ fn main() {
|
||||
let embed_ms = t.elapsed().as_secs_f64() * 1e3;
|
||||
|
||||
println!(
|
||||
" [{i}] conf {:.3} box {:.0},{:.0} {:.0}×{:.0} crop_px {:.0} embed {embed_ms:.0} ms",
|
||||
" [{i}] conf {:.3} box {:.0},{:.0} {:.0}×{:.0} crop_px {:.0} quality {:.1} embed {embed_ms:.0} ms",
|
||||
d.confidence,
|
||||
d.bbox.0,
|
||||
d.bbox.1,
|
||||
d.width(),
|
||||
d.height(),
|
||||
aligned.source_px(),
|
||||
emb.quality,
|
||||
);
|
||||
all.push((path.clone(), i, emb));
|
||||
all.push((path.clone(), i, emb.embedding));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! How fast this machine can scan a library for face pairs.
|
||||
//!
|
||||
//! cargo run --release -p dr-face --example scan_bench [-- FACES…]
|
||||
//!
|
||||
//! Synthetic embeddings, because the scan's cost is `n²/2` dot products and
|
||||
//! does not care what the vectors mean — which is what makes this runnable on
|
||||
//! a phone with no library on it, over `adb shell`, beside
|
||||
//! `tools/face-tests-on-device.sh`.
|
||||
//!
|
||||
//! # What it is for
|
||||
//!
|
||||
//! docs/faces.md §9 has the desktop numbers and the question they leave open:
|
||||
//! a GPU GEMM is worth roughly 1.5× of a regroup on a twenty-core desktop,
|
||||
//! because the scan is under a third of the pass there. On a tablet the CPU is
|
||||
//! several times slower and the GPU is not, so the same optimisation is worth
|
||||
//! something different — and nobody had measured which.
|
||||
//!
|
||||
//! No weights, no catalog, no display: it needs nothing but the binary.
|
||||
|
||||
use dr_face::neighbours::{above_threshold, Faces};
|
||||
use dr_face::{Calibration, EMBEDDING_DIM, RIVAL_FLOOR};
|
||||
|
||||
/// Sizes to time, unless the command line names others.
|
||||
const DEFAULT_SIZES: [usize; 4] = [2_000, 4_000, 8_000, 18_000];
|
||||
|
||||
fn main() {
|
||||
let sizes: Vec<usize> = {
|
||||
let given: Vec<usize> = std::env::args()
|
||||
.skip(1)
|
||||
.filter_map(|a| a.parse().ok())
|
||||
.collect();
|
||||
if given.is_empty() {
|
||||
DEFAULT_SIZES.to_vec()
|
||||
} else {
|
||||
given
|
||||
}
|
||||
};
|
||||
|
||||
// The reference curve, so the cosine floor is the one a real library with
|
||||
// no fit of its own would scan at.
|
||||
let cal = Calibration::default();
|
||||
|
||||
println!(
|
||||
"{:>8} {:>9} {:>10} {:>9}",
|
||||
"faces", "scan", "pairs", "GFLOP/s"
|
||||
);
|
||||
for n in sizes {
|
||||
let (embeddings, crop_px, images) = population(n);
|
||||
let gallery = vec![true; n];
|
||||
let faces = Faces {
|
||||
embeddings: &embeddings,
|
||||
dim: EMBEDDING_DIM,
|
||||
crop_px: &crop_px,
|
||||
images: &images,
|
||||
gallery: &gallery,
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let pairs = above_threshold(&faces, &cal, RIVAL_FLOOR);
|
||||
let secs = start.elapsed().as_secs_f64();
|
||||
|
||||
let flop = n as f64 * n as f64 / 2.0 * EMBEDDING_DIM as f64 * 2.0;
|
||||
println!(
|
||||
"{n:>8} {secs:>8.2}s {:>10} {:>9.1}",
|
||||
pairs.len(),
|
||||
flop / secs / 1e9
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` L2-normalised embeddings in a handful of loose clusters.
|
||||
///
|
||||
/// Clustered rather than uniform so the scan finds a plausible number of pairs
|
||||
/// to keep — a population where nothing survives the threshold would time the
|
||||
/// rejection path alone, which is not the path that matters. Hashed from an
|
||||
/// index rather than drawn from an RNG, so a number is reproducible from the
|
||||
/// command that produced it.
|
||||
fn population(n: usize) -> (Vec<f32>, Vec<f32>, Vec<u64>) {
|
||||
let identities = (n / 12).max(1);
|
||||
let mut embeddings = Vec::with_capacity(n * EMBEDDING_DIM);
|
||||
for i in 0..n {
|
||||
let mut v = unit(i % identities);
|
||||
let noise = unit(i + 1_000_000);
|
||||
for (x, e) in v.iter_mut().zip(&noise) {
|
||||
*x = 0.75 * *x + 0.25 * e;
|
||||
}
|
||||
embeddings.extend(normalise(v));
|
||||
}
|
||||
// Every face in its own photograph: the co-occurrence rule skips pairs
|
||||
// rather than scoring them, and skipped pairs are not what is being timed.
|
||||
((embeddings), vec![150.0; n], (0..n as u64).collect())
|
||||
}
|
||||
|
||||
fn unit(seed: usize) -> Vec<f32> {
|
||||
let mut s = (seed as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
|
||||
let mut v = Vec::with_capacity(EMBEDDING_DIM);
|
||||
for _ in 0..EMBEDDING_DIM {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 7;
|
||||
s ^= s << 17;
|
||||
v.push(((s >> 11) as f64 / (1u64 << 53) as f64) as f32 - 0.5);
|
||||
}
|
||||
normalise(v)
|
||||
}
|
||||
|
||||
fn normalise(mut v: Vec<f32>) -> Vec<f32> {
|
||||
let len = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
for x in &mut v {
|
||||
*x /= len;
|
||||
}
|
||||
v
|
||||
}
|
||||
+95
-10
@@ -285,7 +285,67 @@ pub fn warp(
|
||||
height: usize,
|
||||
landmarks: &[(f32, f32); 5],
|
||||
) -> Option<Aligned112> {
|
||||
if rgb.len() != width * height * 3 {
|
||||
warp_pixels(Pixels::RgbF32(rgb), width, height, landmarks)
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-8
|
||||
/// What the warp may sample, in whichever layout the caller already holds.
|
||||
///
|
||||
/// # Why the 8-bit variant exists
|
||||
///
|
||||
/// FR-CULL-8 requires the crop to come from the **native** render, and a native
|
||||
/// render is large: a 24 MP frame is 96 MB as `RGBA8` and 288 MB converted to
|
||||
/// the `f32` RGB this module was originally written against. Converting the
|
||||
/// whole frame to sample 112×112 from it is three hundred megabytes allocated
|
||||
/// to read about forty thousand pixels, per image, on a pass that runs over a
|
||||
/// whole library — and on Android it is NFR-RES-2's budget spent outright.
|
||||
///
|
||||
/// So the warp reads whatever the caller has instead. It touches so few pixels
|
||||
/// that the per-sample conversion is free, and the buffer never has to be
|
||||
/// duplicated in another layout.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Pixels<'a> {
|
||||
/// Tightly packed `f32` RGB in `0.0..=1.0`, row-major.
|
||||
RgbF32(&'a [f32]),
|
||||
/// Tightly packed 8-bit RGBA, row-major. Alpha is ignored: a face crop has
|
||||
/// no use for it and carrying it would change what the embedder receives.
|
||||
Rgba8(&'a [u8]),
|
||||
}
|
||||
|
||||
impl Pixels<'_> {
|
||||
/// Whether the buffer is the size `width × height` implies.
|
||||
fn fits(&self, width: usize, height: usize) -> bool {
|
||||
match self {
|
||||
Pixels::RgbF32(v) => v.len() == width * height * 3,
|
||||
Pixels::Rgba8(v) => v.len() == width * height * 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// One channel of one pixel, as `0.0..=1.0`. Outside the buffer reads black.
|
||||
///
|
||||
/// Public because the face *crop* stored for the People screen is cut from
|
||||
/// the same buffer by the same caller, and it should not need a second
|
||||
/// copy of this to do it.
|
||||
pub fn channel(&self, w: usize, h: usize, x: isize, y: isize, c: usize) -> f32 {
|
||||
if x < 0 || y < 0 || x >= w as isize || y >= h as isize {
|
||||
return 0.0;
|
||||
}
|
||||
let i = y as usize * w + x as usize;
|
||||
match self {
|
||||
Pixels::RgbF32(v) => v[i * 3 + c],
|
||||
Pixels::Rgba8(v) => v[i * 4 + c] as f32 / 255.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`warp`], over any layout [`Pixels`] describes.
|
||||
pub fn warp_pixels(
|
||||
px: Pixels<'_>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
landmarks: &[(f32, f32); 5],
|
||||
) -> Option<Aligned112> {
|
||||
if !px.fits(width, height) {
|
||||
return None;
|
||||
}
|
||||
let m = fit_similarity(landmarks, &ARCFACE_TEMPLATE)?;
|
||||
@@ -300,7 +360,7 @@ pub fn warp(
|
||||
let (x, y) = m.invert(u as f32 + 0.5, v as f32 + 0.5);
|
||||
let (x, y) = (x - 0.5, y - 0.5);
|
||||
let out = (v * e + u) * 3;
|
||||
sample_bilinear(rgb, width, height, x, y, &mut pixels[out..out + 3]);
|
||||
sample_bilinear(px, width, height, x, y, &mut pixels[out..out + 3]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +372,7 @@ pub fn warp(
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_bilinear(rgb: &[f32], w: usize, h: usize, x: f32, y: f32, out: &mut [f32]) {
|
||||
fn sample_bilinear(px: Pixels<'_>, w: usize, h: usize, x: f32, y: f32, out: &mut [f32]) {
|
||||
let x0 = x.floor();
|
||||
let y0 = y.floor();
|
||||
let fx = x - x0;
|
||||
@@ -321,13 +381,7 @@ fn sample_bilinear(rgb: &[f32], w: usize, h: usize, x: f32, y: f32, out: &mut [f
|
||||
let y0 = y0 as isize;
|
||||
|
||||
for (c, o) in out.iter_mut().enumerate() {
|
||||
let get = |xi: isize, yi: isize| -> f32 {
|
||||
if xi < 0 || yi < 0 || xi >= w as isize || yi >= h as isize {
|
||||
0.0
|
||||
} else {
|
||||
rgb[(yi as usize * w + xi as usize) * 3 + c]
|
||||
}
|
||||
};
|
||||
let get = |xi: isize, yi: isize| -> f32 { px.channel(w, h, xi, yi, c) };
|
||||
let top = get(x0, y0) * (1.0 - fx) + get(x0 + 1, y0) * fx;
|
||||
let bot = get(x0, y0 + 1) * (1.0 - fx) + get(x0 + 1, y0 + 1) * fx;
|
||||
*o = top * (1.0 - fy) + bot * fy;
|
||||
@@ -508,6 +562,37 @@ mod tests {
|
||||
/// against a bright sky is low-contrast, and a raw Laplacian variance would
|
||||
/// reject it as blurred — which would quietly throw away every backlit
|
||||
/// portrait in the library.
|
||||
#[test]
|
||||
fn both_pixel_layouts_warp_to_the_same_crop() {
|
||||
// The 8-bit path exists so a native render need not be converted to
|
||||
// f32 whole; it has to agree with the path it replaces to within the
|
||||
// quantisation it introduces.
|
||||
let (w, h) = (64usize, 64usize);
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
let mut rgb = vec![0.0f32; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let v = [
|
||||
(x * 4 % 256) as u8,
|
||||
(y * 4 % 256) as u8,
|
||||
((x + y) % 256) as u8,
|
||||
];
|
||||
for c in 0..3 {
|
||||
rgba[(y * w + x) * 4 + c] = v[c];
|
||||
rgb[(y * w + x) * 3 + c] = v[c] as f32 / 255.0;
|
||||
}
|
||||
rgba[(y * w + x) * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
let lm = shifted_scaled(0.35, 32.0, 32.0, 0.2);
|
||||
let a = warp_pixels(Pixels::RgbF32(&rgb), w, h, &lm).unwrap();
|
||||
let b = warp_pixels(Pixels::Rgba8(&rgba), w, h, &lm).unwrap();
|
||||
assert_eq!(a.source_px(), b.source_px());
|
||||
for (x, y) in a.pixels().iter().zip(b.pixels()) {
|
||||
assert!((x - y).abs() < 1e-6, "{x} vs {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharpness_survives_the_contrast_being_halved() {
|
||||
let edge = 200;
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! TRACES: FR-CULL-9 | FR-CULL-10
|
||||
//! How sure a *suggestion* is: an identity's share of the evidence for a face.
|
||||
//!
|
||||
//! [`crate::cluster`] decides which people exist; this decides what number to
|
||||
//! put beside "we think this is Anna". They are not the same question, and the
|
||||
//! answer to the second used to be a by-product of the first — the mean
|
||||
//! calibrated probability between a face and *every* other member of its group.
|
||||
//!
|
||||
//! # Why a mean over the group is the wrong number
|
||||
//!
|
||||
//! It measures the wrong thing twice over.
|
||||
//!
|
||||
//! **It punishes large, well-photographed people.** Anna has two hundred faces
|
||||
//! spanning fifteen years; a new photograph of her matches thirty of them
|
||||
//! strongly and is near-orthogonal to the rest, because a face at 8 and a face
|
||||
//! at 23 genuinely are. The mean lands around 0.2 and the interface reports a
|
||||
//! correct suggestion as a doubtful one. The better a person is covered, the
|
||||
//! worse their confidences get, which is exactly backwards.
|
||||
//!
|
||||
//! **It never asks who else it could be.** A face that matches Anna at 0.95 and
|
||||
//! matches nobody else at all, and a face that matches Anna at 0.95 *and her
|
||||
//! sister at 0.93*, are the same number under a within-group mean. The second
|
||||
//! is the one the user actually needs to look at, and it was indistinguishable
|
||||
//! from the first.
|
||||
//!
|
||||
//! # Coherence, times uniqueness
|
||||
//!
|
||||
//! Two questions, and the number is their product because they are genuinely
|
||||
//! independent: *is this the same person at all*, and *of the people we know,
|
||||
//! is it uniquely this one*.
|
||||
//!
|
||||
//! ```text
|
||||
//! evidence(P) = Σ of the top n of { P(same | this face, f) : f ∈ P }
|
||||
//! coherence = evidence(own) / (however many of the top n there were)
|
||||
//! uniqueness = evidence(own) / (evidence(own) + Σ evidence(named rivals))
|
||||
//! confidence = coherence × uniqueness
|
||||
//! ```
|
||||
//!
|
||||
//! **Coherence** is a mean, like the old number, but over the face's best
|
||||
//! [`TOP_MATCHES`] matches into the identity rather than over all of them. That single cap is what
|
||||
//! stops a well-photographed person scoring worse than a thin one: the two
|
||||
//! hundred faces a given photograph is legitimately orthogonal to no longer
|
||||
//! count against it.
|
||||
//!
|
||||
//! **Uniqueness** is the competition. A sole strong match leaves it at 1 and
|
||||
//! the confidence is the coherence; two identities matching equally well pull
|
||||
//! it to 0.5 each, and the screen has told the user the truth, which is that
|
||||
//! this face is a coin toss between two people.
|
||||
//!
|
||||
//! # Only the people the user has named compete
|
||||
//!
|
||||
//! Measured on a real 18,000-face library, normalising across *every* group
|
||||
//! made the number useless: the median suggestion read 21% and four in five
|
||||
//! read under half. The cause is not a bug in the arithmetic but a fact about
|
||||
//! clustering — one person is spread across many groups, since the pairs that
|
||||
//! would have joined them are the ones that fell short of the merge threshold.
|
||||
//! Normalising over groups therefore makes a face compete against *itself*,
|
||||
//! and the better covered the person, the more fragments there are to lose to.
|
||||
//!
|
||||
//! A fragment is not a rival. An identity the user has actually asserted is, so
|
||||
//! the denominator counts only the people they have ruled on — a group carries
|
||||
//! a [`Cluster::person`] when it holds a confirmation, a name, or an ignore —
|
||||
//! and counts them **per person, not per group**, since one person is left in
|
||||
//! several anchored groups for the same reason. Keying it by group had
|
||||
//! Catherine competing with Catherine and put the median suggestion onto a
|
||||
//! named person at 39%; keying it by person put it at 99.5%.
|
||||
//!
|
||||
//! Leave-one-out over that library's 2,702 confirmations across 54 named
|
||||
//! people, this is the regime where the number is worth having: 99.3% of faces
|
||||
//! are placed on the right person (the old mean managed 99.15%), the stated
|
||||
//! percentage is monotone in being right, and it errs low — 100% correct
|
||||
//! wherever it states 80% or more, 84% correct where it states under half.
|
||||
//! Understating is the safe direction for a screen whose whole purpose is
|
||||
//! deciding what to look at first, but it is *not* calibrated in the low bands
|
||||
//! and should not be read as though it were.
|
||||
//!
|
||||
//! Two faces of one unnamed group cannot be told from two fragments of one
|
||||
//! person by similarity alone; that is exactly why clustering stopped where it
|
||||
//! did. So the module does not pretend to: where nobody is named, uniqueness is
|
||||
//! 1 and the number falls back to plain coherence.
|
||||
//!
|
||||
//! # What it does not do
|
||||
//!
|
||||
//! It is not a merge threshold and must not become one. Clustering keeps
|
||||
//! deciding on the pairwise calibrated probability: uniqueness is *relative*,
|
||||
//! so a library with one named person in it would hand every stray face a
|
||||
//! uniqueness of 1. The absolute question ("is this the same person at all")
|
||||
//! and the comparative one ("of the people we know, which") are different, and
|
||||
//! the product is what keeps both in the answer.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::cluster::Cluster;
|
||||
use crate::neighbours::Pair;
|
||||
|
||||
/// Who the evidence is for.
|
||||
///
|
||||
/// Ordered rather than hashed so the sums below are reproducible; the ordering
|
||||
/// itself carries no meaning.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Identity {
|
||||
/// A person the user has confirmed a face onto. Every group anchored to
|
||||
/// them is the same identity, however many of them the clusterer left.
|
||||
Person(u64),
|
||||
/// A group nobody has ruled on. It stands for itself and competes with
|
||||
/// nothing.
|
||||
Group(usize),
|
||||
}
|
||||
|
||||
/// How many of an identity's best matches count as its evidence.
|
||||
///
|
||||
/// The cap is the whole reason the sum works: uncapped, evidence would grow
|
||||
/// with a person's face count and the largest group in the library would win
|
||||
/// every contest. Ten is enough that a person photographed from several angles
|
||||
/// contributes more than one lucky frame, and small enough that the hundred
|
||||
/// mediocre matches inside a well-covered identity cannot add up to a strong
|
||||
/// one. It is a starting point, not a measured optimum — M7's corpus is where
|
||||
/// it would be tuned.
|
||||
pub const TOP_MATCHES: usize = 10;
|
||||
|
||||
/// The weakest match that counts as evidence for an identity.
|
||||
///
|
||||
/// Rivals are half the point of this module, so the evidence scan has to reach
|
||||
/// *below* the merge threshold — a named person who matches at 0.6 will never
|
||||
/// be merged into but is precisely the competition a suggestion should be
|
||||
/// discounted for. Even odds is the natural floor: below it a pair is more
|
||||
/// likely different people than the same, and it is not a small distinction —
|
||||
/// summing the near-orthogonal pairs instead of dropping them lets fifty
|
||||
/// identities' worth of noise, each contributing its *upper tail*, outweigh one
|
||||
/// real match. Measured on a real library, that alone moved the median stated
|
||||
/// confidence from 100% to 31%.
|
||||
pub const RIVAL_FLOOR: f32 = 0.5;
|
||||
|
||||
/// How confident each face's placement is, indexed like the face slice the
|
||||
/// clusters came from.
|
||||
///
|
||||
/// A face in no group, or one with no evidence for anybody, scores 0.
|
||||
///
|
||||
/// `gallery` is one flag per face — which faces may be evidence at all
|
||||
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]). Its length is the face count.
|
||||
/// A pair is evidence *about* either face but only *from* a gallery one: a
|
||||
/// probe learns from the references it matched, and a reference learns nothing
|
||||
/// from a probe that happened to match it, however well. Without that, the one
|
||||
/// short vector in a group would be the strongest match every face in it had.
|
||||
///
|
||||
/// `pairs` must be the *evidence* list — scanned at [`RIVAL_FLOOR`], not at the
|
||||
/// merge threshold. Passing the merge list still works but silently removes
|
||||
/// every rival weaker than a merge, which is most of them, and every uniqueness
|
||||
/// collapses to 1.
|
||||
pub fn identity_shares(
|
||||
gallery: &[bool],
|
||||
clusters: &[Cluster],
|
||||
pairs: &[Pair],
|
||||
top: usize,
|
||||
) -> Vec<f32> {
|
||||
let faces = gallery.len();
|
||||
// An identity is a *person*, not a group. One person routinely holds
|
||||
// several anchored groups — the same reason they hold several unnamed ones
|
||||
// — and keying this by group had Catherine competing with Catherine, which
|
||||
// on the library it was measured against put the median suggestion onto a
|
||||
// named person at 39%.
|
||||
let key_of: Vec<Identity> = clusters
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(g, c)| match c.person {
|
||||
Some(p) => Identity::Person(p),
|
||||
None => Identity::Group(g),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut group_of = vec![usize::MAX; faces];
|
||||
for (g, c) in clusters.iter().enumerate() {
|
||||
for &m in &c.members {
|
||||
if m < faces {
|
||||
group_of[m] = g;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ordered, not hashed: the numbers are sums of floats over these buckets
|
||||
// and this module inherits [`crate::cluster`]'s promise that the same input
|
||||
// yields the same output, bit for bit.
|
||||
let mut evidence: Vec<BTreeMap<Identity, Vec<f32>>> = vec![BTreeMap::new(); faces];
|
||||
for p in pairs {
|
||||
if p.i >= faces || p.j >= faces {
|
||||
continue;
|
||||
}
|
||||
// A pair is evidence in both directions: j's identity hears about i,
|
||||
// and i's identity hears about j. The pair list holds each unordered
|
||||
// pair once, so both have to be recorded here — each only where the
|
||||
// face doing the telling is in the gallery.
|
||||
let (gi, gj) = (group_of[p.i], group_of[p.j]);
|
||||
if gj != usize::MAX && gallery[p.j] {
|
||||
evidence[p.i]
|
||||
.entry(key_of[gj])
|
||||
.or_default()
|
||||
.push(p.probability);
|
||||
}
|
||||
if gi != usize::MAX && gallery[p.i] {
|
||||
evidence[p.j]
|
||||
.entry(key_of[gi])
|
||||
.or_default()
|
||||
.push(p.probability);
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = vec![0.0; faces];
|
||||
for (i, buckets) in evidence.iter_mut().enumerate() {
|
||||
let mine = group_of[i];
|
||||
if mine == usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let mine = key_of[mine];
|
||||
let mut coherence = 0.0;
|
||||
let mut ours = 0.0;
|
||||
let mut rivals = 0.0;
|
||||
for (&who, probabilities) in buckets.iter_mut() {
|
||||
// Descending, and the ties broken by nothing: equal probabilities
|
||||
// sum the same whichever order they land in.
|
||||
probabilities.sort_by(|a, b| b.total_cmp(a));
|
||||
let counted = probabilities.len().min(top);
|
||||
let score: f32 = probabilities.iter().take(top).sum();
|
||||
if who == mine {
|
||||
ours = score;
|
||||
coherence = score / counted as f32;
|
||||
} else if matches!(who, Identity::Person(_)) {
|
||||
// Only an identity the user has ruled on competes. A group
|
||||
// nobody has ruled on and that matches this face is far more
|
||||
// likely to be another fragment of the same person than a
|
||||
// different one — see the module note, and the library it was
|
||||
// measured on.
|
||||
rivals += score;
|
||||
}
|
||||
}
|
||||
let total = ours + rivals;
|
||||
// No evidence at all: a face anchored into a group it has no measured
|
||||
// similarity to. Nothing honest to report, so nothing is claimed.
|
||||
out[i] = if total > 0.0 {
|
||||
coherence * (ours / total)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An unnamed group: nobody has ruled on it, so it competes with nothing.
|
||||
fn cluster(members: &[usize]) -> Cluster {
|
||||
Cluster {
|
||||
members: members.to_vec(),
|
||||
person: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A group the user has confirmed a face onto — an identity, and therefore
|
||||
/// a rival.
|
||||
fn named(members: &[usize], person: u64) -> Cluster {
|
||||
Cluster {
|
||||
members: members.to_vec(),
|
||||
person: Some(person),
|
||||
}
|
||||
}
|
||||
|
||||
fn pair(i: usize, j: usize, probability: f32) -> Pair {
|
||||
Pair { i, j, probability }
|
||||
}
|
||||
|
||||
/// `n` faces, every one of them fit to be compared against.
|
||||
fn all(n: usize) -> Vec<bool> {
|
||||
vec![true; n]
|
||||
}
|
||||
|
||||
/// The failure the module exists to fix: face 0 matches its own group's
|
||||
/// three members strongly, and the group has forty more it is unrelated to.
|
||||
/// The old within-group mean reported ~0.07 for this.
|
||||
#[test]
|
||||
fn a_large_group_does_not_dilute_a_strong_match() {
|
||||
let members: Vec<usize> = (0..44).collect();
|
||||
let clusters = vec![cluster(&members)];
|
||||
let pairs = vec![pair(0, 1, 0.99), pair(0, 2, 0.97), pair(0, 3, 0.95)];
|
||||
|
||||
let shares = identity_shares(&all(44), &clusters, &pairs, TOP_MATCHES);
|
||||
assert!(
|
||||
(shares[0] - 0.97).abs() < 1e-6,
|
||||
"the mean of its three real matches, undiluted: {}",
|
||||
shares[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// Two named people matching equally well is a coin toss, and saying so is
|
||||
/// the point — this is the sibling case FR-CULL-10 warns about.
|
||||
#[test]
|
||||
fn an_ambiguous_face_splits_its_confidence_between_the_rivals() {
|
||||
let clusters = vec![named(&[0, 1, 2], 1), named(&[3, 4], 2)];
|
||||
let pairs = vec![
|
||||
pair(0, 1, 0.90),
|
||||
pair(0, 2, 0.90),
|
||||
pair(0, 3, 0.90),
|
||||
pair(0, 4, 0.90),
|
||||
];
|
||||
|
||||
let shares = identity_shares(&all(5), &clusters, &pairs, TOP_MATCHES);
|
||||
// Coherent at 0.90, and only half of the evidence is its own.
|
||||
assert!(
|
||||
(shares[0] - 0.45).abs() < 1e-6,
|
||||
"even evidence both ways: {}",
|
||||
shares[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// A rival below the merge threshold still has to count, which is why the
|
||||
/// evidence scan reaches down to [`RIVAL_FLOOR`].
|
||||
#[test]
|
||||
fn a_rival_too_weak_to_merge_still_lowers_the_confidence() {
|
||||
let clusters = vec![named(&[0, 1], 1), named(&[2, 3], 2)];
|
||||
let sure = identity_shares(&all(4), &clusters, &[pair(0, 1, 0.95)], TOP_MATCHES);
|
||||
let contested = identity_shares(
|
||||
&all(4),
|
||||
&clusters,
|
||||
&[pair(0, 1, 0.95), pair(0, 2, 0.60)],
|
||||
TOP_MATCHES,
|
||||
);
|
||||
|
||||
assert_eq!(sure[0], 0.95, "nobody else to be: its coherence stands");
|
||||
assert!(
|
||||
contested[0] < 0.59 && contested[0] > 0.57,
|
||||
"0.95 coherent, but 0.95 against 0.60: {}",
|
||||
contested[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// A fragment of the same person is not a rival. Measured on a real
|
||||
/// library, counting unnamed groups as competition put four suggestions in
|
||||
/// five under half — see the module note.
|
||||
#[test]
|
||||
fn an_unnamed_group_is_not_treated_as_competition() {
|
||||
let clusters = vec![cluster(&[0, 1]), cluster(&[2, 3])];
|
||||
let shares = identity_shares(
|
||||
&all(4),
|
||||
&clusters,
|
||||
&[pair(0, 1, 0.95), pair(0, 2, 0.90)],
|
||||
TOP_MATCHES,
|
||||
);
|
||||
assert_eq!(
|
||||
shares[0], 0.95,
|
||||
"an unnamed group took evidence off a suggestion"
|
||||
);
|
||||
}
|
||||
|
||||
/// The cap, doing its job: an identity with fifty mediocre matches must not
|
||||
/// beat one with ten strong ones on volume alone.
|
||||
#[test]
|
||||
fn evidence_is_capped_so_the_biggest_group_cannot_win_on_volume() {
|
||||
let small: Vec<usize> = (0..11).collect();
|
||||
let large: Vec<usize> = (11..62).collect();
|
||||
let clusters = vec![named(&small, 1), named(&large, 2)];
|
||||
|
||||
let mut pairs: Vec<Pair> = (1..11).map(|j| pair(0, j, 0.90)).collect();
|
||||
pairs.extend((11..62).map(|j| pair(0, j, 0.55)));
|
||||
|
||||
let shares = identity_shares(&all(62), &clusters, &pairs, TOP_MATCHES);
|
||||
// Ten at 0.90 against ten at 0.55 — not fifty-one at 0.55.
|
||||
assert!(
|
||||
(shares[0] - 0.90 * (9.0 / 14.5)).abs() < 1e-5,
|
||||
"capped at ten either side: {}",
|
||||
shares[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// A probe learns from the references it matched; a reference learns
|
||||
/// nothing from a probe. The pair is the same pair — what differs is who
|
||||
/// is doing the telling.
|
||||
#[test]
|
||||
fn a_face_outside_the_gallery_is_nobody_s_evidence() {
|
||||
let clusters = vec![named(&[0, 1, 2], 1)];
|
||||
let gallery = vec![true, true, false];
|
||||
let pairs = vec![pair(0, 1, 0.80), pair(0, 2, 0.99), pair(1, 2, 0.99)];
|
||||
|
||||
let shares = identity_shares(&gallery, &clusters, &pairs, TOP_MATCHES);
|
||||
// Faces 0 and 1 hear only from each other: the 0.99 the probe offered
|
||||
// them is not counted.
|
||||
assert!((shares[0] - 0.80).abs() < 1e-6, "{}", shares[0]);
|
||||
assert!((shares[1] - 0.80).abs() < 1e-6, "{}", shares[1]);
|
||||
// The probe hears from both references.
|
||||
assert!((shares[2] - 0.99).abs() < 1e-6, "{}", shares[2]);
|
||||
}
|
||||
|
||||
/// A face nothing has any evidence about claims nothing.
|
||||
#[test]
|
||||
fn a_face_with_no_evidence_reports_no_confidence() {
|
||||
let clusters = vec![cluster(&[0, 1])];
|
||||
let shares = identity_shares(&all(2), &clusters, &[], TOP_MATCHES);
|
||||
assert_eq!(shares, vec![0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
@@ -21,13 +21,26 @@
|
||||
//! fails. The exceptions (mirrors, photographs of photographs, collages) are
|
||||
//! rare enough to be noise at this scale.
|
||||
//!
|
||||
//! **Positives have to be earned.** In order of trustworthiness: pairs the user
|
||||
//! has confirmed onto one person; then burst siblings, since FR-CULL-5 already
|
||||
//! groups bursts and two faces in adjacent frames are near-certainly the same
|
||||
//! person. Nothing else — bootstrapping positives from high cosine is circular,
|
||||
//! fitting the calibration to the belief it was supposed to test.
|
||||
//! **Positives have to be earned.** A positive is a pair of faces the user has
|
||||
//! confirmed onto one person (FR-CULL-10), and there is no second source: the
|
||||
//! only labelling this subsystem has is the labelling somebody did by hand.
|
||||
//! Bootstrapping positives from a high cosine is circular — it fits the
|
||||
//! calibration to the belief it was supposed to test — and that is the whole
|
||||
//! of the alternative.
|
||||
//!
|
||||
//! Which is why a fresh library has **no valid calibration**, and says so.
|
||||
//! docs/faces.md §8.1 names one more that would cost no labelling at all: two
|
||||
//! faces in adjacent frames of one burst are near-certainly the same person,
|
||||
//! and FR-CULL-5's grouping is sitting there. Nothing draws on it. This crate
|
||||
//! cannot see a catalog, let alone the bursts in one — it is handed cosines by
|
||||
//! whoever assembled the pair — and no caller does that assembly on its behalf
|
||||
//! yet. Until one does, and until the purity of a burst pair is *measured*
|
||||
//! rather than assumed, the positives are the confirmations and nothing else.
|
||||
//!
|
||||
//! Which is why a fresh library has **no valid calibration** — no fit of its
|
||||
//! own — and says so. It is not left without a curve: it uses the reference
|
||||
//! implementation's fitted one ([`Calibration::default`]), which is a published
|
||||
//! operating point rather than an invention, and the interface reports which of
|
||||
//! the two it is speaking from.
|
||||
|
||||
/// Bins over cosine ∈ [-1, 1].
|
||||
///
|
||||
@@ -40,9 +53,10 @@ const BINS: usize = 200;
|
||||
/// Far stricter than the reference implementation's floor of two positives and
|
||||
/// one negative. That floor is reasonable there: its pairs come from a curated
|
||||
/// gallery of labelled reference portraits, where a positive pair is
|
||||
/// trustworthy by construction. Here the positives are bootstrapped from bursts
|
||||
/// and a handful of early confirmations, and the whole risk is fitting
|
||||
/// confidently to too few of them.
|
||||
/// trustworthy by construction. Here every positive is a pair somebody
|
||||
/// confirmed while working through a young library's suggestions — a handful,
|
||||
/// arriving slowly — and the whole risk is fitting confidently to too few of
|
||||
/// them.
|
||||
pub const MIN_POSITIVE_PAIRS: u64 = 200;
|
||||
pub const MIN_NEGATIVE_PAIRS: u64 = 2_000;
|
||||
|
||||
@@ -56,11 +70,13 @@ pub struct Calibration {
|
||||
pub b: f32,
|
||||
/// Weight on `log2(min crop_px)` — the face-size term FR-CULL-9 asks for.
|
||||
pub w_size: f32,
|
||||
/// Whether there was enough evidence to trust the fit.
|
||||
/// Whether there was enough evidence to fit this library's own curve.
|
||||
///
|
||||
/// When false the UI says confidence is unavailable. It does **not** present
|
||||
/// an untuned default as though it were measured, which is the distinction
|
||||
/// FR-CULL-9 spends a paragraph on.
|
||||
/// False means the numbers came from the built-in reference curve, and the
|
||||
/// UI says so — once, at the screen level. It does **not** mean confidences
|
||||
/// are withheld: FR-CULL-9's distinction is between presenting an untuned
|
||||
/// default *as though it were measured* and presenting it as what it is,
|
||||
/// and only the first is forbidden.
|
||||
pub valid: bool,
|
||||
pub positive_pairs: u64,
|
||||
pub negative_pairs: u64,
|
||||
@@ -70,10 +86,10 @@ impl Default for Calibration {
|
||||
/// The reference implementation's fitted MBF curve (docs/faces.md §1):
|
||||
/// steepness 16.2, P=0.5 at cosine 0.267.
|
||||
///
|
||||
/// **`valid` is false**, and that is the point. This exists so an
|
||||
/// un-calibrated library has a documented operating point to cluster at
|
||||
/// rather than no behaviour at all — but nothing may show its output as a
|
||||
/// measured confidence.
|
||||
/// **`valid` is false**, and that is the point. It is a documented
|
||||
/// operating point rather than an invented one, so a library with no fit of
|
||||
/// its own can both cluster and quote a probability from it — what it may
|
||||
/// not do is call that probability a measurement of *this* library.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
a: 16.2,
|
||||
|
||||
+528
-21
@@ -19,6 +19,23 @@
|
||||
//! and clustering never moves it. Two groups holding confirmations of
|
||||
//! *different* people cannot merge, whatever their similarity says.
|
||||
//!
|
||||
//! # The gallery, and the faces that are only ever compared against it
|
||||
//!
|
||||
//! A third defence, and the cheapest of all: **a short embedding is never a
|
||||
//! reference.** The length of the raw vector is the model's own reading of
|
||||
//! how recognisable the crop was ([`crate::embedding::MIN_GALLERY_QUALITY`]),
|
||||
//! and a short one sits near the centre of the sphere, matching a little of
|
||||
//! everybody. One of those in a group is a bridge to the next group over.
|
||||
//!
|
||||
//! So the population is split. Faces at or above the floor are the
|
||||
//! **gallery**, and they cluster exactly as described below. Faces under it
|
||||
//! are **probes**: each is measured against the finished groups and joins the
|
||||
//! one it fits, by the same average-link rule and under the same constraints
|
||||
//! — but it is measured against the gallery members only, never against
|
||||
//! another probe, and once placed it is never part of what the next face is
|
||||
//! measured against. A blurred photograph of a known person is still named;
|
||||
//! it just cannot vouch for anyone else.
|
||||
//!
|
||||
//! # Average link, not single link
|
||||
//!
|
||||
//! Single-link chains: one bad edge welds two identities together, and it is
|
||||
@@ -117,6 +134,11 @@ pub struct Candidate {
|
||||
pub embedding: Vec<f32>,
|
||||
/// Source pixels across the aligned crop, for the calibration's size term.
|
||||
pub crop_px: f32,
|
||||
/// Length of the raw embedding, where it was recorded
|
||||
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]). `None` for a face indexed
|
||||
/// before it was kept, which is admitted to the gallery — see
|
||||
/// [`Candidate::in_gallery`].
|
||||
pub quality: Option<f32>,
|
||||
/// The person this face is *confirmed* to be, if any.
|
||||
///
|
||||
/// Suggestions are deliberately not passed here. They are this function's
|
||||
@@ -125,6 +147,13 @@ pub struct Candidate {
|
||||
pub confirmed_person: Option<u64>,
|
||||
}
|
||||
|
||||
impl Candidate {
|
||||
/// Whether this face may be compared *against*, as well as compared.
|
||||
pub fn in_gallery(&self) -> bool {
|
||||
crate::embedding::in_gallery(self.quality)
|
||||
}
|
||||
}
|
||||
|
||||
/// One group of faces the clusterer believes are one person.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Cluster {
|
||||
@@ -148,24 +177,279 @@ pub fn cluster(faces: &[Candidate], cal: &Calibration, min_probability: f32) ->
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let embeddings: Vec<Vec<f32>> = faces.iter().map(|f| f.embedding.clone()).collect();
|
||||
let crop_px: Vec<f32> = faces.iter().map(|f| f.crop_px).collect();
|
||||
let images: Vec<u64> = faces.iter().map(|f| f.image).collect();
|
||||
let view = Faces {
|
||||
embeddings: &embeddings,
|
||||
crop_px: &crop_px,
|
||||
images: &images,
|
||||
};
|
||||
|
||||
let columns = Columns::of(faces);
|
||||
// Every pair that could ever contribute to a merge. See the module note on
|
||||
// why nothing outside this list can matter.
|
||||
let pairs = neighbours::above_threshold(&view, cal, min_probability);
|
||||
let pairs = neighbours::above_threshold(&columns.view(), cal, min_probability);
|
||||
build(faces, cal, min_probability, &pairs)
|
||||
}
|
||||
|
||||
/// Groups, and how confident each face's placement is.
|
||||
///
|
||||
/// The second half is [`crate::assign`]'s share, not the pairwise probability
|
||||
/// that put the face in the group — see that module for why the two are
|
||||
/// different questions.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Grouping {
|
||||
pub clusters: Vec<Cluster>,
|
||||
/// Indexed like the input faces. 0 for a face in no group.
|
||||
pub confidence: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Group faces into people, and score each placement against its rivals.
|
||||
///
|
||||
/// What a caller writing suggestions into a catalog wants: [`cluster`] answers
|
||||
/// *which person*, this answers *and how sure*.
|
||||
///
|
||||
/// One scan, two thresholds. The similarity scan is the expensive part of the
|
||||
/// whole subsystem and running it twice — once to merge, once to find rivals —
|
||||
/// would double the cost of regrouping a library. So it runs once at the looser
|
||||
/// of the two floors, and the merge engine takes the subset at or above
|
||||
/// `min_probability`. That subset is identical, pair for pair and in the same
|
||||
/// order, to what a scan at `min_probability` would have produced, so grouping
|
||||
/// is unchanged by scoring being asked for: `scoring_does_not_change_the_
|
||||
/// groups` holds it to that.
|
||||
pub fn cluster_scored(faces: &[Candidate], cal: &Calibration, min_probability: f32) -> Grouping {
|
||||
if faces.is_empty() {
|
||||
return Grouping {
|
||||
clusters: Vec::new(),
|
||||
confidence: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let columns = Columns::of(faces);
|
||||
let evidence = neighbours::above_threshold(
|
||||
&columns.view(),
|
||||
cal,
|
||||
min_probability.min(crate::assign::RIVAL_FLOOR),
|
||||
);
|
||||
let merges: Vec<neighbours::Pair> = evidence
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| p.probability >= min_probability)
|
||||
.collect();
|
||||
|
||||
let clusters = build(faces, cal, min_probability, &merges);
|
||||
let confidence = crate::assign::identity_shares(
|
||||
&columns.gallery,
|
||||
&clusters,
|
||||
&evidence,
|
||||
crate::assign::TOP_MATCHES,
|
||||
);
|
||||
Grouping {
|
||||
clusters,
|
||||
confidence,
|
||||
}
|
||||
}
|
||||
|
||||
/// The three arrays [`neighbours::Faces`] borrows, owned.
|
||||
///
|
||||
/// [`neighbours`] takes parallel slices rather than candidates on purpose — it
|
||||
/// has no business knowing what a person is — so somebody has to hold the
|
||||
/// columns. Both entry points do, identically, which is the only reason this is
|
||||
/// a type and not three locals.
|
||||
struct Columns {
|
||||
/// Every embedding end to end — see [`Faces::embeddings`] for why flat.
|
||||
embeddings: Vec<f32>,
|
||||
dim: usize,
|
||||
crop_px: Vec<f32>,
|
||||
images: Vec<u64>,
|
||||
gallery: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Columns {
|
||||
fn of(faces: &[Candidate]) -> Self {
|
||||
// Ragged input would index the wrong row for every face after the odd
|
||||
// one, so the widest wins and short rows are padded with zeros: a
|
||||
// zero-padded row scores lower against everything, which is the safe
|
||||
// direction. It does not happen — one model, one dimension — and it is
|
||||
// handled rather than trusted because the failure would be silent.
|
||||
let dim = faces.iter().map(|f| f.embedding.len()).max().unwrap_or(0);
|
||||
let mut embeddings = Vec::with_capacity(faces.len() * dim);
|
||||
for f in faces {
|
||||
embeddings.extend_from_slice(&f.embedding);
|
||||
embeddings.resize(embeddings.len() + dim - f.embedding.len(), 0.0);
|
||||
}
|
||||
Self {
|
||||
embeddings,
|
||||
dim,
|
||||
crop_px: faces.iter().map(|f| f.crop_px).collect(),
|
||||
images: faces.iter().map(|f| f.image).collect(),
|
||||
gallery: faces.iter().map(Candidate::in_gallery).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self) -> Faces<'_> {
|
||||
Faces {
|
||||
embeddings: &self.embeddings,
|
||||
dim: self.dim,
|
||||
crop_px: &self.crop_px,
|
||||
images: &self.images,
|
||||
gallery: &self.gallery,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Agglomerate the gallery over its pairs, then place the probes.
|
||||
///
|
||||
/// `pairs` is what [`neighbours::above_threshold`] returned: every pair has a
|
||||
/// gallery side, but a pair with a probe on the other side is not a merge —
|
||||
/// it is the evidence [`place_probes`] works from. Only the gallery-to-gallery
|
||||
/// pairs reach the engine, so a probe enters it as a singleton with no edges
|
||||
/// and comes out exactly as it went in.
|
||||
fn build(
|
||||
faces: &[Candidate],
|
||||
cal: &Calibration,
|
||||
min_probability: f32,
|
||||
pairs: &[neighbours::Pair],
|
||||
) -> Vec<Cluster> {
|
||||
let gallery: Vec<bool> = faces.iter().map(Candidate::in_gallery).collect();
|
||||
let (merges, probe_pairs): (Vec<_>, Vec<_>) = pairs
|
||||
.iter()
|
||||
.copied()
|
||||
.partition(|p| gallery[p.i] && gallery[p.j]);
|
||||
|
||||
let mut engine = Engine::new(faces, cal, min_probability);
|
||||
for component in components(faces.len(), &pairs) {
|
||||
engine.agglomerate(&component, &pairs);
|
||||
let parts = components(faces.len(), &merges);
|
||||
for (component, edges) in parts.members.iter().zip(&parts.edges) {
|
||||
engine.agglomerate(component, edges);
|
||||
}
|
||||
engine.finish()
|
||||
let dot = engine.dot;
|
||||
let clusters = engine.finish();
|
||||
if probe_pairs.is_empty() {
|
||||
return clusters;
|
||||
}
|
||||
place_probes(
|
||||
faces,
|
||||
cal,
|
||||
min_probability,
|
||||
dot,
|
||||
&gallery,
|
||||
clusters,
|
||||
&probe_pairs,
|
||||
)
|
||||
}
|
||||
|
||||
/// Put each probe into the finished group it fits, or leave it alone.
|
||||
///
|
||||
/// The same decision the engine makes for a singleton — average link over the
|
||||
/// group, at or above `min_probability`, subject to [`Engine::can_link`]'s two
|
||||
/// constraints — with one difference that is the whole point: the average is
|
||||
/// over the group's **gallery** members. A probe already placed is not part of
|
||||
/// what the next one is measured against, so a run of short vectors cannot
|
||||
/// pull each other in one after another.
|
||||
///
|
||||
/// Probes are placed in index order and each placement is final, which is
|
||||
/// what keeps this deterministic. The group a probe joins gains its
|
||||
/// photograph, so a second face from the same frame cannot follow it — the
|
||||
/// co-occurrence rule, applied exactly as the engine applies it.
|
||||
fn place_probes(
|
||||
faces: &[Candidate],
|
||||
cal: &Calibration,
|
||||
min_probability: f32,
|
||||
dot: neighbours::DotFn,
|
||||
gallery: &[bool],
|
||||
mut clusters: Vec<Cluster>,
|
||||
probe_pairs: &[neighbours::Pair],
|
||||
) -> Vec<Cluster> {
|
||||
// Where each face sits, and what each group's photographs and gallery
|
||||
// members are. The probe's own singleton is here too, and is dropped once
|
||||
// it has moved.
|
||||
let mut group_of = vec![usize::MAX; faces.len()];
|
||||
for (g, c) in clusters.iter().enumerate() {
|
||||
for &m in &c.members {
|
||||
group_of[m] = g;
|
||||
}
|
||||
}
|
||||
let mut images: Vec<HashSet<u64>> = clusters
|
||||
.iter()
|
||||
.map(|c| c.members.iter().map(|&m| faces[m].image).collect())
|
||||
.collect();
|
||||
let references: Vec<Vec<usize>> = clusters
|
||||
.iter()
|
||||
.map(|c| c.members.iter().copied().filter(|&m| gallery[m]).collect())
|
||||
.collect();
|
||||
|
||||
// Which groups each probe has any above-threshold pair into. Only those
|
||||
// can average above the threshold — the argument the module note makes
|
||||
// for the engine holds here unchanged.
|
||||
let mut candidates: Vec<Vec<usize>> = vec![Vec::new(); faces.len()];
|
||||
for p in probe_pairs {
|
||||
let (probe, reference) = if gallery[p.i] { (p.j, p.i) } else { (p.i, p.j) };
|
||||
candidates[probe].push(group_of[reference]);
|
||||
}
|
||||
|
||||
let mut moved: Vec<usize> = Vec::new();
|
||||
for probe in 0..faces.len() {
|
||||
if gallery[probe] || candidates[probe].is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut groups = std::mem::take(&mut candidates[probe]);
|
||||
groups.sort_unstable();
|
||||
groups.dedup();
|
||||
|
||||
let face = &faces[probe];
|
||||
let mut best: Option<(f32, usize)> = None;
|
||||
for g in groups {
|
||||
let target = &clusters[g];
|
||||
if let (Some(mine), Some(theirs)) = (face.confirmed_person, target.person) {
|
||||
if mine != theirs {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if images[g].contains(&face.image) {
|
||||
continue;
|
||||
}
|
||||
let (mut sum, mut count) = (0.0_f64, 0.0_f64);
|
||||
for &r in &references[g] {
|
||||
let cos = dot(&face.embedding, &faces[r].embedding);
|
||||
let min_crop = face.crop_px.min(faces[r].crop_px);
|
||||
sum += cal.probability(cos, min_crop, 0.0) as f64;
|
||||
count += 1.0;
|
||||
}
|
||||
if count == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let p = (sum / count) as f32;
|
||||
// Strictly better wins; on a tie the lowest group index, which is
|
||||
// the engine's own tiebreak.
|
||||
if p >= min_probability && best.is_none_or(|(bp, _)| p > bp) {
|
||||
best = Some((p, g));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((_, g)) = best else { continue };
|
||||
let own = group_of[probe];
|
||||
clusters[g].members.push(probe);
|
||||
clusters[g].members.sort_unstable();
|
||||
clusters[g].person = clusters[g].person.or(face.confirmed_person);
|
||||
images[g].insert(face.image);
|
||||
group_of[probe] = g;
|
||||
moved.push(own);
|
||||
}
|
||||
|
||||
if moved.is_empty() {
|
||||
return clusters;
|
||||
}
|
||||
// The singletons the probes left behind, then the order `Engine::finish`
|
||||
// promises: largest first, lowest member first among equals.
|
||||
let mut vacated = vec![false; clusters.len()];
|
||||
for g in moved {
|
||||
vacated[g] = true;
|
||||
}
|
||||
let mut out: Vec<Cluster> = clusters
|
||||
.into_iter()
|
||||
.zip(vacated)
|
||||
.filter(|(_, gone)| !gone)
|
||||
.map(|(c, _)| c)
|
||||
.collect();
|
||||
out.sort_by(|x, y| {
|
||||
y.members
|
||||
.len()
|
||||
.cmp(&x.members.len())
|
||||
.then(x.members[0].cmp(&y.members[0]))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Split one person's faces into the groups a raised threshold separates them
|
||||
@@ -266,6 +550,10 @@ impl Ord for Pending {
|
||||
struct Engine<'a> {
|
||||
faces: &'a [Candidate],
|
||||
cal: &'a Calibration,
|
||||
/// The same kernel [`neighbours`] scans with. [`Engine::cross`] is the one
|
||||
/// place a dot product is computed during agglomeration, and it was using
|
||||
/// the portable loop while the scan beside it had the machine's SIMD.
|
||||
dot: neighbours::DotFn,
|
||||
min_probability: f32,
|
||||
groups: Vec<Group>,
|
||||
links: HashMap<(usize, usize), Link>,
|
||||
@@ -290,6 +578,7 @@ impl<'a> Engine<'a> {
|
||||
Self {
|
||||
faces,
|
||||
cal,
|
||||
dot: neighbours::fastest_dot(),
|
||||
min_probability,
|
||||
groups,
|
||||
links: HashMap::new(),
|
||||
@@ -302,10 +591,9 @@ impl<'a> Engine<'a> {
|
||||
if component.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let members: HashSet<usize> = component.iter().copied().collect();
|
||||
|
||||
let mut heap = BinaryHeap::new();
|
||||
for p in pairs.iter().filter(|p| members.contains(&p.i)) {
|
||||
let mut heap = BinaryHeap::with_capacity(pairs.len());
|
||||
for p in pairs {
|
||||
self.links.insert(
|
||||
key(p.i, p.j),
|
||||
Link {
|
||||
@@ -478,7 +766,7 @@ impl<'a> Engine<'a> {
|
||||
let mut count = 0.0_f64;
|
||||
for &i in members {
|
||||
for &j in &self.groups[group].members {
|
||||
let cos = neighbours::dot(&self.faces[i].embedding, &self.faces[j].embedding);
|
||||
let cos = (self.dot)(&self.faces[i].embedding, &self.faces[j].embedding);
|
||||
let min_crop = self.faces[i].crop_px.min(self.faces[j].crop_px);
|
||||
sum += self.cal.probability(cos, min_crop, 0.0) as f64;
|
||||
count += 1.0;
|
||||
@@ -529,7 +817,7 @@ fn key(a: usize, b: usize) -> (usize, usize) {
|
||||
/// separate and much smaller agglomeration. Returned with the members of each
|
||||
/// component ascending, and the components themselves in order of their lowest
|
||||
/// member — the determinism the merge order inherits.
|
||||
fn components(n: usize, pairs: &[neighbours::Pair]) -> Vec<Vec<usize>> {
|
||||
fn components(n: usize, pairs: &[neighbours::Pair]) -> Components {
|
||||
let mut parent: Vec<usize> = (0..n).collect();
|
||||
|
||||
fn find(parent: &mut [usize], mut x: usize) -> usize {
|
||||
@@ -556,9 +844,44 @@ fn components(n: usize, pairs: &[neighbours::Pair]) -> Vec<Vec<usize>> {
|
||||
let r = find(&mut parent, i);
|
||||
by_root.entry(r).or_default().push(i);
|
||||
}
|
||||
let mut out: Vec<Vec<usize>> = by_root.into_values().filter(|c| c.len() > 1).collect();
|
||||
out.sort_unstable_by_key(|c| c[0]);
|
||||
out
|
||||
let mut members: Vec<Vec<usize>> = by_root.into_values().filter(|c| c.len() > 1).collect();
|
||||
members.sort_unstable_by_key(|c| c[0]);
|
||||
|
||||
// Each pair filed under its component, in one pass.
|
||||
//
|
||||
// **This is not bookkeeping, it is the cost of the whole stage.** Each
|
||||
// component used to scan the entire pair list for the ones that were its
|
||||
// own: 475 components against 804,499 pairs on the reference library, 382
|
||||
// million set lookups, and 3.06 s of a 5.93 s regroup spent before a single
|
||||
// merge was considered. A pair can only ever join two faces of one
|
||||
// component — that is what a component *is* — so one pass over the list
|
||||
// places every pair exactly where it is needed.
|
||||
let mut slot_of = vec![usize::MAX; n];
|
||||
for (slot, c) in members.iter().enumerate() {
|
||||
for &m in c {
|
||||
slot_of[m] = slot;
|
||||
}
|
||||
}
|
||||
let mut edges: Vec<Vec<neighbours::Pair>> = vec![Vec::new(); members.len()];
|
||||
for p in pairs {
|
||||
// Ordering within a component follows the global order, which is what
|
||||
// the seeded heap's tiebreak — and so the determinism promise — rests
|
||||
// on.
|
||||
if let Some(slot) = slot_of.get(p.i).copied().filter(|&s| s != usize::MAX) {
|
||||
edges[slot].push(*p);
|
||||
}
|
||||
}
|
||||
|
||||
Components { members, edges }
|
||||
}
|
||||
|
||||
/// The connected components of the pair graph, each with the pairs inside it.
|
||||
///
|
||||
/// The two halves are parallel: `members[k]` and `edges[k]` describe the same
|
||||
/// component.
|
||||
struct Components {
|
||||
members: Vec<Vec<usize>>,
|
||||
edges: Vec<Vec<neighbours::Pair>>,
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -583,10 +906,19 @@ mod tests {
|
||||
image,
|
||||
embedding: at_cosine(identity, cosine),
|
||||
crop_px: 150.0,
|
||||
quality: None,
|
||||
confirmed_person: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A face too short to be a reference: compared, never compared against.
|
||||
fn probe(face: u64, image: u64, identity: usize, cosine: f32) -> Candidate {
|
||||
Candidate {
|
||||
quality: Some(crate::embedding::MIN_GALLERY_QUALITY - 5.0),
|
||||
..candidate(face, image, identity, cosine)
|
||||
}
|
||||
}
|
||||
|
||||
/// A calibration steep enough that the test's cosines are unambiguous:
|
||||
/// 0.6 is near-certain, 0.1 is near-impossible.
|
||||
fn cal() -> Calibration {
|
||||
@@ -663,6 +995,66 @@ mod tests {
|
||||
assert_eq!(out.len(), 2, "clustering overrode two user confirmations");
|
||||
}
|
||||
|
||||
/// A face at a chosen cosine to identity 0 *and* to identity 1 at once —
|
||||
/// the sibling geometry, which [`at_cosine`]'s per-identity subspaces
|
||||
/// cannot express.
|
||||
fn contested(to_first: f32, to_second: f32) -> Vec<f32> {
|
||||
let mut v = vec![0.0_f32; EMBEDDING_DIM];
|
||||
v[0] = to_first;
|
||||
v[2] = to_second;
|
||||
v[4] = (1.0 - to_first * to_first - to_second * to_second)
|
||||
.max(0.0)
|
||||
.sqrt();
|
||||
v
|
||||
}
|
||||
|
||||
/// The population the scoring tests share: two faces of one person, a
|
||||
/// stranger, and a face that matches the person well and the stranger
|
||||
/// weakly — weakly enough that it will never merge with them, which is
|
||||
/// exactly the rival a within-group score cannot see.
|
||||
fn with_a_rival() -> Vec<Candidate> {
|
||||
let mut x = candidate(4, 13, 0, 0.0);
|
||||
x.embedding = contested(0.45, 0.37);
|
||||
// The stranger is *named*: only an identity the user has asserted
|
||||
// competes for a face (crate::assign).
|
||||
let mut stranger = candidate(3, 12, 1, 1.0);
|
||||
stranger.confirmed_person = Some(7);
|
||||
vec![
|
||||
candidate(1, 10, 0, 1.0),
|
||||
candidate(2, 11, 0, 1.0),
|
||||
stranger,
|
||||
x,
|
||||
]
|
||||
}
|
||||
|
||||
/// Asking for confidences must not move a single face. The scan runs at a
|
||||
/// looser floor to find rivals, and the merge engine has to see exactly the
|
||||
/// pairs it would have seen without them.
|
||||
#[test]
|
||||
fn scoring_does_not_change_the_groups() {
|
||||
let faces = with_a_rival();
|
||||
let plain = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
let scored = cluster_scored(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(plain, scored.clusters);
|
||||
}
|
||||
|
||||
/// The number the user is shown answers "which of these people", so a
|
||||
/// second claimant has to lower it even when it is too weak to merge.
|
||||
#[test]
|
||||
fn a_face_two_identities_could_claim_is_reported_as_less_certain() {
|
||||
let faces = with_a_rival();
|
||||
let scored = cluster_scored(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
|
||||
let alone = cluster_scored(&faces[..2], &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert!(alone.confidence[0] > 0.99, "nobody else to be");
|
||||
|
||||
let contested = scored.confidence[3];
|
||||
assert!(
|
||||
(0.6..0.85).contains(&contested),
|
||||
"a face with a second claimant: {contested}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_suggestion_joins_the_person_its_group_is_anchored_to() {
|
||||
let mut anchor = candidate(1, 10, 0, 1.0);
|
||||
@@ -896,6 +1288,7 @@ mod tests {
|
||||
image,
|
||||
embedding: at_cosine(p, cosine),
|
||||
crop_px: 60.0 + ((out.len() % 11) as f32) * 25.0,
|
||||
quality: None,
|
||||
confirmed_person: None,
|
||||
});
|
||||
image += 1;
|
||||
@@ -979,6 +1372,7 @@ mod tests {
|
||||
image: 5_000,
|
||||
embedding: at_cosine(200, 1.0),
|
||||
crop_px: 150.0,
|
||||
quality: None,
|
||||
confirmed_person: None,
|
||||
});
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
@@ -988,4 +1382,117 @@ mod tests {
|
||||
"the outlier was absorbed"
|
||||
);
|
||||
}
|
||||
|
||||
// ── the gallery ───────────────────────────────────────────────────────
|
||||
|
||||
/// A short vector is still somebody: it joins the group it matches.
|
||||
#[test]
|
||||
fn a_probe_joins_the_group_it_matches() {
|
||||
let faces = vec![
|
||||
candidate(1, 10, 0, 1.0),
|
||||
candidate(2, 11, 0, 0.95),
|
||||
probe(3, 12, 0, 0.92),
|
||||
];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].members, vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
/// Two short vectors that resemble each other are noise agreeing with
|
||||
/// noise, and there is nothing in the gallery for either to be measured
|
||||
/// against.
|
||||
#[test]
|
||||
fn two_probes_are_never_grouped_with_each_other() {
|
||||
let faces = vec![probe(1, 10, 0, 1.0), probe(2, 11, 0, 0.98)];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(out.len(), 2, "two probes were grouped: {out:?}");
|
||||
}
|
||||
|
||||
/// The point of measuring against the gallery only: a probe that has been
|
||||
/// placed is not a stepping stone for the next one.
|
||||
#[test]
|
||||
fn a_placed_probe_is_not_what_the_next_probe_is_measured_against() {
|
||||
let mut first = probe(2, 11, 0, 0.6);
|
||||
// 0.6 along identity 0 and 0.8 along its perpendicular: near enough to
|
||||
// the reference to join it, and much nearer to the face below.
|
||||
first.embedding = at_cosine(0, 0.6);
|
||||
let mut second = probe(3, 12, 0, 0.0);
|
||||
second.embedding = at_cosine(0, 0.0);
|
||||
let faces = vec![candidate(1, 10, 0, 1.0), first, second];
|
||||
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
let group = out.iter().find(|c| c.members.contains(&0)).unwrap();
|
||||
assert_eq!(
|
||||
group.members,
|
||||
vec![0, 1],
|
||||
"the first probe should have joined"
|
||||
);
|
||||
assert!(
|
||||
out.iter().any(|c| c.members == vec![2]),
|
||||
"the second probe reached the group through the first: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A confirmation on a probe is still the user's word: the group it joins
|
||||
/// becomes that person, and a group already someone else's is closed to it.
|
||||
#[test]
|
||||
fn a_probe_carries_its_confirmation_and_respects_others() {
|
||||
let mut anchored = probe(3, 12, 0, 0.92);
|
||||
anchored.confirmed_person = Some(7);
|
||||
let faces = vec![
|
||||
candidate(1, 10, 0, 1.0),
|
||||
candidate(2, 11, 0, 0.95),
|
||||
anchored,
|
||||
];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].person, Some(7));
|
||||
|
||||
let mut theirs = candidate(1, 10, 0, 1.0);
|
||||
theirs.confirmed_person = Some(8);
|
||||
let faces = vec![theirs, candidate(2, 11, 0, 0.95), {
|
||||
let mut a = probe(3, 12, 0, 0.92);
|
||||
a.confirmed_person = Some(7);
|
||||
a
|
||||
}];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert!(
|
||||
out.iter()
|
||||
.any(|c| c.members == vec![2] && c.person == Some(7)),
|
||||
"a probe confirmed as one person joined another's group: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The co-occurrence rule follows a probe in: once it has joined, its
|
||||
/// photograph is the group's.
|
||||
#[test]
|
||||
fn a_probe_cannot_join_a_group_holding_a_face_from_its_own_photograph() {
|
||||
let faces = vec![
|
||||
candidate(1, 10, 0, 1.0),
|
||||
candidate(2, 11, 0, 0.95),
|
||||
probe(3, 10, 0, 0.92),
|
||||
];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert!(out.iter().any(|c| c.members == vec![2]), "{out:?}");
|
||||
}
|
||||
|
||||
/// A probe's placement is scored like anyone else's, from the references
|
||||
/// it matched — and the references' own scores do not hear from it.
|
||||
#[test]
|
||||
fn a_probe_is_scored_but_is_not_evidence() {
|
||||
let gallery_only = vec![candidate(1, 10, 0, 1.0), candidate(2, 11, 0, 0.95)];
|
||||
let without = cluster_scored(&gallery_only, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
|
||||
let mut with_probe = gallery_only.clone();
|
||||
with_probe.push(probe(3, 12, 0, 0.99));
|
||||
let with = cluster_scored(&with_probe, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
|
||||
assert_eq!(with.clusters[0].members, vec![0, 1, 2]);
|
||||
assert!(with.confidence[2] > 0.9, "{}", with.confidence[2]);
|
||||
assert_eq!(
|
||||
&with.confidence[..2],
|
||||
&without.confidence[..],
|
||||
"a probe changed what the references were sure of"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,41 @@ use crate::align::{Aligned112, ALIGNED_EDGE};
|
||||
use crate::embedding::{normalise, Embedding, ModelId, EMBEDDING_DIM};
|
||||
use crate::{install_backend, FaceError};
|
||||
|
||||
/// What one pass of the embedder produces: the direction, and the length.
|
||||
///
|
||||
/// Two fields rather than a `quality` on [`Embedding`], because every other
|
||||
/// holder of an `Embedding` relies on it being unit length and compares by
|
||||
/// dot product; the length is a separate fact about the same face, and it is
|
||||
/// stored separately too.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Embedded {
|
||||
pub embedding: Embedding,
|
||||
/// L2 norm of the raw model output.
|
||||
///
|
||||
/// The model's own opinion of how recognisable the crop was — see
|
||||
/// [`crate::embedding::MIN_GALLERY_QUALITY`] for what it means and where
|
||||
/// it is used.
|
||||
pub quality: f32,
|
||||
}
|
||||
|
||||
impl Embedded {
|
||||
/// Storage form: the **raw** vector, `512 × f16`.
|
||||
///
|
||||
/// Not the unit vector. The length is the quality, and a store that held
|
||||
/// only the direction would have thrown it away at the one moment it could
|
||||
/// be known — which is what this crate used to do. Readers re-normalise
|
||||
/// ([`Embedding::from_f16_bytes`]), so every comparison is still a dot
|
||||
/// product, and [`crate::embedding::read_f16_bytes`] gives the length back
|
||||
/// to a reader that wants it.
|
||||
///
|
||||
/// f16 costs nothing extra at this scale: its precision is relative, so a
|
||||
/// component of a vector of length 20 is kept to the same three figures as
|
||||
/// the same component scaled to length 1.
|
||||
pub fn to_f16_bytes(&self) -> Vec<u8> {
|
||||
self.embedding.to_f16_bytes_scaled(self.quality)
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded ArcFace graph.
|
||||
pub struct Embedder {
|
||||
session: ort::session::Session,
|
||||
@@ -63,7 +98,7 @@ impl Embedder {
|
||||
}
|
||||
|
||||
/// Embed one aligned face.
|
||||
pub fn embed(&mut self, face: &Aligned112) -> Result<Embedding, FaceError> {
|
||||
pub fn embed(&mut self, face: &Aligned112) -> Result<Embedded, FaceError> {
|
||||
// `(x·255 − 127.5) / 128` — see the `/128` note in `detect::Letterbox`.
|
||||
let px = face.pixels();
|
||||
let mut input = Array4::<f32>::zeros((1, 3, ALIGNED_EDGE, ALIGNED_EDGE));
|
||||
@@ -95,11 +130,14 @@ impl Embedder {
|
||||
|
||||
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
|
||||
v.copy_from_slice(&data[..EMBEDDING_DIM]);
|
||||
normalise(&mut v);
|
||||
let quality = normalise(&mut v);
|
||||
|
||||
Ok(Embedding {
|
||||
model: self.model.clone(),
|
||||
v,
|
||||
Ok(Embedded {
|
||||
embedding: Embedding {
|
||||
model: self.model.clone(),
|
||||
v,
|
||||
},
|
||||
quality,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+119
-18
@@ -12,6 +12,43 @@
|
||||
/// Embedding dimensionality. Fixed by the model family, not a parameter.
|
||||
pub const EMBEDDING_DIM: usize = 512;
|
||||
|
||||
/// The shortest raw embedding a face may be *compared against*.
|
||||
///
|
||||
/// # What the length of the vector says
|
||||
///
|
||||
/// ArcFace is trained on the direction of its output and nothing else, and
|
||||
/// the length it leaves behind turns out to be a free quality signal: the
|
||||
/// magnitude grows with how recognisable the crop was to the model, and a
|
||||
/// blurred, occluded, badly lit or hard-profile face comes out short. MagFace
|
||||
/// (Meng et al., CVPR 2021) made that the training objective; the plain
|
||||
/// ArcFace heads this crate runs already show it, weaker but usable, which is
|
||||
/// why it is worth keeping the number the normalisation discards.
|
||||
///
|
||||
/// # Why it gates the gallery and not the face
|
||||
///
|
||||
/// A short vector is a bad *reference*: it sits nearer the centre of the
|
||||
/// sphere than a real identity does and matches a little of everyone, which
|
||||
/// is exactly the face that welds two people together in a clustering pass.
|
||||
/// It is not a bad *probe* — the face is still real, still somebody, and
|
||||
/// comparing it against good references is the only way it will ever be named.
|
||||
/// So a face below this floor is compared against the gallery and never
|
||||
/// becomes part of it: see `cluster::Candidate::in_gallery`.
|
||||
///
|
||||
/// 14 is the operating point for `w600k_mbf`, whose norms on the reference
|
||||
/// library run from about 8 on a blur to the high 20s on a clean portrait. A
|
||||
/// face whose quality was never recorded — indexed before the number was kept
|
||||
/// — is not gated, because a rule that cannot be checked should admit, not
|
||||
/// exclude.
|
||||
pub const MIN_GALLERY_QUALITY: f32 = 14.0;
|
||||
|
||||
/// Whether an embedding of this quality may serve as a reference.
|
||||
///
|
||||
/// `None` is "not measured", and is admitted: the rule is about a number that
|
||||
/// was read and found short, not about a number that is missing.
|
||||
pub fn in_gallery(quality: Option<f32>) -> bool {
|
||||
quality.is_none_or(|q| q >= MIN_GALLERY_QUALITY)
|
||||
}
|
||||
|
||||
/// Which model produced an embedding.
|
||||
///
|
||||
/// Embeddings from different models are not comparable, and this is the one
|
||||
@@ -58,10 +95,19 @@ impl Embedding {
|
||||
}
|
||||
|
||||
/// Storage form: `512 × f16`, 1 KB per face (catalog.md §10.1).
|
||||
///
|
||||
/// This writes the unit vector. What the catalog stores is the raw one —
|
||||
/// `embed::Embedded::to_f16_bytes` — because the length is the quality
|
||||
/// and a unit vector has none left to read.
|
||||
pub fn to_f16_bytes(&self) -> Vec<u8> {
|
||||
self.to_f16_bytes_scaled(1.0)
|
||||
}
|
||||
|
||||
/// The unit vector scaled by `length`, as `512 × f16`.
|
||||
pub(crate) fn to_f16_bytes_scaled(&self, length: f32) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(EMBEDDING_DIM * 2);
|
||||
for &x in self.v.iter() {
|
||||
out.extend_from_slice(&f32_to_f16_bits(x).to_le_bytes());
|
||||
out.extend_from_slice(&f32_to_f16_bits(x * length).to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -71,25 +117,42 @@ impl Embedding {
|
||||
/// The f16 round-trip perturbs a unit vector by ~1e-3 in cosine — three
|
||||
/// orders below the separation between a match and a non-match — but the
|
||||
/// drift is free to remove and invisible if left, so it is removed here
|
||||
/// rather than remembered at every call site.
|
||||
/// rather than remembered at every call site. The same pass is what turns
|
||||
/// a stored raw vector back into the unit one every comparison expects.
|
||||
pub fn from_f16_bytes(model: ModelId, bytes: &[u8]) -> Option<Self> {
|
||||
if bytes.len() != EMBEDDING_DIM * 2 {
|
||||
return None;
|
||||
}
|
||||
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
|
||||
for (i, chunk) in bytes.chunks_exact(2).enumerate() {
|
||||
v[i] = f16_bits_to_f32(u16::from_le_bytes([chunk[0], chunk[1]]));
|
||||
}
|
||||
normalise(&mut v);
|
||||
Some(Self { model, v })
|
||||
read_f16_bytes(model, bytes).map(|(e, _)| e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a stored vector back, with the length it was stored at.
|
||||
///
|
||||
/// The length is the quality where the blob is a raw one, and ~1 where it is
|
||||
/// a unit vector from before raw vectors were stored — which is why the
|
||||
/// catalog keeps the quality beside the blob rather than deriving it from
|
||||
/// this: a unit vector reads as a quality of 1, not as "unmeasured".
|
||||
pub fn read_f16_bytes(model: ModelId, bytes: &[u8]) -> Option<(Embedding, f32)> {
|
||||
if bytes.len() != EMBEDDING_DIM * 2 {
|
||||
return None;
|
||||
}
|
||||
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
|
||||
for (i, chunk) in bytes.chunks_exact(2).enumerate() {
|
||||
v[i] = f16_bits_to_f32(u16::from_le_bytes([chunk[0], chunk[1]]));
|
||||
}
|
||||
let length = normalise(&mut v);
|
||||
Some((Embedding { model, v }, length))
|
||||
}
|
||||
|
||||
fn dot(a: &[f32; EMBEDDING_DIM], b: &[f32; EMBEDDING_DIM]) -> f32 {
|
||||
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
pub(crate) fn normalise(v: &mut [f32; EMBEDDING_DIM]) {
|
||||
/// Scale `v` to unit length, and return the length it had.
|
||||
///
|
||||
/// The length is the one thing about the raw output that survives being
|
||||
/// thrown away by everything downstream, and it is a quality signal
|
||||
/// ([`MIN_GALLERY_QUALITY`]) — so it comes back out rather than being lost
|
||||
/// here.
|
||||
pub(crate) fn normalise(v: &mut [f32; EMBEDDING_DIM]) -> f32 {
|
||||
// Clamped rather than checked: a zero-norm embedding is a broken model,
|
||||
// not a runtime condition worth an error path, and dividing by 1e-6 keeps
|
||||
// the NaN out of the catalog.
|
||||
@@ -97,6 +160,7 @@ pub(crate) fn normalise(v: &mut [f32; EMBEDDING_DIM]) {
|
||||
for x in v.iter_mut() {
|
||||
*x /= norm;
|
||||
}
|
||||
norm
|
||||
}
|
||||
|
||||
// ── f16 ───────────────────────────────────────────────────────────────────
|
||||
@@ -113,9 +177,10 @@ fn f32_to_f16_bits(x: f32) -> u16 {
|
||||
let mant = bits & 0x007f_ffff;
|
||||
|
||||
if exp >= 0x1f {
|
||||
// Overflow, inf, or NaN. Embeddings are unit-norm so this is the
|
||||
// broken-model path; infinity is the honest answer, not a clamp that
|
||||
// hides it.
|
||||
// Overflow, inf, or NaN. No component of an embedding exceeds its
|
||||
// length, and the lengths this model produces are in the tens, so
|
||||
// this is the broken-model path; infinity is the honest answer, not a
|
||||
// clamp that hides it.
|
||||
return sign
|
||||
| 0x7c00
|
||||
| if mant != 0 && exp == 0x1f + 112 {
|
||||
@@ -125,9 +190,9 @@ fn f32_to_f16_bits(x: f32) -> u16 {
|
||||
};
|
||||
}
|
||||
if exp <= 0 {
|
||||
// Subnormal or underflow. A component of a unit 512-vector is ~0.04,
|
||||
// nowhere near here, so this branch exists for correctness rather than
|
||||
// for traffic.
|
||||
// Subnormal or underflow. A component of a unit 512-vector is ~0.04
|
||||
// and a stored one is that times the length, nowhere near here, so
|
||||
// this branch exists for correctness rather than for traffic.
|
||||
if exp < -10 {
|
||||
return sign;
|
||||
}
|
||||
@@ -221,6 +286,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalising_reports_the_length_it_removed() {
|
||||
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
|
||||
v[0] = 3.0;
|
||||
v[1] = 4.0;
|
||||
let norm = normalise(&mut v);
|
||||
assert!((norm - 5.0).abs() < 1e-6, "norm {norm}");
|
||||
assert!((v[0] - 0.6).abs() < 1e-6 && (v[1] - 0.8).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The gate admits what it cannot measure: a face from before the number
|
||||
/// was kept is not a face that was found wanting.
|
||||
#[test]
|
||||
fn an_unmeasured_quality_is_admitted_to_the_gallery() {
|
||||
assert!(in_gallery(None));
|
||||
assert!(in_gallery(Some(MIN_GALLERY_QUALITY)));
|
||||
assert!(in_gallery(Some(27.5)));
|
||||
assert!(!in_gallery(Some(MIN_GALLERY_QUALITY - 0.01)));
|
||||
assert!(!in_gallery(Some(8.0)));
|
||||
}
|
||||
|
||||
/// The storage form carries the length, and the length comes back out —
|
||||
/// without touching the direction every comparison is made on.
|
||||
#[test]
|
||||
fn a_raw_vector_round_trips_with_its_length() {
|
||||
let e = unit(3);
|
||||
let raw = e.to_f16_bytes_scaled(21.5);
|
||||
let (back, length) = read_f16_bytes(e.model.clone(), &raw).unwrap();
|
||||
assert!((length - 21.5).abs() < 0.05, "length {length}");
|
||||
assert!(e.cosine(&back).unwrap() > 0.9999);
|
||||
// A unit vector from an older store reads as length 1, not as an
|
||||
// error — see `read_f16_bytes` on why that is not "unmeasured".
|
||||
let (_, one) = read_f16_bytes(e.model.clone(), &e.to_f16_bytes()).unwrap();
|
||||
assert!((one - 1.0).abs() < 1e-2, "length {one}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f16_round_trip_rejects_a_wrong_length_blob() {
|
||||
assert!(Embedding::from_f16_bytes(ModelId::new("m"), &[0u8; 100]).is_none());
|
||||
|
||||
+36
-6
@@ -24,13 +24,15 @@
|
||||
//!
|
||||
//! # Why the runtime is split behind a feature
|
||||
//!
|
||||
//! [`calibrate`] and [`cluster`] are where this subsystem's accuracy actually
|
||||
//! lives, and both are pure arithmetic over embeddings with no model in them.
|
||||
//! [`calibrate`], [`cluster`] and [`assign`] are where this subsystem's accuracy
|
||||
//! actually lives, and all three are pure arithmetic over embeddings with no
|
||||
//! model in them.
|
||||
//! They build and test without `inference`, on synthetic embeddings, on a
|
||||
//! machine with no weights on it — which is what lets CI cover the part most
|
||||
//! likely to be subtly wrong.
|
||||
|
||||
pub mod align;
|
||||
pub mod assign;
|
||||
pub mod calibrate;
|
||||
pub mod cluster;
|
||||
#[cfg(feature = "inference")]
|
||||
@@ -41,14 +43,42 @@ pub mod embedding;
|
||||
pub mod naming;
|
||||
pub mod neighbours;
|
||||
|
||||
pub use align::{warp, Aligned112, Similarity, ALIGNED_EDGE, ARCFACE_TEMPLATE};
|
||||
/// Smallest long edge a face crop may be sampled from.
|
||||
///
|
||||
/// **A floor on the crop source, not on the detector input.** The distinction
|
||||
/// is the whole of FR-CULL-8 and `docs/faces.md` §7: detection letterboxes
|
||||
/// every buffer into 640×640, so its input resolution decides nothing, while
|
||||
/// [`warp`] samples the 112×112 the embedder sees and so converts source
|
||||
/// resolution directly into embedding quality. FR-CULL-8 requires that crop to
|
||||
/// come from the native render; this is the guard that catches a caller
|
||||
/// sampling from a proxy instead.
|
||||
///
|
||||
/// 1025 rather than 1024 because 1024 is exactly `dr_thumbs::ThumbSize::Large`,
|
||||
/// the stored proxy tier a caller is most likely to reach for by mistake, so
|
||||
/// the floor has to exclude it rather than admit it. Written as a minimum so
|
||||
/// the test is `edge < MIN_CROP_EDGE` with no boundary to get wrong.
|
||||
///
|
||||
/// It is a coarse guard and deliberately so: whether any *individual* crop was
|
||||
/// upsampled is answered exactly by `faces.crop_px` against [`ALIGNED_EDGE`],
|
||||
/// and that is the number §7b measures. This only stops a whole pass reading
|
||||
/// from the wrong tier.
|
||||
pub const MIN_CROP_EDGE: u32 = 1025;
|
||||
|
||||
pub use align::{
|
||||
warp, warp_pixels, Aligned112, Pixels, Similarity, ALIGNED_EDGE, ARCFACE_TEMPLATE,
|
||||
};
|
||||
pub use assign::{identity_shares, RIVAL_FLOOR, TOP_MATCHES};
|
||||
pub use calibrate::{Calibration, Pairs, ReliabilityBand};
|
||||
pub use cluster::{cluster, split, Candidate, Cluster, DEFAULT_MERGE_PROBABILITY};
|
||||
pub use cluster::{
|
||||
cluster, cluster_scored, split, Candidate, Cluster, Grouping, DEFAULT_MERGE_PROBABILITY,
|
||||
};
|
||||
#[cfg(feature = "inference")]
|
||||
pub use detect::{DetectOptions, Detection, Detector};
|
||||
#[cfg(feature = "inference")]
|
||||
pub use embed::Embedder;
|
||||
pub use embedding::{Embedding, ModelId, EMBEDDING_DIM};
|
||||
pub use embed::{Embedded, Embedder};
|
||||
pub use embedding::{
|
||||
in_gallery, read_f16_bytes, Embedding, ModelId, EMBEDDING_DIM, MIN_GALLERY_QUALITY,
|
||||
};
|
||||
pub use naming::{name_for_instance, name_instances, NamedFace};
|
||||
|
||||
/// What can go wrong between an image and a face.
|
||||
|
||||
+322
-41
@@ -72,6 +72,13 @@ use crate::calibrate::Calibration;
|
||||
/// work — and large enough that the per-block overhead disappears.
|
||||
const BLOCK: usize = 64;
|
||||
|
||||
/// Columns compared against one row block before moving on.
|
||||
///
|
||||
/// The other half of the tiling: 64 embeddings of 512 floats is 128 KB, which
|
||||
/// sits in L2 beside the row block instead of being re-read from memory for
|
||||
/// every row. See [`scan_rows`] for what it was worth.
|
||||
const COLUMN_TILE: usize = 64;
|
||||
|
||||
/// Below this many faces, do the whole thing on the calling thread.
|
||||
///
|
||||
/// Spawning threads for a set this small costs more than the scan.
|
||||
@@ -94,13 +101,53 @@ pub struct Pair {
|
||||
/// knowing what a person or a photograph is, and taking the three arrays it
|
||||
/// actually reads keeps it testable on bare vectors.
|
||||
pub struct Faces<'a> {
|
||||
/// L2-normalised, `EMBEDDING_DIM` long, one per face.
|
||||
pub embeddings: &'a [Vec<f32>],
|
||||
/// Every embedding end to end, L2-normalised, [`Faces::dim`] floats each.
|
||||
///
|
||||
/// **Flat, not a slice of vectors**, and the difference is measurable: a
|
||||
/// `&[Vec<f32>]` is one heap allocation per face and the scan chases a
|
||||
/// pointer per row, which defeats both the prefetcher and the tiling this
|
||||
/// module does to stay in cache. One buffer is also the layout a GPU would
|
||||
/// want, which is where this is eventually going.
|
||||
pub embeddings: &'a [f32],
|
||||
/// Floats per embedding — [`crate::EMBEDDING_DIM`] in practice, a parameter
|
||||
/// so the tests can work in 64 dimensions.
|
||||
pub dim: usize,
|
||||
/// Source pixels across the aligned crop, for the calibration's size term.
|
||||
pub crop_px: &'a [f32],
|
||||
/// Which photograph each face came from. Two faces in one frame are not
|
||||
/// the same person, so those pairs are never returned (docs/faces.md §9).
|
||||
pub images: &'a [u64],
|
||||
/// Which faces may be compared *against* — the gallery
|
||||
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]).
|
||||
///
|
||||
/// A pair needs at least one gallery side: a probe measured against a
|
||||
/// reference is a comparison, two short vectors measured against each
|
||||
/// other is noise agreeing with noise, and those pairs are never returned.
|
||||
/// Filtered here rather than by the caller for the same reason
|
||||
/// co-occurrence is: what this module leaves out of the list stays out of
|
||||
/// the graph, the components and the merge order, so nothing downstream
|
||||
/// has to remember the rule.
|
||||
pub gallery: &'a [bool],
|
||||
}
|
||||
|
||||
impl Faces<'_> {
|
||||
/// How many faces there are.
|
||||
pub fn len(&self) -> usize {
|
||||
if self.dim == 0 {
|
||||
0
|
||||
} else {
|
||||
self.embeddings.len() / self.dim
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn row(&self, i: usize) -> &[f32] {
|
||||
&self.embeddings[i * self.dim..(i + 1) * self.dim]
|
||||
}
|
||||
}
|
||||
|
||||
/// Every pair whose calibrated probability reaches `min_probability`.
|
||||
@@ -111,14 +158,20 @@ pub struct Faces<'a> {
|
||||
///
|
||||
/// Ordered by `(i, j)`, which is what the caller's determinism rests on.
|
||||
pub fn above_threshold(faces: &Faces, cal: &Calibration, min_probability: f32) -> Vec<Pair> {
|
||||
let n = faces.embeddings.len();
|
||||
let n = faces.len();
|
||||
if n < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// The loosest cosine that could clear the bar for *any* pair in the set.
|
||||
// Cheaper than the sigmoid by far, and it rejects almost everything.
|
||||
let tau = loosest_cosine(faces.crop_px, cal, min_probability);
|
||||
let scan = Scan {
|
||||
faces,
|
||||
cal,
|
||||
min_probability,
|
||||
tau: loosest_cosine(faces.crop_px, cal, min_probability),
|
||||
dot: fastest_dot(),
|
||||
};
|
||||
|
||||
let blocks: Vec<(usize, usize)> = (0..n)
|
||||
.step_by(BLOCK)
|
||||
@@ -128,7 +181,7 @@ pub fn above_threshold(faces: &Faces, cal: &Calibration, min_probability: f32) -
|
||||
if n < THREADS_ABOVE {
|
||||
let mut out = Vec::new();
|
||||
for &(from, to) in &blocks {
|
||||
scan_block(faces, cal, min_probability, tau, from, to, &mut out);
|
||||
scan_rows(&scan, from, to, &mut out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -158,7 +211,7 @@ pub fn above_threshold(faces: &Faces, cal: &Calibration, min_probability: f32) -
|
||||
break;
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
scan_block(faces, cal, min_probability, tau, from, to, &mut out);
|
||||
scan_rows(&scan, from, to, &mut out);
|
||||
mine.push((b, out));
|
||||
}
|
||||
mine
|
||||
@@ -184,40 +237,70 @@ pub fn above_threshold(faces: &Faces, cal: &Calibration, min_probability: f32) -
|
||||
out
|
||||
}
|
||||
|
||||
/// Compare rows `from..to` against everything after them.
|
||||
/// Everything [`scan_rows`] needs that does not change between blocks.
|
||||
///
|
||||
/// The upper triangle, split by rows. Row `i` only looks at `j > i`, so every
|
||||
/// unordered pair is visited exactly once and the emitted order is `(i, j)`
|
||||
/// ascending within the block.
|
||||
fn scan_block(
|
||||
faces: &Faces,
|
||||
cal: &Calibration,
|
||||
/// A struct rather than eight arguments, and the grouping is real: these five
|
||||
/// are fixed for a whole scan and only the row range moves.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Scan<'a> {
|
||||
faces: &'a Faces<'a>,
|
||||
cal: &'a Calibration,
|
||||
min_probability: f32,
|
||||
tau: f32,
|
||||
from: usize,
|
||||
to: usize,
|
||||
out: &mut Vec<Pair>,
|
||||
) {
|
||||
let n = faces.embeddings.len();
|
||||
for i in from..to {
|
||||
let a = &faces.embeddings[i];
|
||||
let crop_a = faces.crop_px[i];
|
||||
let image_a = faces.images[i];
|
||||
for j in i + 1..n {
|
||||
if image_a == faces.images[j] {
|
||||
dot: DotFn,
|
||||
}
|
||||
|
||||
/// Compare rows `from..to` against everything after them.
|
||||
///
|
||||
/// The upper triangle, split by rows, and **tiled on the column side too**.
|
||||
/// Walking `j` from `i + 1` to `n` for one row at a time streams the whole
|
||||
/// embedding array past the core once per row — 336 GB of traffic for an
|
||||
/// 18,000-face library — where a column tile small enough to sit in L2 is read
|
||||
/// once per *tile* of rows. Measured on that library, the tiling alone took the
|
||||
/// scan from 4.64 s to 2.81 s before any change to the kernel.
|
||||
///
|
||||
/// Pairs come out ordered by `(i, j)`: the tiles are walked in ascending order
|
||||
/// but the row loop is inside them, so the block's own output is sorted before
|
||||
/// it is returned. Blocks are concatenated in row order, so the whole list is
|
||||
/// ordered — which is the promise the caller's determinism rests on.
|
||||
fn scan_rows(scan: &Scan, from: usize, to: usize, out: &mut Vec<Pair>) {
|
||||
let Scan {
|
||||
faces,
|
||||
cal,
|
||||
min_probability,
|
||||
tau,
|
||||
dot,
|
||||
} = *scan;
|
||||
let n = faces.len();
|
||||
for tile in (from..n).step_by(COLUMN_TILE) {
|
||||
let tile_end = (tile + COLUMN_TILE).min(n);
|
||||
for i in from..to {
|
||||
// The diagonal: nothing at or before `i` is this row's business.
|
||||
let start = tile.max(i + 1);
|
||||
if start >= tile_end {
|
||||
continue;
|
||||
}
|
||||
let cos = dot(a, &faces.embeddings[j]);
|
||||
// The cheap rejection, and it takes well over 99% of pairs.
|
||||
if cos < tau {
|
||||
continue;
|
||||
}
|
||||
let probability = cal.probability(cos, crop_a.min(faces.crop_px[j]), 0.0);
|
||||
if probability >= min_probability {
|
||||
out.push(Pair { i, j, probability });
|
||||
let a = faces.row(i);
|
||||
let crop_a = faces.crop_px[i];
|
||||
let image_a = faces.images[i];
|
||||
let gallery_a = faces.gallery[i];
|
||||
for j in start..tile_end {
|
||||
if image_a == faces.images[j] || !(gallery_a || faces.gallery[j]) {
|
||||
continue;
|
||||
}
|
||||
let cos = dot(a, faces.row(j));
|
||||
// The cheap rejection, and it takes well over 99% of pairs.
|
||||
if cos < tau {
|
||||
continue;
|
||||
}
|
||||
let probability = cal.probability(cos, crop_a.min(faces.crop_px[j]), 0.0);
|
||||
if probability >= min_probability {
|
||||
out.push(Pair { i, j, probability });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable_by_key(|p| (p.i, p.j));
|
||||
}
|
||||
|
||||
/// The lowest cosine that could yield `min_probability` for any pair in the set.
|
||||
@@ -266,10 +349,144 @@ fn loosest_cosine(crop_px: &[f32], cal: &Calibration, min_probability: f32) -> f
|
||||
/// something to pipeline and vectorise. The order is fixed and identical on
|
||||
/// every run, which is what the caller's determinism needs — it is a different
|
||||
/// order from the naive sum, not a variable one.
|
||||
/// A dot product over two equal-length, L2-normalised rows.
|
||||
///
|
||||
/// Chosen once per scan rather than per pair — see [`fastest_dot`].
|
||||
pub(crate) type DotFn = fn(&[f32], &[f32]) -> f32;
|
||||
|
||||
/// The widest dot product this machine can actually run.
|
||||
///
|
||||
/// # Why this is worth unsafe code
|
||||
///
|
||||
/// The scan is the arithmetic floor of the whole subsystem and it was running
|
||||
/// at **0.7 flops per cycle**. The workspace builds for baseline `x86-64`,
|
||||
/// which is SSE2 and no FMA, and the portable loop below was not being
|
||||
/// vectorised into even that. Measured over a real 18,143-face library, on
|
||||
/// twenty cores:
|
||||
///
|
||||
/// | kernel | scan | GFLOP/s |
|
||||
/// |---|---|---|
|
||||
/// | portable, untiled (what this replaced) | 4.64 s | 36 |
|
||||
/// | portable, tiled | 2.81 s | 60 |
|
||||
/// | **AVX2 + FMA, tiled** | **0.86 s** | **195 |
|
||||
///
|
||||
/// Identical pair lists — 1,531,969 — all three ways.
|
||||
///
|
||||
/// **Runtime detection on x86-64, unconditional on aarch64.** Advanced SIMD is
|
||||
/// in the aarch64 baseline, so every Android device that runs this has NEON and
|
||||
/// there is nothing to detect; on x86-64 AVX2 is not baseline and a binary that
|
||||
/// assumed it would not start on older hardware.
|
||||
///
|
||||
/// The three kernels sum in different orders, so a cosine may differ in its
|
||||
/// last bit between them. That only matters for a pair sitting exactly on the
|
||||
/// threshold, and the portable kernel already sums in eight accumulators rather
|
||||
/// than one, so the module was never bit-comparable with a naive sum.
|
||||
pub(crate) fn fastest_dot() -> DotFn {
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
if std::arch::is_x86_feature_detected!("avx2") && std::arch::is_x86_feature_detected!("fma")
|
||||
{
|
||||
return dot_avx2;
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
return dot_neon;
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
dot
|
||||
}
|
||||
|
||||
/// Eight-wide fused multiply-add, on the half of desktops that have it.
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
fn dot_avx2(a: &[f32], b: &[f32]) -> f32 {
|
||||
// SAFETY: `fastest_dot` is the only thing that hands this out, and only
|
||||
// after `is_x86_feature_detected!` has said both features are present.
|
||||
unsafe { dot_avx2_inner(a, b) }
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
#[target_feature(enable = "avx2", enable = "fma")]
|
||||
unsafe fn dot_avx2_inner(a: &[f32], b: &[f32]) -> f32 {
|
||||
use std::arch::x86_64::*;
|
||||
|
||||
let n = a.len().min(b.len());
|
||||
let (mut acc0, mut acc1) = (_mm256_setzero_ps(), _mm256_setzero_ps());
|
||||
let mut k = 0;
|
||||
// Two accumulators, because one FMA cannot start until the previous one
|
||||
// retires and the unit is pipelined several deep.
|
||||
while k + 16 <= n {
|
||||
// SAFETY: `k + 16 <= n`, and `n` is within both slices.
|
||||
acc0 = _mm256_fmadd_ps(
|
||||
_mm256_loadu_ps(a.as_ptr().add(k)),
|
||||
_mm256_loadu_ps(b.as_ptr().add(k)),
|
||||
acc0,
|
||||
);
|
||||
acc1 = _mm256_fmadd_ps(
|
||||
_mm256_loadu_ps(a.as_ptr().add(k + 8)),
|
||||
_mm256_loadu_ps(b.as_ptr().add(k + 8)),
|
||||
acc1,
|
||||
);
|
||||
k += 16;
|
||||
}
|
||||
|
||||
let mut lanes = [0.0_f32; 8];
|
||||
_mm256_storeu_ps(lanes.as_mut_ptr(), _mm256_add_ps(acc0, acc1));
|
||||
let mut total = ((lanes[0] + lanes[1]) + (lanes[2] + lanes[3]))
|
||||
+ ((lanes[4] + lanes[5]) + (lanes[6] + lanes[7]));
|
||||
for k in k..n {
|
||||
total += a[k] * b[k];
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// The same, four-wide, for the phone and the tablet.
|
||||
///
|
||||
/// No feature detection and no `target_feature`: Advanced SIMD is mandatory in
|
||||
/// the aarch64 baseline, so this compiles for every Android target the app
|
||||
/// builds for. The explicit `vfmaq` matters — LLVM will not fuse a multiply and
|
||||
/// an add on its own without fast-math, which is most of the win.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
fn dot_neon(a: &[f32], b: &[f32]) -> f32 {
|
||||
use std::arch::aarch64::*;
|
||||
|
||||
let n = a.len().min(b.len());
|
||||
// SAFETY: every load below is bounded by `k + 16 <= n`, and `n` is within
|
||||
// both slices. NEON needs no feature detection on aarch64.
|
||||
unsafe {
|
||||
let mut acc = [vdupq_n_f32(0.0); 4];
|
||||
let mut k = 0;
|
||||
while k + 16 <= n {
|
||||
for (l, slot) in acc.iter_mut().enumerate() {
|
||||
*slot = vfmaq_f32(
|
||||
*slot,
|
||||
vld1q_f32(a.as_ptr().add(k + l * 4)),
|
||||
vld1q_f32(b.as_ptr().add(k + l * 4)),
|
||||
);
|
||||
}
|
||||
k += 16;
|
||||
}
|
||||
|
||||
let mut total = vaddvq_f32(vaddq_f32(
|
||||
vaddq_f32(acc[0], acc[1]),
|
||||
vaddq_f32(acc[2], acc[3]),
|
||||
));
|
||||
for k in k..n {
|
||||
total += a[k] * b[k];
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
/// The portable fallback, and the definition the others have to agree with.
|
||||
///
|
||||
/// Eight accumulators so the adds are independent; that is as much as can be
|
||||
/// asked of a loop that has to compile for anything.
|
||||
pub(crate) fn dot(a: &[f32], b: &[f32]) -> f32 {
|
||||
const LANES: usize = 8;
|
||||
let mut acc = [0.0_f32; LANES];
|
||||
let chunks = a.len() / LANES;
|
||||
let n = a.len().min(b.len());
|
||||
let chunks = n / LANES;
|
||||
|
||||
for c in 0..chunks {
|
||||
let base = c * LANES;
|
||||
@@ -279,7 +496,7 @@ pub(crate) fn dot(a: &[f32], b: &[f32]) -> f32 {
|
||||
}
|
||||
let mut total =
|
||||
((acc[0] + acc[1]) + (acc[2] + acc[3])) + ((acc[4] + acc[5]) + (acc[6] + acc[7]));
|
||||
for k in chunks * LANES..a.len() {
|
||||
for k in chunks * LANES..n {
|
||||
total += a[k] * b[k];
|
||||
}
|
||||
total
|
||||
@@ -344,19 +561,26 @@ mod tests {
|
||||
}
|
||||
|
||||
struct Set {
|
||||
embeddings: Vec<Vec<f32>>,
|
||||
embeddings: Vec<f32>,
|
||||
crop_px: Vec<f32>,
|
||||
images: Vec<u64>,
|
||||
gallery: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Set {
|
||||
fn faces(&self) -> Faces<'_> {
|
||||
Faces {
|
||||
embeddings: &self.embeddings,
|
||||
dim: DIM,
|
||||
crop_px: &self.crop_px,
|
||||
images: &self.images,
|
||||
gallery: &self.gallery,
|
||||
}
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.embeddings.len() / DIM
|
||||
}
|
||||
}
|
||||
|
||||
/// `groups` identities, `per` faces each, every face in its own photograph.
|
||||
@@ -378,25 +602,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let crop_px = vec![150.0; embeddings.len()];
|
||||
let gallery = vec![true; embeddings.len()];
|
||||
Set {
|
||||
embeddings,
|
||||
embeddings: embeddings.concat(),
|
||||
crop_px,
|
||||
images,
|
||||
gallery,
|
||||
}
|
||||
}
|
||||
|
||||
/// The unpruned, unthreaded, unblocked definition of the answer.
|
||||
fn reference(faces: &Faces, cal: &Calibration, min_probability: f32) -> Vec<Pair> {
|
||||
let n = faces.embeddings.len();
|
||||
let n = faces.len();
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in i + 1..n {
|
||||
if faces.images[i] == faces.images[j] {
|
||||
if faces.images[i] == faces.images[j] || !(faces.gallery[i] || faces.gallery[j]) {
|
||||
continue;
|
||||
}
|
||||
let cos: f32 = faces.embeddings[i]
|
||||
let cos: f32 = faces
|
||||
.row(i)
|
||||
.iter()
|
||||
.zip(&faces.embeddings[j])
|
||||
.zip(faces.row(j))
|
||||
.map(|(x, y)| x * y)
|
||||
.sum();
|
||||
let p = cal.probability(cos, faces.crop_px[i].min(faces.crop_px[j]), 0.0);
|
||||
@@ -416,6 +643,31 @@ mod tests {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.i == y.i && x.j == y.j)
|
||||
}
|
||||
|
||||
/// The guard on the SIMD kernels, and the only check the aarch64 one gets
|
||||
/// on a machine that is not aarch64: whatever [`fastest_dot`] picked has to
|
||||
/// agree with the portable definition. A wrong lane index or a mishandled
|
||||
/// tail would show up here as a wildly different number, not a rounding
|
||||
/// difference.
|
||||
#[test]
|
||||
fn the_fastest_kernel_agrees_with_the_portable_one() {
|
||||
let fast = fastest_dot();
|
||||
for seed in 0..64u64 {
|
||||
let a = vector(seed);
|
||||
let b = vector(seed + 1_000);
|
||||
let (want, got) = (dot(&a, &b), fast(&a, &b));
|
||||
assert!(
|
||||
(want - got).abs() < 1e-5,
|
||||
"kernel disagreed on seed {seed}: {want} vs {got}"
|
||||
);
|
||||
}
|
||||
|
||||
// A length that is not a multiple of the widest step, so the tail is
|
||||
// exercised rather than assumed away.
|
||||
let a: Vec<f32> = (0..37).map(|k| k as f32 * 0.01).collect();
|
||||
let b: Vec<f32> = (0..37).map(|k| 1.0 - k as f32 * 0.02).collect();
|
||||
assert!((dot(&a, &b) - fast(&a, &b)).abs() < 1e-5, "tail mishandled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_pair_is_no_pairs() {
|
||||
let s = population(1, 1, 0.9);
|
||||
@@ -438,7 +690,7 @@ mod tests {
|
||||
fn the_threaded_scan_finds_exactly_what_the_reference_does() {
|
||||
let s = population(300, 8, 0.97);
|
||||
assert!(
|
||||
s.embeddings.len() > THREADS_ABOVE,
|
||||
s.len() > THREADS_ABOVE,
|
||||
"population is below the threading cutoff"
|
||||
);
|
||||
let f = s.faces();
|
||||
@@ -514,6 +766,35 @@ mod tests {
|
||||
assert!(above_threshold(&s.faces(), &cal(), 0.9).is_empty());
|
||||
}
|
||||
|
||||
/// A probe against a reference is a comparison; two probes against each
|
||||
/// other is not. The rule lives here so that nothing downstream sees the
|
||||
/// pair at all.
|
||||
#[test]
|
||||
fn two_faces_outside_the_gallery_are_never_paired() {
|
||||
let mut s = population(1, 3, 1.0);
|
||||
s.gallery = vec![false, false, true];
|
||||
let pairs = above_threshold(&s.faces(), &cal(), 0.9);
|
||||
assert!(
|
||||
!pairs.iter().any(|p| p.i == 0 && p.j == 1),
|
||||
"two probes were paired with each other"
|
||||
);
|
||||
// Each probe is still measured against the one reference.
|
||||
assert!(pairs.iter().any(|p| p.i == 0 && p.j == 2));
|
||||
assert!(pairs.iter().any(|p| p.i == 1 && p.j == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gallery_rule_matches_the_reference_at_scale() {
|
||||
let mut s = population(60, 8, 0.97);
|
||||
for (i, g) in s.gallery.iter_mut().enumerate() {
|
||||
*g = i % 3 != 0;
|
||||
}
|
||||
let f = s.faces();
|
||||
let got = above_threshold(&f, &cal(), 0.9);
|
||||
let want = reference(&f, &cal(), 0.9);
|
||||
assert!(same_pairs(&got, &want), "{} vs {}", got.len(), want.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairs_come_back_in_index_order() {
|
||||
let s = population(300, 8, 0.97);
|
||||
|
||||
@@ -161,7 +161,7 @@ fn main() {
|
||||
drain.set_param("saturation", ParamId("saturation"), -100.0);
|
||||
// A touch of feather, or the colour stops dead on the model's outline and
|
||||
// the eye goes straight to the edge instead of to the subject.
|
||||
drain.feather = 0.02;
|
||||
drain.base_mut().feather = 0.02;
|
||||
pop.push(drain);
|
||||
|
||||
render_stack(
|
||||
@@ -183,14 +183,14 @@ fn main() {
|
||||
|
||||
let mut brighter = subject_layer("m1", index, subject);
|
||||
brighter.set_param("exposure", ParamId("exposure"), 0.45);
|
||||
brighter.feather = 0.015;
|
||||
brighter.base_mut().feather = 0.015;
|
||||
lift.push(brighter);
|
||||
|
||||
let mut darker = subject_layer("m2", index, subject);
|
||||
darker.invert = true;
|
||||
darker.set_param("exposure", ParamId("exposure"), -0.55);
|
||||
darker.set_param("saturation", ParamId("saturation"), -25.0);
|
||||
darker.feather = 0.03;
|
||||
darker.base_mut().feather = 0.03;
|
||||
lift.push(darker);
|
||||
|
||||
render_stack(
|
||||
@@ -221,9 +221,9 @@ fn main() {
|
||||
let mut layer = subject_layer("m1", index, subject);
|
||||
layer.invert = true;
|
||||
layer.set_param("saturation", ParamId("saturation"), -100.0);
|
||||
layer.feather = 0.004;
|
||||
layer.morphology = morphology;
|
||||
layer.morph_radius = radius;
|
||||
layer.base_mut().feather = 0.004;
|
||||
layer.base_mut().morphology = morphology;
|
||||
layer.base_mut().morph_radius = radius;
|
||||
stack.push(layer);
|
||||
|
||||
render_stack(
|
||||
@@ -307,14 +307,14 @@ fn render_stack(
|
||||
pw as usize,
|
||||
ph as usize,
|
||||
128,
|
||||
match layer.morphology {
|
||||
match layer.base().morphology {
|
||||
Morphology::None => dr_segment::Morphology::None,
|
||||
Morphology::Dilate => dr_segment::Morphology::Dilate,
|
||||
Morphology::Erode => dr_segment::Morphology::Erode,
|
||||
Morphology::Close => dr_segment::Morphology::Close,
|
||||
Morphology::Open => dr_segment::Morphology::Open,
|
||||
},
|
||||
layer.morph_radius * pw.min(ph) as f32,
|
||||
layer.base().morph_radius * pw.min(ph) as f32,
|
||||
)
|
||||
.distance
|
||||
})
|
||||
@@ -326,7 +326,7 @@ fn render_stack(
|
||||
// after the framing map — which is what makes one mask correct at every
|
||||
// output size, zoom and crop.
|
||||
let array = masks
|
||||
.render(stack, None, Some(&subjects), pw, ph)
|
||||
.render(stack, None, Some(&subjects), Some(source), pw, ph)
|
||||
.expect("rasterise masks");
|
||||
|
||||
let shader = compose_full(
|
||||
@@ -335,6 +335,7 @@ fn render_stack(
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
adjust
|
||||
.render_masked(source, &shader, ow, oh, Some(array))
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Every way of editing a mask, as frames you can watch.
|
||||
//!
|
||||
//! The mask tools are hard to review from a still: what makes them right is
|
||||
//! how the mask *moves* as a stroke is painted, as a correction is subtracted,
|
||||
//! as an edge is shaped. This renders that — a synthetic photograph and one
|
||||
//! frame per step of each mode — so the pipeline's behaviour can be watched
|
||||
//! before any of it is wired to a finger.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p dr-gpu --example mask_modes --release -- out
|
||||
//! ffmpeg -y -framerate 12 -i out/paint-%03d.ppm out/paint.gif
|
||||
//! ```
|
||||
//!
|
||||
//! PPM for the reason every other example here writes it: no encoder
|
||||
//! dependency, and ffmpeg, ImageMagick and every viewer read it.
|
||||
//!
|
||||
//! # What it is really showing
|
||||
//!
|
||||
//! The fold that builds a layer's mask (`MaskPass::render`), through the
|
||||
//! composed shader that samples it. A part drawn in the wrong order, a
|
||||
//! subtraction that took the base with it, an erase stroke that punched
|
||||
//! through the selection underneath — each of those is a frame here that looks
|
||||
//! wrong, and none of them is visible in a single rendered still.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack, Morphology};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::spot::SpotSet;
|
||||
use dr_pipeline::{ops, Framing};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
const W: u32 = 480;
|
||||
const H: u32 = 320;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let dir = std::env::args().nth(1).unwrap_or_else(|| "out".into());
|
||||
std::fs::create_dir_all(&dir).expect("output directory");
|
||||
|
||||
let Some(ctx) = pollster::block_on(GpuContext::new_headless()).ok() else {
|
||||
eprintln!("no adapter; nothing to render");
|
||||
return;
|
||||
};
|
||||
|
||||
let source = DemosaicedImage::from_rgba8(&ctx, &scene(), W, H).expect("upload");
|
||||
let mut masks = MaskPass::new(&ctx).expect("mask pass");
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
let mut shot = Shot {
|
||||
source: &source,
|
||||
masks: &mut masks,
|
||||
adjust: &mut adjust,
|
||||
dir: &dir,
|
||||
};
|
||||
|
||||
write_ppm(&format!("{dir}/original.ppm"), &to_rgb(&scene()), W, H);
|
||||
|
||||
paint(&mut shot);
|
||||
erase(&mut shot);
|
||||
subtract(&mut shot);
|
||||
invert(&mut shot);
|
||||
shape(&mut shot);
|
||||
|
||||
println!("frames in {dir}/");
|
||||
}
|
||||
|
||||
/// One layer, brightened hard, so the mask is legible rather than tasteful.
|
||||
fn lifted(source: MaskSource) -> MaskLayer {
|
||||
let mut layer = MaskLayer::new("m1", source);
|
||||
layer.set_param("exposure", ParamId("exposure"), 1.6);
|
||||
layer.set_param("saturation", ParamId("vibrance"), 0.6);
|
||||
layer
|
||||
}
|
||||
|
||||
/// A stroke painted from left to right across the subject, one frame per dab.
|
||||
///
|
||||
/// Each frame is the whole mask rasterised again, which is what the
|
||||
/// application does today on every shape change — so the frames are also a
|
||||
/// crude answer to "is a stroke's cost growing as it is painted".
|
||||
fn paint(shot: &mut Shot) {
|
||||
let mut layer = lifted(MaskSource::brush());
|
||||
layer.begin_stroke(0, false, 0.13, 0.5, 1.0);
|
||||
for (i, x) in steps(0.18, 0.82, 28).enumerate() {
|
||||
layer.extend_stroke(0, x, 0.52 + 0.06 * (x * 9.0).sin());
|
||||
shot.frame("paint", i, &layer);
|
||||
}
|
||||
layer.end_stroke(0);
|
||||
}
|
||||
|
||||
/// The same layer, with an erase stroke taken back through the middle of it.
|
||||
fn erase(shot: &mut Shot) {
|
||||
let mut layer = lifted(MaskSource::brush());
|
||||
layer.begin_stroke(0, false, 0.16, 0.5, 1.0);
|
||||
for x in steps(0.18, 0.82, 20) {
|
||||
layer.extend_stroke(0, x, 0.5);
|
||||
}
|
||||
layer.end_stroke(0);
|
||||
|
||||
for i in 0..8 {
|
||||
shot.frame("erase", i, &layer);
|
||||
}
|
||||
layer.begin_stroke(0, true, 0.09, 0.7, 1.0);
|
||||
for (i, x) in steps(0.25, 0.75, 20).enumerate() {
|
||||
layer.extend_stroke(0, x, 0.5);
|
||||
shot.frame("erase", 8 + i, &layer);
|
||||
}
|
||||
layer.end_stroke(0);
|
||||
}
|
||||
|
||||
/// A correction joined to a radial selection and then taken out of it: the
|
||||
/// part appears, is painted, and the mask loses exactly what it covers.
|
||||
fn subtract(shot: &mut Shot) {
|
||||
let mut layer = lifted(MaskSource::Radial {
|
||||
centre: (0.5, 0.5),
|
||||
radii: (0.42, 0.34),
|
||||
angle: 0.0,
|
||||
feather: 0.35,
|
||||
});
|
||||
|
||||
for i in 0..8 {
|
||||
shot.frame("subtract", i, &layer);
|
||||
}
|
||||
|
||||
layer.push_part(MaskPart::painted("p2", Join::Subtract));
|
||||
for (i, x) in steps(0.3, 0.72, 22).enumerate() {
|
||||
if i == 0 {
|
||||
layer.begin_stroke(1, false, 0.1, 0.6, 1.0);
|
||||
}
|
||||
layer.extend_stroke(1, x, 0.46);
|
||||
shot.frame("subtract", 8 + i, &layer);
|
||||
}
|
||||
layer.end_stroke(1);
|
||||
|
||||
// And off again, which is the half a stroke cannot do: a part is a thing
|
||||
// that can be switched off after the fact.
|
||||
for i in 0..8 {
|
||||
let mut without = layer.clone();
|
||||
without.remove_part(1);
|
||||
shot.frame("subtract", 30 + i, &without);
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer turned over, and back, holding each state long enough to read.
|
||||
fn invert(shot: &mut Shot) {
|
||||
let mut layer = lifted(MaskSource::Radial {
|
||||
centre: (0.42, 0.52),
|
||||
radii: (0.3, 0.32),
|
||||
angle: 0.0,
|
||||
feather: 0.3,
|
||||
});
|
||||
|
||||
for i in 0..24 {
|
||||
layer.invert = (i / 8) % 2 == 1;
|
||||
shot.frame("invert", i, &layer);
|
||||
}
|
||||
}
|
||||
|
||||
/// The edge controls, swept: a feather opening up, then a dilation pushing the
|
||||
/// boundary out and an erosion pulling it back.
|
||||
fn shape(shot: &mut Shot) {
|
||||
let mut layer = lifted(MaskSource::Radial {
|
||||
centre: (0.5, 0.5),
|
||||
radii: (0.3, 0.3),
|
||||
angle: 0.0,
|
||||
feather: 0.02,
|
||||
});
|
||||
layer.base_mut().falloff = dr_pipeline::mask::Falloff::Smooth;
|
||||
|
||||
for (i, f) in steps(0.0, 0.09, 18).enumerate() {
|
||||
layer.base_mut().feather = f;
|
||||
shot.frame("shape", i, &layer);
|
||||
}
|
||||
layer.base_mut().morphology = Morphology::Dilate;
|
||||
for (i, r) in steps(0.0, 0.06, 12).enumerate() {
|
||||
layer.base_mut().morph_radius = r;
|
||||
shot.frame("shape", 18 + i, &layer);
|
||||
}
|
||||
layer.base_mut().morphology = Morphology::Erode;
|
||||
for (i, r) in steps(0.0, 0.06, 12).enumerate() {
|
||||
layer.base_mut().morph_radius = r;
|
||||
shot.frame("shape", 30 + i, &layer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one frame needs, so the mode functions read as what they do.
|
||||
struct Shot<'a> {
|
||||
source: &'a DemosaicedImage,
|
||||
masks: &'a mut MaskPass,
|
||||
adjust: &'a mut AdjustPass,
|
||||
dir: &'a str,
|
||||
}
|
||||
|
||||
impl Shot<'_> {
|
||||
fn frame(&mut self, mode: &str, index: usize, layer: &MaskLayer) {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer.clone());
|
||||
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
&stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
|
||||
let array = self
|
||||
.masks
|
||||
.render(&stack, None, None, Some(self.source), W, H)
|
||||
.expect("rasterise");
|
||||
self.adjust
|
||||
.render_masked(self.source, &shader, W, H, Some(array))
|
||||
.expect("render");
|
||||
let rgba = self.adjust.export_pixels().expect("readback").0;
|
||||
|
||||
write_ppm(
|
||||
&format!("{}/{mode}-{index:03}.ppm", self.dir),
|
||||
&to_rgb(&rgba),
|
||||
W,
|
||||
H,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `count` values from `from` to `to`, inclusive.
|
||||
fn steps(from: f32, to: f32, count: usize) -> impl Iterator<Item = f32> {
|
||||
(0..count).map(move |i| from + (to - from) * i as f32 / (count.max(2) - 1) as f32)
|
||||
}
|
||||
|
||||
/// A picture with somewhere obvious to put a mask: a graded sky, a ground
|
||||
/// band, and a warm subject sitting on the join.
|
||||
fn scene() -> Vec<u8> {
|
||||
let mut px = vec![0u8; (W * H * 4) as usize];
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let (fx, fy) = (x as f32 / W as f32, y as f32 / H as f32);
|
||||
let sky = [
|
||||
(60.0 + 90.0 * fy) as u8,
|
||||
(110.0 + 90.0 * fy) as u8,
|
||||
(190.0 + 50.0 * fy) as u8,
|
||||
];
|
||||
let ground = [
|
||||
(70.0 + 40.0 * fx) as u8,
|
||||
(85.0 + 30.0 * fx) as u8,
|
||||
(60.0 + 20.0 * fx) as u8,
|
||||
];
|
||||
let mut c = if fy > 0.62 { ground } else { sky };
|
||||
|
||||
// The subject: an ellipse, warm, with a little internal structure
|
||||
// so a feathered edge has something to be soft against.
|
||||
let (dx, dy) = ((fx - 0.5) / 0.22, (fy - 0.52) / 0.3);
|
||||
if dx * dx + dy * dy < 1.0 {
|
||||
let shade = 0.75 + 0.25 * (fx * 40.0).sin() * (fy * 30.0).cos();
|
||||
c = [
|
||||
(205.0 * shade) as u8,
|
||||
(170.0 * shade) as u8,
|
||||
(140.0 * shade) as u8,
|
||||
];
|
||||
}
|
||||
|
||||
let i = ((y * W + x) * 4) as usize;
|
||||
px[i..i + 4].copy_from_slice(&[c[0], c[1], c[2], 255]);
|
||||
}
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
fn to_rgb(rgba: &[u8]) -> Vec<u8> {
|
||||
rgba.chunks_exact(4)
|
||||
.flat_map(|p| [p[0], p[1], p[2]])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_ppm(path: &str, rgb: &[u8], w: u32, h: u32) {
|
||||
use std::io::Write as _;
|
||||
let mut f = std::io::BufWriter::new(std::fs::File::create(path).expect("create"));
|
||||
write!(f, "P6\n{w} {h}\n255\n").expect("header");
|
||||
f.write_all(rgb).expect("body");
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Sweep a range mask's band and show what it selects.
|
||||
//!
|
||||
//! A diagnostic for FR-DEV-10. The other sweep example drives global
|
||||
//! parameters through `EditGraph`; a band is not one of those — it lives on a
|
||||
//! `MaskLayer`, is rasterised by its own pass, and only becomes visible
|
||||
//! through whatever adjustment the layer carries.
|
||||
//!
|
||||
//! So the layer here is given a deliberately blunt adjustment — two stops down
|
||||
//! — because the question this answers is *what does the band select*, not
|
||||
//! *what would a photographer do with it*. A subtle edit would show a subtle
|
||||
//! selection and prove nothing.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p dr-gpu --release --example rangesweep -- IMG.CR2 out/ luminance 21
|
||||
//! cargo run -p dr-gpu --release --example rangesweep -- IMG.CR2 out/ hue 21
|
||||
//! ```
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass};
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::ops;
|
||||
use dr_pipeline::spot::SpotSet;
|
||||
use dr_pipeline::{Framing, ParamId};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
let (Some(input), Some(out_dir), Some(mode)) = (args.next(), args.next(), args.next()) else {
|
||||
eprintln!("usage: rangesweep <file.cr2> <out_dir> <luminance|hue|width> [steps]");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let steps: usize = args
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.filter(|n| *n >= 2)
|
||||
.unwrap_or(21);
|
||||
|
||||
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
|
||||
let image = load(&ctx, &input);
|
||||
std::fs::create_dir_all(&out_dir).expect("create out dir");
|
||||
|
||||
let (full_w, full_h) = image.size();
|
||||
let longest = std::env::var("SWEEP_MAX_PX")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(1000);
|
||||
let scale = (longest as f32 / full_w.max(full_h) as f32).min(1.0);
|
||||
let w = ((full_w as f32 * scale) as u32).max(1);
|
||||
let h = ((full_h as f32 * scale) as u32).max(1);
|
||||
println!("rendering {w} x {h}");
|
||||
|
||||
let mut masks = MaskPass::new(&ctx).expect("mask pass");
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
|
||||
for i in 0..steps {
|
||||
let t = i as f32 / (steps - 1) as f32;
|
||||
|
||||
// What moves, and what the caption should say about it.
|
||||
let (source, label) = match mode.as_str() {
|
||||
// A half-wide band walking from black to white, so the selection
|
||||
// sweeps across the tonal scale rather than merely widening.
|
||||
"luminance" => {
|
||||
let centre = t;
|
||||
let half = 0.15;
|
||||
(
|
||||
MaskSource::luminance_range(centre - half, centre + half, 0.10),
|
||||
format!("luminance band centred {:.2}", centre),
|
||||
)
|
||||
}
|
||||
// The hue circle, at a fixed arc and a chroma floor that keeps the
|
||||
// near-neutral parts of the picture out of it.
|
||||
"hue" => (
|
||||
MaskSource::colour_range(t, 0.08, 0.05, 1.0, 0.10),
|
||||
format!("hue {:.0} deg", t * 360.0),
|
||||
),
|
||||
// The arc opening from nothing to everything, at a fixed hue.
|
||||
"width" => (
|
||||
MaskSource::colour_range(0.08, t * 0.5, 0.05, 1.0, 0.10),
|
||||
format!("hue width {:.0} deg", t * 0.5 * 360.0),
|
||||
),
|
||||
other => {
|
||||
eprintln!("unknown mode `{other}`");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
let mut layer = MaskLayer::new("sweep", source);
|
||||
// Two stops down: blunt on purpose. See the module docs.
|
||||
layer.set_param("exposure", ParamId("exposure"), -2.0);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
|
||||
// No label field and no subject masks: a range needs neither. It is a
|
||||
// weighting over the picture's own values, so the only input it wants
|
||||
// is the picture, which is the `Some(&image)` below.
|
||||
let array = masks
|
||||
.render(&stack, None, None, Some(&image), w, h)
|
||||
.expect("rasterise");
|
||||
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
&stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
adjust
|
||||
.render_masked(&image, &shader, w, h, Some(array))
|
||||
.expect("render");
|
||||
let (pixels, pw, ph) = adjust.export_pixels().expect("readback");
|
||||
|
||||
let path = format!("{out_dir}/{mode}_{i:03}.ppm");
|
||||
write_ppm(&path, &pixels, pw, ph);
|
||||
std::fs::write(format!("{out_dir}/{mode}_{i:03}.txt"), format!("{label}\n"))
|
||||
.expect("write label");
|
||||
println!("{mode}[{i}] {label}");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_ppm(path: &str, rgba: &[u8], w: u32, h: u32) {
|
||||
use std::io::Write;
|
||||
let mut out = Vec::with_capacity((w * h * 3) as usize + 32);
|
||||
out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes());
|
||||
for px in rgba.chunks_exact(4) {
|
||||
out.extend_from_slice(&px[..3]);
|
||||
}
|
||||
std::fs::File::create(path)
|
||||
.expect("create output")
|
||||
.write_all(&out)
|
||||
.expect("write output");
|
||||
}
|
||||
|
||||
/// Load either a RAW or an already-rendered image.
|
||||
///
|
||||
/// A JPEG takes the path `DemosaicedImage::from_rgba8` documents: no CFA to
|
||||
/// interpolate, identity colour matrix, neutral white balance, and the shader
|
||||
/// linearises the gamma-encoded pixels. The controls all still work; their
|
||||
/// neutral is "as the camera left it" rather than "as the sensor recorded it",
|
||||
/// which is worth knowing when reading a sweep made from one.
|
||||
fn load(ctx: &GpuContext, path: &str) -> DemosaicedImage {
|
||||
let bytes = std::fs::read(path).expect("read file");
|
||||
match dr_decode::probe(&bytes) {
|
||||
Some(dr_types::Format::Jpeg) => {
|
||||
let p = dr_decode::decode_jpeg(&bytes).expect("decode jpeg");
|
||||
println!("loaded {} x {} (rendered, not raw)", p.width, p.height);
|
||||
DemosaicedImage::from_rgba8(ctx, &p.rgba, p.width, p.height).expect("upload")
|
||||
}
|
||||
_ => {
|
||||
let raw = dr_decode::decode(&bytes).expect("decode raw");
|
||||
println!("loaded {} x {} (raw)", raw.crop.width, raw.crop.height);
|
||||
let demosaic = Demosaicer::new(ctx).expect("demosaicer");
|
||||
demosaic.run(&raw).expect("demosaic")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Sweep one operation's parameters and write a frame per step.
|
||||
//!
|
||||
//! A diagnostic, not part of the product: it exists to show what a control
|
||||
//! actually does to a photograph, one parameter at a time, so a new node can
|
||||
//! be looked at rather than reasoned about.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p dr-gpu --release --example sweep -- IMG.CR2 out/ colour_grading 21
|
||||
//! ```
|
||||
//!
|
||||
//! It names no operation. The op id arrives as a string, the parameters and
|
||||
//! their ranges come from the graph's own capabilities, and a node declared
|
||||
//! yesterday sweeps on the same terms as one that shipped a year ago — which
|
||||
//! is the property `ops/README.md` promises and the reason this is one example
|
||||
//! rather than one per node.
|
||||
//!
|
||||
//! PPM out, like `develop.rs`, so it needs no encoder dependency; the caller
|
||||
//! turns them into whatever it wants.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
|
||||
use dr_pipeline::{Affects, EditGraph, OpId, ParamId, ParamKind};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
let (Some(input), Some(out_dir), Some(op)) = (args.next(), args.next(), args.next()) else {
|
||||
eprintln!("usage: sweep <file.cr2> <out_dir> <op_id> [steps]");
|
||||
eprintln!(" sweep <file.cr2> <out_dir> --list");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let steps: usize = args
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.filter(|n| *n >= 2)
|
||||
.unwrap_or(21);
|
||||
// Sweep one named parameter rather than all of them.
|
||||
let only: Option<String> = args.next();
|
||||
|
||||
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
|
||||
let image = load(&ctx, &input);
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
|
||||
// `--list` prints every op and parameter with its range, which is how the
|
||||
// caller learns what there is to sweep without this file holding a list
|
||||
// that would go stale.
|
||||
if op == "--list" {
|
||||
for cap in graph.capabilities() {
|
||||
println!("{}", cap.id.0);
|
||||
for p in &cap.params {
|
||||
match p.kind {
|
||||
ParamKind::Scalar { min, max, .. } => {
|
||||
println!(" {:<20} {min} .. {max} default {}", p.id.0, p.default)
|
||||
}
|
||||
ParamKind::Bool => println!(" {:<20} bool", p.id.0),
|
||||
ParamKind::Enum { ref variants } => {
|
||||
println!(" {:<20} enum, {} variants", p.id.0, variants.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&out_dir).expect("create out dir");
|
||||
|
||||
// `OpId`/`ParamId` hold `&'static str`, and an argument is not static.
|
||||
// Leaking is right rather than expedient here: the ids live as long as the
|
||||
// graph does, and this process exits immediately after.
|
||||
let op_id = OpId(Box::leak(op.clone().into_boxed_str()));
|
||||
|
||||
let cap = graph
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id == op_id)
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("no operation `{op}` — try --list");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// Bounded output rather than full sensor resolution. This is the proxy
|
||||
// path FR-DSP-1 already renders through, so it is the same code the
|
||||
// develop view uses — and a 25 MP frame would be a 75 MB PPM, times
|
||||
// several hundred frames in one sweep.
|
||||
let (full_w, full_h) = image.size();
|
||||
let longest = std::env::var("SWEEP_MAX_PX")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(1100);
|
||||
let scale = (longest as f32 / full_w.max(full_h) as f32).min(1.0);
|
||||
let w = ((full_w as f32 * scale) as u32).max(1);
|
||||
let h = ((full_h as f32 * scale) as u32).max(1);
|
||||
println!("rendering {w} x {h} (from {full_w} x {full_h})");
|
||||
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
|
||||
// The reference frame: every parameter at its default. Written once so a
|
||||
// viewer can see what the sweep is departing from.
|
||||
render_to(
|
||||
&mut adjust,
|
||||
&image,
|
||||
&graph,
|
||||
w,
|
||||
h,
|
||||
&out_dir,
|
||||
"neutral",
|
||||
0,
|
||||
0.0,
|
||||
);
|
||||
|
||||
// Parameters held away from their default for the duration, as
|
||||
// `SWEEP_HOLD=shadow_strength=70,midtone_hue=210`.
|
||||
//
|
||||
// Needed because a parameter is not always meaningful alone. Where a
|
||||
// `presentation:` block groups several into one conceptual control — a
|
||||
// hue and the strength behind it — sweeping one with the other at its
|
||||
// default renders the same frame every time, which looks like a broken
|
||||
// node rather than a correctly declared neutral.
|
||||
let hold = std::env::var("SWEEP_HOLD").unwrap_or_default();
|
||||
for clause in hold.split(',').filter(|c| !c.trim().is_empty()) {
|
||||
let Some((name, value)) = clause.split_once('=') else {
|
||||
eprintln!("SWEEP_HOLD wants name=value, got `{clause}`");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let value: f32 = value.trim().parse().expect("hold value");
|
||||
let name = Box::leak(name.trim().to_string().into_boxed_str());
|
||||
graph.set_param(op_id, ParamId(name), value);
|
||||
println!("holding {name} = {value}");
|
||||
}
|
||||
|
||||
for p in &cap.params {
|
||||
if only.as_deref().is_some_and(|o| o != p.id.0) {
|
||||
continue;
|
||||
}
|
||||
let ParamKind::Scalar { min, max, .. } = p.kind else {
|
||||
eprintln!("skipping {} — only scalars sweep meaningfully", p.id.0);
|
||||
continue;
|
||||
};
|
||||
let param_id = ParamId(Box::leak(p.id.0.to_string().into_boxed_str()));
|
||||
|
||||
for i in 0..steps {
|
||||
let t = i as f32 / (steps - 1) as f32;
|
||||
let value = min + (max - min) * t;
|
||||
graph.set_param(op_id, param_id, value);
|
||||
render_to(
|
||||
&mut adjust,
|
||||
&image,
|
||||
&graph,
|
||||
w,
|
||||
h,
|
||||
&out_dir,
|
||||
p.id.0,
|
||||
i,
|
||||
value,
|
||||
);
|
||||
}
|
||||
// Back to default before the next parameter, so each sweep is of one
|
||||
// control rather than of everything tried so far.
|
||||
graph.set_param(op_id, param_id, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_to(
|
||||
adjust: &mut AdjustPass,
|
||||
image: &dr_gpu::DemosaicedImage,
|
||||
graph: &EditGraph,
|
||||
w: u32,
|
||||
h: u32,
|
||||
dir: &str,
|
||||
name: &str,
|
||||
index: usize,
|
||||
value: f32,
|
||||
) {
|
||||
// The detail path, always. A neighbourhood node — dehaze, clarity,
|
||||
// sharpening — runs as its own dispatch after the fused pass, and the
|
||||
// fused path refuses a shader composed with one rather than rendering it
|
||||
// wrongly. `render_detailed` falls through to the plain path when the
|
||||
// chain has no detail stage, so this one call serves both kinds of node
|
||||
// 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 key = graph.invalidation().through(Affects::Colour);
|
||||
adjust
|
||||
.render_detailed(image, &shader, w, h, None, &detail, key)
|
||||
.expect("adjust");
|
||||
let (pixels, pw, ph) = adjust.export_pixels().expect("readback");
|
||||
|
||||
let path = format!("{dir}/{name}_{index:03}.ppm");
|
||||
write_ppm(&path, &pixels, pw, ph);
|
||||
|
||||
// The value goes beside the frame rather than into the filename: a caption
|
||||
// wants "-37.5", and a filename that carried it would need escaping and
|
||||
// would sort wrongly.
|
||||
let meta = format!("{dir}/{name}_{index:03}.txt");
|
||||
std::fs::write(meta, format!("{name} {value:.4}\n")).expect("write value");
|
||||
println!("{name}[{index}] = {value:.4}");
|
||||
}
|
||||
|
||||
fn write_ppm(path: &str, rgba: &[u8], w: u32, h: u32) {
|
||||
use std::io::Write;
|
||||
let mut out = Vec::with_capacity((w * h * 3) as usize + 32);
|
||||
out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes());
|
||||
for px in rgba.chunks_exact(4) {
|
||||
out.extend_from_slice(&px[..3]);
|
||||
}
|
||||
std::fs::File::create(path)
|
||||
.expect("create output")
|
||||
.write_all(&out)
|
||||
.expect("write output");
|
||||
}
|
||||
|
||||
/// Load either a RAW or an already-rendered image.
|
||||
///
|
||||
/// A JPEG takes the path `DemosaicedImage::from_rgba8` documents: no CFA to
|
||||
/// interpolate, identity colour matrix, neutral white balance, and the shader
|
||||
/// linearises the gamma-encoded pixels. The controls all still work; their
|
||||
/// neutral is "as the camera left it" rather than "as the sensor recorded it",
|
||||
/// which is worth knowing when reading a sweep made from one.
|
||||
fn load(ctx: &GpuContext, path: &str) -> DemosaicedImage {
|
||||
let bytes = std::fs::read(path).expect("read file");
|
||||
match dr_decode::probe(&bytes) {
|
||||
Some(dr_types::Format::Jpeg) => {
|
||||
let p = dr_decode::decode_jpeg(&bytes).expect("decode jpeg");
|
||||
println!("loaded {} x {} (rendered, not raw)", p.width, p.height);
|
||||
DemosaicedImage::from_rgba8(ctx, &p.rgba, p.width, p.height).expect("upload")
|
||||
}
|
||||
_ => {
|
||||
let raw = dr_decode::decode(&bytes).expect("decode raw");
|
||||
println!("loaded {} x {} (raw)", raw.crop.width, raw.crop.height);
|
||||
let demosaic = Demosaicer::new(ctx).expect("demosaicer");
|
||||
demosaic.run(&raw).expect("demosaic")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1026,6 +1026,44 @@ impl AdjustPass {
|
||||
h
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
|
||||
/// Give back every allocation this pass is holding only to be fast again.
|
||||
///
|
||||
/// What goes, and why each is safe to lose:
|
||||
///
|
||||
/// - **The compiled pipelines**, here and in the detail stage. A pure
|
||||
/// lookup keyed by structure hash with a compile-on-miss behind it, and
|
||||
/// unbounded until now — nothing ever removed an entry, so a session
|
||||
/// that visited enough distinct edit structures accumulated shader
|
||||
/// objects for the life of the process.
|
||||
/// - **The detail intermediates**, which are viewport-sized `Rgba16Float`
|
||||
/// and, as `detail.rs` says of them, grow but never shrink.
|
||||
/// - **The two output textures.** Dropping these does not take the picture
|
||||
/// off the screen: whatever was handed to the compositor holds its own
|
||||
/// reference to the `wgpu::Texture`, so releasing ours only means the
|
||||
/// *next* render allocates rather than reuses. `ensure_target` already
|
||||
/// treats an empty slot as "allocate", because that is the state it
|
||||
/// starts in.
|
||||
///
|
||||
/// **`colour_key` must be cleared with them, and this is the part that
|
||||
/// would bite.** The key is the promise that slot 0 of the detail pool
|
||||
/// still holds the fused colour result, and it is what lets a sharpening
|
||||
/// slider skip the colour chain (FR-DEV-3d). Freeing the pool while the
|
||||
/// promise stood would make the next detail-only render sample a
|
||||
/// just-allocated texture with nothing in it — a silently wrong frame, not
|
||||
/// a failure, and one that would only appear on a device under memory
|
||||
/// pressure.
|
||||
///
|
||||
/// What deliberately stays: the demosaiced source is not this pass's to
|
||||
/// drop, the film tables are set once by a caller that will not be asked
|
||||
/// again, and the bind group layouts are bytes rather than megabytes.
|
||||
pub fn release_caches(&mut self) {
|
||||
self.cache.clear();
|
||||
self.detail.release_caches();
|
||||
self.targets = [None, None];
|
||||
self.colour_key = None;
|
||||
}
|
||||
|
||||
/// How many distinct pipelines are compiled. Exposed for tests asserting
|
||||
/// that slider movement does not recompile.
|
||||
pub fn cached_pipelines(&self) -> usize {
|
||||
@@ -1807,7 +1845,21 @@ mod tests {
|
||||
fused_blocks += usize::from(point);
|
||||
}
|
||||
|
||||
// The count the loop above accumulated, plus framing — which emits a
|
||||
// The lens corrections, which are the third kind of block. They are
|
||||
// not in `descriptors` — they rewrite coordinates rather than
|
||||
// transform a colour, so they are not operations — and they run ahead
|
||||
// of the fetch rather than in either stage the loop above sorts into.
|
||||
let mut warp_blocks = 0;
|
||||
for desc in g.warp_descriptors() {
|
||||
let id = desc.id.0;
|
||||
assert!(
|
||||
shader.source.contains(&format!("---- warp: {id} ----")),
|
||||
"{id} was armed above and did not reach the shader"
|
||||
);
|
||||
warp_blocks += 1;
|
||||
}
|
||||
|
||||
// The counts the loops above accumulated, plus framing — which emits a
|
||||
// stage of its own rather than an operation block and is not in
|
||||
// `descriptors`. Asserted as well as the per-operation exclusive-or
|
||||
// because the two catch different faults: the XOR catches an operation
|
||||
@@ -1815,7 +1867,7 @@ mod tests {
|
||||
// in the chain asked for.
|
||||
assert_eq!(
|
||||
shader.source.matches("---- ").count(),
|
||||
fused_blocks + 1,
|
||||
fused_blocks + warp_blocks + 1,
|
||||
"the fused shader carries a block nothing in the chain asked for"
|
||||
);
|
||||
assert!(
|
||||
|
||||
+215
-11
@@ -49,6 +49,32 @@
|
||||
//! 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 reduced chain, and why a second one was needed
|
||||
//!
|
||||
//! A pass may declare [`dr_pipeline::detail::DetailPass::output_scale`] and
|
||||
//! write a target a fraction of the render size — clarity's base does, which
|
||||
//! is what TD-4 bought back. Such a pass cannot be part of the ping-pong
|
||||
//! above, and the reason is the shape of an unsharp mask rather than anything
|
||||
//! about textures: the pass that *combines* needs the blur **and** the
|
||||
//! full-resolution colour, and a colour that has been through a quarter-scale
|
||||
//! target is no longer full resolution. If the scaled passes wrote into the
|
||||
//! main chain they would destroy the very thing the last pass is going to
|
||||
//! subtract from.
|
||||
//!
|
||||
//! So there are two chains. The full-resolution one carries the colour and is
|
||||
//! untouched by a scaled pass; the reduced one carries the base. A scaled pass
|
||||
//! reads the reduced chain if anything has been written to it and the
|
||||
//! full-resolution chain otherwise — which is exactly "read what the pass
|
||||
//! before you wrote", the same rule as before. A full-resolution pass always
|
||||
//! reads the full-resolution chain, and sees the reduced one through binding 4
|
||||
//! as `reduced_at()`.
|
||||
//!
|
||||
//! One reduced buffer, not one per operation. Two operations both wanting a
|
||||
//! reduced base in the same frame would need more, and nothing does: clarity
|
||||
//! is the only caller and texture's band is a decade finer, so it must stay at
|
||||
//! full resolution. A `debug_assert` in [`DetailRunner::encode`] holds that
|
||||
//! claim rather than leaving it as a comment.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -131,6 +157,24 @@ impl Intermediates {
|
||||
self.allocations += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLAT-AND-5
|
||||
/// Drop the pool, leaving it as [`Intermediates::new`] left it.
|
||||
///
|
||||
/// The size is reset along with the slots, not merely because it is tidy:
|
||||
/// [`Self::ensure`] only refills when the count is short *or* the size
|
||||
/// differs, so a pool cleared while still claiming its old dimensions is
|
||||
/// indistinguishable from one that never held anything — which is fine
|
||||
/// here, and would stop being fine the moment `ensure` grew a fast path
|
||||
/// that trusted the stored size. `allocations` deliberately keeps
|
||||
/// counting: it exists so a test can see textures being made, and a
|
||||
/// counter reset on eviction would hide a reallocation storm rather than
|
||||
/// report one.
|
||||
fn release(&mut self) {
|
||||
self.slots.clear();
|
||||
self.width = 0;
|
||||
self.height = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the detail stage.
|
||||
@@ -149,8 +193,14 @@ pub(crate) struct DetailRunner {
|
||||
/// Compiled pipelines by pass structure hash.
|
||||
cache: HashMap<u64, wgpu::ComputePipeline>,
|
||||
pool: Intermediates,
|
||||
/// The reduced chain — see the module documentation. Its own pool rather
|
||||
/// than more slots in `pool`, because its textures are a different size
|
||||
/// and [`Intermediates::ensure`] drops the lot when the size changes.
|
||||
reduced: Intermediates,
|
||||
/// See [`placeholder_instances`].
|
||||
no_instances: wgpu::Buffer,
|
||||
/// See [`placeholder_reduced`].
|
||||
no_reduced: wgpu::TextureView,
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
@@ -174,6 +224,32 @@ fn placeholder_instances(ctx: &GpuContext) -> wgpu::Buffer {
|
||||
})
|
||||
}
|
||||
|
||||
/// What binding 4 holds for a pass that never calls `reduced_at`.
|
||||
///
|
||||
/// The same trick as [`placeholder_instances`], for the same reason: one bind
|
||||
/// group layout has to serve a pass that reads the reduced chain and a pass
|
||||
/// that has never heard of it, and a binding cannot be left unbound. 1x1 and
|
||||
/// allocated once, so the cost of the arrangement is four bytes for the life
|
||||
/// of the runner.
|
||||
fn placeholder_reduced(ctx: &GpuContext) -> wgpu::TextureView {
|
||||
ctx.device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("detail-reduced-placeholder"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: INTERMEDIATE_FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
impl DetailRunner {
|
||||
pub(crate) fn new(ctx: &GpuContext) -> Self {
|
||||
Self {
|
||||
@@ -182,7 +258,9 @@ impl DetailRunner {
|
||||
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
|
||||
cache: HashMap::new(),
|
||||
pool: Intermediates::new(),
|
||||
reduced: Intermediates::new(),
|
||||
no_instances: placeholder_instances(ctx),
|
||||
no_reduced: placeholder_reduced(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,18 +299,93 @@ impl DetailRunner {
|
||||
self.compile(pass)?;
|
||||
}
|
||||
|
||||
for (index, pass) in chain.passes.iter().enumerate() {
|
||||
// Read what the previous pass wrote; write the next slot, or the
|
||||
// display texture if this is the last one. `index % 2` alternates
|
||||
// between slots 1 and 2, so a pass never reads the texture it is
|
||||
// writing — which on a compute pass is not an error the driver
|
||||
// reports, merely a picture that depends on scheduling.
|
||||
let source_slot = if index == 0 { 0 } else { 2 - (index % 2) };
|
||||
let source = &self.pool.slots[source_slot].view;
|
||||
// The reduced chain's size, allocated once for the whole chain.
|
||||
//
|
||||
// One scale per chain, so the first scaled pass names the only scale
|
||||
// there is — see the module documentation for why one buffer is
|
||||
// enough, and the assertion for what would have to change.
|
||||
if let Some(scale) = chain
|
||||
.passes
|
||||
.iter()
|
||||
.map(|p| p.output_scale)
|
||||
.find(|&scale| scale > 1)
|
||||
{
|
||||
debug_assert!(
|
||||
chain
|
||||
.passes
|
||||
.iter()
|
||||
.all(|p| p.output_scale == 1 || p.output_scale == scale),
|
||||
"two reduced scales in one chain, and the runner holds one \
|
||||
reduced buffer"
|
||||
);
|
||||
// Two, for the ping-pong the reduce and the two blur halves need.
|
||||
// A separable blur cannot read the texture it is writing.
|
||||
self.reduced.ensure(
|
||||
&self.ctx,
|
||||
2,
|
||||
width.div_ceil(scale).max(1),
|
||||
height.div_ceil(scale).max(1),
|
||||
);
|
||||
}
|
||||
|
||||
// Where each chain last wrote. `full` starts at slot 0 — what the
|
||||
// fused colour pass left there — and `carried` starts empty, which is
|
||||
// what makes the first scaled pass read the colour rather than an
|
||||
// uninitialised base.
|
||||
let mut full = 0usize;
|
||||
let mut full_writes = 0usize;
|
||||
let mut carried: Option<usize> = None;
|
||||
let mut reduced_writes = 0usize;
|
||||
|
||||
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 {
|
||||
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",
|
||||
pass.label, pass.output_scale
|
||||
)));
|
||||
}
|
||||
|
||||
let (dispatch_w, dispatch_h) = if scaled {
|
||||
(
|
||||
width.div_ceil(pass.output_scale).max(1),
|
||||
height.div_ceil(pass.output_scale).max(1),
|
||||
)
|
||||
} else {
|
||||
(width, height)
|
||||
};
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
&self.reduced.slots[reduced_writes % 2].view
|
||||
} else {
|
||||
&self.pool.slots[1 + (index % 2)].view
|
||||
&self.pool.slots[1 + (full_writes % 2)].view
|
||||
};
|
||||
// Binding 4. Present for every pass, because one bind group layout
|
||||
// serves both kinds; a pass that never calls `reduced_at` gets the
|
||||
// 1x1 placeholder and never reads it.
|
||||
let reduced_source = match carried {
|
||||
Some(slot) => &self.reduced.slots[slot].view,
|
||||
None => &self.no_reduced,
|
||||
};
|
||||
let layout = if pass.writes_output {
|
||||
&self.to_output
|
||||
@@ -290,6 +443,10 @@ impl DetailRunner {
|
||||
binding: 3,
|
||||
resource: instances.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::TextureView(reduced_source),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -304,7 +461,23 @@ impl DetailRunner {
|
||||
});
|
||||
compute.set_pipeline(pipeline);
|
||||
compute.set_bind_group(0, &bind_group, &[]);
|
||||
compute.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
|
||||
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 {
|
||||
carried = Some(reduced_writes % 2);
|
||||
reduced_writes += 1;
|
||||
} else {
|
||||
full = 1 + (full_writes % 2);
|
||||
full_writes += 1;
|
||||
// A full-resolution pass consumes the reduced chain. It is the
|
||||
// combine — the base has been subtracted and now lives in the
|
||||
// colour — so a later operation must not be handed a base
|
||||
// belonging to this one.
|
||||
carried = None;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(chain.passes.len())
|
||||
@@ -371,11 +544,27 @@ impl DetailRunner {
|
||||
self.cache.len()
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLAT-AND-5
|
||||
/// Give back everything this stage is only holding to be fast.
|
||||
///
|
||||
/// Both pools and the pipeline cache. Nothing here is state: a pool slot
|
||||
/// is re-created by the next [`Intermediates::ensure`] and a pipeline by
|
||||
/// the next compile-on-miss, so the only cost of this call is the work of
|
||||
/// doing both again.
|
||||
pub(crate) fn release_caches(&mut self) {
|
||||
self.cache.clear();
|
||||
self.pool.release();
|
||||
self.reduced.release();
|
||||
}
|
||||
|
||||
/// How many intermediate textures have been allocated since this pass was
|
||||
/// created. For tests — see [`crate::MaskPass::allocations`] for the
|
||||
/// regression this shape of counter exists to catch.
|
||||
pub(crate) fn allocations(&self) -> usize {
|
||||
self.pool.allocations
|
||||
// Both pools. A reduced buffer reallocated every frame is exactly the
|
||||
// regression this counter exists to catch, and counting only the
|
||||
// full-resolution one would hide it.
|
||||
self.pool.allocations + self.reduced.allocations
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +628,21 @@ impl Layout {
|
||||
// that never reads the buffer costs nothing for it being
|
||||
// bound.
|
||||
storage_entry(3),
|
||||
// The reduced chain, for a pass that calls `reduced_at`.
|
||||
// Bound on every layout for the same reason binding 3 is:
|
||||
// a pass that never reads it costs nothing for it being
|
||||
// there, and two more layouts would cost a great deal more
|
||||
// than that.
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+249
-33
@@ -1,8 +1,12 @@
|
||||
//! TRACES: NFR-PORT-2
|
||||
//! GPU device and compute for DarkRoom.
|
||||
//!
|
||||
//! In v0.1 this exists to prove one thing: a compute shader can write a
|
||||
//! texture that reaches the screen without a CPU round-trip (ARCH §6.1). It
|
||||
//! holds no pipeline, no tiling, and no masks — those arrive in v0.2.
|
||||
//! It began as a spike proving one thing — that a compute shader can write a
|
||||
//! texture reaching the screen without a CPU round-trip (ARCH §6.1) — and the
|
||||
//! module doc said for eight releases that it held no pipeline and no masks.
|
||||
//! It holds both now, plus demosaic, detail, segmentation masks, two
|
||||
//! histograms and focus peaking. The zero-copy claim is still the one that
|
||||
//! matters, and TD-1 records the one platform where it does not hold.
|
||||
//!
|
||||
//! Deliberately free of UI dependencies (ARCH §6.5a). The texture is handed
|
||||
//! out as a `wgpu::Texture`; who composites it is not this crate's concern.
|
||||
@@ -21,8 +25,10 @@ mod adjust;
|
||||
mod demosaic;
|
||||
mod detail;
|
||||
mod error;
|
||||
mod focus;
|
||||
mod histogram;
|
||||
mod mask;
|
||||
mod raw_histogram;
|
||||
mod readback;
|
||||
mod segment;
|
||||
pub use adjust::AdjustPass;
|
||||
@@ -33,10 +39,20 @@ pub use adjust::AdjustPass;
|
||||
pub use demosaic::{DemosaicedImage, Demosaicer};
|
||||
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
|
||||
pub use error::GpuError;
|
||||
pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity};
|
||||
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
|
||||
// at all at a crate root shared with demosaic and segmentation.
|
||||
pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS};
|
||||
pub use mask::{LabelField, MaskArray, MaskPass, SubjectMasks};
|
||||
// Renamed on the way out on the same terms as the display histogram's
|
||||
// constants above, and kept distinct from them because the two axes are
|
||||
// different quantities: one counts output code values, the other counts stops
|
||||
// below sensor saturation. A caller that confused them would draw a correct
|
||||
// plot against the wrong scale.
|
||||
pub use raw_histogram::{
|
||||
RawHistogram, RawHistogramPass, BINS as RAW_HISTOGRAM_BINS,
|
||||
BINS_PER_STOP as RAW_HISTOGRAM_BINS_PER_STOP, STOPS as RAW_HISTOGRAM_STOPS,
|
||||
};
|
||||
pub use segment::{SegmentOptions, SegmentPass, Segmentation};
|
||||
|
||||
/// Owns the wgpu device and queue.
|
||||
@@ -74,6 +90,68 @@ pub struct SharedGpu {
|
||||
pub adapter: wgpu::Adapter,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-1 | NFR-RES-4
|
||||
/// Which GPU to prefer, on a machine with more than one.
|
||||
///
|
||||
/// **Not obviously the fastest one**, which is why this is a choice rather
|
||||
/// than a constant. A discrete card wins on raw compute and loses on every
|
||||
/// byte that has to reach it: a 24 MP frame is ~96 MB of RGBA, and each
|
||||
/// upload and each export readback crosses PCIe. An integrated GPU shares
|
||||
/// memory with the CPU, so those transfers are not transfers. It also does not
|
||||
/// empty a laptop battery.
|
||||
///
|
||||
/// Which of those dominates depends on the work — a slider drag over a
|
||||
/// resident texture is compute-bound and favours the discrete card, while
|
||||
/// import, export and thumbnailing are transfer-heavy — so the honest thing is
|
||||
/// to let it be set rather than to assume.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AdapterPreference {
|
||||
/// The most capable GPU. What this has always done, and the default: it is
|
||||
/// the right answer for interactive editing, which is the frame budget
|
||||
/// that FR-DSP-3 actually measures.
|
||||
#[default]
|
||||
Performance,
|
||||
/// An integrated GPU where there is one — shared memory, no bus crossing,
|
||||
/// and far less power.
|
||||
Efficiency,
|
||||
}
|
||||
|
||||
impl AdapterPreference {
|
||||
/// Read the override, defaulting to [`Performance`](Self::Performance).
|
||||
///
|
||||
/// An environment variable rather than a setting, *for now*: this belongs
|
||||
/// on the settings page beside the cache budget, and putting it there
|
||||
/// needs a control and a restart prompt, because the device is opened once
|
||||
/// at startup and shared with the compositor. The variable is what makes
|
||||
/// the choice testable and gives someone with a broken primary GPU a way
|
||||
/// out today.
|
||||
pub fn from_env() -> Self {
|
||||
match std::env::var("DARKROOM_GPU").as_deref() {
|
||||
Ok("integrated") | Ok("efficiency") | Ok("igpu") => Self::Efficiency,
|
||||
_ => Self::Performance,
|
||||
}
|
||||
}
|
||||
|
||||
/// How much we want an adapter, lowest first.
|
||||
///
|
||||
/// A CPU adapter sorts last under both policies rather than being
|
||||
/// excluded: software rendering is a poor experience and a working one,
|
||||
/// and on a machine where every real GPU has failed it is the difference
|
||||
/// between a slow editor and no editor.
|
||||
fn rank(self, device_type: wgpu::DeviceType) -> u8 {
|
||||
use wgpu::DeviceType as D;
|
||||
match (self, device_type) {
|
||||
(_, D::Cpu) => 4,
|
||||
(Self::Performance, D::DiscreteGpu) => 0,
|
||||
(Self::Performance, D::IntegratedGpu) => 1,
|
||||
(Self::Efficiency, D::IntegratedGpu) => 0,
|
||||
(Self::Efficiency, D::DiscreteGpu) => 1,
|
||||
(_, D::VirtualGpu) => 2,
|
||||
(_, D::Other) => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuContext {
|
||||
/// Create a headless context — no surface, no window.
|
||||
///
|
||||
@@ -111,6 +189,38 @@ impl GpuContext {
|
||||
Self::open(wgpu::Backends::VULKAN).await
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-1 | NFR-R1
|
||||
/// Open a device, trying every adapter rather than only the best one.
|
||||
///
|
||||
/// # Why this is not `request_adapter`
|
||||
///
|
||||
/// `request_adapter` with `HighPerformance` returns *one* adapter and no
|
||||
/// second chance. That is the right answer on a healthy machine and the
|
||||
/// wrong one on a machine with a sick GPU, which is not a rare state:
|
||||
/// observed 2026-08-29 on a laptop whose discrete card had hit an NVRM
|
||||
/// assertion failure and a fullchip reset. The driver still advertised the
|
||||
/// adapter, `request_adapter` dutifully picked it as the highest
|
||||
/// performing, and the process died on it — while a working integrated GPU
|
||||
/// and a working external card sat unused in the same enumeration.
|
||||
///
|
||||
/// A photo editor that will not start because the *fastest* GPU is broken,
|
||||
/// on a machine holding two that are not, is worse than a slow one.
|
||||
///
|
||||
/// So: enumerate, order by how much we want each, and take the first that
|
||||
/// actually yields a device. The ordering reproduces what
|
||||
/// `HighPerformance` means — discrete, then integrated, then anything —
|
||||
/// so the healthy case picks exactly what it picked before and pays one
|
||||
/// extra enumeration for it.
|
||||
///
|
||||
/// # What this cannot do
|
||||
///
|
||||
/// A GPU sick enough to accept `request_device` and fail later is still
|
||||
/// fatal, because the failure arrives as a segfault inside the driver
|
||||
/// rather than as an error we could catch. This moves the boundary from
|
||||
/// "the preferred adapter is unusable" to "the preferred adapter is
|
||||
/// unusable *and* dishonest about it"; it does not remove it. Device loss
|
||||
/// after a successful open is a different problem with a different answer
|
||||
/// (ARCH §5.6).
|
||||
async fn open(backends: wgpu::Backends) -> Result<SharedGpu, GpuError> {
|
||||
// `new_without_display_handle` rather than a struct literal: the
|
||||
// descriptor carries a boxed display handle and so has no `Default`,
|
||||
@@ -119,27 +229,61 @@ impl GpuContext {
|
||||
descriptor.backends = backends;
|
||||
let instance = wgpu::Instance::new(descriptor);
|
||||
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: None,
|
||||
force_fallback_adapter: false,
|
||||
})
|
||||
.await
|
||||
// A `Result` since wgpu 24, where it was an `Option`. The error
|
||||
// says which backends were tried, which is worth more than the
|
||||
// bare "no adapter" this used to report.
|
||||
.map_err(|_| GpuError::NoAdapter)?;
|
||||
let mut adapters: Vec<wgpu::Adapter> = instance.enumerate_adapters(backends).await;
|
||||
if adapters.is_empty() {
|
||||
return Err(GpuError::NoAdapter);
|
||||
}
|
||||
let policy = AdapterPreference::from_env();
|
||||
adapters.sort_by_key(|a| policy.rank(a.get_info().device_type));
|
||||
|
||||
let adapter_info = adapter.get_info();
|
||||
log::info!(
|
||||
"gpu: {} ({:?}, {:?})",
|
||||
adapter_info.name,
|
||||
adapter_info.device_type,
|
||||
adapter_info.backend
|
||||
);
|
||||
// Kept so a total failure can say what it tried. "No suitable GPU
|
||||
// adapter found" on a machine with three of them sends the reader to
|
||||
// look for a driver that is installed and loaded.
|
||||
let mut refusals: Vec<String> = Vec::new();
|
||||
|
||||
let (device, queue) = adapter
|
||||
for adapter in adapters {
|
||||
let adapter_info = adapter.get_info();
|
||||
match Self::device_from(&adapter).await {
|
||||
Ok((device, queue)) => {
|
||||
log::info!(
|
||||
"gpu: {} ({:?}, {:?})",
|
||||
adapter_info.name,
|
||||
adapter_info.device_type,
|
||||
adapter_info.backend
|
||||
);
|
||||
if !refusals.is_empty() {
|
||||
// At `info`, not `debug`: the user is now running on
|
||||
// their second-choice GPU and any performance
|
||||
// complaint that follows begins here.
|
||||
log::info!(
|
||||
"gpu: fell back after {} unusable adapter(s): {}",
|
||||
refusals.len(),
|
||||
refusals.join("; ")
|
||||
);
|
||||
}
|
||||
return Ok(SharedGpu {
|
||||
ctx: Self {
|
||||
device: Arc::new(device),
|
||||
queue: Arc::new(queue),
|
||||
adapter_info,
|
||||
},
|
||||
instance,
|
||||
adapter,
|
||||
});
|
||||
}
|
||||
Err(e) => refusals.push(format!("{} ({e})", adapter_info.name)),
|
||||
}
|
||||
}
|
||||
|
||||
Err(GpuError::DeviceRequest(format!(
|
||||
"every adapter refused a device: {}",
|
||||
refusals.join("; ")
|
||||
)))
|
||||
}
|
||||
|
||||
/// Ask one adapter for a device, with the limits the pipeline needs.
|
||||
async fn device_from(adapter: &wgpu::Adapter) -> Result<(wgpu::Device, wgpu::Queue), GpuError> {
|
||||
adapter
|
||||
.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("darkroom-device"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
@@ -164,17 +308,7 @@ impl GpuContext {
|
||||
trace: wgpu::Trace::Off,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| GpuError::DeviceRequest(e.to_string()))?;
|
||||
|
||||
Ok(SharedGpu {
|
||||
ctx: Self {
|
||||
device: Arc::new(device),
|
||||
queue: Arc::new(queue),
|
||||
adapter_info,
|
||||
},
|
||||
instance,
|
||||
adapter,
|
||||
})
|
||||
.map_err(|e| GpuError::DeviceRequest(e.to_string()))
|
||||
}
|
||||
|
||||
/// Build a context from a device and queue owned by someone else — the
|
||||
@@ -597,3 +731,85 @@ mod tests {
|
||||
assert_eq!(rt.size(), (1, 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod adapter_choice_tests {
|
||||
//! Which GPU gets picked, and what happens when it will not open.
|
||||
//!
|
||||
//! These are about the *ordering*, which is pure — opening a device needs
|
||||
//! hardware and is covered by every other test in this crate implicitly.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Only the two fields the ordering reads are set; the rest come from
|
||||
/// `Default`, so a wgpu upgrade that adds another does not break this.
|
||||
/// The order adapters would be tried in, named so a failure reads as the
|
||||
/// hardware it stands for.
|
||||
fn order(policy: AdapterPreference, mut gpus: Vec<(&str, wgpu::DeviceType)>) -> Vec<&str> {
|
||||
gpus.sort_by_key(|(_, t)| policy.rank(*t));
|
||||
gpus.into_iter().map(|(name, _)| name).collect()
|
||||
}
|
||||
|
||||
fn a_laptop() -> Vec<(&'static str, wgpu::DeviceType)> {
|
||||
vec![
|
||||
("Iris Xe", wgpu::DeviceType::IntegratedGpu),
|
||||
("RTX 3050", wgpu::DeviceType::DiscreteGpu),
|
||||
("llvmpipe", wgpu::DeviceType::Cpu),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn performance_takes_the_discrete_card() {
|
||||
// What this has always done, and what an interactive slider drag wants:
|
||||
// the texture is already resident, so the work is compute and the bus
|
||||
// does not come into it.
|
||||
assert_eq!(
|
||||
order(AdapterPreference::Performance, a_laptop()),
|
||||
["RTX 3050", "Iris Xe", "llvmpipe"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn efficiency_takes_the_integrated_one() {
|
||||
// Shared memory, so a 96 MB frame upload is not a transfer, and a
|
||||
// laptop battery that lasts. The discrete card stays as the fallback
|
||||
// rather than being excluded.
|
||||
assert_eq!(
|
||||
order(AdapterPreference::Efficiency, a_laptop()),
|
||||
["Iris Xe", "RTX 3050", "llvmpipe"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn software_rendering_is_last_but_never_dropped() {
|
||||
// On a machine where every real GPU has failed this is the difference
|
||||
// between a slow editor and no editor.
|
||||
for policy in [
|
||||
AdapterPreference::Performance,
|
||||
AdapterPreference::Efficiency,
|
||||
] {
|
||||
assert_eq!(
|
||||
*order(policy, a_laptop()).last().unwrap(),
|
||||
"llvmpipe",
|
||||
"{policy:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_machine_with_one_gpu_is_unaffected_by_the_policy() {
|
||||
// The common case: no choice to make, and no behaviour to change.
|
||||
let one = vec![("Iris Xe", wgpu::DeviceType::IntegratedGpu)];
|
||||
assert_eq!(
|
||||
order(AdapterPreference::Performance, one.clone()),
|
||||
order(AdapterPreference::Efficiency, one)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_is_what_it_did_before() {
|
||||
// Changing which GPU an existing user lands on is not something to do
|
||||
// by accident.
|
||||
assert_eq!(AdapterPreference::default(), AdapterPreference::Performance);
|
||||
}
|
||||
}
|
||||
|
||||
+607
-91
@@ -21,6 +21,17 @@
|
||||
//! boxes, one draw each, compositing onto the slice with blend state — see the
|
||||
//! second half of `mask.wgsl`.
|
||||
//!
|
||||
//! # The photograph, bound as an input
|
||||
//!
|
||||
//! A range mask (FR-DEV-10) selects by what a pixel *is*, so this pass reads
|
||||
//! the demosaiced source as well as writing masks. It is bound for every draw
|
||||
//! and looked at by two modes; everything else gets a 1x1 placeholder, for the
|
||||
//! reason the label field below does — the bindings are fixed, and a second
|
||||
//! pipeline differing only in what it ignores costs more than a texel.
|
||||
//!
|
||||
//! Nothing is read back and nothing is rasterised on this side. What crosses
|
||||
//! into CPU memory for a range layer is five floats and a matrix.
|
||||
//!
|
||||
//! # The label field
|
||||
//!
|
||||
//! Region masks index a compacted label field uploaded once per segmentation.
|
||||
@@ -30,10 +41,11 @@
|
||||
//! `region_count`. The compaction is CPU-side and once per image, which is the
|
||||
//! same place and cadence the region adjacency graph is already built at.
|
||||
|
||||
use dr_pipeline::mask::{MaskSource, MaskStack, Stroke, MAX_LAYERS};
|
||||
use dr_pipeline::mask::{Join, MaskSource, MaskStack, Stroke, MAX_LAYERS};
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::{GpuContext, GpuError};
|
||||
use crate::{DemosaicedImage, GpuContext, GpuError};
|
||||
|
||||
/// Modes understood by `mask.wgsl`. Kept beside the shader's `switch`.
|
||||
const MODE_REGIONS: u32 = 0;
|
||||
@@ -43,6 +55,10 @@ const MODE_SUBJECT: u32 = 3;
|
||||
/// Brush layers go through their own entry points rather than the `switch`, so
|
||||
/// this is only ever read by a person looking at a captured frame.
|
||||
const MODE_BRUSH: u32 = 4;
|
||||
/// TRACES: FR-DEV-10
|
||||
const MODE_LUMINANCE: u32 = 5;
|
||||
/// TRACES: FR-DEV-10
|
||||
const MODE_COLOUR: u32 = 6;
|
||||
|
||||
/// Six vertices — two triangles — per stroke. See `vs_brush`.
|
||||
const VERTICES_PER_STROKE: u32 = 6;
|
||||
@@ -66,7 +82,25 @@ struct MaskParams {
|
||||
axis: [f32; 2],
|
||||
softness: f32,
|
||||
angle: f32,
|
||||
_pad1: [f32; 2],
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Source texels per mask texel, per axis. See `image_value` in the
|
||||
/// shader for why a range averages its footprint rather than sampling it.
|
||||
source_step: [f32; 2],
|
||||
|
||||
/// Camera RGB → linear sRGB, one row per `vec4` because that is the
|
||||
/// alignment a uniform gives a three-component vector anyway. Only a
|
||||
/// range mask reads them.
|
||||
cam_to_srgb: [[f32; 4]; 3],
|
||||
/// `rgb`: as-shot white balance. `w`: non-zero for a gamma-encoded source.
|
||||
/// The same packing the generated adjust shader uses, so the two agree by
|
||||
/// construction rather than by inspection.
|
||||
as_shot_wb: [f32; 4],
|
||||
/// Whether this part is turned over before it joins the mask. Read by the
|
||||
/// combine pass and by nothing else — see `fs_combine`.
|
||||
invert: u32,
|
||||
/// A uniform buffer is a multiple of sixteen bytes, and the flag above
|
||||
/// takes four of them.
|
||||
_pad: [u32; 3],
|
||||
}
|
||||
|
||||
/// One stroke, as `mask.wgsl`'s `StrokeHeader` expects it.
|
||||
@@ -355,6 +389,19 @@ pub struct MaskPass {
|
||||
/// `dst(1 - a)` to erase.
|
||||
brush_add: wgpu::RenderPipeline,
|
||||
brush_erase: wgpu::RenderPipeline,
|
||||
/// Reads a part back out of [`Self::scratch`] and blends it into the
|
||||
/// layer's slice. The set operation is the blend state, so these two are
|
||||
/// one shader as well.
|
||||
combine_layout: wgpu::BindGroupLayout,
|
||||
combine_union: wgpu::RenderPipeline,
|
||||
combine_subtract: wgpu::RenderPipeline,
|
||||
/// Where a part is drawn before it is joined.
|
||||
///
|
||||
/// One texture for the whole stack rather than one per layer, because
|
||||
/// layers rasterise in sequence and a part is read back immediately after
|
||||
/// it is drawn. Allocated the first time a layer has more than one part,
|
||||
/// so a library of unedited masks never pays for it.
|
||||
scratch: Option<Scratch>,
|
||||
array: Option<MaskArray>,
|
||||
/// How many times the array texture has been (re)allocated.
|
||||
///
|
||||
@@ -371,6 +418,14 @@ pub struct MaskPass {
|
||||
/// label slots even when rasterising a gradient. A placeholder is cheaper
|
||||
/// and far simpler than two pipelines differing only in what they ignore.
|
||||
placeholder: LabelField,
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Bound at the image slot for every mask that is not a range.
|
||||
///
|
||||
/// Never sampled by those modes, so its contents do not matter — but it is
|
||||
/// cleared rather than left undefined, because a placeholder whose value
|
||||
/// is arbitrary is one that makes a binding mistake look like a mask that
|
||||
/// nearly works.
|
||||
empty_image: wgpu::TextureView,
|
||||
}
|
||||
|
||||
impl MaskPass {
|
||||
@@ -405,6 +460,20 @@ impl MaskPass {
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
// TRACES: FR-DEV-10
|
||||
// The photograph, for a range mask. Unfilterable for the
|
||||
// same reason the field above is: every read is a
|
||||
// `textureLoad`, and this pipeline binds no sampler.
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 6,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -501,6 +570,88 @@ impl MaskPass {
|
||||
"mask-brush-add",
|
||||
blend_state(wgpu::BlendFactor::One, wgpu::BlendFactor::OneMinusSrc),
|
||||
);
|
||||
// The pipelines that join one part to the mask so far. The blend
|
||||
// state is the set operation and the shader is the same three
|
||||
// vertices either way — which is why adding a way to combine masks
|
||||
// cost no shader arithmetic at all.
|
||||
let combine_layout =
|
||||
ctx.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("mask-combine-bgl"),
|
||||
entries: &[
|
||||
uniform_entry(0),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 7,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
// Loaded texel by texel at matching size, so
|
||||
// there is nothing to filter and no sampler.
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let combine_pipeline_layout =
|
||||
ctx.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("mask-combine-layout"),
|
||||
bind_group_layouts: &[Some(&combine_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
let combine = |label, blend| {
|
||||
ctx.device
|
||||
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&combine_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_combine"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: MaskArray::FORMAT,
|
||||
blend: Some(blend),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
|
||||
// `max`, not source-over: a union must not build up where two parts
|
||||
// overlap. Two selections that both half-cover a pixel select it half
|
||||
// — adding them would make the overlap of two soft edges harder than
|
||||
// either, which is a seam exactly where a photographer joined two
|
||||
// things to avoid one.
|
||||
let combine_union = combine(
|
||||
"mask-combine-union",
|
||||
wgpu::BlendState {
|
||||
color: MAX_BLEND,
|
||||
alpha: MAX_BLEND,
|
||||
},
|
||||
);
|
||||
// `dst * (1 - src)`, which is the erase blend one level up: what the
|
||||
// mask had, minus what this part covers, in proportion to how much of
|
||||
// it the part covers.
|
||||
let combine_subtract = combine(
|
||||
"mask-combine-subtract",
|
||||
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
|
||||
);
|
||||
|
||||
// The same, with the deposit thrown away: coverage is only ever taken
|
||||
// off what earlier strokes on this layer put down. There is no negative
|
||||
// coverage to accumulate, so erasing an unpainted layer is a no-op
|
||||
@@ -515,6 +666,7 @@ impl MaskPass {
|
||||
}
|
||||
|
||||
let placeholder = LabelField::upload(ctx, &[0], 1, 1, 0)?;
|
||||
let empty_image = empty_image(ctx);
|
||||
// Everywhere outside, so a layer that somehow reaches this masks
|
||||
// nothing rather than everything.
|
||||
let empty_subject = SubjectMasks::upload(ctx, &[&[-1.0f32][..]], 1, 1)?;
|
||||
@@ -526,10 +678,15 @@ impl MaskPass {
|
||||
brush_layout,
|
||||
brush_add,
|
||||
brush_erase,
|
||||
combine_layout,
|
||||
combine_union,
|
||||
combine_subtract,
|
||||
scratch: None,
|
||||
array: None,
|
||||
allocations: 0,
|
||||
placeholder,
|
||||
empty_subject,
|
||||
empty_image,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -538,17 +695,49 @@ impl MaskPass {
|
||||
/// `labels` may be `None` when no layer is a region mask; a region layer
|
||||
/// without one is skipped rather than drawn wrong, since a mask that
|
||||
/// silently covers the whole frame would apply an edit everywhere.
|
||||
///
|
||||
/// `source` is the photograph a range layer measures (FR-DEV-10), and it
|
||||
/// is skipped on the same rule for the same reason: without it the shader
|
||||
/// would read a blank placeholder, and a band that happens to contain
|
||||
/// black would then cover the whole frame.
|
||||
pub fn render(
|
||||
&mut self,
|
||||
stack: &MaskStack,
|
||||
labels: Option<&LabelField>,
|
||||
subjects: Option<&SubjectMasks>,
|
||||
source: Option<&DemosaicedImage>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<&MaskArray, GpuError> {
|
||||
self.render_revealing(stack, labels, subjects, source, width, height, None)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// [`Self::render`], also drawing the layer being looked at.
|
||||
///
|
||||
/// A selection with no adjustment on it changes no pixel, so it is not
|
||||
/// active and has no slice — which is right until somebody asks to *see*
|
||||
/// it, and that is the state a photographer is in from choosing a subject
|
||||
/// until deciding what to do to it.
|
||||
///
|
||||
/// `reveal` has to be the same one the shader was composed with and the
|
||||
/// same one the distance fields were built for: all three index this array
|
||||
/// by position in [`MaskStack::rendered`], and two of them disagreeing
|
||||
/// shows as an adjustment applied through another layer's mask.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_revealing(
|
||||
&mut self,
|
||||
stack: &MaskStack,
|
||||
labels: Option<&LabelField>,
|
||||
subjects: Option<&SubjectMasks>,
|
||||
source: Option<&DemosaicedImage>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
reveal: Option<&dr_pipeline::mask::Reveal>,
|
||||
) -> Result<&MaskArray, GpuError> {
|
||||
// At least one layer, because a zero-layer texture array is invalid
|
||||
// and the shader binds this slot unconditionally.
|
||||
let active = stack.active_count().clamp(1, MAX_LAYERS) as u32;
|
||||
let active = stack.rendered_count(reveal).clamp(1, MAX_LAYERS) as u32;
|
||||
self.ensure_array(width, height, active)?;
|
||||
|
||||
let mut encoder = self
|
||||
@@ -558,52 +747,131 @@ impl MaskPass {
|
||||
label: Some("mask-encoder"),
|
||||
});
|
||||
|
||||
for (slot, layer) in stack.active().enumerate().take(MAX_LAYERS) {
|
||||
let field = match (&layer.source, labels) {
|
||||
(MaskSource::Regions { .. }, None) => {
|
||||
log::warn!(
|
||||
"mask layer {} is a region mask with no segmentation loaded; skipping",
|
||||
layer.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
(MaskSource::Regions { .. }, Some(f)) => f,
|
||||
(_, _) => &self.placeholder,
|
||||
};
|
||||
for (slot, layer) in stack.rendered(reveal).enumerate().take(MAX_LAYERS) {
|
||||
// **The path a mask with one part takes is the path every mask
|
||||
// took before parts existed**: drawn straight into the layer's
|
||||
// slice, cleared by the draw itself. Nothing about an unedited
|
||||
// library's rendering changes, and the scratch texture is never
|
||||
// allocated for it.
|
||||
//
|
||||
// An inverted base is the exception, because turning a part over
|
||||
// is done where it is read back rather than where it is drawn —
|
||||
// a brush deposits dabs and cannot know what the rest of the
|
||||
// frame is. See `fs_combine`.
|
||||
let direct = layer.parts().len() == 1 && !layer.base().invert;
|
||||
if !direct {
|
||||
self.ensure_scratch(width, height)?;
|
||||
}
|
||||
|
||||
// A subject layer whose instance is missing is skipped for the
|
||||
// same reason a region layer without a segmentation is: an absent
|
||||
// mask that defaults to "everything" would apply the adjustment to
|
||||
// the whole photograph, which is a much louder failure than none.
|
||||
// Indexed by *slot*, not by the instance the layer names: the
|
||||
// fields are built per layer, in this same order, because two
|
||||
// layers over one subject can carry different morphology.
|
||||
let subject = match &layer.source {
|
||||
MaskSource::Subject { .. } => match subjects.filter(|s| slot < s.len()) {
|
||||
Some(s) => (s, slot),
|
||||
None => {
|
||||
log::warn!("mask layer {} has no distance field; skipping", layer.id);
|
||||
for (index, part) in layer.parts().iter().enumerate() {
|
||||
let base = index == 0;
|
||||
let field = match (&part.source, labels) {
|
||||
(MaskSource::Regions { .. }, None) => {
|
||||
log::warn!(
|
||||
"mask layer {} is a region mask with no segmentation loaded; skipping",
|
||||
layer.id
|
||||
);
|
||||
if base {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
},
|
||||
_ => (&self.empty_subject, 0),
|
||||
};
|
||||
(MaskSource::Regions { .. }, Some(f)) => f,
|
||||
(_, _) => &self.placeholder,
|
||||
};
|
||||
|
||||
let params = self.params(layer, field, width, height);
|
||||
match &layer.source {
|
||||
MaskSource::Brush { strokes } => {
|
||||
self.draw_brush(&mut encoder, slot as u32, ¶ms, strokes, width, height)
|
||||
// A subject part whose instance is missing is skipped for the
|
||||
// same reason a region part without a segmentation is: an
|
||||
// absent mask that defaults to "everything" would apply the
|
||||
// adjustment to the whole photograph, which is a much louder
|
||||
// failure than none.
|
||||
//
|
||||
// Indexed by *slot*, not by the instance the part names: the
|
||||
// fields are built per layer, in this same order, because two
|
||||
// layers over one subject can carry different morphology.
|
||||
// Which is also why only a base part can have one — a model
|
||||
// part joined to a mask has no field built for it yet, and it
|
||||
// is skipped rather than drawn against a placeholder that
|
||||
// would cover the frame.
|
||||
let subject = match &part.source {
|
||||
// Category alongside Subject: both are model coverage
|
||||
// turned into a distance field, both are built per layer
|
||||
// in this same order, and leaving a category out of here
|
||||
// is precisely the failure the comment above warns about —
|
||||
// it binds the 1x1 placeholder, so the mask covers
|
||||
// everything and the adjustment silently goes global.
|
||||
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
|
||||
match subjects.filter(|s| base && slot < s.len()) {
|
||||
Some(s) => (s, slot),
|
||||
None => {
|
||||
log::warn!(
|
||||
"part {} of mask layer {} has no distance field; skipping",
|
||||
part.id,
|
||||
layer.id
|
||||
);
|
||||
if base {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (&self.empty_subject, 0),
|
||||
};
|
||||
|
||||
// TRACES: FR-DEV-10
|
||||
// A range part with no photograph bound is skipped rather than
|
||||
// drawn against the placeholder, on exactly the rule the two
|
||||
// cases above follow: an absent mask that defaults to
|
||||
// "everything" takes a local adjustment global, which is a far
|
||||
// quieter failure than a part that visibly did not render.
|
||||
let image = match (&part.source, source) {
|
||||
(s, None) if s.is_range() => {
|
||||
log::warn!(
|
||||
"mask layer {} selects a range with no image loaded; skipping",
|
||||
layer.id
|
||||
);
|
||||
if base {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
(_, image) => image,
|
||||
};
|
||||
|
||||
let params = self.params(part, field, image, width, height);
|
||||
let target = if direct {
|
||||
self.slice_view(slot as u32)
|
||||
} else {
|
||||
self.scratch_view()
|
||||
};
|
||||
|
||||
match &part.source {
|
||||
MaskSource::Brush { strokes } => {
|
||||
self.draw_brush(&mut encoder, &target, ¶ms, strokes, width, height)
|
||||
}
|
||||
_ => {
|
||||
let selected = self.selection_buffer(part, field);
|
||||
self.draw(
|
||||
&mut encoder,
|
||||
&target,
|
||||
¶ms,
|
||||
field,
|
||||
&selected,
|
||||
subject,
|
||||
image,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let selected = self.selection_buffer(layer, field);
|
||||
self.draw(
|
||||
&mut encoder,
|
||||
slot as u32,
|
||||
¶ms,
|
||||
field,
|
||||
&selected,
|
||||
subject,
|
||||
);
|
||||
|
||||
if !direct {
|
||||
// The first part joins a cleared slice, so it lands
|
||||
// exactly as it was drawn whichever way it says it joins —
|
||||
// there is nothing yet for a subtraction to take away
|
||||
// from, and a mask that began by subtracting from nothing
|
||||
// would render as empty however it was painted afterwards.
|
||||
let join = if base { Join::Union } else { part.join };
|
||||
self.combine(&mut encoder, slot as u32, join, base, ¶ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -624,11 +892,36 @@ impl MaskPass {
|
||||
|
||||
fn params(
|
||||
&self,
|
||||
layer: &dr_pipeline::mask::MaskLayer,
|
||||
part: &dr_pipeline::mask::MaskPart,
|
||||
field: &LabelField,
|
||||
source: Option<&DemosaicedImage>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> MaskParams {
|
||||
// TRACES: FR-DEV-10
|
||||
// How much of the photograph one mask texel covers. One when there is
|
||||
// no image bound, which is a value nothing reads — the range modes are
|
||||
// the only readers and they are skipped in that case.
|
||||
let source_step = match source {
|
||||
Some(image) => {
|
||||
let (sw, sh) = image.size();
|
||||
[
|
||||
sw as f32 / width.max(1) as f32,
|
||||
sh as f32 / height.max(1) as f32,
|
||||
]
|
||||
}
|
||||
None => [1.0, 1.0],
|
||||
};
|
||||
// Row-major nine, widened to three `vec4`s. Identity where there is no
|
||||
// image, so a range that somehow reached the shader without one would
|
||||
// read camera values rather than nothing — the same defensive choice
|
||||
// the demosaicer makes for an uncalibrated body.
|
||||
let m = source.map_or([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], |i| {
|
||||
i.color_matrix()
|
||||
});
|
||||
let wb = source.map_or([1.0, 1.0, 1.0], |i| i.as_shot_wb());
|
||||
let non_linear = source.is_some_and(|i| i.is_non_linear());
|
||||
|
||||
let base = MaskParams {
|
||||
width,
|
||||
height,
|
||||
@@ -642,10 +935,18 @@ impl MaskPass {
|
||||
axis: [1.0, 0.0],
|
||||
softness: 0.0,
|
||||
angle: 0.0,
|
||||
_pad1: [0.0, 0.0],
|
||||
source_step,
|
||||
cam_to_srgb: [
|
||||
[m[0], m[1], m[2], 0.0],
|
||||
[m[3], m[4], m[5], 0.0],
|
||||
[m[6], m[7], m[8], 0.0],
|
||||
],
|
||||
as_shot_wb: [wb[0], wb[1], wb[2], if non_linear { 1.0 } else { 0.0 }],
|
||||
invert: u32::from(part.invert),
|
||||
_pad: [0; 3],
|
||||
};
|
||||
|
||||
match &layer.source {
|
||||
match &part.source {
|
||||
// `softness` carries the layer's feather. The model's coverage is
|
||||
// already a soft sigmoid, so zero means "use the edge the model
|
||||
// drew" rather than "hard edge" — the one place in this shader
|
||||
@@ -654,17 +955,23 @@ impl MaskPass {
|
||||
// edge; the field is in proxy pixels. Converting here keeps the
|
||||
// stored edit resolution-independent while the shader works in the
|
||||
// units its texture is actually measured in.
|
||||
MaskSource::Subject { .. } => {
|
||||
// A category shares the subject's mode, and that is not a
|
||||
// shortcut: both arrive as a soft coverage buffer at proxy
|
||||
// resolution and both are turned into a distance field before they
|
||||
// reach here. The shader has no way to tell them apart and no
|
||||
// reason to want one — what differs is only which model produced
|
||||
// the coverage.
|
||||
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
|
||||
let short = field_short_edge(width, height);
|
||||
MaskParams {
|
||||
mode: MODE_SUBJECT,
|
||||
// `softness` is the feather half-width in pixels.
|
||||
softness: (layer.feather * short).max(0.0),
|
||||
softness: (part.feather * short).max(0.0),
|
||||
// `angle` carries the morphology offset — reused rather
|
||||
// than padded, since a subject layer has no ellipse to
|
||||
// rotate.
|
||||
angle: morph_offset(layer) * short,
|
||||
falloff: falloff_code(layer.falloff),
|
||||
angle: morph_offset(part) * short,
|
||||
falloff: falloff_code(part.falloff),
|
||||
..base
|
||||
}
|
||||
}
|
||||
@@ -705,6 +1012,33 @@ impl MaskPass {
|
||||
mode: MODE_BRUSH,
|
||||
..base
|
||||
},
|
||||
// TRACES: FR-DEV-10
|
||||
// A band, carried in the fields the gradients measure geometry
|
||||
// in. Reused rather than given their own, and it is not a
|
||||
// shortcut: `centre` and `axis` are two pairs of floats whose
|
||||
// meaning has always been the mode's to decide, and a range that
|
||||
// added four more would grow the uniform every other mask pays
|
||||
// for. What matters is that nothing here is a *coordinate* — a
|
||||
// range is not a function of position at all.
|
||||
MaskSource::Luminance { lo, hi, softness } => MaskParams {
|
||||
mode: MODE_LUMINANCE,
|
||||
axis: [*lo, *hi],
|
||||
softness: *softness,
|
||||
..base
|
||||
},
|
||||
MaskSource::Colour {
|
||||
hue,
|
||||
hue_width,
|
||||
chroma_lo,
|
||||
chroma_hi,
|
||||
softness,
|
||||
} => MaskParams {
|
||||
mode: MODE_COLOUR,
|
||||
centre: [*hue, *hue_width],
|
||||
axis: [*chroma_lo, *chroma_hi],
|
||||
softness: *softness,
|
||||
..base
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,7 +1057,7 @@ impl MaskPass {
|
||||
fn draw_brush(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
slot: u32,
|
||||
target: &wgpu::TextureView,
|
||||
params: &MaskParams,
|
||||
strokes: &[Stroke],
|
||||
width: u32,
|
||||
@@ -785,19 +1119,10 @@ impl MaskPass {
|
||||
})
|
||||
});
|
||||
|
||||
let array = self.array.as_ref().expect("array ensured by caller");
|
||||
let view = array.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("mask-slice"),
|
||||
dimension: Some(wgpu::TextureViewDimension::D2),
|
||||
base_array_layer: slot,
|
||||
array_layer_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("mask-brush-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
view: target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -843,11 +1168,11 @@ impl MaskPass {
|
||||
/// One byte-flag per region, or a single zero for a non-region layer.
|
||||
fn selection_buffer(
|
||||
&self,
|
||||
layer: &dr_pipeline::mask::MaskLayer,
|
||||
part: &dr_pipeline::mask::MaskPart,
|
||||
field: &LabelField,
|
||||
) -> wgpu::Buffer {
|
||||
let mut flags = vec![0u32; field.region_count.max(1) as usize];
|
||||
if let MaskSource::Regions { ids, .. } = &layer.source {
|
||||
if let MaskSource::Regions { ids, .. } = &part.source {
|
||||
for &id in ids {
|
||||
if let Some(slot) = flags.get_mut(id as usize) {
|
||||
*slot = 1;
|
||||
@@ -868,11 +1193,12 @@ impl MaskPass {
|
||||
fn draw(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
slot: u32,
|
||||
target: &wgpu::TextureView,
|
||||
params: &MaskParams,
|
||||
field: &LabelField,
|
||||
selected: &wgpu::Buffer,
|
||||
subject: (&SubjectMasks, usize),
|
||||
source: Option<&DemosaicedImage>,
|
||||
) {
|
||||
let params_buf = self
|
||||
.ctx
|
||||
@@ -910,25 +1236,23 @@ impl MaskPass {
|
||||
}),
|
||||
),
|
||||
},
|
||||
// TRACES: FR-DEV-10
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(
|
||||
source.map_or(&self.empty_image, |i| i.view()),
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// The array slice is selected by the attachment rather than by a
|
||||
// uniform the shader reads — one fewer value that can disagree with
|
||||
// where the pass actually writes.
|
||||
let array = self.array.as_ref().expect("array ensured by caller");
|
||||
let view = array.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("mask-slice"),
|
||||
dimension: Some(wgpu::TextureViewDimension::D2),
|
||||
base_array_layer: slot,
|
||||
array_layer_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("mask-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
view: target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -949,6 +1273,140 @@ impl MaskPass {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// A view of one layer's slice of the array.
|
||||
fn slice_view(&self, slot: u32) -> wgpu::TextureView {
|
||||
// The array slice is selected by the attachment rather than by a
|
||||
// uniform the shader reads — one fewer value that can disagree with
|
||||
// where the pass actually writes.
|
||||
let array = self.array.as_ref().expect("array ensured by caller");
|
||||
array.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("mask-slice"),
|
||||
dimension: Some(wgpu::TextureViewDimension::D2),
|
||||
base_array_layer: slot,
|
||||
array_layer_count: Some(1),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn scratch_view(&self) -> wgpu::TextureView {
|
||||
self.scratch
|
||||
.as_ref()
|
||||
.expect("scratch ensured by caller")
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("mask-part"),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Blend the part sitting in [`Self::scratch`] into a layer's slice.
|
||||
///
|
||||
/// `first` clears the slice instead of loading it, which is both cheaper
|
||||
/// on a tiler and the only thing that makes the fold start from nothing
|
||||
/// covered rather than from whatever the last rasterisation left.
|
||||
fn combine(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
slot: u32,
|
||||
join: Join,
|
||||
first: bool,
|
||||
params: &MaskParams,
|
||||
) {
|
||||
let params_buf = self
|
||||
.ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("mask-combine-params"),
|
||||
contents: bytemuck::bytes_of(params),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
|
||||
let bind_group = self
|
||||
.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("mask-combine-bind"),
|
||||
layout: &self.combine_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: params_buf.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&self.scratch_view()),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let target = self.slice_view(slot);
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("mask-combine-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: if first {
|
||||
wgpu::LoadOp::Clear(wgpu::Color::BLACK)
|
||||
} else {
|
||||
wgpu::LoadOp::Load
|
||||
},
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
|
||||
pass.set_pipeline(match join {
|
||||
Join::Union => &self.combine_union,
|
||||
Join::Subtract => &self.combine_subtract,
|
||||
});
|
||||
pass.set_bind_group(0, &bind_group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// The texture a part is drawn in before it is joined.
|
||||
///
|
||||
/// Allocated on the first mask that has more than one part and kept at the
|
||||
/// rasterisation size, which is the same size the array is: a part and the
|
||||
/// slice it joins are compared texel for texel, so there is nothing to
|
||||
/// scale and nothing to sample between.
|
||||
fn ensure_scratch(&mut self, width: u32, height: u32) -> Result<(), GpuError> {
|
||||
if self
|
||||
.scratch
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.width == width && s.height == height)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("mask-scratch"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: MaskArray::FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
|
||||
self.scratch = Some(Scratch {
|
||||
texture,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_array(&mut self, width: u32, height: u32, layers: u32) -> Result<(), GpuError> {
|
||||
if self
|
||||
.array
|
||||
@@ -991,6 +1449,57 @@ impl MaskPass {
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture one part is drawn into on its way into a layer's slice.
|
||||
struct Scratch {
|
||||
texture: wgpu::Texture,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// `max(dst, src)` — the union of two parts.
|
||||
///
|
||||
/// Not source-over, which would build up: two parts that each half-cover a
|
||||
/// pixel select it half, and adding them would make the overlap of two soft
|
||||
/// edges harder than either of them, drawing a seam exactly where a
|
||||
/// photographer joined two selections to avoid one.
|
||||
const MAX_BLEND: wgpu::BlendComponent = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Max,
|
||||
};
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// A single black texel, bound at the image slot for a mask that is not a
|
||||
/// range.
|
||||
///
|
||||
/// Written rather than merely allocated. Undefined contents would be read by
|
||||
/// nothing today, but a binding mistake in a range mask would then produce
|
||||
/// whatever the driver left in memory — a mask that flickers between builds
|
||||
/// and machines, which is the hardest shape of bug this pass could have.
|
||||
fn empty_image(ctx: &GpuContext) -> wgpu::TextureView {
|
||||
let texture = ctx.device.create_texture_with_data(
|
||||
&ctx.queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("mask-empty-image"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: DemosaicedImage::FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
// Four half-floats of zero. Rgba16Float, so eight bytes.
|
||||
&[0u8; 8],
|
||||
);
|
||||
texture.create_view(&wgpu::TextureViewDescriptor::default())
|
||||
}
|
||||
|
||||
/// The shorter edge of the space the mask is rasterised in.
|
||||
///
|
||||
/// Feather and morphology are stored as fractions of it, so the same edit is
|
||||
@@ -1004,11 +1513,11 @@ fn field_short_edge(width: u32, height: u32) -> f32 {
|
||||
/// Zero for closing and opening: those are folded into the field itself when
|
||||
/// it is built, because their second half acts on a shape the original field
|
||||
/// does not describe.
|
||||
fn morph_offset(layer: &dr_pipeline::mask::MaskLayer) -> f32 {
|
||||
fn morph_offset(part: &dr_pipeline::mask::MaskPart) -> f32 {
|
||||
use dr_pipeline::mask::Morphology;
|
||||
match layer.morphology {
|
||||
Morphology::Dilate => layer.morph_radius,
|
||||
Morphology::Erode => -layer.morph_radius,
|
||||
match part.morphology {
|
||||
Morphology::Dilate => part.morph_radius,
|
||||
Morphology::Erode => -part.morph_radius,
|
||||
Morphology::None | Morphology::Close | Morphology::Open => 0.0,
|
||||
}
|
||||
}
|
||||
@@ -1117,7 +1626,7 @@ mod tests {
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass
|
||||
.render(&stack, Some(&field), None, w, h)
|
||||
.render(&stack, Some(&field), None, None, w, h)
|
||||
.expect("render");
|
||||
assert_eq!(array.size(), (w, h));
|
||||
assert_eq!(array.layers(), 1);
|
||||
@@ -1139,7 +1648,7 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
assert!(pass.render(&stack, None, None, 8, 8).is_ok());
|
||||
assert!(pass.render(&stack, None, None, None, 8, 8).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1162,7 +1671,9 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass.render(&stack, None, None, 16, 16).expect("render");
|
||||
let array = pass
|
||||
.render(&stack, None, None, None, 16, 16)
|
||||
.expect("render");
|
||||
assert_eq!(array.layers(), 2, "one slice per active layer");
|
||||
}
|
||||
|
||||
@@ -1172,11 +1683,11 @@ mod tests {
|
||||
fn painted(gestures: &[Gesture]) -> MaskLayer {
|
||||
let mut layer = lit(MaskSource::brush());
|
||||
for (erase, radius, path) in gestures {
|
||||
layer.begin_stroke(*erase, *radius, 0.5, 1.0);
|
||||
layer.begin_stroke(0, *erase, *radius, 0.5, 1.0);
|
||||
for &(x, y) in path {
|
||||
layer.extend_stroke(x, y);
|
||||
layer.extend_stroke(0, x, y);
|
||||
}
|
||||
layer.end_stroke();
|
||||
layer.end_stroke(0);
|
||||
}
|
||||
layer
|
||||
}
|
||||
@@ -1194,7 +1705,9 @@ mod tests {
|
||||
stack.push(painted(&[(false, 0.1, vec![(0.2, 0.2), (0.8, 0.8)])]));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass.render(&stack, None, None, 32, 32).expect("render");
|
||||
let array = pass
|
||||
.render(&stack, None, None, None, 32, 32)
|
||||
.expect("render");
|
||||
assert_eq!(array.layers(), 1);
|
||||
}
|
||||
|
||||
@@ -1244,7 +1757,7 @@ mod tests {
|
||||
};
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass
|
||||
.render(&MaskStack::new(), None, None, 8, 8)
|
||||
.render(&MaskStack::new(), None, None, None, 8, 8)
|
||||
.expect("render");
|
||||
assert_eq!(
|
||||
array.layers(),
|
||||
@@ -1267,17 +1780,20 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
pass.render(&stack, None, None, 32, 32).expect("render");
|
||||
pass.render(&stack, None, None, None, 32, 32)
|
||||
.expect("render");
|
||||
assert_eq!(pass.allocations(), 1);
|
||||
|
||||
pass.render(&stack, None, None, 32, 32).expect("render");
|
||||
pass.render(&stack, None, None, None, 32, 32)
|
||||
.expect("render");
|
||||
assert_eq!(
|
||||
pass.allocations(),
|
||||
1,
|
||||
"same size and layer count should not reallocate"
|
||||
);
|
||||
|
||||
pass.render(&stack, None, None, 64, 64).expect("render");
|
||||
pass.render(&stack, None, None, None, 64, 64)
|
||||
.expect("render");
|
||||
assert_eq!(pass.allocations(), 2, "a resize must reallocate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,861 @@
|
||||
//! TRACES: FR-CULL-3
|
||||
//! Counting the sensor's own numbers, on an axis measured in stops of
|
||||
//! headroom.
|
||||
//!
|
||||
//! # Why there are two histograms, and what each one answers
|
||||
//!
|
||||
//! [`crate::HistogramPass`] beside this file counts the frame the display is
|
||||
//! about to show. It is tagged FR-DSP-7, its own documentation says a clipped
|
||||
//! bin means "a highlight that is actually gone rather than one the transform
|
||||
//! might still recover", and that is exactly right for the question it is
|
||||
//! there to answer: *what will this image look like when I send it out.*
|
||||
//!
|
||||
//! FR-CULL-3 asks the opposite question, and says why: "a JPEG's clipping
|
||||
//! warnings systematically lie about what is recoverable in the raw". A
|
||||
//! photographer culling three thousand frames is deciding whether a highlight
|
||||
//! can be brought back, not whether the current rendering happens to have
|
||||
//! kept it. A readout that measures the render cannot answer that however it
|
||||
//! is presented, so this is a second instrument rather than a setting on the
|
||||
//! first — and the panel offers both, because both are true and they are true
|
||||
//! about different things.
|
||||
//!
|
||||
//! # What is reduced over, and why it is not the CFA samples
|
||||
//!
|
||||
//! ARCH §5.5 originally specified a reduction over the *pre-demosaic* texture.
|
||||
//! This reduces over the demosaiced scene-linear texture instead —
|
||||
//! [`crate::DemosaicedImage`]'s `Rgba16Float`, the one the whole develop chain
|
||||
//! then works from — and the amendment to §5.5 records the decision rather
|
||||
//! 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
|
||||
//! 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.
|
||||
//!
|
||||
//! And retaining the CFA samples would have cost real memory for the
|
||||
//! difference. `Demosaicer::run` uploads the packed `u32` sample buffer and
|
||||
//! drops it the moment the dispatch is encoded; keeping it resident to reduce
|
||||
//! over later is 48 MB at 24 MP and 120 MB at 60 MP, per photograph opened,
|
||||
//! whether or not anyone ever looks at the histogram. ARCH §6.2 exists because
|
||||
//! memory is scarce on the platform this has to run on. A more complete
|
||||
//! instrument that is paid for on every image by everyone who never opens it
|
||||
//! is not a better instrument.
|
||||
//!
|
||||
//! # Three things this cannot tell you
|
||||
//!
|
||||
//! Each of these is a different failure, and none of them is hidden by
|
||||
//! presenting the result well.
|
||||
//!
|
||||
//! **It counts pixels, not photosites.** Every value here passed through the
|
||||
//! demosaic, so a saturated photosite pulls its interpolated neighbours up
|
||||
//! with it: per-channel clipping is smeared across roughly a demosaic kernel.
|
||||
//! The count of clipped pixels is therefore an overestimate of the count of
|
||||
//! clipped photosites, by an amount that depends on how isolated the clipping
|
||||
//! is — a large blown sky is barely affected, a field of specular glints on
|
||||
//! water is affected a great deal.
|
||||
//!
|
||||
//! **It cannot see above white.** `demosaic.wgsl` clamps each photosite at 1.0
|
||||
//! for a good reason of its own — a Canon 6D reads to 16383 against a declared
|
||||
//! white level of 15070, and carrying that overshoot forward turns blown
|
||||
//! highlights pink through the white balance — but the consequence here is
|
||||
//! that "at saturation" and "a stop past saturation" arrive in the same bin.
|
||||
//! The top column says *how much* is gone and never *how far* gone, which
|
||||
//! matters because some of what a raw converter can recover lives exactly
|
||||
//! there.
|
||||
//!
|
||||
//! **It is measured after the CFA pattern is gone.** Which channel of the
|
||||
//! mosaic saturated first at a given site is a fact about the sensor readout,
|
||||
//! and by this point each pixel carries three channels, two of which were
|
||||
//! reconstructed from its neighbours. The series below say which *colour*
|
||||
//! clipped in the reconstructed image; they cannot say which photosite went
|
||||
//! first.
|
||||
//!
|
||||
//! # What it costs, and when it runs
|
||||
//!
|
||||
//! One dispatch over the source texture plus a 4 KB buffer copy and a mapping.
|
||||
//! Unlike the display histogram this is a property of the **file**, not of the
|
||||
//! edit: nothing downstream of the demosaic can change it, so it is computed
|
||||
//! once per photograph and cached rather than recomputed on every settled
|
||||
//! frame. That is also why it describes the whole frame rather than the
|
||||
//! visible region — a crop changes what is on screen and changes nothing about
|
||||
//! what the sensor recorded.
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::readback::await_mapping;
|
||||
use crate::{GpuContext, GpuError};
|
||||
|
||||
/// Bins on the stops axis.
|
||||
///
|
||||
/// 256, the same count [`crate::HistogramPass`] uses, so the presentation code
|
||||
/// that folds bins into drawable columns is shared between the two rather than
|
||||
/// written twice against two different divisors.
|
||||
pub const BINS: usize = 256;
|
||||
|
||||
/// Bins per stop. Must equal `BINS_PER_STOP` in `raw_histogram.wgsl`.
|
||||
///
|
||||
/// 16 over 256 bins puts the floor of the axis at just under 16 stops, which
|
||||
/// covers every sensor anyone will point at this and a couple more besides —
|
||||
/// the best-measured full-frame bodies reach about 15 stops at base ISO, and
|
||||
/// nothing below the noise floor is a reading anyway. A finer division would
|
||||
/// buy resolution in a part of the plot that is already narrower than one
|
||||
/// drawn column.
|
||||
pub const BINS_PER_STOP: usize = 16;
|
||||
|
||||
/// Stops the axis spans, as a whole number.
|
||||
///
|
||||
/// Note that bin 0 sits at `STOPS - 1/BINS_PER_STOP` below saturation rather
|
||||
/// than at `STOPS`, and holds everything darker as well — see
|
||||
/// [`RawHistogram::stops`].
|
||||
///
|
||||
/// The last two stops of this range are, strictly, further than the source can
|
||||
/// carry: [`crate::DemosaicedImage::FORMAT`] is `Rgba16Float`, whose smallest
|
||||
/// normal value is 2^-14, so below fourteen stops a value survives only as an
|
||||
/// f16 subnormal and many drivers flush those to zero. It costs nothing to
|
||||
/// leave the axis at a round sixteen — no sensor records usable signal
|
||||
/// anywhere near there, and the alternative is a plot whose floor is an
|
||||
/// implementation detail of a texture format.
|
||||
pub const STOPS: usize = BINS / BINS_PER_STOP;
|
||||
|
||||
/// Four series plus the two clip counters, as the shader lays them out. Kept
|
||||
/// next to the shader's own constants because the two must agree.
|
||||
const CHANNELS: usize = 4;
|
||||
const SATURATED: usize = CHANNELS * BINS;
|
||||
const AT_BLACK: usize = CHANNELS * BINS + 1;
|
||||
const SLOTS: usize = CHANNELS * BINS + 2;
|
||||
|
||||
/// TRACES: FR-CULL-3
|
||||
/// A counted sensor frame: how many pixels sit at each distance below
|
||||
/// saturation.
|
||||
///
|
||||
/// Counts, not proportions, and bins rather than stops — the same division of
|
||||
/// labour [`crate::Histogram`] draws (ARCH §4.3a). Folding 256 bins into the
|
||||
/// columns a panel can show, deciding how much clipping is worth an alarm and
|
||||
/// turning a bin index into a figure a photographer reads are all questions
|
||||
/// about an interface. What this crate owes is the numbers.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RawHistogram {
|
||||
red: [u32; BINS],
|
||||
green: [u32; BINS],
|
||||
blue: [u32; BINS],
|
||||
brightest: [u32; BINS],
|
||||
saturated: u32,
|
||||
at_black: u32,
|
||||
pixels: u32,
|
||||
}
|
||||
|
||||
impl RawHistogram {
|
||||
/// Counts per bin for the camera's red channel, darkest first.
|
||||
///
|
||||
/// **Camera-native and unbalanced.** These are not the red of a rendered
|
||||
/// image: the as-shot white balance has not been applied, so on a neutral
|
||||
/// subject under daylight red typically sits around a stop below green and
|
||||
/// blue rather further. That is not a defect to be corrected before
|
||||
/// drawing — it is the point. Red reaching saturation while green has a
|
||||
/// stop left is a fact about the exposure, and balancing it away would
|
||||
/// hide the very thing the instrument is for.
|
||||
pub fn red(&self) -> &[u32; BINS] {
|
||||
&self.red
|
||||
}
|
||||
|
||||
pub fn green(&self) -> &[u32; BINS] {
|
||||
&self.green
|
||||
}
|
||||
|
||||
pub fn blue(&self) -> &[u32; BINS] {
|
||||
&self.blue
|
||||
}
|
||||
|
||||
/// The brightest of the three channels at each pixel.
|
||||
///
|
||||
/// The envelope the three series sit under, and the one that answers the
|
||||
/// headroom question directly: a pixel is safe exactly as long as its
|
||||
/// *highest* channel is, because that is the one that saturates first.
|
||||
///
|
||||
/// Where the display histogram draws Rec.709 luma, this draws a maximum,
|
||||
/// and the substitution is not cosmetic. Luma is a weighted sum of
|
||||
/// display-referred primaries; these values are camera-native and
|
||||
/// unbalanced, so any weighting of them is a number about nothing.
|
||||
pub fn brightest(&self) -> &[u32; BINS] {
|
||||
&self.brightest
|
||||
}
|
||||
|
||||
/// Pixels with **any** channel at sensor saturation.
|
||||
///
|
||||
/// Any rather than all, for the reason the display histogram gives about
|
||||
/// its own counter: a channel at the ceiling has no gradation left in it
|
||||
/// however much the other two still hold, and counting only neutral white
|
||||
/// would stay silent on exactly the saturated subject that clips first.
|
||||
///
|
||||
/// Read this against [`Self::pixels`], and read the module documentation
|
||||
/// before reading it against a count of photosites — the demosaic has
|
||||
/// already spread each clipped site over its neighbours.
|
||||
pub fn saturated(&self) -> u32 {
|
||||
self.saturated
|
||||
}
|
||||
|
||||
/// Pixels with any channel at or below the sensor's black level.
|
||||
///
|
||||
/// The other end, and a genuinely different statement from the display
|
||||
/// histogram's shadow clipping: this is a photosite that recorded nothing
|
||||
/// but read noise, where that one is a tone the output transform put at
|
||||
/// zero and which a lifted black point would bring back.
|
||||
pub fn at_black(&self) -> u32 {
|
||||
self.at_black
|
||||
}
|
||||
|
||||
/// Pixels counted. The denominator for the two figures above.
|
||||
pub fn pixels(&self) -> u32 {
|
||||
self.pixels
|
||||
}
|
||||
|
||||
/// How far below sensor saturation a bin sits, in stops.
|
||||
///
|
||||
/// Bin 255 is 0 stops — the top 1/16 of a stop, where a saturated pixel
|
||||
/// lands. Bin 239 is exactly 1 stop below. Bin 0 is 15.9375 stops below
|
||||
/// **and everything darker than that**, because it is the end of the axis
|
||||
/// rather than a bin of the same width as its neighbours; a photograph
|
||||
/// with genuinely 20 stops in it puts its bottom four into that column.
|
||||
///
|
||||
/// An index past the end is clamped rather than refused: this exists to
|
||||
/// label a plot, and a panel that panicked because a loop ran one past its
|
||||
/// last column would be a worse failure than a label at the floor of the
|
||||
/// axis.
|
||||
pub fn stops(bin: usize) -> f32 {
|
||||
(BINS - 1 - bin.min(BINS - 1)) as f32 / BINS_PER_STOP as f32
|
||||
}
|
||||
|
||||
/// Rebuild from the flat slot array the shader writes.
|
||||
///
|
||||
/// `pixels` is summed from the red series rather than taken from the image
|
||||
/// dimensions, for the reason [`crate::Histogram`] gives: every pixel lands
|
||||
/// in exactly one red bin, so the sum *is* the count, and deriving it that
|
||||
/// way makes a dropped or double-counted texel show up as a wrong
|
||||
/// denominator instead of hiding.
|
||||
fn from_slots(slots: &[u32]) -> Result<Self, GpuError> {
|
||||
if slots.len() < SLOTS {
|
||||
return Err(GpuError::Readback(format!(
|
||||
"raw histogram readback was {} slots, expected {SLOTS}",
|
||||
slots.len()
|
||||
)));
|
||||
}
|
||||
let series = |i: usize| -> [u32; BINS] {
|
||||
let mut out = [0u32; BINS];
|
||||
out.copy_from_slice(&slots[i * BINS..(i + 1) * BINS]);
|
||||
out
|
||||
};
|
||||
let red = series(0);
|
||||
Ok(Self {
|
||||
pixels: red.iter().sum(),
|
||||
red,
|
||||
green: series(1),
|
||||
blue: series(2),
|
||||
brightest: series(3),
|
||||
saturated: slots[SATURATED],
|
||||
at_black: slots[AT_BLACK],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The dispatch's view of the source. Padded to 16 bytes for std140.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Dims {
|
||||
width: u32,
|
||||
height: u32,
|
||||
pad_0: u32,
|
||||
pad_1: u32,
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-3
|
||||
/// Counts a scene-linear sensor frame into [`RawHistogram`].
|
||||
///
|
||||
/// Holds its buffers for the life of the session. They are a fixed 4104 bytes
|
||||
/// whatever the sensor size, which is the property that makes the whole shape
|
||||
/// affordable — there is nothing to reallocate when a different photograph is
|
||||
/// opened.
|
||||
pub struct RawHistogramPass {
|
||||
ctx: GpuContext,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
bind_group_layout: wgpu::BindGroupLayout,
|
||||
/// Where the shader accumulates. Cleared before each dispatch.
|
||||
bins: wgpu::Buffer,
|
||||
/// Mappable destination; a storage buffer cannot also be `MAP_READ`.
|
||||
staging: wgpu::Buffer,
|
||||
dims: wgpu::Buffer,
|
||||
}
|
||||
|
||||
impl RawHistogramPass {
|
||||
pub fn new(ctx: &GpuContext) -> Result<Self, GpuError> {
|
||||
// A validation error here is a bug in the shader beside this file, not
|
||||
// anything a user did — surfaced as a `Result` rather than left to
|
||||
// wgpu's default handler, which panics.
|
||||
let scope = ctx.device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
|
||||
let module = ctx
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("raw-histogram"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/raw_histogram.wgsl").into()),
|
||||
});
|
||||
|
||||
let bind_group_layout =
|
||||
ctx.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("raw-histogram-bgl"),
|
||||
entries: &[
|
||||
// The demosaiced scene-linear texture, read with
|
||||
// `textureLoad` — the same one the adjust pass samples
|
||||
// from, so what is counted is what the develop chain
|
||||
// starts from.
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: false },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let layout = ctx
|
||||
.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("raw-histogram-layout"),
|
||||
bind_group_layouts: &[Some(&bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
let pipeline = ctx
|
||||
.device
|
||||
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("raw-histogram-pipeline"),
|
||||
layout: Some(&layout),
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
|
||||
if let Some(err) = pollster::block_on(scope.pop()) {
|
||||
return Err(GpuError::ShaderCompilation(err.to_string()));
|
||||
}
|
||||
|
||||
let bytes = (SLOTS * std::mem::size_of::<u32>()) as u64;
|
||||
let bins = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("raw-histogram-bins"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_SRC
|
||||
| wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let staging = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("raw-histogram-staging"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let dims = ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("raw-histogram-dims"),
|
||||
contents: bytemuck::bytes_of(&Dims {
|
||||
width: 0,
|
||||
height: 0,
|
||||
pad_0: 0,
|
||||
pad_1: 0,
|
||||
}),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
ctx: ctx.clone(),
|
||||
pipeline,
|
||||
bind_group_layout,
|
||||
bins,
|
||||
staging,
|
||||
dims,
|
||||
})
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-3
|
||||
/// Count one scene-linear frame.
|
||||
///
|
||||
/// `texture` must be linear, camera-native and normalised so that 1.0 is
|
||||
/// the sensor's white level — which is what [`crate::Demosaicer::run`]
|
||||
/// produces and what nothing else in this crate does. Handing it the
|
||||
/// display frame instead would produce a perfectly well-formed plot of the
|
||||
/// wrong quantity, on an axis whose origin means nothing there, so callers
|
||||
/// must check [`crate::DemosaicedImage::is_non_linear`] first: a texture
|
||||
/// that came from a JPEG carries no sensor scale to measure headroom
|
||||
/// against, and the honest answer for one is that there is no reading
|
||||
/// rather than a reading nobody should trust.
|
||||
///
|
||||
/// It must also carry `TEXTURE_BINDING`, which the demosaic output does
|
||||
/// because the adjust pass samples it.
|
||||
pub fn compute(&self, texture: &wgpu::Texture) -> Result<RawHistogram, GpuError> {
|
||||
let (width, height) = (texture.width(), texture.height());
|
||||
self.ctx.queue.write_buffer(
|
||||
&self.dims,
|
||||
0,
|
||||
bytemuck::bytes_of(&Dims {
|
||||
width,
|
||||
height,
|
||||
pad_0: 0,
|
||||
pad_1: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
let view = texture.create_view(&Default::default());
|
||||
let bind_group = self
|
||||
.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("raw-histogram-bg"),
|
||||
layout: &self.bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: self.bins.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.dims.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let mut enc = self
|
||||
.ctx
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("raw-histogram-encoder"),
|
||||
});
|
||||
// The accumulator is reused between photographs, so it carries the
|
||||
// previous one's counts until this line. Forgetting it does not fail —
|
||||
// it quietly integrates every image opened this session, which looks
|
||||
// like a histogram that is roughly the right shape and never quite
|
||||
// about the picture on screen.
|
||||
enc.clear_buffer(&self.bins, 0, None);
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("raw-histogram-pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &bind_group, &[]);
|
||||
pass.dispatch_workgroups(width.div_ceil(16), height.div_ceil(16), 1);
|
||||
}
|
||||
enc.copy_buffer_to_buffer(&self.bins, 0, &self.staging, 0, self.staging.size());
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
|
||||
let slice = self.staging.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
await_mapping(&self.ctx, &rx)?;
|
||||
|
||||
let data = slice.get_mapped_range();
|
||||
let slots: Vec<u32> = bytemuck::cast_slice::<u8, u32>(&data).to_vec();
|
||||
drop(data);
|
||||
self.staging.unmap();
|
||||
|
||||
RawHistogram::from_slots(&slots)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An f32 as IEEE 754 half-precision bits, for building source textures.
|
||||
///
|
||||
/// Written out rather than pulled in as a dependency, exactly as
|
||||
/// `demosaic.rs` does for the same reason: every value these tests upload
|
||||
/// is inside `0.0..=2.0`, comfortably within f16's normal range, so the
|
||||
/// subnormal and overflow cases a general converter must handle cannot
|
||||
/// arise. The mantissa is truncated rather than rounded, which is why the
|
||||
/// tests below place their values in the *middle* of a bin instead of on
|
||||
/// its edge.
|
||||
fn half(v: f32) -> u16 {
|
||||
let v = v.clamp(0.0, 2.0);
|
||||
if v == 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let bits = v.to_bits();
|
||||
let exp = ((bits >> 23) & 0xFF) as i32 - 127 + 15;
|
||||
let mantissa = (bits >> 13) & 0x3FF;
|
||||
if exp <= 0 {
|
||||
return 0;
|
||||
}
|
||||
((exp as u16) << 10) | mantissa as u16
|
||||
}
|
||||
|
||||
/// Upload `rgb` as the `Rgba16Float` texture the pass expects, the way
|
||||
/// `Demosaicer::run` hands its output over.
|
||||
fn source(ctx: &GpuContext, rgb: &[[f32; 3]], width: u32, height: u32) -> wgpu::Texture {
|
||||
let halves: Vec<u16> = rgb
|
||||
.iter()
|
||||
.flat_map(|px| [half(px[0]), half(px[1]), half(px[2]), half(1.0)])
|
||||
.collect();
|
||||
let tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("raw-histogram-test-source"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba16Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
ctx.queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &tex,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
bytemuck::cast_slice(&halves),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
// Four channels of two bytes.
|
||||
bytes_per_row: Some(width * 8),
|
||||
rows_per_image: Some(height),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
ctx.queue.submit(std::iter::empty());
|
||||
tex
|
||||
}
|
||||
|
||||
/// The value at the centre of the bin `stops` below saturation.
|
||||
///
|
||||
/// Deliberately mid-bin. On a bin edge the assertion would be testing
|
||||
/// whether this machine's `log2` and this test's `powf` round a tie the
|
||||
/// same way, which is a question about two libraries rather than about the
|
||||
/// reduction — and the kind of disagreement that fails once in a thousand
|
||||
/// runs and looks like flakiness.
|
||||
fn mid_bin(bin_below_top: usize) -> f32 {
|
||||
2f32.powf(-((bin_below_top as f32) + 0.5) / BINS_PER_STOP as f32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_axis_runs_from_saturation_downward_in_stops() {
|
||||
// Arithmetic, so no device. The single most consequential convention
|
||||
// in the file, and the one every reading of the plot depends on: if
|
||||
// the axis were inverted the histogram would still look like a
|
||||
// histogram, and every headroom judgement made from it would be
|
||||
// exactly backwards.
|
||||
assert_eq!(
|
||||
RawHistogram::stops(BINS - 1),
|
||||
0.0,
|
||||
"the top bin is saturation"
|
||||
);
|
||||
assert_eq!(RawHistogram::stops(BINS - 1 - BINS_PER_STOP), 1.0);
|
||||
assert_eq!(RawHistogram::stops(BINS - 1 - 8 * BINS_PER_STOP), 8.0);
|
||||
// The floor is one bin short of `STOPS`, because bin 0 is the end of
|
||||
// the axis rather than the start of another stop.
|
||||
assert!(RawHistogram::stops(0) < STOPS as f32);
|
||||
assert!(RawHistogram::stops(0) > STOPS as f32 - 0.1);
|
||||
// Monotonic: further down the axis is always more stops below.
|
||||
for bin in 1..BINS {
|
||||
assert!(
|
||||
RawHistogram::stops(bin) < RawHistogram::stops(bin - 1),
|
||||
"bin {bin} is not below bin {}",
|
||||
bin - 1
|
||||
);
|
||||
}
|
||||
// Past the end is clamped, not a panic — see the doc comment.
|
||||
assert_eq!(RawHistogram::stops(BINS), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saturated_frame_lands_in_the_top_bin_and_is_counted_as_clipped() {
|
||||
// The arithmetic at its most checkable, at the end of the axis that
|
||||
// matters most: 64x64 pixels at the white level must produce exactly
|
||||
// 4096 in bin 255, nothing anywhere else, and 4096 clipped.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
||||
|
||||
let rgb = vec![[1.0f32, 1.0, 1.0]; 64 * 64];
|
||||
let hist = pass.compute(&source(&ctx, &rgb, 64, 64)).expect("compute");
|
||||
|
||||
assert_eq!(hist.pixels(), 4096);
|
||||
assert_eq!(hist.red()[BINS - 1], 4096);
|
||||
assert_eq!(hist.green()[BINS - 1], 4096);
|
||||
assert_eq!(hist.blue()[BINS - 1], 4096);
|
||||
assert_eq!(hist.brightest()[BINS - 1], 4096);
|
||||
assert_eq!(
|
||||
hist.red().iter().filter(|c| **c > 0).count(),
|
||||
1,
|
||||
"one value can only occupy one bin"
|
||||
);
|
||||
assert_eq!(hist.saturated(), 4096);
|
||||
assert_eq!(hist.at_black(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_at_the_black_level_lands_in_the_bottom_bin() {
|
||||
// Zero has no logarithm, so the bottom of the axis is a special case
|
||||
// in the shader rather than a value that falls out of the formula. A
|
||||
// frame of it must land in bin 0 and be counted as black-clipped —
|
||||
// getting this wrong produces a NaN bin index, which on most hardware
|
||||
// is bin 0 anyway and on some is not.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
||||
|
||||
let rgb = vec![[0.0f32, 0.0, 0.0]; 32 * 32];
|
||||
let hist = pass.compute(&source(&ctx, &rgb, 32, 32)).expect("compute");
|
||||
|
||||
assert_eq!(hist.pixels(), 1024);
|
||||
assert_eq!(hist.red()[0], 1024);
|
||||
assert_eq!(hist.brightest()[0], 1024);
|
||||
assert_eq!(hist.at_black(), 1024);
|
||||
assert_eq!(hist.saturated(), 0);
|
||||
}
|
||||
|
||||
/// Bins the ramp below walks, from the top of the axis downward.
|
||||
///
|
||||
/// Twelve stops rather than the whole sixteen, and the reason is the
|
||||
/// source format rather than the reduction. `Rgba16Float`'s smallest
|
||||
/// *normal* value is 2^-14, so the bottom two stops of the axis can only
|
||||
/// be held as f16 subnormals — which many GPUs flush to zero — and a test
|
||||
/// that walked into them would be measuring the driver's denormal policy
|
||||
/// rather than this shader. Twelve stops is past the noise floor of every
|
||||
/// sensor this will meet, so nothing a photograph actually contains is
|
||||
/// left unchecked.
|
||||
const RAMP: usize = 12 * BINS_PER_STOP;
|
||||
|
||||
#[test]
|
||||
fn every_bin_the_axis_can_hold_lands_where_it_belongs() {
|
||||
// A ramp down the axis, four pixels per bin, each value placed at the
|
||||
// centre of the bin it belongs in. This is the test that catches an
|
||||
// off-by-one in the binning or a `BINS_PER_STOP` that disagrees across
|
||||
// the language boundary — either shifts the whole plot along the axis,
|
||||
// which on a real photograph looks like nothing at all and is a
|
||||
// headroom figure out by a stop.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
||||
|
||||
let (w, h) = (RAMP as u32, 4u32);
|
||||
let mut rgb = Vec::with_capacity((w * h) as usize);
|
||||
for _ in 0..h {
|
||||
for x in 0..w {
|
||||
// x = 0 is the top bin, so `x` is also the number of bins
|
||||
// below saturation.
|
||||
let v = mid_bin(x as usize);
|
||||
rgb.push([v, v, v]);
|
||||
}
|
||||
}
|
||||
let hist = pass.compute(&source(&ctx, &rgb, w, h)).expect("compute");
|
||||
|
||||
assert_eq!(hist.pixels(), w * h);
|
||||
for below in 0..RAMP {
|
||||
let bin = BINS - 1 - below;
|
||||
assert_eq!(
|
||||
hist.red()[bin],
|
||||
h,
|
||||
"{below} bins below saturation should hold exactly {h} pixels"
|
||||
);
|
||||
// Neutral, so the brightest channel must land on the same bin as
|
||||
// the three do.
|
||||
assert_eq!(
|
||||
hist.brightest()[bin],
|
||||
h,
|
||||
"the envelope drifted at bin {bin}"
|
||||
);
|
||||
}
|
||||
// Nothing below the ramp, so an off-by-one that spilled a pixel past
|
||||
// the end of it would show here rather than hiding in a bin the loop
|
||||
// above never looks at.
|
||||
assert!(
|
||||
hist.red()[..BINS - RAMP].iter().all(|c| *c == 0),
|
||||
"a pixel landed below the ramp"
|
||||
);
|
||||
// A neutral ramp touching neither end: nothing is at the white level
|
||||
// and nothing is at black, so neither counter may fire.
|
||||
assert_eq!(hist.saturated(), 0);
|
||||
assert_eq!(hist.at_black(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_channel_saturating_alone_is_seen_and_the_others_are_not_dragged_with_it() {
|
||||
// **The claim the whole instrument rests on**, and the one a
|
||||
// luma-weighted or balanced reduction would fail. A red sunset clips
|
||||
// red while green and blue still hold two stops — that pixel is
|
||||
// clipped, its red must be at the top of the axis, and its green and
|
||||
// blue must be where they actually are. A readout that averaged the
|
||||
// three would report a comfortable exposure over a channel that is
|
||||
// already gone.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
||||
|
||||
// Two stops below saturation, mid-bin.
|
||||
let two_stops = mid_bin(2 * BINS_PER_STOP);
|
||||
let rgb = vec![
|
||||
[1.0f32, two_stops, two_stops],
|
||||
[1.0, two_stops, two_stops],
|
||||
[two_stops, two_stops, two_stops],
|
||||
[two_stops, two_stops, two_stops],
|
||||
];
|
||||
let hist = pass.compute(&source(&ctx, &rgb, 4, 1)).expect("compute");
|
||||
|
||||
assert_eq!(hist.pixels(), 4);
|
||||
assert_eq!(
|
||||
hist.saturated(),
|
||||
2,
|
||||
"two pixels have a channel at the ceiling"
|
||||
);
|
||||
assert_eq!(hist.at_black(), 0);
|
||||
assert_eq!(hist.red()[BINS - 1], 2);
|
||||
|
||||
let two_below = BINS - 1 - 2 * BINS_PER_STOP;
|
||||
assert_eq!(hist.red()[two_below], 2, "the unclipped pixels' red moved");
|
||||
assert_eq!(
|
||||
hist.green()[two_below],
|
||||
4,
|
||||
"green was dragged along by red's clipping"
|
||||
);
|
||||
assert_eq!(hist.blue()[two_below], 4);
|
||||
// The envelope follows the highest channel, which for the clipped
|
||||
// pixels is red.
|
||||
assert_eq!(hist.brightest()[BINS - 1], 2);
|
||||
assert_eq!(hist.brightest()[two_below], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overshoot_above_the_white_level_folds_into_the_top_bin() {
|
||||
// The demosaic clamps each *photosite* at 1.0, but the Malvar
|
||||
// correction can overshoot upward between two clipped ones, so values
|
||||
// above 1.0 do reach this pass. `log2` of them is negative and a
|
||||
// signed bin index would wrap to somewhere near the bottom of the
|
||||
// axis — a blown highlight drawn as deep shadow, which is the single
|
||||
// most misleading thing this plot could do.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
||||
|
||||
let rgb = vec![[1.4f32, 1.05, 1.0]; 8 * 8];
|
||||
let hist = pass.compute(&source(&ctx, &rgb, 8, 8)).expect("compute");
|
||||
|
||||
assert_eq!(hist.pixels(), 64);
|
||||
assert_eq!(hist.red()[BINS - 1], 64);
|
||||
assert_eq!(hist.green()[BINS - 1], 64);
|
||||
assert_eq!(hist.blue()[BINS - 1], 64);
|
||||
assert_eq!(hist.saturated(), 64);
|
||||
assert_eq!(hist.red()[0], 0, "an overshoot wrapped to the bottom bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edge_tile_is_neither_dropped_nor_counted_twice() {
|
||||
// A size that is not a multiple of the 16x16 workgroup, so the last
|
||||
// row and column of workgroups run off the image. The denominator is
|
||||
// where this shows: `pixels` is summed from the red series, so a tile
|
||||
// that failed to merge undercounts it and one merged twice doubles it,
|
||||
// and a percentage computed against either is wrong in a way nobody
|
||||
// can see on the plot.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
||||
|
||||
let (w, h) = (101u32, 37u32);
|
||||
let mut rgb = Vec::with_capacity((w * h) as usize);
|
||||
let mut state = 0x2545_F491_4F6C_DD1Du64;
|
||||
for _ in 0..w * h {
|
||||
// xorshift64*, so the frame is identical on every machine and a
|
||||
// failure can be reproduced rather than merely observed.
|
||||
state ^= state >> 12;
|
||||
state ^= state << 25;
|
||||
state ^= state >> 27;
|
||||
let below = (state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 56) as usize % RAMP;
|
||||
let v = mid_bin(below);
|
||||
rgb.push([v, v, v]);
|
||||
}
|
||||
let hist = pass.compute(&source(&ctx, &rgb, w, h)).expect("compute");
|
||||
|
||||
assert_eq!(hist.pixels(), w * h, "an edge tile was dropped or doubled");
|
||||
// Every series counts the same pixels, so every series must sum to the
|
||||
// same total — a merge that lost one channel's tile would not move the
|
||||
// denominator at all.
|
||||
for series in [hist.green(), hist.blue(), hist.brightest()] {
|
||||
assert_eq!(series.iter().sum::<u32>(), w * h);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_photograph_replaces_the_first_rather_than_adding_to_it() {
|
||||
// The accumulator is reused across images, so a missing clear
|
||||
// integrates every photograph opened this session. The symptom is
|
||||
// subtle and awful on a culling pass in particular: the plot keeps a
|
||||
// plausible shape and slowly stops responding to the file on screen,
|
||||
// because each new image's contribution shrinks against the running
|
||||
// total of the folder behind it.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
||||
|
||||
let bright = vec![[mid_bin(BINS_PER_STOP); 3]; 16 * 16];
|
||||
let dark = vec![[mid_bin(6 * BINS_PER_STOP); 3]; 16 * 16];
|
||||
|
||||
let one_stop = BINS - 1 - BINS_PER_STOP;
|
||||
let six_stops = BINS - 1 - 6 * BINS_PER_STOP;
|
||||
|
||||
let first = pass.compute(&source(&ctx, &bright, 16, 16)).expect("first");
|
||||
assert_eq!(first.red()[one_stop], 256);
|
||||
|
||||
let second = pass.compute(&source(&ctx, &dark, 16, 16)).expect("second");
|
||||
assert_eq!(
|
||||
second.pixels(),
|
||||
256,
|
||||
"the previous photograph was still counted"
|
||||
);
|
||||
assert_eq!(second.red()[one_stop], 0);
|
||||
assert_eq!(second.red()[six_stops], 256);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// TRACES: FR-CULL-3 | NFR-P14
|
||||
// Marking what is sharp, in a layer laid over the frame rather than into it.
|
||||
//
|
||||
// # Why the top octave, and not a gradient
|
||||
//
|
||||
// The obvious detector is a gradient magnitude — Sobel, or a central
|
||||
// difference — and it is the wrong one, for a reason that decides whether the
|
||||
// overlay is useful at all. A gradient answers "is there an edge here", and a
|
||||
// defocused edge is still an edge: blur a 100-code step with a two-pixel
|
||||
// Gaussian and the peak gradient is still around 20 codes per pixel, larger
|
||||
// than a genuinely sharp edge across a low-contrast texture. Peaking built on
|
||||
// gradients lights up the out-of-focus background of every portrait ever
|
||||
// taken, which is the frame it exists to reject.
|
||||
//
|
||||
// What separates sharp from soft is *scale*, not amplitude. Defocus is a
|
||||
// low-pass: it removes the top octave and leaves everything below it intact.
|
||||
// So the detector is a high-pass — this pixel against the mean of its eight
|
||||
// neighbours, a discrete Laplacian — which by construction responds only to
|
||||
// the frequencies defocus destroys.
|
||||
//
|
||||
// The arithmetic, on a one-dimensional step of height D:
|
||||
//
|
||||
// | profile | abs(centre - mean of 8) |
|
||||
// |--------------------------|-------------------------|
|
||||
// | hard step, 1 px | 0.375 D |
|
||||
// | Gaussian blur, sigma 1 | ~0.10 D |
|
||||
// | Gaussian blur, sigma 2 | ~0.03 D |
|
||||
// | linear ramp, any slope | 0 |
|
||||
//
|
||||
// The ramp row is the property being bought: the smooth luminance falloff
|
||||
// across an out-of-focus highlight scores zero however bright it is.
|
||||
//
|
||||
// # Why luma, and why the histogram's luma
|
||||
//
|
||||
// One channel rather than three, because a colour edge carrying no luminance
|
||||
// difference is both rare and, at the acuity an overlay is read at, invisible.
|
||||
// The weights are `histogram.wgsl`'s 54/183/19 over 256 — the same Rec.709
|
||||
// weighting on the same encoded values — so the two instruments in this
|
||||
// application agree about what "luma" means. Two definitions of brightness in
|
||||
// one panel is the kind of disagreement nobody finds until it has already
|
||||
// misled someone.
|
||||
//
|
||||
// # Why the frame is read where it is encoded, and not in linear light
|
||||
//
|
||||
// This runs on the output of the display transform, on encoded values, and
|
||||
// that is deliberate: a fixed difference in sRGB code values is roughly
|
||||
// equally visible wherever it sits in the range, which is what a transfer
|
||||
// curve is for. Measured in linear light the same detector would need a
|
||||
// threshold that varied with exposure, and a shadow texture the photographer
|
||||
// can plainly see would score a hundredth of the identical texture in the
|
||||
// highlights. The encoding has already done the normalisation, so the
|
||||
// threshold is one number.
|
||||
//
|
||||
// # Why this writes a layer and not the picture
|
||||
//
|
||||
// The frame the compositor is handed is also what the histogram counts and
|
||||
// what an export renders (`app.slint`, on the region overlay: a diagnostic
|
||||
// "must not reach the histogram, an export, or the texture the develop pass
|
||||
// hands the compositor"). So the marks go in their own texture — transparent
|
||||
// everywhere except where something is in focus — and the compositor blends
|
||||
// them. Nothing about the photograph changes, and the peaking overlay cannot
|
||||
// leak into a measurement or a file.
|
||||
//
|
||||
// Alpha is written as exactly 0 or exactly 1, never between. The importing
|
||||
// compositor's convention for whether colour arrives premultiplied is not
|
||||
// something this shader can see, and at those two values the two conventions
|
||||
// agree — which is a cheaper guarantee than being right about which one it is.
|
||||
|
||||
struct Params {
|
||||
width: u32,
|
||||
height: u32,
|
||||
// Luma difference at which a pixel is called in focus. See
|
||||
// `PeakSensitivity::threshold` for where the three values come from.
|
||||
threshold: f32,
|
||||
// std140 rounds the scalar block up to 16 bytes before the vec4; named so
|
||||
// the Rust struct's padding is visibly the same shape.
|
||||
pad_0: u32,
|
||||
// The mark's colour, fully saturated. Its alpha is ignored — see above.
|
||||
marker: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var frame: texture_2d<f32>;
|
||||
@group(0) @binding(1) var<uniform> params: Params;
|
||||
@group(0) @binding(2) var marks: texture_storage_2d<rgba8unorm, write>;
|
||||
|
||||
/// Rec.709 luma of an encoded triple, weighted exactly as `histogram.wgsl`
|
||||
/// weights it. 54 + 183 + 19 is 256, so the weights sum to unity.
|
||||
fn luma(c: vec3<f32>) -> f32 {
|
||||
return dot(c, vec3<f32>(54.0, 183.0, 19.0) / 256.0);
|
||||
}
|
||||
|
||||
/// A neighbour, with the frame edge held rather than wrapped.
|
||||
///
|
||||
/// Clamping duplicates the edge pixel into the missing half of the
|
||||
/// neighbourhood, which pulls the mean towards the centre and so biases the
|
||||
/// response *down* on the outermost row and column. That is the right
|
||||
/// direction to be wrong in: the failure is a missing mark at the frame edge,
|
||||
/// where nobody is judging focus, rather than a false mark produced by
|
||||
/// folding the opposite side of the picture into the kernel.
|
||||
fn neighbour(x: i32, y: i32) -> f32 {
|
||||
let cx = clamp(x, 0i, i32(params.width) - 1i);
|
||||
let cy = clamp(y, 0i, i32(params.height) - 1i);
|
||||
return luma(textureLoad(frame, vec2<i32>(cx, cy), 0).rgb);
|
||||
}
|
||||
|
||||
// 8x8, matching the detail stage's dispatch. Each texel is loaded by nine
|
||||
// invocations and no workgroup-memory tile is built to avoid that: at viewport
|
||||
// resolution the reads are perfectly coherent and the texture cache serves
|
||||
// eight of the nine. The budget is NFR-P14's 100 ms against a dispatch
|
||||
// measured in tenths of a millisecond, so there is nothing here worth the
|
||||
// complexity of a tiled load.
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= params.width || gid.y >= params.height) {
|
||||
return;
|
||||
}
|
||||
let x = i32(gid.x);
|
||||
let y = i32(gid.y);
|
||||
|
||||
// The eight neighbours, centre excluded. Excluded rather than folded in
|
||||
// because it makes the response readable: `abs(c - mean8)` is the height
|
||||
// of this pixel above its surroundings in the same units as the step it
|
||||
// sits on, so the threshold can be quoted as a luma difference rather than
|
||||
// as eight-ninths of one.
|
||||
var sum = 0.0;
|
||||
for (var dy = -1; dy <= 1; dy = dy + 1) {
|
||||
for (var dx = -1; dx <= 1; dx = dx + 1) {
|
||||
if (dx != 0 || dy != 0) {
|
||||
sum = sum + neighbour(x + dx, y + dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
let centre = luma(textureLoad(frame, vec2<i32>(x, y), 0).rgb);
|
||||
let response = abs(centre - sum / 8.0);
|
||||
|
||||
if (response >= params.threshold) {
|
||||
textureStore(marks, vec2<i32>(x, y), vec4<f32>(params.marker.rgb, 1.0));
|
||||
} else {
|
||||
textureStore(marks, vec2<i32>(x, y), vec4<f32>(0.0, 0.0, 0.0, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ struct MaskParams {
|
||||
label_width: u32,
|
||||
label_height: u32,
|
||||
|
||||
// 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush.
|
||||
// 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush,
|
||||
// 5 = luminance range, 6 = colour range.
|
||||
//
|
||||
// A brush does not read this — it has its own entry points, because it is
|
||||
// the one mask that is not a function of the whole frame — but it is set
|
||||
@@ -50,10 +51,49 @@ struct MaskParams {
|
||||
// Linear: (cos, sin) of the ramp direction. Radial: semi-axes.
|
||||
axis: vec2<f32>,
|
||||
// Linear: ramp width. Radial: edge falloff as a fraction of the radius.
|
||||
// A range: the fade at each edge of its band, in the band's own units.
|
||||
softness: f32,
|
||||
// Radial only: rotation of the ellipse.
|
||||
angle: f32,
|
||||
_pad1: vec2<f32>,
|
||||
|
||||
// TRACES: FR-DEV-10
|
||||
// How many source texels one mask texel spans, per axis.
|
||||
//
|
||||
// The mask array is rasterised at a proxy size and the photograph is not,
|
||||
// so one texel here covers several there. A range mask is a function of
|
||||
// pixel *values*, and point-sampling one source texel in four would make
|
||||
// its edge follow the sensor's noise wherever the picture has fine
|
||||
// texture — speckle that is then a mask, and therefore visible in the
|
||||
// adjustment. Averaging the footprint is what makes the band land on the
|
||||
// tone the area actually is.
|
||||
source_step: vec2<f32>,
|
||||
|
||||
// Camera RGB → linear sRGB, one row each. Only a range reads these: it is
|
||||
// the one mask that looks at the photograph, and a hue is the body's own
|
||||
// primaries until this matrix has been applied — so the same stored arc
|
||||
// would select a different set of colours on every make of sensor.
|
||||
cam_to_srgb_0: vec4<f32>,
|
||||
cam_to_srgb_1: vec4<f32>,
|
||||
cam_to_srgb_2: vec4<f32>,
|
||||
// rgb: as-shot white balance. w: non-zero when the source arrived
|
||||
// gamma-encoded rather than linear.
|
||||
as_shot_wb: vec4<f32>,
|
||||
|
||||
// Whether this part is turned over before it joins the mask.
|
||||
//
|
||||
// Read by `fs_combine` and by nothing else, deliberately. A brush deposits
|
||||
// dabs onto an empty field and has no idea what the rest of the frame is,
|
||||
// so a stroke shader cannot invert anything; doing it where the finished
|
||||
// part is read back is the one place that works for every kind of source.
|
||||
invert: u32,
|
||||
// Three scalars rather than a `vec3<u32>`: a three-component vector is
|
||||
// aligned to sixteen bytes in the uniform address space, so it would sit
|
||||
// at offset 144 and make this struct 160 bytes against the Rust side's
|
||||
// 144 — a mismatch wgpu reports as a binding too small for the shader,
|
||||
// several layers away from the padding that caused it.
|
||||
_pad0: u32,
|
||||
_pad1: u32,
|
||||
_pad2: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: MaskParams;
|
||||
@@ -74,6 +114,19 @@ struct MaskParams {
|
||||
// shrinking and feathering free: each is arithmetic on this, so a slider moves
|
||||
// a uniform instead of rebuilding a mask.
|
||||
@group(0) @binding(3) var subject: texture_2d<f32>;
|
||||
// TRACES: FR-DEV-10
|
||||
// The photograph itself, as the demosaicer left it: camera RGB, unbalanced,
|
||||
// with no edit applied. A 1x1 placeholder for every mask that is a shape,
|
||||
// because the bindings are fixed and a second pipeline differing only in what
|
||||
// it ignores would cost more than one texel.
|
||||
//
|
||||
// **The unedited image, and that is the design rather than an accident of
|
||||
// pass order.** A band over the *edited* result would move as the edit was
|
||||
// made: raising the highlights would change which pixels counted as
|
||||
// highlights, so the slider would chase its own mask. Measuring what the
|
||||
// camera recorded means the selection stays where the photographer put it
|
||||
// while they work on it.
|
||||
@group(0) @binding(6) var image: texture_2d<f32>;
|
||||
|
||||
// A full-screen triangle rather than a quad: three vertices instead of six,
|
||||
// no shared edge for the rasteriser to crack along, and no vertex buffer.
|
||||
@@ -221,6 +274,177 @@ fn subject_mask(uv: vec2<f32>) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Range masks (FR-DEV-10)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The masks that select by what a pixel *is* rather than by where it sits.
|
||||
// Nothing below reads `frame_delta`, and that absence is the point: a range is
|
||||
// not a function of position, so it cannot be stretched by an aspect ratio,
|
||||
// cannot drift under a crop, and comes out the same at a proxy size and at an
|
||||
// export because the only thing it depends on is the photograph's own values.
|
||||
//
|
||||
// The band arrives entirely in the fields the gradients use — `axis` is the
|
||||
// pair of bounds, `centre` is a colour range's arc, `softness` is the fade —
|
||||
// so a range costs nothing in the uniform beyond the image transform above.
|
||||
|
||||
// Display-encoded sRGB back to linear.
|
||||
//
|
||||
// A JPEG is uploaded with its bytes untouched, so its values are gamma-encoded
|
||||
// where the demosaicer's are linear. The same undoing the generated adjust
|
||||
// shader does, at the same point and for the same reason: a band over
|
||||
// brightness is meaningless if two sources disagree about what a value means.
|
||||
fn decode_srgb(c: vec3<f32>) -> vec3<f32> {
|
||||
let lo = c / 12.92;
|
||||
let hi = pow((max(c, vec3<f32>(0.04045)) + 0.055) / 1.055, vec3<f32>(2.4));
|
||||
return select(hi, lo, c <= vec3<f32>(0.04045));
|
||||
}
|
||||
|
||||
// One source texel, as linear sRGB.
|
||||
//
|
||||
// This is the prologue of the generated adjust shader, repeated: decode,
|
||||
// balance, pull a clipped pixel back to neutral, then the camera matrix. It is
|
||||
// repeated rather than shared because the composer emits WGSL for the *edit*
|
||||
// and this pass is not one — but it must agree with it, since a range mask
|
||||
// exists to select the values the layer's own adjustments will then see.
|
||||
//
|
||||
// The highlight desaturation is the part that looks skippable and is not. A
|
||||
// fully clipped photosite arrives as (1,1,1), carrying no colour at all; the
|
||||
// as-shot multipliers are far from neutral, so balancing it and passing it
|
||||
// through the matrix produces a strong magenta. A colour range would then
|
||||
// select every blown sky as if the photographer had asked for magenta.
|
||||
fn source_texel(px: vec2<i32>) -> vec3<f32> {
|
||||
var c = textureLoad(image, px, 0).rgb;
|
||||
if (p.as_shot_wb.w > 0.5) {
|
||||
c = decode_srgb(c);
|
||||
}
|
||||
|
||||
let clipped = smoothstep(0.985, 1.0, max(c.r, max(c.g, c.b)));
|
||||
c = c * p.as_shot_wb.rgb;
|
||||
if (clipped > 0.0) {
|
||||
c = mix(c, vec3<f32>(max(c.r, max(c.g, c.b))), clipped);
|
||||
}
|
||||
|
||||
return vec3<f32>(
|
||||
dot(p.cam_to_srgb_0.rgb, c),
|
||||
dot(p.cam_to_srgb_1.rgb, c),
|
||||
dot(p.cam_to_srgb_2.rgb, c),
|
||||
);
|
||||
}
|
||||
|
||||
// The most taps one mask texel averages, per axis.
|
||||
//
|
||||
// A cap rather than the true footprint. At a 1600 px proxy over a 24 MP frame
|
||||
// the ratio is under four, so this is the whole footprint for every ordinary
|
||||
// photograph; past it the taps stride across the footprint instead of
|
||||
// covering it, which is a sample of the area rather than its mean. That is the
|
||||
// right way to run out of budget here — the estimate gets noisier, it does not
|
||||
// start measuring somewhere else.
|
||||
const MAX_SOURCE_TAPS: i32 = 4;
|
||||
|
||||
// The photograph's value under one mask texel, in linear sRGB.
|
||||
fn image_value(px: vec2<i32>) -> vec3<f32> {
|
||||
let dims = vec2<i32>(textureDimensions(image));
|
||||
let last = dims - vec2<i32>(1);
|
||||
|
||||
// The footprint's top-left corner in source texels. Not a centre plus a
|
||||
// radius: the mask texel is a *box* over the source, and sampling
|
||||
// symmetrically about its centre would weight the middle of every
|
||||
// footprint twice at odd tap counts.
|
||||
let origin = vec2<f32>(px) * p.source_step;
|
||||
let taps = clamp(vec2<i32>(ceil(p.source_step)), vec2<i32>(1), vec2<i32>(MAX_SOURCE_TAPS));
|
||||
// `stride`, not `step`: WGSL has a builtin of that name, and a local that
|
||||
// shadows one is legal and unreadable in the same breath.
|
||||
let stride = p.source_step / vec2<f32>(taps);
|
||||
|
||||
var total = vec3<f32>(0.0);
|
||||
for (var y = 0; y < taps.y; y = y + 1) {
|
||||
for (var x = 0; x < taps.x; x = x + 1) {
|
||||
let at = origin + (vec2<f32>(f32(x), f32(y)) + vec2<f32>(0.5)) * stride;
|
||||
total = total + source_texel(clamp(vec2<i32>(at), vec2<i32>(0), last));
|
||||
}
|
||||
}
|
||||
return total / f32(taps.x * taps.y);
|
||||
}
|
||||
|
||||
// A soft band: one inside, nothing outside, a smooth ramp across each edge.
|
||||
//
|
||||
// The `min` rather than a product of the two ramps. A band narrower than twice
|
||||
// its softness has no plateau, and multiplying the rising and falling ramps
|
||||
// would then peak well below one — so "select the highlights" would come out
|
||||
// at sixty per cent and the photographer would compensate with opacity,
|
||||
// against a mask that was quietly weaker than it said. `min` keeps the
|
||||
// plateau where there is one and degrades to a single peak where there is not.
|
||||
fn band(v: f32, lo: f32, hi: f32, soft: f32) -> f32 {
|
||||
if (soft <= 0.0) {
|
||||
return select(0.0, 1.0, v >= lo && v <= hi);
|
||||
}
|
||||
return min(smoothstep(lo - soft, lo, v), 1.0 - smoothstep(hi, hi + soft, v));
|
||||
}
|
||||
|
||||
fn luminance_mask(px: vec2<i32>) -> f32 {
|
||||
let y = dot(image_value(px), vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
// Onto the perceptual position `tone_position` in `ops/_helpers.yaml`
|
||||
// establishes, which is where the stored bounds are measured. Linear light
|
||||
// puts middle grey at 0.18, so a band stated in it would spend four fifths
|
||||
// of its travel inside the shadows.
|
||||
let t = clamp(pow(max(y, 0.0), 1.0 / 3.0), 0.0, 1.0);
|
||||
return band(t, p.axis.x, p.axis.y, p.softness);
|
||||
}
|
||||
|
||||
// Hue in turns, 0 at red and increasing through yellow.
|
||||
//
|
||||
// The plain six-sector definition. Zero for a neutral, which is a value the
|
||||
// caller must not act on — the chroma bound below is what keeps a colour range
|
||||
// away from the greys where this number is rounding noise.
|
||||
fn hue_of(c: vec3<f32>) -> f32 {
|
||||
let hi = max(c.r, max(c.g, c.b));
|
||||
let lo = min(c.r, min(c.g, c.b));
|
||||
let d = hi - lo;
|
||||
if (d <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
var h = 0.0;
|
||||
if (hi == c.r) {
|
||||
h = (c.g - c.b) / d;
|
||||
} else if (hi == c.g) {
|
||||
h = (c.b - c.r) / d + 2.0;
|
||||
} else {
|
||||
h = (c.r - c.g) / d + 4.0;
|
||||
}
|
||||
return fract(h / 6.0);
|
||||
}
|
||||
|
||||
fn colour_mask(px: vec2<i32>) -> f32 {
|
||||
let c = max(image_value(px), vec3<f32>(0.0));
|
||||
let hi = max(c.r, max(c.g, c.b));
|
||||
let lo = min(c.r, min(c.g, c.b));
|
||||
// The max-minus-min chroma `colour_saturation` uses, so the number the
|
||||
// band is stated in is the one the rest of the pipeline means by
|
||||
// 'colourfulness'.
|
||||
var chroma = 0.0;
|
||||
if (hi > 0.0) {
|
||||
chroma = (hi - lo) / hi;
|
||||
}
|
||||
|
||||
// Distance round the circle, so an arc centred near red reaches both ways
|
||||
// past zero. Written as a wrap rather than as two comparisons because red
|
||||
// is exactly where skin sits, and an arc that stopped at the seam would
|
||||
// select half of it.
|
||||
let d = abs(fract(hue_of(c) - p.centre.x + 0.5) - 0.5);
|
||||
var arc = 0.0;
|
||||
if (p.softness <= 0.0) {
|
||||
arc = select(0.0, 1.0, d <= p.centre.y);
|
||||
} else {
|
||||
arc = 1.0 - smoothstep(p.centre.y, p.centre.y + p.softness, d);
|
||||
}
|
||||
|
||||
// Both, not either: an arc alone selects a haze of noise everywhere the
|
||||
// picture is nearly grey, because a hue rounded out of three almost-equal
|
||||
// channels is still a hue.
|
||||
return min(arc, band(chroma, p.axis.x, p.axis.y, p.softness));
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let px = vec2<i32>(i32(pos.x), i32(pos.y));
|
||||
@@ -235,6 +459,8 @@ fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
case 1u: { m = linear_mask(uv); }
|
||||
case 2u: { m = radial_mask(uv); }
|
||||
case 3u: { m = subject_mask(uv); }
|
||||
case 5u: { m = luminance_mask(px); }
|
||||
case 6u: { m = colour_mask(px); }
|
||||
default: { m = 0.0; }
|
||||
}
|
||||
|
||||
@@ -387,3 +613,29 @@ fn fs_brush(in: BrushVertex) -> @location(0) vec4<f32> {
|
||||
|
||||
return vec4<f32>(clamp(coverage * s.flow, 0.0, 1.0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Joining one part to the mask so far
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A layer's mask is a fold over its parts, and the set operation is the *blend
|
||||
// state* rather than arithmetic here: union is `max(dst, src)`, subtraction is
|
||||
// `dst * (1 - src)`. Both are fixed-function, so joining a part costs one
|
||||
// full-screen draw and no second texture beyond the one being read.
|
||||
//
|
||||
// # Why a part is drawn aside first, rather than straight onto the mask
|
||||
//
|
||||
// Because an erase stroke inside a part means "a hole in *this* part", not "a
|
||||
// hole in the mask". Painted straight onto the accumulator it would take away
|
||||
// whatever the parts before it had put there — so a correction that tidied its
|
||||
// own edge would punch through the subject underneath, and the failure would
|
||||
// look like the model's mask had holes in it.
|
||||
|
||||
@group(0) @binding(7) var part_mask: texture_2d<f32>;
|
||||
|
||||
@fragment
|
||||
fn fs_combine(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let v = textureLoad(part_mask, vec2<i32>(i32(pos.x), i32(pos.y)), 0).r;
|
||||
let m = select(v, 1.0 - v, p.invert != 0u);
|
||||
return vec4<f32>(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// TRACES: FR-CULL-3
|
||||
// A reduction of the *scene-linear* frame onto a stops-below-saturation axis.
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// divergence between them would be a difference nobody chose.
|
||||
//
|
||||
// **Why the axis is logarithmic and measured downward from 1.0.** The texture
|
||||
// is normalised by the sensor's own black and white levels (see
|
||||
// `demosaic.wgsl`), so 1.0 *is* saturation by construction and there is no
|
||||
// other meaningful origin. And the question a photographer asks of raw data is
|
||||
// "how much headroom is left", which is a question in stops: a linear axis
|
||||
// spends half its width on the top stop and crushes the eleven below it into
|
||||
// the leftmost pixel, which is why nobody has ever drawn a useful linear raw
|
||||
// histogram.
|
||||
//
|
||||
// So bin 255 is the top 1/16 stop below saturation, bin 239 is one stop below,
|
||||
// bin 0 is 15.9375 stops below **and everything darker**. A bin is 1/16 of a
|
||||
// stop, which is about 4.4% in value — fine enough that a clipped highlight is
|
||||
// visibly its own column rather than smeared over the top quarter of the plot.
|
||||
//
|
||||
// **Counted per workgroup first, then merged**, for the reason
|
||||
// `histogram.wgsl` gives at length: a photograph is not noise, tens of
|
||||
// thousands of adjacent pixels land in one bin, and having every invocation
|
||||
// contend for one global atomic serialises the dispatch. Four kilobytes of
|
||||
// workgroup storage against a 16 KB floor.
|
||||
|
||||
const BINS: u32 = 256u;
|
||||
// Bins per stop. Must equal `BINS_PER_STOP` on the Rust side, which turns a
|
||||
// bin index back into a stops figure for the readout — the two disagreeing
|
||||
// would put a correct plot under a wrong number.
|
||||
const BINS_PER_STOP: f32 = 16.0;
|
||||
|
||||
// Two counters past the four series, in the same buffer and for the same
|
||||
// reason `histogram.wgsl` puts its there: they are gathered from the texel
|
||||
// read the binning already did, and a second reduction to answer them would be
|
||||
// a second pass over the whole image for two integers.
|
||||
const SATURATED: u32 = 4u * BINS;
|
||||
const AT_BLACK: u32 = 4u * BINS + 1u;
|
||||
const SLOTS: u32 = 4u * BINS + 2u;
|
||||
// 16x16. Stated as a constant because the clear and merge loops stride by it,
|
||||
// and a workgroup size that disagreed would leave slots uncleared.
|
||||
const THREADS: u32 = 256u;
|
||||
|
||||
// How close to 1.0 counts as saturated.
|
||||
//
|
||||
// **Not `>= 1.0`, and the margin is arithmetic rather than taste.** A photosite
|
||||
// at or above the declared white level leaves `demosaic.wgsl` clamped to
|
||||
// exactly 1.0, but it then travels through an f16 texture and, at a green
|
||||
// site, through an interpolation kernel whose terms are added in a different
|
||||
// order than a reader would expect. f16 spaces its values 2^-11 apart just
|
||||
// below 1.0, so a value that was 1.0 can arrive an ulp or two under it. This
|
||||
// margin is four of those, and 0.0014 of a stop — a hundredth of the width of
|
||||
// one bin — so it cannot move a column of the plot, only stop the counter
|
||||
// missing a genuinely blown pixel.
|
||||
const SATURATION: f32 = 0.999;
|
||||
|
||||
struct Dims {
|
||||
width: u32,
|
||||
height: u32,
|
||||
// std140 rounds a uniform block up to 16 bytes; named rather than left
|
||||
// implicit so the Rust side's padding is visibly the same shape.
|
||||
pad_0: u32,
|
||||
pad_1: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var<storage, read_write> bins: array<atomic<u32>>;
|
||||
@group(0) @binding(2) var<uniform> dims: Dims;
|
||||
|
||||
var<workgroup> tile: array<atomic<u32>, SLOTS>;
|
||||
|
||||
// The bin a scene-linear value belongs in.
|
||||
//
|
||||
// Zero and below land in bin 0: `log2` of them is not a number, and a
|
||||
// photosite at or under the black level has no signal to place on a
|
||||
// logarithmic axis anyway — it is the bottom of the scale, which is where the
|
||||
// bottom bin is.
|
||||
//
|
||||
// Above 1.0 lands in bin 255. Such values exist: the demosaic clamps each
|
||||
// *photosite* at the white level, but the Malvar correction term can overshoot
|
||||
// upward between two clipped ones, so the interpolated output can pass 1.0 by
|
||||
// a little. That is an artefact of the reconstruction and not headroom above
|
||||
// saturation, and folding it into the top bin says exactly that.
|
||||
fn bin_of(v: f32) -> u32 {
|
||||
if (v <= 0.0) {
|
||||
return 0u;
|
||||
}
|
||||
// Stops below saturation, in sixteenths, floored. `-log2` because the axis
|
||||
// increases downward from 1.0 and a bin index increases upward.
|
||||
let steps = floor(-log2(v) * BINS_PER_STOP);
|
||||
return (BINS - 1u) - u32(clamp(steps, 0.0, f32(BINS - 1u)));
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16, 1)
|
||||
fn main(
|
||||
@builtin(global_invocation_id) gid: vec3<u32>,
|
||||
@builtin(local_invocation_index) lid: u32,
|
||||
) {
|
||||
for (var i = lid; i < SLOTS; i = i + THREADS) {
|
||||
atomicStore(&tile[i], 0u);
|
||||
}
|
||||
workgroupBarrier();
|
||||
|
||||
// Guarded rather than dispatched exactly: the workgroup is 16x16 and an
|
||||
// image is not, so the last row and column of workgroups run off the edge.
|
||||
if (gid.x < dims.width && gid.y < dims.height) {
|
||||
let texel = textureLoad(source, vec2<i32>(i32(gid.x), i32(gid.y)), 0);
|
||||
let r = texel.r;
|
||||
let g = texel.g;
|
||||
let b = texel.b;
|
||||
|
||||
// The fourth series is the **brightest** channel at each pixel, where
|
||||
// the display histogram's fourth series is Rec.709 luma. Luma would be
|
||||
// meaningless here: these are camera-native values with the as-shot
|
||||
// white balance still un-applied, so red and blue sit a stop or more
|
||||
// below green on a neutral subject and any weighted sum of them is a
|
||||
// number about nothing. The brightest channel, by contrast, is exactly
|
||||
// the quantity the headroom question is about — it is the one that
|
||||
// reaches saturation first and decides whether the pixel is
|
||||
// recoverable.
|
||||
let brightest = max(r, max(g, b));
|
||||
|
||||
atomicAdd(&tile[bin_of(r)], 1u);
|
||||
atomicAdd(&tile[BINS + bin_of(g)], 1u);
|
||||
atomicAdd(&tile[2u * BINS + bin_of(b)], 1u);
|
||||
atomicAdd(&tile[3u * BINS + bin_of(brightest)], 1u);
|
||||
|
||||
// Any channel, not all three, exactly as the display histogram counts
|
||||
// it: a saturated red photosite has no gradation left in it however
|
||||
// much headroom green and blue still hold, and a sunset or a red
|
||||
// jersey is precisely the subject that clips one channel first.
|
||||
if (brightest >= SATURATION) {
|
||||
atomicAdd(&tile[SATURATED], 1u);
|
||||
}
|
||||
// And at the other end: a channel that reached the black level has no
|
||||
// signal, only the read noise the normalisation clamped away.
|
||||
if (min(r, min(g, b)) <= 0.0) {
|
||||
atomicAdd(&tile[AT_BLACK], 1u);
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
|
||||
for (var i = lid; i < SLOTS; i = i + THREADS) {
|
||||
let count = atomicLoad(&tile[i]);
|
||||
// Most bins of most workgroups are empty — a 16x16 tile can touch 256
|
||||
// of 1026 slots at the very most, and usually far fewer.
|
||||
if (count != 0u) {
|
||||
atomicAdd(&bins[i], count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
.to_string();
|
||||
|
||||
ComposedDetailPass {
|
||||
output_scale: 1,
|
||||
label: "test/instances".to_string(),
|
||||
source,
|
||||
uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0],
|
||||
|
||||
@@ -413,3 +413,56 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
|
||||
assert_eq!(pass.detail_dispatches(), 0);
|
||||
assert_eq!(pass.detail_allocations(), 0);
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLAT-AND-5
|
||||
#[test]
|
||||
fn eviction_gives_the_pools_back_without_changing_a_pixel() {
|
||||
// The half of memory-pressure eviction that cannot be checked by looking
|
||||
// at a counter. `release_caches` frees the detail pool, and slot 0 of that
|
||||
// pool is where the fused colour result lives between frames — so the
|
||||
// render after an eviction has to notice that the promise recorded in
|
||||
// `colour_key` no longer holds and run the colour chain again.
|
||||
//
|
||||
// Leave the key standing and this test does not error: it draws. It draws
|
||||
// whatever a freshly-allocated texture happens to contain, which is the
|
||||
// failure worth building a test around, because on a device it would
|
||||
// appear only under memory pressure and only as a wrong-looking photograph.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 48;
|
||||
let source = step_edge(&ctx, SIZE);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
graph.set_param(PROBE, RADIUS, 0.05);
|
||||
|
||||
let before = render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
assert!(
|
||||
pass.cached_pipelines() > 0,
|
||||
"the colour pass compiled something"
|
||||
);
|
||||
assert!(
|
||||
pass.cached_detail_pipelines() > 0,
|
||||
"so did the detail stage"
|
||||
);
|
||||
let allocations = pass.detail_allocations();
|
||||
assert!(allocations > 0, "and the pool holds textures");
|
||||
|
||||
pass.release_caches();
|
||||
assert_eq!(pass.cached_pipelines(), 0);
|
||||
assert_eq!(pass.cached_detail_pipelines(), 0);
|
||||
|
||||
// The same edit at the same size. Nothing about the picture changed, so
|
||||
// nothing about the pixels may change either — only what it cost.
|
||||
let after = render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
assert_eq!(before.len(), after.len());
|
||||
for (i, (a, b)) in before.iter().zip(&after).enumerate() {
|
||||
assert!(
|
||||
a.abs_diff(*b) <= 1,
|
||||
"byte {i}: {a} before eviction, {b} after — the colour chain did \
|
||||
not re-run, so this frame is reading an empty intermediate"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
pass.detail_allocations() > allocations,
|
||||
"the pool was rebuilt, which is the evidence it was really given back"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack, Reveal, RevealStyle};
|
||||
use dr_pipeline::operation::{compose_full, compose_full_revealing};
|
||||
use dr_pipeline::spot::SpotSet;
|
||||
use dr_pipeline::{ops, EditGraph, Framing};
|
||||
use dr_types::ColourSpace;
|
||||
@@ -72,10 +72,13 @@ fn render_at(
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks.render(stack, field, None, w, h).expect("rasterise");
|
||||
let array = masks
|
||||
.render(stack, field, None, None, w, h)
|
||||
.expect("rasterise");
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
@@ -283,11 +286,11 @@ type Gesture = (bool, f32, f32, Vec<(f32, f32)>);
|
||||
fn painted(gestures: &[Gesture]) -> MaskLayer {
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
for (erase, radius, flow, path) in gestures {
|
||||
layer.begin_stroke(*erase, *radius, 0.9, *flow);
|
||||
layer.begin_stroke(0, *erase, *radius, 0.9, *flow);
|
||||
for &(x, y) in path {
|
||||
layer.extend_stroke(x, y);
|
||||
layer.extend_stroke(0, x, y);
|
||||
}
|
||||
layer.end_stroke();
|
||||
layer.end_stroke(0);
|
||||
}
|
||||
layer
|
||||
}
|
||||
@@ -502,9 +505,9 @@ fn hardness_decides_how_quickly_the_edge_falls_away() {
|
||||
|
||||
let edge = |hardness: f32| {
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
layer.begin_stroke(false, 0.4, hardness, 1.0);
|
||||
layer.extend_stroke(0.5, 0.5);
|
||||
layer.end_stroke();
|
||||
layer.begin_stroke(0, false, 0.4, hardness, 1.0);
|
||||
layer.extend_stroke(0, 0.5, 0.5);
|
||||
layer.end_stroke(0);
|
||||
|
||||
let pixels = render(&ctx, &stack_of(layer), None);
|
||||
// How many pixels along the centre row are neither fully painted nor
|
||||
@@ -605,3 +608,417 @@ fn an_empty_stack_renders_exactly_as_the_unmasked_path() {
|
||||
let masked = render(&ctx, &MaskStack::new(), None);
|
||||
assert_eq!(plain, masked, "an empty mask stack must be a no-op");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parts — a mask built from more than one selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Everything, so a part joined to it has something to change.
|
||||
fn whole_frame() -> MaskSource {
|
||||
MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0, 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint one gesture into a part of a layer, at a radius large enough that a
|
||||
/// 32-pixel frame can tell where it landed.
|
||||
fn paint(layer: &mut MaskLayer, part: usize, erase: bool, path: &[(f32, f32)]) {
|
||||
assert!(
|
||||
layer.begin_stroke(part, erase, 0.2, 1.0, 1.0),
|
||||
"the layer had no room for a stroke"
|
||||
);
|
||||
for &(x, y) in path {
|
||||
layer.extend_stroke(part, x, y);
|
||||
}
|
||||
layer.end_stroke(part);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_union_part_adds_what_the_base_did_not_cover() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut layer = brighten(MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0],
|
||||
});
|
||||
assert!(layer.push_part(MaskPart::painted("p2", Join::Union)));
|
||||
paint(&mut layer, 1, false, &[(0.85, 0.5)]);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
|
||||
|
||||
assert!(
|
||||
luma_at(&pixels, 4, 16) > 200,
|
||||
"the base region is still masked"
|
||||
);
|
||||
assert!(
|
||||
luma_at(&pixels, 27, 16) > 200,
|
||||
"and the painted part joined the other half in"
|
||||
);
|
||||
assert_eq!(
|
||||
luma_at(&pixels, 20, 2),
|
||||
128,
|
||||
"while what neither covers is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtract_part_takes_a_bite_out_of_the_mask() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut layer = brighten(whole_frame());
|
||||
assert!(layer.push_part(MaskPart::painted("p2", Join::Subtract)));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
|
||||
|
||||
assert_eq!(
|
||||
luma_at(&pixels, 16, 16),
|
||||
128,
|
||||
"the middle was taken back out of the mask"
|
||||
);
|
||||
assert!(
|
||||
luma_at(&pixels, 1, 1) > 200,
|
||||
"and the corner the stroke never reached is still in it"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The test the scratch texture exists for.** An erase stroke means a hole in
|
||||
/// the part it was painted into, not a hole in the mask: drawn straight onto
|
||||
/// the accumulator it would take away whatever the parts before it had put
|
||||
/// there, so tidying the edge of a correction would punch through the subject
|
||||
/// underneath — and the failure would read as the model's mask having holes.
|
||||
#[test]
|
||||
fn an_erase_stroke_holes_its_own_part_and_not_the_mask() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut layer = brighten(whole_frame());
|
||||
assert!(layer.push_part(MaskPart::painted("p2", Join::Union)));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
paint(&mut layer, 1, true, &[(0.5, 0.5)]);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
|
||||
|
||||
assert!(
|
||||
luma_at(&pixels, 16, 16) > 200,
|
||||
"the base still covers where the correction erased itself"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inverted_part_joins_everything_it_did_not_paint() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// A base that covers nothing, so what shows is the part alone.
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
paint(&mut layer, 0, false, &[(0.1, 0.1)]);
|
||||
assert!(layer.push_part(MaskPart::painted("p2", Join::Union)));
|
||||
layer.parts_mut()[1].invert = true;
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
|
||||
|
||||
assert_eq!(
|
||||
luma_at(&pixels, 16, 16),
|
||||
128,
|
||||
"where the inverted part was painted is outside the mask"
|
||||
);
|
||||
assert!(
|
||||
luma_at(&pixels, 30, 30) > 200,
|
||||
"and everywhere it was not is inside it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Parts fold in order, so the same two selections joined the other way round
|
||||
/// are a different mask. A subtraction that lands before the part it was meant
|
||||
/// to cut into would take away nothing at all.
|
||||
#[test]
|
||||
fn the_order_parts_are_joined_in_is_the_mask() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let render_pair = |cut_first: bool| {
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
if cut_first {
|
||||
layer.push_part(MaskPart::painted("p2", Join::Subtract));
|
||||
layer.push_part(MaskPart::painted("p3", Join::Union));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
paint(&mut layer, 2, false, &[(0.5, 0.5)]);
|
||||
} else {
|
||||
layer.push_part(MaskPart::painted("p2", Join::Union));
|
||||
layer.push_part(MaskPart::painted("p3", Join::Subtract));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
paint(&mut layer, 2, false, &[(0.5, 0.5)]);
|
||||
}
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
render(&ctx, &stack, Some(&split_field(&ctx)))
|
||||
};
|
||||
|
||||
assert!(
|
||||
luma_at(&render_pair(true), 16, 16) > 200,
|
||||
"adding after a subtraction leaves the addition standing"
|
||||
);
|
||||
assert_eq!(
|
||||
luma_at(&render_pair(false), 16, 16),
|
||||
128,
|
||||
"and subtracting after an addition takes it away again"
|
||||
);
|
||||
}
|
||||
|
||||
// --- seeing the mask (FR-DEV-19c) ------------------------------------------
|
||||
|
||||
/// A radial that covers the middle of the frame and nothing near the corners.
|
||||
fn middle() -> MaskSource {
|
||||
MaskSource::Radial {
|
||||
centre: (0.5, 0.5),
|
||||
radii: (0.3, 0.3),
|
||||
angle: 0.0,
|
||||
feather: 0.05,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`render`], with one layer's mask drawn over the result.
|
||||
fn render_revealing(ctx: &GpuContext, stack: &MaskStack, reveal: &Reveal) -> Vec<u8> {
|
||||
let source = grey(ctx);
|
||||
let shader = compose_full_revealing(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
Some(reveal),
|
||||
);
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks
|
||||
.render_revealing(stack, None, None, None, SIZE, SIZE, Some(reveal))
|
||||
.expect("rasterise");
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
|
||||
.expect("render");
|
||||
adjust.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// The state every mask is in for its first few seconds: chosen, and not yet
|
||||
/// used for anything.
|
||||
///
|
||||
/// Such a layer changes no pixel, so it is not active, so it occupied no mask
|
||||
/// slot and was never rasterised — and the reveal drew nothing. That is the
|
||||
/// whole of "I clicked the category and nothing happened": there was a mask,
|
||||
/// and no way to see that there was.
|
||||
#[test]
|
||||
fn a_selection_with_no_adjustment_can_still_be_seen() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
assert!(
|
||||
stack.is_neutral(),
|
||||
"the fixture must be a selection with nothing done to it"
|
||||
);
|
||||
|
||||
let pixels = render_revealing(&ctx, &stack, &Reveal::one("m1", RevealStyle::Alpha));
|
||||
|
||||
assert!(
|
||||
luma_at(&pixels, SIZE / 2, SIZE / 2) > 200,
|
||||
"the middle is inside the mask and should read white"
|
||||
);
|
||||
assert!(
|
||||
luma_at(&pixels, 1, 1) < 40,
|
||||
"the corner is outside it and should read black"
|
||||
);
|
||||
}
|
||||
|
||||
/// And with nobody looking, the same stack changes nothing at all.
|
||||
///
|
||||
/// The other half of the property above: a layer renders *because* it is being
|
||||
/// revealed, so it must stop when the reveal does — otherwise a selection with
|
||||
/// no adjustment would leave a slice in the array for ever.
|
||||
#[test]
|
||||
fn a_mask_nobody_is_looking_at_draws_nothing() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
|
||||
let pixels = render(&ctx, &stack, None);
|
||||
assert_eq!(
|
||||
luma_at(&pixels, SIZE / 2, SIZE / 2),
|
||||
128,
|
||||
"flat grey, exactly as it went in"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// A tint has to leave the photograph visible, or it cannot be judged against
|
||||
/// it — which is the one thing an overlay exists for.
|
||||
#[test]
|
||||
fn a_tint_colours_the_mask_and_leaves_the_rest_alone() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
|
||||
let pixels = render_revealing(&ctx, &stack, &Reveal::one("m1", RevealStyle::Tint));
|
||||
|
||||
let at = |x: u32, y: u32| {
|
||||
let i = ((y * SIZE + x) * 4) as usize;
|
||||
(pixels[i], pixels[i + 1], pixels[i + 2])
|
||||
};
|
||||
|
||||
let (r, g, _) = at(SIZE / 2, SIZE / 2);
|
||||
assert!(r > g + 40, "the mask should read red, got r={r} g={g}");
|
||||
assert!(
|
||||
g > 20,
|
||||
"and not opaque — the photograph under it is what the tint is judged \
|
||||
against, got g={g}"
|
||||
);
|
||||
|
||||
let (r, g, b) = at(1, 1);
|
||||
assert!(
|
||||
(120..=136).contains(&r) && r == g && g == b,
|
||||
"outside the mask the photograph is untouched, got ({r}, {g}, {b})"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// An outline draws where the mask stops and nowhere else — which is the
|
||||
/// point of it, since the other two styles cover the detail the boundary has
|
||||
/// to be judged against.
|
||||
#[test]
|
||||
fn an_outline_draws_the_boundary_and_not_the_interior() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
|
||||
let pixels = render_revealing(&ctx, &stack, &Reveal::one("m1", RevealStyle::Edge));
|
||||
|
||||
// Where the line landed, along the row through the centre. Searched
|
||||
// rather than sampled at one place: the radial's edge crosses this row
|
||||
// about 9.6 pixels out from the middle on a 32px frame, and asserting a
|
||||
// particular pixel would be asserting the rounding.
|
||||
let (at, brightest) = (SIZE / 2..SIZE)
|
||||
.map(|x| (x, luma_at(&pixels, x, SIZE / 2)))
|
||||
.max_by_key(|&(_, v)| v)
|
||||
.expect("the row is not empty");
|
||||
|
||||
assert!(
|
||||
brightest > 160,
|
||||
"there should be a line somewhere on this row, brightest was {brightest}"
|
||||
);
|
||||
assert!(
|
||||
(SIZE / 2 + 7..=SIZE / 2 + 12).contains(&at),
|
||||
"and it should be on the mask's boundary, not somewhere else: x={at}"
|
||||
);
|
||||
assert_eq!(
|
||||
luma_at(&pixels, SIZE / 2, SIZE / 2),
|
||||
128,
|
||||
"the picture inside the mask is untouched"
|
||||
);
|
||||
assert_eq!(
|
||||
luma_at(&pixels, 1, 1),
|
||||
128,
|
||||
"and so is the picture outside it"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// Two masks shown at once come out in two colours, each where its own mask
|
||||
/// is — which is what makes "where do these meet" a question the screen can
|
||||
/// answer.
|
||||
#[test]
|
||||
fn two_shown_masks_are_drawn_each_in_its_own_colour() {
|
||||
use dr_pipeline::mask::RevealedLayer;
|
||||
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// A left half and a right half, as two brush layers with one fat dab each.
|
||||
let half = |id: &str, x: f32| {
|
||||
let mut layer = MaskLayer::new(id, MaskSource::brush());
|
||||
paint(&mut layer, 0, false, &[(x, 0.5)]);
|
||||
layer
|
||||
};
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(half("left", 0.2));
|
||||
stack.push(half("right", 0.8));
|
||||
|
||||
let reveal = Reveal {
|
||||
layers: vec![
|
||||
RevealedLayer {
|
||||
layer: "left".into(),
|
||||
colour: [1.0, 0.0, 0.0],
|
||||
},
|
||||
RevealedLayer {
|
||||
layer: "right".into(),
|
||||
colour: [0.0, 0.0, 1.0],
|
||||
},
|
||||
],
|
||||
style: RevealStyle::Alpha,
|
||||
};
|
||||
let pixels = render_revealing(&ctx, &stack, &reveal);
|
||||
|
||||
let at = |x: u32| {
|
||||
let i = ((SIZE / 2 * SIZE + x) * 4) as usize;
|
||||
(pixels[i], pixels[i + 1], pixels[i + 2])
|
||||
};
|
||||
let (r, _, b) = at(SIZE / 5);
|
||||
assert!(
|
||||
r > 200 && b < 40,
|
||||
"the left mask reads red, got r={r} b={b}"
|
||||
);
|
||||
let (r, _, b) = at(SIZE * 4 / 5);
|
||||
assert!(
|
||||
b > 200 && r < 40,
|
||||
"the right mask reads blue, got r={r} b={b}"
|
||||
);
|
||||
let (r, g, b) = at(SIZE / 2);
|
||||
assert!(
|
||||
r < 40 && g < 40 && b < 40,
|
||||
"between them, alpha shows black: ({r}, {g}, {b})"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::{OpId, ParamId};
|
||||
use dr_pipeline::detail::RenderScale;
|
||||
use dr_pipeline::ops::local_contrast::Clarity;
|
||||
use dr_pipeline::{Affects, EditGraph, OutputMode};
|
||||
use dr_types::ColourSpace;
|
||||
@@ -323,6 +324,87 @@ fn a_proxy_and_an_export_agree_about_the_effect() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_the_reduction_threshold_does_not_change_the_picture() {
|
||||
// TRACES: FR-DSP-3 — `docs/technical-debt.md` TD-4, held in pixels.
|
||||
//
|
||||
// Clarity's base is computed on a reduced grid, and how reduced depends on
|
||||
// the viewport: `LocalContrast::reduction` steps 4 -> 2 -> 1 as sigma
|
||||
// falls, because a quarter of a small sigma is not a Gaussian any more.
|
||||
// The whole claim of the optimisation is that this is invisible — that a
|
||||
// base sampled at a quarter is not an approximation of the full-resolution
|
||||
// one but the same band-limited function, sampled where it is still fully
|
||||
// determined.
|
||||
//
|
||||
// Every other test in this file measures one form against itself. This is
|
||||
// the only one that measures the forms against *each other*, and it is the
|
||||
// one that would fail if the reduction were quietly softening the control,
|
||||
// shifting it half a reduced pixel, or blocking the base into 4x4 squares.
|
||||
//
|
||||
// The two sizes are chosen to sit either side of a step-down: sigma is
|
||||
// 1.2% of the shorter edge, so 512 gives 6.1 px and reduces by four, while
|
||||
// 288 gives 3.5 px — under `MIN_REDUCED_SIGMA` once quartered — and
|
||||
// reduces by two. Asserted rather than assumed, because the whole test is
|
||||
// vacuous if both sides land on the same reduction.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
assert_eq!(
|
||||
Clarity::with_amount(100.0).reduction(RenderScale::full((512, 512))),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
Clarity::with_amount(100.0).reduction(RenderScale::full((288, 288))),
|
||||
2
|
||||
);
|
||||
|
||||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||||
|
||||
// Peak excursion in stops and the fraction of the frame it covers — the
|
||||
// same two numbers `a_proxy_and_an_export_agree_about_the_effect` uses,
|
||||
// and for the same reason: both are scale-free, so they are comparable
|
||||
// between two renders of different sizes.
|
||||
let measure = |out: u32| -> (f32, f32) {
|
||||
let mut plain_pass = AdjustPass::new(&ctx);
|
||||
let plain = row(
|
||||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, out),
|
||||
out,
|
||||
out / 2,
|
||||
);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let edited = row(
|
||||
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, out),
|
||||
out,
|
||||
out / 2,
|
||||
);
|
||||
let moved: Vec<f32> = (0..out as usize)
|
||||
.map(|x| stops(edited[x], plain[x]).abs())
|
||||
.collect();
|
||||
let peak = moved.iter().cloned().fold(0.0f32, f32::max);
|
||||
let touched = moved.iter().filter(|m| **m > peak * 0.1).count();
|
||||
(peak, touched as f32 / out as f32)
|
||||
};
|
||||
|
||||
let (quartered_peak, quartered_reach) = measure(512);
|
||||
let (halved_peak, halved_reach) = measure(288);
|
||||
|
||||
// The same tolerances the proxy/export test uses. They are not loose: the
|
||||
// bound this operation guarantees is 0.35 stops, so 0.03 is under a tenth
|
||||
// of the full excursion.
|
||||
assert!(
|
||||
(quartered_peak - halved_peak).abs() < 0.03,
|
||||
"a quarter-scale base gives {quartered_peak:.3} stops and a half-scale \
|
||||
one {halved_peak:.3}; the reduction is supposed to be invisible"
|
||||
);
|
||||
assert!(
|
||||
(quartered_reach - halved_reach).abs() < 0.02,
|
||||
"the effect covers {quartered_reach:.3} of the frame reduced by four \
|
||||
and {halved_reach:.3} reduced by two; the base has changed width"
|
||||
);
|
||||
assert!(
|
||||
quartered_peak > 0.1,
|
||||
"{quartered_peak:.3} stops — two flat images would also agree"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_acts_at_a_finer_scale_than_clarity() {
|
||||
// The whole reason there are two nodes. If the two controls ever reach the
|
||||
@@ -485,10 +567,15 @@ fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() {
|
||||
let mut graph = graph_with(CLARITY, 40.0);
|
||||
render(&mut pass, &graph, &source, 256);
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
// Four at this size: reduce, the two blur halves on the reduced grid, and
|
||||
// the combine. σ is 1.2% of 256 px, so `LocalContrast::reduction` lands on
|
||||
// a half here rather than the quarter a desktop viewport gets — the number
|
||||
// is the viewport's, and what this test is about is that it does not
|
||||
// change while the slider moves.
|
||||
assert_eq!(
|
||||
pass.detail_dispatches(),
|
||||
2,
|
||||
"a separable mask is two passes"
|
||||
4,
|
||||
"a reduced separable mask is four passes"
|
||||
);
|
||||
let pipelines = pass.cached_detail_pipelines();
|
||||
|
||||
@@ -501,17 +588,21 @@ fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() {
|
||||
1,
|
||||
"the fused colour pass re-ran for a change it does not depend on"
|
||||
);
|
||||
assert_eq!(pass.detail_dispatches(), 8);
|
||||
// Four renders of a four-pass chain. The counter accumulates, so this is
|
||||
// the claim that every one of those renders ran the detail stage and only
|
||||
// the detail stage.
|
||||
assert_eq!(pass.detail_dispatches(), 16);
|
||||
assert_eq!(
|
||||
pass.cached_detail_pipelines(),
|
||||
pipelines,
|
||||
"an amount is a uniform, not a shader"
|
||||
);
|
||||
|
||||
// Turning on the other control adds its own pair, and only its own pair.
|
||||
// Turning on the other control adds its own pair, and only its own pair:
|
||||
// 16, plus clarity's four again, plus texture's two.
|
||||
graph.set_param(TEXTURE, AMOUNT, 40.0);
|
||||
render(&mut pass, &graph, &source, 256);
|
||||
assert_eq!(pass.detail_dispatches(), 12);
|
||||
assert_eq!(pass.detail_dispatches(), 22);
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
}
|
||||
|
||||
@@ -536,7 +627,11 @@ fn the_two_controls_stack_without_overwriting_each_other() {
|
||||
graph.set_param(TEXTURE, AMOUNT, 80.0);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let both = row(&render(&mut pass, &graph, &source, SIZE), SIZE, SIZE / 2);
|
||||
assert_eq!(pass.detail_dispatches(), 4);
|
||||
// Clarity's four plus texture's two. Texture is never reduced — its band
|
||||
// is a decade finer than clarity's, so a coarser grid could not hold its
|
||||
// base — and the two operations keeping different pass counts here is that
|
||||
// asymmetry showing through.
|
||||
assert_eq!(pass.detail_dispatches(), 6);
|
||||
|
||||
let edge = (SIZE / 2) as usize;
|
||||
// Both sides of the edge move the way local contrast moves them...
|
||||
|
||||
@@ -51,7 +51,7 @@ fn brightening_stack() -> MaskStack {
|
||||
},
|
||||
);
|
||||
layer.set_param("exposure", ParamId("exposure"), 2.0);
|
||||
layer.feather = 0.0;
|
||||
layer.base_mut().feather = 0.0;
|
||||
stack.push(layer);
|
||||
stack
|
||||
}
|
||||
@@ -81,7 +81,7 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks
|
||||
.render(stack, None, Some(&subjects), PROXY, PROXY)
|
||||
.render(stack, None, Some(&subjects), None, PROXY, PROXY)
|
||||
.expect("rasterise");
|
||||
|
||||
let shader = compose_full(
|
||||
@@ -90,6 +90,7 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
@@ -108,6 +109,7 @@ fn render_unmasked(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust.render(&source, &shader, out, out).expect("render");
|
||||
@@ -222,7 +224,9 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u
|
||||
let source = DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload");
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks.render(stack, None, None, w, h).expect("rasterise");
|
||||
let array = masks
|
||||
.render(stack, None, None, None, w, h)
|
||||
.expect("rasterise");
|
||||
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
@@ -230,6 +234,7 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// TRACES: FR-DEV-10
|
||||
//! A range mask must select by the photograph's values, and by nothing else.
|
||||
//!
|
||||
//! Two properties, and both are silent when they break. A range mask that
|
||||
//! reads the wrong pixels still produces a plausible-looking selection, and one
|
||||
//! that depends on the size it was rasterised at looks right in the develop
|
||||
//! view and wrong only in the export — the one place nobody is watching.
|
||||
//!
|
||||
//! Everything here goes through the real pass. There is no CPU rasterisation of
|
||||
//! a mask to test against and there must not be (ARCH §5.4), so the mask is
|
||||
//! observed the only way it exists: through the adjustment it weights.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::spot::SpotSet;
|
||||
use dr_pipeline::{ops, Framing};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
/// The source and the output are the same size, so a difference between two
|
||||
/// runs can only have come from the mask.
|
||||
const SIZE: u32 = 64;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A frame whose left half is one colour and right half another.
|
||||
///
|
||||
/// Deliberately not a ramp. A range mask's whole job is to divide the picture
|
||||
/// by value, so a source that is already divided by value makes the assertion
|
||||
/// "the mask found the half it was aimed at" rather than "the mask is roughly
|
||||
/// where it should be".
|
||||
fn split(ctx: &GpuContext, left: [u8; 3], right: [u8; 3]) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..SIZE * SIZE)
|
||||
.flat_map(|i| {
|
||||
let c = if i % SIZE < SIZE / 2 { left } else { right };
|
||||
[c[0], c[1], c[2], 255]
|
||||
})
|
||||
.collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
|
||||
}
|
||||
|
||||
fn brightened(source: MaskSource) -> MaskStack {
|
||||
let mut stack = MaskStack::new();
|
||||
let mut layer = MaskLayer::new("m1", source);
|
||||
// Two stops, so "selected" and "not selected" are not a judgement call.
|
||||
layer.set_param("exposure", ParamId("exposure"), 2.0);
|
||||
stack.push(layer);
|
||||
stack
|
||||
}
|
||||
|
||||
/// Render `stack` over `source`, with the mask rasterised at `raster`.
|
||||
///
|
||||
/// `raster` is a parameter because it is the thing that must not matter: the
|
||||
/// develop view and an export ask for the same mask at different sizes, and a
|
||||
/// range that answered differently at each would be a mask that changes when
|
||||
/// the photograph is exported.
|
||||
fn render(ctx: &GpuContext, source: &DemosaicedImage, stack: &MaskStack, raster: u32) -> Vec<u8> {
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks
|
||||
.render(stack, None, None, Some(source), raster, raster)
|
||||
.expect("rasterise");
|
||||
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render_masked(source, &shader, SIZE, SIZE, Some(array))
|
||||
.expect("render");
|
||||
adjust.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
fn red_at(pixels: &[u8], x: u32, y: u32) -> u8 {
|
||||
pixels[((y * SIZE + x) * 4) as usize]
|
||||
}
|
||||
|
||||
/// The centre of each half, away from the seam the mask's own softness
|
||||
/// straddles.
|
||||
fn halves(pixels: &[u8]) -> (u8, u8) {
|
||||
(
|
||||
red_at(pixels, SIZE / 4, SIZE / 2),
|
||||
red_at(pixels, SIZE * 3 / 4, SIZE / 2),
|
||||
)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
#[test]
|
||||
fn a_tone_band_brightens_only_the_half_inside_it() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
// 200 lands near 0.83 on the perceptual scale and 30 near 0.24, so a band
|
||||
// over the upper half contains one and not the other with room to spare.
|
||||
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
|
||||
let stack = brightened(MaskSource::luminance_range(0.5, 1.0, 0.15));
|
||||
|
||||
let pixels = render(&ctx, &source, &stack, SIZE);
|
||||
let (bright, dark) = halves(&pixels);
|
||||
|
||||
assert!(
|
||||
bright > 230,
|
||||
"the bright half is inside the band and should have been lifted, got {bright}"
|
||||
);
|
||||
assert!(
|
||||
dark < 45,
|
||||
"the dark half is outside the band and must be untouched, got {dark}"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// The property the develop view and the export path share. The mask array is
|
||||
/// rasterised at a proxy size in both, but nothing in this pass may *depend*
|
||||
/// on that size — a range is a function of the photograph's values, and those
|
||||
/// do not change when somebody asks for a bigger picture.
|
||||
#[test]
|
||||
fn a_tone_band_is_the_same_mask_at_any_raster_size() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
|
||||
let stack = brightened(MaskSource::luminance_range(0.5, 1.0, 0.15));
|
||||
|
||||
// A quarter of the source and twice it: one mask texel averaging sixteen
|
||||
// source texels, and one source texel spread over four mask texels.
|
||||
let small = halves(&render(&ctx, &source, &stack, SIZE / 4));
|
||||
let large = halves(&render(&ctx, &source, &stack, SIZE * 2));
|
||||
|
||||
// A tolerance rather than equality: the two rasters land their edges on
|
||||
// different grids, and the assertion is that the *selection* is the same,
|
||||
// not that two resamplings of it are bit-identical.
|
||||
assert!(
|
||||
small.0.abs_diff(large.0) <= 4 && small.1.abs_diff(large.1) <= 4,
|
||||
"the same band selected differently at two raster sizes: {small:?} vs {large:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
#[test]
|
||||
fn a_colour_band_follows_hue_rather_than_brightness() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
// Short of saturation on purpose. A fully clipped channel carries no
|
||||
// colour at all, and the pass pulls such a pixel back to neutral before
|
||||
// the band ever sees it — which is correct, and would make this test about
|
||||
// that instead.
|
||||
let source = split(&ctx, [200, 40, 40], [40, 40, 200]);
|
||||
// A narrow arc at red, above the chroma floor that keeps the greys out.
|
||||
let stack = brightened(MaskSource::colour_range(0.0, 0.05, 0.15, 1.0, 0.05));
|
||||
|
||||
let pixels = render(&ctx, &source, &stack, SIZE);
|
||||
let (red, blue) = halves(&pixels);
|
||||
|
||||
assert!(
|
||||
red > 230,
|
||||
"the red half is inside the arc and should have been lifted, got {red}"
|
||||
);
|
||||
assert!(
|
||||
blue < 60,
|
||||
"the blue half is a third of the circle away and must be untouched, got {blue}"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// The greys a hue arc would otherwise sweep up.
|
||||
///
|
||||
/// A nearly-neutral pixel still has a hue — three almost-equal channels round
|
||||
/// to one — so an arc without a chroma floor selects a haze of noise across
|
||||
/// every desaturated part of the picture. It is the failure that looks like the
|
||||
/// mask working badly rather than like a control that is missing.
|
||||
#[test]
|
||||
fn a_colour_band_ignores_the_greys() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
// Both halves neutral, at the two brightnesses the tone test uses, so the
|
||||
// only reason either could be selected is the hue arc reaching them.
|
||||
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
|
||||
let stack = brightened(MaskSource::colour_range(0.0, 0.5, 0.15, 1.0, 0.05));
|
||||
|
||||
let pixels = render(&ctx, &source, &stack, SIZE);
|
||||
let (light, dark) = halves(&pixels);
|
||||
|
||||
assert!(
|
||||
light < 215 && dark < 45,
|
||||
"a grey frame was selected by a colour mask: {light}, {dark}"
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
id: capture_sharpen
|
||||
order: 120
|
||||
|
||||
attributes: [detail]
|
||||
rust: CaptureSharpen
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
id: clarity
|
||||
order: 130
|
||||
|
||||
attributes: [detail]
|
||||
rust: Clarity
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
# TRACES: FR-DEV-12
|
||||
id: colour_grading
|
||||
label: op.colour_grading
|
||||
order: 105
|
||||
attributes: [colour]
|
||||
|
||||
doc: |
|
||||
Colour grading — a hue and a strength for the shadows, the midtones and the
|
||||
highlights, and one more cast over the whole frame.
|
||||
|
||||
The distinction from [`colour_mixer`](colour_mixer.yaml) is the whole reason
|
||||
this exists. The mixer reaches for a hue that is *already in the picture*:
|
||||
it will turn the greens that are there, and it can do nothing at all where
|
||||
there are none. This reaches for a tonal *range* and casts colour into it
|
||||
whether any was there or not — which makes it the only control that can warm
|
||||
an already-neutral highlight, and the only one that can tone a monochrome
|
||||
conversion, since a picture with no hue left in it gives the mixer nothing to
|
||||
find.
|
||||
|
||||
Split toning is the case worth naming: cool shadows against warm highlights,
|
||||
which is what a print toned in two baths did and what most of the looks sold
|
||||
since are built on.
|
||||
|
||||
placement: |
|
||||
After every colour correction, the mixer included. Grading is the closing
|
||||
statement rather than a correction, so it should land on the colours the
|
||||
photographer has already settled rather than be argued with by a control
|
||||
further down the chain. Before the detail stage, which is where every
|
||||
neighbourhood operation runs whatever this file says.
|
||||
|
||||
params:
|
||||
shadow_hue:
|
||||
label: param.shadow_hue
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 360
|
||||
default: 0
|
||||
unit: none
|
||||
scale: linear
|
||||
precision: 0
|
||||
doc: |
|
||||
Degrees around the hue wheel, red at zero — the same units the straighten
|
||||
control reports, and for the same reason: a photographer reading "210"
|
||||
knows where on the wheel that is, where a normalised 0..1 has to be
|
||||
translated first.
|
||||
|
||||
The two ends of the travel are the same colour. That is a fact about
|
||||
hue rather than a defect, and it is what a wheel makes obvious and a
|
||||
slider cannot — one of the reasons `presentation:` below asks for one.
|
||||
shadow_strength:
|
||||
label: param.shadow_strength
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 100
|
||||
default: 0
|
||||
unit: percent
|
||||
scale: linear
|
||||
precision: 0
|
||||
doc: |
|
||||
How far from neutral, which is the wheel's radius. It does not go
|
||||
negative: a negative radius is the hue opposite, which the hue control
|
||||
already says, and two ways to spell one colour is how a preset comes
|
||||
back looking like its own complement.
|
||||
|
||||
midtone_hue:
|
||||
label: param.midtone_hue
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 360
|
||||
default: 0
|
||||
unit: none
|
||||
scale: linear
|
||||
precision: 0
|
||||
midtone_strength:
|
||||
label: param.midtone_strength
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 100
|
||||
default: 0
|
||||
unit: percent
|
||||
scale: linear
|
||||
precision: 0
|
||||
doc: |
|
||||
The midtones are where skin lives, so this is the range that goes wrong
|
||||
first and the one most often left at zero. It is offered anyway because
|
||||
a grade that can only reach the ends of the scale cannot answer a cast
|
||||
that sits in the middle of it.
|
||||
|
||||
highlight_hue:
|
||||
label: param.highlight_hue
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 360
|
||||
default: 0
|
||||
unit: none
|
||||
scale: linear
|
||||
precision: 0
|
||||
highlight_strength:
|
||||
label: param.highlight_strength
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 100
|
||||
default: 0
|
||||
unit: percent
|
||||
scale: linear
|
||||
precision: 0
|
||||
|
||||
global_hue:
|
||||
label: param.global_hue
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 360
|
||||
default: 0
|
||||
unit: none
|
||||
scale: linear
|
||||
precision: 0
|
||||
global_strength:
|
||||
label: param.global_strength
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 100
|
||||
default: 0
|
||||
unit: percent
|
||||
scale: linear
|
||||
precision: 0
|
||||
doc: |
|
||||
The cast that carries no tonal weight — it applies equally at every
|
||||
brightness. Without it, a photographer wanting one colour everywhere and
|
||||
a second in one range has to set all three ranges to the first hue, and
|
||||
can then no longer move any one of them without disturbing the other
|
||||
two.
|
||||
|
||||
# The neutral is *no strength anywhere*, not "nothing has been touched".
|
||||
#
|
||||
# A hue with no strength behind it is a direction with no distance: the
|
||||
# picture is identical, and every uniform below is zero. Under the default
|
||||
# rule, nudging a hue while the strength sat at zero would make the node
|
||||
# active, and it would then cost a uniform block, a helper and a block in the
|
||||
# fused shader for a change nobody can see. Strengths cannot go negative, so
|
||||
# their sum is zero exactly when every one of them is.
|
||||
active: shadow_strength + midtone_strength + highlight_strength + global_strength
|
||||
|
||||
uniforms:
|
||||
shadow_angle:
|
||||
value: shadow_hue * 0.017453292
|
||||
doc: |
|
||||
Degrees to radians, here rather than in the shader. The wheel is a slider
|
||||
on the photographer's side and a cosine on the GPU's, and the conversion
|
||||
belongs at the seam between them — the fragment then has one meaning for
|
||||
an angle rather than two.
|
||||
shadow_amount:
|
||||
value: shadow_strength / 100 * 0.5
|
||||
doc: |
|
||||
Full strength is half a stop of shift on the leading channel, the same
|
||||
ceiling white balance holds itself to and for the same reason: a colour
|
||||
control that can blow a channel on its own is a trap. Four of these can
|
||||
stack, so the honest worst case is a stop, which takes both a maximum
|
||||
global cast and a maximum range on top of it.
|
||||
midtone_angle: midtone_hue * 0.017453292
|
||||
midtone_amount: midtone_strength / 100 * 0.5
|
||||
highlight_angle: highlight_hue * 0.017453292
|
||||
highlight_amount: highlight_strength / 100 * 0.5
|
||||
global_angle: global_hue * 0.017453292
|
||||
global_amount: global_strength / 100 * 0.5
|
||||
|
||||
helpers: [luminance, tone_position]
|
||||
|
||||
define:
|
||||
hue_cast: |
|
||||
// A per-channel gain carrying a hue, centred on no change at all.
|
||||
//
|
||||
// The three channels are cosines 120 degrees apart, which is the hue wheel
|
||||
// written directly as an RGB direction — a round trip through HSV would
|
||||
// buy nothing here and would have to decide what to do with a colour that
|
||||
// has no hue. Their sum is zero at every angle, so exp2 turns them into
|
||||
// three gains whose product is exactly one: the cast tilts the balance
|
||||
// without moving the overall level. That property is what keeps grading
|
||||
// from doubling as an exposure control, which is the failure that has the
|
||||
// photographer chasing brightness with a colour slider.
|
||||
fn hue_cast(angle: f32, amount: f32) -> vec3<f32> {
|
||||
let tilt = vec3<f32>(cos(angle), cos(angle - 2.0943951), cos(angle + 2.0943951));
|
||||
return exp2(tilt * amount);
|
||||
}
|
||||
|
||||
wgsl: |
|
||||
let pos = tone_position(luminance(c));
|
||||
|
||||
// Three weights that partition the tonal scale: at every luminance they sum
|
||||
// to exactly one. The two ends use the same 0.5 midpoint as
|
||||
// highlights_shadows, so "shadows" means the same range of the picture in
|
||||
// both places, and the midtones are defined as whatever the ends leave.
|
||||
//
|
||||
// The partition is what makes an equal setting on all three identical to
|
||||
// the global cast. Weights that overlapped would make the join between two
|
||||
// ranges stronger than either of them, so a split tone would darken or
|
||||
// colour its own midtones as a side effect of the two settings meeting —
|
||||
// an interaction with no control over it.
|
||||
let lo_w = 1.0 - smoothstep(0.0, 0.5, pos);
|
||||
let hi_w = smoothstep(0.5, 1.0, pos);
|
||||
let mid_w = 1.0 - lo_w - hi_w;
|
||||
|
||||
// The ranges compose by multiplication rather than by mixing, because a
|
||||
// product of luminance-neutral triples is another one — so three casts and
|
||||
// a global still leave the tonal relationships the tone controls
|
||||
// established. The global cast takes no weight: it is the whole frame.
|
||||
c = c * hue_cast(shadow_angle, shadow_amount * lo_w)
|
||||
* hue_cast(midtone_angle, midtone_amount * mid_w)
|
||||
* hue_cast(highlight_angle, highlight_amount * hi_w)
|
||||
* hue_cast(global_angle, global_amount);
|
||||
|
||||
presentation:
|
||||
# Four wheels, each a hue and a distance from the centre. Named in pairs
|
||||
# because that is the order a wheel wants them; a frontend that draws none
|
||||
# of these renders eight ordinary sliders and the edit is unchanged, which
|
||||
# is the state this ships in.
|
||||
#
|
||||
# That fallback is why each parameter carries its range in its own name
|
||||
# instead of leaning on the wheel to say which one it belongs to. A flat
|
||||
# list is what the panel produces today, and four sliders all called "Hue"
|
||||
# would be four controls nobody can tell apart.
|
||||
widgets: [colour_wheel]
|
||||
demand:
|
||||
two_dimensional: true
|
||||
# A hue a few degrees off is a slightly different warm, not a wrong
|
||||
# answer, so this is usable with a fingertip. Saying otherwise would take
|
||||
# the wheel away from touch to protect an accuracy nobody needs from it.
|
||||
precise_pointing: false
|
||||
params:
|
||||
- shadow_hue
|
||||
- shadow_strength
|
||||
- midtone_hue
|
||||
- midtone_strength
|
||||
- highlight_hue
|
||||
- highlight_strength
|
||||
- global_hue
|
||||
- global_strength
|
||||
|
||||
tests:
|
||||
- name: it_starts_neutral
|
||||
why: |
|
||||
Neutral means absent: at defaults the node must contribute no code, no
|
||||
uniform and no branch to the fused shader. Every angle and amount being
|
||||
zero is the arithmetic half of that; `expect_active` is the half that
|
||||
keeps it out of the shader at all.
|
||||
expect:
|
||||
shadow_angle: 0.0
|
||||
shadow_amount: 0.0
|
||||
midtone_amount: 0.0
|
||||
highlight_amount: 0.0
|
||||
global_amount: 0.0
|
||||
expect_active: false
|
||||
|
||||
- name: a_hue_with_no_strength_is_still_neutral
|
||||
why: |
|
||||
What `active:` buys. A hue is a direction and a strength is the distance
|
||||
travelled along it, so a hue moved on its own changes nothing — but the
|
||||
default rule ("some parameter has moved") would call the node active and
|
||||
make every fused shader carry it for nothing.
|
||||
set: { shadow_hue: 240, global_hue: 40 }
|
||||
expect_active: false
|
||||
|
||||
- name: strength_alone_reaches_the_shader
|
||||
why: |
|
||||
The complement, and the reason the neutral cannot simply be "nothing
|
||||
touched": red is hue zero, so a grade toward red never moves a hue
|
||||
slider off its default and would otherwise never be applied.
|
||||
set: { shadow_strength: 20 }
|
||||
expect_active: true
|
||||
|
||||
- name: hue_is_converted_to_radians
|
||||
why: |
|
||||
The shader's cosines take radians and this uniform is the only place the
|
||||
conversion happens. Degrees arriving unconverted would be an angle 57
|
||||
times too large — it would wrap the wheel several times and land on a
|
||||
colour with no relation to the one under the pointer, which reads as the
|
||||
control being broken rather than as a missing constant.
|
||||
set: { shadow_hue: 180 }
|
||||
expect: { shadow_angle: 3.1415926 }
|
||||
|
||||
- name: full_strength_is_half_a_stop
|
||||
why: |
|
||||
The ceiling on one range's contribution, matching white balance's. A
|
||||
grade that could take a channel to clipping by itself would make the
|
||||
strength slider unusable over its top third.
|
||||
set: { highlight_strength: 100 }
|
||||
expect: { highlight_amount: 0.5 }
|
||||
|
||||
- name: strength_maps_linearly_onto_the_shift
|
||||
why: |
|
||||
Half the slider must be half the shift. A curve here would make the
|
||||
wheel's radius mean something different at each distance from the
|
||||
centre, and a wheel is read as a distance.
|
||||
set: { midtone_strength: 50 }
|
||||
expect: { midtone_amount: 0.25 }
|
||||
|
||||
- name: the_three_ranges_partition_the_tones
|
||||
why: |
|
||||
The midtone weight is defined as what the other two leave, so the three
|
||||
sum to one at every luminance. Computed independently they would overlap
|
||||
at the joins, and a split tone would then colour its own midtones as a
|
||||
side effect of the shadow and highlight settings meeting.
|
||||
expect_wgsl: ["let mid_w = 1.0 - lo_w - hi_w;"]
|
||||
|
||||
- name: the_global_cast_takes_no_tonal_weight
|
||||
why: |
|
||||
It is the one that means "everywhere". Weighted like the others it would
|
||||
land mostly on the midtones, since that weight is the largest across the
|
||||
range an ordinary photograph occupies, and "global" would quietly become
|
||||
a fourth midtone control.
|
||||
expect_wgsl: ["hue_cast(global_angle, global_amount)"]
|
||||
|
||||
- name: a_cast_does_not_change_the_level
|
||||
why: |
|
||||
The cosines sum to zero at every angle, so the three gains multiply to
|
||||
one and a cast tilts the balance without lifting or dropping the
|
||||
picture. Built any other way — a clamp, an added tint, an HSV round trip
|
||||
— a strong grade would double as an exposure change, and the
|
||||
photographer would correct it with a control that cannot reach it.
|
||||
expect_helper_wgsl:
|
||||
hue_cast: ["return exp2(tilt * amount);"]
|
||||
@@ -1,6 +1,5 @@
|
||||
id: colour_mixer
|
||||
order: 100
|
||||
attributes: [colour]
|
||||
rust: ColourMixer
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# A hand-written node, and a neighbourhood one: the veil it removes is measured
|
||||
# from the pixels around the one it is writing, so it runs in the detail stage
|
||||
# rather than as a fragment in the fused pass. See `../src/detail.rs` for why
|
||||
# that stage exists and `README.md`'s "Nodes that read their neighbours" for
|
||||
# the contract.
|
||||
#
|
||||
# As with every `rust:` node, its descriptor, parameters and behaviour come
|
||||
# from the type; this file exists so that `ops/` remains the one place the
|
||||
# pipeline's order is written down.
|
||||
id: dehaze
|
||||
order: 125
|
||||
|
||||
rust: Dehaze
|
||||
|
||||
why_rust: |
|
||||
Haze is defined by what the pixels around a pixel are doing, and the schema
|
||||
above describes a function of one colour — `wgsl:` is handed `c` and no
|
||||
coordinate, which is the wall the detail stage exists on the other side of.
|
||||
It declares `Affects::Detail` and returns five `DetailPass`es: four that
|
||||
erode the dark channel into a per-pixel veil, and one that inverts the
|
||||
scattering model with the transmission that veil implies.
|
||||
|
||||
Nor is it four facts. The erosion is split into two exact stages per axis so
|
||||
that a patch 1% of the frame wide costs its square root in taps, and the
|
||||
split is arithmetic over the render size that has to be recomputed every
|
||||
frame. Stretching this schema to express it would produce a worse language
|
||||
than Rust, aimed at one caller.
|
||||
|
||||
placement: |
|
||||
First among the compositional detail nodes: after noise reduction and
|
||||
capture sharpening, before clarity and texture.
|
||||
|
||||
After noise reduction because dehaze divides by a transmission below one, so
|
||||
it amplifies whatever noise is in the veiled distance by exactly the factor
|
||||
it recovers the contrast by. Running it first would ask the denoiser to
|
||||
remove grain that dehaze had already multiplied — the same argument clarity
|
||||
records, and stronger here, because the amplification is largest in the low-
|
||||
contrast regions where noise is most visible.
|
||||
|
||||
Before clarity and texture, and for the reason that orders those two against
|
||||
each other: coarse before fine. Dehaze acts on the widest structure in the
|
||||
frame, the veil that varies with distance, and clarity's base should be
|
||||
computed on the picture as the veil has left it rather than on a modelling
|
||||
that is about to be divided out.
|
||||
|
||||
What this cannot honour, and it is worth writing down rather than leaving to
|
||||
be rediscovered: dehaze shifts colour. It subtracts a grey term and rescales,
|
||||
so it changes saturation everywhere the veil is thick, and the colour
|
||||
controls would ideally be correcting the picture that leaves here. They
|
||||
cannot be. The detail stage runs as a *group* after every point operation,
|
||||
because a neighbourhood pass is a separate dispatch reading a texture the
|
||||
fused pass has already finished writing — so an `order:` placing this node
|
||||
ahead of `vibrance` or `colour_mixer` would be a lie the chain cannot tell.
|
||||
Interleaving the two would mean splitting the fused pass in half around this
|
||||
one, which costs a second full-frame dispatch and intermediate on every edit
|
||||
in the catalogue, whether or not it uses dehaze at all.
|
||||
@@ -1,6 +1,9 @@
|
||||
id: film_sim
|
||||
order: 25
|
||||
attributes: [tone, colour]
|
||||
# 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
|
||||
# in `../src/ops/film_sim.rs`.
|
||||
rust: FilmSim
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
id: noise_reduction
|
||||
order: 110
|
||||
|
||||
attributes: [detail]
|
||||
rust: NoiseReduction
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
id: texture
|
||||
order: 140
|
||||
|
||||
attributes: [detail]
|
||||
rust: Texture
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -12,7 +12,6 @@ order: 60
|
||||
# Both, and this is the case the plural exists for: the RGB curve is
|
||||
# tonal and the per-channel curves are chromatic. Filing it under one
|
||||
# would hide it from half the people looking for it.
|
||||
attributes: [tone, colour]
|
||||
rust: ToneCurve
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# A hand-written node, and the first thing in `Attribute::Optics`.
|
||||
#
|
||||
# Written, tested and unreferenced until now: `src/ops/vignetting.rs` has
|
||||
# existed with a full descriptor and a working polynomial, and without an entry
|
||||
# here it was never in `chain()` — so it reached no photograph and no panel.
|
||||
# This file is the whole of what was missing, which is the point of `ops/`
|
||||
# being the one place the pipeline's order is written down.
|
||||
id: vignetting
|
||||
order: 5
|
||||
|
||||
rust: Vignetting
|
||||
|
||||
why_rust: |
|
||||
It carries a lens profile's `pa` coefficients, which are not parameters: they
|
||||
come from the body and lens that took the photograph, not from the
|
||||
photographer, and no `uniforms:` expression could produce them. The one
|
||||
parameter that *is* theirs — the manual trim — is composed with `k1` in Rust,
|
||||
because the profile and the trim have to reach the shader as a single
|
||||
polynomial rather than as two the fragment would have to add up.
|
||||
|
||||
placement: |
|
||||
First in the chain, ahead of white balance and exposure.
|
||||
|
||||
Vignetting is what the lens did to the light before the sensor measured it,
|
||||
so undoing it belongs with reading the file rather than with editing the
|
||||
picture — everything downstream is then working on the frame the lens would
|
||||
have delivered had it been even.
|
||||
|
||||
The ordering is load-bearing rather than tidy. Correcting a corner means
|
||||
*dividing* by an attenuation below one, which pushes those pixels up: a fast
|
||||
prime wide open needs about two stops there. Run after the tonal stages, that
|
||||
recovery happens once the highlights have already been rolled off and
|
||||
clipped, so it lifts values that no longer have anywhere to go and the
|
||||
corners posterise instead of brightening. Run here, the headroom to hold them
|
||||
still exists (ARCH §5.2).
|
||||
|
||||
Before distortion and chromatic aberration in intent, though those are
|
||||
`lens::Warp`s rather than nodes and compose ahead of the fetch, so no `order:`
|
||||
relates the two.
|
||||
@@ -32,6 +32,33 @@ params:
|
||||
label: param.tint
|
||||
kind: amount
|
||||
|
||||
# An eyedropper, where the frontend has a canvas to hang one on.
|
||||
#
|
||||
# Sampling a neutral is the first move of the global tonal pass — every colour
|
||||
# judgement afterwards is measured against where the grey was put — and it is
|
||||
# a thing you do by pointing at the photograph, not by guessing at two sliders
|
||||
# until a wall stops looking green.
|
||||
#
|
||||
# A *hint*, on the usual terms: the two parameters below stay ordinary
|
||||
# addressable scalars, and a frontend with nowhere to host a sampler renders
|
||||
# them as the sliders they already were. This one is additive rather than a
|
||||
# replacement — the picker writes temperature and tint and the photographer
|
||||
# still nudges them afterwards — which is a difference the frontend draws for
|
||||
# itself; nothing here has to say it.
|
||||
#
|
||||
# The order matters and is the widget kind's own contract: the first parameter
|
||||
# trades red against blue, the second green against magenta.
|
||||
presentation:
|
||||
widgets: [white_point]
|
||||
params: [temperature, tint]
|
||||
demand:
|
||||
# A neutral is a point on the picture, so both axes at once.
|
||||
two_dimensional: true
|
||||
# Deliberately false. A grey card, a cloud, a white wall — the things
|
||||
# worth sampling are large, and FR-UI-7 grows the hit region to the
|
||||
# modality in any case, so a thumb is as workable as a mouse.
|
||||
precise_pointing: false
|
||||
|
||||
# Temperature trades red against blue; tint trades green against magenta.
|
||||
# Both are scaled so the full range is a strong but not destructive
|
||||
# correction: ±0.5 in log2 at the extremes — half a stop of channel shift,
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
//! TRACES: FR-DEV-3 | FR-CAT-8
|
||||
//! A model's mask, in a form a sidecar can carry.
|
||||
//!
|
||||
//! [`MaskSource::Subject`](crate::mask::MaskSource::Subject) and
|
||||
//! [`MaskSource::Category`](crate::mask::MaskSource::Category) name what they
|
||||
//! cover — an index, a class, a category — and naming is enough only while
|
||||
//! the run that produced the numbers is still in memory. Reopen the
|
||||
//! photograph, or export it from the grid, and there is no run: the layer
|
||||
//! resolves to nothing and the local adjustment silently is not applied. That
|
||||
//! is what this module exists to stop. It stores the *pixels* the layer
|
||||
//! covered, beside the identity rather than instead of it, so a second model
|
||||
//! pass is an optimisation rather than a precondition.
|
||||
//!
|
||||
//! # Two levels, and why that is not a compromise
|
||||
//!
|
||||
//! The model hands out a byte per pixel, but nothing downstream reads more
|
||||
//! than one bit of it. A subject or category layer becomes a mask by way of
|
||||
//! an exact Euclidean distance field, and that field is measured from
|
||||
//! `coverage >= threshold` — the soft shoulder the model produced is
|
||||
//! discarded on the first line of the transform. Everything soft about the
|
||||
//! rendered edge comes afterwards, from [`MaskLayer::feather`] and
|
||||
//! [`MaskLayer::falloff`], which are read off the *distance*.
|
||||
//!
|
||||
//! [`MaskLayer::feather`]: crate::mask::MaskLayer::feather
|
||||
//! [`MaskLayer::falloff`]: crate::mask::MaskLayer::falloff
|
||||
//!
|
||||
//! So [`RENDERED_LEVELS`] is two, and the result is not an approximation of
|
||||
//! what the model said: it is exactly the part of what the model said that
|
||||
//! reaches a pixel. Storing all 256 levels would be storing 1.7 MB of
|
||||
//! interpolation to reconstruct a predicate — and it would not even compress,
|
||||
//! because a model mask is a bilinear upsample of a coarse grid and therefore
|
||||
//! has almost no two adjacent bytes alike. Measured on a simulated sky and a
|
||||
//! simulated figure at 1600x1067, against 1.71 MB raw: **4.0 kB and 6.5 kB at
|
||||
//! two levels**, 46 kB and 76 kB at sixteen, and 835 kB and 1.43 MB at all
|
||||
//! 256 — the last two being over [`MAX_PAYLOAD`] and therefore not storable
|
||||
//! at all.
|
||||
//!
|
||||
//! [`Coverage::encode`] still takes the level count, and it is written into
|
||||
//! the line, so a later build that finds a use for the shoulder can write
|
||||
//! sixteen levels and this one will read them back correctly rather than
|
||||
//! misreading a stream of lengths as pairs.
|
||||
//!
|
||||
//! # One line, because a node is a line
|
||||
//!
|
||||
//! FR-NC-9 merges the edit graph per node and [`Version::merge`] does that by
|
||||
//! comparing lines, so a stored mask is one `coverage = ...` line inside the
|
||||
//! layer's block — the same shape the `regions = ...` line already had, for
|
||||
//! the same reason. Splitting it over many lines would put a single opaque
|
||||
//! blob into the merge as several independently-winnable keys, which is a
|
||||
//! merge that can produce a mask neither device ever had.
|
||||
//!
|
||||
//! [`Version::merge`]: crate::sidecar::Version::merge
|
||||
//!
|
||||
//! # Hand-rolled, and it has to be
|
||||
//!
|
||||
//! `dr-pipeline` links nothing (ARCH §6.5a), which is what lets the descriptor
|
||||
//! and codegen logic be tested without a device. That rules out `flate2`,
|
||||
//! `serde` and `base64`, so the run-length coder and the digits below are
|
||||
//! written out. It is forty lines, and the alternative was a dependency in the
|
||||
//! one crate that has none.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// The number of coverage levels the renderer can actually tell apart.
|
||||
///
|
||||
/// Two. See the module header: the distance field is built from a threshold,
|
||||
/// so a second level is the whole of the information that survives into a
|
||||
/// rendered frame. Named rather than written as `2` at the call site because
|
||||
/// the number is a *claim about the render path*, and a claim wants somewhere
|
||||
/// to be explained.
|
||||
pub const RENDERED_LEVELS: u32 = 2;
|
||||
|
||||
/// The most encoded payload a stored coverage may take, in bytes.
|
||||
///
|
||||
/// Sidecars sync over WebDAV and are read whole by every device that opens the
|
||||
/// photograph, so a mask that will not compress must not be allowed to make
|
||||
/// the file enormous — it is a *cache* of something a model can produce again,
|
||||
/// and no cache is worth a megabyte of sync traffic per layer.
|
||||
///
|
||||
/// A realistic mask lands between 4 and 7 kB, so this is roughly ten times the
|
||||
/// worst case anyone has measured: enough for genuinely awkward subjects —
|
||||
/// foliage, chain-link, hair against a busy background — and far short of a
|
||||
/// file a human cannot open. Past it [`Coverage::encode`] returns `None`, the
|
||||
/// layer stores nothing, and the behaviour falls back to what it was before
|
||||
/// this module existed: the mask needs the model run. Refusing rather than
|
||||
/// truncating, because half a mask renders as a *wrong* mask, which is the
|
||||
/// failure that announces itself to nobody.
|
||||
pub const MAX_PAYLOAD: usize = 64 * 1024;
|
||||
|
||||
/// The most pixels a coverage read from a file may claim.
|
||||
///
|
||||
/// A file is not trusted. The proxy a mask is built at is bounded by the long
|
||||
/// edge the segmentation runs on — under three megapixels — so this is ample
|
||||
/// headroom, and it is here so that `width * height` from a corrupt line
|
||||
/// cannot ask for an allocation measured in gigabytes.
|
||||
pub const MAX_PIXELS: usize = 16 << 20;
|
||||
|
||||
/// Digits of the payload's base-64 varint. Ordered so the alphabet is stable
|
||||
/// and contains nothing a line-oriented format would have to escape — no
|
||||
/// whitespace, no `=`, no `#`.
|
||||
const DIGITS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
|
||||
/// Bit set in a digit that means "another digit follows".
|
||||
const CONTINUE: u32 = 32;
|
||||
|
||||
/// Value bits carried by one digit.
|
||||
const CHUNK: u32 = 5;
|
||||
|
||||
const fn reverse_digits() -> [u8; 256] {
|
||||
let mut table = [255u8; 256];
|
||||
let mut i = 0;
|
||||
while i < 64 {
|
||||
table[DIGITS[i] as usize] = i as u8;
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
/// Digit value by byte, `255` for anything that is not a digit.
|
||||
const REVERSE: [u8; 256] = reverse_digits();
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// One layer's pixel coverage, held in the form it is stored in.
|
||||
///
|
||||
/// **Encoded, not expanded.** The struct owns the payload text rather than the
|
||||
/// 1.7 MB raster it decodes to, because that raster is wanted exactly once —
|
||||
/// when a distance field is built — and is held by nothing afterwards. Keeping
|
||||
/// it expanded would put a megabyte and a half per layer into every undo
|
||||
/// snapshot the history stack holds, to save a decode that costs far less than
|
||||
/// the exact Euclidean transform immediately following it.
|
||||
///
|
||||
/// It also makes the round trip byte-identical for free: a coverage read from
|
||||
/// a file and written back is the same characters, which is the property that
|
||||
/// lets a caller skip an upload by comparing content
|
||||
/// ([`Sidecar`](crate::Sidecar)).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Coverage {
|
||||
width: usize,
|
||||
height: usize,
|
||||
levels: u32,
|
||||
payload: String,
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
/// Encode one byte-per-pixel coverage, or `None` where it will not fit.
|
||||
///
|
||||
/// `levels` is what the bytes are quantised to on the way in; see
|
||||
/// [`RENDERED_LEVELS`] for why two is the honest answer for a mask that is
|
||||
/// going to be thresholded.
|
||||
///
|
||||
/// `None` for a mismatched length, a nonsensical level count, or a payload
|
||||
/// over [`MAX_PAYLOAD`] — all three meaning "do not store this", which the
|
||||
/// caller can act on identically because the fallback is the same in every
|
||||
/// case.
|
||||
pub fn encode(values: &[u8], width: usize, height: usize, levels: u32) -> Option<Self> {
|
||||
if width == 0 || height == 0 || values.len() != width.checked_mul(height)? {
|
||||
return None;
|
||||
}
|
||||
if !(2..=256).contains(&levels) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let top = levels - 1;
|
||||
let mut payload = String::new();
|
||||
let mut index = 0;
|
||||
while index < values.len() {
|
||||
let level = quantise(values[index], top);
|
||||
let mut run = 1;
|
||||
while index + run < values.len() && quantise(values[index + run], top) == level {
|
||||
run += 1;
|
||||
}
|
||||
push_varint(&mut payload, level as u64);
|
||||
push_varint(&mut payload, run as u64);
|
||||
index += run;
|
||||
|
||||
// Checked inside the loop rather than after it: the pathological
|
||||
// input is one that runs to a payload larger than the raster, and
|
||||
// building the whole of that before deciding to throw it away is
|
||||
// the allocation this bound exists to prevent.
|
||||
if payload.len() > MAX_PAYLOAD {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
width,
|
||||
height,
|
||||
levels,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the value of a sidecar `coverage` line.
|
||||
///
|
||||
/// `None` for anything that does not describe a complete raster. A
|
||||
/// coverage is a cache, so refusing it costs a model run; accepting a
|
||||
/// partial one costs a photograph rendered with a mask that is wrong in a
|
||||
/// way nothing reports.
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
let mut tokens = value.split_whitespace();
|
||||
let width: usize = tokens.next()?.parse().ok()?;
|
||||
let height: usize = tokens.next()?.parse().ok()?;
|
||||
let levels: u32 = tokens.next()?.parse().ok()?;
|
||||
let payload = tokens.next()?;
|
||||
|
||||
let pixels = width.checked_mul(height)?;
|
||||
if pixels == 0 || pixels > MAX_PIXELS || !(2..=256).contains(&levels) {
|
||||
return None;
|
||||
}
|
||||
if payload.len() > MAX_PAYLOAD {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Measured rather than expanded. The payload has to be checked here —
|
||||
// failing at the point of use would put the error in the renderer,
|
||||
// where there is no longer a file to name in the message — but a
|
||||
// library scan parses thousands of sidecars, and materialising a
|
||||
// megabyte and a half per layer to establish that the arithmetic adds
|
||||
// up would make opening the grid pay for masks nobody is rendering.
|
||||
if measure(payload, levels)? != pixels {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
width,
|
||||
height,
|
||||
levels,
|
||||
payload: payload.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The value to write after `coverage = `.
|
||||
pub fn to_text(&self) -> String {
|
||||
format!(
|
||||
"{} {} {} {}",
|
||||
self.width, self.height, self.levels, self.payload
|
||||
)
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.height
|
||||
}
|
||||
|
||||
pub fn levels(&self) -> u32 {
|
||||
self.levels
|
||||
}
|
||||
|
||||
/// The encoded payload's length in bytes — what this costs a sidecar.
|
||||
pub fn encoded_len(&self) -> usize {
|
||||
self.payload.len()
|
||||
}
|
||||
|
||||
/// Expand back to one byte per pixel, at the size it was stored at.
|
||||
pub fn decode(&self) -> Vec<u8> {
|
||||
decode(&self.payload, self.width * self.height, self.levels)
|
||||
.expect("a Coverage only exists once its payload has been decoded once")
|
||||
}
|
||||
|
||||
/// Expand to `width` x `height`, resampling if that is not the size it was
|
||||
/// stored at.
|
||||
///
|
||||
/// Nearest neighbour, and deliberately: the values are a threshold's two
|
||||
/// sides, so interpolating between them would invent coverage levels that
|
||||
/// mean nothing and move the boundary by a rounding rule rather than by a
|
||||
/// measurement. The resample only runs at all when a build reads a mask
|
||||
/// stored against a different proxy edge — in the ordinary case the sizes
|
||||
/// match and this is the decode.
|
||||
pub fn decode_at(&self, width: usize, height: usize) -> Vec<u8> {
|
||||
let source = self.decode();
|
||||
if (width, height) == (self.width, self.height) {
|
||||
return source;
|
||||
}
|
||||
if width == 0 || height == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut out = vec![0u8; width * height];
|
||||
for y in 0..height {
|
||||
let sy = ((y * self.height) / height).min(self.height - 1);
|
||||
let row = sy * self.width;
|
||||
for x in 0..width {
|
||||
let sx = ((x * self.width) / width).min(self.width - 1);
|
||||
out[y * width + x] = source[row + sx];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// One byte to its level, rounding to nearest.
|
||||
fn quantise(value: u8, top: u32) -> u32 {
|
||||
((value as u32 * top) + 127) / 255
|
||||
}
|
||||
|
||||
/// One level back to a byte, so that the top level is exactly 255.
|
||||
fn dequantise(level: u32, top: u32) -> u8 {
|
||||
(((level * 255) + top / 2) / top).min(255) as u8
|
||||
}
|
||||
|
||||
/// Little-endian base-64 varint: five value bits per digit, the sixth saying
|
||||
/// whether another follows.
|
||||
fn push_varint(out: &mut String, mut value: u64) {
|
||||
loop {
|
||||
let chunk = (value & (CONTINUE - 1) as u64) as u32;
|
||||
value >>= CHUNK;
|
||||
let more = if value != 0 { CONTINUE } else { 0 };
|
||||
let _ = out.write_char(DIGITS[(chunk | more) as usize] as char);
|
||||
if value == 0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one varint, returning it and how many digits it took.
|
||||
fn read_varint(bytes: &[u8]) -> Option<(u64, usize)> {
|
||||
let mut value: u64 = 0;
|
||||
let mut shift = 0;
|
||||
for (taken, &byte) in bytes.iter().enumerate() {
|
||||
let digit = REVERSE[byte as usize];
|
||||
if digit == 255 {
|
||||
return None;
|
||||
}
|
||||
// A run cannot exceed MAX_PIXELS and a level cannot exceed 255, so a
|
||||
// varint past this width is a corrupt line rather than a large number.
|
||||
if shift >= 64 {
|
||||
return None;
|
||||
}
|
||||
value |= ((digit as u64) & (CONTINUE - 1) as u64) << shift;
|
||||
if digit as u32 & CONTINUE == 0 {
|
||||
return Some((value, taken + 1));
|
||||
}
|
||||
shift += CHUNK;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Walk a payload's runs, handing each `(level, length)` to `take`.
|
||||
///
|
||||
/// Returns the total length, or `None` for a payload that is not well formed:
|
||||
/// a digit that is not one, a truncated varint, a zero-length run, or a level
|
||||
/// the declared count does not contain.
|
||||
fn walk(payload: &str, levels: u32, mut take: impl FnMut(u32, usize)) -> Option<usize> {
|
||||
let top = levels - 1;
|
||||
let bytes = payload.as_bytes();
|
||||
let mut total: usize = 0;
|
||||
let mut at = 0;
|
||||
|
||||
while at < bytes.len() {
|
||||
let (level, used) = read_varint(&bytes[at..])?;
|
||||
at += used;
|
||||
let (run, used) = read_varint(&bytes[at..])?;
|
||||
at += used;
|
||||
|
||||
let level = u32::try_from(level).ok()?;
|
||||
if level > top {
|
||||
return None;
|
||||
}
|
||||
let run = usize::try_from(run).ok()?;
|
||||
// A zero-length run is not a shorter way of saying anything, so it is
|
||||
// a corrupt line rather than a run to skip — and left in, two of them
|
||||
// would encode the same raster two ways and break the byte-identical
|
||||
// round trip the sidecar relies on.
|
||||
if run == 0 {
|
||||
return None;
|
||||
}
|
||||
total = total.checked_add(run)?;
|
||||
if total > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
take(level, run);
|
||||
}
|
||||
|
||||
Some(total)
|
||||
}
|
||||
|
||||
/// How many pixels a payload covers, without building any of them.
|
||||
fn measure(payload: &str, levels: u32) -> Option<usize> {
|
||||
walk(payload, levels, |_, _| {})
|
||||
}
|
||||
|
||||
/// Expand a payload to `pixels` bytes, or `None` if it does not describe
|
||||
/// exactly that many.
|
||||
fn decode(payload: &str, pixels: usize, levels: u32) -> Option<Vec<u8>> {
|
||||
let top = levels - 1;
|
||||
let mut out = Vec::with_capacity(pixels);
|
||||
let total = walk(payload, levels, |level, run| {
|
||||
out.resize(out.len() + run, dequantise(level, top));
|
||||
})?;
|
||||
(total == pixels).then_some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Round trip at the level count the renderer actually uses.
|
||||
fn round_trip(values: &[u8], width: usize, height: usize) -> Vec<u8> {
|
||||
let coverage = Coverage::encode(values, width, height, RENDERED_LEVELS)
|
||||
.expect("this mask should encode");
|
||||
let text = coverage.to_text();
|
||||
let read = Coverage::parse(&text).expect("what was written should parse");
|
||||
assert_eq!(read, coverage, "the round trip changed the encoding");
|
||||
assert_eq!(read.to_text(), text, "re-writing must be byte-identical");
|
||||
read.decode()
|
||||
}
|
||||
|
||||
/// Two levels is exactly the predicate the distance transform applies, so
|
||||
/// the round trip must agree with it on every pixel.
|
||||
fn thresholded(values: &[u8]) -> Vec<bool> {
|
||||
values.iter().map(|&v| v >= 128).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_mask_round_trips() {
|
||||
let values = vec![0u8; 64 * 32];
|
||||
let back = round_trip(&values, 64, 32);
|
||||
assert_eq!(back, values);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_mask_round_trips() {
|
||||
let values = vec![255u8; 64 * 32];
|
||||
let back = round_trip(&values, 64, 32);
|
||||
assert_eq!(back, values);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_pixel_mask_round_trips() {
|
||||
let mut values = vec![0u8; 64 * 32];
|
||||
values[17 * 64 + 33] = 255;
|
||||
let back = round_trip(&values, 64, 32);
|
||||
assert_eq!(back, values);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_pixel_raster_round_trips() {
|
||||
assert_eq!(round_trip(&[255], 1, 1), vec![255]);
|
||||
assert_eq!(round_trip(&[0], 1, 1), vec![0]);
|
||||
}
|
||||
|
||||
/// The whole of the fidelity claim: two levels loses nothing the renderer
|
||||
/// could have used, because the renderer thresholds.
|
||||
#[test]
|
||||
fn two_levels_preserve_the_threshold_exactly() {
|
||||
let values: Vec<u8> = (0..=255u8).collect();
|
||||
let back = round_trip(&values, 16, 16);
|
||||
assert_eq!(thresholded(&back), thresholded(&values));
|
||||
// And the shoulder really is gone, which is the cost being paid.
|
||||
assert!(back.iter().all(|&v| v == 0 || v == 255));
|
||||
}
|
||||
|
||||
/// A soft edge quantised to sixteen levels stays within one step of what
|
||||
/// went in, so a later build that wants the shoulder can have it.
|
||||
#[test]
|
||||
fn sixteen_levels_are_within_one_step() {
|
||||
let values: Vec<u8> = (0..256).map(|i| i as u8).collect();
|
||||
let coverage = Coverage::encode(&values, 16, 16, 16).expect("should encode");
|
||||
let back = Coverage::parse(&coverage.to_text())
|
||||
.expect("should parse")
|
||||
.decode();
|
||||
for (a, b) in values.iter().zip(&back) {
|
||||
assert!(
|
||||
(*a as i32 - *b as i32).abs() <= 255 / 15 / 2 + 1,
|
||||
"{a} came back as {b}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The case run-length coding is worst at. It must refuse rather than
|
||||
/// write a payload larger than the raster it came from.
|
||||
#[test]
|
||||
fn alternating_detail_is_refused_rather_than_expanded() {
|
||||
let (w, h) = (512, 512);
|
||||
let values: Vec<u8> = (0..w * h)
|
||||
.map(|i| if i % 2 == 0 { 0 } else { 255 })
|
||||
.collect();
|
||||
assert!(
|
||||
Coverage::encode(&values, w, h, RENDERED_LEVELS).is_none(),
|
||||
"a checkerboard must not be stored"
|
||||
);
|
||||
}
|
||||
|
||||
/// Small enough to fit, and still exact — the bound is on size, not on
|
||||
/// shape, so awkward detail that *does* fit must survive intact.
|
||||
#[test]
|
||||
fn alternating_detail_that_fits_is_exact() {
|
||||
let (w, h) = (64, 64);
|
||||
let values: Vec<u8> = (0..w * h)
|
||||
.map(|i| if i % 2 == 0 { 0 } else { 255 })
|
||||
.collect();
|
||||
let back = round_trip(&values, w, h);
|
||||
assert_eq!(back, values);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mask_of_the_wrong_length_is_refused() {
|
||||
assert!(Coverage::encode(&[0u8; 10], 4, 4, RENDERED_LEVELS).is_none());
|
||||
assert!(Coverage::encode(&[], 0, 0, RENDERED_LEVELS).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_payload_that_does_not_cover_the_raster_is_refused() {
|
||||
let values = vec![0u8; 32];
|
||||
let coverage = Coverage::encode(&values, 8, 4, RENDERED_LEVELS).expect("should encode");
|
||||
let payload = coverage.to_text();
|
||||
let payload = payload.rsplit_once(' ').expect("a payload").1;
|
||||
// The same payload, against a raster twice the size it covers.
|
||||
assert!(Coverage::parse(&format!("8 4 2 {payload}")).is_some());
|
||||
assert!(Coverage::parse(&format!("8 8 2 {payload}")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonsense_is_refused_rather_than_guessed_at() {
|
||||
assert!(Coverage::parse("").is_none());
|
||||
assert!(Coverage::parse("8 4 2").is_none(), "no payload");
|
||||
assert!(
|
||||
Coverage::parse("8 4 1 AA").is_none(),
|
||||
"one level is not a mask"
|
||||
);
|
||||
assert!(Coverage::parse("8 4 2 ****").is_none(), "not digits");
|
||||
assert!(
|
||||
Coverage::parse(&format!("{} {} 2 AA", usize::MAX, usize::MAX)).is_none(),
|
||||
"a size that overflows must not be believed"
|
||||
);
|
||||
assert!(
|
||||
Coverage::parse("100000 100000 2 A_____").is_none(),
|
||||
"a raster past the cap must not be allocated"
|
||||
);
|
||||
}
|
||||
|
||||
/// A level a payload is not allowed to name, in a file that names it.
|
||||
#[test]
|
||||
fn a_level_outside_the_range_is_refused() {
|
||||
// "BB" is level 1, run 1 — the shortest legal payload there is.
|
||||
assert_eq!(decode("BB", 1, 2), Some(vec![255]));
|
||||
// "DB" is level 3, run 1, and a two-level coverage has no level 3.
|
||||
assert!(decode("DB", 1, 2).is_none());
|
||||
// A run of zero says nothing and is refused rather than skipped.
|
||||
assert!(decode("BA", 1, 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampling_lands_on_the_same_shape() {
|
||||
let (w, h) = (32, 32);
|
||||
let mut values = vec![0u8; w * h];
|
||||
for y in 8..24 {
|
||||
for x in 8..24 {
|
||||
values[y * w + x] = 255;
|
||||
}
|
||||
}
|
||||
let coverage = Coverage::encode(&values, w, h, RENDERED_LEVELS).expect("should encode");
|
||||
|
||||
let same = coverage.decode_at(w, h);
|
||||
assert_eq!(same, values, "the matching size must not resample at all");
|
||||
|
||||
let half = coverage.decode_at(16, 16);
|
||||
assert_eq!(half.len(), 256);
|
||||
assert_eq!(half.iter().filter(|&&v| v == 255).count(), 64);
|
||||
|
||||
let double = coverage.decode_at(64, 64);
|
||||
assert_eq!(double.len(), 4096);
|
||||
assert_eq!(double.iter().filter(|&&v| v == 255).count(), 1024);
|
||||
}
|
||||
|
||||
/// Long runs cross rows, which is what makes a flat mask cost almost
|
||||
/// nothing: a 1600x1067 empty frame is two numbers.
|
||||
#[test]
|
||||
fn a_flat_mask_costs_almost_nothing() {
|
||||
let values = vec![0u8; 1600 * 1067];
|
||||
let coverage =
|
||||
Coverage::encode(&values, 1600, 1067, RENDERED_LEVELS).expect("should encode");
|
||||
assert!(
|
||||
coverage.encoded_len() < 16,
|
||||
"an empty mask took {} bytes",
|
||||
coverage.encoded_len()
|
||||
);
|
||||
}
|
||||
|
||||
/// What a real one costs. The shape is a bilinear upsample of a coarse
|
||||
/// grid, which is what both models produce, so the run structure is the
|
||||
/// one a photograph actually gives.
|
||||
#[test]
|
||||
fn a_realistic_mask_fits_in_a_sidecar() {
|
||||
let (w, h) = (1600usize, 1067usize);
|
||||
let (gw, gh) = (160usize, 107usize);
|
||||
let mut grid = vec![0f32; gw * gh];
|
||||
for y in 0..gh {
|
||||
for x in 0..gw {
|
||||
let dx = (x as f32 - 80.0) / 26.0;
|
||||
let dy = (y as f32 - 60.0) / 42.0;
|
||||
let r = (dx * dx + dy * dy).sqrt()
|
||||
+ 0.06 * ((y as f32 * 0.9).sin() * (x as f32 * 0.7).cos());
|
||||
grid[y * gw + x] = 1.0 / (1.0 + ((r - 1.0) * 9.0).exp());
|
||||
}
|
||||
}
|
||||
let mut values = vec![0u8; w * h];
|
||||
for y in 0..h {
|
||||
let fy = ((y as f32 + 0.5) / h as f32 * gh as f32 - 0.5).max(0.0);
|
||||
let (y0, ty) = (fy.floor() as usize, fy.fract());
|
||||
let y1 = (y0 + 1).min(gh - 1);
|
||||
for x in 0..w {
|
||||
let fx = ((x as f32 + 0.5) / w as f32 * gw as f32 - 0.5).max(0.0);
|
||||
let (x0, tx) = (fx.floor() as usize, fx.fract());
|
||||
let x1 = (x0 + 1).min(gw - 1);
|
||||
let a = grid[y0 * gw + x0] * (1.0 - tx) + grid[y0 * gw + x1] * tx;
|
||||
let b = grid[y1 * gw + x0] * (1.0 - tx) + grid[y1 * gw + x1] * tx;
|
||||
values[y * w + x] = ((a * (1.0 - ty) + b * ty) * 255.0) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
let coverage = Coverage::encode(&values, w, h, RENDERED_LEVELS).expect("should encode");
|
||||
let expected: Vec<u8> = values
|
||||
.iter()
|
||||
.map(|&v| if v >= 128 { 255 } else { 0 })
|
||||
.collect();
|
||||
assert_eq!(
|
||||
coverage.decode(),
|
||||
expected,
|
||||
"the stored mask must threshold identically to the model's"
|
||||
);
|
||||
// Measured at 6,464 bytes; the bound is loose enough not to fail over
|
||||
// a change of rounding and tight enough to catch a coder that has
|
||||
// stopped coding. Against 1,707,200 bytes raw.
|
||||
assert!(
|
||||
coverage.encoded_len() < 8 * 1024,
|
||||
"a realistic subject took {} bytes",
|
||||
coverage.encoded_len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ pub enum Attr {
|
||||
Colour,
|
||||
Detail,
|
||||
Optics,
|
||||
Geometry,
|
||||
Compose,
|
||||
Effect,
|
||||
}
|
||||
|
||||
@@ -132,20 +132,22 @@ impl Attr {
|
||||
Attr::Colour => "colour",
|
||||
Attr::Detail => "detail",
|
||||
Attr::Optics => "optics",
|
||||
Attr::Geometry => "geometry",
|
||||
Attr::Compose => "compose",
|
||||
Attr::Effect => "effect",
|
||||
}
|
||||
}
|
||||
|
||||
/// Every attribute, in the order `crate::descriptor::Attribute` declares
|
||||
/// them.
|
||||
/// Every attribute, in the order `crate::descriptor::Attribute::ALL`
|
||||
/// lists them — the reasoning for the sequence lives there, and the
|
||||
/// agreement test in `declared/mod.rs` zips the two, so a reorder there
|
||||
/// that is not mirrored here is a failure rather than a drift.
|
||||
pub const ALL: [Attr; 6] = [
|
||||
Attr::Optics,
|
||||
Attr::Compose,
|
||||
Attr::Tone,
|
||||
Attr::Colour,
|
||||
Attr::Detail,
|
||||
Attr::Optics,
|
||||
Attr::Geometry,
|
||||
Attr::Effect,
|
||||
Attr::Detail,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -494,11 +496,28 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
|
||||
if let Some(ty) = root.get("rust") {
|
||||
let ty = as_str(ty, "rust")?.to_string();
|
||||
check_type_name(&ty)?;
|
||||
for key in ["params", "uniforms", "wgsl", "helpers", "define", "label"] {
|
||||
// `attributes` is in this list, and was not until a declaration that
|
||||
// said `[effect]` sat above a type that said `[tone, colour]` for a
|
||||
// whole commit without anything noticing. The line parsed, validated
|
||||
// against the vocabulary, and was then dropped on the floor — so the
|
||||
// file read as though it had moved the operation and the panel went on
|
||||
// filing it under two groups it did not belong to. A key that is
|
||||
// *ignored* is worse than one that is rejected, because it looks like
|
||||
// it worked.
|
||||
for key in [
|
||||
"params",
|
||||
"uniforms",
|
||||
"wgsl",
|
||||
"helpers",
|
||||
"define",
|
||||
"label",
|
||||
"attributes",
|
||||
] {
|
||||
if root.contains_key(key) {
|
||||
return Err(format!(
|
||||
"`{key}` is meaningless on a `rust:` node — {ty} publishes \
|
||||
its own descriptor. Remove one or the other."
|
||||
its own descriptor, and this file would be ignored. Say it \
|
||||
in the type instead."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ fn attribute(a: decl::Attr) -> Attribute {
|
||||
decl::Attr::Colour => Attribute::Colour,
|
||||
decl::Attr::Detail => Attribute::Detail,
|
||||
decl::Attr::Optics => Attribute::Optics,
|
||||
decl::Attr::Geometry => Attribute::Geometry,
|
||||
decl::Attr::Compose => Attribute::Compose,
|
||||
decl::Attr::Effect => Attribute::Effect,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,24 @@ pub enum WidgetKind {
|
||||
/// On-canvas brush strokes.
|
||||
BrushMask,
|
||||
/// An eyedropper bound to the canvas, setting white balance from a pixel.
|
||||
///
|
||||
/// **The one widget that reads the photograph rather than driving it**,
|
||||
/// and that is what gives it a contract the others do not need. A curve
|
||||
/// tells its frontend where its points are and the frontend moves them; an
|
||||
/// eyedropper is handed a colour and has to work out what the parameters
|
||||
/// should become, which is only possible if the operation says enough
|
||||
/// about itself to be inverted:
|
||||
///
|
||||
/// * The first two parameters in [`Presentation::params`] are its axes —
|
||||
/// the first trading red against blue, the second green against
|
||||
/// magenta — and each is monotonic in its axis.
|
||||
/// * The operation publishes exactly three uniforms: the linear
|
||||
/// per-channel gains, in red, green, blue order.
|
||||
///
|
||||
/// The same kind of contract [`Self::ToneCurve`] carries when it says its
|
||||
/// parameters are point coordinates interleaved, and it is checked rather
|
||||
/// than trusted — see [`crate::neutral`], which does the inverting so that
|
||||
/// no frontend has to hold a second copy of the declared response.
|
||||
WhitePoint,
|
||||
}
|
||||
|
||||
@@ -403,6 +421,27 @@ impl ParamDescriptor {
|
||||
}
|
||||
}
|
||||
|
||||
/// A toggle that is **on** when nothing has been chosen.
|
||||
///
|
||||
/// The reset contract is unchanged — a default is still the neutral value
|
||||
/// and a sidecar still stores only departures from it. What differs is
|
||||
/// which state is neutral, and that is a fact about the setting rather
|
||||
/// than about switches: a correction the file itself asked for is on
|
||||
/// unless the photographer says otherwise, so "off" is the edit and
|
||||
/// storing it is right. A switch written the other way round would have to
|
||||
/// be labelled for its negation — "Ignore the lens profile" — and every
|
||||
/// photograph that simply wanted correcting would carry a stored
|
||||
/// parameter saying so.
|
||||
pub const fn switch_on(id: &'static str, label: &'static str) -> Self {
|
||||
Self {
|
||||
id: ParamId(id),
|
||||
label: LocalizedKey(label),
|
||||
kind: ParamKind::Bool,
|
||||
default: 1.0,
|
||||
facet: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One of a fixed list of alternatives, defaulting to the first.
|
||||
///
|
||||
/// The first rather than a caller-chosen index, so the reset contract
|
||||
@@ -580,26 +619,61 @@ pub enum Attribute {
|
||||
/// Corrections for the lens that took the photograph: distortion,
|
||||
/// chromatic aberration, vignetting.
|
||||
Optics,
|
||||
/// The shape of the frame: crop, straighten, rotation, flips.
|
||||
Geometry,
|
||||
/// How the frame is composed: crop, straighten, rotation, flips.
|
||||
///
|
||||
/// Named for the decision rather than for the maths. The lens corrections
|
||||
/// are geometry too — distortion moves pixels exactly as a straighten
|
||||
/// does — and lumping the two together would file a correction the
|
||||
/// photographer never asked for beside a choice that is the whole reason
|
||||
/// they opened the photograph. [`Self::Optics`] is what the lens did;
|
||||
/// this is what they decided.
|
||||
Compose,
|
||||
/// Applied rather than corrected — a look, not a fix.
|
||||
Effect,
|
||||
}
|
||||
|
||||
impl Attribute {
|
||||
/// Every attribute, in declaration order.
|
||||
/// Every attribute, in roughly the order a photographer works through
|
||||
/// them.
|
||||
///
|
||||
/// Declaration order is roughly the order a photographer works in, which
|
||||
/// makes it a reasonable *default* for a frontend that wants one. It is
|
||||
/// not a screen order: nothing here obliges a frontend to show them all,
|
||||
/// show them in this sequence, or show them at all.
|
||||
/// That makes it a reasonable *default* for a frontend that wants one. It
|
||||
/// is not a screen order: nothing here obliges a frontend to show them
|
||||
/// all, show them in this sequence, or show them at all.
|
||||
///
|
||||
/// `Optics` leads because correcting the lens is not a decision about the
|
||||
/// photograph — it is undoing what the equipment did, a property of the
|
||||
/// capture rather than a choice — and it moves the ground every later
|
||||
/// judgement stands on. Removing a vignette *brightens the frame*, so an
|
||||
/// exposure set before the correction has to be set again after it.
|
||||
///
|
||||
/// `Compose` follows as the first decision actually made about the
|
||||
/// photograph, and the one every later judgement is made inside: there is
|
||||
/// no sense in balancing tones across a frame that is about to lose a
|
||||
/// third of its width.
|
||||
///
|
||||
/// `Detail` trails because sharpening and noise reduction depend on
|
||||
/// everything above them, and are the only ones here that cannot be judged
|
||||
/// at fit view at all. Offering them fourth, before the lens has even been
|
||||
/// corrected, invites the photographer to settle grain against an image
|
||||
/// that is still going to move.
|
||||
///
|
||||
/// `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.
|
||||
///
|
||||
/// 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
|
||||
/// began driving a column read top to bottom.
|
||||
pub const ALL: [Attribute; 6] = [
|
||||
Attribute::Optics,
|
||||
Attribute::Compose,
|
||||
Attribute::Tone,
|
||||
Attribute::Colour,
|
||||
Attribute::Detail,
|
||||
Attribute::Optics,
|
||||
Attribute::Geometry,
|
||||
Attribute::Effect,
|
||||
Attribute::Detail,
|
||||
];
|
||||
|
||||
/// The localisation key naming this concept.
|
||||
@@ -613,11 +687,28 @@ impl Attribute {
|
||||
Self::Colour => "attr.colour",
|
||||
Self::Detail => "attr.detail",
|
||||
Self::Optics => "attr.optics",
|
||||
Self::Geometry => "attr.geometry",
|
||||
Self::Compose => "attr.compose",
|
||||
Self::Effect => "attr.effect",
|
||||
})
|
||||
}
|
||||
|
||||
/// The name used in `ops/<id>.yaml`, and the inverse of [`Self::from_name`].
|
||||
///
|
||||
/// A stable identifier rather than a label: it is written into settings
|
||||
/// and read back, so changing one of these strings would silently reset a
|
||||
/// photographer's choice of what a paste carries. The *displayed* name is
|
||||
/// [`Self::label`], which is localised and free to change.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Tone => "tone",
|
||||
Self::Colour => "colour",
|
||||
Self::Detail => "detail",
|
||||
Self::Optics => "optics",
|
||||
Self::Compose => "compose",
|
||||
Self::Effect => "effect",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the name used in `ops/<id>.yaml`.
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
Some(match name {
|
||||
@@ -625,7 +716,15 @@ impl Attribute {
|
||||
"colour" => Self::Colour,
|
||||
"detail" => Self::Detail,
|
||||
"optics" => Self::Optics,
|
||||
"geometry" => Self::Geometry,
|
||||
"compose" => Self::Compose,
|
||||
// The name this attribute was persisted under before it was
|
||||
// called Compose. Settings written by an older build carry it in
|
||||
// `develop.copy_attributes`, and `from_name` returning `None`
|
||||
// there does not fail loudly — `presets::scope_for` logs and drops
|
||||
// the entry, silently narrowing what a paste carries. Accepted on
|
||||
// the way in only; `name` writes the current spelling, so a
|
||||
// settings file rewrites itself the first time it is saved.
|
||||
"geometry" => Self::Compose,
|
||||
"effect" => Self::Effect,
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -301,6 +301,40 @@ pub struct DetailPass {
|
||||
/// the kind of artefact that looks like a driver bug. State it honestly.
|
||||
pub radius: u32,
|
||||
|
||||
/// How much smaller than the render this pass writes.
|
||||
///
|
||||
/// `1` is the ordinary case and means "the render size", which is what
|
||||
/// every pass did before this field existed. A larger value writes a
|
||||
/// target that many times smaller on each axis, into a **second** chain
|
||||
/// held alongside the full-resolution one — see [`Self::wgsl`] for how the
|
||||
/// two are addressed, and the module documentation for why there are two.
|
||||
///
|
||||
/// # Why a pass may want this
|
||||
///
|
||||
/// A blur wide enough to be a *base* — clarity's is 1.2% of the frame,
|
||||
/// 52 render pixels at 4K — holds no spatial frequency a quarter-scale
|
||||
/// grid cannot represent. Computing it at the render size therefore buys
|
||||
/// nothing and costs everything: 105 taps over 8.3 M pixels, twice, which
|
||||
/// measured at 34 ms and is where `docs/technical-debt.md` TD-4 came from.
|
||||
/// At a quarter it is a sixteenth of the pixels at a quarter of the
|
||||
/// radius, and the result is not an approximation of the full-resolution
|
||||
/// base — it is the same band-limited function, sampled where it is still
|
||||
/// Nyquist-safe.
|
||||
///
|
||||
/// [`Self::radius`] stays in this pass's **own** pixels, so a pass at
|
||||
/// scale 4 with a radius of 13 declares 13, not 52. The halo it implies
|
||||
/// for a tile scheduler is `radius * output_scale`, and
|
||||
/// [`ComposedDetail::radius`] is what performs that multiplication —
|
||||
/// stating the radius in the grid the loop actually runs in is what keeps
|
||||
/// the shader and the declaration the same number.
|
||||
///
|
||||
/// **Never the last pass.** The final pass carries the output transform
|
||||
/// and writes the display texture, which is full resolution by
|
||||
/// definition; a scaled pass in that position is a codegen bug and
|
||||
/// `dr-gpu` refuses it rather than binding a shader to a target of the
|
||||
/// wrong size.
|
||||
pub output_scale: u32,
|
||||
|
||||
/// The WGSL body.
|
||||
///
|
||||
/// Reads and writes `c`, a `vec3<f32>` of **linear sRGB**, pre-loaded with
|
||||
@@ -319,6 +353,19 @@ pub struct DetailPass {
|
||||
/// pixel that survives to the next pass**, pre-loaded with what the
|
||||
/// previous pass left there and written back out unless the body
|
||||
/// assigns it.
|
||||
/// - `reduced_at(coord) -> f32` — the **reduced chain's** scalar at this
|
||||
/// pixel,
|
||||
/// bilinearly upsampled. Zero unless a scaled pass ran earlier in this
|
||||
/// operation; see [`Self::output_scale`].
|
||||
///
|
||||
/// `coord` is always in *this pass's own* output grid, and `tap` maps it
|
||||
/// into the source's grid for you. A pass at [`Self::output_scale`] 4
|
||||
/// therefore addresses its own quarter-size target with `coord`, while
|
||||
/// `tap(coord, offset)` offsets in **source** pixels — which is what lets
|
||||
/// a reduce pass average the 4 x 4 block a single output pixel covers by
|
||||
/// looping `offset` over it. Where source and target are the same size the
|
||||
/// mapping is the identity, so every pass written before scaling existed
|
||||
/// behaves exactly as it did.
|
||||
///
|
||||
/// # Why `aux` exists
|
||||
///
|
||||
@@ -424,6 +471,8 @@ pub struct ComposedDetailPass {
|
||||
pub storage: Vec<[f32; 4]>,
|
||||
/// See [`DetailPass::radius`].
|
||||
pub radius: u32,
|
||||
/// See [`DetailPass::output_scale`].
|
||||
pub output_scale: u32,
|
||||
/// Whether this pass writes the display/export texture rather than another
|
||||
/// linear intermediate.
|
||||
///
|
||||
@@ -457,8 +506,18 @@ impl ComposedDetail {
|
||||
}
|
||||
|
||||
/// The widest halo any pass needs, in render pixels (ARCH §5.3).
|
||||
///
|
||||
/// A pass declares its radius in its own grid, so a scaled pass's has to
|
||||
/// be multiplied back up before the maxima are comparable: 13 reduced
|
||||
/// pixels at scale 4 reach exactly as far across the photograph as 52
|
||||
/// render pixels do, and a scheduler comparing the two unscaled would size
|
||||
/// a halo at a quarter of what the pass actually reads.
|
||||
pub fn radius(&self) -> u32 {
|
||||
self.passes.iter().map(|p| p.radius).max().unwrap_or(0)
|
||||
self.passes
|
||||
.iter()
|
||||
.map(|p| p.radius.saturating_mul(p.output_scale))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,6 +631,7 @@ pub fn compose_detail_with(
|
||||
RESOLVE_ID,
|
||||
&[],
|
||||
&DetailPass {
|
||||
output_scale: 1,
|
||||
label: "resolve",
|
||||
radius: 0,
|
||||
wgsl: String::new(),
|
||||
@@ -723,6 +783,25 @@ struct Params {{
|
||||
// pass leaves this bound to a single empty element and never looks at it. See
|
||||
// `DetailPass::storage` for why the list is not in the uniform block.
|
||||
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
|
||||
// The reduced chain — what a scaled pass most recently wrote, at whatever
|
||||
// fraction of the render size it declared. Bound to a 1x1 placeholder for
|
||||
// every pass that never calls `base`, so that one bind group layout serves a
|
||||
// pass which uses it and a pass which has never heard of it.
|
||||
@group(0) @binding(4) var reduced: texture_2d<f32>;
|
||||
|
||||
// Where in `source` this output pixel begins.
|
||||
//
|
||||
// The ratio is 1 whenever a pass writes what it reads, which is every pass
|
||||
// that does not set `output_scale` — the multiply and the divide cancel
|
||||
// exactly, so the ordinary case is unchanged and pays two integer operations
|
||||
// for the privilege. A scaled pass gets the top-left of the block it covers,
|
||||
// which is what makes `tap`'s offsets mean *source* pixels and lets a reduce
|
||||
// pass walk its own footprint.
|
||||
fn source_origin(coord: vec2<i32>) -> vec2<i32> {{
|
||||
let src = vec2<i32>(textureDimensions(source));
|
||||
let dst = vec2<i32>(textureDimensions(output));
|
||||
return coord * src / max(dst, vec2<i32>(1));
|
||||
}}
|
||||
|
||||
// A neighbour, clamped to the edge of the image.
|
||||
//
|
||||
@@ -732,13 +811,48 @@ struct Params {{
|
||||
// classic way a first convolution goes wrong.
|
||||
fn tap(coord: vec2<i32>, offset: vec2<i32>) -> vec3<f32> {{
|
||||
let last = vec2<i32>(textureDimensions(source)) - vec2<i32>(1);
|
||||
return textureLoad(source, clamp(coord + offset, vec2<i32>(0), last), 0).rgb;
|
||||
let at = source_origin(coord) + offset;
|
||||
return textureLoad(source, clamp(at, vec2<i32>(0), last), 0).rgb;
|
||||
}}
|
||||
|
||||
// The same neighbour's scratch lane — see `aux` in the body below.
|
||||
fn tap_aux(coord: vec2<i32>, offset: vec2<i32>) -> f32 {{
|
||||
let last = vec2<i32>(textureDimensions(source)) - vec2<i32>(1);
|
||||
return textureLoad(source, clamp(coord + offset, vec2<i32>(0), last), 0).a;
|
||||
let at = source_origin(coord) + offset;
|
||||
return textureLoad(source, clamp(at, vec2<i32>(0), last), 0).a;
|
||||
}}
|
||||
|
||||
// The reduced chain, read at this pass's own resolution.
|
||||
//
|
||||
// Named `reduced_at` rather than `base` because `base` is a natural local in a
|
||||
// body that has just computed one — spot removal already has such a local, and
|
||||
// a function shadowed by a variable is a compile error a long way from its
|
||||
// cause.
|
||||
//
|
||||
// Bilinear, and on pixel *centres* rather than corners: the reduce pass took
|
||||
// its sample at the centre of the block it averaged, so an upsample that
|
||||
// treated the grids as corner-aligned would shift the base by half a reduced
|
||||
// pixel — two full pixels at scale 4, which on a wide unsharp mask is a base
|
||||
// offset from the image it is subtracted from, and reads as a directional
|
||||
// smear along every edge.
|
||||
//
|
||||
// Nearest would be cheaper and is not enough: the base is subtracted from the
|
||||
// full-resolution image, so any blockiness in it appears in the *difference*
|
||||
// at full contrast. That is a visible 4-pixel grid over the whole frame.
|
||||
fn reduced_at(coord: vec2<i32>) -> f32 {{
|
||||
let rd = vec2<f32>(textureDimensions(reduced));
|
||||
let dst = vec2<f32>(max(textureDimensions(output), vec2<u32>(1u)));
|
||||
let p = (vec2<f32>(coord) + vec2<f32>(0.5)) * rd / dst - vec2<f32>(0.5);
|
||||
let last = vec2<i32>(rd) - vec2<i32>(1);
|
||||
let base_px = vec2<i32>(floor(p));
|
||||
let f = fract(p);
|
||||
|
||||
let s00 = textureLoad(reduced, clamp(base_px, vec2<i32>(0), last), 0).a;
|
||||
let s10 = textureLoad(reduced, clamp(base_px + vec2<i32>(1, 0), vec2<i32>(0), last), 0).a;
|
||||
let s01 = textureLoad(reduced, clamp(base_px + vec2<i32>(0, 1), vec2<i32>(0), last), 0).a;
|
||||
let s11 = textureLoad(reduced, clamp(base_px + vec2<i32>(1, 1), vec2<i32>(0), last), 0).a;
|
||||
|
||||
return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y);
|
||||
}}
|
||||
|
||||
{helper_src}{encode_fn}
|
||||
@@ -786,6 +900,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
uniforms: uniform_values,
|
||||
storage: pass.storage.clone(),
|
||||
radius: pass.radius,
|
||||
// Clamped rather than trusted: a zero would divide by nothing in the
|
||||
// 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,
|
||||
}
|
||||
@@ -871,6 +989,7 @@ mod tests {
|
||||
dr_types::ColourSpace::Srgb,
|
||||
&crate::mask::MaskStack::new(),
|
||||
&crate::spot::SpotSet::new(),
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ impl DetailStage for BoxBlur {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(axis, _)| DetailPass {
|
||||
output_scale: 1,
|
||||
label: if axis == 0 { "horizontal" } else { "vertical" },
|
||||
radius: r,
|
||||
// A convolution, not a list: nothing to bind at binding 3.
|
||||
|
||||
+434
-11
@@ -78,7 +78,7 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
// The shape of the frame, and the only operation that changes the
|
||||
// output's dimensions.
|
||||
attributes: vec![Attribute::Geometry],
|
||||
attributes: vec![Attribute::Compose],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.framing"),
|
||||
params: vec![
|
||||
@@ -173,6 +173,129 @@ impl CropRect {
|
||||
height: finite(self.height, 1.0).min(1.0 - y).max(Self::MIN_EXTENT),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Reshape onto an aspect ratio, holding one point of the rect still.
|
||||
///
|
||||
/// `ratio` is width over height **in output pixels** — 1.5 for 3:2 — which
|
||||
/// is what a photographer means by an aspect ratio and is emphatically not
|
||||
/// what this rect stores. The rect is in fractions of a frame that is
|
||||
/// itself not square, so the ratio it needs is `ratio * height / width` of
|
||||
/// that frame. Skipping the conversion yields a "1:1" crop that is square
|
||||
/// only on a square photograph, which is the one case nobody tests on.
|
||||
///
|
||||
/// `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.
|
||||
///
|
||||
/// **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
|
||||
/// inside instead reads as a dead control — dragging a corner outward
|
||||
/// 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.
|
||||
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);
|
||||
if ratio <= 0.0 || frame_w == 0 || frame_h == 0 {
|
||||
return rect;
|
||||
}
|
||||
|
||||
// Width over height as a fraction of *this* frame, not in pixels.
|
||||
let r = ratio * frame_h as f32 / frame_w as f32;
|
||||
|
||||
let ax = finite(anchor.0, 0.5).clamp(0.0, 1.0);
|
||||
let ay = finite(anchor.1, 0.5).clamp(0.0, 1.0);
|
||||
// Where the fixed point sits in the frame. Everything below is built
|
||||
// outwards from it, which is what makes the anchor mean anything.
|
||||
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;
|
||||
|
||||
// Scaled to fit, never clamped to fit: clamping one axis against the
|
||||
// frame would break the very ratio this exists to hold.
|
||||
let shrink = (room(px, ax) / w).min(room(py, ay) / h).min(1.0);
|
||||
if shrink.is_finite() && shrink > 0.0 {
|
||||
w *= shrink;
|
||||
h *= shrink;
|
||||
}
|
||||
|
||||
Self {
|
||||
x: px - ax * w,
|
||||
y: py - ay * h,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The largest copy of this rect, at its own shape, sitting inside
|
||||
/// `bound`.
|
||||
///
|
||||
/// Scaled down only as far as `bound` demands and then slid inside it,
|
||||
/// rather than replaced by `bound` or centred within it. Both of those
|
||||
/// throw away a composition: a crop placed deliberately off-centre is a
|
||||
/// decision, and an automatic correction that recentres it has undone the
|
||||
/// user's work to fix a problem they did not have.
|
||||
pub fn fitted_into(self, bound: Self) -> Self {
|
||||
let rect = self.normalised();
|
||||
let bound = bound.normalised();
|
||||
|
||||
let shrink = (bound.width / rect.width)
|
||||
.min(bound.height / rect.height)
|
||||
.min(1.0);
|
||||
let shrink = if shrink.is_finite() && shrink > 0.0 {
|
||||
shrink
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let (w, h) = (rect.width * shrink, rect.height * shrink);
|
||||
|
||||
// Shrunk about its own centre, so what was framed stays framed — but
|
||||
// only when it actually shrank. Routing the untouched case through
|
||||
// the same arithmetic moves the origin by a rounding error, and a
|
||||
// rect that is already inside its bound must come back *identical*:
|
||||
// this is called on every straighten, and a rect that drifts a
|
||||
// millionth each time is an edit recorded for no reason.
|
||||
let (x, y) = if shrink < 1.0 {
|
||||
(
|
||||
rect.x + (rect.width - w) * 0.5,
|
||||
rect.y + (rect.height - h) * 0.5,
|
||||
)
|
||||
} else {
|
||||
(rect.x, rect.y)
|
||||
};
|
||||
|
||||
Self {
|
||||
// `max` on the upper limit for the same reason `normalised`
|
||||
// orders its bounds that way: rounding can put the far edge a
|
||||
// hair *below* the near one, and `clamp` panics on an inverted
|
||||
// range rather than resolving it.
|
||||
x: x.clamp(bound.x, (bound.x + bound.width - w).max(bound.x)),
|
||||
y: y.clamp(bound.y, (bound.y + bound.height - h).max(bound.y)),
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a rect anchored at `p` with the anchor `a` fractions along it may
|
||||
/// extend before it leaves the `0..1` frame.
|
||||
///
|
||||
/// One axis at a time, and infinite where the anchor is at the far end and the
|
||||
/// rect therefore only grows away from that edge.
|
||||
fn room(p: f32, a: f32) -> f32 {
|
||||
let before = if a > 0.0 { p / a } else { f32::INFINITY };
|
||||
let after = if a < 1.0 {
|
||||
(1.0 - p) / (1.0 - a)
|
||||
} else {
|
||||
f32::INFINITY
|
||||
};
|
||||
before.min(after)
|
||||
}
|
||||
|
||||
/// Replace a non-finite value with a fallback.
|
||||
@@ -225,9 +348,19 @@ pub struct Framing {
|
||||
/// Which part of the framed image the viewport is looking at.
|
||||
///
|
||||
/// **Not an edit.** Zooming changes what you are inspecting, never what
|
||||
/// the file becomes: it is excluded from [`Self::is_active`], from the
|
||||
/// structure hash, and from the sidecar, so a zoomed view exports exactly
|
||||
/// as an unzoomed one does.
|
||||
/// the file becomes, so it is excluded from [`Self::is_active`], from the
|
||||
/// structure hash, and from the sidecar.
|
||||
///
|
||||
/// **That is not on its own enough to make an export ignore it**, and
|
||||
/// this doc comment used to claim it was. The exclusions keep the view out
|
||||
/// of the *edit* — out of what is saved, out of the output size, out of
|
||||
/// the crop. They cannot keep it out of a *render*, because
|
||||
/// [`Self::visible_rect`] deliberately folds it into the one rect the
|
||||
/// shader samples. Anything composing this framing and rendering it gets
|
||||
/// the zoom; a caller that wants the photograph rather than the canvas
|
||||
/// has to suspend the view first, as `DevelopSession::render_the_file`
|
||||
/// and `render_uncropped` both do. Exporting at 4:1 wrote the middle of
|
||||
/// the frame, magnified, until it did.
|
||||
///
|
||||
/// It lives here rather than in the UI because it composes with the crop
|
||||
/// in the same normalised space — nesting one rect inside the other is a
|
||||
@@ -760,9 +893,16 @@ impl Framing {
|
||||
/// The WGSL mapping an output pixel to a **normalised centred** source
|
||||
/// position, ready for the warp chain.
|
||||
///
|
||||
/// Leaves the result in `p`: centre `(0, 0)`, `r == 1` at the corner —
|
||||
/// exactly the space [`crate::lens`] documents, so lens correction
|
||||
/// composes on top of this without either stage naming the other.
|
||||
/// Leaves the result in `p`: centre `(0, 0)`, spanning `±0.5 * aspect` —
|
||||
/// the space [`crate::lens`] documents, so lens correction composes on top
|
||||
/// of this without either stage naming the other.
|
||||
///
|
||||
/// **`p` is centred but not corner-normalised**, and this used to claim it
|
||||
/// was. Its length at the corner is `0.5 * length(aspect)`, not 1. The
|
||||
/// corner-normalised radius the radial corrections need is published
|
||||
/// separately as `radius` by `operation::sample_source`, which divides by
|
||||
/// exactly that; anything reading `length(p)` as a lens radius is off by
|
||||
/// an aspect-dependent factor.
|
||||
///
|
||||
/// `aspect` is left in scope alongside it, since the warp chain and the
|
||||
/// sampler both need it to return to texture coordinates.
|
||||
@@ -1129,10 +1269,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_does_not_change_the_exported_image() {
|
||||
fn zooming_does_not_change_the_size_or_the_crop() {
|
||||
// The property that makes zoom a viewing tool rather than an edit: it
|
||||
// must not reach the output size or the crop. If it did, exporting
|
||||
// while zoomed would write the zoomed view.
|
||||
// must not reach the output size or the crop.
|
||||
//
|
||||
// **Renamed, because the old name promised more than the body checks
|
||||
// and the gap was where a real bug lived.** "Does not change the
|
||||
// exported image" was read as a guarantee about pixels; it is a
|
||||
// guarantee about two numbers. Both held perfectly while
|
||||
// `render_for_export` was writing the zoomed view at full size,
|
||||
// because the view reaches the render through `visible_rect` and
|
||||
// never through either of these. The pixels are guarded where pixels
|
||||
// exist — `dr-ui`'s `export_ignores_the_viewport`.
|
||||
//
|
||||
// The structure key is deliberately not asserted here — see
|
||||
// `zooming_from_neutral_changes_the_structure_key` for why it must
|
||||
@@ -1590,6 +1738,281 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// --- the aspect lock and the safe-area fit ---------------------------
|
||||
|
||||
/// A crop's shape in output pixels, which is the thing an aspect ratio is
|
||||
/// about — the rect's own numbers are fractions of a frame that is not
|
||||
/// square, so they are not comparable to `3.0 / 2.0` on their own.
|
||||
fn pixel_ratio(c: CropRect, w: u32, h: u32) -> f32 {
|
||||
(c.width * w as f32) / (c.height * h as f32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_ratio_is_a_ratio_of_pixels_not_of_fractions() {
|
||||
// The whole of why `with_aspect` takes the frame size. On a 3:2
|
||||
// photograph a square crop is *not* a square in fractions, and a
|
||||
// version that skipped the conversion would pass every test written
|
||||
// on a square frame.
|
||||
let c = CropRect::default().with_aspect(6000, 4000, 1.0, (0.5, 0.5));
|
||||
assert!(
|
||||
(pixel_ratio(c, 6000, 4000) - 1.0).abs() < 1e-4,
|
||||
"expected a square in pixels, got {c:?}"
|
||||
);
|
||||
assert!(
|
||||
(c.width - c.height).abs() > 0.1,
|
||||
"a square on a 3:2 frame must not be square in fractions: {c:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_offered_ratio_comes_back_at_the_ratio_asked_for() {
|
||||
for (rw, rh) in [(1, 1), (3, 2), (4, 3), (5, 4), (16, 9), (2, 3), (9, 16)] {
|
||||
let want = rw as f32 / rh as f32;
|
||||
for (fw, fh) in [(6000u32, 4000u32), (4000, 6000), (3000, 3000)] {
|
||||
let c = CropRect {
|
||||
x: 0.2,
|
||||
y: 0.3,
|
||||
width: 0.4,
|
||||
height: 0.25,
|
||||
}
|
||||
.with_aspect(fw, fh, want, (0.5, 0.5));
|
||||
let got = pixel_ratio(c, fw, fh);
|
||||
assert!(
|
||||
(got / want - 1.0).abs() < 1e-3,
|
||||
"{rw}:{rh} on {fw}x{fh} came back as {got}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_rect_stays_inside_the_frame_whatever_is_asked_for() {
|
||||
// The reason the fit is a scale rather than a clamp: clamping one
|
||||
// axis at the edge would hold the rect inside at the cost of the
|
||||
// ratio, which is the one property the caller asked for.
|
||||
for (rw, rh) in [(1, 1), (3, 2), (16, 9), (9, 16)] {
|
||||
for anchor in [(0.0, 0.0), (1.0, 1.0), (1.0, 0.0), (0.5, 0.5)] {
|
||||
let c = CropRect {
|
||||
x: 0.05,
|
||||
y: 0.9,
|
||||
width: 0.9,
|
||||
height: 0.09,
|
||||
}
|
||||
.with_aspect(6000, 4000, rw as f32 / rh as f32, anchor);
|
||||
assert!(
|
||||
c.x >= -1e-5
|
||||
&& c.y >= -1e-5
|
||||
&& c.x + c.width <= 1.0 + 1e-5
|
||||
&& c.y + c.height <= 1.0 + 1e-5,
|
||||
"{rw}:{rh} at {anchor:?} left the frame: {c:?}"
|
||||
);
|
||||
let got = pixel_ratio(c, 6000, 4000);
|
||||
let want = rw as f32 / rh as f32;
|
||||
assert!(
|
||||
(got / want - 1.0).abs() < 1e-3,
|
||||
"{rw}:{rh} at {anchor:?} lost its ratio: {got}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_anchor_corner_is_the_one_that_does_not_move() {
|
||||
// What makes a corner drag feel like a corner drag: the opposite
|
||||
// corner stays nailed down while the held one shapes the rect.
|
||||
let start = CropRect {
|
||||
x: 0.2,
|
||||
y: 0.2,
|
||||
width: 0.3,
|
||||
height: 0.3,
|
||||
};
|
||||
// Bottom-right held still, top-left free.
|
||||
let c = start.with_aspect(6000, 4000, 1.0, (1.0, 1.0));
|
||||
assert!(
|
||||
(c.x + c.width - (start.x + start.width)).abs() < 1e-5,
|
||||
"{c:?}"
|
||||
);
|
||||
assert!(
|
||||
(c.y + c.height - (start.y + start.height)).abs() < 1e-5,
|
||||
"{c:?}"
|
||||
);
|
||||
|
||||
// Top-left held still, bottom-right free.
|
||||
let c = start.with_aspect(6000, 4000, 1.0, (0.0, 0.0));
|
||||
assert!((c.x - start.x).abs() < 1e-5, "{c:?}");
|
||||
assert!((c.y - start.y).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
|
||||
// axis clamps the first straight back, and the handle refuses to move.
|
||||
let start = CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.6,
|
||||
height: 0.3,
|
||||
};
|
||||
let c = start.with_aspect(4000, 4000, 1.0, (0.0, 0.0));
|
||||
assert!(
|
||||
c.width >= start.width - 1e-5,
|
||||
"{c:?} narrower than {start:?}"
|
||||
);
|
||||
assert!(
|
||||
c.height >= start.height - 1e-5,
|
||||
"{c:?} shorter than {start:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_free_or_nonsense_ratio_leaves_the_rect_alone() {
|
||||
// Nothing here should be a failure the caller has to handle: "no
|
||||
// ratio" is the ordinary state of the crop tool.
|
||||
let start = CropRect {
|
||||
x: 0.1,
|
||||
y: 0.2,
|
||||
width: 0.3,
|
||||
height: 0.4,
|
||||
};
|
||||
for bad in [0.0, -1.5, f32::NAN, f32::INFINITY] {
|
||||
assert_eq!(start.with_aspect(6000, 4000, bad, (0.5, 0.5)), start);
|
||||
}
|
||||
// A frame with no extent cannot define a ratio either.
|
||||
assert_eq!(start.with_aspect(0, 0, 1.0, (0.5, 0.5)), start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rect_already_inside_its_bound_is_left_where_it_is() {
|
||||
let bound = CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.8,
|
||||
height: 0.8,
|
||||
};
|
||||
let rect = CropRect {
|
||||
x: 0.2,
|
||||
y: 0.2,
|
||||
width: 0.3,
|
||||
height: 0.3,
|
||||
};
|
||||
assert_eq!(rect.fitted_into(bound), rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_into_a_bound_keeps_the_shape_and_the_side_it_was_on() {
|
||||
// A crop placed deliberately off-centre is a decision. Recentring it
|
||||
// to solve a problem the user did not have would undo their work.
|
||||
let bound = CropRect {
|
||||
x: 0.25,
|
||||
y: 0.25,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
};
|
||||
let rect = CropRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 0.8,
|
||||
height: 0.4,
|
||||
};
|
||||
let fitted = rect.fitted_into(bound);
|
||||
|
||||
assert!(
|
||||
(fitted.width / fitted.height - rect.width / rect.height).abs() < 1e-4,
|
||||
"shape changed: {fitted:?}"
|
||||
);
|
||||
assert!(
|
||||
fitted.x >= bound.x - 1e-5
|
||||
&& fitted.y >= bound.y - 1e-5
|
||||
&& fitted.x + fitted.width <= bound.x + bound.width + 1e-5
|
||||
&& fitted.y + fitted.height <= bound.y + bound.height + 1e-5,
|
||||
"{fitted:?} is not inside {bound:?}"
|
||||
);
|
||||
// It came from the top-left, so it should still be against those
|
||||
// edges rather than centred in the bound.
|
||||
assert!((fitted.x - bound.x).abs() < 1e-5, "{fitted:?}");
|
||||
assert!((fitted.y - bound.y).abs() < 1e-5, "{fitted:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_crop_still_fits_inside_the_straightened_safe_area() {
|
||||
// The two new pieces meet here: an angle shrinks the safe area, and a
|
||||
// ratio the user locked has to survive being fitted into it.
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, 7.0);
|
||||
let bound = f.max_inscribed_crop(6000, 4000);
|
||||
|
||||
let locked = CropRect::default().with_aspect(6000, 4000, 1.0, (0.5, 0.5));
|
||||
let fitted = locked.fitted_into(bound);
|
||||
|
||||
assert!(
|
||||
(pixel_ratio(fitted, 6000, 4000) - 1.0).abs() < 1e-3,
|
||||
"the lock did not survive the fit: {fitted:?}"
|
||||
);
|
||||
assert!(
|
||||
fitted.x >= bound.x - 1e-5
|
||||
&& fitted.y >= bound.y - 1e-5
|
||||
&& fitted.x + fitted.width <= bound.x + bound.width + 1e-5
|
||||
&& fitted.y + fitted.height <= bound.y + bound.height + 1e-5,
|
||||
"{fitted:?} is not inside {bound:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_into_the_whole_frame_is_the_identity() {
|
||||
// What makes the straighten correction reversible: at zero degrees the
|
||||
// safe area is the whole frame, so recomputing the crop from the
|
||||
// user's own rectangle hands it straight back, bit for bit.
|
||||
for rect in [
|
||||
CropRect::default(),
|
||||
CropRect {
|
||||
x: 0.1,
|
||||
y: 0.2,
|
||||
width: 0.3,
|
||||
height: 0.4,
|
||||
},
|
||||
CropRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 0.5,
|
||||
},
|
||||
] {
|
||||
assert_eq!(rect.fitted_into(CropRect::default()), rect, "{rect:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_recomputed_from_intent_grows_back_as_the_angle_falls() {
|
||||
// The whole reason the correction is recomputed rather than
|
||||
// accumulated. Taking each angle's fit from the *previous* fit
|
||||
// ratchets — the excursion to 20 degrees is never given back — where
|
||||
// taking it from the user's own rectangle every time returns it.
|
||||
let intended = CropRect::default();
|
||||
let bound_at = |deg: f32| {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, deg);
|
||||
f.max_inscribed_crop(6000, 4000)
|
||||
};
|
||||
|
||||
let out = intended.fitted_into(bound_at(20.0));
|
||||
let back = intended.fitted_into(bound_at(3.0));
|
||||
assert!(
|
||||
back.width > out.width && back.height > out.height,
|
||||
"3 degrees should give more back than 20 took: {out:?} -> {back:?}"
|
||||
);
|
||||
|
||||
// And all the way home.
|
||||
assert_eq!(intended.fitted_into(bound_at(0.0)), intended);
|
||||
|
||||
// The ratcheting version, for contrast: chaining the fits never
|
||||
// recovers, which is the behaviour this arrangement exists to avoid.
|
||||
let chained = out.fitted_into(bound_at(3.0));
|
||||
assert!(
|
||||
chained.width <= out.width + 1e-6,
|
||||
"a chained fit must not grow — that is the bug being pinned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_round_trip() {
|
||||
let mut f = Framing::new();
|
||||
@@ -1896,7 +2319,7 @@ mod tests {
|
||||
// And the sampler's last step, which lives in `operation.rs` and is
|
||||
// the half of the map this file does not emit.
|
||||
assert!(
|
||||
crate::operation::sample_source(false).contains("p / aspect + vec2<f32>(0.5)"),
|
||||
crate::operation::sample_source(false, false).contains("p / aspect + vec2<f32>(0.5)"),
|
||||
"the sampler's return to texture coordinates moved"
|
||||
);
|
||||
}
|
||||
|
||||
+687
-12
@@ -1,3 +1,4 @@
|
||||
//! TRACES: FR-DEV-1
|
||||
//! The edit graph — an ordered set of operations (ARCH §3.4).
|
||||
//!
|
||||
//! CPU-side state, deliberately. The GPU device can be lost and rebuilt at any
|
||||
@@ -13,8 +14,9 @@ use crate::descriptor::{
|
||||
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
|
||||
};
|
||||
use crate::framing::{CropRect, Framing};
|
||||
use crate::lens::LensProfile;
|
||||
use crate::mask::MaskStack;
|
||||
use crate::operation::{compose_full, ComposedShader, Operation};
|
||||
use crate::operation::{compose_full, ComposedShader, Operation, Uniform};
|
||||
use crate::ops;
|
||||
use crate::preset::{Preset, Scope};
|
||||
use crate::spot::SpotSet;
|
||||
@@ -119,6 +121,48 @@ pub struct EditGraph {
|
||||
/// *where* an adjustment applies, and a spot says where a piece of the
|
||||
/// photograph comes from.
|
||||
spots: SpotSet,
|
||||
/// The lens corrections that rewrite coordinates: distortion and lateral
|
||||
/// chromatic aberration (`docs/architecture.md` §5.2).
|
||||
///
|
||||
/// Apart from `ops` for the fourth time, and this one is not about shape
|
||||
/// but about direction. Every [`Operation`] is a function from colour to
|
||||
/// colour, and these run *before* a colour exists: they decide which
|
||||
/// source pixel is read, and CA decides it three times over. See
|
||||
/// [`crate::lens`] for why that cannot be expressed as an operation.
|
||||
///
|
||||
/// Beside [`Self::framing`] in every way that matters — the two compose
|
||||
/// into one coordinate map, and neither can be applied without the other's
|
||||
/// result — but held separately because framing also changes the output's
|
||||
/// dimensions, which a warp never does.
|
||||
warps: Vec<Box<dyn crate::lens::Warp>>,
|
||||
/// The lens profile the corrections above were given, if any.
|
||||
///
|
||||
/// Kept as well as fanned out, because the corrections hold it in a form
|
||||
/// nothing can read back: each has folded its own share of the profile
|
||||
/// into private coefficients. Something has to be able to answer "is this
|
||||
/// photograph corrected from a measurement, or by hand?" — the interface
|
||||
/// is required to say so plainly rather than let an automatic correction
|
||||
/// silently do nothing — and this is the only place that can.
|
||||
///
|
||||
/// Not in [`EditState`], not in the sidecar, and not undoable: it is
|
||||
/// derived from the file's EXIF and a database, exactly as the film's
|
||||
/// baked tables are derived from a stock's id.
|
||||
lens_profile: Option<LensProfile>,
|
||||
/// Whether the profile above is being used.
|
||||
///
|
||||
/// **The one part of automatic lens correction that is an edit.** The
|
||||
/// coefficients are a measurement of the lens and belong to the file;
|
||||
/// whether to accept them is the photographer's, and it is the answer a
|
||||
/// tick box in the panel gives. So this rides with the parameters rather
|
||||
/// than with the profile: it is published as
|
||||
/// [`crate::lens::profile_switch`], captured by [`Preset`], stored in the
|
||||
/// sidecar and replayed by the undo stack, all by the one road every other
|
||||
/// setting travels (FR-DEV-3c).
|
||||
///
|
||||
/// True on a fresh graph, because a profile that was found is a
|
||||
/// correction the photograph asked for — see
|
||||
/// [`crate::descriptor::ParamDescriptor::switch_on`].
|
||||
lens_profile_applied: bool,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
@@ -163,6 +207,18 @@ impl EditGraph {
|
||||
masks: Arc::new(MaskStack::new()),
|
||||
film: None,
|
||||
spots: SpotSet::new(),
|
||||
// Distortion first, then CA, and the order is the correction's
|
||||
// rather than a preference. Each warp receives the position the
|
||||
// previous one produced, and lateral CA is a magnification about
|
||||
// the optical axis of the *undistorted* frame — measured on a
|
||||
// barrel-distorted one it would be fitted to a radius the lens
|
||||
// profile does not describe.
|
||||
warps: vec![
|
||||
Box::new(crate::ops::Distortion::new()),
|
||||
Box::new(crate::ops::Aberration::new()),
|
||||
],
|
||||
lens_profile: None,
|
||||
lens_profile_applied: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +303,93 @@ impl EditGraph {
|
||||
self.ops.iter().map(|o| o.descriptor()).collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Apply a lens profile's measured coefficients to every correction that
|
||||
/// wants some, or clear them all with `None`.
|
||||
///
|
||||
/// **Derived state, not an edit.** A profile comes from the file's EXIF
|
||||
/// plus a database this crate does not link, so it is not a parameter, is
|
||||
/// not in the sidecar, and is not undoable. What *is* an edit is the manual
|
||||
/// trim beside it: each correction composes the profile with its own
|
||||
/// slider, so a photographer can lean on the measurement, override it, or
|
||||
/// work without one.
|
||||
///
|
||||
/// **Clearing matters as much as setting.** Opening a photograph from an
|
||||
/// unrecognised lens must pass `None` rather than simply not calling this:
|
||||
/// a graph reused across images would otherwise correct this frame for the
|
||||
/// optics of the last one, which is both wrong and invisible.
|
||||
///
|
||||
/// Fans out over the warps and the operations alike. Which trait a
|
||||
/// correction implements is a fact about where it sits relative to the
|
||||
/// fetch, and no business of the caller's — see
|
||||
/// [`crate::Operation::set_lens_profile`].
|
||||
pub fn set_lens_profile(&mut self, profile: Option<LensProfile>) {
|
||||
self.lens_profile = profile;
|
||||
self.fan_out_lens_profile();
|
||||
}
|
||||
|
||||
/// The profile this photograph was matched to, if any.
|
||||
///
|
||||
/// Reports what was *found*, not what is in effect: a profile the
|
||||
/// photographer has switched off is still the profile for this lens, and
|
||||
/// the switch is what says whether it is being used. A caller wanting the
|
||||
/// coefficients that are actually in the shader asks
|
||||
/// [`Self::lens_profile_applied`] as well — which is what the interface's
|
||||
/// lens line does, because "corrected" and "correction available, off" are
|
||||
/// two different things to tell a photographer.
|
||||
pub fn lens_profile(&self) -> Option<&LensProfile> {
|
||||
self.lens_profile.as_ref()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Whether the matched profile is being applied.
|
||||
pub fn lens_profile_applied(&self) -> bool {
|
||||
self.lens_profile_applied
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Use the matched profile, or decline it.
|
||||
///
|
||||
/// Reached through `set_param` by the panel, like every other setting;
|
||||
/// this is the named door for a caller that has a `bool` rather than a
|
||||
/// parameter id.
|
||||
///
|
||||
/// Setting this with no profile matched is meaningful and harmless: a
|
||||
/// preset carrying the switch may land on a photograph whose lens the
|
||||
/// database has never heard of, and remembering the answer costs nothing.
|
||||
pub fn set_lens_profile_applied(&mut self, applied: bool) {
|
||||
self.lens_profile_applied = applied;
|
||||
self.fan_out_lens_profile();
|
||||
}
|
||||
|
||||
/// Hand every correction the coefficients it should be using.
|
||||
///
|
||||
/// `None` where the switch is off, which is the same call opening an
|
||||
/// unrecognised lens makes — the corrections cannot tell the difference
|
||||
/// between "no profile" and "not this one, thank you", and have no reason
|
||||
/// to.
|
||||
fn fan_out_lens_profile(&mut self) {
|
||||
let profile = self.lens_profile.filter(|_| self.lens_profile_applied);
|
||||
for warp in &mut self.warps {
|
||||
warp.set_profile(profile.as_ref());
|
||||
}
|
||||
for op in &mut self.ops {
|
||||
op.set_lens_profile(profile.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Descriptors for the coordinate-domain lens corrections, in order.
|
||||
///
|
||||
/// The counterpart to [`Self::descriptors`] and split from it for the same
|
||||
/// reason framing is absent there: a warp emits its own block in the
|
||||
/// generated shader rather than a colour fragment, so the codegen tests
|
||||
/// that count `---- ` markers have to know which kind they are counting.
|
||||
/// A UI wanting everything still reads [`Self::capabilities`], where all
|
||||
/// three kinds arrive together and indistinguishably (FR-DEV-3a).
|
||||
pub fn warp_descriptors(&self) -> Vec<Arc<OpDescriptor>> {
|
||||
self.warps.iter().map(|w| w.descriptor()).collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a | FR-DEV-3c
|
||||
/// Everything a UI needs to build its controls.
|
||||
///
|
||||
@@ -285,6 +428,39 @@ impl EditGraph {
|
||||
}
|
||||
});
|
||||
|
||||
// The lens corrections first, matching where they sit in the shader:
|
||||
// they rewrite the coordinate before any colour is fetched, so nothing
|
||||
// below them can be judged until they are right. It also puts the
|
||||
// three optical corrections together in the panel — these two and the
|
||||
// vignetting node, which is an ordinary operation and arrives above
|
||||
// through `ops`.
|
||||
let warps = self.warps.iter().map(|w| {
|
||||
let desc = w.descriptor();
|
||||
OpCapability {
|
||||
id: desc.id,
|
||||
label: desc.label,
|
||||
active: w.is_active(),
|
||||
params: desc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| ParamCapability {
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
kind: p.kind.clone(),
|
||||
default: p.default,
|
||||
value: w.param(p.id),
|
||||
facet: p.facet,
|
||||
})
|
||||
.collect(),
|
||||
// No preferred widget. A distortion amount and the two CA
|
||||
// scales are ordinary scalars, and plain sliders — the
|
||||
// fallback every frontend implements — are the right control
|
||||
// for them.
|
||||
presentation: None,
|
||||
attributes: desc.attributes.clone(),
|
||||
}
|
||||
});
|
||||
|
||||
// Framing last, matching where it sits in the pipeline: the crop is
|
||||
// decided after the image looks right, not before.
|
||||
let desc = self.framing.descriptor();
|
||||
@@ -313,7 +489,73 @@ impl EditGraph {
|
||||
attributes: desc.attributes.clone(),
|
||||
};
|
||||
|
||||
ops.chain(std::iter::once(framing)).collect()
|
||||
// The lens profile switch, ahead of the corrections it drives — and
|
||||
// **only when a profile was matched**. A tick box on a photograph
|
||||
// whose lens the database has never heard of would be a control that
|
||||
// does nothing, which is the failure `dr_lens` names: an automatic
|
||||
// correction is allowed to be unavailable and is not allowed to look
|
||||
// available and be inert. Where there is no profile the interface says
|
||||
// so in words instead.
|
||||
let switch = self.lens_profile.map(|_| {
|
||||
let desc = crate::lens::profile_switch::descriptor();
|
||||
OpCapability {
|
||||
id: desc.id,
|
||||
label: desc.label,
|
||||
// A profile that is on is doing something to this photograph,
|
||||
// which is what the panel's modified marker is for. Off is the
|
||||
// departure from default, and reads as one either way.
|
||||
active: self.lens_profile_applied,
|
||||
params: desc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| ParamCapability {
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
kind: p.kind.clone(),
|
||||
default: p.default,
|
||||
value: if self.lens_profile_applied { 1.0 } else { 0.0 },
|
||||
facet: p.facet,
|
||||
})
|
||||
.collect(),
|
||||
// An ordinary switch. Nothing about it wants the canvas.
|
||||
presentation: None,
|
||||
attributes: desc.attributes.clone(),
|
||||
}
|
||||
});
|
||||
|
||||
switch
|
||||
.into_iter()
|
||||
.chain(warps)
|
||||
.chain(ops)
|
||||
.chain(std::iter::once(framing))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a
|
||||
/// What one operation's uniforms currently evaluate to.
|
||||
///
|
||||
/// The same numbers [`Self::compose`] would bake into the uniform block,
|
||||
/// asked for one node rather than for the whole chain. `None` where no
|
||||
/// operation carries that id — the framing and the lens warps are not in
|
||||
/// `ops`, and neither publishes uniforms of this kind.
|
||||
///
|
||||
/// **Why anything outside composition wants these.** A widget that reads
|
||||
/// the photograph rather than driving it — an eyedropper, above all — has
|
||||
/// to invert the operation: it knows what the pixel is and what it should
|
||||
/// become, and needs the parameter values that get it there. The mapping
|
||||
/// from parameters to effect lives in the node's own declaration, and
|
||||
/// this is the only way to ask it what that mapping currently says
|
||||
/// without composing a shader and rendering one. See [`crate::neutral`],
|
||||
/// which is the one caller.
|
||||
///
|
||||
/// Cheap: a declared node evaluates a handful of small arithmetic
|
||||
/// expressions. It is not, however, a per-frame path, and it is not on
|
||||
/// one — composition reads the same values by its own route.
|
||||
pub fn uniforms_of(&self, op: OpId) -> Option<Vec<Uniform>> {
|
||||
self.ops
|
||||
.iter()
|
||||
.find(|o| o.descriptor().id == op)
|
||||
.map(|o| o.uniforms())
|
||||
}
|
||||
|
||||
/// Set a parameter, clamping to the descriptor's declared range.
|
||||
@@ -361,6 +603,20 @@ impl EditGraph {
|
||||
// nothing to register (FR-DEV-3c).
|
||||
ops: _,
|
||||
framing: _,
|
||||
// Reached through `capabilities` with the other two. A warp's
|
||||
// parameters are ordinary scalars once they are in that list, so
|
||||
// the sidecar, the clipboard and the undo stack carry them with
|
||||
// nothing registered anywhere (FR-DEV-3c).
|
||||
warps: _,
|
||||
// Derived from the file and a database, so it is rebuilt on open
|
||||
// rather than restored — the same reason the film's tables travel
|
||||
// as an id and not as numbers.
|
||||
lens_profile: _,
|
||||
// Whether that profile is *used* is an edit, and it is in the
|
||||
// state below: it reaches `Preset::capture` through
|
||||
// `capabilities`, with the operations and the warps and for the
|
||||
// same reason (FR-DEV-3c).
|
||||
lens_profile_applied: _,
|
||||
masks,
|
||||
film,
|
||||
spots,
|
||||
@@ -407,7 +663,7 @@ impl EditGraph {
|
||||
// At full scope. `Scope` is a question about what a paste carries
|
||||
// *between* photographs; this is one photograph's own edit being put
|
||||
// back, so there is nothing to leave behind.
|
||||
params.apply(self, Scope::Everything);
|
||||
params.apply(self, Scope::everything());
|
||||
|
||||
self.masks = Arc::clone(masks);
|
||||
self.spots = spots.clone();
|
||||
@@ -426,6 +682,18 @@ impl EditGraph {
|
||||
}
|
||||
|
||||
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
|
||||
if op == crate::lens::profile_switch::ID {
|
||||
if param != crate::lens::profile_switch::APPLY {
|
||||
log::warn!("unknown parameter {param} on {op}; ignoring");
|
||||
return;
|
||||
}
|
||||
// Accepted whether or not a profile was matched, for the reason
|
||||
// `set_lens_profile_applied` gives: a preset may carry the answer
|
||||
// to a photograph that has nothing to apply it to.
|
||||
self.set_lens_profile_applied(value != 0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
if op == crate::framing::ID {
|
||||
// Bound rather than chained: `descriptor()` hands back an owned
|
||||
// `Arc` now, so a `param()` borrowed straight out of the call
|
||||
@@ -439,6 +707,20 @@ impl EditGraph {
|
||||
return;
|
||||
}
|
||||
|
||||
// The warps, before the operations. Their ids cannot collide with an
|
||||
// operation's — `ops/` and the warp list are disjoint by construction,
|
||||
// and `declared_parity` asserts the chain is exactly what `ops/`
|
||||
// declares — so the order is for readability rather than precedence.
|
||||
if let Some(warp) = self.warps.iter_mut().find(|w| w.descriptor().id == op) {
|
||||
let descriptor = warp.descriptor();
|
||||
let Some(desc) = descriptor.param(param) else {
|
||||
log::warn!("unknown parameter {param} on {op}; ignoring");
|
||||
return;
|
||||
};
|
||||
warp.set_param(param, desc.clamp(value));
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(operation) = self.ops.iter_mut().find(|o| o.descriptor().id == op) else {
|
||||
// A sidecar naming an operation this build does not have. The
|
||||
// rest of the edit must still apply.
|
||||
@@ -457,6 +739,10 @@ impl EditGraph {
|
||||
|
||||
/// Read a parameter back.
|
||||
pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> {
|
||||
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 });
|
||||
}
|
||||
if op == crate::framing::ID {
|
||||
return self
|
||||
.framing
|
||||
@@ -464,6 +750,9 @@ impl EditGraph {
|
||||
.param(param)
|
||||
.map(|_| self.framing.param(param));
|
||||
}
|
||||
if let Some(warp) = self.warps.iter().find(|w| w.descriptor().id == op) {
|
||||
return warp.descriptor().param(param).map(|_| warp.param(param));
|
||||
}
|
||||
self.ops
|
||||
.iter()
|
||||
.find(|o| o.descriptor().id == op)
|
||||
@@ -477,6 +766,11 @@ impl EditGraph {
|
||||
op.set_param(p.id, p.default);
|
||||
}
|
||||
}
|
||||
for warp in &mut self.warps {
|
||||
for p in &warp.descriptor().params {
|
||||
warp.set_param(p.id, p.default);
|
||||
}
|
||||
}
|
||||
self.framing.reset();
|
||||
// Masks go too, and this is why `apply` can be a replacement rather
|
||||
// than an overlay: a sidecar with no mask blocks means an edit with no
|
||||
@@ -487,6 +781,10 @@ impl EditGraph {
|
||||
// stock, and turning a name into tables needs the profile database,
|
||||
// which this crate deliberately does not link (ARCH §6.5a).
|
||||
self.set_film(None);
|
||||
// The *matched profile* stays — it is the file's, not the edit's, and
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// Set the crop rectangle. Clamped to keep it inside the frame.
|
||||
@@ -541,7 +839,43 @@ impl EditGraph {
|
||||
/// graph renders to the screen and to a file in the same breath, and the
|
||||
/// two want different answers.
|
||||
pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader {
|
||||
compose_full(&self.ops, &self.framing, output, &self.masks, &self.spots)
|
||||
compose_full(
|
||||
&self.ops,
|
||||
&self.framing,
|
||||
output,
|
||||
&self.masks,
|
||||
&self.spots,
|
||||
&self.warps,
|
||||
)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// [`Self::compose_for`], with one layer's mask drawn over the picture.
|
||||
///
|
||||
/// **The screen's composition, and only the screen's.** The reveal is not
|
||||
/// on the graph and cannot be: it is how a photographer is looking at an
|
||||
/// edit, not part of one, so it arrives as an argument to the one call
|
||||
/// that draws the canvas. Every other path through this type composes
|
||||
/// without it and could not ask for it if it wanted to.
|
||||
///
|
||||
/// The revealed layer renders whether or not it carries an adjustment —
|
||||
/// which is the whole point, since a fresh selection carries none — so the
|
||||
/// mask array must be rasterised for the same `reveal`. See
|
||||
/// [`crate::mask::MaskStack::rendered`] for what the two have to agree on.
|
||||
pub fn compose_revealing(
|
||||
&self,
|
||||
output: dr_types::ColourSpace,
|
||||
reveal: Option<&crate::mask::Reveal>,
|
||||
) -> ComposedShader {
|
||||
crate::operation::compose_full_revealing(
|
||||
&self.ops,
|
||||
&self.framing,
|
||||
output,
|
||||
&self.masks,
|
||||
&self.spots,
|
||||
&self.warps,
|
||||
reveal,
|
||||
)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-1
|
||||
@@ -636,6 +970,22 @@ impl EditGraph {
|
||||
u64::from(crate::operation::canonical_bits(self.framing.param(p.id))),
|
||||
);
|
||||
}
|
||||
// The warps belong to the geometry key, not the colour one: they decide
|
||||
// which source pixel a colour is read from, so a cached *result* of
|
||||
// this stage is wrong the moment one moves. Hashed by id as well as by
|
||||
// value, so two warps swapping their amounts is not the same edit.
|
||||
for warp in &self.warps {
|
||||
let descriptor = warp.descriptor();
|
||||
geometry = hash_bytes(geometry, descriptor.id.0.as_bytes());
|
||||
for p in &descriptor.params {
|
||||
geometry = hash_bytes(geometry, p.id.0.as_bytes());
|
||||
geometry = mix(
|
||||
geometry,
|
||||
u64::from(crate::operation::canonical_bits(warp.param(p.id))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The view rect is not a parameter and not in the structure key — it
|
||||
// is not an edit (see `Framing::view`). It is still an input to every
|
||||
// rendered pixel, so a cache that ignored it would show the wrong part
|
||||
@@ -668,12 +1018,26 @@ impl EditGraph {
|
||||
// out of step the first time a variant gains a field — silently,
|
||||
// and showing as a mask that stops updating. `Debug` cannot fall
|
||||
// out of step, because it is derived from the definition itself.
|
||||
colour = hash_bytes(colour, format!("{:?}", layer.source).as_bytes());
|
||||
colour = mix(colour, u64::from(layer.enabled));
|
||||
colour = mix(colour, u64::from(layer.invert));
|
||||
colour = hash_bytes(colour, layer.falloff.name().as_bytes());
|
||||
for v in [layer.opacity, layer.feather, layer.morph_radius] {
|
||||
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
|
||||
colour = mix(
|
||||
colour,
|
||||
u64::from(crate::operation::canonical_bits(layer.opacity)),
|
||||
);
|
||||
// Every part, in order, because the mask is the fold over them: a
|
||||
// part added, removed, reshaped or joined the other way round is a
|
||||
// different shape even when nothing else moved. The order is
|
||||
// folded in by construction — the same parts joined the other way
|
||||
// round hash differently because they arrive in the other order.
|
||||
for part in layer.parts() {
|
||||
colour = hash_bytes(colour, part.id.as_bytes());
|
||||
colour = hash_bytes(colour, part.join.name().as_bytes());
|
||||
colour = hash_bytes(colour, format!("{:?}", part.source).as_bytes());
|
||||
colour = mix(colour, u64::from(part.invert));
|
||||
colour = hash_bytes(colour, part.falloff.name().as_bytes());
|
||||
for v in [part.feather, part.morph_radius] {
|
||||
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
|
||||
}
|
||||
}
|
||||
for (op_id, param_id, value) in layer.params() {
|
||||
colour = hash_bytes(colour, op_id.as_bytes());
|
||||
@@ -822,20 +1186,331 @@ mod tests {
|
||||
assert_ne!(first.uniforms, second.uniforms);
|
||||
}
|
||||
|
||||
/// The whole point of putting the warps in `capabilities`: everything that
|
||||
/// walks that list carries them, with nothing registered anywhere.
|
||||
#[test]
|
||||
fn a_warp_is_carried_by_the_machinery_it_never_told_about_itself() {
|
||||
use crate::ops::{aberration, distortion};
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
|
||||
g.set_param(aberration::ID, aberration::RED, 25.0);
|
||||
|
||||
assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(40.0));
|
||||
assert_eq!(g.param(aberration::ID, aberration::RED), Some(25.0));
|
||||
|
||||
// Through the same capture/apply the sidecar, the clipboard and the
|
||||
// undo stack all use.
|
||||
let state = g.state();
|
||||
let mut restored = EditGraph::default_chain();
|
||||
// No film in this graph, so nothing is owed; the result is asserted
|
||||
// rather than dropped because ignoring it elsewhere would leave a
|
||||
// photograph rendering without its stock.
|
||||
assert_eq!(restored.set_state(&state), FilmRebake::NotNeeded);
|
||||
|
||||
assert_eq!(
|
||||
restored.param(distortion::ID, distortion::AMOUNT),
|
||||
Some(40.0),
|
||||
"a distortion correction did not survive a state round trip, so \
|
||||
reopening the photograph would silently drop it"
|
||||
);
|
||||
assert_eq!(restored.param(aberration::ID, aberration::RED), Some(25.0));
|
||||
}
|
||||
|
||||
/// A profile has to reach all three corrections, across both traits.
|
||||
///
|
||||
/// The failure this guards is the quiet one: a profile that reached the
|
||||
/// warps and not the vignetting node would correct the geometry and leave
|
||||
/// the corners dark, which looks like an under-corrected lens rather than
|
||||
/// like a wiring fault.
|
||||
#[test]
|
||||
fn a_lens_profile_reaches_every_correction_that_wants_one() {
|
||||
use crate::lens::{LensProfile, Tca};
|
||||
use crate::ops::{aberration, distortion, vignetting};
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
for id in [distortion::ID, aberration::ID, vignetting::ID] {
|
||||
assert_eq!(
|
||||
g.param(id, ParamId("amount")).unwrap_or(0.0),
|
||||
0.0,
|
||||
"{id} should start neutral"
|
||||
);
|
||||
}
|
||||
|
||||
g.set_lens_profile(Some(LensProfile {
|
||||
distortion: Some(distortion::PtLens {
|
||||
a: 0.0,
|
||||
b: -0.012,
|
||||
c: 0.0,
|
||||
}),
|
||||
tca: Some(Tca {
|
||||
red_scale: 1.000_32,
|
||||
blue_scale: 0.999_93,
|
||||
}),
|
||||
vignetting: Some(vignetting::Pa {
|
||||
k1: -0.42,
|
||||
k2: 0.05,
|
||||
k3: 0.0,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Every correction is now doing something, with every slider still at
|
||||
// its default — which is the whole point of a profile.
|
||||
let source = g.compose().source;
|
||||
for marker in [
|
||||
"---- warp: distortion ----",
|
||||
"---- warp: aberration ----",
|
||||
"---- vignetting ----",
|
||||
] {
|
||||
assert!(
|
||||
source.contains(marker),
|
||||
"a profile did not reach {marker}: {source}"
|
||||
);
|
||||
}
|
||||
|
||||
// And clearing it puts the photograph back, which is what opening an
|
||||
// image from an unrecognised lens has to do.
|
||||
g.set_lens_profile(None);
|
||||
let cleared = g.compose().source;
|
||||
assert!(!cleared.contains("---- warp: "));
|
||||
assert!(!cleared.contains("---- vignetting ----"));
|
||||
assert!(g.lens_profile().is_none());
|
||||
}
|
||||
|
||||
/// A measured profile, for the switch's tests. Coefficients are one real
|
||||
/// wide-angle's, rounded — what matters is that each of the three
|
||||
/// corrections gets something to do.
|
||||
fn measured() -> crate::lens::LensProfile {
|
||||
use crate::lens::{LensProfile, Tca};
|
||||
use crate::ops::{distortion, vignetting};
|
||||
|
||||
LensProfile {
|
||||
distortion: Some(distortion::PtLens {
|
||||
a: 0.0,
|
||||
b: -0.012,
|
||||
c: 0.0,
|
||||
}),
|
||||
tca: Some(Tca {
|
||||
red_scale: 1.000_32,
|
||||
blue_scale: 0.999_93,
|
||||
}),
|
||||
vignetting: Some(vignetting::Pa {
|
||||
k1: -0.42,
|
||||
k2: 0.05,
|
||||
k3: 0.0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The switch takes the whole profile out of the shader, and puts it back.
|
||||
///
|
||||
/// The control the panel draws is this call, and what it has to mean is
|
||||
/// "develop this photograph as if the database had never heard of the
|
||||
/// lens" — all three corrections, not the geometry alone.
|
||||
#[test]
|
||||
fn declining_the_profile_removes_every_correction_it_was_driving() {
|
||||
use crate::lens::profile_switch;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_lens_profile(Some(measured()));
|
||||
assert!(g.compose().source.contains("---- warp: distortion ----"));
|
||||
|
||||
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
||||
let declined = g.compose().source;
|
||||
assert!(!declined.contains("---- warp: "), "{declined}");
|
||||
assert!(!declined.contains("---- vignetting ----"));
|
||||
|
||||
// The profile itself is untouched. It is a fact about the file, and
|
||||
// the interface still has to be able to say which lens this was.
|
||||
assert!(
|
||||
g.lens_profile().is_some(),
|
||||
"declining a profile must not forget it, or the switch could \
|
||||
never be turned back on"
|
||||
);
|
||||
|
||||
g.set_param(profile_switch::ID, profile_switch::APPLY, 1.0);
|
||||
assert!(g.compose().source.contains("---- warp: distortion ----"));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The manual trims keep working with the profile declined.
|
||||
///
|
||||
/// The two are independent by construction — each correction composes the
|
||||
/// profile with its own slider — and that is what makes the switch safe to
|
||||
/// offer: turning it off is "correct this by hand", not "stop correcting".
|
||||
#[test]
|
||||
fn declining_the_profile_leaves_the_manual_corrections_alone() {
|
||||
use crate::lens::profile_switch;
|
||||
use crate::ops::distortion;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_lens_profile(Some(measured()));
|
||||
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
|
||||
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
||||
|
||||
assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(40.0));
|
||||
assert!(
|
||||
g.compose().source.contains("---- warp: distortion ----"),
|
||||
"the slider still bends the frame with the profile switched off"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The switch is only offered where there is a profile to switch.
|
||||
///
|
||||
/// `dr_lens`'s rule, as a property of the capability list: a tick box on a
|
||||
/// photograph whose lens the database has never heard of would be a
|
||||
/// control that looks available and does nothing, which is the failure the
|
||||
/// automatic correction is supposed to avoid rather than an instance of
|
||||
/// it.
|
||||
#[test]
|
||||
fn the_switch_is_absent_until_a_profile_is_matched() {
|
||||
use crate::lens::profile_switch;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
assert!(
|
||||
!g.capabilities().iter().any(|c| c.id == profile_switch::ID),
|
||||
"an unmatched lens must not grow a tick box"
|
||||
);
|
||||
|
||||
g.set_lens_profile(Some(measured()));
|
||||
let cap = g
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id == profile_switch::ID)
|
||||
.expect("a matched profile is offered as a control");
|
||||
assert_eq!(cap.params.len(), 1);
|
||||
assert_eq!(cap.params[0].kind, ParamKind::Bool);
|
||||
// On, and on is the default — so a photograph nobody has touched
|
||||
// carries nothing in its sidecar and is still corrected.
|
||||
assert_eq!(cap.params[0].value, 1.0);
|
||||
assert_eq!(cap.params[0].default, 1.0);
|
||||
assert!(!cap.params[0].is_modified());
|
||||
|
||||
// Ahead of the corrections it drives, so the panel reads top down:
|
||||
// the profile, then what to add to it by hand.
|
||||
let ids: Vec<&str> = g.capabilities().iter().map(|c| c.id.0).collect();
|
||||
assert_eq!(ids.first(), Some(&profile_switch::ID.0));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-DEV-5
|
||||
/// Declining the profile is an edit, so it travels like one.
|
||||
///
|
||||
/// The reason it is a parameter at all: capture and apply are the road the
|
||||
/// sidecar, the clipboard and the undo stack all take, and a `bool` on the
|
||||
/// side would have had to be added to each of them by hand.
|
||||
#[test]
|
||||
fn the_switch_survives_a_state_round_trip() {
|
||||
use crate::lens::profile_switch;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_lens_profile(Some(measured()));
|
||||
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
||||
|
||||
let state = g.state();
|
||||
|
||||
// Reopened: the profile is looked up again from the file, and the
|
||||
// stored edit says what to do with it.
|
||||
let mut reopened = EditGraph::default_chain();
|
||||
reopened.set_lens_profile(Some(measured()));
|
||||
assert_eq!(reopened.set_state(&state), FilmRebake::NotNeeded);
|
||||
|
||||
assert_eq!(
|
||||
reopened.param(profile_switch::ID, profile_switch::APPLY),
|
||||
Some(0.0),
|
||||
"a declined profile came back applied, so reopening the \
|
||||
photograph would silently correct it again"
|
||||
);
|
||||
assert!(!reopened.compose().source.contains("---- warp: "));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// A reset accepts the profile again, and keeps it.
|
||||
#[test]
|
||||
fn resetting_returns_to_the_measured_profile() {
|
||||
use crate::lens::profile_switch;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_lens_profile(Some(measured()));
|
||||
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
||||
|
||||
g.reset();
|
||||
|
||||
assert!(g.lens_profile().is_some(), "the file still names a lens");
|
||||
assert!(g.lens_profile_applied());
|
||||
assert!(g.compose().source.contains("---- warp: distortion ----"));
|
||||
}
|
||||
|
||||
/// A warp is an edit, so `reset` has to reach it. It did not until the
|
||||
/// loop was added: a reset that left the lens corrections standing would
|
||||
/// mean "back to the file as it is" quietly did not mean that.
|
||||
#[test]
|
||||
fn resetting_the_graph_neutralises_the_warps() {
|
||||
use crate::ops::distortion;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
|
||||
g.reset();
|
||||
|
||||
assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(0.0));
|
||||
}
|
||||
|
||||
/// The warps belong to the geometry key, not the colour one.
|
||||
///
|
||||
/// A tile cache keyed on geometry holds the *result* of the coordinate
|
||||
/// stage. Moving a distortion slider changes which source pixel every
|
||||
/// output pixel reads, so a cache that did not notice would keep drawing
|
||||
/// the previous correction — visibly, and only where it had already
|
||||
/// cached.
|
||||
#[test]
|
||||
fn a_warp_moves_the_geometry_key_and_leaves_the_others_alone() {
|
||||
use crate::operation::Affects;
|
||||
use crate::ops::distortion;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
let before = g.invalidation();
|
||||
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
|
||||
let after = g.invalidation();
|
||||
|
||||
assert_ne!(
|
||||
before.of(Affects::Geometry),
|
||||
after.of(Affects::Geometry),
|
||||
"a distortion change must invalidate the geometry stage"
|
||||
);
|
||||
assert_eq!(
|
||||
before.of(Affects::Colour),
|
||||
after.of(Affects::Colour),
|
||||
"and must not invalidate the colour stage, which it does not touch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_describe_every_operation_and_parameter() {
|
||||
// The UI builds its whole panel from this. Anything missing here is
|
||||
// something the UI would have to hardcode.
|
||||
let g = EditGraph::default_chain();
|
||||
let caps = g.capabilities();
|
||||
// Every operation, plus framing — which is not an operation and so
|
||||
// is absent from `descriptors`, but must still reach the panel.
|
||||
assert_eq!(caps.len(), g.descriptors().len() + 1);
|
||||
// Every operation, plus everything that is *not* an operation and so
|
||||
// is absent from `descriptors`: the framing, and the coordinate-domain
|
||||
// lens corrections. All of them have parameters a photographer sets,
|
||||
// so all of them have to reach the panel — and the panel is forbidden
|
||||
// from naming any of them (FR-DEV-3a), which leaves this list as the
|
||||
// only way they can arrive.
|
||||
assert_eq!(caps.len(), g.descriptors().len() + g.warps.len() + 1);
|
||||
assert!(
|
||||
caps.iter().any(|c| c.id == crate::framing::ID),
|
||||
"framing must appear in the capability list, or the UI cannot \
|
||||
build a crop control without naming it"
|
||||
);
|
||||
for warp in &g.warps {
|
||||
let id = warp.descriptor().id;
|
||||
assert!(
|
||||
caps.iter().any(|c| c.id == id),
|
||||
"{id} must appear in the capability list, or its correction is \
|
||||
in the shader with no control anywhere that can reach it"
|
||||
);
|
||||
}
|
||||
|
||||
for cap in &caps {
|
||||
assert!(!cap.params.is_empty(), "{} exposes no parameters", cap.id);
|
||||
@@ -1163,7 +1838,7 @@ mod tests {
|
||||
|
||||
// Moving the gradient is a different mask, so a different result.
|
||||
if let Some(layer) = g.masks_mut().get_mut("l1") {
|
||||
layer.source = MaskSource::Linear {
|
||||
layer.base_mut().source = MaskSource::Linear {
|
||||
centre: (0.2, 0.7),
|
||||
angle: 0.4,
|
||||
width: 0.2,
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
//! `(0, 0)`, and the radius is scaled so that `r == 1` at the corner. Both
|
||||
//! properties matter.
|
||||
//!
|
||||
//! Note which variable carries which. The shader's `p` supplies the centring
|
||||
//! and spans `±0.5 * aspect`; the corner normalisation is applied on top of it
|
||||
//! by `operation::sample_source`, which publishes the result as `radius`. A
|
||||
//! warp reading `length(p)` and calling it `r` would be evaluating its
|
||||
//! polynomial short of where the profile was fitted.
|
||||
//!
|
||||
//! Centring is what makes the polynomial meaningful — lens distortion is
|
||||
//! radially symmetric about the optical axis, so a formula written about any
|
||||
//! other origin would need cross terms to say the same thing.
|
||||
@@ -53,11 +59,95 @@
|
||||
//! wearing the same lens, which defeats the point of a lens profile.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{OpDescriptor, ParamId};
|
||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Helper, Uniform};
|
||||
|
||||
/// Lateral chromatic aberration, as a per-channel radial scale.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Tca {
|
||||
pub red_scale: f32,
|
||||
pub blue_scale: f32,
|
||||
}
|
||||
|
||||
/// What a lens profile says about one shot, in the pipeline's own types.
|
||||
///
|
||||
/// **Deliberately a mirror of `dr_lens::LensProfile` rather than that type
|
||||
/// itself.** The dependency would have to run the wrong way: `dr-lens` carries
|
||||
/// an XML parser and 5.5 MB of Lensfun data, and this crate is organised around
|
||||
/// having no dependencies so that its codegen is testable without a GPU or a
|
||||
/// database (ARCH §6.5a). Whatever sits above both does the conversion; it is
|
||||
/// nine fields and a `match`.
|
||||
///
|
||||
/// Every field is independently optional because the database is: it commonly
|
||||
/// carries distortion for a lens and no vignetting, or covers only part of a
|
||||
/// zoom. A partial profile is useful and must not be discarded wholesale.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct LensProfile {
|
||||
pub distortion: Option<crate::ops::distortion::PtLens>,
|
||||
pub tca: Option<Tca>,
|
||||
pub vignetting: Option<crate::ops::vignetting::Pa>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The lens profile itself, as one switch a photographer can reach.
|
||||
///
|
||||
/// **Not an operation and not a warp** — it corrects nothing on its own. It
|
||||
/// is the answer to "use the measured profile for this lens, or not", and the
|
||||
/// three corrections that read the coefficients ([`crate::ops::Distortion`],
|
||||
/// [`crate::ops::Aberration`] and the vignetting node) are where the
|
||||
/// correction actually happens. Framing is in the capability list on the same
|
||||
/// terms: something a photographer sets which is not an `Operation`.
|
||||
///
|
||||
/// # Why this exists at all
|
||||
///
|
||||
/// The profile arrives from the file's EXIF and a database, and applying it
|
||||
/// silently was the whole of the interface for it. That reads as the feature
|
||||
/// being absent: the corrections in the panel are manual sliders, the
|
||||
/// automatic part is a line of grey text under the camera, and nothing
|
||||
/// anywhere says "this is on and you may turn it off". `dr_lens`'s honesty
|
||||
/// rule — an automatic correction the user cannot see is worse than one they
|
||||
/// can see is unavailable — asks for a control here, not just a caption.
|
||||
///
|
||||
/// # Why it is a parameter rather than a flag on the session
|
||||
///
|
||||
/// Everything a photographer sets travels by one road: the capability list
|
||||
/// feeds the generated panel, [`crate::Preset`] captures it, the sidecar
|
||||
/// stores it and the undo stack replays it (FR-DEV-3c). A `bool` on the
|
||||
/// session would have needed its own place in each of those four, and would
|
||||
/// have been forgotten in at least one — which is exactly how the mask stack
|
||||
/// came to be missing from the history. As a parameter it is in all of them
|
||||
/// with nothing registered.
|
||||
pub mod profile_switch {
|
||||
use super::*;
|
||||
|
||||
pub const ID: OpId = OpId("lens_profile");
|
||||
pub const APPLY: ParamId = ParamId("apply");
|
||||
|
||||
/// On by default: the profile is a measurement of the lens that took the
|
||||
/// photograph, so applying it is the neutral state and declining it is
|
||||
/// the edit. See [`ParamDescriptor::switch_on`].
|
||||
pub(crate) static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.lens_profile"),
|
||||
params: vec![ParamDescriptor::switch_on(
|
||||
"apply",
|
||||
"param.lens_profile.apply",
|
||||
)],
|
||||
// Optics, so it sits with the corrections it drives rather than in
|
||||
// a group of its own.
|
||||
attributes: vec![Attribute::Optics],
|
||||
})
|
||||
});
|
||||
|
||||
/// This switch's description, for a caller building a control from it.
|
||||
pub fn descriptor() -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A coordinate-domain operation, applied before the source is sampled.
|
||||
///
|
||||
/// Object-safe for the same reason [`crate::operation::Operation`] is: the
|
||||
@@ -112,6 +202,20 @@ pub trait Warp: Send + Sync {
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Take whatever this warp needs from a lens profile.
|
||||
///
|
||||
/// Handed the *whole* profile rather than its own slice of it, so that the
|
||||
/// graph fanning one out does not have to know which correction wants
|
||||
/// which coefficients — the same reason an operation is handed a
|
||||
/// [`ParamId`] rather than a field. `None` clears any profile in place,
|
||||
/// which is what opening a photograph from an unrecognised lens must do:
|
||||
/// leaving the previous one standing would correct this frame for the
|
||||
/// optics of the last one.
|
||||
///
|
||||
/// Defaulted, because a warp need not be profile-driven. Nothing about the
|
||||
/// coordinate stage requires a database behind it.
|
||||
fn set_profile(&mut self, _profile: Option<&LensProfile>) {}
|
||||
}
|
||||
|
||||
/// The composed geometry stage: WGSL, uniforms, and what it needs from the
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//! TRACES: R3
|
||||
//! The develop pipeline — operations, descriptors, and shader composition.
|
||||
//!
|
||||
//! # What this crate is
|
||||
@@ -31,6 +32,7 @@
|
||||
//! single multiply and white balance a per-channel scale; on gamma-encoded
|
||||
//! data neither would be physically meaningful (ARCH §5.2).
|
||||
|
||||
pub mod coverage;
|
||||
pub mod declared;
|
||||
pub mod descriptor;
|
||||
pub mod detail;
|
||||
@@ -39,13 +41,16 @@ pub mod graph;
|
||||
pub mod history;
|
||||
pub mod lens;
|
||||
pub mod mask;
|
||||
pub mod neutral;
|
||||
pub mod operation;
|
||||
pub mod ops;
|
||||
pub mod preset;
|
||||
pub mod sidecar;
|
||||
pub mod spot;
|
||||
pub mod starter;
|
||||
pub mod state;
|
||||
|
||||
pub use coverage::Coverage;
|
||||
pub use declared::{Declaration, DeclaredOp};
|
||||
pub use descriptor::{
|
||||
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
|
||||
@@ -57,12 +62,12 @@ pub use detail::{
|
||||
pub use framing::{CropRect, Framing};
|
||||
pub use graph::{EditGraph, OpCapability, ParamCapability};
|
||||
pub use history::{Edit, Entry as HistoryEntry, History, Step};
|
||||
pub use lens::{compose_warps, ComposedWarp, Warp};
|
||||
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, RESERVED_UNIFORM_FIELDS,
|
||||
};
|
||||
pub use preset::{Preset, Scope};
|
||||
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
|
||||
pub use sidecar::{Sidecar, Version};
|
||||
pub use spot::{Spot, SpotMode, SpotSet};
|
||||
pub use state::{EditState, FilmRebake, FilmRef};
|
||||
|
||||
+1440
-213
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,391 @@
|
||||
//! TRACES: FR-DEV-3 | FR-DEV-3a
|
||||
//! Turning a colour that ought to be grey into white balance parameters.
|
||||
//!
|
||||
//! # Why this is in the core and not in the interface
|
||||
//!
|
||||
//! FR-DEV-3 asks for a white balance picker: point at something neutral and
|
||||
//! the correction follows. The pointing is the interface's — it owns the
|
||||
//! canvas, the pixel and the modality — but the *arithmetic* is not, and the
|
||||
//! reason is ARCH §4.3a rather than tidiness. How far a hundred units of
|
||||
//! temperature move red against blue is declared in
|
||||
//! `core/dr-pipeline/ops/white_balance.yaml`, in one expression, and an
|
||||
//! interface that inverted it would be holding a second copy of a number it
|
||||
//! is not allowed to know. The copy would then be wrong the first time
|
||||
//! somebody adjusted the range, silently, in the direction of "the picker is
|
||||
//! slightly off".
|
||||
//!
|
||||
//! So the interface hands over a colour and this hands back a moved graph.
|
||||
//!
|
||||
//! # What it is allowed to assume, which is a widget kind and not an operation
|
||||
//!
|
||||
//! Nothing here names white balance. It looks for the operation that asks for
|
||||
//! [`WidgetKind::WhitePoint`], and everything else is that widget kind's own
|
||||
//! contract — the same kind of contract [`WidgetKind::ToneCurve`] carries when
|
||||
//! it says its parameters are point coordinates interleaved:
|
||||
//!
|
||||
//! * The **first two parameters** the presentation names are the axes. The
|
||||
//! first trades red against blue, the second green against magenta.
|
||||
//! * The operation publishes **exactly three uniforms: the linear per-channel
|
||||
//! gains, in red, green, blue order.** That is what an eyedropper needs in
|
||||
//! order to be invertible at all, and it is the honest way to say so —
|
||||
//! a node whose effect on a grey is not three gains is not a node an
|
||||
//! eyedropper can drive, and declaring the widget on one is the mistake
|
||||
//! this refuses rather than approximates.
|
||||
//!
|
||||
//! Checked rather than trusted: a node that declares the widget and publishes
|
||||
//! four uniforms gets no picker, and its sliders go on working.
|
||||
//!
|
||||
//! # Why a search rather than an inversion
|
||||
//!
|
||||
//! The declared expression is arbitrary — `exp2` today, a table lookup or a
|
||||
//! polynomial tomorrow — and only its *monotonicity* is part of the bargain:
|
||||
//! warmer is warmer all the way along, or the slider is not a slider. A
|
||||
//! bisection needs exactly that and nothing more, so it stays correct across
|
||||
//! every expression the declaration language can grow, where a closed-form
|
||||
//! inverse would be a second definition of the node maintained here.
|
||||
//!
|
||||
//! It is also free. Twenty halvings of two ranges, three times over, is a few
|
||||
//! hundred evaluations of two small arithmetic expressions — on a click, not
|
||||
//! on a frame.
|
||||
|
||||
use crate::descriptor::{OpId, ParamId, ParamKind, WidgetKind};
|
||||
use crate::graph::{EditGraph, OpCapability};
|
||||
|
||||
/// Halvings per axis.
|
||||
///
|
||||
/// Twenty resolves a ±100 control to about a five-thousandth of a unit, which
|
||||
/// is far under the precision any of them is displayed at. There is no reason
|
||||
/// to stop earlier: each step is two multiplications.
|
||||
const STEPS: u32 = 20;
|
||||
|
||||
/// How many times the two axes are solved in turn.
|
||||
///
|
||||
/// One pass suffices for a node whose axes are independent, which is what
|
||||
/// "trades red against blue" and "trades green against magenta" describe. The
|
||||
/// repeats are for a node whose are not — a green correction that also lifts
|
||||
/// red would leave the first axis a little out — and they cost nothing worth
|
||||
/// counting.
|
||||
const ROUNDS: u32 = 3;
|
||||
|
||||
/// Below this a channel carries no ratio worth balancing.
|
||||
///
|
||||
/// A sample in the deep shadows, or one taken on a blown highlight where a
|
||||
/// channel has already clipped to nothing, has no white balance in it: the
|
||||
/// logarithms below would run away and the picker would slam a slider to its
|
||||
/// stop. Refusing is the honest answer, and the caller reports that the point
|
||||
/// was not usable rather than moving the photograph.
|
||||
const FLOOR: f32 = 1e-4;
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Move the graph so that `sample` renders neutral.
|
||||
///
|
||||
/// `sample` is linear RGB, as the operation's own gains multiply it — that is,
|
||||
/// measured with the sampling operation at its defaults. Returns whether the
|
||||
/// graph was moved: `false` where the chain offers no white point widget, or
|
||||
/// where the colour has no balance in it to correct.
|
||||
///
|
||||
/// **Absolute, not relative.** The values written depend on the colour and not
|
||||
/// on where the sliders happened to be, so sampling the same wall twice lands
|
||||
/// in the same place — and sampling a second, better neutral corrects the
|
||||
/// photograph rather than correcting the correction.
|
||||
pub fn neutralise(graph: &mut EditGraph, sample: [f32; 3]) -> bool {
|
||||
if sample.iter().any(|c| !c.is_finite() || *c < FLOOR) {
|
||||
return false;
|
||||
}
|
||||
let Some(axes) = sampler(graph) else {
|
||||
return false;
|
||||
};
|
||||
// The uniform contract, checked before anything moves. A node that
|
||||
// declares the widget and publishes something other than three gains
|
||||
// cannot be driven from a pixel, and half-solving it would leave the
|
||||
// photograph somewhere nobody asked for.
|
||||
if gains(graph, axes.op).is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
for _ in 0..ROUNDS {
|
||||
solve(
|
||||
graph,
|
||||
axes.op,
|
||||
axes.warm,
|
||||
axes.warm_axis,
|
||||
sample,
|
||||
warm_error,
|
||||
);
|
||||
solve(
|
||||
graph,
|
||||
axes.op,
|
||||
axes.green,
|
||||
axes.green_axis,
|
||||
sample,
|
||||
green_error,
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The operation an eyedropper writes to, and the two axes it moves.
|
||||
struct Axes {
|
||||
op: OpId,
|
||||
/// Red against blue, with its declared travel and display precision.
|
||||
warm: ParamId,
|
||||
warm_axis: (f32, f32, u8),
|
||||
/// Green against magenta, on the same terms.
|
||||
green: ParamId,
|
||||
green_axis: (f32, f32, u8),
|
||||
}
|
||||
|
||||
/// Find the operation that asked to be driven from a pixel.
|
||||
///
|
||||
/// The *first* one, if a chain ever carries two. Two white points in one chain
|
||||
/// is a chain that has already decided something odd, and picking the first is
|
||||
/// at least the same answer every time.
|
||||
fn sampler(graph: &EditGraph) -> Option<Axes> {
|
||||
graph.capabilities().into_iter().find_map(|cap| {
|
||||
let presentation = cap.presentation.as_ref()?;
|
||||
if !presentation.widgets.contains(&WidgetKind::WhitePoint) {
|
||||
return None;
|
||||
}
|
||||
let mut owned = presentation.params.iter().copied();
|
||||
let warm = owned.next()?;
|
||||
let green = owned.next()?;
|
||||
Some(Axes {
|
||||
op: cap.id,
|
||||
warm,
|
||||
warm_axis: axis(&cap, warm)?,
|
||||
green,
|
||||
green_axis: axis(&cap, green)?,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// How far a parameter may be moved and how finely, from its own declaration.
|
||||
///
|
||||
/// A non-scalar axis is refused rather than coerced: a bisection over a list
|
||||
/// of named alternatives is meaningless, and an operation offering one has not
|
||||
/// declared what this widget kind needs.
|
||||
fn axis(cap: &OpCapability, param: ParamId) -> Option<(f32, f32, u8)> {
|
||||
cap.params
|
||||
.iter()
|
||||
.find(|p| p.id == param)
|
||||
.and_then(|p| match &p.kind {
|
||||
ParamKind::Scalar {
|
||||
min,
|
||||
max,
|
||||
precision,
|
||||
..
|
||||
} => Some((*min, *max, *precision)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Round to the precision the parameter is displayed at.
|
||||
///
|
||||
/// **The search has to stop somewhere, and this is the honest place.** Twenty
|
||||
/// halvings land on something like -63.0117, which the panel would draw as
|
||||
/// -63 and a photographer could never reproduce by hand — so the two would
|
||||
/// disagree about what the control says, and a slider nudged one unit either
|
||||
/// way would silently discard a fraction nobody could see.
|
||||
///
|
||||
/// It also fixes the case that matters most: sampling something that is
|
||||
/// *already* neutral. Unrounded, that lands a ten-thousandth off zero, which
|
||||
/// is a photograph marked modified and a step on the undo stack for a
|
||||
/// correction of nothing.
|
||||
fn snap(value: f32, precision: u8) -> f32 {
|
||||
let scale = 10f32.powi(i32::from(precision));
|
||||
(value * scale).round() / scale
|
||||
}
|
||||
|
||||
/// The three linear gains this operation currently applies, in RGB order.
|
||||
///
|
||||
/// `None` unless there are exactly three, which is the widget kind's contract
|
||||
/// stated as a check. See the module note for why it is a contract and not a
|
||||
/// guess.
|
||||
fn gains(graph: &EditGraph, op: OpId) -> Option<[f32; 3]> {
|
||||
let uniforms = graph.uniforms_of(op)?;
|
||||
match uniforms.as_slice() {
|
||||
[r, g, b] => Some([r.value, g.value, b.value]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far red sits from blue in the corrected sample, in stops.
|
||||
///
|
||||
/// Logarithms because the correction is multiplicative and the controls are
|
||||
/// symmetric: warming by n and cooling by n are exact reciprocals, so an error
|
||||
/// measured as a ratio is linear in the thing being searched and a difference
|
||||
/// of products is not.
|
||||
fn warm_error(gains: [f32; 3], sample: [f32; 3]) -> f32 {
|
||||
(gains[0] * sample[0]).ln() - (gains[2] * sample[2]).ln()
|
||||
}
|
||||
|
||||
/// How far green sits from the red-blue midpoint, in stops.
|
||||
///
|
||||
/// Against the midpoint rather than against red alone, so that the two axes do
|
||||
/// not fight: with red and blue already balanced the two are the same number,
|
||||
/// and while they are not, green is being pulled toward where the other axis
|
||||
/// is heading rather than toward one end of it.
|
||||
fn green_error(gains: [f32; 3], sample: [f32; 3]) -> f32 {
|
||||
let r = (gains[0] * sample[0]).ln();
|
||||
let b = (gains[2] * sample[2]).ln();
|
||||
(gains[1] * sample[1]).ln() - 0.5 * (r + b)
|
||||
}
|
||||
|
||||
/// Drive one axis until `error` crosses zero, and leave it there.
|
||||
///
|
||||
/// The direction is read off the ends rather than assumed: which way "warmer"
|
||||
/// runs is the node's business, and a picker that had guessed would move the
|
||||
/// slider the wrong way on the first node that disagreed with it.
|
||||
fn solve(
|
||||
graph: &mut EditGraph,
|
||||
op: OpId,
|
||||
param: ParamId,
|
||||
(min, max, precision): (f32, f32, u8),
|
||||
sample: [f32; 3],
|
||||
error: fn([f32; 3], [f32; 3]) -> f32,
|
||||
) {
|
||||
let at = |graph: &mut EditGraph, value: f32| -> f32 {
|
||||
graph.set_param(op, param, value);
|
||||
gains(graph, op).map_or(0.0, |g| error(g, sample))
|
||||
};
|
||||
|
||||
let mut low = min;
|
||||
let mut high = max;
|
||||
let low_error = at(graph, low);
|
||||
let high_error = at(graph, high);
|
||||
|
||||
// No crossing inside the declared travel: the correction this colour needs
|
||||
// is more than the control can give. Taken as far as it goes, which is
|
||||
// what a photographer would do by hand — refusing would leave a badly cast
|
||||
// frame uncorrected because it could not be corrected *entirely*.
|
||||
if low_error.is_sign_positive() == high_error.is_sign_positive() {
|
||||
let end = if low_error.abs() <= high_error.abs() {
|
||||
low
|
||||
} else {
|
||||
high
|
||||
};
|
||||
graph.set_param(op, param, snap(end, precision));
|
||||
return;
|
||||
}
|
||||
|
||||
for _ in 0..STEPS {
|
||||
let middle = 0.5 * (low + high);
|
||||
if at(graph, middle).is_sign_positive() == low_error.is_sign_positive() {
|
||||
low = middle;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
graph.set_param(op, param, snap(0.5 * (low + high), precision));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The spread of a triple, relative to its middle channel.
|
||||
fn cast(rendered: [f32; 3]) -> f32 {
|
||||
let high = rendered.iter().copied().fold(f32::MIN, f32::max);
|
||||
let low = rendered.iter().copied().fold(f32::MAX, f32::min);
|
||||
(high - low) / rendered[1].max(f32::EPSILON)
|
||||
}
|
||||
|
||||
/// What the sampled colour becomes once the graph has been moved.
|
||||
fn corrected(graph: &EditGraph, sample: [f32; 3]) -> [f32; 3] {
|
||||
let axes = sampler(graph).expect("the default chain declares a white point");
|
||||
let g = gains(graph, axes.op).expect("three gains");
|
||||
[g[0] * sample[0], g[1] * sample[1], g[2] * sample[2]]
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The whole point: a warm grey comes back grey.
|
||||
#[test]
|
||||
fn sampling_a_warm_grey_makes_it_grey() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let sample = [0.62, 0.50, 0.40];
|
||||
assert!(cast(sample) > 0.3, "the premise: this is a strong cast");
|
||||
|
||||
assert!(neutralise(&mut graph, sample));
|
||||
assert!(
|
||||
cast(corrected(&graph, sample)) < 0.02,
|
||||
"the sample should render neutral: {:?}",
|
||||
corrected(&graph, sample)
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// And so does a cold one, which is the other half of the same claim: the
|
||||
/// search finds its own direction rather than being told one.
|
||||
#[test]
|
||||
fn sampling_a_cold_grey_makes_it_grey_too() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let sample = [0.40, 0.50, 0.62];
|
||||
|
||||
assert!(neutralise(&mut graph, sample));
|
||||
assert!(
|
||||
cast(corrected(&graph, sample)) < 0.02,
|
||||
"{:?}",
|
||||
corrected(&graph, sample)
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Sampling is absolute: the second sample corrects the photograph, not
|
||||
/// the previous correction.
|
||||
///
|
||||
/// The failure this guards is the one every relative picker has — sample a
|
||||
/// wall, decide a cloud was better, sample the cloud, and land somewhere
|
||||
/// neither of them describes.
|
||||
#[test]
|
||||
fn a_second_sample_replaces_the_first_rather_than_compounding_it() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
|
||||
assert!(neutralise(&mut graph, [0.62, 0.50, 0.40]));
|
||||
assert!(neutralise(&mut graph, [0.44, 0.50, 0.56]));
|
||||
|
||||
let second = [0.44, 0.50, 0.56];
|
||||
assert!(
|
||||
cast(corrected(&graph, second)) < 0.02,
|
||||
"{:?}",
|
||||
corrected(&graph, second)
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// A sample with nothing in it is refused rather than acted on.
|
||||
///
|
||||
/// Black has no white balance. A picker that treated it as one would take
|
||||
/// the ratio of two numbers that are both noise and slam the controls to
|
||||
/// their stops, which reads as the feature being broken rather than as the
|
||||
/// point having been a poor choice.
|
||||
#[test]
|
||||
fn a_sample_with_no_balance_in_it_moves_nothing() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let before = crate::Preset::capture(&graph);
|
||||
|
||||
assert!(!neutralise(&mut graph, [0.0, 0.0, 0.0]));
|
||||
assert!(!neutralise(&mut graph, [f32::NAN, 0.5, 0.5]));
|
||||
|
||||
assert_eq!(crate::Preset::capture(&graph), before);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a
|
||||
/// The picker is found through the widget kind, not through a name.
|
||||
///
|
||||
/// If this ever fails because the declaration moved, the right fix is in
|
||||
/// the declaration: nothing here is entitled to know which node it is.
|
||||
#[test]
|
||||
fn the_chain_offers_exactly_one_white_point() {
|
||||
let graph = EditGraph::default_chain();
|
||||
let found = graph
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
c.presentation
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.widgets.contains(&WidgetKind::WhitePoint))
|
||||
})
|
||||
.count();
|
||||
assert_eq!(found, 1, "one operation should ask to be driven by a pixel");
|
||||
}
|
||||
}
|
||||
@@ -377,6 +377,20 @@ pub trait Operation: Send + Sync {
|
||||
fn presentation(&self) -> Option<Presentation> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Take whatever this operation needs from a lens profile.
|
||||
///
|
||||
/// The counterpart of [`crate::lens::Warp::set_profile`], and it exists on
|
||||
/// this trait as well because the optical corrections do not all live on
|
||||
/// the same side of the fetch. Distortion and CA rewrite coordinates and
|
||||
/// are warps; vignetting applies a gain to the pixel already there and is
|
||||
/// an ordinary node. Splitting the fan-out by which trait a correction
|
||||
/// happens to implement would make the caller reason about that, so both
|
||||
/// traits carry the same door and `EditGraph::set_lens_profile` walks
|
||||
/// both lists the same way.
|
||||
///
|
||||
/// Defaulted: fourteen of the fifteen operations have nothing to take.
|
||||
fn set_lens_profile(&mut self, _profile: Option<&crate::lens::LensProfile>) {}
|
||||
}
|
||||
|
||||
/// A named WGSL helper function, deduplicated across operations.
|
||||
@@ -510,6 +524,11 @@ pub fn compose_with_framing(
|
||||
output,
|
||||
&MaskStack::new(),
|
||||
&crate::spot::SpotSet::new(),
|
||||
// No lens corrections. This entry point exists for callers that have
|
||||
// an operation chain and nothing else — the codegen tests, and the
|
||||
// export path before it grew a graph — and a warp is not something a
|
||||
// caller can hold without one.
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -531,7 +550,35 @@ pub fn compose_full(
|
||||
output: ColourSpace,
|
||||
masks: &MaskStack,
|
||||
spots: &crate::spot::SpotSet,
|
||||
warps: &[Box<dyn crate::lens::Warp>],
|
||||
) -> ComposedShader {
|
||||
compose_full_revealing(ops, framing, output, masks, spots, warps, None)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// [`compose_full`], with one layer's mask drawn over the finished picture.
|
||||
///
|
||||
/// Separate from [`compose_full`] rather than an argument on it, and that is
|
||||
/// the safety property rather than a convenience: the reveal is a thing the
|
||||
/// screen does, and every other consumer of the pipeline — the exporter, the
|
||||
/// thumbnail, the neutral probe — calls the function that has no way to ask
|
||||
/// for it. A flag reachable from the graph would have been one forgotten reset
|
||||
/// away from a red tint baked into an exported file.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compose_full_revealing(
|
||||
ops: &[Box<dyn Operation>],
|
||||
framing: &Framing,
|
||||
output: ColourSpace,
|
||||
masks: &MaskStack,
|
||||
spots: &crate::spot::SpotSet,
|
||||
warps: &[Box<dyn crate::lens::Warp>],
|
||||
reveal: Option<&crate::mask::Reveal>,
|
||||
) -> ComposedShader {
|
||||
// The lens corrections, composed into one coordinate transform. Beside
|
||||
// `framing` because they are the other half of the same stage: framing
|
||||
// says which part of the source this output pixel comes from, and a warp
|
||||
// says where the lens put it once it got there.
|
||||
let warp = crate::lens::compose_warps(warps);
|
||||
// Active *point* operations. A neighbourhood operation is filtered out
|
||||
// here rather than asked for a fragment it cannot write: it reads pixels
|
||||
// it is not writing, so it belongs to the detail stage that runs after
|
||||
@@ -636,6 +683,19 @@ pub fn compose_full(
|
||||
);
|
||||
uniform_values.extend_from_slice(&framing.uniforms());
|
||||
|
||||
// The warps, immediately after framing and before any operation, matching
|
||||
// where they sit in the shader. Slot order is emission order and nothing
|
||||
// addresses a slot by number, so this only has to be consistent with
|
||||
// itself — but keeping it in pipeline order is what makes the generated
|
||||
// struct readable next to the generated body.
|
||||
uniform_fields.push_str(&warp.uniform_fields);
|
||||
uniform_values.extend_from_slice(&warp.uniforms);
|
||||
for h in &warp.helpers {
|
||||
if !helpers.iter().any(|existing| existing.name == h.name) {
|
||||
helpers.push(*h);
|
||||
}
|
||||
}
|
||||
|
||||
for op in &active {
|
||||
let id = op.descriptor().id.0;
|
||||
let prefix = sanitise(id);
|
||||
@@ -677,7 +737,13 @@ pub fn compose_full(
|
||||
// from. Their uniforms follow the global ops' in the block for the same
|
||||
// reason those follow framing's — slot order is emission order, and
|
||||
// nothing addresses a slot by number.
|
||||
let layers = crate::mask::compose_layers(masks);
|
||||
let layers = crate::mask::compose_layers_revealing(masks, reveal);
|
||||
// TRACES: FR-DEV-19c
|
||||
// Held apart from the body, because it belongs after the output transform
|
||||
// rather than among the operations — see `mask::LayerShader::reveal`.
|
||||
// Empty for every composition nobody is looking at a mask through, which
|
||||
// is all of them but the screen's.
|
||||
let reveal_block = layers.reveal.clone();
|
||||
uniform_fields.push_str(&layers.uniform_fields);
|
||||
uniform_values.extend_from_slice(&layers.uniform_values);
|
||||
body.push_str(&layers.body);
|
||||
@@ -702,17 +768,35 @@ pub fn compose_full(
|
||||
|
||||
// The coordinate stage: output pixel -> source position -> colour. Emitted
|
||||
// ahead of the operation fragments, which receive the sampled `c`.
|
||||
let prologue = format!(
|
||||
"{}\n{}",
|
||||
framing.wgsl_prologue(),
|
||||
sample_source(framing.needs_interpolation())
|
||||
);
|
||||
let sampler_helper = if framing.needs_interpolation() {
|
||||
BILINEAR_HELPER
|
||||
// A warp puts an output pixel between source pixels exactly as a free
|
||||
// angle does, so either one forces the interpolating sampler. Asking
|
||||
// framing alone — which is what this did before the warps existed — would
|
||||
// have nearest-neighboured a distortion correction on an unstraightened
|
||||
// frame, and the aliasing would have looked like a bad profile.
|
||||
let interpolate = framing.needs_interpolation() || warp.is_active();
|
||||
|
||||
// Declared ahead of the warp block, which assigns to them. They enter
|
||||
// equal to `p` so that a chain mixing a splitting warp with a
|
||||
// non-splitting one still carries every earlier correction into the red
|
||||
// and blue paths — a distortion correction must move all three channels,
|
||||
// and only the CA that follows it may move them apart.
|
||||
let channel_positions = if warp.splits_channels {
|
||||
"\n // Per-channel source positions, for the lateral CA correction.\n\
|
||||
\x20 var p_r = p;\n\
|
||||
\x20 var p_b = p;\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let prologue = format!(
|
||||
"{}{}{}\n{}",
|
||||
framing.wgsl_prologue(),
|
||||
channel_positions,
|
||||
warp.body,
|
||||
sample_source(interpolate, warp.splits_channels)
|
||||
);
|
||||
let sampler_helper = if interpolate { BILINEAR_HELPER } else { "" };
|
||||
|
||||
// The tail, and it is the whole of the difference between the two output
|
||||
// modes. Everything above — the prologue, the fragments, the mask layers,
|
||||
// the camera matrix — is emitted identically either way, so an operation
|
||||
@@ -919,7 +1003,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
c = mix(c, neutral, clipped);
|
||||
}}
|
||||
{body}
|
||||
{rendering_tail}{to_output}
|
||||
{rendering_tail}{to_output}{reveal_block}
|
||||
{store}
|
||||
}}
|
||||
",
|
||||
@@ -1049,7 +1133,51 @@ fn encode_output(c: vec3<f32>) -> vec3<f32> {{
|
||||
/// Split out because it is the join between the coordinate stage and the
|
||||
/// colour stage, and because the choice it makes — an exact integer load, or
|
||||
/// a filtered sample — is the one thing the free-angle case changes.
|
||||
pub(crate) fn sample_source(interpolate: bool) -> &'static str {
|
||||
pub(crate) fn sample_source(interpolate: bool, splits_channels: bool) -> &'static str {
|
||||
if splits_channels {
|
||||
// Lateral chromatic aberration is a per-channel radial magnification,
|
||||
// so red and blue are fetched from positions green is not — which is
|
||||
// the whole reason a warp is not an `Operation`. By the time a colour
|
||||
// reaches an operation the three channels have been sampled together
|
||||
// and the divergence is gone.
|
||||
//
|
||||
// Green never moves. It is the reference the other two are scaled
|
||||
// about, so a correction that is wrong still leaves one channel sharp
|
||||
// rather than softening all three.
|
||||
return " // Back to texture coordinates, once per channel.
|
||||
let uv_src = p / aspect + vec2<f32>(0.5);
|
||||
let uv_r = p_r / aspect + vec2<f32>(0.5);
|
||||
let uv_b = p_b / aspect + vec2<f32>(0.5);
|
||||
|
||||
// Tested on green alone, not on all three.
|
||||
//
|
||||
// The three positions differ by a fraction of a pixel at any correction a
|
||||
// real lens needs, so testing each would only let the outermost row of the
|
||||
// frame disagree with itself about whether it exists — which draws a
|
||||
// coloured fringe along the edge, the exact artefact this is here to
|
||||
// remove. `sample_bilinear` clamps its own texel indices, so red and blue
|
||||
// land on the edge pixel rather than out of bounds.
|
||||
if (any(uv_src < vec2<f32>(0.0)) || any(uv_src >= vec2<f32>(1.0))) {
|
||||
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(0.0, 0.0, 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-3f
|
||||
// Where this pixel sits on the *source*, in source pixels. Green's
|
||||
// position, since that is the one that did not move.
|
||||
let source_px = uv_src * vec2<f32>(src_dims);
|
||||
let radius = length(p) / (0.5 * length(aspect));
|
||||
|
||||
// Three fetches, one channel kept from each. Two thirds of the work is
|
||||
// discarded, which is why `Warp::splits_channels` exists: with no CA in
|
||||
// the chain the single-sample path below is emitted instead.
|
||||
var c = vec3<f32>(
|
||||
sample_bilinear(uv_r, src_dims).r,
|
||||
sample_bilinear(uv_src, src_dims).g,
|
||||
sample_bilinear(uv_b, src_dims).b,
|
||||
);
|
||||
";
|
||||
}
|
||||
if interpolate {
|
||||
" // Back to texture coordinates.
|
||||
let uv_src = p / aspect + vec2<f32>(0.5);
|
||||
@@ -1074,6 +1202,23 @@ pub(crate) fn sample_source(interpolate: bool) -> &'static str {
|
||||
let source_px = uv_src * vec2<f32>(src_dims);
|
||||
// A free angle puts output pixels between source pixels. Nearest-neighbour
|
||||
// here is what makes a straightened horizon stair-step, so interpolate.
|
||||
// Distance from the optical axis, normalised so the corner is exactly 1.
|
||||
//
|
||||
// Published beside `source_px` and for the same reason: a fragment is
|
||||
// handed a colour with no way back to a coordinate, and the radial
|
||||
// corrections need one. Derived from `p` after the whole coordinate stage,
|
||||
// so it measures the *source* frame — which is what a lens profile is
|
||||
// calibrated against, and why an off-centre crop still gets the falloff
|
||||
// its corner actually had rather than one centred on the crop.
|
||||
//
|
||||
// **The division is the part that is easy to leave out.** `p` spans
|
||||
// `+/-0.5 * aspect`, so at the corner its length is `0.5 * length(aspect)`
|
||||
// -- about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted
|
||||
// against a corner radius of 1, so passing `length(p)` straight in
|
||||
// evaluates every one of them short of where it was measured, and by an
|
||||
// amount that changes with the aspect ratio. It reads as a correction that
|
||||
// is simply too weak, which is indistinguishable from a bad profile.
|
||||
let radius = length(p) / (0.5 * length(aspect));
|
||||
var c = sample_bilinear(uv_src, src_dims);
|
||||
"
|
||||
} else {
|
||||
@@ -1101,6 +1246,23 @@ pub(crate) fn sample_source(interpolate: bool) -> &'static str {
|
||||
// put, and its *amount* is handled separately by how much film a pixel
|
||||
// covers -- see `dr_film::Grain`.
|
||||
let source_px = uv_src * vec2<f32>(src_dims);
|
||||
// Distance from the optical axis, normalised so the corner is exactly 1.
|
||||
//
|
||||
// Published beside `source_px` and for the same reason: a fragment is
|
||||
// handed a colour with no way back to a coordinate, and the radial
|
||||
// corrections need one. Derived from `p` after the whole coordinate stage,
|
||||
// so it measures the *source* frame — which is what a lens profile is
|
||||
// calibrated against, and why an off-centre crop still gets the falloff
|
||||
// its corner actually had rather than one centred on the crop.
|
||||
//
|
||||
// **The division is the part that is easy to leave out.** `p` spans
|
||||
// `+/-0.5 * aspect`, so at the corner its length is `0.5 * length(aspect)`
|
||||
// -- about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted
|
||||
// against a corner radius of 1, so passing `length(p)` straight in
|
||||
// evaluates every one of them short of where it was measured, and by an
|
||||
// amount that changes with the aspect ratio. It reads as a correction that
|
||||
// is simply too weak, which is indistinguishable from a bad profile.
|
||||
let radius = length(p) / (0.5 * length(aspect));
|
||||
var c = textureLoad(source, coord, 0).rgb;
|
||||
"
|
||||
}
|
||||
@@ -1393,6 +1555,186 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
use crate::lens::Warp as _;
|
||||
|
||||
/// A neutral warp list must leave the shader exactly as it was.
|
||||
///
|
||||
/// The property the whole `is_active` filter exists for: an unedited
|
||||
/// photograph keeps the integer `textureLoad` path, and pays nothing —
|
||||
/// not a bilinear fetch, not a uniform slot, not a recompile — for
|
||||
/// corrections nobody has asked for.
|
||||
#[test]
|
||||
fn warps_at_neutral_change_nothing_at_all() {
|
||||
let ops = crate::ops::chain();
|
||||
let framing = Framing::new();
|
||||
|
||||
let without = compose_full(
|
||||
&ops,
|
||||
&framing,
|
||||
ColourSpace::Srgb,
|
||||
&MaskStack::new(),
|
||||
&crate::spot::SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let with_neutral = compose_full(
|
||||
&ops,
|
||||
&framing,
|
||||
ColourSpace::Srgb,
|
||||
&MaskStack::new(),
|
||||
&crate::spot::SpotSet::new(),
|
||||
&[
|
||||
Box::new(crate::ops::Distortion::new()) as Box<dyn crate::lens::Warp>,
|
||||
Box::new(crate::ops::Aberration::new()),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(without.source, with_neutral.source);
|
||||
assert_eq!(without.structure_hash, with_neutral.structure_hash);
|
||||
assert_eq!(without.uniforms, with_neutral.uniforms);
|
||||
assert!(
|
||||
!without.source.contains("sample_bilinear"),
|
||||
"an unwarped, unstraightened frame must keep the integer load path"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// **The property that keeps a reveal off an exported file.**
|
||||
///
|
||||
/// `compose_full` is the entry point the exporter, the thumbnail and the
|
||||
/// neutral probe all use, and it has no argument that could ask for a
|
||||
/// mask overlay. Only `compose_full_revealing` does, and only the canvas
|
||||
/// calls it. Asserted rather than left to the type signature because the
|
||||
/// tempting simplification — a flag on the graph — would type-check, be
|
||||
/// shorter, and bake a red tint into every file the photographer sold.
|
||||
#[test]
|
||||
fn an_ordinary_composition_cannot_draw_a_mask_over_the_picture() {
|
||||
use crate::mask::{MaskLayer, MaskSource, Reveal, RevealStyle};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
let mut layer = MaskLayer::new("m1", MaskSource::brush());
|
||||
layer.set_param("exposure", crate::descriptor::ParamId("exposure"), 1.0);
|
||||
stack.push(layer);
|
||||
|
||||
let plain = compose_full(
|
||||
&crate::ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
&stack,
|
||||
&crate::spot::SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
!plain.source.contains("==== showing mask"),
|
||||
"an export must never carry the overlay"
|
||||
);
|
||||
|
||||
let shown = compose_full_revealing(
|
||||
&crate::ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
&stack,
|
||||
&crate::spot::SpotSet::new(),
|
||||
&[],
|
||||
Some(&Reveal::one("m1", RevealStyle::Tint)),
|
||||
);
|
||||
assert!(shown.source.contains("==== showing mask"));
|
||||
assert_ne!(
|
||||
plain.structure_hash, shown.structure_hash,
|
||||
"two different shaders must not share a pipeline cache entry"
|
||||
);
|
||||
}
|
||||
|
||||
/// Distortion alone samples once; chromatic aberration samples three times.
|
||||
///
|
||||
/// `splits_channels` is the whole reason for this test. Lateral CA fetches
|
||||
/// red and blue from positions green is not, and paying that everywhere
|
||||
/// would triple the texture bandwidth of the common case — a distortion
|
||||
/// correction with no CA, which is most lens profiles.
|
||||
#[test]
|
||||
fn only_chromatic_aberration_splits_the_channels() {
|
||||
let compose_with = |warps: Vec<Box<dyn crate::lens::Warp>>| {
|
||||
compose_full(
|
||||
&crate::ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
&MaskStack::new(),
|
||||
&crate::spot::SpotSet::new(),
|
||||
&warps,
|
||||
)
|
||||
.source
|
||||
};
|
||||
|
||||
let mut distortion = crate::ops::Distortion::new();
|
||||
distortion.set_param(crate::ops::distortion::AMOUNT, 40.0);
|
||||
let only_distortion = compose_with(vec![Box::new(distortion)]);
|
||||
|
||||
assert!(
|
||||
only_distortion.contains("---- warp: distortion ----"),
|
||||
"an active distortion must reach the shader"
|
||||
);
|
||||
assert!(
|
||||
only_distortion.contains("sample_bilinear"),
|
||||
"a warp puts output pixels between source pixels, so it forces \
|
||||
the interpolating sampler even on an unstraightened frame"
|
||||
);
|
||||
assert!(
|
||||
!only_distortion.contains("var p_r"),
|
||||
"distortion moves all three channels together and must not pay \
|
||||
for the per-channel path"
|
||||
);
|
||||
|
||||
let mut ca = crate::ops::Aberration::new();
|
||||
ca.set_param(crate::ops::aberration::RED, 25.0);
|
||||
let with_ca = compose_with(vec![Box::new(ca)]);
|
||||
|
||||
assert!(
|
||||
with_ca.contains("var p_r"),
|
||||
"CA needs per-channel positions"
|
||||
);
|
||||
assert_eq!(
|
||||
with_ca.matches("sample_bilinear(").count(),
|
||||
// Three fetches in the body, plus the helper's own definition.
|
||||
4,
|
||||
"CA must fetch each channel from its own position"
|
||||
);
|
||||
}
|
||||
|
||||
/// An active warp is a different shader and must not reuse the cached one.
|
||||
///
|
||||
/// Covered by `hash_source` rather than by anything warp-specific — the
|
||||
/// body is written into the source — but asserted because the alternative
|
||||
/// failure is silent: the correction would simply never appear, exactly as
|
||||
/// a zoom did before `Framing::structure_key` gained its last bit.
|
||||
#[test]
|
||||
fn arming_a_warp_recompiles_but_moving_its_slider_does_not() {
|
||||
let compose_at = |amount: f32| {
|
||||
let mut d = crate::ops::Distortion::new();
|
||||
d.set_param(crate::ops::distortion::AMOUNT, amount);
|
||||
compose_full(
|
||||
&crate::ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
&MaskStack::new(),
|
||||
&crate::spot::SpotSet::new(),
|
||||
&[Box::new(d) as Box<dyn crate::lens::Warp>],
|
||||
)
|
||||
};
|
||||
|
||||
let neutral = compose_at(0.0);
|
||||
let armed = compose_at(40.0);
|
||||
let further = compose_at(70.0);
|
||||
|
||||
assert_ne!(
|
||||
neutral.structure_hash, armed.structure_hash,
|
||||
"arming a warp adds a block to the shader and must recompile"
|
||||
);
|
||||
assert_eq!(
|
||||
armed.structure_hash, further.structure_hash,
|
||||
"its magnitude is a uniform, so a drag must not recompile"
|
||||
);
|
||||
assert_ne!(armed.uniforms, further.uniforms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structure_hash_ignores_values_but_tracks_the_op_set() {
|
||||
// The property the shader cache depends on: moving a slider must not
|
||||
|
||||
@@ -141,6 +141,17 @@ impl Warp for Aberration {
|
||||
r != 1.0 || b != 1.0
|
||||
}
|
||||
|
||||
fn set_profile(&mut self, profile: Option<&crate::lens::LensProfile>) {
|
||||
// The inherent `set_profile` taking just this correction's own
|
||||
// coefficients, not this trait method: an inherent method wins over a
|
||||
// trait one of the same name, so this is a narrowing and not a loop.
|
||||
self.set_profile(
|
||||
profile
|
||||
.and_then(|p| p.tca)
|
||||
.map(|t| (t.red_scale, t.blue_scale)),
|
||||
);
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
// `p_r` and `p_b` enter equal to `p` and are carried out of the block.
|
||||
// Green is deliberately absent: it is the reference and never moves.
|
||||
|
||||
@@ -359,6 +359,7 @@ impl DetailStage for CaptureSharpen {
|
||||
["horizontal", "vertical"]
|
||||
.into_iter()
|
||||
.map(|label| DetailPass {
|
||||
output_scale: 1,
|
||||
label,
|
||||
radius: extent,
|
||||
// A convolution, not a list: nothing to bind at binding 3.
|
||||
@@ -412,6 +413,7 @@ impl DetailStage for CaptureSharpen {
|
||||
/// texture, which is a rounding error against the dispatches around it.
|
||||
fn nothing_to_sharpen() -> DetailPass {
|
||||
DetailPass {
|
||||
output_scale: 1,
|
||||
label: "unresolved",
|
||||
// Reads only the pixel it writes, so a tile needs no halo at all.
|
||||
radius: 0,
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
//! TRACES: FR-DEV-18 | FR-DSP-1
|
||||
//! Dehaze — measuring the veil distance puts over a subject, and dividing it
|
||||
//! back out.
|
||||
//!
|
||||
//! Haze is not a tone problem wearing a spatial disguise, which is why none of
|
||||
//! the controls already in the chain can remove it. Atmospheric scattering
|
||||
//! composites an *airlight* over the scene in proportion to how far away each
|
||||
//! part of it is:
|
||||
//!
|
||||
//! ```text
|
||||
//! I = J·t + A·(1 − t), t = e^(−β·d)
|
||||
//! ```
|
||||
//!
|
||||
//! `J` is the scene, `A` the airlight, `t` the transmission and `d` the
|
||||
//! distance. The two things it does — lifting the black point towards `A` and
|
||||
//! compressing contrast towards it — are both *per-pixel*, because `d` is. A
|
||||
//! black point that clears the mountains crushes the foreground; a contrast
|
||||
//! curve that clears the mountains does the same. So the operation has to
|
||||
//! estimate `t` at every pixel, and estimating it is the whole of the work.
|
||||
//!
|
||||
//! # How the transmission is estimated
|
||||
//!
|
||||
//! The dark-channel prior: in a haze-free patch of an ordinary photograph, at
|
||||
//! least one of the three channels is close to zero *somewhere* — a shadow, a
|
||||
//! dark surface, a saturated colour whose complementary channels are empty.
|
||||
//! Wherever that local minimum sits well above zero instead, something
|
||||
//! additive has lifted it, and the amount it has been lifted by is `A·(1 − t)`.
|
||||
//! So the veil is a local minimum over the channels and over a patch, and the
|
||||
//! transmission follows from it.
|
||||
//!
|
||||
//! The prior fails on a genuinely bright, genuinely haze-free subject — snow,
|
||||
//! a white wall filling the patch — where it reads the brightness as veil and
|
||||
//! this operation darkens it. That is the known failure of the prior and it is
|
||||
//! why the amount is a slider a photographer sets by eye rather than an
|
||||
//! automatic correction applied on open.
|
||||
//!
|
||||
//! # Why the airlight is taken as neutral, and as one
|
||||
//!
|
||||
//! The literature estimates `A` from the brightest few pixels of the dark
|
||||
//! channel — a *whole-frame* reduction, which this stage cannot perform. The
|
||||
//! detail chain hands each pass the pass before it at a fixed fraction of the
|
||||
//! render size; there is no reduction to a single value in it, and adding one
|
||||
//! would be a second chain of `log(n)` dispatches whose shape changes with
|
||||
//! every viewport.
|
||||
//!
|
||||
//! It is not needed. Airlight is the illuminant scattered towards the camera,
|
||||
//! and white balance is the first node in the chain — by the time the detail
|
||||
//! stage runs, the illuminant has already been driven to neutral, so `A` is
|
||||
//! grey and only its *magnitude* is unknown. Taking that magnitude as one — as
|
||||
//! bright as diffuse white — makes the estimated veil a fixed multiple of the
|
||||
//! true one, and a fixed multiple of the veil is exactly what the amount
|
||||
//! slider already scales. The unknown lands on a control the photographer is
|
||||
//! setting by eye anyway, rather than on a reduction the architecture would
|
||||
//! have to grow to compute.
|
||||
//!
|
||||
//! # In linear light, deliberately unlike clarity
|
||||
//!
|
||||
//! [`crate::ops::local_contrast`] works in stops, because a perceptual local
|
||||
//! contrast control has to mean the same thing in a highlight and in a shadow.
|
||||
//! This operation is the opposite case: it inverts a physical model stated in
|
||||
//! linear radiance, where the veil is an *additive* term. Taking logarithms
|
||||
//! first would turn the subtraction into something that is not the inverse of
|
||||
//! anything, and the correction would stop being a correction. The intermediate
|
||||
//! this stage reads is linear and scene-referred, so the model applies to it as
|
||||
//! written.
|
||||
//!
|
||||
//! # The radius is a fraction of the frame
|
||||
//!
|
||||
//! [`RenderScale`] names two units and choosing the wrong one is the mistake a
|
||||
//! neighbourhood operation makes silently. The patch is compositional, so it
|
||||
//! takes [`RenderScale::frame_fraction`] — the unit clarity, texture and a mask
|
||||
//! feather already use — and never [`RenderScale::source_pixels`].
|
||||
//!
|
||||
//! The test is whose property the length is. Capture sharpening's radius
|
||||
//! belongs to the *sensor*: it stands for the spread of a point across
|
||||
//! photosites and does not change when the frame is cropped. This one belongs
|
||||
//! to the *picture*: the patch has to be large enough to contain something
|
||||
//! dark and small enough that the veil it measures is still local, and both of
|
||||
//! those are statements about how much of the composition it covers. Crop into
|
||||
//! a quarter of the frame and the depth structure now fills it, so the patch
|
||||
//! that measures it really has grown — which `frame_fraction` gives, because
|
||||
//! [`crate::EditGraph::render_scale`] folds the crop in before this code runs.
|
||||
//!
|
||||
//! Stated in raw pixels it would be a different photograph on screen and in
|
||||
//! the file: the develop view renders at whatever the viewport needs
|
||||
//! (FR-DSP-1), so a patch tuned at one-third scale would be three times too
|
||||
//! narrow relative to the picture in the export — and the export is the only
|
||||
//! render anybody keeps.
|
||||
//!
|
||||
//! Unlike texture, the pass is **not** dropped when the patch rounds small.
|
||||
//! Texture's absence on a thumbnail is the honest answer, because a two-pixel
|
||||
//! surface structure is not present in a 300-pixel rendering of the frame at
|
||||
//! all. Dehaze changes the overall tone and colour of the picture, and a
|
||||
//! thumbnail disagreeing with the develop view about *that* reads as a bug
|
||||
//! rather than as a scale. So the patch is floored at one pixel instead.
|
||||
//!
|
||||
//! # What it costs, and the identity that makes it affordable
|
||||
//!
|
||||
//! A minimum over a patch is separable, as a Gaussian is: minimum along x,
|
||||
//! then along y. That alone is not enough. The patch is 1% of the shorter edge
|
||||
//! — 61 taps across at 4K — and two passes of 61 taps is the arithmetic that
|
||||
//! measured 34 ms for clarity and became `docs/technical-debt.md` TD-4.
|
||||
//!
|
||||
//! A minimum has a property a Gaussian does not: **erosions compose by adding
|
||||
//! their structuring elements**. The minimum over a contiguous run of `d`
|
||||
//! pixels, followed by the minimum over `k` pixels spaced `d` apart, is the
|
||||
//! minimum over the whole `k·d`-wide window, because `{0…d−1} ⊕ {0, d, …} =
|
||||
//! {0…kd−1}`. With `d ≈ √W` the window costs `d + k ≈ 2√W` taps instead of
|
||||
//! `W` — 16 rather than 61 at 4K — and it is the **same filter**, not an
|
||||
//! approximation of one.
|
||||
//!
|
||||
//! That distinction is the whole reason this is allowed here while the strided
|
||||
//! kernel `local_contrast` refuses is not. A stride samples an image that is
|
||||
//! not band-limited and aliases: high-frequency content folds down into the
|
||||
//! base, the base is subtracted, and the aliasing arrives as low-frequency
|
||||
//! mottling across smooth gradients. This decomposition samples nothing — it
|
||||
//! evaluates the exact minimum over every pixel of the window, in two steps.
|
||||
//!
|
||||
//! It is also why this operation does not use the reduced chain that TD-4 gave
|
||||
//! clarity. The runner holds one reduced buffer, so every scaled pass in a
|
||||
//! chain must declare the same `output_scale`; clarity's steps down with the
|
||||
//! viewport, so a second operation choosing its own would disagree with it at
|
||||
//! some window sizes and not others. Four cheap full-resolution passes cost
|
||||
//! less than that coupling, and the decomposition is what makes them cheap.
|
||||
//!
|
||||
//! # The artefact this does not fix
|
||||
//!
|
||||
//! An erosion by a wide element carries a dark object's value out to the
|
||||
//! patch radius around it, so the transmission map says "no haze here" in a
|
||||
//! band around every dark foreground shape and the correction falls off
|
||||
//! inside it. That band is the classic dark-channel halo, and the published
|
||||
//! answer to it is a guided filter or a matting Laplacian, which refines the
|
||||
//! transmission against the picture's own edges.
|
||||
//!
|
||||
//! Neither is done. A guided filter is a second chain carrying two more
|
||||
//! moments per pixel, and this stage hands each pass exactly one scalar lane
|
||||
//! (see [`DetailPass::wgsl`]); its regularisation parameter is a number no
|
||||
//! requirement supplies and would be a guess dressed up as a constant. The
|
||||
//! erosion of a continuous image is continuous, so what is left is a gradient
|
||||
//! rather than an edge — an under-correction near dark objects, not a ring
|
||||
//! around them.
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::detail::{DetailPass, DetailStage, RenderScale};
|
||||
use crate::operation::{Affects, Helper, Operation, Uniform};
|
||||
|
||||
pub const ID: OpId = OpId("dehaze");
|
||||
|
||||
/// The one parameter, so the sidecar key reads `dehaze.amount`.
|
||||
pub const AMOUNT: ParamId = ParamId("amount");
|
||||
|
||||
/// The patch the veil is measured over, as a fraction of the frame's shorter
|
||||
/// edge — a **half**-width, so the window is twice this plus one.
|
||||
///
|
||||
/// 1% — 30 px either side on a 4000 x 3000 frame. The two ends of the range
|
||||
/// are set by opposite failures of the prior. Narrower, and an ordinary patch
|
||||
/// of sky or skin contains nothing dark, so the minimum reads brightness as
|
||||
/// veil and the operation darkens the subject. Wider, and the minimum stops
|
||||
/// being local: it starts reporting the darkest thing in a large region of the
|
||||
/// frame, so a single shadow suppresses the correction across a quarter of the
|
||||
/// picture.
|
||||
///
|
||||
/// It is within a factor of two of the 15 x 15 patch the dark-channel
|
||||
/// literature uses on the ~600 px images it is demonstrated at, which is the
|
||||
/// same fraction of a frame stated the other way round.
|
||||
const PATCH: f32 = 0.01;
|
||||
|
||||
/// The fraction of the estimated veil that full slider travel removes.
|
||||
///
|
||||
/// Not one, and this is a decision about the photograph rather than a safety
|
||||
/// margin. Aerial perspective is how a picture says "far away"; removing all
|
||||
/// of it flattens a landscape into a cut-out, which is the look that makes
|
||||
/// heavy dehaze recognisable as an effect. Keeping a twentieth of the veil
|
||||
/// leaves distance reading as distance at the top of the slider.
|
||||
const MAX_OMEGA: f32 = 0.95;
|
||||
|
||||
/// The smallest transmission the recovery will divide by.
|
||||
///
|
||||
/// Where the veil is nearly total there is nothing left to recover — the
|
||||
/// signal that survived is a few percent of the airlight, and dividing it back
|
||||
/// up amplifies whatever noise came with it by the same factor. A tenth is the
|
||||
/// floor the dark-channel literature uses and it means the same thing here: at
|
||||
/// worst this operation multiplies by ten, and a region that hits the floor
|
||||
/// keeps a trace of haze rather than becoming a picture of its own noise.
|
||||
const MIN_TRANSMISSION: f32 = 0.1;
|
||||
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.dehaze"),
|
||||
params: vec![ParamDescriptor::amount("amount", "param.dehaze.amount")],
|
||||
attributes: vec![Attribute::Detail],
|
||||
})
|
||||
});
|
||||
|
||||
/// The darkest channel at a pixel, floored at zero.
|
||||
///
|
||||
/// Declared here rather than in `_helpers.yaml` because it is not a shared
|
||||
/// idea: it is the dark-channel prior's own statistic and means nothing
|
||||
/// outside this file.
|
||||
const DARK_CHANNEL: Helper = Helper {
|
||||
name: "dark_channel",
|
||||
source: "\
|
||||
// The smallest of the three channels — the dark-channel prior's statistic,
|
||||
// before the minimum over a patch is taken.
|
||||
//
|
||||
// Floored at zero because the intermediate is unclipped and scene-referred, so
|
||||
// an out-of-gamut colour arrives with a negative channel. A negative veil
|
||||
// would come back through the recovery as extra contrast in exactly the
|
||||
// pixels the working space could not represent, which is a bright fringe
|
||||
// arriving from a control the photographer reads as haze removal.
|
||||
fn dark_channel(c: vec3<f32>) -> f32 {
|
||||
return max(min(min(c.r, c.g), c.b), 0.0);
|
||||
}",
|
||||
};
|
||||
|
||||
static HELPERS: &[Helper] = &[DARK_CHANNEL];
|
||||
|
||||
/// TRACES: FR-DEV-18
|
||||
/// Dehaze: estimate the atmospheric veil from the picture and divide it out.
|
||||
///
|
||||
/// `Default` is derived rather than written out: the neutral of this operation
|
||||
/// is an amount of zero and nothing else, so there is no second fact for a
|
||||
/// hand-written impl to state. Capture sharpening needs one because its radius
|
||||
/// has no neutral value — a blur of zero width is not the identity, it is a
|
||||
/// kernel that does not exist — and the patch here is a constant rather than a
|
||||
/// parameter, so that problem does not arise.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct Dehaze {
|
||||
/// −100…100, exactly as the slider reports it. Negative *adds* haze — see
|
||||
/// [`Self::omega`].
|
||||
amount: f32,
|
||||
}
|
||||
|
||||
impl Dehaze {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Start from a slider position, for tests and presets.
|
||||
pub fn with_amount(amount: f32) -> Self {
|
||||
Self { amount }
|
||||
}
|
||||
|
||||
/// The patch's half-width at this render, in **render pixels**.
|
||||
///
|
||||
/// The one unit conversion this operation performs, and a method rather
|
||||
/// than a line inside [`Self::passes`] so that a test can state what it
|
||||
/// expects without repeating the rounding rule — a test that recomputed it
|
||||
/// would agree with a bug in it.
|
||||
///
|
||||
/// Floored at one pixel rather than allowed to reach zero: see the module
|
||||
/// documentation for why this operation does not drop its pass on a
|
||||
/// thumbnail the way texture does.
|
||||
pub fn patch(&self, scale: RenderScale) -> u32 {
|
||||
scale.frame_fraction(PATCH).round().max(1.0) as u32
|
||||
}
|
||||
|
||||
/// How much of the estimated veil this setting removes.
|
||||
///
|
||||
/// Negative at a negative amount, and the same formula then *adds* haze:
|
||||
/// the recovery becomes a composite of airlight over the picture in
|
||||
/// proportion to the veil already measured. So negative dehaze deepens the
|
||||
/// aerial perspective a scene already has rather than fogging it evenly,
|
||||
/// which is what a photographer asking for atmosphere means — and a scene
|
||||
/// with no haze in it stays clear, because there is no veil to scale.
|
||||
fn omega(&self) -> f32 {
|
||||
self.amount / 100.0 * MAX_OMEGA
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimum filter of half-width `radius`, split into two exact stages.
|
||||
///
|
||||
/// See the module documentation: eroding by a contiguous run and then by a set
|
||||
/// of points spaced one run apart erodes by the sum of the two, which is the
|
||||
/// whole window. This is the arithmetic of that split, in one place, because
|
||||
/// both axes need it and a second copy is a second chance to get the centring
|
||||
/// wrong.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Split {
|
||||
/// Length of the contiguous run the first pass takes the minimum over.
|
||||
pub run: u32,
|
||||
/// How many runs the second pass chains together, spaced `run` apart.
|
||||
pub span: u32,
|
||||
/// What the second pass subtracts from its offsets to centre the window.
|
||||
///
|
||||
/// The composite covers `run * span` pixels, which is at least the window
|
||||
/// asked for and can be one or two more; the surplus falls on the far side
|
||||
/// rather than being trimmed, because trimming it would need a third pass
|
||||
/// and a patch a pixel wider on one side is not a visible difference in a
|
||||
/// field this smooth.
|
||||
pub shift: i32,
|
||||
}
|
||||
|
||||
impl Split {
|
||||
/// Split a window of half-width `radius`.
|
||||
///
|
||||
/// `run` is the square root of the window rather than any other divisor
|
||||
/// because `d + ceil(W/d)` is smallest there — the two stages cost the
|
||||
/// same, which is what minimises their sum for a fixed product.
|
||||
pub fn of(radius: u32) -> Self {
|
||||
let width = 2 * radius + 1;
|
||||
let run = (width as f32).sqrt().ceil().max(1.0) as u32;
|
||||
let span = width.div_ceil(run);
|
||||
Self {
|
||||
run,
|
||||
span,
|
||||
shift: ((run * span - 1) / 2) as i32,
|
||||
}
|
||||
}
|
||||
|
||||
/// The furthest the second pass reads, in pixels.
|
||||
///
|
||||
/// Stated rather than assumed symmetric: the composite window is centred
|
||||
/// to within a pixel and not exactly, so the two directions can differ by
|
||||
/// one. An understated radius is a seam at every tile boundary (ARCH
|
||||
/// §5.3), which is the kind of artefact that looks like a driver bug.
|
||||
pub fn reach(&self) -> u32 {
|
||||
let far = (self.span.saturating_sub(1) * self.run) as i32 - self.shift;
|
||||
self.shift.max(far).max(0) as u32
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for Dehaze {
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
Arc::clone(&DESCRIPTOR)
|
||||
}
|
||||
|
||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||
self.amount = value;
|
||||
}
|
||||
|
||||
fn param(&self, _id: ParamId) -> f32 {
|
||||
self.amount
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.amount != 0.0
|
||||
}
|
||||
|
||||
/// Never called: a neighbourhood operation contributes no fused fragment,
|
||||
/// and `compose_full` filters it out before asking.
|
||||
fn wgsl_body(&self) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn affects(&self) -> Affects {
|
||||
Affects::Detail
|
||||
}
|
||||
|
||||
fn detail(&self) -> Option<&dyn DetailStage> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
HELPERS
|
||||
}
|
||||
}
|
||||
|
||||
impl DetailStage for Dehaze {
|
||||
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
|
||||
let split = Split::of(self.patch(scale));
|
||||
|
||||
// The run stage's uniforms are the same on both axes, and so are the
|
||||
// span stage's. Only the offset expression differs, which is what
|
||||
// `erode_run` and `erode_span` take as an argument — each filter
|
||||
// written once, so the two axes cannot drift into being different
|
||||
// filters.
|
||||
let run = vec![Uniform {
|
||||
name: "run",
|
||||
value: split.run as f32,
|
||||
}];
|
||||
let span = vec![
|
||||
Uniform {
|
||||
name: "span",
|
||||
value: split.span as f32,
|
||||
},
|
||||
Uniform {
|
||||
name: "stride",
|
||||
value: split.run as f32,
|
||||
},
|
||||
Uniform {
|
||||
name: "shift",
|
||||
value: split.shift as f32,
|
||||
},
|
||||
];
|
||||
|
||||
vec![
|
||||
DetailPass {
|
||||
output_scale: 1,
|
||||
label: "veil-run-x",
|
||||
// The run starts at this pixel and walks forward, so it reads
|
||||
// `run - 1` beyond itself and nothing behind.
|
||||
radius: split.run.saturating_sub(1),
|
||||
storage: Vec::new(),
|
||||
uniforms: run.clone(),
|
||||
wgsl: erode_run(Axis::X),
|
||||
},
|
||||
DetailPass {
|
||||
output_scale: 1,
|
||||
label: "veil-span-x",
|
||||
radius: split.reach(),
|
||||
storage: Vec::new(),
|
||||
uniforms: span.clone(),
|
||||
wgsl: erode_span(Axis::X),
|
||||
},
|
||||
DetailPass {
|
||||
output_scale: 1,
|
||||
label: "veil-run-y",
|
||||
radius: split.run.saturating_sub(1),
|
||||
storage: Vec::new(),
|
||||
uniforms: run,
|
||||
wgsl: erode_run(Axis::Y),
|
||||
},
|
||||
DetailPass {
|
||||
output_scale: 1,
|
||||
label: "veil-span-y",
|
||||
radius: split.reach(),
|
||||
storage: Vec::new(),
|
||||
uniforms: span,
|
||||
wgsl: erode_span(Axis::Y),
|
||||
},
|
||||
DetailPass {
|
||||
output_scale: 1,
|
||||
label: "clear",
|
||||
// Reads only the pixel it writes: the veil arrived in the
|
||||
// scratch lane four passes ago.
|
||||
radius: 0,
|
||||
storage: Vec::new(),
|
||||
uniforms: vec![
|
||||
Uniform {
|
||||
name: "omega",
|
||||
value: self.omega(),
|
||||
},
|
||||
Uniform {
|
||||
name: "min_transmission",
|
||||
value: MIN_TRANSMISSION,
|
||||
},
|
||||
],
|
||||
wgsl: CLEAR.to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Which way a separable half runs.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Axis {
|
||||
X,
|
||||
Y,
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
/// The offset expression for a scalar step `i` along this axis.
|
||||
fn offset(&self, step: &str) -> String {
|
||||
match self {
|
||||
Axis::X => format!("vec2<i32>({step}, 0)"),
|
||||
Axis::Y => format!("vec2<i32>(0, {step})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first stage: the minimum over a contiguous run.
|
||||
///
|
||||
/// Along x it reads the colour and reduces it to the dark channel; along y the
|
||||
/// dark channel is already in the scratch lane, so it reads that instead.
|
||||
/// Doing the channel minimum again on the second axis would be reducing a
|
||||
/// scalar and would quietly discard the x erosion.
|
||||
///
|
||||
/// Neither stage touches `c`. The recovery needs the original colour *and* the
|
||||
/// veil in the same place at the same time, and the ping-pong hands each pass
|
||||
/// only what the pass before it wrote — so the veil travels in `aux` and the
|
||||
/// colour rides through untouched. See [`DetailPass::wgsl`].
|
||||
fn erode_run(axis: Axis) -> String {
|
||||
let source = match axis {
|
||||
Axis::X => "dark_channel(tap(coord, OFFSET))",
|
||||
Axis::Y => "tap_aux(coord, OFFSET)",
|
||||
};
|
||||
let first = source.replace("OFFSET", &axis.offset("0"));
|
||||
let rest = source.replace("OFFSET", &axis.offset("i"));
|
||||
|
||||
format!(
|
||||
"\
|
||||
// Half of the erosion's first stage: the minimum over `run` contiguous pixels,
|
||||
// walking forward from this one. The second stage chains these together, and
|
||||
// the two structuring elements add up to the whole patch — which is why this
|
||||
// one is not centred and does not need to be.
|
||||
let n = i32(run);
|
||||
var veil = {first};
|
||||
for (var i = 1; i < n; i = i + 1) {{
|
||||
veil = min(veil, {rest});
|
||||
}}
|
||||
aux = veil;"
|
||||
)
|
||||
}
|
||||
|
||||
/// The second stage: the minimum over `span` points spaced `stride` apart.
|
||||
///
|
||||
/// Each of those points already holds the minimum over the run that starts
|
||||
/// there, so this reads the whole window while touching `span` pixels of it.
|
||||
/// `shift` is what centres the composite on the pixel being written; without
|
||||
/// it the veil would be measured from a patch lying entirely to one side, and
|
||||
/// the correction would appear to lag the picture by half a patch.
|
||||
fn erode_span(axis: Axis) -> String {
|
||||
let offset = axis.offset("j * s - o");
|
||||
let first = axis.offset("-o");
|
||||
|
||||
format!(
|
||||
"\
|
||||
// The erosion's second stage. `span` taps, spaced a whole run apart, each
|
||||
// standing for the run that begins at it — so the minimum over the patch costs
|
||||
// `run + span` taps rather than the `run * span` pixels it covers, and it is
|
||||
// the exact minimum over all of them rather than a sample of them.
|
||||
let n = i32(span);
|
||||
let s = i32(stride);
|
||||
let o = i32(shift);
|
||||
var veil = tap_aux(coord, {first});
|
||||
for (var j = 1; j < n; j = j + 1) {{
|
||||
veil = min(veil, tap_aux(coord, {offset}));
|
||||
}}
|
||||
aux = veil;"
|
||||
)
|
||||
}
|
||||
|
||||
/// The recovery: invert the scattering model with the transmission the erosion
|
||||
/// implies.
|
||||
const CLEAR: &str = "\
|
||||
// The veil the four erosion passes measured: the smallest channel anywhere in
|
||||
// the patch around this pixel, which the dark-channel prior reads as the
|
||||
// airlight that has been composited over the scene here.
|
||||
//
|
||||
// Capped at one because the airlight is taken as diffuse white (see the module
|
||||
// documentation) and the intermediate is unclipped: a specular highlight
|
||||
// arrives at three, and three units of `veil` would drive the transmission
|
||||
// negative and turn the recovery inside out. A value above one is a highlight,
|
||||
// not more haze. The lower bound is already guaranteed by `dark_channel`.
|
||||
let veil = min(aux, 1.0);
|
||||
|
||||
// `omega * veil` is the part of the veil this setting removes — negative at a
|
||||
// negative amount, where the same expression composites airlight back on and
|
||||
// deepens the aerial perspective instead.
|
||||
let lifted = omega * veil;
|
||||
|
||||
// The transmission implied by that veil, floored. Where the veil is nearly
|
||||
// total the surviving signal is a few percent of the airlight, and dividing it
|
||||
// back up amplifies its noise by the same factor; the floor is what makes the
|
||||
// worst case a trace of remaining haze rather than a picture of the noise.
|
||||
// Adding haze cannot reach it — `lifted` is negative there, so the
|
||||
// transmission is above one and the max never bites.
|
||||
let t = max(1.0 - lifted, min_transmission);
|
||||
|
||||
// The scattering model, inverted: I = J·t + A·(1 − t) with A taken as one, so
|
||||
// J = (I − A·(1 − t)) / t. Applied per channel rather than as a gain on
|
||||
// luminance, and that is the difference from every other control in this
|
||||
// stage: the veil is grey, so subtracting it *is* a chromaticity change, and
|
||||
// it is the right one — a haze-veiled distance is desaturated because the
|
||||
// airlight diluted it, and removing the airlight is what gives the colour
|
||||
// back.
|
||||
c = (c - lifted) / t;";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::detail::compose_detail;
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
fn ops(amount: f32) -> Vec<Box<dyn Operation>> {
|
||||
vec![Box::new(Dehaze::with_amount(amount))]
|
||||
}
|
||||
|
||||
fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail {
|
||||
compose_detail(&ops(amount), scale, ColourSpace::Srgb)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dehaze_starts_neutral_and_costs_nothing() {
|
||||
// The rule the whole pipeline rests on. An unedited photograph must not
|
||||
// pay for a slider nobody has touched — and this one is five dispatches
|
||||
// when it is on, so "nothing" here is a worthwhile amount of nothing.
|
||||
assert!(!Dehaze::new().is_active());
|
||||
assert!(composed(0.0, RenderScale::full((2000, 1500))).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_patch_is_a_fraction_of_the_frame_and_not_a_count_of_source_pixels() {
|
||||
// TRACES: FR-DSP-1 — the decision the units of this file rest on.
|
||||
//
|
||||
// The patch has to contain something dark and has to stay local, and
|
||||
// both are statements about how much of the *composition* it covers. So
|
||||
// it must be the same proportion of the picture on a proxy as in the
|
||||
// export, which `frame_fraction` gives and `source_pixels` would not:
|
||||
// at one-third scale a source-pixel patch would be three times too
|
||||
// narrow relative to the frame, and the export — the only render
|
||||
// anybody keeps — would be the one that looked nothing like what was
|
||||
// tuned.
|
||||
//
|
||||
// frame_fraction takes the *shorter* edge, and PATCH is 0.01:
|
||||
// 300 → 0.01 × 300 = 3
|
||||
// 3000 → 0.01 × 3000 = 30
|
||||
// 4500 → 0.01 × 4500 = 45
|
||||
for (w, h, expected) in [(400u32, 300u32, 3u32), (4000, 3000, 30), (6000, 4500, 45)] {
|
||||
let patch = Dehaze::with_amount(50.0).patch(RenderScale::full((w, h)));
|
||||
assert_eq!(patch, expected, "{w}x{h}");
|
||||
assert!(
|
||||
(patch as f32 / w.min(h) as f32 - PATCH).abs() < 0.002,
|
||||
"the patch drifted from its declared fraction at {w}x{h}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thumbnail_still_gets_the_correction() {
|
||||
// Deliberately unlike texture, which contributes no pass once its
|
||||
// kernel rounds to nothing. Texture's absence at that size is honest —
|
||||
// a two-pixel surface structure is not in the picture. Dehaze changes
|
||||
// the overall tone and colour, so a thumbnail that disagreed with the
|
||||
// develop view about it would read as a bug.
|
||||
let tiny = RenderScale::full((48, 32));
|
||||
assert_eq!(Dehaze::with_amount(50.0).patch(tiny), 1);
|
||||
assert_eq!(composed(50.0, tiny).len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_split_erosion_covers_the_whole_patch_and_costs_its_square_root() {
|
||||
// The identity the affordability of this operation rests on: eroding by
|
||||
// a run of `d` and then by `k` points spaced `d` apart erodes by the
|
||||
// whole `k·d` window, because structuring elements add. If the product
|
||||
// ever falls short the patch is silently narrower than the one this
|
||||
// file documents, and nothing else would say so.
|
||||
//
|
||||
// 30 px either side → a window of 61
|
||||
// run = ceil(sqrt(61)) = 8
|
||||
// span = ceil(61 / 8) = 8 → covers 64 >= 61
|
||||
let split = Split::of(30);
|
||||
assert_eq!(split.run, 8);
|
||||
assert_eq!(split.span, 8);
|
||||
assert!(split.run * split.span >= 61, "the window is not covered");
|
||||
assert!(
|
||||
split.run + split.span < 61,
|
||||
"the split costs more taps than the window it replaces"
|
||||
);
|
||||
// Centred to within the pixel the odd surplus leaves over: the window
|
||||
// covers [-31, 32] around the pixel being written.
|
||||
assert_eq!(split.shift, 31);
|
||||
assert_eq!(split.reach(), 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_patch_of_one_pixel_still_splits_into_something_that_runs() {
|
||||
// The degenerate end, which a thumbnail reaches. A window of three
|
||||
// splits into two runs of two, covering four — one pixel more than
|
||||
// asked for, on the far side, which is the surplus `Split::shift`
|
||||
// documents rather than an error to correct with a third pass.
|
||||
let split = Split::of(1);
|
||||
assert_eq!(split.run, 2);
|
||||
assert_eq!(split.span, 2);
|
||||
assert!(split.run * split.span >= 3, "the window is not covered");
|
||||
assert_eq!(split.shift, 1);
|
||||
assert_eq!(split.reach(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chain_is_four_erosions_and_a_recovery() {
|
||||
// The shape of the operation, asserted where it is cheap to assert.
|
||||
// The erosions leave the colour alone and hand the veil forward in the
|
||||
// scratch lane; only the last pass touches `c`, which is what makes an
|
||||
// unsharp-mask-shaped operation expressible in a chain that hands each
|
||||
// pass exactly one texture.
|
||||
let composed = composed(60.0, RenderScale::full((2000, 1500)));
|
||||
let labels: Vec<&str> = composed.passes.iter().map(|p| p.label.as_str()).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
[
|
||||
"dehaze/veil-run-x",
|
||||
"dehaze/veil-span-x",
|
||||
"dehaze/veil-run-y",
|
||||
"dehaze/veil-span-y",
|
||||
"dehaze/clear",
|
||||
]
|
||||
);
|
||||
|
||||
// Only the last writes the display texture, so the output transform
|
||||
// happens exactly once (FR-DEV-2).
|
||||
assert!(composed.passes[..4].iter().all(|p| !p.writes_output));
|
||||
assert!(composed.passes[4].writes_output);
|
||||
|
||||
// Nothing here uses the reduced chain — see the module documentation
|
||||
// for why a second operation cannot pick its own `output_scale` while
|
||||
// the runner holds one reduced buffer.
|
||||
assert!(composed.passes.iter().all(|p| p.output_scale == 1));
|
||||
|
||||
// Every pass declares a uniform block the GPU will accept: a struct
|
||||
// whose size is not a multiple of sixteen bytes is rejected outright by
|
||||
// the WGSL uniform address space rules.
|
||||
assert!(composed.passes.iter().all(|p| p.uniforms.len() % 4 == 0));
|
||||
assert!(composed
|
||||
.passes
|
||||
.iter()
|
||||
.all(|p| p.uniforms.iter().all(|v| v.is_finite())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_haze_is_the_same_expression_with_the_sign_turned_round() {
|
||||
// Negative dehaze is the forward model rather than a second operation:
|
||||
// it composites airlight back on in proportion to the veil already
|
||||
// measured, so it deepens the aerial perspective a scene has instead of
|
||||
// fogging it evenly — and a scene with no haze in it stays clear.
|
||||
assert!(Dehaze::with_amount(-100.0).is_active());
|
||||
assert!(Dehaze::with_amount(-40.0).omega() < 0.0);
|
||||
let symmetric = Dehaze::with_amount(-40.0).omega() + Dehaze::with_amount(40.0).omega();
|
||||
assert!(symmetric.abs() < 1e-6, "the two directions disagree");
|
||||
// And at the top of the range some veil is deliberately left behind, so
|
||||
// a landscape keeps its distance rather than becoming a cut-out.
|
||||
assert!(Dehaze::with_amount(100.0).omega() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_veil_is_measured_from_the_colour_once_and_then_from_the_lane() {
|
||||
// The one asymmetry between the two axes, and the one that would be
|
||||
// invisible if it were wrong: x reduces the colour to its dark channel,
|
||||
// y erodes the scalar x left behind. Taking the channel minimum again
|
||||
// on the second axis would reduce a scalar and quietly discard the
|
||||
// whole x erosion — a patch half the width this file documents, with
|
||||
// nothing to say so.
|
||||
let composed = composed(60.0, RenderScale::full((2000, 1500)));
|
||||
assert!(composed.passes[0].source.contains("dark_channel(tap(coord"));
|
||||
assert!(!composed.passes[2].source.contains("dark_channel(tap(coord"));
|
||||
// The helper is still emitted for every pass of the operation, and it
|
||||
// must define the function it is named for or the shader fails to
|
||||
// compile a long way from here.
|
||||
assert!(composed
|
||||
.passes
|
||||
.iter()
|
||||
.all(|p| p.source.contains("fn dark_channel(")));
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,13 @@ impl Warp for Distortion {
|
||||
c.a != 0.0 || c.b != 0.0 || c.c != 0.0
|
||||
}
|
||||
|
||||
fn set_profile(&mut self, profile: Option<&crate::lens::LensProfile>) {
|
||||
// The inherent `set_profile` taking just this correction's own
|
||||
// coefficients, not this trait method: an inherent method wins over a
|
||||
// trait one of the same name, so this is a narrowing and not a loop.
|
||||
self.set_profile(profile.and_then(|p| p.distortion));
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
// Written against `p`, which is already normalised and centred.
|
||||
"\
|
||||
|
||||
@@ -78,7 +78,17 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
// Tone and colour both, and not `Effect`: a stock is not something applied
|
||||
// on top of a photograph, it is what the photograph was made on.
|
||||
attributes: vec![Attribute::Tone, Attribute::Colour],
|
||||
// Effect, not tone-and-colour. **This is the descriptor a `rust:` node
|
||||
// is actually read from** — the `attributes:` line in `ops/*.yaml`
|
||||
// describes a *declared* node and is inert here, which is how an
|
||||
// earlier attempt to make this move changed nothing at all.
|
||||
//
|
||||
// A stock is `Effect`'s own definition: applied rather than corrected,
|
||||
// a look and not a fix. Declaring tone and colour put "Kodachrome" in
|
||||
// the Light group beside exposure and again in Colour beside white
|
||||
// balance — two places, neither of which is where anyone looks for it.
|
||||
// That it moves tone and colour is true of every look.
|
||||
attributes: vec![Attribute::Effect],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.film_sim"),
|
||||
params: vec![
|
||||
|
||||
@@ -141,16 +141,35 @@
|
||||
//! # What this costs
|
||||
//!
|
||||
//! Clarity's kernel is large — of the order of a hundred taps per pass at
|
||||
//! preview resolution — and the two passes are the honest, exact separable
|
||||
//! Gaussian rather than a sparse approximation of one. A strided kernel would
|
||||
//! be several times cheaper and is deliberately not taken: undersampling an
|
||||
//! image that is not band-limited aliases high-frequency content down into the
|
||||
//! base, the base is then subtracted, and the aliasing arrives in the output as
|
||||
//! preview resolution — and each half is the honest, exact separable Gaussian
|
||||
//! rather than a sparse approximation of one. A strided kernel would be
|
||||
//! several times cheaper and is deliberately not taken: undersampling an image
|
||||
//! that is not band-limited aliases high-frequency content down into the base,
|
||||
//! the base is then subtracted, and the aliasing arrives in the output as
|
||||
//! low-frequency mottling across smooth gradients. Mottled skies are precisely
|
||||
//! the artefact this control must not have. The right optimisation is a base
|
||||
//! computed at reduced resolution, which needs a detail stage that can write a
|
||||
//! smaller target than it reads; that is a change to [`crate::detail`], not to
|
||||
//! this file.
|
||||
//! the artefact this control must not have.
|
||||
//!
|
||||
//! Run at the render size, that measured **34 ms at 4K** — seven times the
|
||||
//! entire fused point chain, for one slider — which is `docs/technical-debt.md`
|
||||
//! TD-4 and is what [`Recipe::base_scale`] now answers. The base is computed on
|
||||
//! a grid a quarter the size on each axis: a sixteenth of the pixels at a
|
||||
//! quarter of the radius.
|
||||
//!
|
||||
//! **This is not the strided kernel wearing a hat**, and the difference is
|
||||
//! exactly the paragraph above. A stride samples an image that is not band-
|
||||
//! limited and aliases; the reduction *band-limits first* — that is what the
|
||||
//! `reduce` pass is for and why it is a separate dispatch — and only then
|
||||
//! samples. What is thrown away is content the base could not represent at any
|
||||
//! resolution, because a Gaussian at σ = 26 px has nothing above one cycle per
|
||||
//! 26 px in it and the quarter-scale grid carries one cycle per 8 px. So the
|
||||
//! reduced base is not an approximation of the full-resolution base; it is the
|
||||
//! same band-limited function, sampled where it is still fully determined.
|
||||
//!
|
||||
//! Which is also why [`LocalContrast::reduction`] steps down and why texture
|
||||
//! never reduces at all. The argument holds only while the reduced grid can
|
||||
//! still carry the Gaussian, and the moment it cannot, the honest answer is
|
||||
//! the full-resolution one — which is the cheap case anyway, because the
|
||||
//! viewport that produced it is small.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
@@ -176,6 +195,14 @@ pub const AMOUNT: ParamId = ParamId("amount");
|
||||
/// pipeline.
|
||||
const TRUNCATION: f32 = 2.0;
|
||||
|
||||
/// The smallest σ, in reduced pixels, worth running a Gaussian over.
|
||||
///
|
||||
/// One pixel, which with [`TRUNCATION`] is a five-tap kernel — the narrowest
|
||||
/// that still has a shape. Below it the weights collapse towards a single tap
|
||||
/// and the blur that survives is the reduce pass's box, which is a different
|
||||
/// filter with a different edge response. See [`LocalContrast::reduction`].
|
||||
const MIN_REDUCED_SIGMA: f32 = 1.0;
|
||||
|
||||
/// Everything that makes one of these two controls the control it is.
|
||||
///
|
||||
/// A struct rather than four associated constants so that the differences
|
||||
@@ -200,6 +227,15 @@ pub struct Recipe {
|
||||
/// (4) — true for clarity, false for texture, and that asymmetry is
|
||||
/// deliberate.
|
||||
midtone_taper: bool,
|
||||
/// The most this band's base may be shrunk before it is blurred.
|
||||
///
|
||||
/// A ceiling, not the answer — [`LocalContrast::reduction`] steps it down
|
||||
/// on a viewport too small to carry it. `1` refuses the optimisation
|
||||
/// outright, which is the only correct value for a band whose σ is already
|
||||
/// a few pixels.
|
||||
///
|
||||
/// Must be a power of two: the step-down halves.
|
||||
base_scale: u32,
|
||||
}
|
||||
|
||||
/// The band of spatial frequencies a control acts on.
|
||||
@@ -235,6 +271,22 @@ impl Band for Coarse {
|
||||
threshold: 0.35,
|
||||
gain: 1.0,
|
||||
midtone_taper: true,
|
||||
// A quarter, which is what `docs/technical-debt.md` TD-4 bought back.
|
||||
//
|
||||
// σ is 1.2% of the shorter edge — 26 px at 4K — so the base holds no
|
||||
// spatial frequency anywhere near the quarter-scale Nyquist of one
|
||||
// cycle per 8 px. Computing it there is not an approximation of the
|
||||
// full-resolution base; it is the same band-limited function sampled
|
||||
// where it is still fully determined. What it costs is a sixteenth of
|
||||
// the pixels at a quarter of the radius, about a sixty-fourth of the
|
||||
// work, against the 34 ms this control measured at 4K.
|
||||
//
|
||||
// Not an eighth. σ/8 is 3.2 px at 4K and under two on a 1080p
|
||||
// viewport, which is where the reduce pass's own box filter starts
|
||||
// doing more of the blurring than the Gaussian does — and the halo
|
||||
// behaviour this operation is careful about is a property of the
|
||||
// Gaussian.
|
||||
base_scale: 4,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,6 +308,13 @@ impl Band for Fine {
|
||||
// equal numbers on the two sliders should land at comparable strength.
|
||||
gain: 1.25,
|
||||
midtone_taper: false,
|
||||
// Never reduced, and this is the reason the scale belongs to the band
|
||||
// rather than to the stage. Texture's σ is a decade finer — 2.6 px at
|
||||
// 4K — so a quarter-scale grid would not hold its base at all: the
|
||||
// reduce pass's 4x4 box is already wider than the Gaussian it would be
|
||||
// prefiltering, and what came back would be a blur of the wrong width
|
||||
// rather than a cheaper blur of the right one.
|
||||
base_scale: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -385,6 +444,32 @@ impl<B: Band> LocalContrast<B> {
|
||||
(self.sigma(scale) * TRUNCATION).round().max(0.0) as u32
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-3
|
||||
/// The factor this render's base is computed at — 1 meaning "the render
|
||||
/// size", as everything did before TD-4.
|
||||
///
|
||||
/// [`Recipe::base_scale`] is a ceiling rather than the answer, because a
|
||||
/// reduced grid still has to hold a Gaussian. At a quarter of a small
|
||||
/// viewport clarity's σ falls under a pixel, and a kernel of one or two
|
||||
/// taps is not a Gaussian — it is the reduce pass's own box filter with a
|
||||
/// rounding error on top, which would make the control change character on
|
||||
/// a window resize rather than merely get cheaper.
|
||||
///
|
||||
/// So the reduction steps down by halves until the reduced σ is worth
|
||||
/// convolving: a quarter on a desktop viewport, a half on a small one,
|
||||
/// none on a thumbnail. Stepping down rather than switching off keeps most
|
||||
/// of the saving in the middle of the range, and the case it gives up on
|
||||
/// is the one that was already cheap — the cost is `radius x pixels` and a
|
||||
/// small viewport is small in both.
|
||||
pub fn reduction(&self, scale: RenderScale) -> u32 {
|
||||
let sigma = self.sigma(scale);
|
||||
let mut reduction = B::RECIPE.base_scale.max(1);
|
||||
while reduction > 1 && sigma / (reduction as f32) < MIN_REDUCED_SIGMA {
|
||||
reduction /= 2;
|
||||
}
|
||||
reduction
|
||||
}
|
||||
|
||||
/// Stops of local contrast at this slider position.
|
||||
fn gain(&self) -> f32 {
|
||||
self.amount / 100.0 * B::RECIPE.gain
|
||||
@@ -481,27 +566,134 @@ impl<B: Band> DetailStage for LocalContrast<B> {
|
||||
value: self.gain(),
|
||||
});
|
||||
|
||||
let reduction = self.reduction(scale);
|
||||
if reduction == 1 {
|
||||
// The full-resolution form, unchanged: blur x into the scratch
|
||||
// lane, then finish along y and apply the mask in one pass.
|
||||
return vec![
|
||||
DetailPass {
|
||||
output_scale: 1,
|
||||
label: "base",
|
||||
radius,
|
||||
// A convolution, not a list: nothing to bind at binding 3.
|
||||
storage: Vec::new(),
|
||||
uniforms: shape,
|
||||
wgsl: BASE_X.to_string(),
|
||||
},
|
||||
DetailPass {
|
||||
output_scale: 1,
|
||||
label: "combine",
|
||||
radius,
|
||||
// A convolution, not a list: nothing to bind at binding 3.
|
||||
storage: Vec::new(),
|
||||
uniforms: combine,
|
||||
wgsl: combine_body(B::RECIPE.midtone_taper, Base::Convolved),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The reduced form. Four passes rather than two, and cheaper than the
|
||||
// two by a factor of about `reduction²`: three of them run on a grid
|
||||
// that many times smaller on each axis, and the one that does not is a
|
||||
// single bilinear read.
|
||||
//
|
||||
// The σ and the radius are the band's own, divided — not recomputed
|
||||
// from `RenderScale`, which knows nothing about this grid. Deriving
|
||||
// them from the numbers the full-resolution path uses is what keeps
|
||||
// the two forms the same filter, so that crossing the threshold in
|
||||
// `reduction` does not change the picture.
|
||||
let reduced_sigma = sigma / reduction as f32;
|
||||
// At least one tap either side. `reduction` has already guaranteed
|
||||
// σ >= MIN_REDUCED_SIGMA, so this floor is a belt on top of a brace.
|
||||
let reduced_radius = ((reduced_sigma * TRUNCATION).round() as u32).max(1);
|
||||
let reduced_shape = vec![
|
||||
Uniform {
|
||||
name: "radius",
|
||||
value: reduced_radius as f32,
|
||||
},
|
||||
Uniform {
|
||||
name: "inv_variance",
|
||||
value: 1.0 / (reduced_sigma * reduced_sigma),
|
||||
},
|
||||
];
|
||||
|
||||
// The combining pass no longer convolves anything, so it needs neither
|
||||
// the radius nor the variance — only the two numbers the unsharp mask
|
||||
// itself is made of.
|
||||
let mask = vec![
|
||||
Uniform {
|
||||
name: "threshold",
|
||||
value: B::RECIPE.threshold,
|
||||
},
|
||||
Uniform {
|
||||
name: "gain",
|
||||
value: self.gain(),
|
||||
},
|
||||
];
|
||||
|
||||
vec![
|
||||
DetailPass {
|
||||
label: "base",
|
||||
radius,
|
||||
// A convolution, not a list: nothing to bind at binding 3.
|
||||
output_scale: reduction,
|
||||
label: "reduce",
|
||||
// Reads only the block it writes, so it reaches no further
|
||||
// than the pixel it is producing and a tile needs no halo for
|
||||
// it. The halo the *chain* needs comes from the blurs below.
|
||||
radius: 0,
|
||||
storage: Vec::new(),
|
||||
uniforms: shape,
|
||||
wgsl: BASE_X.to_string(),
|
||||
uniforms: vec![Uniform {
|
||||
name: "reduction",
|
||||
value: reduction as f32,
|
||||
}],
|
||||
wgsl: REDUCE.to_string(),
|
||||
},
|
||||
DetailPass {
|
||||
label: "combine",
|
||||
radius,
|
||||
// A convolution, not a list: nothing to bind at binding 3.
|
||||
output_scale: reduction,
|
||||
label: "base-x",
|
||||
radius: reduced_radius,
|
||||
storage: Vec::new(),
|
||||
uniforms: combine,
|
||||
wgsl: combine_body(B::RECIPE.midtone_taper),
|
||||
uniforms: reduced_shape.clone(),
|
||||
wgsl: reduced_blur(Axis::X),
|
||||
},
|
||||
DetailPass {
|
||||
output_scale: reduction,
|
||||
label: "base-y",
|
||||
radius: reduced_radius,
|
||||
storage: Vec::new(),
|
||||
uniforms: reduced_shape,
|
||||
wgsl: reduced_blur(Axis::Y),
|
||||
},
|
||||
DetailPass {
|
||||
output_scale: 1,
|
||||
label: "combine",
|
||||
// One bilinear read of the reduced chain, which reaches one
|
||||
// reduced pixel — `reduction` render pixels — around itself.
|
||||
// Stated rather than left at zero because an understated
|
||||
// radius is a tile seam, and a seam is worth more than the
|
||||
// three lines it costs to be accurate here.
|
||||
radius: reduction,
|
||||
storage: Vec::new(),
|
||||
uniforms: mask,
|
||||
wgsl: combine_body(B::RECIPE.midtone_taper, Base::Reduced),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Which way a separable half runs.
|
||||
enum Axis {
|
||||
X,
|
||||
Y,
|
||||
}
|
||||
|
||||
/// Where the combining pass finds the base it subtracts.
|
||||
enum Base {
|
||||
/// Convolved along y by the combining pass itself, out of the scratch
|
||||
/// lane the previous pass wrote. The full-resolution form.
|
||||
Convolved,
|
||||
/// Already finished, on the reduced chain, and read back up.
|
||||
Reduced,
|
||||
}
|
||||
|
||||
/// Half of the base, along x.
|
||||
///
|
||||
/// Deliberately does not touch `c`: the pass after this one needs the
|
||||
@@ -533,13 +725,114 @@ for (var i = -r; i <= r; i = i + 1) {
|
||||
// exist.
|
||||
aux = sum / weight;";
|
||||
|
||||
/// Band-limit the image onto the reduced grid, in log luminance.
|
||||
///
|
||||
/// A pass of its own rather than something the first blur half does on the
|
||||
/// way past, because it is a different filter doing a different job: this one
|
||||
/// exists so that the Gaussian's *input* is representable on the coarse grid.
|
||||
/// Sampling every fourth pixel instead would alias — a shimmer that changes
|
||||
/// when the viewport is resized, which is the classic way a mip-based blur
|
||||
/// goes wrong and is very hard to attribute to a clarity slider.
|
||||
///
|
||||
/// A box over exactly the block the output pixel covers. Not a wider or
|
||||
/// prettier prefilter: the Gaussian that follows is 8σ wide on this grid, so
|
||||
/// what a better prefilter would buy is a correction of a fraction of a
|
||||
/// reduced pixel to a curve four pixels across, and it would cost taps on the
|
||||
/// only pass here that reads the full-resolution image.
|
||||
///
|
||||
/// **In log luminance, not linear.** The base is a mean of logarithms — that
|
||||
/// is what makes `detail` a ratio and the whole operation exposure-invariant
|
||||
/// (see the module documentation, halo control 1). Averaging linear values
|
||||
/// here and taking the logarithm later is a different number, and the
|
||||
/// difference is precisely the local contrast this operation exists to
|
||||
/// measure: it would be quietly subtracted out of every block.
|
||||
const REDUCE: &str = "\
|
||||
// The colour rides through untouched, as it does in every pass of this
|
||||
// operation — though here it is untouched and also unused: the reduced chain
|
||||
// carries a scalar, and the colour the combining pass subtracts from is the
|
||||
// full-resolution one it reads from the other chain.
|
||||
let s = i32(reduction);
|
||||
var sum = 0.0;
|
||||
for (var y = 0; y < s; y = y + 1) {
|
||||
for (var x = 0; x < s; x = x + 1) {
|
||||
sum = sum + log_luma(tap(coord, vec2<i32>(x, y)));
|
||||
}
|
||||
}
|
||||
aux = sum / f32(s * s);";
|
||||
|
||||
/// One half of the reduced separable Gaussian.
|
||||
///
|
||||
/// Reads the scratch lane rather than the colour, which is the one line that
|
||||
/// differs from [`BASE_X`]: by this point the log-luminance conversion has
|
||||
/// already been done, once, by the reduce pass. Doing it again per tap would
|
||||
/// be a logarithm inside the inner loop for a value that cannot have changed.
|
||||
fn reduced_blur(axis: Axis) -> String {
|
||||
let offset = match axis {
|
||||
Axis::X => "vec2<i32>(i, 0)",
|
||||
Axis::Y => "vec2<i32>(0, i)",
|
||||
};
|
||||
format!(
|
||||
"\
|
||||
// Half of a separable Gaussian over the reduced base, in log luminance.
|
||||
//
|
||||
// Weights are evaluated rather than tabulated, as in `BASE_X` and for the same
|
||||
// reason — and here the loop is a quarter as long, which is the whole point.
|
||||
let r = i32(radius);
|
||||
var sum = 0.0;
|
||||
var weight = 0.0;
|
||||
for (var i = -r; i <= r; i = i + 1) {{
|
||||
let f = f32(i);
|
||||
let w = exp(-0.5 * f * f * inv_variance);
|
||||
sum = sum + w * tap_aux(coord, {offset});
|
||||
weight = weight + w;
|
||||
}}
|
||||
// Normalised by the weights actually summed, so a kernel clamped at the
|
||||
// border averages the pixels that exist rather than fading towards zero.
|
||||
aux = sum / weight;"
|
||||
)
|
||||
}
|
||||
|
||||
/// The second pass: finish the base along y, then apply the mask.
|
||||
///
|
||||
/// Generated rather than constant because the midtone taper is present for
|
||||
/// clarity and absent for texture. Emitting the line only where it applies
|
||||
/// keeps texture's shader honest about not having one, and saves it a uniform
|
||||
/// and two helper functions it would never call.
|
||||
fn combine_body(midtone_taper: bool) -> String {
|
||||
fn combine_body(midtone_taper: bool, base: Base) -> String {
|
||||
// Where the base comes from — the one thing that differs between the two
|
||||
// forms. Everything below this line is the unsharp mask itself, written
|
||||
// once, so the reduced form cannot drift into being a different operation
|
||||
// from the full-resolution one it replaces.
|
||||
let base = match base {
|
||||
Base::Convolved => "\
|
||||
// The other half of the base, then the unsharp mask itself.
|
||||
//
|
||||
// `tap_aux` reads the previous pass's log-luminance blur, while `c` is still
|
||||
// the colour the colour pass produced — which is the arrangement that makes an
|
||||
// unsharp mask expressible in a chain that hands on one texture per pass.
|
||||
let r = i32(radius);
|
||||
var sum = 0.0;
|
||||
var weight = 0.0;
|
||||
for (var i = -r; i <= r; i = i + 1) {
|
||||
let f = f32(i);
|
||||
let w = exp(-0.5 * f * f * inv_variance);
|
||||
sum = sum + w * tap_aux(coord, vec2<i32>(0, i));
|
||||
weight = weight + w;
|
||||
}
|
||||
let base = sum / weight;"
|
||||
.to_string(),
|
||||
Base::Reduced => "\
|
||||
// The base, finished on the reduced chain and read back up bilinearly. `c` is
|
||||
// the full-resolution colour, straight off the other chain — which is why the
|
||||
// reduced passes had to leave that chain alone, and why this pass reads two
|
||||
// textures rather than one.
|
||||
//
|
||||
// One read where the full-resolution form runs a 105-tap convolution. That
|
||||
// difference *is* TD-4.
|
||||
let base = reduced_at(coord);"
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let weight = if midtone_taper {
|
||||
"\n\
|
||||
// Clarity only: tapered to nothing at both ends of the range. See\n\
|
||||
@@ -556,21 +849,7 @@ fn combine_body(midtone_taper: bool) -> String {
|
||||
|
||||
format!(
|
||||
"\
|
||||
// The other half of the base, then the unsharp mask itself.
|
||||
//
|
||||
// `tap_aux` reads the previous pass's log-luminance blur, while `c` is still
|
||||
// the colour the colour pass produced — which is the arrangement that makes an
|
||||
// unsharp mask expressible in a chain that hands on one texture per pass.
|
||||
let r = i32(radius);
|
||||
var sum = 0.0;
|
||||
var weight = 0.0;
|
||||
for (var i = -r; i <= r; i = i + 1) {{
|
||||
let f = f32(i);
|
||||
let w = exp(-0.5 * f * f * inv_variance);
|
||||
sum = sum + w * tap_aux(coord, vec2<i32>(0, i));
|
||||
weight = weight + w;
|
||||
}}
|
||||
let base = sum / weight;
|
||||
{base}
|
||||
|
||||
// Local contrast, in **stops**. Both terms are logarithms, so this is a ratio:
|
||||
// `detail` says how much brighter this pixel is than its surroundings, and
|
||||
@@ -632,14 +911,34 @@ mod tests {
|
||||
// forty-pixel blur for a control sitting at zero.
|
||||
let scale = RenderScale::full((2000, 1500));
|
||||
let only_clarity = composed(50.0, 0.0, scale);
|
||||
assert_eq!(only_clarity.len(), 2, "one operation, two passes");
|
||||
// Four at this viewport, because clarity's base is computed reduced
|
||||
// here — reduce, two blur halves, combine. The number is the band's
|
||||
// and the viewport's, not a constant; what this test is about is that
|
||||
// *all* of them belong to clarity.
|
||||
assert_eq!(only_clarity.len(), 4, "one operation, its own passes");
|
||||
assert!(only_clarity
|
||||
.passes
|
||||
.iter()
|
||||
.all(|p| p.label.starts_with("clarity/")));
|
||||
|
||||
// Both on: clarity's four plus texture's two. Texture stays at the
|
||||
// render size whatever the viewport — its band is a decade finer, so
|
||||
// a reduced grid could not hold its base — and that asymmetry is the
|
||||
// reason the scale belongs to the band rather than to the stage.
|
||||
let both = composed(50.0, 50.0, scale);
|
||||
assert_eq!(both.len(), 4);
|
||||
assert_eq!(both.len(), 6);
|
||||
assert_eq!(
|
||||
both.passes
|
||||
.iter()
|
||||
.filter(|p| p.label.starts_with("texture/"))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(both
|
||||
.passes
|
||||
.iter()
|
||||
.filter(|p| p.label.starts_with("texture/"))
|
||||
.all(|p| p.output_scale == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -759,21 +1058,46 @@ mod tests {
|
||||
// passes on one texture per pass. If the blur pass ever writes `c`,
|
||||
// the combining pass has nothing to subtract the base *from* and the
|
||||
// operation silently becomes a blur.
|
||||
let passes = Clarity::with_amount(50.0).passes(RenderScale::full((2000, 1500)));
|
||||
let base = &passes[0];
|
||||
let combine = &passes[1];
|
||||
// Both forms, because they are two chains and the property has to
|
||||
// hold in each. A thumbnail is too small to carry a reduced base and
|
||||
// takes the two-pass path; a desktop viewport takes the four-pass one.
|
||||
for scale in [
|
||||
RenderScale::full((160, 120)),
|
||||
RenderScale::full((2000, 1500)),
|
||||
] {
|
||||
let passes = Clarity::with_amount(50.0).passes(scale);
|
||||
let (combine, blurs) = passes.split_last().expect("clarity is active");
|
||||
|
||||
assert!(base.wgsl.contains("aux = sum / weight;"));
|
||||
for blur in blurs {
|
||||
assert!(
|
||||
!blur.wgsl.contains("c = "),
|
||||
"{} must leave the colour alone: {}",
|
||||
blur.label,
|
||||
blur.wgsl
|
||||
);
|
||||
assert!(
|
||||
blur.wgsl.contains("aux ="),
|
||||
"{} must put its result in the scratch lane",
|
||||
blur.label
|
||||
);
|
||||
}
|
||||
|
||||
// However the base was arrived at, the mask subtracts it from the
|
||||
// colour the *colour* pass produced. That is the whole property:
|
||||
// an unsharp mask needs the blur and the original together, and
|
||||
// neither chain may have overwritten the original on the way.
|
||||
assert!(combine.wgsl.contains("log_luma(c) - base"));
|
||||
}
|
||||
|
||||
// And the two forms differ in exactly one place — where `base` came
|
||||
// from. A reduced combine does no convolution at all.
|
||||
let reduced = Clarity::with_amount(50.0).passes(RenderScale::full((2000, 1500)));
|
||||
let combine = reduced.last().expect("clarity is active");
|
||||
assert!(combine.wgsl.contains("let base = reduced_at(coord);"));
|
||||
assert!(
|
||||
!base.wgsl.contains("c = "),
|
||||
"the blur pass must leave the colour alone: {}",
|
||||
base.wgsl
|
||||
!combine.wgsl.contains("tap_aux("),
|
||||
"a reduced combine has nothing left to convolve"
|
||||
);
|
||||
assert!(
|
||||
combine.wgsl.contains("tap_aux("),
|
||||
"the combining pass must read the blur out of the scratch lane"
|
||||
);
|
||||
assert!(combine.wgsl.contains("log_luma(c) - base"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -795,8 +1119,29 @@ mod tests {
|
||||
let clarity = Clarity::with_amount(50.0).kernel(scale);
|
||||
assert_eq!(composed.radius(), clarity, "the widest pass sets the halo");
|
||||
for pass in &composed.passes {
|
||||
// The reduce pass is the one honest exception: it reads exactly
|
||||
// the block it writes and no further, so a tile computing it needs
|
||||
// no halo at all. Every other pass reaches somewhere and must say
|
||||
// so.
|
||||
if pass.label.ends_with("/reduce") {
|
||||
assert_eq!(pass.radius, 0, "the reduce pass reads only its own block");
|
||||
continue;
|
||||
}
|
||||
assert!(pass.radius > 0, "{} declared no reach", pass.label);
|
||||
}
|
||||
|
||||
// The equality above is the claim worth restating: a reduced base
|
||||
// reaches exactly as far across the photograph as the full-resolution
|
||||
// one it replaces. `radius x output_scale`, 9 x 4 against 36, which is
|
||||
// what makes the reduction invisible to a tile scheduler.
|
||||
let reduced = Clarity::with_amount(50.0);
|
||||
assert_eq!(reduced.reduction(scale), 4);
|
||||
let base_x = composed
|
||||
.passes
|
||||
.iter()
|
||||
.find(|p| p.label.ends_with("/base-x"))
|
||||
.expect("a reduced chain has an x half");
|
||||
assert_eq!(base_x.radius * base_x.output_scale, clarity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -805,7 +1150,8 @@ mod tests {
|
||||
// saturates at the threshold, so the largest overshoot a full-travel
|
||||
// slider can produce is `gain * threshold` stops however violent the
|
||||
// edge — a bound that holds by construction rather than by tuning.
|
||||
let combine = &Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1];
|
||||
let passes = Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)));
|
||||
let combine = passes.last().expect("clarity is active");
|
||||
assert!(combine
|
||||
.wgsl
|
||||
.contains("threshold * tanh(detail / threshold)"));
|
||||
@@ -824,7 +1170,8 @@ mod tests {
|
||||
// Both at full travel, which is what makes them a bound and not a
|
||||
// measurement: no picture, and no edge in any picture, can produce more.
|
||||
assert!((bound(&combine.uniforms) - 0.35).abs() < 1e-6);
|
||||
let texture = &Texture::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1];
|
||||
let texture_passes = Texture::with_amount(100.0).passes(RenderScale::full((2000, 1500)));
|
||||
let texture = texture_passes.last().expect("texture is active");
|
||||
assert!((bound(&texture.uniforms) - 1.25).abs() < 1e-6);
|
||||
}
|
||||
|
||||
@@ -834,8 +1181,10 @@ mod tests {
|
||||
// highlight and fabric in a shadow, which is exactly where clarity
|
||||
// must not.
|
||||
let scale = RenderScale::full((2000, 1500));
|
||||
let clarity = &Clarity::with_amount(50.0).passes(scale)[1];
|
||||
let texture = &Texture::with_amount(50.0).passes(scale)[1];
|
||||
let clarity_passes = Clarity::with_amount(50.0).passes(scale);
|
||||
let texture_passes = Texture::with_amount(50.0).passes(scale);
|
||||
let clarity = clarity_passes.last().expect("clarity is active");
|
||||
let texture = texture_passes.last().expect("texture is active");
|
||||
assert!(clarity.wgsl.contains("midtone_weight(luminance(c))"));
|
||||
assert!(!texture.wgsl.contains("midtone_weight"));
|
||||
|
||||
@@ -865,15 +1214,24 @@ mod tests {
|
||||
let up = Clarity::with_amount(50.0).passes(scale);
|
||||
let down = Clarity::with_amount(-50.0).passes(scale);
|
||||
let gain = |p: &[DetailPass]| {
|
||||
p[1].uniforms
|
||||
p.last()
|
||||
.expect("clarity is active")
|
||||
.uniforms
|
||||
.iter()
|
||||
.find(|u| u.name == "gain")
|
||||
.unwrap()
|
||||
.value
|
||||
};
|
||||
assert!((gain(&up) + gain(&down)).abs() < 1e-6);
|
||||
// Same kernel either way — the direction is a sign, not a scale.
|
||||
assert_eq!(up[0].radius, down[0].radius);
|
||||
// Same kernel either way — the direction is a sign, not a scale. Read
|
||||
// off the blur halves rather than the first pass, which since the
|
||||
// reduced base exists is a downscale carrying no radius at all.
|
||||
let radii = |p: &[DetailPass]| {
|
||||
p.iter()
|
||||
.map(|d| (d.radius, d.output_scale))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(radii(&up), radii(&down));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -54,6 +54,14 @@
|
||||
//! than `Operation`, because they rewrite *coordinates* before the source is
|
||||
//! sampled rather than transforming a colour after it. They are not part of
|
||||
//! the develop chain and do not appear in `ops/`.
|
||||
//!
|
||||
//! [`vignetting`] is the exception, and the reason the split is drawn at
|
||||
//! coordinates rather than at "lens correction": it applies a gain to the
|
||||
//! pixel already fetched, so it is an ordinary node in `ops/` like any other.
|
||||
//! What it needs that a colour fragment is not otherwise given is the pixel's
|
||||
//! distance from the optical axis, which the sampler publishes as `radius`
|
||||
//! — corner-normalised there, because the profile coefficients are fitted
|
||||
//! against a corner radius of 1 and the prologue's `p` is not.
|
||||
|
||||
// Hand-written nodes. Each is listed in `ops/` with `rust:`, which is what
|
||||
// places it in the chain; these are the implementations that entry points at.
|
||||
@@ -61,6 +69,7 @@ pub mod aberration;
|
||||
pub mod capture_sharpen;
|
||||
pub mod colour_mixer;
|
||||
pub mod curve;
|
||||
pub mod dehaze;
|
||||
pub mod distortion;
|
||||
pub mod film_sim;
|
||||
pub mod local_contrast;
|
||||
@@ -71,6 +80,7 @@ pub use aberration::Aberration;
|
||||
pub use capture_sharpen::CaptureSharpen;
|
||||
pub use colour_mixer::ColourMixer;
|
||||
pub use curve::ToneCurve;
|
||||
pub use dehaze::Dehaze;
|
||||
pub use distortion::Distortion;
|
||||
pub use film_sim::{FilmSim, FilmTables};
|
||||
// Clarity and texture are one implementation at two scales; see the module's
|
||||
|
||||
@@ -437,6 +437,7 @@ impl DetailStage for NoiseReduction {
|
||||
let luma = self.luminance_kernel(scale);
|
||||
if luma > 0 {
|
||||
passes.push(DetailPass {
|
||||
output_scale: 1,
|
||||
label: "luminance",
|
||||
radius: luma,
|
||||
// A convolution, not a list: nothing to bind at binding 3.
|
||||
@@ -473,6 +474,7 @@ impl DetailStage for NoiseReduction {
|
||||
// from the other and the result acquires a diagonal bias.
|
||||
for (index, (sx, sy)) in [(1.0, 0.0), (0.0, 1.0)].into_iter().enumerate() {
|
||||
passes.push(DetailPass {
|
||||
output_scale: 1,
|
||||
label: if index == 0 {
|
||||
"chroma-horizontal"
|
||||
} else {
|
||||
|
||||
@@ -133,6 +133,13 @@ impl Operation for Vignetting {
|
||||
c.k1 != 0.0 || c.k2 != 0.0 || c.k3 != 0.0
|
||||
}
|
||||
|
||||
fn set_lens_profile(&mut self, profile: Option<&crate::lens::LensProfile>) {
|
||||
// The inherent `set_profile` taking just this correction's own
|
||||
// coefficients, not this trait method: an inherent method wins over a
|
||||
// trait one of the same name, so this is a narrowing and not a loop.
|
||||
self.set_profile(profile.and_then(|p| p.vignetting));
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
// `radius` comes from the prologue: the pixel's distance from the
|
||||
// optical axis, normalised so the corner is 1.
|
||||
|
||||
+832
-36
File diff suppressed because it is too large
Load Diff
+781
-62
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