Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fb3eb5d2d | ||
|
|
f9e331eed2 | ||
|
|
f41b3f6e8e | ||
|
|
bb3b512b81 | ||
|
|
d2af6a3981 | ||
|
|
d70fe9da8d | ||
|
|
2c84aa1224 | ||
|
|
0bba882fb1 | ||
|
|
e997be8c72 | ||
|
|
a8ce1ff19c | ||
|
|
a1e8f494b9 | ||
|
|
35febf31dd | ||
|
|
0729dfa359 | ||
|
|
9052b1a1de | ||
|
|
76eece8500 | ||
|
|
43097f5033 | ||
|
|
4ed10f7b23 | ||
|
|
cf614efa61 | ||
|
|
b60f9d10d9 | ||
|
|
41daa4cca7 | ||
|
|
c81d6865a7 | ||
|
|
1d4c3348af | ||
|
|
c7cdf7e5f0 | ||
|
|
af5a13b3f7 | ||
|
|
a1790c3e67 | ||
|
|
329c388d30 | ||
|
|
7d23fe8683 | ||
|
|
4fa914cbb1 | ||
|
|
ffc9e1aea7 | ||
|
|
cafa63ca6f | ||
|
|
d6380fecc8 | ||
|
|
79c0520506 | ||
|
|
7275c020d7 | ||
|
|
c10dca984f | ||
|
|
7dfbe3184a | ||
|
|
28c046130c | ||
|
|
7b4263ceb9 | ||
|
|
371038614f | ||
|
|
242374fd0f | ||
|
|
051447bda6 | ||
|
|
617262b4da | ||
|
|
5e4c7424ed | ||
|
|
2c56729354 | ||
|
|
fb1b44ce47 | ||
|
|
8202c05d9d | ||
|
|
29da637fa2 | ||
|
|
0cd3ef1b3f | ||
|
|
3b5bba62f1 | ||
|
|
510d1a26cb | ||
|
|
2e6825ded0 | ||
|
|
772a69711d | ||
|
|
b858fc029a | ||
|
|
0c3b8cb1c4 | ||
|
|
13deaa2fbb | ||
|
|
3e5840e413 | ||
|
|
c8c6368542 | ||
|
|
d4a34effe7 | ||
|
|
69dccaa061 | ||
|
|
131004393d | ||
|
|
7235972ca4 | ||
|
|
57c0cc0d35 | ||
|
|
e4875498ca | ||
|
|
16c17c349d | ||
|
|
2d95807542 | ||
|
|
725f7bf77f | ||
|
|
8155f5276e | ||
|
|
eaafacc3fb | ||
|
|
b846b312b8 | ||
|
|
9997623df4 | ||
|
|
721efbc371 | ||
|
|
d777f7f44d | ||
|
|
7e1c33ebed | ||
|
|
ce201c7dd6 | ||
|
|
25c88d9dbd | ||
|
|
f00b92a0e6 | ||
|
|
61c4547b9c | ||
|
|
b55812812a | ||
|
|
d7a81375ee | ||
|
|
b89f1cfece | ||
|
|
93efdf27a6 | ||
|
|
d562ceaaf4 | ||
|
|
5622a58ce3 | ||
|
|
2944c1b704 | ||
|
|
96d07da15f | ||
|
|
4a82753d22 | ||
|
|
336a1fd296 | ||
|
|
26a1eb7e28 | ||
|
|
e0cb968e04 | ||
|
|
bc07c32611 | ||
|
|
e14bc34a9e | ||
|
|
626780276d | ||
|
|
3e607222c6 | ||
|
|
10b10569e2 | ||
|
|
8ab9440190 | ||
|
|
2ac069a6b3 | ||
|
|
871a0eac28 | ||
|
|
d06b24c485 | ||
|
|
2958444835 | ||
|
|
f00b3ae924 | ||
|
|
5323608051 | ||
|
|
00e78dc2ac | ||
|
|
aac3136407 | ||
|
|
6997c0f7ac | ||
|
|
19981c1033 | ||
|
|
44444f7768 | ||
|
|
72410f39c6 | ||
|
|
97479a0512 | ||
|
|
ec740115b6 | ||
|
|
8d8d6491ad |
@@ -272,12 +272,30 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "checking $SO"
|
||||
file "$SO"
|
||||
API=$(file "$SO" | sed -n 's/.*for Android \([0-9]*\).*/\1/p')
|
||||
if [ -z "$API" ] || [ "$API" != "$MIN_API" ]; then
|
||||
echo "FAIL: linked for Android '${API:-unknown}', expected $MIN_API"
|
||||
# `file` is kept for the log — it names the NDK that built this — but
|
||||
# the check no longer depends on it.
|
||||
file "$SO" || true
|
||||
# The API level is the first word of the `.note.android.ident` ELF
|
||||
# note, little-endian. Read the note rather than asking `file` for it:
|
||||
# `file` only prints "for Android 28" when its magic database is new
|
||||
# enough to decode that note, and this image's is not. The parse then
|
||||
# produced nothing, `${API:-unknown}` reported "unknown", and every
|
||||
# push failed here for weeks on a .so that was linked perfectly
|
||||
# correctly. A note read straight out of the ELF cannot go stale that
|
||||
# way.
|
||||
readelf -n "$SO" | sed -n '/android.ident/,+3p'
|
||||
HEX=$(readelf -n "$SO" 2>/dev/null \
|
||||
| awk '/description data:/ { print $6 $5 $4 $3; exit }')
|
||||
if [ -z "$HEX" ]; then
|
||||
echo "FAIL: no .note.android.ident in $SO — nothing states an API level"
|
||||
exit 1
|
||||
fi
|
||||
API=$(( 0x$HEX ))
|
||||
if [ "$API" != "$MIN_API" ]; then
|
||||
echo "FAIL: linked for Android $API, expected $MIN_API"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: linked for Android $API"
|
||||
|
||||
# The APK itself, so a run leaves something installable behind rather
|
||||
# than only the knowledge that it would have linked. The assembly is
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# Contributing to DarkRoom
|
||||
|
||||
There is a lot of documentation here — 14 documents and 177 numbered
|
||||
requirements — and almost all of it is written for someone who has already
|
||||
decided to work on this. This file is the other thing: how to get a first
|
||||
change landed without reading any of it.
|
||||
|
||||
## The shortest useful contribution
|
||||
|
||||
**A develop operation is one file.** Not one file plus a registration, plus a
|
||||
shader edit, plus a control in the UI — one file:
|
||||
|
||||
```
|
||||
core/dr-pipeline/ops/split_toning.yaml
|
||||
```
|
||||
|
||||
`build.rs` finds it with `read_dir`, compiles it into Rust implementing
|
||||
`Operation`, and from there it is indistinguishable from a hand-written node.
|
||||
It arrives with controls built from its declared parameter kinds, a place in
|
||||
the chain from `order:`, a place in the panel from `attributes:`, sidecar
|
||||
persistence, and its own tests — which are declared in the same file and run
|
||||
under `cargo test`.
|
||||
|
||||
Read [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) and
|
||||
copy [`exposure.yaml`](core/dr-pipeline/ops/exposure.yaml). Split toning,
|
||||
colour zones, selective colour and channel-mixer variants are all pure point
|
||||
operations, which means all of them are declarations rather than code.
|
||||
|
||||
If you want to understand one thing about the architecture before starting,
|
||||
make it this: **the core describes its capabilities and the interface composes
|
||||
them.** No code in `ui/` names an operation, and a test enforces that
|
||||
(`ui/dr-ui/tests/ui_names_no_operation.rs`). It is why your node needs no UI
|
||||
change.
|
||||
|
||||
## Getting it to build
|
||||
|
||||
**Git LFS is required.** Model weights are stored in LFS, and a clone made
|
||||
without it leaves a ~130-byte text pointer where an 11 MB model should be:
|
||||
|
||||
```bash
|
||||
git lfs install && git lfs pull
|
||||
```
|
||||
|
||||
Forget this and `dr-segment`'s build script stops with an instruction rather
|
||||
than embedding the pointer and failing at inference time — but it is easier to
|
||||
run the two commands now.
|
||||
|
||||
**The toolchain pins itself.** `rust-toolchain.toml` selects 1.92.0 and rustup
|
||||
fetches it on first use. Do not override it; `cargo fmt` and `clippy` are both
|
||||
version-sensitive and CI runs exactly this version.
|
||||
|
||||
**System packages.** Slint and winit need these at build time. On Debian or
|
||||
Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt-get install pkg-config libfontconfig1-dev libxkbcommon-dev
|
||||
```
|
||||
|
||||
**Then:**
|
||||
|
||||
```bash
|
||||
cargo run -p darkroom-desktop
|
||||
```
|
||||
|
||||
The first build resolves 826 crates and takes a while — on a laptop, long
|
||||
enough to look like a hang. It is not one.
|
||||
|
||||
Android is a containerised toolchain and is not needed for most work; see
|
||||
[`docker/android/README.md`](docker/android/README.md) if you get there.
|
||||
|
||||
## What CI will check
|
||||
|
||||
All four of these run on every push, so run them before you send anything:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
cargo build --workspace --release
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Requirements and traceability
|
||||
|
||||
[`requirements.md`](docs/requirements.md) is the register of record.
|
||||
[`traceability.md`](docs/traceability.md) is generated from `TRACES:` tags in
|
||||
the source and must never be hand-edited:
|
||||
|
||||
```rust
|
||||
// TRACES: FR-DEV-3a | FR-DEV-3c
|
||||
```
|
||||
|
||||
Tags are read from `.rs`, `.slint`, `.wgsl` and `.yaml` — the last so a
|
||||
declared operation can record the requirement it satisfies, since the Rust it
|
||||
generates lands in `OUT_DIR` and is not scanned.
|
||||
|
||||
A pre-commit hook regenerates the matrix and stages it whenever you touch
|
||||
something that can carry a tag, so you should not have to think about it. If
|
||||
you do need to run it by hand:
|
||||
|
||||
```bash
|
||||
cargo run -p traceability -- report
|
||||
```
|
||||
|
||||
Note that it tracks line numbers, so a change that only moves code still moves
|
||||
the matrix. Never regenerate it with a stale prebuilt binary.
|
||||
|
||||
**One convention that the tooling cannot enforce.** A tag proves that a tag
|
||||
exists, not that the code under it does the thing — `docs/code-health.md`
|
||||
CH-4 has the details, and two requirements currently read as covered on the
|
||||
strength of plumbing a future feature would use. So: **close a requirement
|
||||
with a test that would fail if the behaviour were removed.** Coverage that
|
||||
moves slowly and means something beats coverage that moves quickly.
|
||||
|
||||
## Two invariants the build defends
|
||||
|
||||
Worth knowing before you trip one, because both failures name a requirement
|
||||
rather than a line:
|
||||
|
||||
- **No operation may be named in `ui/`** (FR-DEV-3a). Special-casing one
|
||||
operation in the panel to fix a layout problem is how a generated interface
|
||||
stops being generated. If a node needs presentation the panel cannot give it,
|
||||
the answer is a `presentation:` hint in the declaration and a `WidgetKind`,
|
||||
not a branch in `develop.rs`.
|
||||
- **The operation schema rejects ambiguity at build time**: a duplicate
|
||||
`order:`, a filename disagreeing with its `id:`, a default outside its own
|
||||
range, an expression naming something that is not a parameter. Each error
|
||||
names the key you got wrong and exits rather than panicking.
|
||||
|
||||
## Commit messages
|
||||
|
||||
Imperative subject describing the change from the reader's side — "Offer the
|
||||
merge when two people turn out to share a name", not "fix: merge dialog". No
|
||||
conventional-commits prefixes.
|
||||
|
||||
The body is where the reasoning goes, and it is expected to be substantial when
|
||||
the change is. This codebase records *why* far more than most, in commits and
|
||||
in comments alike, and that is the single habit most worth adopting: the
|
||||
constraint you worked around is invisible to whoever reads the diff next.
|
||||
|
||||
One commit per change. If you fixed two things, that is two commits.
|
||||
|
||||
## Where to read next, in order
|
||||
|
||||
| Document | Read it when |
|
||||
|---|---|
|
||||
| [`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/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen |
|
||||
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading |
|
||||
|
||||
`technical-debt.md` is the one to check before "fixing" anything surprising.
|
||||
It records compromises that were deliberate, each with the reasoning and a
|
||||
falsifiable condition for when it stops being one — the point being that you
|
||||
can tell a constraint from an accident without asking.
|
||||
|
||||
## Licence
|
||||
|
||||
GPL-3.0-or-later. By contributing you agree your work is licensed the same way.
|
||||
Generated
+38
-18
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-android"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-sync-nextcloud",
|
||||
@@ -1232,7 +1232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-desktop"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-ui",
|
||||
@@ -1404,9 +1404,11 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dr-catalog"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"dr-face",
|
||||
"dr-plat",
|
||||
"dr-thumbs",
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
"log",
|
||||
@@ -1417,7 +1419,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-decode"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
@@ -1431,7 +1433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-export"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-gpu",
|
||||
@@ -1447,9 +1449,22 @@ dependencies = [
|
||||
"zune-jpeg 0.4.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-face"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"log",
|
||||
"ndarray",
|
||||
"ort",
|
||||
"ort-tract",
|
||||
"thiserror 2.0.20",
|
||||
"zune-jpeg 0.4.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-film"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1458,7 +1473,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-gpu"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dr-decode",
|
||||
@@ -1475,7 +1490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ingest"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
@@ -1487,7 +1502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-lens"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"lensfun",
|
||||
"log",
|
||||
@@ -1495,7 +1510,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pipeline"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -1504,7 +1519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-plat"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"android-native-keyring-store",
|
||||
"dr-types",
|
||||
@@ -1513,11 +1528,14 @@ dependencies = [
|
||||
"keyring-core",
|
||||
"log",
|
||||
"thiserror 2.0.20",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"log",
|
||||
@@ -1530,7 +1548,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-types",
|
||||
@@ -1541,7 +1559,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-nextcloud"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-decode",
|
||||
@@ -1562,7 +1580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-thumbs"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"jpeg-encoder",
|
||||
@@ -1574,7 +1592,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-types"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1583,12 +1601,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ui"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-catalog",
|
||||
"dr-decode",
|
||||
"dr-export",
|
||||
"dr-face",
|
||||
"dr-film",
|
||||
"dr-gpu",
|
||||
"dr-ingest",
|
||||
@@ -1599,6 +1618,7 @@ dependencies = [
|
||||
"dr-sync-nextcloud",
|
||||
"dr-thumbs",
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"ndk-context",
|
||||
@@ -6907,7 +6927,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "traceability"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
|
||||
+25
-1
@@ -6,6 +6,7 @@ members = [
|
||||
"core/dr-thumbs",
|
||||
"core/dr-decode",
|
||||
"core/dr-export",
|
||||
"core/dr-face",
|
||||
"core/dr-film",
|
||||
"core/dr-ingest",
|
||||
"core/dr-gpu",
|
||||
@@ -22,7 +23,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -35,6 +36,10 @@ dr-catalog = { path = "core/dr-catalog" }
|
||||
dr-thumbs = { path = "core/dr-thumbs" }
|
||||
dr-decode = { path = "core/dr-decode" }
|
||||
dr-export = { path = "core/dr-export" }
|
||||
# Stated explicitly for the same reason as `dr-segment` below: no dependant
|
||||
# should drag in an ONNX runtime by accident. Members opt in with
|
||||
# `features = ["inference"]`.
|
||||
dr-face = { path = "core/dr-face", default-features = false }
|
||||
dr-film = { path = "core/dr-film" }
|
||||
dr-ingest = { path = "core/dr-ingest" }
|
||||
dr-gpu = { path = "core/dr-gpu" }
|
||||
@@ -110,6 +115,25 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.23"
|
||||
|
||||
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
|
||||
#
|
||||
# Neither is a new cost: winit already builds both, so the versions are the
|
||||
# ones Slint's backend has resolved to and pinning anything else here would
|
||||
# compile a second copy. Both are pure Rust — x11rb speaks the X11 wire
|
||||
# protocol itself rather than binding libxcb, and wayland-client binds
|
||||
# libwayland only under a feature that is off — which keeps the Android
|
||||
# cross-compile a plain Rust dependency graph, the same criterion as the TLS
|
||||
# and SQLite choices above. They are declared under a target predicate that
|
||||
# excludes Android, where neither display server exists.
|
||||
#
|
||||
# `staging` on wayland-protocols is what carries `wp_color_manager_v1`: the
|
||||
# colour-management extension is still staging upstream, which is the
|
||||
# protocol-level statement of the thing FR-DSP-8 anticipates when it says
|
||||
# Wayland's colour management "is not universally available".
|
||||
x11rb = { version = "0.13", features = ["randr"] }
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
|
||||
|
||||
# Platform secure storage: Secret Service on Linux, Keystore on Android
|
||||
# (FR-NC-2). Credentials never touch the catalog or a plain file.
|
||||
# keyring 4 restructured its features: `v1` is the default set and brings
|
||||
|
||||
@@ -12,6 +12,7 @@ A cross-platform, non-destructive RAW photo editor for Linux and Android.
|
||||
| [requirements.md](docs/requirements.md) | What the software must do — 122 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 |
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ fn android_main(app: slint::android::AndroidApp) {
|
||||
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);
|
||||
|
||||
if let Err(e) = slint::android::init(app) {
|
||||
log::error!("Slint Android backend failed to initialise: {e}");
|
||||
return;
|
||||
@@ -61,3 +64,78 @@ fn android_main(app: slint::android::AndroidApp) {
|
||||
log::error!("DarkRoom exited with error: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Unpack the face 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.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// Absent assets are the ordinary case, not an error — the same quiet "no model
|
||||
/// installed" state a fresh desktop install is in.
|
||||
#[cfg(target_os = "android")]
|
||||
fn install_bundled_face_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] = [
|
||||
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
|
||||
(c"models/arcface_mbf_b1.onnx", "arcface_mbf_b1.onnx"),
|
||||
];
|
||||
|
||||
let dir = dr_ui::shared_face_models_dir();
|
||||
let assets = app.asset_manager();
|
||||
|
||||
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.
|
||||
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");
|
||||
continue;
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
if let Err(e) = asset.read_to_end(&mut bytes) {
|
||||
log::error!("bundled {name} could not be read: {e}");
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
log::error!("cannot create {}: {e}", dir.display());
|
||||
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
|
||||
// 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()),
|
||||
Err(e) => {
|
||||
log::error!("cannot install {name}: {e}");
|
||||
let _ = std::fs::remove_file(&part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,15 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
# The face subsystem's arithmetic — `Calibration` in particular, so the sigmoid
|
||||
# that turns a cosine into a probability has exactly one definition. Default
|
||||
# features are off, so this brings in no ONNX runtime and no weights: only the
|
||||
# model-free half compiles here.
|
||||
dr-face.workspace = true
|
||||
# For `SHARD_MAX_BYTES` alone. The face shards are capped at the same 25 MB the
|
||||
# thumbnail shards are, and sharing the constant is what keeps them from
|
||||
# drifting apart — the cap is a statement about sync cost, not about thumbnails.
|
||||
dr-thumbs.workspace = true
|
||||
# The `Storage` trait, and nothing else from it. A scan has to read a real
|
||||
# directory, and this is how `core/` reaches the platform without a
|
||||
# `#[cfg(target_os)]` of its own (ARCH §4.1: calls go downward).
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! What a second device ends up with after adopting this library.
|
||||
//!
|
||||
//! Stands up an empty catalog, gives it the images the real one has, adopts the
|
||||
//! face shards into it exactly as a sync would, merges the real catalog in as a
|
||||
//! remote — and then counts. The point is to answer "why does the tablet show
|
||||
//! fewer faces for this person" without needing the tablet.
|
||||
//!
|
||||
//! cargo run -p dr-catalog --example sync_probe -- CATALOG.sqlite FACES_DIR
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dr_catalog::face_shard::{self, FaceShardStore};
|
||||
use dr_catalog::Catalog;
|
||||
|
||||
const MODEL: &str = "w600k_mbf";
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.len() < 2 {
|
||||
eprintln!("usage: sync_probe CATALOG.sqlite FACES_DIR");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let source = PathBuf::from(&args[0]);
|
||||
let faces_dir = PathBuf::from(&args[1]);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("dr-sync-probe-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("catalog.sqlite");
|
||||
|
||||
let far = Catalog::open(&dest).expect("fresh catalog");
|
||||
let conn = far.connection();
|
||||
|
||||
// The images a scan would have found. Nothing else: no faces, no people.
|
||||
conn.execute(
|
||||
"ATTACH DATABASE ?1 AS src",
|
||||
[source.to_string_lossy().as_ref()],
|
||||
)
|
||||
.unwrap();
|
||||
// Foreign keys off for the copy: `images` carries self-references
|
||||
// (`shadowed_by`) that are only consistent once every row is in, and this
|
||||
// is a bulk clone rather than an edit.
|
||||
conn.execute_batch(
|
||||
"PRAGMA foreign_keys = OFF;
|
||||
INSERT INTO roots SELECT * FROM src.roots;
|
||||
INSERT INTO images SELECT * FROM src.images;
|
||||
INSERT INTO remote SELECT * FROM src.remote;
|
||||
PRAGMA foreign_keys = ON;",
|
||||
)
|
||||
.unwrap();
|
||||
let images: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM images", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
conn.execute_batch("DETACH DATABASE src").unwrap();
|
||||
println!("second device starts with {images} image(s), no faces");
|
||||
|
||||
// Adopt every shard, which is what a completed face sync leaves behind.
|
||||
let store = FaceShardStore::open(&faces_dir).expect("shard store");
|
||||
let adopted = face_shard::import_from_shards(conn, &store, MODEL).expect("import");
|
||||
let faces: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM faces", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
println!("adopted {adopted} image(s) from the shards -> {faces} face(s)");
|
||||
|
||||
// Then the catalog merge, which is where people and their judgements come.
|
||||
let report = dr_catalog::sync::merge_remote(conn, &source).expect("merge");
|
||||
println!(
|
||||
"merge: {} people in, {} updated, {} kept local, {} face(s) assigned, \
|
||||
{} kept local, {} rejection(s)",
|
||||
report.people_inserted,
|
||||
report.people_updated,
|
||||
report.people_kept_local,
|
||||
report.faces_assigned,
|
||||
report.faces_kept_local,
|
||||
report.faces_rejected,
|
||||
);
|
||||
|
||||
// Per person, against what the source holds.
|
||||
conn.execute(
|
||||
"ATTACH DATABASE ?1 AS src",
|
||||
[source.to_string_lossy().as_ref()],
|
||||
)
|
||||
.unwrap();
|
||||
let mut q = conn
|
||||
.prepare(
|
||||
"SELECT p.name,
|
||||
(SELECT COUNT(*) FROM src.face_person sfp
|
||||
JOIN src.people sp ON sp.id = sfp.person_id
|
||||
WHERE sp.uuid = p.uuid) AS there,
|
||||
(SELECT COUNT(*) FROM face_person fp WHERE fp.person_id = p.id) AS here
|
||||
FROM people p
|
||||
WHERE p.name != ''
|
||||
ORDER BY there DESC LIMIT 12",
|
||||
)
|
||||
.unwrap();
|
||||
println!("\n{:<24} {:>8} {:>8}", "person", "source", "here");
|
||||
let rows = q
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
for row in rows.flatten() {
|
||||
println!("{:<24} {:>8} {:>8}", row.0, row.1, row.2);
|
||||
}
|
||||
|
||||
println!("\nprobe catalog left at {}", dest.display());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,16 @@ pub enum JobKind {
|
||||
FetchPreview = 6,
|
||||
/// Fetch a full original: pinned by rule, or explicitly asked for.
|
||||
FetchOriginal = 7,
|
||||
/// Detect and embed the faces in one image (FR-CULL-8).
|
||||
///
|
||||
/// One job does both, rather than splitting them: the proxy is already
|
||||
/// decoded and in memory, and the natural unit of resumable work is one
|
||||
/// photograph. Splitting would double the queue's row count for nothing.
|
||||
///
|
||||
/// Runs against the proxy tier, never a full decode — a library that has
|
||||
/// been browsed has already paid for its proxies, so face indexing adds no
|
||||
/// RAW decodes that were not already happening.
|
||||
DetectFaces = 8,
|
||||
}
|
||||
|
||||
impl JobKind {
|
||||
@@ -48,6 +58,7 @@ impl JobKind {
|
||||
5 => JobKind::ContentHash,
|
||||
6 => JobKind::FetchPreview,
|
||||
7 => JobKind::FetchOriginal,
|
||||
8 => JobKind::DetectFaces,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//! - [`query`] — selectors compiled to indexed SQL, windowed for the grid
|
||||
//! - [`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
|
||||
//! - [`jobs`] — the durable background work queue
|
||||
//! - [`trash`] — soft delete to a folder, then permanent delete
|
||||
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
|
||||
@@ -36,6 +37,8 @@ pub mod cache;
|
||||
pub mod collections;
|
||||
pub mod dedup;
|
||||
pub mod error;
|
||||
pub mod face_shard;
|
||||
pub mod faces;
|
||||
pub mod jobs;
|
||||
pub mod keywords;
|
||||
pub mod merge;
|
||||
@@ -51,6 +54,8 @@ pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
|
||||
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 jobs::{Job, JobKind, Priority};
|
||||
pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword};
|
||||
pub use merge::MergeReport;
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
//! A unique index on the name would instead abort the merge transaction at
|
||||
//! that moment, which is the ordinary case rather than a corner one.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
|
||||
use crate::error::CatalogError;
|
||||
|
||||
@@ -101,6 +101,19 @@ pub struct MergeReport {
|
||||
pub keywords_deleted: usize,
|
||||
/// Keywords where this device's revision was at least as high.
|
||||
pub keywords_kept_local: usize,
|
||||
/// People the remote had and this device did not.
|
||||
pub people_inserted: usize,
|
||||
/// People the remote had renamed, set aside, or brought back.
|
||||
pub people_updated: usize,
|
||||
/// People where this device's revision was at least as high.
|
||||
pub people_kept_local: usize,
|
||||
/// Faces this device now agrees belong to somebody.
|
||||
pub faces_assigned: usize,
|
||||
/// Faces whose local confirmation outranked the remote's.
|
||||
pub faces_kept_local: usize,
|
||||
/// "Not this person" judgements taken from the remote.
|
||||
pub faces_rejected: usize,
|
||||
|
||||
/// Redundant identities for one word, retired by
|
||||
/// [`crate::keywords::fuse_duplicates`].
|
||||
pub keywords_fused: usize,
|
||||
@@ -179,6 +192,19 @@ pub fn merge_all(conn: &Connection) -> Result<MergeReport, CatalogError> {
|
||||
let mut report = MergeReport::default();
|
||||
merge_collections_within(&tx, &mut report)?;
|
||||
merge_keywords_within(&tx, &mut report)?;
|
||||
merge_people_within(&tx, &mut report)?;
|
||||
tx.commit()?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Merge people and identity judgements from an attached catalog.
|
||||
///
|
||||
/// The people half of [`merge_all`], on its own, for the same reason the other
|
||||
/// two have one: the rules are independent and worth exercising alone.
|
||||
pub fn merge_people(conn: &Connection) -> Result<MergeReport, CatalogError> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut report = MergeReport::default();
|
||||
merge_people_within(&tx, &mut report)?;
|
||||
tx.commit()?;
|
||||
Ok(report)
|
||||
}
|
||||
@@ -685,6 +711,381 @@ fn attached_has_table(conn: &Connection, schema: &str, table: &str) -> Result<bo
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
// ── people, and who the user said they are ────────────────────────────────
|
||||
|
||||
/// Merge people and the user's identity judgements from an attached catalog.
|
||||
///
|
||||
/// # Why this is here at all
|
||||
///
|
||||
/// Face *data* syncs as sealed shards ([`crate::face_shard`]) — boxes,
|
||||
/// landmarks, embeddings, the run marker. What the shards deliberately do not
|
||||
/// carry is who anybody **is**: the person rows, their names, and the
|
||||
/// assignments joining the two. Those were supposed to travel in the catalog
|
||||
/// snapshot, which is a whole-file copy and therefore does contain them — but
|
||||
/// the snapshot is *merged*, not adopted, and this merge only ever looked at
|
||||
/// collections and keywords. So a second device received every face and no
|
||||
/// people at all, and drew an empty People screen over a full catalog.
|
||||
///
|
||||
/// # What travels, and what is recomputed
|
||||
///
|
||||
/// The rule this module already follows for the rest of the catalog: user
|
||||
/// judgements travel, inference is rebuilt. Concretely (docs/faces.md, and the
|
||||
/// asymmetry `crate::faces` opens with):
|
||||
///
|
||||
/// - **People** — uuid, name, and whether the user set them aside. Merged by
|
||||
/// uuid on `revision`, exactly as a collection is.
|
||||
/// - **Confirmations** — the user said this face is this person.
|
||||
/// - **Rejections** — the user said it is *not*, which is equally a fact and
|
||||
/// is why re-clustering does not put it back.
|
||||
/// - **The assignments inside an ignored group** — carried even though they are
|
||||
/// only suggestions, because they are what anchors the ignore. Without them
|
||||
/// a group set aside on one device reappears on the other, which is the same
|
||||
/// fault that made "Not interested" not stick locally.
|
||||
///
|
||||
/// Ordinary suggestions are *not* carried. They are this pass's own output,
|
||||
/// clustering is deterministic, and both devices hold the same embeddings — so
|
||||
/// each recomputes them and arrives at the same answer. Shipping them would
|
||||
/// double the merge for no new information.
|
||||
///
|
||||
/// # Faces have no cross-device identity, so one is derived
|
||||
///
|
||||
/// `faces.id` is a local row id and means nothing in another catalog; there is
|
||||
/// no uuid to fall back on. What both devices *do* agree on is `oc:fileid` and
|
||||
/// the box, so a remote face is matched to the local face on the same
|
||||
/// photograph whose box overlaps it most, above a floor of 0.5 IoU.
|
||||
///
|
||||
/// That is not a new rule: it is the one
|
||||
/// [`crate::faces::record_detections`] already uses to carry a confirmation
|
||||
/// across a re-index, and it is loose on purpose — the question is "is this the
|
||||
/// same face in the frame", not "is this the same rectangle", and a device
|
||||
/// running a newer detector is entitled to have moved the box a little.
|
||||
fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> {
|
||||
// A remote written before faces existed has none of these tables, and one
|
||||
// written before V10 has no `ignored`. Both are ordinary — `remote_is_
|
||||
// mergeable` admits any catalog at or below this schema version — so they
|
||||
// are probed for rather than assumed, and an absent one skips this half
|
||||
// instead of aborting a merge that would otherwise have succeeded.
|
||||
if !remote_has(tx, "people")? || !remote_has(tx, "faces")? {
|
||||
return Ok(());
|
||||
}
|
||||
let ignored_col = remote_has_column(tx, "people", "ignored")?;
|
||||
// Spelled into the SQL rather than branched around it: the two queries
|
||||
// below would otherwise each need a second copy.
|
||||
let ignored_sel = if ignored_col { "r.ignored" } else { "0" };
|
||||
|
||||
// ---- the people themselves -------------------------------------------
|
||||
struct Incoming {
|
||||
uuid: String,
|
||||
name: String,
|
||||
ignored: bool,
|
||||
created: i64,
|
||||
revision: i64,
|
||||
modified: i64,
|
||||
/// Resolved after every person exists, since a merge target can arrive
|
||||
/// after the person redirecting to it.
|
||||
merged_into_uuid: Option<String>,
|
||||
verdict: MergeVerdict,
|
||||
}
|
||||
|
||||
let rows: Vec<Incoming> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"SELECT r.uuid, r.name, {ignored_sel}, r.created, r.revision, r.modified,
|
||||
rm.uuid, l.revision, l.modified
|
||||
FROM remote_cat.people r
|
||||
LEFT JOIN main.people l ON l.uuid = r.uuid
|
||||
LEFT JOIN remote_cat.people rm ON rm.id = r.merged_into"
|
||||
))?;
|
||||
let mapped = stmt.query_map([], |r| {
|
||||
let revision: i64 = r.get(4)?;
|
||||
let modified: i64 = r.get(5)?;
|
||||
let local_rev: Option<i64> = r.get(7)?;
|
||||
let local_mod: Option<i64> = r.get(8)?;
|
||||
Ok(Incoming {
|
||||
uuid: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
ignored: r.get(2)?,
|
||||
created: r.get(3)?,
|
||||
revision,
|
||||
modified,
|
||||
merged_into_uuid: r.get(6)?,
|
||||
// People have no tombstone: a person is merged away rather
|
||||
// than deleted, and `merged_into` is that redirect.
|
||||
verdict: verdict(local_rev.zip(local_mod), (revision, modified), false),
|
||||
})
|
||||
})?;
|
||||
mapped.collect::<Result<_, _>>()?
|
||||
};
|
||||
|
||||
for p in &rows {
|
||||
match p.verdict {
|
||||
MergeVerdict::InsertedFromRemote => {
|
||||
tx.execute(
|
||||
"INSERT INTO people (uuid, name, ignored, created, revision, modified)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![p.uuid, p.name, p.ignored, p.created, p.revision, p.modified],
|
||||
)?;
|
||||
report.people_inserted += 1;
|
||||
}
|
||||
MergeVerdict::UpdatedFromRemote | MergeVerdict::DeletedByRemote => {
|
||||
tx.execute(
|
||||
"UPDATE people
|
||||
SET name = ?2, ignored = ?3, revision = ?4, modified = ?5
|
||||
WHERE uuid = ?1",
|
||||
rusqlite::params![p.uuid, p.name, p.ignored, p.revision, p.modified],
|
||||
)?;
|
||||
report.people_updated += 1;
|
||||
}
|
||||
MergeVerdict::KeptLocal => report.people_kept_local += 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Redirects, once every person on both sides exists locally.
|
||||
for p in rows.iter().filter(|p| p.merged_into_uuid.is_some()) {
|
||||
if p.verdict == MergeVerdict::KeptLocal {
|
||||
continue;
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE people
|
||||
SET merged_into = (SELECT id FROM people WHERE uuid = ?2)
|
||||
WHERE uuid = ?1",
|
||||
rusqlite::params![p.uuid, p.merged_into_uuid],
|
||||
)?;
|
||||
}
|
||||
|
||||
// ---- match the remote's faces onto this device's ----------------------
|
||||
let face_map = match_faces(tx)?;
|
||||
if face_map.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ---- confirmations, and the anchors under an ignored group -----------
|
||||
{
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"SELECT fp.face_id, p.uuid, fp.probability, fp.confirmed
|
||||
FROM remote_cat.face_person fp
|
||||
JOIN remote_cat.people p ON p.id = fp.person_id
|
||||
WHERE fp.confirmed = 1 OR {} = 1",
|
||||
if ignored_col { "p.ignored" } else { "0" }
|
||||
))?;
|
||||
let incoming: Vec<(i64, String, f64, bool)> = stmt
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
for (remote_face, uuid, probability, confirmed) in incoming {
|
||||
let Some(&local_face) = face_map.get(&remote_face) else {
|
||||
continue;
|
||||
};
|
||||
let person: Option<i64> = tx
|
||||
.query_row("SELECT id FROM people WHERE uuid = ?1", [&uuid], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
let Some(person) = person else { continue };
|
||||
|
||||
// A local confirmation is never overwritten, in either direction.
|
||||
// Two devices confirming the same face as different people is a
|
||||
// genuine disagreement and there is no revision on an assignment to
|
||||
// settle it with; silently taking the remote's answer would let a
|
||||
// sync undo something the user did here. It stays as it is, and the
|
||||
// user can change it on the device they are looking at.
|
||||
let locally_confirmed: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM face_person
|
||||
WHERE face_id = ?1 AND confirmed = 1)",
|
||||
[local_face],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if locally_confirmed {
|
||||
report.faces_kept_local += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A rejection here outranks an assignment from elsewhere: it is
|
||||
// this user's judgement about this pair, and re-suggesting what
|
||||
// they pushed away is the behaviour that makes the feature feel
|
||||
// broken.
|
||||
let rejected: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM face_person_rejected
|
||||
WHERE face_id = ?1 AND person_id = ?2)",
|
||||
[local_face, person],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if rejected {
|
||||
continue;
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(face_id) DO UPDATE SET
|
||||
person_id = excluded.person_id,
|
||||
probability = excluded.probability,
|
||||
confirmed = excluded.confirmed",
|
||||
rusqlite::params![local_face, person, probability, confirmed],
|
||||
)?;
|
||||
report.faces_assigned += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- rejections, as a set union --------------------------------------
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT fr.face_id, p.uuid
|
||||
FROM remote_cat.face_person_rejected fr
|
||||
JOIN remote_cat.people p ON p.id = fr.person_id",
|
||||
)?;
|
||||
let incoming: Vec<(i64, String)> = stmt
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
for (remote_face, uuid) in incoming {
|
||||
let Some(&local_face) = face_map.get(&remote_face) else {
|
||||
continue;
|
||||
};
|
||||
let person: Option<i64> = tx
|
||||
.query_row("SELECT id FROM people WHERE uuid = ?1", [&uuid], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
let Some(person) = person else { continue };
|
||||
|
||||
let n = tx.execute(
|
||||
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
|
||||
VALUES (?1, ?2)",
|
||||
[local_face, person],
|
||||
)?;
|
||||
report.faces_rejected += n;
|
||||
|
||||
// A rejection that lands on a face currently *suggested* to be
|
||||
// that person has to take the suggestion with it, or the screen
|
||||
// keeps offering exactly what the other device just refused.
|
||||
tx.execute(
|
||||
"DELETE FROM face_person
|
||||
WHERE face_id = ?1 AND person_id = ?2 AND confirmed = 0",
|
||||
[local_face, person],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the attached remote holds a table.
|
||||
fn remote_has(tx: &Connection, table: &str) -> Result<bool, CatalogError> {
|
||||
Ok(tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM remote_cat.sqlite_master
|
||||
WHERE type = 'table' AND name = ?1)",
|
||||
[table],
|
||||
|r| r.get::<_, bool>(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Whether a table in the attached remote holds a column.
|
||||
fn remote_has_column(tx: &Connection, table: &str, column: &str) -> Result<bool, CatalogError> {
|
||||
// `pragma_table_info` takes the schema as a second argument, which is the
|
||||
// only way to ask about an attached database rather than the main one.
|
||||
let mut stmt =
|
||||
tx.prepare("SELECT 1 FROM pragma_table_info(?1, 'remote_cat') WHERE name = ?2")?;
|
||||
Ok(stmt.exists(rusqlite::params![table, column])?)
|
||||
}
|
||||
|
||||
/// Remote face row id to local face row id, by photograph and box overlap.
|
||||
///
|
||||
/// See [`merge_people_within`] for why a face has no shared identity and this
|
||||
/// has to be derived. Only faces from the same model are compared: boxes from
|
||||
/// two different detectors are not the same measurement, and matching across
|
||||
/// them would attach a judgement to a face nobody looked at.
|
||||
fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, CatalogError> {
|
||||
/// Loose on purpose — "the same face in the frame", not "the same
|
||||
/// rectangle". The figure `record_detections` uses for the same job.
|
||||
const MIN_IOU: f32 = 0.5;
|
||||
|
||||
type Boxed = (i64, f32, f32, f32, f32);
|
||||
|
||||
// Local faces, grouped by the photograph's cross-device id.
|
||||
let mut local: std::collections::HashMap<(i64, String), Vec<Boxed>> =
|
||||
std::collections::HashMap::new();
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT f.id, r.file_id, f.model_id, f.x, f.y, f.w, f.h
|
||||
FROM main.faces f
|
||||
JOIN main.remote r ON r.image_id = f.image_id
|
||||
WHERE r.file_id IS NOT NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
(
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, f64>(3)? as f32,
|
||||
r.get::<_, f64>(4)? as f32,
|
||||
r.get::<_, f64>(5)? as f32,
|
||||
r.get::<_, f64>(6)? as f32,
|
||||
),
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (file_id, model, boxed) = row?;
|
||||
local.entry((file_id, model)).or_default().push(boxed);
|
||||
}
|
||||
}
|
||||
if local.is_empty() {
|
||||
return Ok(Default::default());
|
||||
}
|
||||
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT f.id, r.file_id, f.model_id, f.x, f.y, f.w, f.h
|
||||
FROM remote_cat.faces f
|
||||
JOIN remote_cat.remote r ON r.image_id = f.image_id
|
||||
WHERE r.file_id IS NOT NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
(
|
||||
r.get::<_, f64>(3)? as f32,
|
||||
r.get::<_, f64>(4)? as f32,
|
||||
r.get::<_, f64>(5)? as f32,
|
||||
r.get::<_, f64>(6)? as f32,
|
||||
),
|
||||
))
|
||||
})?;
|
||||
|
||||
for row in rows {
|
||||
let (remote_id, file_id, model, rbox) = row?;
|
||||
let Some(candidates) = local.get(&(file_id, model)) else {
|
||||
continue;
|
||||
};
|
||||
let best = candidates
|
||||
.iter()
|
||||
.map(|&(id, x, y, w, h)| (id, iou(rbox, (x, y, w, h))))
|
||||
.filter(|&(_, score)| score >= MIN_IOU)
|
||||
.max_by(|a, b| a.1.total_cmp(&b.1));
|
||||
if let Some((local_id, _)) = best {
|
||||
map.insert(remote_id, local_id);
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Intersection over union of two `(x, y, w, h)` boxes.
|
||||
fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 {
|
||||
let x0 = a.0.max(b.0);
|
||||
let y0 = a.1.max(b.1);
|
||||
let x1 = (a.0 + a.2).min(b.0 + b.2);
|
||||
let y1 = (a.1 + a.3).min(b.1 + b.3);
|
||||
let inter = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
|
||||
let union = a.2 * a.3 + b.2 * b.3 - inter;
|
||||
if union <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
inter / union
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1490,4 +1891,286 @@ mod tests {
|
||||
assert_eq!(report.kept_local, 1);
|
||||
assert!(report.should_upload());
|
||||
}
|
||||
|
||||
// ── people, and who the user said they are ────────────────────────────
|
||||
|
||||
/// An image present in `db` and carrying the cross-device file id both
|
||||
/// catalogs agree on.
|
||||
fn add_synced_image(c: &Connection, db: &str, id: i64, file_id: i64) {
|
||||
add_image_without_hash(c, db, id);
|
||||
c.execute(
|
||||
&format!("INSERT INTO {db}.remote(image_id, file_id) VALUES (?1, ?2)"),
|
||||
rusqlite::params![id, file_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A face on `image`, at a box the caller can nudge to test the matching.
|
||||
fn add_face(c: &Connection, db: &str, id: i64, image: i64, x: f64) -> i64 {
|
||||
c.execute(
|
||||
&format!(
|
||||
"INSERT INTO {db}.faces
|
||||
(id, image_id, x, y, w, h, landmarks, detector_confidence,
|
||||
embedding, crop_px, model_id, detected_at)
|
||||
VALUES (?1, ?2, ?3, 0.2, 0.2, 0.2, X'00', 0.9, X'00', 150.0,
|
||||
'w600k_mbf', 0)"
|
||||
),
|
||||
rusqlite::params![id, image, x],
|
||||
)
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn add_person(c: &Connection, db: &str, id: i64, uuid: &str, name: &str, ignored: bool) {
|
||||
c.execute(
|
||||
&format!(
|
||||
"INSERT INTO {db}.people(id, uuid, name, ignored, created, revision, modified)
|
||||
VALUES (?1, ?2, ?3, ?4, 0, 1, 1)"
|
||||
),
|
||||
rusqlite::params![id, uuid, name, ignored],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn assign(c: &Connection, db: &str, face: i64, person: i64, confirmed: bool) {
|
||||
c.execute(
|
||||
&format!(
|
||||
"INSERT INTO {db}.face_person(face_id, person_id, probability, confirmed)
|
||||
VALUES (?1, ?2, 0.9, ?3)"
|
||||
),
|
||||
rusqlite::params![face, person, confirmed],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn person_of(c: &Connection, face: i64) -> Option<(String, bool)> {
|
||||
c.query_row(
|
||||
"SELECT p.name, fp.confirmed
|
||||
FROM main.face_person fp JOIN main.people p ON p.id = fp.person_id
|
||||
WHERE fp.face_id = ?1",
|
||||
[face],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The bug: a second device received every face through the shards and no
|
||||
/// people at all, because this merge only ever looked at collections and
|
||||
/// keywords. It drew an empty People screen over a full catalog.
|
||||
#[test]
|
||||
fn a_named_person_and_their_confirmed_face_cross_over() {
|
||||
let c = two_catalogs();
|
||||
for db in ["main", "remote_cat"] {
|
||||
add_synced_image(&c, db, 1, 5000);
|
||||
}
|
||||
// The same face, found independently on each device, so the row ids
|
||||
// differ — which is the whole difficulty.
|
||||
let local = add_face(&c, "main", 7, 1, 0.30);
|
||||
let remote = add_face(&c, "remote_cat", 42, 1, 0.31);
|
||||
add_person(&c, "remote_cat", 3, "u-anna", "Anna", false);
|
||||
assign(&c, "remote_cat", remote, 3, true);
|
||||
|
||||
let report = merge_all(&c).unwrap();
|
||||
assert_eq!(report.people_inserted, 1);
|
||||
assert_eq!(report.faces_assigned, 1);
|
||||
assert_eq!(person_of(&c, local), Some(("Anna".to_string(), true)));
|
||||
}
|
||||
|
||||
/// Boxes from two devices are close but not identical. Matching has to be
|
||||
/// by overlap, not equality, or nothing ever lines up.
|
||||
#[test]
|
||||
fn a_face_in_a_different_photograph_is_not_matched() {
|
||||
let c = two_catalogs();
|
||||
for db in ["main", "remote_cat"] {
|
||||
add_synced_image(&c, db, 1, 5000);
|
||||
add_synced_image(&c, db, 2, 6000);
|
||||
}
|
||||
let elsewhere = add_face(&c, "main", 7, 2, 0.30);
|
||||
let remote = add_face(&c, "remote_cat", 42, 1, 0.30);
|
||||
add_person(&c, "remote_cat", 3, "u-anna", "Anna", false);
|
||||
assign(&c, "remote_cat", remote, 3, true);
|
||||
|
||||
merge_all(&c).unwrap();
|
||||
assert_eq!(person_of(&c, elsewhere), None, "matched across photographs");
|
||||
}
|
||||
|
||||
/// Two faces in one frame, and the judgement must land on the right one.
|
||||
#[test]
|
||||
fn the_overlapping_face_is_the_one_that_gets_the_name() {
|
||||
let c = two_catalogs();
|
||||
for db in ["main", "remote_cat"] {
|
||||
add_synced_image(&c, db, 1, 5000);
|
||||
}
|
||||
let left = add_face(&c, "main", 7, 1, 0.10);
|
||||
let right = add_face(&c, "main", 8, 1, 0.70);
|
||||
let remote = add_face(&c, "remote_cat", 42, 1, 0.71);
|
||||
add_person(&c, "remote_cat", 3, "u-bob", "Bob", false);
|
||||
assign(&c, "remote_cat", remote, 3, true);
|
||||
|
||||
merge_all(&c).unwrap();
|
||||
assert_eq!(person_of(&c, right), Some(("Bob".to_string(), true)));
|
||||
assert_eq!(person_of(&c, left), None);
|
||||
}
|
||||
|
||||
/// A group set aside on one device stays set aside on the other — which
|
||||
/// needs its *suggestions* to travel, since that is what anchors it.
|
||||
#[test]
|
||||
fn a_group_set_aside_stays_set_aside_on_the_other_device() {
|
||||
let c = two_catalogs();
|
||||
for db in ["main", "remote_cat"] {
|
||||
add_synced_image(&c, db, 1, 5000);
|
||||
}
|
||||
let local = add_face(&c, "main", 7, 1, 0.30);
|
||||
let remote = add_face(&c, "remote_cat", 42, 1, 0.30);
|
||||
add_person(&c, "remote_cat", 3, "u-stranger", "", true);
|
||||
// Only ever a suggestion, which is exactly why it needs carrying.
|
||||
assign(&c, "remote_cat", remote, 3, false);
|
||||
|
||||
merge_all(&c).unwrap();
|
||||
let ignored: bool = c
|
||||
.query_row(
|
||||
"SELECT ignored FROM main.people WHERE uuid = 'u-stranger'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ignored, "the set-aside flag did not travel");
|
||||
assert_eq!(person_of(&c, local), Some((String::new(), false)));
|
||||
}
|
||||
|
||||
/// An ordinary suggestion is this pass's own output. Both devices hold the
|
||||
/// same embeddings and clustering is deterministic, so each recomputes it —
|
||||
/// shipping it would double the merge for no new information.
|
||||
#[test]
|
||||
fn an_ordinary_suggestion_does_not_travel() {
|
||||
let c = two_catalogs();
|
||||
for db in ["main", "remote_cat"] {
|
||||
add_synced_image(&c, db, 1, 5000);
|
||||
}
|
||||
let local = add_face(&c, "main", 7, 1, 0.30);
|
||||
let remote = add_face(&c, "remote_cat", 42, 1, 0.30);
|
||||
add_person(&c, "remote_cat", 3, "u-guess", "", false);
|
||||
assign(&c, "remote_cat", remote, 3, false);
|
||||
|
||||
merge_all(&c).unwrap();
|
||||
assert_eq!(person_of(&c, local), None);
|
||||
}
|
||||
|
||||
/// A sync must not undo what the user did on the device they are holding.
|
||||
#[test]
|
||||
fn a_local_confirmation_outranks_the_remotes() {
|
||||
let c = two_catalogs();
|
||||
for db in ["main", "remote_cat"] {
|
||||
add_synced_image(&c, db, 1, 5000);
|
||||
}
|
||||
let local = add_face(&c, "main", 7, 1, 0.30);
|
||||
let remote = add_face(&c, "remote_cat", 42, 1, 0.30);
|
||||
add_person(&c, "main", 1, "u-anna", "Anna", false);
|
||||
add_person(&c, "remote_cat", 3, "u-bob", "Bob", false);
|
||||
assign(&c, "main", local, 1, true);
|
||||
assign(&c, "remote_cat", remote, 3, true);
|
||||
|
||||
let report = merge_all(&c).unwrap();
|
||||
assert_eq!(report.faces_kept_local, 1);
|
||||
assert_eq!(person_of(&c, local), Some(("Anna".to_string(), true)));
|
||||
}
|
||||
|
||||
/// "Not this person" is a judgement too, and it has to outrank an
|
||||
/// assignment arriving from elsewhere.
|
||||
#[test]
|
||||
fn a_rejection_travels_and_removes_the_suggestion_it_contradicts() {
|
||||
let c = two_catalogs();
|
||||
for db in ["main", "remote_cat"] {
|
||||
add_synced_image(&c, db, 1, 5000);
|
||||
}
|
||||
let local = add_face(&c, "main", 7, 1, 0.30);
|
||||
let remote = add_face(&c, "remote_cat", 42, 1, 0.30);
|
||||
add_person(&c, "main", 1, "u-anna", "Anna", false);
|
||||
add_person(&c, "remote_cat", 3, "u-anna", "Anna", false);
|
||||
// Locally suggested; the other device says it is not her.
|
||||
assign(&c, "main", local, 1, false);
|
||||
c.execute(
|
||||
"INSERT INTO remote_cat.face_person_rejected(face_id, person_id)
|
||||
VALUES (?1, 3)",
|
||||
[remote],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
merge_all(&c).unwrap();
|
||||
assert_eq!(person_of(&c, local), None, "the refused suggestion stayed");
|
||||
let rejected: bool = c
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM main.face_person_rejected WHERE face_id = ?1)",
|
||||
[local],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(rejected);
|
||||
}
|
||||
|
||||
/// A rename on the other device wins on revision, like everything else.
|
||||
#[test]
|
||||
fn a_higher_revision_renames_a_person() {
|
||||
let c = two_catalogs();
|
||||
add_person(&c, "main", 1, "u-anna", "Ana", false);
|
||||
add_person(&c, "remote_cat", 3, "u-anna", "Anna", false);
|
||||
c.execute(
|
||||
"UPDATE remote_cat.people SET revision = 5, modified = 5 WHERE uuid = 'u-anna'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = merge_all(&c).unwrap();
|
||||
assert_eq!(report.people_updated, 1);
|
||||
let name: String = c
|
||||
.query_row(
|
||||
"SELECT name FROM main.people WHERE uuid = 'u-anna'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(name, "Anna");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lower_revision_does_not_rename_a_person() {
|
||||
let c = two_catalogs();
|
||||
add_person(&c, "main", 1, "u-anna", "Anna", false);
|
||||
add_person(&c, "remote_cat", 3, "u-anna", "Ana", false);
|
||||
c.execute("UPDATE main.people SET revision = 9, modified = 9", [])
|
||||
.unwrap();
|
||||
|
||||
let report = merge_all(&c).unwrap();
|
||||
assert_eq!(report.people_kept_local, 1);
|
||||
let name: String = c
|
||||
.query_row(
|
||||
"SELECT name FROM main.people WHERE uuid = 'u-anna'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(name, "Anna");
|
||||
}
|
||||
|
||||
/// Merging twice must not double anything — a sync runs on every pass.
|
||||
#[test]
|
||||
fn merging_people_twice_changes_nothing_the_second_time() {
|
||||
let c = two_catalogs();
|
||||
for db in ["main", "remote_cat"] {
|
||||
add_synced_image(&c, db, 1, 5000);
|
||||
}
|
||||
add_face(&c, "main", 7, 1, 0.30);
|
||||
let remote = add_face(&c, "remote_cat", 42, 1, 0.30);
|
||||
add_person(&c, "remote_cat", 3, "u-anna", "Anna", false);
|
||||
assign(&c, "remote_cat", remote, 3, true);
|
||||
|
||||
merge_all(&c).unwrap();
|
||||
let second = merge_all(&c).unwrap();
|
||||
assert_eq!(second.people_inserted, 0);
|
||||
let people: i64 = c
|
||||
.query_row("SELECT COUNT(*) FROM main.people", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(people, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// Schema version this build writes and understands.
|
||||
pub const SCHEMA_VERSION: i64 = 7;
|
||||
pub const SCHEMA_VERSION: i64 = 10;
|
||||
|
||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
@@ -78,6 +78,25 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
||||
tx.pragma_update(None, "user_version", 7)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if from < 8 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V8)?;
|
||||
tx.pragma_update(None, "user_version", 8)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if from < 9 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V9)?;
|
||||
tx.pragma_update(None, "user_version", 9)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 10 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V10)?;
|
||||
tx.pragma_update(None, "user_version", 10)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(from)
|
||||
}
|
||||
@@ -187,10 +206,17 @@ pub fn v1_for_attached(schema_name: &str) -> String {
|
||||
/// lost by its absence — it exists to make the *grid* page quickly, and the
|
||||
/// grid never reads across an attachment.
|
||||
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
|
||||
// case and `merge::merge_people_within` probes for them; this is the
|
||||
// *current* shape, which is what the tests want.
|
||||
format!(
|
||||
"{}\n{}",
|
||||
"{}\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;",
|
||||
rewrite_for_attached(V1, schema_name),
|
||||
rewrite_for_attached(V6, schema_name)
|
||||
rewrite_for_attached(V6, schema_name),
|
||||
rewrite_for_attached(V8, schema_name),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -327,6 +353,199 @@ fn stem_of(path: &str) -> &str {
|
||||
/// Both narrow the walk rather than reorder it, so the index still supplies the
|
||||
/// ordering and SQLite tests the extra predicate per row. That is the cheap
|
||||
/// direction: the expensive part was never the filtering, it was the sort.
|
||||
const V10: &str = r#"
|
||||
-- TRACES: FR-CULL-10 | FR-CULL-12
|
||||
-- Two columns the People screen turned out to need, and neither is derivable.
|
||||
|
||||
-- A person the user does not want to identify.
|
||||
--
|
||||
-- Most of a real library's clusters are strangers: people in the background of
|
||||
-- a street, guests at somebody else's party, a face on a poster. They are
|
||||
-- correctly detected and correctly grouped, and the user will never name any of
|
||||
-- them -- but they crowd out the handful of groups that matter, and there is no
|
||||
-- way to tell "not yet looked at" from "looked at, don't care" without
|
||||
-- recording the second.
|
||||
--
|
||||
-- **User data**, and the reason this is a column rather than a deletion: a
|
||||
-- deleted cluster comes straight back on the next Regroup, because the faces
|
||||
-- are still there and still similar. Nothing short of remembering the judgement
|
||||
-- survives re-clustering, which is the same argument `face_person_rejected`
|
||||
-- makes one level down (FR-CULL-12).
|
||||
ALTER TABLE people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- The face, cut out and kept.
|
||||
--
|
||||
-- A face used to be drawn by decoding the 1024px proxy it was found on and
|
||||
-- cutting the box out again, every time the screen opened. That made the People
|
||||
-- screen a *derivative of the thumbnail cache*: evict a proxy -- which the
|
||||
-- cache is entitled to do at any moment -- and the cell goes blank, with no way
|
||||
-- back short of re-fetching the original over the network and re-detecting it.
|
||||
-- It also cost one full JPEG decode per image per visit to show a 96px cell.
|
||||
--
|
||||
-- So the crop is cut once, when the pixels are already in hand at detection
|
||||
-- time, and kept. Small: a 160px JPEG is a few KB, against ~250 KB for the
|
||||
-- proxy it replaces reading.
|
||||
--
|
||||
-- Nullable, because a face indexed before this column existed has no crop and
|
||||
-- must still work -- the reader falls back to the old proxy path, and the next
|
||||
-- indexing pass fills it in.
|
||||
--
|
||||
-- **Stripped from the sync snapshot.** The catalog is uploaded whole, so this
|
||||
-- would otherwise put tens of MB of JPEG on every sync; crops travel in the
|
||||
-- face shards instead, which is where the bulk per-face data already goes
|
||||
-- (`face_shard`). See `sync::snapshot_for_upload`.
|
||||
ALTER TABLE faces ADD COLUMN crop BLOB;
|
||||
"#;
|
||||
|
||||
const V9: &str = r#"
|
||||
-- TRACES: FR-CULL-8
|
||||
-- A record that face detection has *run* on an image, distinct from what it
|
||||
-- found.
|
||||
--
|
||||
-- # Why the faces table cannot answer this
|
||||
--
|
||||
-- Without this, "has this image been indexed" is asked as "does it have any
|
||||
-- faces", and those are not the same question. **A photograph with no faces in
|
||||
-- it is indistinguishable from one that has never been looked at**, so every
|
||||
-- indexing pass re-examines every landscape, every still life and every
|
||||
-- document scan in the library, for ever. In a typical personal library that is
|
||||
-- most of it: the pass never converges, and the cost is paid again on every
|
||||
-- run rather than once.
|
||||
--
|
||||
-- It also makes a coverage figure possible, which is the thing a user actually
|
||||
-- wants to see — "4,812 of 5,000 images indexed" — where counting face rows
|
||||
-- can only ever report how many faces exist.
|
||||
--
|
||||
-- # Why it is keyed on the model
|
||||
--
|
||||
-- Embeddings from different models are not comparable, so a model change has
|
||||
-- to re-index. Keying the marker on `(image_id, model_id)` makes that
|
||||
-- automatic: new model, no marker, image comes back into the queue. The id
|
||||
-- names the whole pipeline -- detector and embedder together -- because
|
||||
-- changing either changes what is found.
|
||||
CREATE TABLE face_index (
|
||||
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
indexed_at INTEGER NOT NULL,
|
||||
-- Zero is a real and common answer, and recording it is the entire point.
|
||||
faces_found INTEGER NOT NULL,
|
||||
-- Long edge of the proxy this ran against. A face too small to detect at
|
||||
-- 1024 may be findable at 2048, so a library whose proxies grow can
|
||||
-- re-index the images that stand to gain instead of all of them.
|
||||
source_edge INTEGER NOT NULL,
|
||||
PRIMARY KEY (image_id, model_id)
|
||||
);
|
||||
CREATE INDEX face_index_model ON face_index(model_id);
|
||||
"#;
|
||||
|
||||
const V8: &str = r#"
|
||||
-- TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
|
||||
-- People and faces (docs/faces.md, docs/catalog.md §10).
|
||||
--
|
||||
-- Everything here is **derived data** except one column. Faces, landmarks,
|
||||
-- embeddings, cluster assignments and suggestions are all reproducible by
|
||||
-- re-indexing and are never written to a sidecar (FR-CULL-12); a person's
|
||||
-- *name*, once the user has confirmed it, is a human judgement of the same
|
||||
-- class as a rating and travels with the photograph.
|
||||
--
|
||||
-- That asymmetry is the whole design: deleting the catalog costs an afternoon
|
||||
-- of re-indexing and loses nothing the user typed (ARCH §6.12).
|
||||
|
||||
CREATE TABLE people (
|
||||
id INTEGER PRIMARY KEY,
|
||||
-- Merge identity, not the name. Two devices that independently name the
|
||||
-- same cluster produce two people; merging them keys on this, exactly as
|
||||
-- collections do (FR-CAT-7, ARCH §6.3).
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
-- Tombstone-by-redirect. A merged person must outlive its merge or a
|
||||
-- device that still holds it resurrects it on the next sync -- the same
|
||||
-- hazard collections have, solved the same way.
|
||||
merged_into INTEGER REFERENCES people(id) ON DELETE SET NULL,
|
||||
created INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
modified INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE faces (
|
||||
id INTEGER PRIMARY KEY,
|
||||
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
-- Normalised to the image's long edge, so a face survives the proxy it was
|
||||
-- found on being evicted and regenerated at another resolution. Storing
|
||||
-- pixels would bind a face to a resolution the cache is entitled to change.
|
||||
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
|
||||
-- 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
|
||||
-- the §8 calibration -- FR-CULL-9 names face size as an axis along which an
|
||||
-- uncalibrated similarity misbehaves -- and the selector a later
|
||||
-- higher-resolution re-embedding pass would run on.
|
||||
crop_px REAL NOT NULL,
|
||||
-- Which model produced this embedding.
|
||||
--
|
||||
-- The one mistake in this subsystem that yields plausible-looking garbage
|
||||
-- rather than an error: embeddings from different models are not
|
||||
-- comparable. Storing the model with the vector makes a model change
|
||||
-- detectable and re-indexable instead of quietly poisoning every
|
||||
-- similarity in the library.
|
||||
model_id TEXT NOT NULL,
|
||||
detected_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX faces_image ON faces(image_id);
|
||||
CREATE INDEX faces_model ON faces(model_id);
|
||||
|
||||
CREATE TABLE face_person (
|
||||
face_id INTEGER PRIMARY KEY REFERENCES faces(id) ON DELETE CASCADE,
|
||||
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||
-- Calibrated P(this face is this person), never a raw cosine (FR-CULL-9).
|
||||
probability REAL NOT NULL,
|
||||
-- The user said so. Never overwritten by a later inference pass.
|
||||
--
|
||||
-- A column rather than a probability of 1.0, because a confirmation is a
|
||||
-- different kind of fact from a confident guess and collapsing them loses
|
||||
-- the ability to recompute suggestions without touching user data.
|
||||
confirmed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX face_person_person ON face_person(person_id, confirmed);
|
||||
|
||||
-- Faces the user has explicitly said are NOT a given person.
|
||||
--
|
||||
-- Needed because rejection is not the absence of an assignment: without it,
|
||||
-- the next clustering pass re-suggests exactly the face the user just pushed
|
||||
-- away, and the tool feels broken. Same reasoning as `confirmed` -- a
|
||||
-- judgement is user data (FR-CULL-12) whichever direction it points.
|
||||
CREATE TABLE face_person_rejected (
|
||||
face_id INTEGER NOT NULL REFERENCES faces(id) ON DELETE CASCADE,
|
||||
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (face_id, person_id)
|
||||
);
|
||||
|
||||
-- The FR-CULL-9 calibration, fitted from this library's own faces.
|
||||
--
|
||||
-- One row per model, because the fit is a property of the embedding space and
|
||||
-- a library indexed across a model change holds two. `face_set_hash` is what
|
||||
-- makes a stale fit detectable: a materially changed library recomputes rather
|
||||
-- than trusting numbers derived from a set that no longer exists.
|
||||
CREATE TABLE face_calibration (
|
||||
model_id TEXT PRIMARY KEY,
|
||||
-- P(same) = sigmoid(a*cos + b + w_size*log2(min(crop_px)) + log_prior_odds)
|
||||
a REAL NOT NULL,
|
||||
b REAL NOT NULL,
|
||||
w_size REAL NOT NULL DEFAULT 0.0,
|
||||
-- Whether the fit is usable at all. When it is not, the UI says the
|
||||
-- confidence is unavailable; it does not present an untuned default as
|
||||
-- though it were measured (FR-CULL-9).
|
||||
valid INTEGER NOT NULL DEFAULT 0,
|
||||
positive_pairs INTEGER NOT NULL DEFAULT 0,
|
||||
negative_pairs INTEGER NOT NULL DEFAULT 0,
|
||||
face_set_hash TEXT NOT NULL,
|
||||
fitted_at INTEGER NOT NULL
|
||||
);
|
||||
"#;
|
||||
|
||||
const V7: &str = r#"
|
||||
CREATE INDEX images_grid_order
|
||||
ON images(captured_at IS NULL, captured_at, source_ref)
|
||||
|
||||
@@ -61,6 +61,42 @@ pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), Catalog
|
||||
// have. The effect is the same: one step, no interleaved writers, no
|
||||
// progress callback. A 50k-image catalog is tens of megabytes.
|
||||
backup.run_to_completion(i32::MAX, std::time::Duration::ZERO, None)?;
|
||||
drop(backup);
|
||||
|
||||
strip_face_crops(&out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the stored face crops from a snapshot before it is uploaded.
|
||||
///
|
||||
/// The snapshot is the *whole catalog*, uploaded on every sync and downloaded
|
||||
/// by every device. Face crops are a few KB each and a fully indexed library
|
||||
/// holds tens of thousands of them, so leaving them in would put tens of MB on
|
||||
/// every round trip — the exact cost `face_shard`'s 25 MB cap exists to bound,
|
||||
/// and the reason the bulk per-face data lives in shards in the first place.
|
||||
///
|
||||
/// Crops are not lost by this: they travel in the face shards
|
||||
/// ([`crate::face_shard::export_to_shards`]), which are written once and
|
||||
/// downloaded once. Nothing reads a crop out of a merged remote catalog —
|
||||
/// [`merge_all`] touches collections and keywords only — so removing them here
|
||||
/// costs a receiving device nothing it would otherwise have had.
|
||||
///
|
||||
/// `VACUUM` afterwards because SQLite does not return freed pages to the file
|
||||
/// on its own, and an upload sized by the file rather than by its contents
|
||||
/// would keep paying for bytes that are no longer there.
|
||||
fn strip_face_crops(snapshot: &Connection) -> Result<(), CatalogError> {
|
||||
// A catalog older than the crop column is a legitimate input here — a
|
||||
// snapshot taken mid-migration, or a test fixture built from an earlier
|
||||
// schema — so an absent column is nothing to fail over.
|
||||
let has_crop = snapshot
|
||||
.prepare("SELECT crop FROM faces LIMIT 1")
|
||||
.map(|_| true)
|
||||
.unwrap_or(false);
|
||||
if !has_crop {
|
||||
return Ok(());
|
||||
}
|
||||
snapshot.execute("UPDATE faces SET crop = NULL WHERE crop IS NOT NULL", [])?;
|
||||
snapshot.execute_batch("VACUUM")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -250,4 +286,62 @@ mod tests {
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
base
|
||||
}
|
||||
|
||||
/// The whole reason crops live in the shards: a snapshot is uploaded whole,
|
||||
/// on every sync, to every device.
|
||||
#[test]
|
||||
fn the_snapshot_carries_no_face_crops() {
|
||||
let dir = tempdir();
|
||||
let live = dir.join("catalog.sqlite");
|
||||
let snap = dir.join("snap.sqlite");
|
||||
|
||||
let c = seeded(&live);
|
||||
c.execute(
|
||||
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (1, 1, 'a.CR3', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO faces
|
||||
(image_id, x, y, w, h, landmarks, detector_confidence, embedding,
|
||||
crop_px, model_id, detected_at, crop)
|
||||
VALUES (1, 0.1, 0.1, 0.2, 0.2, X'00', 0.9, X'00', 180.0, 'm', 0, ?1)",
|
||||
[vec![7u8; 4096]],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
snapshot_for_upload(&c, &snap).unwrap();
|
||||
|
||||
let out = Connection::open(&snap).unwrap();
|
||||
let crops: i64 = out
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM faces WHERE crop IS NOT NULL",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(crops, 0, "the snapshot still carries face crops");
|
||||
|
||||
// The face itself must still be there — only the pixels are dropped.
|
||||
let faces: i64 = out
|
||||
.query_row("SELECT COUNT(*) FROM faces", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(faces, 1);
|
||||
|
||||
// And the local catalog keeps its crop: this strips the copy, never
|
||||
// the original.
|
||||
let kept: i64 = c
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM faces WHERE crop IS NOT NULL",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(kept, 1, "stripping the snapshot damaged the live catalog");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,27 +39,14 @@ impl Preview {
|
||||
/// not: a quarter turn is a permutation, so it costs the same either way,
|
||||
/// and doing it on the smaller buffer moves a fraction of the bytes.
|
||||
///
|
||||
/// Allocates a second buffer rather than rotating in place. An in-place
|
||||
/// quarter turn on a non-square image is a cycle-following permutation
|
||||
/// that is both slower per pixel and far harder to get right, for a saving
|
||||
/// that a thumbnail-sized buffer does not need.
|
||||
/// The turn itself is [`dr_types::Orientation::into_shown`], which every
|
||||
/// other consumer of an orientation in this codebase also goes through.
|
||||
/// That is deliberate: a hand-written permutation per caller is how two of
|
||||
/// them come to disagree, and a disagreement here shows as a thumbnail
|
||||
/// facing the other way from the develop view.
|
||||
pub fn apply_orientation(&mut self, orientation: dr_types::Orientation) {
|
||||
if orientation.is_normal() || self.width == 0 || self.height == 0 {
|
||||
return;
|
||||
}
|
||||
let (dw, dh) = orientation.oriented_size(self.width, self.height);
|
||||
|
||||
let mut out = vec![0u8; (dw as usize) * (dh as usize) * 4];
|
||||
for y in 0..dh {
|
||||
for x in 0..dw {
|
||||
let (sx, sy) = orientation.source_pixel(x, y, dw, dh);
|
||||
let s = ((sy * self.width + sx) * 4) as usize;
|
||||
let d = ((y * dw + x) * 4) as usize;
|
||||
out[d..d + 4].copy_from_slice(&self.rgba[s..s + 4]);
|
||||
}
|
||||
}
|
||||
|
||||
self.rgba = out;
|
||||
let (rgba, dw, dh) = orientation.into_shown(&self.rgba, self.width, self.height, 4);
|
||||
self.rgba = rgba;
|
||||
self.width = dw;
|
||||
self.height = dh;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
[package]
|
||||
name = "dr-face"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
# Inference. `ort` is the API; **tract is the engine** — see the workspace
|
||||
# manifest, and docs/faces.md §3, for why the C++ ONNX Runtime is not linked.
|
||||
ort = { workspace = true, optional = true }
|
||||
ort-tract = { workspace = true, optional = true }
|
||||
ndarray = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
zune-jpeg.workspace = true
|
||||
env_logger.workspace = true
|
||||
# The M1 probe drives `ort` directly so it can print the raw load error.
|
||||
ort = { workspace = true }
|
||||
ort-tract = { workspace = true }
|
||||
|
||||
[[example]]
|
||||
name = "probe"
|
||||
required-features = ["inference"]
|
||||
|
||||
[[example]]
|
||||
name = "faces"
|
||||
required-features = ["inference"]
|
||||
|
||||
[features]
|
||||
# Nothing on by default, and in particular **no `embedded-model`**: the weights
|
||||
# are not a build input and never become one (docs/faces.md §2.2). A feature
|
||||
# flag that *could* embed them is a flag someone eventually sets in a packaging
|
||||
# script, and the InsightFace grant does not survive that.
|
||||
default = []
|
||||
|
||||
# The ONNX runtime, and the two stages that need it.
|
||||
#
|
||||
# Separable because the accuracy of this subsystem lives in `calibrate` and
|
||||
# `cluster`, which are arithmetic over embeddings with no model in them. They
|
||||
# must be testable against synthetic embeddings on a machine with no weights on
|
||||
# it — a test suite that needs a research-licensed download is a test suite
|
||||
# that does not run in CI.
|
||||
inference = ["dep:ort", "dep:ort-tract", "dep:ndarray"]
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Detect, align and embed the faces in a JPEG.
|
||||
//!
|
||||
//! The thing worth looking at is whether the landmarks land on a real
|
||||
//! photograph — the same reason `dr-segment` has `examples/detect.rs`.
|
||||
//!
|
||||
//! cargo run -p dr-face --features inference --example faces -- \
|
||||
//! DET.onnx EMB.onnx photo.jpg [photo.jpg ...]
|
||||
//!
|
||||
//! The models must have had their input dims frozen first; see
|
||||
//! `tools/fix-face-model-shapes.sh` and docs/faces.md §12 M1.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use dr_face::{align, DetectOptions, Detector, Embedder, ModelId};
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!("usage: faces DET.onnx EMB.onnx IMAGE.jpg [IMAGE.jpg ...]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
let t = Instant::now();
|
||||
let mut detector = Detector::from_path(&args[0]).expect("load detector");
|
||||
let mut embedder =
|
||||
Embedder::from_path(&args[1], ModelId::new("w600k_mbf")).expect("load embedder");
|
||||
println!(
|
||||
"loaded both models in {:?} (strides {:?})",
|
||||
t.elapsed(),
|
||||
detector.strides()
|
||||
);
|
||||
|
||||
let opts = DetectOptions::default();
|
||||
let mut all = Vec::new();
|
||||
|
||||
for path in &args[2..] {
|
||||
let (rgb, w, h) = match load_jpeg(path) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("{path}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let t = Instant::now();
|
||||
let dets = detector.detect(&rgb, w, h, &opts).expect("detect");
|
||||
let detect_ms = t.elapsed().as_secs_f64() * 1e3;
|
||||
|
||||
println!(
|
||||
"\n{path} ({w}×{h}) {} face(s) in {detect_ms:.0} ms",
|
||||
dets.len()
|
||||
);
|
||||
|
||||
for (i, d) in dets.iter().enumerate() {
|
||||
let Some(aligned) = align::warp(&rgb, w, h, &d.landmarks) else {
|
||||
println!(" [{i}] degenerate landmarks, skipped");
|
||||
continue;
|
||||
};
|
||||
let t = Instant::now();
|
||||
let emb = embedder.embed(&aligned).expect("embed");
|
||||
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",
|
||||
d.confidence,
|
||||
d.bbox.0,
|
||||
d.bbox.1,
|
||||
d.width(),
|
||||
d.height(),
|
||||
aligned.source_px(),
|
||||
);
|
||||
all.push((path.clone(), i, emb));
|
||||
}
|
||||
}
|
||||
|
||||
// Every pair, so the numbers can be eyeballed against the expectation that
|
||||
// faces from one identity's folder score high and everything else low.
|
||||
if all.len() > 1 {
|
||||
println!("\ncosine similarity");
|
||||
for i in 0..all.len() {
|
||||
for j in i + 1..all.len() {
|
||||
let cos = all[i].2.cosine(&all[j].2).expect("same model");
|
||||
println!(
|
||||
" {:.4} {}#{} vs {}#{}",
|
||||
cos,
|
||||
short(&all[i].0),
|
||||
all[i].1,
|
||||
short(&all[j].0),
|
||||
all[j].1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn short(path: &str) -> String {
|
||||
let p = std::path::Path::new(path);
|
||||
let file = p.file_name().unwrap_or_default().to_string_lossy();
|
||||
match p.parent().and_then(|d| d.file_name()) {
|
||||
Some(dir) => format!("{}/{file}", dir.to_string_lossy()),
|
||||
None => file.into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode to the tightly packed `f32` RGB `0.0..=1.0` the crate expects.
|
||||
fn load_jpeg(path: &str) -> Result<(Vec<f32>, usize, usize), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let mut dec = zune_jpeg::JpegDecoder::new(&bytes);
|
||||
let px = dec.decode().map_err(|e| e.to_string())?;
|
||||
let info = dec.info().ok_or("no jpeg header")?;
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
|
||||
let rgb: Vec<f32> = match px.len() / (w * h) {
|
||||
3 => px.iter().map(|&v| v as f32 / 255.0).collect(),
|
||||
1 => px
|
||||
.iter()
|
||||
.flat_map(|&v| {
|
||||
let g = v as f32 / 255.0;
|
||||
[g, g, g]
|
||||
})
|
||||
.collect(),
|
||||
n => return Err(format!("{n} components per pixel, expected 1 or 3")),
|
||||
};
|
||||
Ok((rgb, w, h))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! M1 (docs/faces.md §12) — will tract load these graphs at all?
|
||||
//!
|
||||
//! The one measurement everything else in the face subsystem is conditional
|
||||
//! on. `det_500m.onnx` has a dynamic H/W input, which is exactly what tract
|
||||
//! failed on for YOLO26n-seg, so a plain "no" here is the expected outcome and
|
||||
//! the interesting part is the error it gives.
|
||||
//!
|
||||
//! cargo run -p dr-face --features inference --example probe -- MODEL...
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let paths: Vec<String> = std::env::args().skip(1).collect();
|
||||
if paths.is_empty() {
|
||||
eprintln!("usage: probe MODEL.onnx [MODEL.onnx ...]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
let mut failures = 0;
|
||||
for path in &paths {
|
||||
println!("\n=== {path} ===");
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
println!(" UNREADABLE: {e}");
|
||||
failures += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
println!(" {} bytes", bytes.len());
|
||||
|
||||
dr_face::install_backend_for_probe();
|
||||
|
||||
let session =
|
||||
ort::session::Session::builder().and_then(|mut b| b.commit_from_memory(&bytes));
|
||||
|
||||
match session {
|
||||
Err(e) => {
|
||||
println!(" LOAD FAILED: {e}");
|
||||
failures += 1;
|
||||
}
|
||||
Ok(s) => {
|
||||
println!(" LOADED");
|
||||
for i in s.inputs() {
|
||||
println!(" in {:<24} {:?}", i.name(), i.dtype().tensor_shape());
|
||||
}
|
||||
for o in s.outputs() {
|
||||
println!(" out {:<24} {:?}", o.name(), o.dtype().tensor_shape());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n{} of {} failed", failures, paths.len());
|
||||
if failures > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Five-point face alignment (docs/faces.md §5).
|
||||
//!
|
||||
//! ArcFace embeddings are trained on faces warped to a canonical 112×112
|
||||
//! arrangement. Feeding the model a plain bounding-box crop *works* — it
|
||||
//! produces 512 numbers, they are unit-norm, and cosine similarities between
|
||||
//! them look entirely reasonable. They are just much worse, and nothing in the
|
||||
//! system reports it.
|
||||
//!
|
||||
//! That is the whole reason this module exists, and the reason [`Aligned112`]
|
||||
//! is a newtype only [`warp`] can construct: the mistake is not one a reviewer
|
||||
//! catches, so the type system catches it instead.
|
||||
//!
|
||||
//! Model-free, so it builds and tests without the `inference` feature.
|
||||
|
||||
/// Canonical landmark positions for a 112×112 ArcFace crop.
|
||||
///
|
||||
/// # The naming is a trap; the order is not
|
||||
///
|
||||
/// Point 0 sits at x=38 on a 112-wide canvas — left of centre *in the image*,
|
||||
/// which is the subject's **right** eye. Both namings are in circulation and
|
||||
/// they are opposite, so the array is written in the detector's order and the
|
||||
/// comment says whose left is whose:
|
||||
///
|
||||
/// ```text
|
||||
/// 0 subject's right eye (image-left)
|
||||
/// 1 subject's left eye (image-right)
|
||||
/// 2 nose tip
|
||||
/// 3 subject's right mouth corner
|
||||
/// 4 subject's left mouth corner
|
||||
/// ```
|
||||
///
|
||||
/// SCRFD emits its five points in this same order, so the correct amount of
|
||||
/// reordering between detector and template is **none**. A detector with a
|
||||
/// different order carries its own permutation beside its model id rather than
|
||||
/// this constant growing an assumption.
|
||||
pub const ARCFACE_TEMPLATE: [(f32, f32); 5] = [
|
||||
(38.2946, 51.6963),
|
||||
(73.5318, 51.5014),
|
||||
(56.0252, 71.7366),
|
||||
(41.5493, 92.3655),
|
||||
(70.7299, 92.2041),
|
||||
];
|
||||
|
||||
/// Edge of the aligned crop, in pixels. Fixed by the embedder's input.
|
||||
pub const ALIGNED_EDGE: usize = 112;
|
||||
|
||||
/// A face warped to [`ARCFACE_TEMPLATE`], ready for the embedder.
|
||||
///
|
||||
/// Constructible only by [`warp`]. That is the point: an `Embedder` that took
|
||||
/// a plain `&[f32]` would accept an unaligned bounding-box crop and silently
|
||||
/// return worse embeddings, which is a failure no test of the embedder itself
|
||||
/// would catch.
|
||||
pub struct Aligned112 {
|
||||
/// `112 × 112 × 3`, row-major RGB in `0.0..=1.0`.
|
||||
pixels: Vec<f32>,
|
||||
/// Source pixels across the crop before warping — `crop_px` in the catalog.
|
||||
///
|
||||
/// Carried here rather than recomputed later because the scale factor is
|
||||
/// known exactly at warp time and only approximately from the box
|
||||
/// afterwards. §7: it is the honest quality signal, and a feature in the
|
||||
/// calibration.
|
||||
source_px: f32,
|
||||
}
|
||||
|
||||
impl Aligned112 {
|
||||
pub fn pixels(&self) -> &[f32] {
|
||||
&self.pixels
|
||||
}
|
||||
|
||||
/// Source pixels spanned by the 112-pixel crop.
|
||||
///
|
||||
/// Below ~112 the face was upsampled to reach the embedder and the
|
||||
/// embedding is correspondingly weaker; above it, downsampled and healthy.
|
||||
pub fn source_px(&self) -> f32 {
|
||||
self.source_px
|
||||
}
|
||||
|
||||
/// How sharp the face the embedder is about to see actually is.
|
||||
///
|
||||
/// # Why size is not enough
|
||||
///
|
||||
/// A face can be large and useless. A subject walking through a half-second
|
||||
/// exposure, a frame focused on the person behind them, a hand-held shot at
|
||||
/// 1/15 — all yield a big box, a confident detection and five landmarks in
|
||||
/// plausible places. The embedding that comes back is not *wrong* in any
|
||||
/// way the system can see: it is unit-norm and its cosines look ordinary.
|
||||
/// It is simply an embedding of a blur, and blurs resemble each other more
|
||||
/// than they resemble the people they were, so they cluster together and
|
||||
/// bridge identities that have nothing to do with one another.
|
||||
///
|
||||
/// That is the failure this exists to prevent, and it is the same class of
|
||||
/// fault as the unaligned-crop one the [`Aligned112`] newtype guards
|
||||
/// against: plausible output, no error, worse results, nothing reported.
|
||||
///
|
||||
/// # The measure
|
||||
///
|
||||
/// Variance of the Laplacian — the standard blur metric — **divided by the
|
||||
/// variance of the luma it was taken over**. The division is what makes it
|
||||
/// usable here. Raw Laplacian variance scales with contrast, so a sharp
|
||||
/// face in flat, hazy or backlit light scores like a blurred one in hard
|
||||
/// light, and a threshold on it would quietly throw away every face shot
|
||||
/// against a bright sky. The ratio asks the question that actually matters
|
||||
/// — *how much of this crop's variation is edges rather than broad
|
||||
/// gradients* — and is invariant to exposure and contrast.
|
||||
///
|
||||
/// Computed on luma over the interior, so the 3x3 kernel never needs a
|
||||
/// border rule. Returns 0.0 for a crop with no variation at all, which is
|
||||
/// a flat patch and correctly unusable rather than infinitely sharp.
|
||||
///
|
||||
/// # This is not independent of size
|
||||
///
|
||||
/// A face smaller than 112 pixels was *upsampled* to reach the embedder,
|
||||
/// and upsampling invents no edges — so a small face scores low here even
|
||||
/// when the original was perfectly sharp. That is not a flaw to correct: it
|
||||
/// is the honest statement that the embedder is looking at a soft image.
|
||||
/// The size floor and this one overlap deliberately, and
|
||||
/// `face_index --quality` prints the joint distribution so the two are
|
||||
/// chosen together rather than each in ignorance of the other.
|
||||
pub fn sharpness(&self) -> f32 {
|
||||
let e = ALIGNED_EDGE;
|
||||
let luma: Vec<f32> = self
|
||||
.pixels
|
||||
.chunks_exact(3)
|
||||
.map(|p| 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2])
|
||||
.collect();
|
||||
|
||||
let (mut lap_sum, mut lap_sq) = (0.0_f64, 0.0_f64);
|
||||
let (mut lum_sum, mut lum_sq) = (0.0_f64, 0.0_f64);
|
||||
let mut n = 0.0_f64;
|
||||
|
||||
for y in 1..e - 1 {
|
||||
for x in 1..e - 1 {
|
||||
let i = y * e + x;
|
||||
// Four-neighbour Laplacian. The 8-neighbour form is more
|
||||
// sensitive to diagonal detail and also to noise, which on a
|
||||
// high-ISO frame is exactly the thing that must not read as
|
||||
// sharpness.
|
||||
let lap = 4.0 * luma[i] - luma[i - 1] - luma[i + 1] - luma[i - e] - luma[i + e];
|
||||
let lap = lap as f64;
|
||||
lap_sum += lap;
|
||||
lap_sq += lap * lap;
|
||||
|
||||
let l = luma[i] as f64;
|
||||
lum_sum += l;
|
||||
lum_sq += l * l;
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
if n == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let lap_var = (lap_sq / n - (lap_sum / n).powi(2)).max(0.0);
|
||||
let lum_var = (lum_sq / n - (lum_sum / n).powi(2)).max(0.0);
|
||||
|
||||
// A crop with no luma variation has no edges to find either, so the
|
||||
// ratio is 0/0. Zero is the right answer: nothing there is a face.
|
||||
if lum_var <= 1e-9 {
|
||||
return 0.0;
|
||||
}
|
||||
(lap_var / lum_var) as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// A similarity transform: rotation, uniform scale, translation.
|
||||
///
|
||||
/// Stored as the four independent parameters rather than a 2×3 matrix so that
|
||||
/// [`Similarity::scale`] is readable without a decomposition.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Similarity {
|
||||
a: f32,
|
||||
b: f32,
|
||||
tx: f32,
|
||||
ty: f32,
|
||||
}
|
||||
|
||||
impl Similarity {
|
||||
/// `x' = a·x − b·y + tx`, `y' = b·x + a·y + ty`.
|
||||
pub fn apply(&self, x: f32, y: f32) -> (f32, f32) {
|
||||
(
|
||||
self.a * x - self.b * y + self.tx,
|
||||
self.b * x + self.a * y + self.ty,
|
||||
)
|
||||
}
|
||||
|
||||
/// Uniform scale factor — destination pixels per source pixel.
|
||||
pub fn scale(&self) -> f32 {
|
||||
(self.a * self.a + self.b * self.b).sqrt()
|
||||
}
|
||||
|
||||
fn invert(&self, u: f32, v: f32) -> (f32, f32) {
|
||||
let det = self.a * self.a + self.b * self.b;
|
||||
let du = u - self.tx;
|
||||
let dv = v - self.ty;
|
||||
(
|
||||
(self.a * du + self.b * dv) / det,
|
||||
(-self.b * du + self.a * dv) / det,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Least-squares similarity transform from `src` onto `dst`.
|
||||
///
|
||||
/// # Why least squares and not RANSAC
|
||||
///
|
||||
/// The reference C++ implementation (docs/faces.md §1.1) fits this with
|
||||
/// OpenCV's `estimateAffinePartial2D` under RANSAC. RANSAC over five points is
|
||||
/// a strange fit: the minimal sample for a similarity is two, so it can discard
|
||||
/// landmarks it judges outliers and solve from a subset — and on a profile face
|
||||
/// the "outlier" is as likely to be the correct geometry as the wrong one.
|
||||
/// InsightFace's own pipeline uses plain least squares over all five points,
|
||||
/// which cannot silently drop anything, and that is what this is.
|
||||
///
|
||||
/// # The closed form
|
||||
///
|
||||
/// A 2-D similarity is linear in its four parameters:
|
||||
///
|
||||
/// ```text
|
||||
/// x' = a·x − b·y + tx
|
||||
/// y' = b·x + a·y + ty
|
||||
/// ```
|
||||
///
|
||||
/// so this is an ordinary linear least-squares problem, not an SVD one.
|
||||
/// Centring both point sets kills `tx`/`ty` from the normal equations and
|
||||
/// leaves `a` and `b` as two dot products over a common denominator — which is
|
||||
/// why there is no matrix decomposition anywhere in this function.
|
||||
///
|
||||
/// Returns `None` when the source points are degenerate (coincident or
|
||||
/// collinear to within f32), which does happen: a detector firing on a
|
||||
/// motion-blurred profile can put all five landmarks on a line.
|
||||
pub fn fit_similarity(src: &[(f32, f32); 5], dst: &[(f32, f32); 5]) -> Option<Similarity> {
|
||||
let n = 5.0_f32;
|
||||
let (mut sx, mut sy, mut dx, mut dy) = (0.0, 0.0, 0.0, 0.0);
|
||||
for i in 0..5 {
|
||||
sx += src[i].0;
|
||||
sy += src[i].1;
|
||||
dx += dst[i].0;
|
||||
dy += dst[i].1;
|
||||
}
|
||||
let (sx, sy, dx, dy) = (sx / n, sy / n, dx / n, dy / n);
|
||||
|
||||
let mut var = 0.0_f32;
|
||||
let mut num_a = 0.0_f32;
|
||||
let mut num_b = 0.0_f32;
|
||||
for i in 0..5 {
|
||||
let (px, py) = (src[i].0 - sx, src[i].1 - sy);
|
||||
let (qx, qy) = (dst[i].0 - dx, dst[i].1 - dy);
|
||||
var += px * px + py * py;
|
||||
num_a += px * qx + py * qy;
|
||||
num_b += px * qy - py * qx;
|
||||
}
|
||||
|
||||
// Degenerate: every landmark on one point. Collinear input still solves,
|
||||
// but with a scale that can be absurd, so the caller's sanity check on
|
||||
// `scale()` is what catches that case.
|
||||
if var <= f32::EPSILON {
|
||||
return None;
|
||||
}
|
||||
|
||||
let a = num_a / var;
|
||||
let b = num_b / var;
|
||||
if !a.is_finite() || !b.is_finite() || (a * a + b * b) <= f32::EPSILON {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Similarity {
|
||||
a,
|
||||
b,
|
||||
tx: dx - (a * sx - b * sy),
|
||||
ty: dy - (b * sx + a * sy),
|
||||
})
|
||||
}
|
||||
|
||||
/// Warp a face onto the canonical 112×112 arrangement.
|
||||
///
|
||||
/// `rgb` is tightly packed `f32` RGB in `0.0..=1.0`, row-major — the same
|
||||
/// convention `dr-segment` uses, so both read the same proxy.
|
||||
///
|
||||
/// Sampling is bilinear **from the source in one step**: never crop-then-warp,
|
||||
/// which resamples twice and throws away detail the warp could have used.
|
||||
/// Pixels falling outside the source read as black.
|
||||
pub fn warp(
|
||||
rgb: &[f32],
|
||||
width: usize,
|
||||
height: usize,
|
||||
landmarks: &[(f32, f32); 5],
|
||||
) -> Option<Aligned112> {
|
||||
if rgb.len() != width * height * 3 {
|
||||
return None;
|
||||
}
|
||||
let m = fit_similarity(landmarks, &ARCFACE_TEMPLATE)?;
|
||||
|
||||
let e = ALIGNED_EDGE;
|
||||
let mut pixels = vec![0.0_f32; e * e * 3];
|
||||
for v in 0..e {
|
||||
for u in 0..e {
|
||||
// Pixel centres, so the transform is not off by half a pixel —
|
||||
// which is small enough to survive review and large enough to
|
||||
// matter on a 40-pixel face.
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
Some(Aligned112 {
|
||||
pixels,
|
||||
// The warp maps `scale` source pixels to one destination pixel, so the
|
||||
// crop spans 112/scale of the source.
|
||||
source_px: ALIGNED_EDGE as f32 / m.scale(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_bilinear(rgb: &[f32], w: usize, h: usize, x: f32, y: f32, out: &mut [f32]) {
|
||||
let x0 = x.floor();
|
||||
let y0 = y.floor();
|
||||
let fx = x - x0;
|
||||
let fy = y - y0;
|
||||
let x0 = x0 as isize;
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn shifted_scaled(scale: f32, dx: f32, dy: f32, rot: f32) -> [(f32, f32); 5] {
|
||||
let (s, c) = (rot.sin(), rot.cos());
|
||||
let mut out = [(0.0, 0.0); 5];
|
||||
for (i, &(x, y)) in ARCFACE_TEMPLATE.iter().enumerate() {
|
||||
out[i] = (scale * (c * x - s * y) + dx, scale * (s * x + c * y) + dy);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_onto_itself_is_the_identity() {
|
||||
let m = fit_similarity(&ARCFACE_TEMPLATE, &ARCFACE_TEMPLATE).unwrap();
|
||||
for &(x, y) in &ARCFACE_TEMPLATE {
|
||||
let (u, v) = m.apply(x, y);
|
||||
assert!((u - x).abs() < 1e-3, "{u} vs {x}");
|
||||
assert!((v - y).abs() < 1e-3, "{v} vs {y}");
|
||||
}
|
||||
assert!((m.scale() - 1.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// The property that matters: whatever similarity the face was seen under,
|
||||
/// the fit must undo it and land the landmarks back on the template. This
|
||||
/// is the test that fails if the transform is ever "simplified" into an
|
||||
/// affine or a bare scale-and-translate.
|
||||
#[test]
|
||||
fn any_similarity_of_the_template_maps_back_onto_it() {
|
||||
for &(scale, dx, dy, rot) in &[
|
||||
(1.0_f32, 0.0_f32, 0.0_f32, 0.0_f32),
|
||||
(2.5, 100.0, -40.0, 0.0),
|
||||
(0.4, -12.0, 300.0, 0.6),
|
||||
(1.7, 5.0, 5.0, -1.2),
|
||||
] {
|
||||
let observed = shifted_scaled(scale, dx, dy, rot);
|
||||
let m = fit_similarity(&observed, &ARCFACE_TEMPLATE).unwrap();
|
||||
for (i, &(tx, ty)) in ARCFACE_TEMPLATE.iter().enumerate() {
|
||||
let (u, v) = m.apply(observed[i].0, observed[i].1);
|
||||
assert!(
|
||||
(u - tx).abs() < 1e-2 && (v - ty).abs() < 1e-2,
|
||||
"scale={scale} rot={rot}: point {i} landed at ({u}, {v}), want ({tx}, {ty})"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
(m.scale() - 1.0 / scale).abs() < 1e-3,
|
||||
"scale {} should invert {scale}",
|
||||
m.scale()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_landmarks_are_rejected_rather_than_producing_a_crop() {
|
||||
let degenerate = [(50.0, 50.0); 5];
|
||||
assert!(fit_similarity(°enerate, &ARCFACE_TEMPLATE).is_none());
|
||||
let rgb = vec![0.5_f32; 64 * 64 * 3];
|
||||
assert!(warp(&rgb, 64, 64, °enerate).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_px_reports_the_face_size_the_embedder_actually_saw() {
|
||||
let rgb = vec![0.5_f32; 400 * 400 * 3];
|
||||
// A face twice the template's size spans 224 source pixels.
|
||||
let big = shifted_scaled(2.0, 80.0, 80.0, 0.0);
|
||||
let a = warp(&rgb, 400, 400, &big).unwrap();
|
||||
assert!((a.source_px() - 224.0).abs() < 0.5, "{}", a.source_px());
|
||||
|
||||
// Half-size: 56 source pixels upsampled to 112, which §7 calls the
|
||||
// degraded bucket.
|
||||
let small = shifted_scaled(0.5, 10.0, 10.0, 0.0);
|
||||
let a = warp(&rgb, 400, 400, &small).unwrap();
|
||||
assert!((a.source_px() - 56.0).abs() < 0.5, "{}", a.source_px());
|
||||
}
|
||||
|
||||
/// A white square on black, warped by a transform that should centre it:
|
||||
/// checks the sampler's geometry rather than the fit's algebra.
|
||||
#[test]
|
||||
fn warp_resamples_the_right_pixels() {
|
||||
let (w, h) = (224, 224);
|
||||
let mut rgb = vec![0.0_f32; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if (56..168).contains(&x) && (56..168).contains(&y) {
|
||||
for c in 0..3 {
|
||||
rgb[(y * w + x) * 3 + c] = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Landmarks placed so the fit is a pure translation of (56, 56):
|
||||
// the white square maps exactly onto the 112×112 output.
|
||||
let lm = shifted_scaled(1.0, 56.0, 56.0, 0.0);
|
||||
let a = warp(&rgb, w, h, &lm).unwrap();
|
||||
let px = a.pixels();
|
||||
for (i, v) in px.iter().enumerate() {
|
||||
assert!((v - 1.0).abs() < 1e-3, "pixel {i} is {v}, expected white");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_bounds_samples_read_black_rather_than_wrapping() {
|
||||
let rgb = vec![1.0_f32; 32 * 32 * 3];
|
||||
// Face far outside the image: every sample is out of bounds.
|
||||
let lm = shifted_scaled(1.0, 5000.0, 5000.0, 0.0);
|
||||
let a = warp(&rgb, 32, 32, &lm).unwrap();
|
||||
assert!(a.pixels().iter().all(|&v| v == 0.0));
|
||||
}
|
||||
|
||||
// ── sharpness ─────────────────────────────────────────────────────────
|
||||
|
||||
/// An image of `edge` square, filled by `f(x, y) -> luma`.
|
||||
fn image(edge: usize, f: impl Fn(usize, usize) -> f32) -> Vec<f32> {
|
||||
let mut v = Vec::with_capacity(edge * edge * 3);
|
||||
for y in 0..edge {
|
||||
for x in 0..edge {
|
||||
let l = f(x, y);
|
||||
v.extend_from_slice(&[l, l, l]);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// One box-blur pass, which is enough to move the metric a long way.
|
||||
fn blur(rgb: &[f32], edge: usize) -> Vec<f32> {
|
||||
let mut out = rgb.to_vec();
|
||||
for y in 1..edge - 1 {
|
||||
for x in 1..edge - 1 {
|
||||
for c in 0..3 {
|
||||
let mut sum = 0.0;
|
||||
for dy in -1isize..=1 {
|
||||
for dx in -1isize..=1 {
|
||||
let i = (((y as isize + dy) as usize) * edge
|
||||
+ ((x as isize + dx) as usize))
|
||||
* 3
|
||||
+ c;
|
||||
sum += rgb[i];
|
||||
}
|
||||
}
|
||||
out[(y * edge + x) * 3 + c] = sum / 9.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Landmarks placing the template into a larger image at scale 1, so the
|
||||
/// warp resamples one-to-one and the metric sees the source detail.
|
||||
fn centred(edge: usize) -> [(f32, f32); 5] {
|
||||
let off = (edge as f32 - ALIGNED_EDGE as f32) / 2.0;
|
||||
shifted_scaled(1.0, off, off, 0.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blurred_face_scores_lower_than_a_sharp_one() {
|
||||
let edge = 200;
|
||||
let sharp = image(
|
||||
edge,
|
||||
|x, y| if (x / 3 + y / 3) % 2 == 0 { 0.9 } else { 0.1 },
|
||||
);
|
||||
let soft = blur(&blur(&sharp, edge), edge);
|
||||
|
||||
let a = warp(&sharp, edge, edge, ¢red(edge))
|
||||
.unwrap()
|
||||
.sharpness();
|
||||
let b = warp(&soft, edge, edge, ¢red(edge)).unwrap().sharpness();
|
||||
assert!(a > b * 2.0, "sharp {a} should clearly beat blurred {b}");
|
||||
}
|
||||
|
||||
/// The reason for dividing by luma variance. A sharp face photographed
|
||||
/// 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 sharpness_survives_the_contrast_being_halved() {
|
||||
let edge = 200;
|
||||
let full = image(
|
||||
edge,
|
||||
|x, y| if (x / 3 + y / 3) % 2 == 0 { 0.9 } else { 0.1 },
|
||||
);
|
||||
// Same detail, half the contrast, lifted so it does not clip.
|
||||
let flat = image(
|
||||
edge,
|
||||
|x, y| {
|
||||
if (x / 3 + y / 3) % 2 == 0 {
|
||||
0.55
|
||||
} else {
|
||||
0.45
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let a = warp(&full, edge, edge, ¢red(edge)).unwrap().sharpness();
|
||||
let b = warp(&flat, edge, edge, ¢red(edge)).unwrap().sharpness();
|
||||
let ratio = a / b;
|
||||
assert!(
|
||||
(0.5..2.0).contains(&ratio),
|
||||
"contrast changed the score {ratio}x ({a} vs {b})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_crop_has_no_sharpness() {
|
||||
let edge = 200;
|
||||
let flat = image(edge, |_, _| 0.5);
|
||||
assert_eq!(
|
||||
warp(&flat, edge, edge, ¢red(edge)).unwrap().sharpness(),
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
/// Upsampling invents no detail, so a face that had to be stretched to
|
||||
/// reach the embedder scores lower than the same face at full size. That
|
||||
/// overlap with the size floor is deliberate and documented; this pins it
|
||||
/// so a future change cannot quietly remove it.
|
||||
#[test]
|
||||
fn an_upsampled_face_scores_lower_than_the_same_face_at_full_size() {
|
||||
let edge = 200;
|
||||
let src = image(
|
||||
edge,
|
||||
|x, y| if (x / 3 + y / 3) % 2 == 0 { 0.9 } else { 0.1 },
|
||||
);
|
||||
|
||||
let full = warp(&src, edge, edge, ¢red(edge)).unwrap();
|
||||
// Half scale: the crop spans 56 source pixels and is stretched to 112.
|
||||
let off = (edge as f32 - ALIGNED_EDGE as f32 / 2.0) / 2.0;
|
||||
let small = warp(&src, edge, edge, &shifted_scaled(0.5, off, off, 0.0)).unwrap();
|
||||
|
||||
assert!(small.source_px() < full.source_px());
|
||||
assert!(
|
||||
small.sharpness() < full.sharpness(),
|
||||
"upsampled {} should be softer than full {}",
|
||||
small.sharpness(),
|
||||
full.sharpness()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
//! Cosine to probability (docs/faces.md §8, FR-CULL-9).
|
||||
//!
|
||||
//! FR-CULL-9 is a hard requirement rather than an implementation detail: no
|
||||
//! code path may threshold a bare cosine, every threshold in the subsystem is
|
||||
//! stated as a probability, and the fit is per library and reports its own
|
||||
//! validity. The failure it guards against is invisible — a raw cosine means
|
||||
//! something different for every model, every population and every face size,
|
||||
//! and an uncalibrated similarity still *looks* like a plausible number all the
|
||||
//! way to the user interface.
|
||||
//!
|
||||
//! Model-free, so the part of this subsystem most likely to be subtly wrong is
|
||||
//! testable on synthetic embeddings with no weights on the machine.
|
||||
//!
|
||||
//! # Where the training pairs come from
|
||||
//!
|
||||
//! **Negatives are free and abundant.** Two faces detected in *the same
|
||||
//! photograph* are almost never the same person, which hands every multi-face
|
||||
//! image in the library a full set of negative pairs at no labelling cost — and
|
||||
//! they are *hard* negatives, from the same camera, lighting and processing,
|
||||
//! which is exactly the population where a threshold tuned on easy negatives
|
||||
//! 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.
|
||||
//!
|
||||
//! Which is why a fresh library has **no valid calibration**, and says so.
|
||||
|
||||
/// Bins over cosine ∈ [-1, 1].
|
||||
///
|
||||
/// 200 is the reference implementation's figure and the resolution is not
|
||||
/// critical; what matters is that there *is* a histogram. See [`Pairs`].
|
||||
const BINS: usize = 200;
|
||||
|
||||
/// Minimum evidence before a fit is trusted.
|
||||
///
|
||||
/// 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.
|
||||
pub const MIN_POSITIVE_PAIRS: u64 = 200;
|
||||
pub const MIN_NEGATIVE_PAIRS: u64 = 2_000;
|
||||
|
||||
/// A fitted `P(same person | cosine, face size)`.
|
||||
///
|
||||
/// The single definition of what a similarity means in this subsystem. The
|
||||
/// catalog stores its parameters; nothing re-implements the sigmoid.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Calibration {
|
||||
pub a: f32,
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
pub valid: bool,
|
||||
pub positive_pairs: u64,
|
||||
pub negative_pairs: u64,
|
||||
}
|
||||
|
||||
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.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
a: 16.2,
|
||||
b: -16.2 * 0.267,
|
||||
w_size: 0.0,
|
||||
valid: false,
|
||||
positive_pairs: 0,
|
||||
negative_pairs: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Calibration {
|
||||
/// P(same person), shifted by a base-rate prior.
|
||||
///
|
||||
/// `log_prior_odds` is applied at evaluation rather than folded into the
|
||||
/// fit, so one stored calibration serves every context: the odds that two
|
||||
/// faces in a 40-image album match are not the odds in a 40,000-image
|
||||
/// archive. Folding a prior in would need a refit per context and would
|
||||
/// make the stored parameters mean different things depending on where they
|
||||
/// came from.
|
||||
pub fn probability(&self, cosine: f32, min_crop_px: f32, log_prior_odds: f32) -> f32 {
|
||||
sigmoid(self.logit(cosine, min_crop_px) + log_prior_odds)
|
||||
}
|
||||
|
||||
fn logit(&self, cosine: f32, min_crop_px: f32) -> f32 {
|
||||
self.a * cosine + self.b + self.w_size * min_crop_px.max(1.0).log2()
|
||||
}
|
||||
|
||||
/// The cosine at which [`Calibration::probability`] crosses `p`.
|
||||
///
|
||||
/// What turns "merge above 0.9" into one comparison against a stored
|
||||
/// similarity, rather than a sigmoid evaluated per candidate edge.
|
||||
pub fn boundary_at(&self, p: f32, min_crop_px: f32, log_prior_odds: f32) -> f32 {
|
||||
((p / (1.0 - p)).ln() - self.b - self.w_size * min_crop_px.max(1.0).log2() - log_prior_odds)
|
||||
/ self.a
|
||||
}
|
||||
}
|
||||
|
||||
fn sigmoid(z: f32) -> f32 {
|
||||
// Branch on the sign so neither tail overflows: exp(-z) for large positive
|
||||
// z, exp(z) for large negative.
|
||||
if z >= 0.0 {
|
||||
1.0 / (1.0 + (-z).exp())
|
||||
} else {
|
||||
let e = z.exp();
|
||||
e / (1.0 + e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulated pair evidence, as a histogram rather than a list.
|
||||
///
|
||||
/// # Why a histogram
|
||||
///
|
||||
/// A 25,000-face library has ~3×10⁸ pairs and no gradient descent is running
|
||||
/// over that. Bucketing them costs 200 counters per class and reduces the fit
|
||||
/// to two parameters against per-bin totals; the expensive part becomes the
|
||||
/// similarity matrix, which is one blocked GEMM. This is the trick that makes a
|
||||
/// per-library fit affordable at all, and it is not obvious from outside.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pairs {
|
||||
positive: Vec<f64>,
|
||||
negative: Vec<f64>,
|
||||
n_pos: u64,
|
||||
n_neg: u64,
|
||||
}
|
||||
|
||||
impl Default for Pairs {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Pairs {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
positive: vec![0.0; BINS],
|
||||
negative: vec![0.0; BINS],
|
||||
n_pos: 0,
|
||||
n_neg: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a pair known to be the same person.
|
||||
pub fn push_positive(&mut self, cosine: f32) {
|
||||
self.positive[bin(cosine)] += 1.0;
|
||||
self.n_pos += 1;
|
||||
}
|
||||
|
||||
/// Record a pair known to be different people.
|
||||
pub fn push_negative(&mut self, cosine: f32) {
|
||||
self.negative[bin(cosine)] += 1.0;
|
||||
self.n_neg += 1;
|
||||
}
|
||||
|
||||
pub fn positives(&self) -> u64 {
|
||||
self.n_pos
|
||||
}
|
||||
pub fn negatives(&self) -> u64 {
|
||||
self.n_neg
|
||||
}
|
||||
|
||||
/// Fit `P(same) = σ(a·cos + b)` by weighted logistic regression.
|
||||
///
|
||||
/// Class weights are explicit because negatives outnumber positives by
|
||||
/// orders of magnitude, and an unweighted fit produces a well-shaped curve
|
||||
/// sitting at the wrong height — precisely the "plausible number all the
|
||||
/// way to the user interface" failure FR-CULL-9 describes.
|
||||
///
|
||||
/// Returns a calibration with `valid` set only if there was enough
|
||||
/// evidence; the parameters are filled in either way so a caller with no
|
||||
/// better option can still cluster at a documented operating point.
|
||||
pub fn fit(&self) -> Calibration {
|
||||
let base = Calibration {
|
||||
positive_pairs: self.n_pos,
|
||||
negative_pairs: self.n_neg,
|
||||
..Calibration::default()
|
||||
};
|
||||
if self.n_pos < MIN_POSITIVE_PAIRS || self.n_neg < MIN_NEGATIVE_PAIRS {
|
||||
return base;
|
||||
}
|
||||
|
||||
let total = self.n_pos as f64 + self.n_neg as f64;
|
||||
let w_pos = total / (2.0 * self.n_pos as f64);
|
||||
let w_neg = total / (2.0 * self.n_neg as f64);
|
||||
|
||||
// Start from the reference's fitted MBF curve rather than from zero:
|
||||
// it is the right order of magnitude for every model in this family,
|
||||
// so descent converges in far fewer steps and cannot wander into a
|
||||
// sign-flipped solution on thin evidence.
|
||||
let mut a = base.a as f64;
|
||||
let mut b = base.b as f64;
|
||||
const LR: f64 = 0.05;
|
||||
const MAX_ITER: usize = 20_000;
|
||||
const TOL: f64 = 1e-7;
|
||||
|
||||
for _ in 0..MAX_ITER {
|
||||
let (mut da, mut db) = (0.0, 0.0);
|
||||
for i in 0..BINS {
|
||||
let x = bin_centre(i) as f64;
|
||||
let s = 1.0 / (1.0 + (-(a * x + b)).exp());
|
||||
if self.positive[i] > 0.0 {
|
||||
let e = (s - 1.0) * w_pos * self.positive[i];
|
||||
da += e * x;
|
||||
db += e;
|
||||
}
|
||||
if self.negative[i] > 0.0 {
|
||||
let e = s * w_neg * self.negative[i];
|
||||
da += e * x;
|
||||
db += e;
|
||||
}
|
||||
}
|
||||
da /= total;
|
||||
db /= total;
|
||||
a -= LR * da;
|
||||
b -= LR * db;
|
||||
if da * da + db * db < TOL * TOL {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Calibration {
|
||||
a: a as f32,
|
||||
b: b as f32,
|
||||
w_size: 0.0,
|
||||
valid: true,
|
||||
positive_pairs: self.n_pos,
|
||||
negative_pairs: self.n_neg,
|
||||
}
|
||||
}
|
||||
|
||||
/// How well the fit predicts the evidence, as a reliability diagram.
|
||||
///
|
||||
/// FR-CULL-9's acceptance criterion is exactly this and not a single
|
||||
/// accuracy figure: for each populated probability band, the observed match
|
||||
/// rate against the predicted one. Returned rather than asserted so the
|
||||
/// caller can show it, log it, or fail a test on it.
|
||||
pub fn reliability(&self, cal: &Calibration, bands: usize) -> Vec<ReliabilityBand> {
|
||||
let mut out = vec![
|
||||
ReliabilityBand {
|
||||
predicted: 0.0,
|
||||
observed: 0.0,
|
||||
count: 0
|
||||
};
|
||||
bands
|
||||
];
|
||||
let mut sum_pred = vec![0.0_f64; bands];
|
||||
for i in 0..BINS {
|
||||
let n_pos = self.positive[i];
|
||||
let n_neg = self.negative[i];
|
||||
if n_pos + n_neg == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let p = cal.probability(bin_centre(i), 112.0, 0.0) as f64;
|
||||
let band = ((p * bands as f64) as usize).min(bands - 1);
|
||||
sum_pred[band] += p * (n_pos + n_neg);
|
||||
out[band].observed += n_pos as f32;
|
||||
out[band].count += (n_pos + n_neg) as u64;
|
||||
}
|
||||
for (band, o) in out.iter_mut().enumerate() {
|
||||
if o.count > 0 {
|
||||
o.predicted = (sum_pred[band] / o.count as f64) as f32;
|
||||
o.observed /= o.count as f32;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of a reliability diagram.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ReliabilityBand {
|
||||
/// Mean probability the calibration predicted for pairs in this band.
|
||||
pub predicted: f32,
|
||||
/// Fraction of them that were actually the same person.
|
||||
pub observed: f32,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
fn bin(cosine: f32) -> usize {
|
||||
let width = 2.0 / BINS as f32;
|
||||
(((cosine + 1.0) / width) as usize).min(BINS - 1)
|
||||
}
|
||||
|
||||
fn bin_centre(i: usize) -> f32 {
|
||||
let width = 2.0 / BINS as f32;
|
||||
-1.0 + (i as f32 + 0.5) * width
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Synthesise pairs from two well-separated cosine distributions, the way
|
||||
/// a real embedding space behaves: positives near 0.6, negatives near 0.05
|
||||
/// — the numbers our own end-to-end run actually produced.
|
||||
fn realistic_pairs(n_pos: u64, n_neg: u64) -> Pairs {
|
||||
let mut p = Pairs::new();
|
||||
let mut s = 12345_u32;
|
||||
let mut rand = move || {
|
||||
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
(s >> 8) as f32 / (1u32 << 24) as f32
|
||||
};
|
||||
for _ in 0..n_pos {
|
||||
// ~N(0.60, 0.12), by summing uniforms.
|
||||
let g = (0..4).map(|_| rand()).sum::<f32>() / 4.0 - 0.5;
|
||||
p.push_positive((0.60 + g * 0.48).clamp(-1.0, 1.0));
|
||||
}
|
||||
for _ in 0..n_neg {
|
||||
let g = (0..4).map(|_| rand()).sum::<f32>() / 4.0 - 0.5;
|
||||
p.push_negative((0.05 + g * 0.32).clamp(-1.0, 1.0));
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_library_has_no_valid_calibration() {
|
||||
let cal = Pairs::new().fit();
|
||||
assert!(!cal.valid, "a fit with no evidence must not claim validity");
|
||||
assert_eq!(cal.positive_pairs, 0);
|
||||
}
|
||||
|
||||
/// The exact case FR-CULL-9 legislates: enough negatives, too few
|
||||
/// positives. The answer is "unavailable", not a plausible-looking curve.
|
||||
#[test]
|
||||
fn too_few_positives_is_invalid_however_many_negatives_there_are() {
|
||||
let p = realistic_pairs(MIN_POSITIVE_PAIRS - 1, MIN_NEGATIVE_PAIRS * 10);
|
||||
assert!(!p.fit().valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_few_negatives_is_invalid_too() {
|
||||
let p = realistic_pairs(MIN_POSITIVE_PAIRS * 10, MIN_NEGATIVE_PAIRS - 1);
|
||||
assert!(!p.fit().valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_well_separated_library_fits_a_usable_curve() {
|
||||
let cal = realistic_pairs(2_000, 40_000).fit();
|
||||
assert!(cal.valid);
|
||||
assert!(cal.a > 0.0, "steepness must be positive: {}", cal.a);
|
||||
|
||||
// The decision boundary lands between the two populations.
|
||||
let boundary = cal.boundary_at(0.5, 112.0, 0.0);
|
||||
assert!(
|
||||
boundary > 0.05 && boundary < 0.60,
|
||||
"boundary {boundary} is not between the negative and positive modes"
|
||||
);
|
||||
|
||||
// And the measured cosines from the real end-to-end run fall the
|
||||
// right side of it.
|
||||
assert!(cal.probability(0.596, 200.0, 0.0) > 0.9);
|
||||
assert!(cal.probability(0.050, 200.0, 0.0) < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probability_and_boundary_are_inverses() {
|
||||
let cal = realistic_pairs(2_000, 40_000).fit();
|
||||
for &p in &[0.1_f32, 0.5, 0.9, 0.99] {
|
||||
let cos = cal.boundary_at(p, 112.0, 0.0);
|
||||
assert!((cal.probability(cos, 112.0, 0.0) - p).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
|
||||
/// A base rate shifts the answer without a refit — the property that lets
|
||||
/// one stored calibration serve a small album and a large archive.
|
||||
#[test]
|
||||
fn a_prior_moves_the_boundary_in_the_right_direction() {
|
||||
let cal = realistic_pairs(2_000, 40_000).fit();
|
||||
let neutral = cal.probability(0.4, 112.0, 0.0);
|
||||
let pessimistic = cal.probability(0.4, 112.0, -2.0);
|
||||
let optimistic = cal.probability(0.4, 112.0, 2.0);
|
||||
assert!(pessimistic < neutral && neutral < optimistic);
|
||||
}
|
||||
|
||||
/// FR-CULL-9's acceptance criterion, run against the fit's own evidence:
|
||||
/// in every populated band, the stated probability should track the
|
||||
/// observed match rate.
|
||||
#[test]
|
||||
fn the_fit_is_reliable_on_the_evidence_it_was_fitted_to() {
|
||||
let pairs = realistic_pairs(4_000, 40_000);
|
||||
let cal = pairs.fit();
|
||||
let bands = pairs.reliability(&cal, 10);
|
||||
|
||||
let mut checked = 0;
|
||||
for b in &bands {
|
||||
// Thinly populated bands are noise, not evidence.
|
||||
if b.count < 200 {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
assert!(
|
||||
(b.predicted - b.observed).abs() < 0.15,
|
||||
"band predicted {:.3} but observed {:.3} over {} pairs",
|
||||
b.predicted,
|
||||
b.observed,
|
||||
b.count
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
checked >= 2,
|
||||
"only {checked} bands had enough pairs to check"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_curve_is_the_references_and_is_not_marked_valid() {
|
||||
let cal = Calibration::default();
|
||||
assert!(!cal.valid);
|
||||
assert!((cal.boundary_at(0.5, 112.0, 0.0) - 0.267).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bins_cover_the_cosine_range_without_overflowing() {
|
||||
assert_eq!(bin(-1.0), 0);
|
||||
assert_eq!(bin(1.0), BINS - 1);
|
||||
assert_eq!(bin(2.0), BINS - 1, "an out-of-range cosine must not panic");
|
||||
assert!((bin_centre(bin(0.5)) - 0.5).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,991 @@
|
||||
//! Grouping faces into people (docs/faces.md §9, FR-CULL-10).
|
||||
//!
|
||||
//! Model-free: this is arithmetic over embeddings, and it is where the
|
||||
//! subsystem's accuracy actually lives, so it is testable with no weights on
|
||||
//! the machine.
|
||||
//!
|
||||
//! # Constraints, not just a threshold
|
||||
//!
|
||||
//! FR-CULL-10 warns that clustering will over-merge on siblings, on parents and
|
||||
//! children, and on the same person a decade apart. Two structural defences,
|
||||
//! both cheaper than a better threshold:
|
||||
//!
|
||||
//! **Cannot-link on co-occurrence.** Two faces in the same photograph are never
|
||||
//! merged. It is the same observation [`crate::calibrate`] mines for free
|
||||
//! negatives, used here as a hard constraint, and it is the single cheapest
|
||||
//! defence against over-merging that exists.
|
||||
//!
|
||||
//! **Confirmed faces are anchors.** A confirmation is user data (FR-CULL-12)
|
||||
//! and clustering never moves it. Two groups holding confirmations of
|
||||
//! *different* people cannot merge, whatever their similarity says.
|
||||
//!
|
||||
//! # Average link, not single link
|
||||
//!
|
||||
//! Single-link chains: one bad edge welds two identities together, and it is
|
||||
//! the documented way face clustering fails on families. Average link asks
|
||||
//! whether the *groups* are similar, which one outlier cannot force.
|
||||
//!
|
||||
//! # How it runs, and why the obvious way does not
|
||||
//!
|
||||
//! The first implementation of this was the textbook one: compute every
|
||||
//! pairwise cosine, then repeatedly scan all live group pairs, score each with
|
||||
//! average link, and merge the best. It is correct, it is twenty lines, and on
|
||||
//! a real library it does not finish.
|
||||
//!
|
||||
//! The reason is that the scan is inside the loop. Each merge rescans every
|
||||
//! surviving pair — `O(g²)` of them — and each score is recomputed from
|
||||
//! scratch over every cross pair, `O(|A|·|B|)`. With 1,813 faces that is
|
||||
//! roughly 1.6 million pair scores per merge and some 700 merges to do; the
|
||||
//! window simply stops responding, which is what a user reports as "Regroup is
|
||||
//! broken". At 25,000 faces it is not slow, it is impossible.
|
||||
//!
|
||||
//! Three changes, none of which alter the answer:
|
||||
//!
|
||||
//! **Only above-threshold pairs can ever matter.** An average that reaches the
|
||||
//! threshold must have at least one term at or above it, so two groups with no
|
||||
//! qualifying pair between them can never merge — not now and not after any
|
||||
//! sequence of merges, since merging only adds terms. [`crate::neighbours`]
|
||||
//! produces exactly that sparse pair list, and everything below works on it.
|
||||
//! On the library above it is 7,875 pairs rather than 1.6 million.
|
||||
//!
|
||||
//! **Merges cannot cross components.** Groups only ever merge along those
|
||||
//! pairs, so the connected components of that graph are independent problems.
|
||||
//! A library of four hundred people becomes four hundred small agglomerations
|
||||
//! instead of one large one, and the quadratic term is paid per component.
|
||||
//!
|
||||
//! **Average link is additive.** `sum(A ∪ B, C) = sum(A, C) + sum(B, C)`, so a
|
||||
//! merged group's scores follow from the two it came from by addition — the
|
||||
//! Lance-Williams update. Kept as running `(sum, count)` per adjacent pair, a
|
||||
//! score costs one division instead of a nested loop, and a binary heap with
|
||||
//! lazy invalidation replaces the rescan.
|
||||
//!
|
||||
//! The output is unchanged, deliberately and testably so: `the_fast_engine_
|
||||
//! agrees_with_the_reference` runs both over the same population and asserts
|
||||
//! the clusters are identical.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BinaryHeap, HashMap, HashSet};
|
||||
|
||||
use crate::calibrate::Calibration;
|
||||
use crate::neighbours::{self, Faces};
|
||||
|
||||
/// Probability above which two groups are judged the same person.
|
||||
///
|
||||
/// Stated as a probability and not a cosine, because FR-CULL-9 forbids
|
||||
/// thresholding a bare similarity anywhere in this subsystem.
|
||||
///
|
||||
/// # Why 0.80
|
||||
///
|
||||
/// It was 0.90, and 0.90 left most of a real library ungrouped. Measured over
|
||||
/// the 1,813-face reference library, with `dr-ui`'s `face_index --tune`:
|
||||
///
|
||||
/// | P | cosine | groups | faces grouped | largest group |
|
||||
/// |---|---|---|---|---|
|
||||
/// | 0.95 | 0.449 | 311 | 62% | 51 |
|
||||
/// | 0.90 | 0.403 | 316 | 67% | 51 |
|
||||
/// | 0.85 | 0.374 | 318 | 70% | 57 |
|
||||
/// | **0.80** | **0.353** | **328** | **74%** | **69** |
|
||||
/// | 0.75 | 0.335 | 327 | 77% | 69 |
|
||||
/// | 0.70 | 0.319 | 326 | 79% | 81 |
|
||||
/// | 0.50 | 0.267 | 303 | 85% | 90 |
|
||||
///
|
||||
/// The count of *groups* is the signal, not the count of grouped faces. Loosen
|
||||
/// from 0.95 and it climbs: real people are being assembled out of fragments.
|
||||
/// It peaks at 0.80 and then falls, and a falling group count while the grouped
|
||||
/// faces keep rising is the shape of over-merging — separate identities being
|
||||
/// welded, which is the failure FR-CULL-10 warns about and the one the user
|
||||
/// cannot easily undo by hand.
|
||||
///
|
||||
/// So: the loosest setting that is still building people rather than melting
|
||||
/// them together. A third more of the library gets grouped than at 0.90, and
|
||||
/// the largest group grows by eighteen faces rather than by forty.
|
||||
///
|
||||
/// This is a *default*, not a constant of nature — the numbers above are one
|
||||
/// library, and `--tune` reruns the table on any other.
|
||||
pub const DEFAULT_MERGE_PROBABILITY: f32 = 0.80;
|
||||
|
||||
/// A face presented to the clusterer.
|
||||
///
|
||||
/// Ids are opaque `u64`s rather than catalog types: this crate has no business
|
||||
/// knowing what a `FaceId` means, and the caller does the translation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Candidate {
|
||||
pub face: u64,
|
||||
/// Which photograph it came from — the cannot-link key.
|
||||
pub image: u64,
|
||||
/// L2-normalised, `EMBEDDING_DIM` long.
|
||||
pub embedding: Vec<f32>,
|
||||
/// Source pixels across the aligned crop, for the calibration's size term.
|
||||
pub crop_px: f32,
|
||||
/// The person this face is *confirmed* to be, if any.
|
||||
///
|
||||
/// Suggestions are deliberately not passed here. They are this function's
|
||||
/// own previous output, and feeding them back in would let a guess harden
|
||||
/// into a fact across successive passes.
|
||||
pub confirmed_person: Option<u64>,
|
||||
}
|
||||
|
||||
/// One group of faces the clusterer believes are one person.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Cluster {
|
||||
/// Indices into the input slice.
|
||||
pub members: Vec<usize>,
|
||||
/// The person this group is already known to be, from its anchors.
|
||||
///
|
||||
/// `Some` means the group contains confirmed faces and the suggestions in
|
||||
/// it attach to that existing person. `None` is a new unnamed group.
|
||||
pub person: Option<u64>,
|
||||
}
|
||||
|
||||
/// Group faces into people.
|
||||
///
|
||||
/// `min_probability` is compared against the calibrated average-link
|
||||
/// probability between two groups. Deterministic: the same input yields the
|
||||
/// same clusters, because the merge order is by score with the index pair as
|
||||
/// the tiebreak.
|
||||
pub fn cluster(faces: &[Candidate], cal: &Calibration, min_probability: f32) -> Vec<Cluster> {
|
||||
if faces.is_empty() {
|
||||
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,
|
||||
};
|
||||
|
||||
// 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 mut engine = Engine::new(faces, cal, min_probability);
|
||||
for component in components(faces.len(), &pairs) {
|
||||
engine.agglomerate(&component, &pairs);
|
||||
}
|
||||
engine.finish()
|
||||
}
|
||||
|
||||
/// Split one person's faces into the groups a raised threshold separates them
|
||||
/// into.
|
||||
///
|
||||
/// FR-CULL-10 requires splitting to be as easy as merging, and a split that
|
||||
/// hands the user a pile of loose faces to re-sort is not that. This re-runs
|
||||
/// the same agglomeration at a stricter probability so the user is offered
|
||||
/// coherent sub-groups to pull apart.
|
||||
///
|
||||
/// Anchors are ignored here on purpose: every face in the input is already
|
||||
/// nominally the same person, so honouring the anchors would refuse to split
|
||||
/// anything.
|
||||
pub fn split(faces: &[Candidate], cal: &Calibration, min_probability: f32) -> Vec<Cluster> {
|
||||
let anchorless: Vec<Candidate> = faces
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut f| {
|
||||
f.confirmed_person = None;
|
||||
f
|
||||
})
|
||||
.collect();
|
||||
cluster(&anchorless, cal, min_probability)
|
||||
}
|
||||
|
||||
// ── the merge engine ──────────────────────────────────────────────────────
|
||||
|
||||
/// One live group, identified throughout by the index of its lowest member.
|
||||
#[derive(Debug)]
|
||||
struct Group {
|
||||
/// Ascending, always — [`Engine::cross`] sums in this order, and a stable
|
||||
/// order is what makes the floating-point total reproducible.
|
||||
members: Vec<usize>,
|
||||
images: HashSet<u64>,
|
||||
person: Option<u64>,
|
||||
alive: bool,
|
||||
/// Bumped on every merge, so heap entries naming an older state can be
|
||||
/// recognised and dropped instead of acted on.
|
||||
version: u64,
|
||||
}
|
||||
|
||||
/// Running average-link state for one adjacent pair of groups.
|
||||
///
|
||||
/// `sum` is over **every** cross pair, not only the above-threshold ones —
|
||||
/// average link is an average over all of them, and counting only the
|
||||
/// qualifying pairs would report a similarity no group actually has.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Link {
|
||||
sum: f64,
|
||||
count: f64,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
fn probability(&self) -> f32 {
|
||||
if self.count == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.sum / self.count) as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate merge, waiting in the heap.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Pending {
|
||||
probability: f32,
|
||||
a: usize,
|
||||
b: usize,
|
||||
/// Group versions when this was pushed. A mismatch on pop means a merge
|
||||
/// has happened since and a fresher entry for this pair is already queued.
|
||||
va: u64,
|
||||
vb: u64,
|
||||
}
|
||||
|
||||
impl PartialEq for Pending {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.cmp(other) == Ordering::Equal
|
||||
}
|
||||
}
|
||||
impl Eq for Pending {}
|
||||
impl PartialOrd for Pending {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
impl Ord for Pending {
|
||||
/// Greatest pops first, so: highest probability, and on a tie the lowest
|
||||
/// index pair. That tiebreak is not cosmetic — it is what the old
|
||||
/// ascending scan did, and it is the whole of the determinism guarantee.
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.probability
|
||||
.total_cmp(&other.probability)
|
||||
.then_with(|| other.a.cmp(&self.a))
|
||||
.then_with(|| other.b.cmp(&self.b))
|
||||
}
|
||||
}
|
||||
|
||||
struct Engine<'a> {
|
||||
faces: &'a [Candidate],
|
||||
cal: &'a Calibration,
|
||||
min_probability: f32,
|
||||
groups: Vec<Group>,
|
||||
links: HashMap<(usize, usize), Link>,
|
||||
/// Adjacency, as group ids. Kept alongside `links` so a merge can find
|
||||
/// everything it has to update without scanning the whole map.
|
||||
adjacent: Vec<HashSet<usize>>,
|
||||
}
|
||||
|
||||
impl<'a> Engine<'a> {
|
||||
fn new(faces: &'a [Candidate], cal: &'a Calibration, min_probability: f32) -> Self {
|
||||
let groups = faces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| Group {
|
||||
members: vec![i],
|
||||
images: HashSet::from([f.image]),
|
||||
person: f.confirmed_person,
|
||||
alive: true,
|
||||
version: 0,
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
faces,
|
||||
cal,
|
||||
min_probability,
|
||||
groups,
|
||||
links: HashMap::new(),
|
||||
adjacent: vec![HashSet::new(); faces.len()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Agglomerate one connected component to exhaustion.
|
||||
fn agglomerate(&mut self, component: &[usize], pairs: &[neighbours::Pair]) {
|
||||
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)) {
|
||||
self.links.insert(
|
||||
key(p.i, p.j),
|
||||
Link {
|
||||
sum: p.probability as f64,
|
||||
count: 1.0,
|
||||
},
|
||||
);
|
||||
self.adjacent[p.i].insert(p.j);
|
||||
self.adjacent[p.j].insert(p.i);
|
||||
heap.push(Pending {
|
||||
probability: p.probability,
|
||||
a: p.i.min(p.j),
|
||||
b: p.i.max(p.j),
|
||||
va: 0,
|
||||
vb: 0,
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(top) = heap.pop() {
|
||||
let Pending {
|
||||
probability,
|
||||
a,
|
||||
b,
|
||||
va,
|
||||
vb,
|
||||
} = top;
|
||||
|
||||
// Stale: one side has merged since this was queued, and the
|
||||
// replacement entry is already in the heap.
|
||||
if !self.groups[a].alive
|
||||
|| !self.groups[b].alive
|
||||
|| self.groups[a].version != va
|
||||
|| self.groups[b].version != vb
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// The heap is ordered by probability, so the first entry below the
|
||||
// bar means nothing left in this component can reach it.
|
||||
if probability < self.min_probability {
|
||||
break;
|
||||
}
|
||||
if !self.can_link(a, b) {
|
||||
// Never becomes possible again: images only accumulate and an
|
||||
// anchor is never given up, so drop the pair for good.
|
||||
self.unlink(a, b);
|
||||
continue;
|
||||
}
|
||||
self.merge(a, b, &mut heap);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two groups are allowed to merge at all, before similarity is
|
||||
/// asked.
|
||||
fn can_link(&self, a: usize, b: usize) -> bool {
|
||||
let (ga, gb) = (&self.groups[a], &self.groups[b]);
|
||||
// Two confirmations of different people. The user has said these are
|
||||
// not the same person, and no similarity overrides that.
|
||||
if let (Some(pa), Some(pb)) = (ga.person, gb.person) {
|
||||
if pa != pb {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Co-occurrence: a photograph containing a face from each group means
|
||||
// the two faces are in the same frame, so they are not the same person.
|
||||
ga.images.is_disjoint(&gb.images)
|
||||
}
|
||||
|
||||
/// Fold `b` into `a` and re-score everything that touched either.
|
||||
fn merge(&mut self, a: usize, b: usize, heap: &mut BinaryHeap<Pending>) {
|
||||
// Sorted, and deduplicated by the set: `a` and `b` may share
|
||||
// neighbours, and each must be visited once. Sorting is what keeps the
|
||||
// floating-point sums identical from run to run.
|
||||
let mut touched: Vec<usize> = self.adjacent[a]
|
||||
.union(&self.adjacent[b])
|
||||
.copied()
|
||||
.filter(|&c| c != a && c != b && self.groups[c].alive)
|
||||
.collect();
|
||||
touched.sort_unstable();
|
||||
|
||||
// Take the pair sums before the groups change underneath them.
|
||||
let carried: Vec<(usize, Option<Link>, Option<Link>)> = touched
|
||||
.iter()
|
||||
.map(|&c| {
|
||||
(
|
||||
c,
|
||||
self.links.get(&key(a, c)).copied(),
|
||||
self.links.get(&key(b, c)).copied(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// a's own members, before b's are folded in. A missing a-side sum has
|
||||
// to be computed over these and not over the merged list, or b's
|
||||
// contribution would be counted twice.
|
||||
let a_members = self.groups[a].members.clone();
|
||||
|
||||
// Absorb b into a.
|
||||
let taken = std::mem::replace(
|
||||
&mut self.groups[b],
|
||||
Group {
|
||||
members: Vec::new(),
|
||||
images: HashSet::new(),
|
||||
person: None,
|
||||
alive: false,
|
||||
version: 0,
|
||||
},
|
||||
);
|
||||
{
|
||||
let ga = &mut self.groups[a];
|
||||
ga.members.extend(taken.members.iter().copied());
|
||||
ga.members.sort_unstable();
|
||||
ga.images.extend(taken.images.iter().copied());
|
||||
// At most one side carries a person: `can_link` refuses a merge of
|
||||
// two groups anchored to different people, so this cannot silently
|
||||
// discard one of them.
|
||||
ga.person = ga.person.or(taken.person);
|
||||
ga.version += 1;
|
||||
}
|
||||
|
||||
// b's own links are gone with it.
|
||||
for c in self.adjacent[b].clone() {
|
||||
self.links.remove(&key(b, c));
|
||||
self.adjacent[c].remove(&b);
|
||||
}
|
||||
self.adjacent[b].clear();
|
||||
self.links.remove(&key(a, b));
|
||||
self.adjacent[a].remove(&b);
|
||||
|
||||
for (c, from_a, from_b) in carried {
|
||||
// Dropping a pair the constraints now forbid saves computing a
|
||||
// score for a merge that can never happen — which for a newly
|
||||
// adjacent side is a real cost, not a bookkeeping one.
|
||||
if !self.can_link(a, c) {
|
||||
self.unlink(a, c);
|
||||
continue;
|
||||
}
|
||||
// A side with no stored link was not adjacent before, so its cross
|
||||
// pairs were all below threshold and were never summed. They still
|
||||
// belong in the average, so they are computed now — once, after
|
||||
// which the additive update carries them forward.
|
||||
let from_a = from_a.unwrap_or_else(|| self.cross(&a_members, c));
|
||||
let from_b = from_b.unwrap_or_else(|| self.cross(&taken.members, c));
|
||||
let merged = Link {
|
||||
sum: from_a.sum + from_b.sum,
|
||||
count: from_a.count + from_b.count,
|
||||
};
|
||||
self.links.insert(key(a, c), merged);
|
||||
self.adjacent[a].insert(c);
|
||||
self.adjacent[c].insert(a);
|
||||
|
||||
heap.push(Pending {
|
||||
probability: merged.probability(),
|
||||
a: a.min(c),
|
||||
b: a.max(c),
|
||||
va: self.groups[a.min(c)].version,
|
||||
vb: self.groups[a.max(c)].version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact `(sum, count)` over every cross pair between a member list and a
|
||||
/// group.
|
||||
///
|
||||
/// The one place a dot product is still computed during agglomeration, and
|
||||
/// it happens only when two groups become adjacent through a third — at
|
||||
/// which point their sub-threshold pairs, never summed because they were
|
||||
/// never interesting, have to be accounted for.
|
||||
fn cross(&self, members: &[usize], group: usize) -> Link {
|
||||
let mut sum = 0.0_f64;
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
Link { sum, count }
|
||||
}
|
||||
|
||||
fn unlink(&mut self, a: usize, b: usize) {
|
||||
self.links.remove(&key(a, b));
|
||||
self.adjacent[a].remove(&b);
|
||||
self.adjacent[b].remove(&a);
|
||||
}
|
||||
|
||||
fn finish(self) -> Vec<Cluster> {
|
||||
let mut out: Vec<Cluster> = self
|
||||
.groups
|
||||
.into_iter()
|
||||
.filter(|g| g.alive)
|
||||
.map(|g| Cluster {
|
||||
members: g.members,
|
||||
person: g.person,
|
||||
})
|
||||
.collect();
|
||||
// Largest first: the People view shows the best-evidenced groups at the
|
||||
// top.
|
||||
out.sort_by(|x, y| {
|
||||
y.members
|
||||
.len()
|
||||
.cmp(&x.members.len())
|
||||
.then(x.members[0].cmp(&y.members[0]))
|
||||
});
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn key(a: usize, b: usize) -> (usize, usize) {
|
||||
if a < b {
|
||||
(a, b)
|
||||
} else {
|
||||
(b, a)
|
||||
}
|
||||
}
|
||||
|
||||
/// Connected components of the above-threshold graph.
|
||||
///
|
||||
/// Faces in different components can never end up in one group, so each is a
|
||||
/// 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>> {
|
||||
let mut parent: Vec<usize> = (0..n).collect();
|
||||
|
||||
fn find(parent: &mut [usize], mut x: usize) -> usize {
|
||||
while parent[x] != x {
|
||||
// Path halving: keeps the tree flat without a second pass.
|
||||
parent[x] = parent[parent[x]];
|
||||
x = parent[x];
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
for p in pairs {
|
||||
let (ra, rb) = (find(&mut parent, p.i), find(&mut parent, p.j));
|
||||
if ra != rb {
|
||||
// Lowest root wins, so the representative of a component is
|
||||
// reproducible rather than an artefact of union order.
|
||||
let (lo, hi) = if ra < rb { (ra, rb) } else { (rb, ra) };
|
||||
parent[hi] = lo;
|
||||
}
|
||||
}
|
||||
|
||||
let mut by_root: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
for i in 0..n {
|
||||
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
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::embedding::EMBEDDING_DIM;
|
||||
|
||||
/// An embedding a known cosine away from a base direction, built by mixing
|
||||
/// two orthogonal unit vectors. Lets a test state "these two faces are 0.7
|
||||
/// similar" and have it be exactly true.
|
||||
fn at_cosine(identity: usize, cosine: f32) -> Vec<f32> {
|
||||
let mut v = vec![0.0_f32; EMBEDDING_DIM];
|
||||
let base = identity * 2;
|
||||
let perp = identity * 2 + 1;
|
||||
v[base] = cosine;
|
||||
v[perp] = (1.0 - cosine * cosine).max(0.0).sqrt();
|
||||
v
|
||||
}
|
||||
|
||||
fn candidate(face: u64, image: u64, identity: usize, cosine: f32) -> Candidate {
|
||||
Candidate {
|
||||
face,
|
||||
image,
|
||||
embedding: at_cosine(identity, cosine),
|
||||
crop_px: 150.0,
|
||||
confirmed_person: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A calibration steep enough that the test's cosines are unambiguous:
|
||||
/// 0.6 is near-certain, 0.1 is near-impossible.
|
||||
fn cal() -> Calibration {
|
||||
Calibration {
|
||||
a: 30.0,
|
||||
b: -30.0 * 0.35,
|
||||
w_size: 0.0,
|
||||
valid: true,
|
||||
positive_pairs: 1000,
|
||||
negative_pairs: 10_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_faces_makes_no_clusters() {
|
||||
assert!(cluster(&[], &cal(), DEFAULT_MERGE_PROBABILITY).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similar_faces_from_different_photographs_group_together() {
|
||||
let faces = vec![
|
||||
candidate(1, 10, 0, 1.0),
|
||||
candidate(2, 11, 0, 0.95),
|
||||
candidate(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]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dissimilar_faces_stay_apart() {
|
||||
let faces = vec![candidate(1, 10, 0, 1.0), candidate(2, 11, 1, 1.0)];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
/// The cheapest defence against over-merging: two faces in one frame are
|
||||
/// not the same person however similar the model finds them.
|
||||
#[test]
|
||||
fn two_faces_in_one_photograph_never_merge() {
|
||||
// Identical embeddings — siblings, or a model that cannot tell them
|
||||
// apart — but both in image 10.
|
||||
let faces = vec![candidate(1, 10, 0, 1.0), candidate(2, 10, 0, 1.0)];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(out.len(), 2, "co-occurring faces were merged");
|
||||
}
|
||||
|
||||
/// And the constraint has to survive transitively: once a group holds a
|
||||
/// face from image 10, no other group holding one from image 10 may join
|
||||
/// it, even indirectly.
|
||||
#[test]
|
||||
fn the_co_occurrence_constraint_propagates_through_a_group() {
|
||||
let faces = vec![
|
||||
candidate(1, 10, 0, 1.0), // A, in the group photo
|
||||
candidate(2, 10, 0, 1.0), // B, in the same group photo
|
||||
candidate(3, 11, 0, 0.99), // A again, alone
|
||||
];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(out.len(), 2);
|
||||
// Whichever of A/B absorbed face 2, the other stays out.
|
||||
assert!(out.iter().any(|c| c.members.len() == 2));
|
||||
assert!(out.iter().any(|c| c.members.len() == 1));
|
||||
}
|
||||
|
||||
/// FR-CULL-10: a confirmation is user data and no inference overrides it.
|
||||
#[test]
|
||||
fn groups_confirmed_as_different_people_do_not_merge() {
|
||||
let mut a = candidate(1, 10, 0, 1.0);
|
||||
let mut b = candidate(2, 11, 0, 1.0);
|
||||
a.confirmed_person = Some(100);
|
||||
b.confirmed_person = Some(200);
|
||||
let out = cluster(&[a, b], &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(out.len(), 2, "clustering overrode two user confirmations");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_suggestion_joins_the_person_its_group_is_anchored_to() {
|
||||
let mut anchor = candidate(1, 10, 0, 1.0);
|
||||
anchor.confirmed_person = Some(42);
|
||||
let loose = candidate(2, 11, 0, 0.95);
|
||||
let out = cluster(&[anchor, loose], &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].person, Some(42));
|
||||
assert_eq!(out[0].members.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unanchored_group_is_a_new_unnamed_person() {
|
||||
let out = cluster(
|
||||
&[candidate(1, 10, 0, 1.0), candidate(2, 11, 0, 0.95)],
|
||||
&cal(),
|
||||
DEFAULT_MERGE_PROBABILITY,
|
||||
);
|
||||
assert_eq!(out[0].person, None);
|
||||
}
|
||||
|
||||
/// Average link rather than single link: one strong edge must not weld two
|
||||
/// otherwise-dissimilar groups together. This is the family failure mode
|
||||
/// FR-CULL-10 names.
|
||||
#[test]
|
||||
fn one_strong_edge_does_not_chain_two_groups_together() {
|
||||
// Two tight pairs, with a single borderline link between them.
|
||||
let faces = vec![
|
||||
candidate(1, 10, 0, 1.00),
|
||||
candidate(2, 11, 0, 0.99),
|
||||
candidate(3, 12, 0, 0.42),
|
||||
candidate(4, 13, 0, 0.40),
|
||||
];
|
||||
let out = cluster(&faces, &cal(), 0.99);
|
||||
assert!(
|
||||
out.len() >= 2,
|
||||
"single-link chaining merged everything into {} cluster(s)",
|
||||
out.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clustering_is_deterministic() {
|
||||
let faces = vec![
|
||||
candidate(1, 10, 0, 1.0),
|
||||
candidate(2, 11, 0, 0.96),
|
||||
candidate(3, 12, 1, 1.0),
|
||||
candidate(4, 13, 1, 0.97),
|
||||
candidate(5, 14, 0, 0.94),
|
||||
];
|
||||
let a = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
let b = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clusters_come_back_largest_first() {
|
||||
let faces = vec![
|
||||
candidate(1, 10, 1, 1.0),
|
||||
candidate(2, 11, 0, 1.0),
|
||||
candidate(3, 12, 0, 0.97),
|
||||
candidate(4, 13, 0, 0.95),
|
||||
];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(out[0].members.len(), 3);
|
||||
assert_eq!(out[1].members.len(), 1);
|
||||
}
|
||||
|
||||
/// Splitting is the inverse operation and must actually separate a group
|
||||
/// that a looser threshold had merged.
|
||||
#[test]
|
||||
fn split_separates_a_group_that_a_looser_threshold_merged() {
|
||||
let faces = vec![
|
||||
candidate(1, 10, 0, 1.00),
|
||||
candidate(2, 11, 0, 0.98),
|
||||
candidate(3, 12, 0, 0.45),
|
||||
candidate(4, 13, 0, 0.43),
|
||||
];
|
||||
// Loose: one person.
|
||||
assert_eq!(cluster(&faces, &cal(), 0.5).len(), 1);
|
||||
// Strict: the two sub-groups the user wants offered.
|
||||
let parts = split(&faces, &cal(), 0.999);
|
||||
assert!(parts.len() >= 2, "split produced {} group(s)", parts.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_ignores_the_anchor_so_a_mislabelled_person_can_be_taken_apart() {
|
||||
let mut a = candidate(1, 10, 0, 1.0);
|
||||
let mut b = candidate(2, 11, 0, 0.40);
|
||||
a.confirmed_person = Some(7);
|
||||
b.confirmed_person = Some(7);
|
||||
let parts = split(&[a, b], &cal(), 0.99);
|
||||
assert_eq!(parts.len(), 2);
|
||||
}
|
||||
|
||||
/// The size term earns its place: the same cosine between two thumbnail-
|
||||
/// sized faces should be less convincing than between two large ones.
|
||||
#[test]
|
||||
fn the_face_size_term_moves_the_probability() {
|
||||
let sized = Calibration {
|
||||
w_size: 0.5,
|
||||
b: -30.0 * 0.35 - 0.5 * 7.0,
|
||||
..cal()
|
||||
};
|
||||
let big = sized.probability(0.5, 300.0, 0.0);
|
||||
let small = sized.probability(0.5, 40.0, 0.0);
|
||||
assert!(big > small, "big {big} should beat small {small}");
|
||||
}
|
||||
|
||||
// ── the fast engine against the obvious one ───────────────────────────
|
||||
|
||||
/// The original implementation, kept as the oracle.
|
||||
///
|
||||
/// Deliberately the naive version this module replaced: rescan every live
|
||||
/// pair, score it from scratch over all cross pairs, merge the best,
|
||||
/// repeat. It is the definition of the answer, and the only thing the
|
||||
/// rewrite was allowed to change is how long it takes to get there.
|
||||
fn reference(faces: &[Candidate], cal: &Calibration, min_probability: f32) -> Vec<Cluster> {
|
||||
#[derive(Clone)]
|
||||
struct G {
|
||||
members: Vec<usize>,
|
||||
images: HashSet<u64>,
|
||||
person: Option<u64>,
|
||||
alive: bool,
|
||||
}
|
||||
if faces.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let n = faces.len();
|
||||
let mut groups: Vec<G> = faces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| G {
|
||||
members: vec![i],
|
||||
images: HashSet::from([f.image]),
|
||||
person: f.confirmed_person,
|
||||
alive: true,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut cos = vec![0.0_f32; n * n];
|
||||
for i in 0..n {
|
||||
for j in i + 1..n {
|
||||
let c = neighbours::dot(&faces[i].embedding, &faces[j].embedding);
|
||||
cos[i * n + j] = c;
|
||||
cos[j * n + i] = c;
|
||||
}
|
||||
}
|
||||
let linkable = |a: &G, b: &G| {
|
||||
if let (Some(pa), Some(pb)) = (a.person, b.person) {
|
||||
if pa != pb {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
a.images.is_disjoint(&b.images)
|
||||
};
|
||||
let average = |a: &G, b: &G| {
|
||||
let mut sum = 0.0_f32;
|
||||
let mut count = 0.0_f32;
|
||||
for &i in &a.members {
|
||||
for &j in &b.members {
|
||||
let min_crop = faces[i].crop_px.min(faces[j].crop_px);
|
||||
sum += cal.probability(cos[i * n + j], min_crop, 0.0);
|
||||
count += 1.0;
|
||||
}
|
||||
}
|
||||
if count == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
sum / count
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
let mut best: Option<(f32, usize, usize)> = None;
|
||||
for a in 0..n {
|
||||
if !groups[a].alive {
|
||||
continue;
|
||||
}
|
||||
for b in a + 1..n {
|
||||
if !groups[b].alive || !linkable(&groups[a], &groups[b]) {
|
||||
continue;
|
||||
}
|
||||
let p = average(&groups[a], &groups[b]);
|
||||
if p >= min_probability && best.is_none_or(|(bp, _, _)| p > bp) {
|
||||
best = Some((p, a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((_, a, b)) = best else { break };
|
||||
let taken = groups[b].clone();
|
||||
groups[b].alive = false;
|
||||
groups[a].members.extend(taken.members);
|
||||
groups[a].images.extend(taken.images);
|
||||
groups[a].person = groups[a].person.or(taken.person);
|
||||
}
|
||||
|
||||
let mut out: Vec<Cluster> = groups
|
||||
.into_iter()
|
||||
.filter(|g| g.alive)
|
||||
.map(|mut g| {
|
||||
g.members.sort_unstable();
|
||||
Cluster {
|
||||
members: g.members,
|
||||
person: g.person,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|x, y| {
|
||||
y.members
|
||||
.len()
|
||||
.cmp(&x.members.len())
|
||||
.then(x.members[0].cmp(&y.members[0]))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// `people` identities of `per` faces, each face in its own photograph,
|
||||
/// spread either side of the threshold so the population has genuine
|
||||
/// near-misses rather than obvious answers.
|
||||
fn population(people: usize, per: usize) -> Vec<Candidate> {
|
||||
let mut out = Vec::new();
|
||||
let mut image = 0u64;
|
||||
for p in 0..people {
|
||||
for m in 0..per {
|
||||
// Walks down through the merge boundary as m grows, so some
|
||||
// members join their group and some do not.
|
||||
let cosine = 1.0 - (m as f32) * 0.035;
|
||||
out.push(Candidate {
|
||||
face: out.len() as u64,
|
||||
image,
|
||||
embedding: at_cosine(p, cosine),
|
||||
crop_px: 60.0 + ((out.len() % 11) as f32) * 25.0,
|
||||
confirmed_person: None,
|
||||
});
|
||||
image += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The point of the rewrite: same clusters, less work. A disagreement here
|
||||
/// is the rewrite being wrong, not the reference being slow.
|
||||
#[test]
|
||||
fn the_fast_engine_agrees_with_the_reference() {
|
||||
let faces = population(40, 6);
|
||||
assert_eq!(
|
||||
cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY),
|
||||
reference(&faces, &cal(), DEFAULT_MERGE_PROBABILITY),
|
||||
);
|
||||
}
|
||||
|
||||
/// The two structural constraints are the ones a sparse graph could
|
||||
/// plausibly break, so they get their own comparison with anchors and
|
||||
/// co-occurrence in play.
|
||||
#[test]
|
||||
fn the_fast_engine_agrees_with_the_reference_under_constraints() {
|
||||
let mut faces = population(30, 6);
|
||||
// Some faces share a photograph, so cannot-link has to propagate
|
||||
// through groups that formed for other reasons.
|
||||
for i in (0..faces.len()).step_by(7) {
|
||||
faces[i].image = 900 + (i as u64 % 4);
|
||||
}
|
||||
// And some carry confirmations, including two of different people that
|
||||
// must never be brought together.
|
||||
for (n, i) in (0..faces.len()).step_by(11).enumerate() {
|
||||
faces[i].confirmed_person = Some(1 + (n as u64 % 3));
|
||||
}
|
||||
assert_eq!(
|
||||
cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY),
|
||||
reference(&faces, &cal(), DEFAULT_MERGE_PROBABILITY),
|
||||
);
|
||||
}
|
||||
|
||||
/// The size term makes the merge boundary depend on the pair, which is the
|
||||
/// case the sparse pre-filter has to be built carefully to preserve.
|
||||
#[test]
|
||||
fn the_fast_engine_agrees_with_the_reference_with_a_size_term() {
|
||||
let faces = population(30, 6);
|
||||
let sized = Calibration {
|
||||
w_size: 0.4,
|
||||
b: -30.0 * 0.35 - 0.4 * 7.0,
|
||||
..cal()
|
||||
};
|
||||
assert_eq!(
|
||||
cluster(&faces, &sized, DEFAULT_MERGE_PROBABILITY),
|
||||
reference(&faces, &sized, DEFAULT_MERGE_PROBABILITY),
|
||||
);
|
||||
}
|
||||
|
||||
/// Determinism has to hold at a size where the indexed neighbour search is
|
||||
/// in play, not just on the handful of faces the small cases use.
|
||||
#[test]
|
||||
fn clustering_is_deterministic_at_scale() {
|
||||
let faces = population(200, 6);
|
||||
assert!(
|
||||
faces.len() > 1024,
|
||||
"population is below the indexing cutoff"
|
||||
);
|
||||
assert_eq!(
|
||||
cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY),
|
||||
cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY),
|
||||
);
|
||||
}
|
||||
|
||||
/// A face that matches nobody is left alone rather than being swept into
|
||||
/// the nearest group, and costs nothing to establish — it is in no
|
||||
/// component at all.
|
||||
#[test]
|
||||
fn a_face_matching_nothing_stays_on_its_own() {
|
||||
let mut faces = population(5, 4);
|
||||
faces.push(Candidate {
|
||||
face: 999,
|
||||
image: 5_000,
|
||||
embedding: at_cosine(200, 1.0),
|
||||
crop_px: 150.0,
|
||||
confirmed_person: None,
|
||||
});
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
let last = faces.len() - 1;
|
||||
assert!(
|
||||
out.iter().any(|c| c.members == vec![last]),
|
||||
"the outlier was absorbed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
//! SCRFD face detection (docs/faces.md §4).
|
||||
//!
|
||||
//! One forward pass produces a box, a confidence and **five landmarks** per
|
||||
//! face — the landmarks being the reason for this detector rather than a
|
||||
//! general one, since [`crate::align`] cannot work without them.
|
||||
//!
|
||||
//! # The graph must have fixed input dimensions
|
||||
//!
|
||||
//! InsightFace ships `det_500m.onnx` with a dynamic H/W input, and **tract
|
||||
//! cannot parse it in that form** — it fails at node #0. The same file run
|
||||
//! through `tools/fix-face-model-shapes.sh` loads cleanly. Its outputs were
|
||||
//! already static at 640, so 640 is not a choice made here: it is the shape
|
||||
//! the export was always going to run at.
|
||||
|
||||
use ndarray::Array4;
|
||||
|
||||
use crate::{install_backend, FaceError};
|
||||
|
||||
/// The graph's input edge, in pixels. See the module note: not configurable.
|
||||
pub const INPUT_EDGE: usize = 640;
|
||||
|
||||
/// Strides, in the order SCRFD emits them.
|
||||
const ALL_STRIDES: [usize; 4] = [8, 16, 32, 64];
|
||||
|
||||
/// Anchors per feature-map location.
|
||||
const ANCHORS: usize = 2;
|
||||
|
||||
/// How detection is tuned.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct DetectOptions {
|
||||
/// Minimum detector confidence.
|
||||
///
|
||||
/// Deliberately *not* the low threshold `dr-segment` chose. There a false
|
||||
/// positive costs one spurious row in a list the user is picking from;
|
||||
/// here it costs a face in the People view to reject and — worse — a
|
||||
/// garbage embedding that can bridge two real clusters into one. A false
|
||||
/// negative is recoverable by re-indexing with a better model; a polluted
|
||||
/// cluster graph, once the user has confirmed faces inside it, is not.
|
||||
pub confidence: f32,
|
||||
/// Box IoU above which two detections are judged to be the same face.
|
||||
pub nms_iou: f32,
|
||||
/// Cheap pre-filter: smallest box to keep, in source pixels on the shorter
|
||||
/// edge.
|
||||
///
|
||||
/// **Not the real size floor** — [`DetectOptions::min_source_px`] is, and
|
||||
/// it is measured on the aligned crop rather than the box. This one exists
|
||||
/// only to throw away the obviously hopeless before paying for a warp, so
|
||||
/// it is deliberately set *below* what the real floor will accept: the
|
||||
/// aligned crop spans roughly 1.3x the box's shorter edge, so 24 here
|
||||
/// cannot reject a face that would have cleared 32 there.
|
||||
pub min_face_px: f32,
|
||||
/// Smallest face the embedder may be given, in **source pixels across the
|
||||
/// aligned crop** — `crop_px` in the catalog.
|
||||
///
|
||||
/// The honest statement of "a face must be at least 32x32", because this is
|
||||
/// the number of real pixels behind the 112x112 the model actually sees.
|
||||
/// The box's own size is not that: the ArcFace template reaches past the
|
||||
/// box for forehead and chin, so a 64-pixel box and a 64-pixel crop are
|
||||
/// different faces.
|
||||
///
|
||||
/// Below this the crop was upsampled to reach the embedder, and upsampling
|
||||
/// invents no detail — the embedding is of a soft, stretched face and is
|
||||
/// correspondingly untrustworthy.
|
||||
///
|
||||
/// Applied after alignment, so it lives with the sharpness floor rather
|
||||
/// than with the detector. See [`DetectOptions::min_sharpness`].
|
||||
pub min_source_px: f32,
|
||||
/// Least acceptable [`crate::align::Aligned112::sharpness`].
|
||||
///
|
||||
/// Applied after alignment rather than here, because it is a property of
|
||||
/// the warped crop the embedder receives and not of the box. The pipeline
|
||||
/// that enforces it is `dr_ui::faces::index_proxy`; it lives on this struct
|
||||
/// so that every quality decision about a face is configured in one place
|
||||
/// and a caller cannot enable one gate while forgetting the other.
|
||||
///
|
||||
/// Zero disables it, which is what a measurement run wants.
|
||||
///
|
||||
/// # It has to move with the size floor
|
||||
///
|
||||
/// The two are coupled, because an upsampled face scores low here whatever
|
||||
/// its original sharpness. Measured over the reference library, with the
|
||||
/// size floor at 32 source pixels:
|
||||
///
|
||||
/// | min sharpness | of what the size floor left, this removes |
|
||||
/// |---|---|
|
||||
/// | 0.002 | 3% |
|
||||
/// | 0.005 | 8% |
|
||||
/// | 0.010 | 16% |
|
||||
/// | 0.020 | 27% |
|
||||
///
|
||||
/// At a 64-pixel floor, 0.020 removed 7% — the same *kind* of face, the
|
||||
/// large-but-soft one this gate exists for. Holding 0.020 while dropping
|
||||
/// the size floor to 32 would have thrown away a quarter of the newly
|
||||
/// admitted faces for being small rather than for being blurred, undoing
|
||||
/// most of the point of lowering it. 0.005 removes 8% at 32, which is the
|
||||
/// same job.
|
||||
pub min_sharpness: f32,
|
||||
}
|
||||
|
||||
impl Default for DetectOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
confidence: 0.5,
|
||||
nms_iou: 0.4,
|
||||
min_face_px: 24.0,
|
||||
min_source_px: 32.0,
|
||||
min_sharpness: 0.005,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One detected face, in **source image pixels**.
|
||||
///
|
||||
/// Pixels rather than the normalised form the catalog stores, because the
|
||||
/// caller still has to crop from this image. Normalisation happens at the
|
||||
/// storage boundary, where the long edge is known to be the right divisor.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Detection {
|
||||
/// `(x0, y0, x1, y1)`.
|
||||
pub bbox: (f32, f32, f32, f32),
|
||||
/// Five points in the detector's own order — see [`crate::align`], which
|
||||
/// consumes them without reordering.
|
||||
pub landmarks: [(f32, f32); 5],
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
impl Detection {
|
||||
pub fn width(&self) -> f32 {
|
||||
self.bbox.2 - self.bbox.0
|
||||
}
|
||||
pub fn height(&self) -> f32 {
|
||||
self.bbox.3 - self.bbox.1
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded SCRFD graph.
|
||||
pub struct Detector {
|
||||
session: ort::session::Session,
|
||||
/// Feature-map count: 3 for strides {8,16,32}, 4 for {8,16,32,64}.
|
||||
///
|
||||
/// Discovered from the output count rather than assumed, because both
|
||||
/// exports exist and hardcoding 3 silently ignores the largest faces a
|
||||
/// four-stride model finds.
|
||||
fmc: usize,
|
||||
}
|
||||
|
||||
impl Detector {
|
||||
pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self, FaceError> {
|
||||
let bytes = std::fs::read(path).map_err(FaceError::ModelRead)?;
|
||||
Self::from_bytes(&bytes)
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, FaceError> {
|
||||
install_backend();
|
||||
|
||||
let session = ort::session::Session::builder()
|
||||
.map_err(FaceError::Inference)?
|
||||
.commit_from_memory(bytes)
|
||||
.map_err(FaceError::Inference)?;
|
||||
|
||||
let n_out = session.outputs().len();
|
||||
if n_out % 3 != 0 || !(9..=12).contains(&n_out) {
|
||||
return Err(FaceError::WrongModel {
|
||||
expected: "InsightFace SCRFD",
|
||||
detail: format!("expected 9 or 12 outputs, got {n_out}"),
|
||||
});
|
||||
}
|
||||
let fmc = n_out / 3;
|
||||
|
||||
// The check that actually distinguishes the models. YuNet also has
|
||||
// twelve outputs in three strides, so the count proves nothing — its
|
||||
// groups are cls/obj/bbox/kps where SCRFD's are score/bbox/kps, and
|
||||
// decoding one as the other yields a page of plausible numbers rather
|
||||
// than an error. The last dimension is what separates them.
|
||||
for (group, expected_last) in [1_i64, 4, 10].into_iter().enumerate() {
|
||||
for s in 0..fmc {
|
||||
let idx = group * fmc + s;
|
||||
let out = &session.outputs()[idx];
|
||||
let last: Option<i64> = out.dtype().tensor_shape().and_then(|d| d.last().copied());
|
||||
if last != Some(expected_last) {
|
||||
return Err(FaceError::WrongModel {
|
||||
expected: "InsightFace SCRFD",
|
||||
detail: format!(
|
||||
"output '{}' last dim is {:?}, expected {expected_last} \
|
||||
(a YuNet export fails exactly here)",
|
||||
out.name(),
|
||||
last
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self { session, fmc })
|
||||
}
|
||||
|
||||
/// Stride levels this graph emits.
|
||||
pub fn strides(&self) -> &'static [usize] {
|
||||
&ALL_STRIDES[..self.fmc]
|
||||
}
|
||||
|
||||
/// Find the faces in an image.
|
||||
///
|
||||
/// `rgb` is tightly packed `f32` RGB in `0.0..=1.0`, row-major — the same
|
||||
/// convention `dr-segment` and [`crate::align`] use.
|
||||
pub fn detect(
|
||||
&mut self,
|
||||
rgb: &[f32],
|
||||
width: usize,
|
||||
height: usize,
|
||||
options: &DetectOptions,
|
||||
) -> Result<Vec<Detection>, FaceError> {
|
||||
if width == 0 || height == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if rgb.len() != width * height * 3 {
|
||||
return Err(FaceError::ImageShape {
|
||||
expected: width * height * 3,
|
||||
got: rgb.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let lb = Letterbox::fit(width as f32, height as f32);
|
||||
let input = lb.sample(rgb, width, height);
|
||||
|
||||
let outputs = self
|
||||
.session
|
||||
.run(ort::inputs![
|
||||
ort::value::Tensor::from_array(input).map_err(FaceError::Inference)?
|
||||
])
|
||||
.map_err(FaceError::Inference)?;
|
||||
|
||||
let mut raw: Vec<Detection> = Vec::new();
|
||||
|
||||
for (si, &stride) in ALL_STRIDES[..self.fmc].iter().enumerate() {
|
||||
let (_, scores) = outputs[si]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(FaceError::Inference)?;
|
||||
let (_, boxes) = outputs[self.fmc + si]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(FaceError::Inference)?;
|
||||
let (_, kps) = outputs[self.fmc * 2 + si]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(FaceError::Inference)?;
|
||||
|
||||
let fw = INPUT_EDGE / stride;
|
||||
let fh = INPUT_EDGE / stride;
|
||||
let s = stride as f32;
|
||||
|
||||
for r in 0..fh {
|
||||
for c in 0..fw {
|
||||
for a in 0..ANCHORS {
|
||||
let idx = (r * fw + c) * ANCHORS + a;
|
||||
let score = scores[idx];
|
||||
if score < options.confidence {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Anchor centre in input space, then distance-to-box
|
||||
// decoding: the four regressed values are distances
|
||||
// left/top/right/bottom in units of the stride.
|
||||
let (cx, cy) = ((c * stride) as f32, (r * stride) as f32);
|
||||
let b = &boxes[idx * 4..idx * 4 + 4];
|
||||
let (x0, y0) = lb.into_source(cx - b[0] * s, cy - b[1] * s);
|
||||
let (x1, y1) = lb.into_source(cx + b[2] * s, cy + b[3] * s);
|
||||
|
||||
let k = &kps[idx * 10..idx * 10 + 10];
|
||||
let mut landmarks = [(0.0_f32, 0.0_f32); 5];
|
||||
for (p, lm) in landmarks.iter_mut().enumerate() {
|
||||
*lm = lb.into_source(cx + k[p * 2] * s, cy + k[p * 2 + 1] * s);
|
||||
}
|
||||
|
||||
raw.push(Detection {
|
||||
bbox: (x0, y0, x1, y1),
|
||||
landmarks,
|
||||
confidence: score,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut kept = non_max_suppress(raw, options.nms_iou);
|
||||
|
||||
// Size floor last, on the *merged* boxes: a face that only clears the
|
||||
// floor once NMS has picked the best of its overlapping detections
|
||||
// should be kept.
|
||||
kept.retain(|d| d.width().min(d.height()) >= options.min_face_px);
|
||||
|
||||
// No cap on the count. The reference implementation keeps the ten
|
||||
// largest, which is right for a film frame where background extras are
|
||||
// noise; it is wrong for a photo library, where a group shot with
|
||||
// thirty faces is precisely the picture worth indexing.
|
||||
Ok(kept)
|
||||
}
|
||||
}
|
||||
|
||||
/// Greedy NMS across all strides together.
|
||||
fn non_max_suppress(mut dets: Vec<Detection>, iou_threshold: f32) -> Vec<Detection> {
|
||||
dets.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
|
||||
let mut kept: Vec<Detection> = Vec::new();
|
||||
for d in dets {
|
||||
if kept.iter().all(|k| iou(&k.bbox, &d.bbox) <= iou_threshold) {
|
||||
kept.push(d);
|
||||
}
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
fn iou(a: &(f32, f32, f32, f32), b: &(f32, f32, f32, f32)) -> f32 {
|
||||
let ix = (a.2.min(b.2) - a.0.max(b.0)).max(0.0);
|
||||
let iy = (a.3.min(b.3) - a.1.max(b.1)).max(0.0);
|
||||
let inter = ix * iy;
|
||||
let area_a = (a.2 - a.0).max(0.0) * (a.3 - a.1).max(0.0);
|
||||
let area_b = (b.2 - b.0).max(0.0) * (b.3 - b.1).max(0.0);
|
||||
let union = area_a + area_b - inter;
|
||||
if union <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
inter / union
|
||||
}
|
||||
}
|
||||
|
||||
/// How the image is fitted into the graph's fixed square input.
|
||||
///
|
||||
/// The forward and inverse mappings live in one struct on purpose:
|
||||
/// docs/faces.md §4.1 notes that what matters is not *where* the padding goes
|
||||
/// but that the two agree. A mismatch offsets every box and landmark by the
|
||||
/// padding, producing detections that look plausible and embeddings that
|
||||
/// quietly cluster badly three stages later.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Letterbox {
|
||||
/// Input pixels per source pixel.
|
||||
scale: f32,
|
||||
pad_x: f32,
|
||||
pad_y: f32,
|
||||
}
|
||||
|
||||
impl Letterbox {
|
||||
fn fit(w: f32, h: f32) -> Self {
|
||||
let scale = (INPUT_EDGE as f32 / w).min(INPUT_EDGE as f32 / h);
|
||||
Self {
|
||||
scale,
|
||||
pad_x: (INPUT_EDGE as f32 - w * scale) * 0.5,
|
||||
pad_y: (INPUT_EDGE as f32 - h * scale) * 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resample into `[1, 3, 640, 640]`, normalised as the weights expect.
|
||||
///
|
||||
/// `(x·255 − 127.5) / 128` — note `/128`, not `/127.5`. The reference
|
||||
/// implementation this is ported from uses `/128` for both models, and
|
||||
/// every measured number in docs/faces.md §1 came from it.
|
||||
///
|
||||
/// Padding is grey, matching the reference's `114`: the value the network
|
||||
/// reads least as an edge, where black would draw a hard border across the
|
||||
/// frame and invite a detection along it.
|
||||
fn sample(&self, rgb: &[f32], width: usize, height: usize) -> Array4<f32> {
|
||||
const PAD: f32 = 114.0;
|
||||
let norm = |v: f32| (v * 255.0 - 127.5) / 128.0;
|
||||
|
||||
let mut input =
|
||||
Array4::<f32>::from_elem((1, 3, INPUT_EDGE, INPUT_EDGE), (PAD - 127.5) / 128.0);
|
||||
|
||||
for iy in 0..INPUT_EDGE {
|
||||
let sy = (iy as f32 + 0.5 - self.pad_y) / self.scale - 0.5;
|
||||
if sy < -0.5 || sy > height as f32 - 0.5 {
|
||||
continue;
|
||||
}
|
||||
for ix in 0..INPUT_EDGE {
|
||||
let sx = (ix as f32 + 0.5 - self.pad_x) / self.scale - 0.5;
|
||||
if sx < -0.5 || sx > width as f32 - 0.5 {
|
||||
continue;
|
||||
}
|
||||
let (x0f, y0f) = (sx.floor(), sy.floor());
|
||||
let (fx, fy) = (sx - x0f, sy - y0f);
|
||||
let x0 = (x0f as isize).clamp(0, width as isize - 1) as usize;
|
||||
let y0 = (y0f as isize).clamp(0, height as isize - 1) as usize;
|
||||
let x1 = (x0 + 1).min(width - 1);
|
||||
let y1 = (y0 + 1).min(height - 1);
|
||||
|
||||
for c in 0..3 {
|
||||
let at = |x: usize, y: usize| rgb[(y * width + x) * 3 + c];
|
||||
let top = at(x0, y0) * (1.0 - fx) + at(x1, y0) * fx;
|
||||
let bot = at(x0, y1) * (1.0 - fx) + at(x1, y1) * fx;
|
||||
input[[0, c, iy, ix]] = norm(top * (1.0 - fy) + bot * fy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input
|
||||
}
|
||||
|
||||
/// Input-space point back to source pixels.
|
||||
fn into_source(self, x: f32, y: f32) -> (f32, f32) {
|
||||
((x - self.pad_x) / self.scale, (y - self.pad_y) / self.scale)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn letterbox_round_trips_a_point() {
|
||||
let lb = Letterbox::fit(1024.0, 683.0);
|
||||
for &(x, y) in &[(0.0_f32, 0.0_f32), (512.0, 341.0), (1023.0, 682.0)] {
|
||||
let (bx, by) = lb.into_source(x * lb.scale + lb.pad_x, y * lb.scale + lb.pad_y);
|
||||
assert!((bx - x).abs() < 1e-2, "{bx} vs {x}");
|
||||
assert!((by - y).abs() < 1e-2, "{by} vs {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letterbox_centres_the_short_axis() {
|
||||
let lb = Letterbox::fit(640.0, 320.0);
|
||||
assert!((lb.scale - 1.0).abs() < 1e-6);
|
||||
assert!(lb.pad_x.abs() < 1e-6);
|
||||
assert!((lb.pad_y - 160.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nms_keeps_the_confident_box_and_drops_its_duplicate() {
|
||||
let d = |x: f32, conf: f32| Detection {
|
||||
bbox: (x, 0.0, x + 100.0, 100.0),
|
||||
landmarks: [(0.0, 0.0); 5],
|
||||
confidence: conf,
|
||||
};
|
||||
let kept = non_max_suppress(vec![d(0.0, 0.8), d(5.0, 0.9), d(500.0, 0.7)], 0.4);
|
||||
assert_eq!(kept.len(), 2);
|
||||
assert!((kept[0].confidence - 0.9).abs() < 1e-6);
|
||||
assert!((kept[1].bbox.0 - 500.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iou_of_a_box_with_itself_is_one_and_with_a_disjoint_box_is_zero() {
|
||||
let a = (0.0, 0.0, 10.0, 10.0);
|
||||
assert!((iou(&a, &a) - 1.0).abs() < 1e-6);
|
||||
assert!(iou(&a, &(100.0, 100.0, 110.0, 110.0)) < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! ArcFace / MobileFaceNet inference (docs/faces.md §6).
|
||||
//!
|
||||
//! Takes an aligned crop and returns 512 L2-normalised floats. The alignment is
|
||||
//! not optional and cannot be skipped by accident: [`Embedder::embed`] takes an
|
||||
//! [`Aligned112`], which only [`crate::align::warp`] can construct.
|
||||
//!
|
||||
//! # The graph must have a fixed batch
|
||||
//!
|
||||
//! `w600k_mbf.onnx` declares its batch dimension as the literal `dim_param`
|
||||
//! `"None"`, and tract fails to analyse the first Conv because of it. Pinned to
|
||||
//! 1 by `tools/fix-face-model-shapes.sh`, it loads and runs.
|
||||
|
||||
use ndarray::Array4;
|
||||
|
||||
use crate::align::{Aligned112, ALIGNED_EDGE};
|
||||
use crate::embedding::{normalise, Embedding, ModelId, EMBEDDING_DIM};
|
||||
use crate::{install_backend, FaceError};
|
||||
|
||||
/// A loaded ArcFace graph.
|
||||
pub struct Embedder {
|
||||
session: ort::session::Session,
|
||||
model: ModelId,
|
||||
}
|
||||
|
||||
impl Embedder {
|
||||
pub fn from_path(path: impl AsRef<std::path::Path>, model: ModelId) -> Result<Self, FaceError> {
|
||||
let bytes = std::fs::read(path).map_err(FaceError::ModelRead)?;
|
||||
Self::from_bytes(&bytes, model)
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8], model: ModelId) -> Result<Self, FaceError> {
|
||||
install_backend();
|
||||
|
||||
let session = ort::session::Session::builder()
|
||||
.map_err(FaceError::Inference)?
|
||||
.commit_from_memory(bytes)
|
||||
.map_err(FaceError::Inference)?;
|
||||
|
||||
// One output, `[1, 512]`. Checked because an ArcFace variant with a
|
||||
// different embedding width would otherwise be read as a truncated
|
||||
// one, and 512 is baked into the catalog's BLOB width.
|
||||
let out = session.outputs().first().ok_or(FaceError::WrongModel {
|
||||
expected: "ArcFace",
|
||||
detail: "model has no outputs".into(),
|
||||
})?;
|
||||
let last = out.dtype().tensor_shape().and_then(|d| d.last().copied());
|
||||
if last != Some(EMBEDDING_DIM as i64) {
|
||||
return Err(FaceError::WrongModel {
|
||||
expected: "ArcFace",
|
||||
detail: format!(
|
||||
"output '{}' is {:?}-wide, expected {EMBEDDING_DIM}",
|
||||
out.name(),
|
||||
last
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self { session, model })
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &ModelId {
|
||||
&self.model
|
||||
}
|
||||
|
||||
/// Embed one aligned face.
|
||||
pub fn embed(&mut self, face: &Aligned112) -> Result<Embedding, 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));
|
||||
for y in 0..ALIGNED_EDGE {
|
||||
for x in 0..ALIGNED_EDGE {
|
||||
for c in 0..3 {
|
||||
let v = px[(y * ALIGNED_EDGE + x) * 3 + c];
|
||||
input[[0, c, y, x]] = (v * 255.0 - 127.5) / 128.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let outputs = self
|
||||
.session
|
||||
.run(ort::inputs![
|
||||
ort::value::Tensor::from_array(input).map_err(FaceError::Inference)?
|
||||
])
|
||||
.map_err(FaceError::Inference)?;
|
||||
|
||||
let (_, data) = outputs[0]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(FaceError::Inference)?;
|
||||
if data.len() < EMBEDDING_DIM {
|
||||
return Err(FaceError::WrongModel {
|
||||
expected: "ArcFace",
|
||||
detail: format!("got {} values, expected {EMBEDDING_DIM}", data.len()),
|
||||
});
|
||||
}
|
||||
|
||||
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
|
||||
v.copy_from_slice(&data[..EMBEDDING_DIM]);
|
||||
normalise(&mut v);
|
||||
|
||||
Ok(Embedding {
|
||||
model: self.model.clone(),
|
||||
v,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! What an embedder produces, and how it is stored (docs/faces.md §6).
|
||||
//!
|
||||
//! Deliberately **model-free**: the vector, its identity, its comparison and
|
||||
//! its storage encoding are arithmetic, and `calibrate` and `cluster` are built
|
||||
//! on them. Keeping them out of the `inference` feature is what lets the part
|
||||
//! of this subsystem most likely to be subtly wrong be tested on a machine with
|
||||
//! no weights on it.
|
||||
//!
|
||||
//! [`crate::embed::Embedder`] is the thing that needs a model, and it lives
|
||||
//! behind the feature.
|
||||
|
||||
/// Embedding dimensionality. Fixed by the model family, not a parameter.
|
||||
pub const EMBEDDING_DIM: usize = 512;
|
||||
|
||||
/// Which model produced an embedding.
|
||||
///
|
||||
/// Embeddings from different models are not comparable, and this is the one
|
||||
/// mistake that produces plausible-looking garbage rather than an error — so
|
||||
/// the id travels *with* the vector rather than beside it, and
|
||||
/// [`Embedding::cosine`] refuses a cross-model comparison.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ModelId(pub std::sync::Arc<str>);
|
||||
|
||||
impl ModelId {
|
||||
pub fn new(s: impl Into<std::sync::Arc<str>>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ModelId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 512-d L2-normalised face embedding.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Embedding {
|
||||
pub model: ModelId,
|
||||
pub v: Box<[f32; EMBEDDING_DIM]>,
|
||||
}
|
||||
|
||||
impl Embedding {
|
||||
/// Cosine similarity, which for unit vectors is the plain dot product.
|
||||
///
|
||||
/// `None` when the two came from different models. That is a real
|
||||
/// possibility in a library indexed across a model upgrade, and the
|
||||
/// alternative — returning a number — is the failure mode
|
||||
/// `faces.model_id` exists to prevent.
|
||||
pub fn cosine(&self, other: &Embedding) -> Option<f32> {
|
||||
if self.model != other.model {
|
||||
return None;
|
||||
}
|
||||
Some(dot(&self.v, &other.v))
|
||||
}
|
||||
|
||||
/// Storage form: `512 × f16`, 1 KB per face (catalog.md §10.1).
|
||||
pub fn to_f16_bytes(&self) -> 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
|
||||
}
|
||||
|
||||
/// Read back from storage, re-normalising.
|
||||
///
|
||||
/// 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.
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
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]) {
|
||||
// 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.
|
||||
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-6);
|
||||
for x in v.iter_mut() {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
|
||||
// ── f16 ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Hand-rolled rather than pulling in `half`: two functions over a format that
|
||||
// has not changed since 2008, used at exactly one boundary. The dependency
|
||||
// policy (D13, D1) makes the bar for a new crate high, and this is well under
|
||||
// it.
|
||||
|
||||
fn f32_to_f16_bits(x: f32) -> u16 {
|
||||
let bits = x.to_bits();
|
||||
let sign = ((bits >> 16) & 0x8000) as u16;
|
||||
let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
|
||||
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.
|
||||
return sign
|
||||
| 0x7c00
|
||||
| if mant != 0 && exp == 0x1f + 112 {
|
||||
0x200
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
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.
|
||||
if exp < -10 {
|
||||
return sign;
|
||||
}
|
||||
let mant = mant | 0x0080_0000;
|
||||
let shift = (14 - exp) as u32;
|
||||
let half = (mant >> shift) as u16;
|
||||
// Round to nearest, ties to even.
|
||||
let rem = mant & ((1 << shift) - 1);
|
||||
let tie = 1 << (shift - 1);
|
||||
let round = u16::from(rem > tie || (rem == tie && (half & 1) == 1));
|
||||
return sign | (half + round);
|
||||
}
|
||||
|
||||
let half = ((exp as u16) << 10) | (mant >> 13) as u16;
|
||||
let rem = mant & 0x1fff;
|
||||
let round = u16::from(rem > 0x1000 || (rem == 0x1000 && (half & 1) == 1));
|
||||
sign | (half + round)
|
||||
}
|
||||
|
||||
fn f16_bits_to_f32(h: u16) -> f32 {
|
||||
let sign = ((h & 0x8000) as u32) << 16;
|
||||
let exp = ((h >> 10) & 0x1f) as u32;
|
||||
let mant = (h & 0x03ff) as u32;
|
||||
|
||||
if exp == 0 {
|
||||
if mant == 0 {
|
||||
return f32::from_bits(sign);
|
||||
}
|
||||
// Subnormal: renormalise into f32's range.
|
||||
let mut e = -1_i32;
|
||||
let mut m = mant;
|
||||
while m & 0x0400 == 0 {
|
||||
m <<= 1;
|
||||
e -= 1;
|
||||
}
|
||||
let m = m & 0x03ff;
|
||||
return f32::from_bits(sign | (((127 - 15 + 1 + e) as u32) << 23) | (m << 13));
|
||||
}
|
||||
if exp == 0x1f {
|
||||
return f32::from_bits(sign | 0x7f80_0000 | (mant << 13));
|
||||
}
|
||||
f32::from_bits(sign | ((exp + 127 - 15) << 23) | (mant << 13))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unit(seed: u32) -> Embedding {
|
||||
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
|
||||
let mut s = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
|
||||
for x in v.iter_mut() {
|
||||
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
*x = (s >> 8) as f32 / (1u32 << 23) as f32 - 0.5;
|
||||
}
|
||||
normalise(&mut v);
|
||||
Embedding {
|
||||
model: ModelId::new("test"),
|
||||
v,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normalised_embedding_has_cosine_one_with_itself() {
|
||||
let e = unit(7);
|
||||
assert!((e.cosine(&e).unwrap() - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embeddings_from_different_models_do_not_compare() {
|
||||
let a = unit(1);
|
||||
let mut b = unit(1);
|
||||
b.model = ModelId::new("other");
|
||||
assert_eq!(
|
||||
a.cosine(&b),
|
||||
None,
|
||||
"a cross-model cosine must not be a number"
|
||||
);
|
||||
}
|
||||
|
||||
/// The claim docs/faces.md §6 makes about the storage format: the f16
|
||||
/// round-trip costs ~1e-3 of cosine, three orders below the separation
|
||||
/// between a match and a non-match.
|
||||
#[test]
|
||||
fn f16_round_trip_preserves_the_embedding() {
|
||||
for seed in 0..16 {
|
||||
let e = unit(seed);
|
||||
let back = Embedding::from_f16_bytes(e.model.clone(), &e.to_f16_bytes()).unwrap();
|
||||
let cos = e.cosine(&back).unwrap();
|
||||
assert!(cos > 0.9999, "seed {seed}: round-trip cosine {cos}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f16_round_trip_rejects_a_wrong_length_blob() {
|
||||
assert!(Embedding::from_f16_bytes(ModelId::new("m"), &[0u8; 100]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f16_handles_the_values_an_embedding_actually_contains() {
|
||||
// Components of a unit 512-vector cluster around ±1/sqrt(512) ≈ 0.044.
|
||||
for &x in &[0.0_f32, 1.0, -1.0, 0.044_194_17, -0.044_194_17, 1e-3, -7e-4] {
|
||||
let back = f16_bits_to_f32(f32_to_f16_bits(x));
|
||||
assert!(
|
||||
(back - x).abs() <= 1e-3 * x.abs().max(1e-3),
|
||||
"{x} round-tripped to {back}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Faces and identity (S14, docs/faces.md).
|
||||
//!
|
||||
//! Two models, run over the proxy tier, producing per face a box, five
|
||||
//! landmarks, a confidence and a 512-d embedding (FR-CULL-8) — and then the
|
||||
//! arithmetic that turns embeddings into people (FR-CULL-9, FR-CULL-10).
|
||||
//!
|
||||
//! Like `dr-segment`, this crate is **device-free**: no GPU adapter, no
|
||||
//! Slint, nothing that needs a display. Unlike `dr-segment`, it carries **no
|
||||
//! weights at all**, and the absence is deliberate — see [`the licence
|
||||
//! note`](#the-weights-are-not-in-this-repository) below.
|
||||
//!
|
||||
//! # The weights are not in this repository
|
||||
//!
|
||||
//! The models this crate is built for — SCRFD-500MF and ArcFace/MobileFaceNet
|
||||
//! — are InsightFace's, and their pretrained weights carry a **non-commercial
|
||||
//! research-only** grant. That is incompatible with GPL-3.0-or-later and with
|
||||
//! every channel DarkRoom ships through, so the weights cannot be committed
|
||||
//! here the way `dr-segment`'s can, and there is no `embedded-model` feature
|
||||
//! for a packaging script to switch on. The application obtains a model at
|
||||
//! runtime; this crate takes bytes and never fetches anything.
|
||||
//!
|
||||
//! docs/faces.md §2 is the full reading, including what would have to change
|
||||
//! for that to stop being true.
|
||||
//!
|
||||
//! # 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.
|
||||
//! 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 calibrate;
|
||||
pub mod cluster;
|
||||
#[cfg(feature = "inference")]
|
||||
pub mod detect;
|
||||
#[cfg(feature = "inference")]
|
||||
pub mod embed;
|
||||
pub mod embedding;
|
||||
pub mod naming;
|
||||
pub mod neighbours;
|
||||
|
||||
pub use align::{warp, Aligned112, Similarity, ALIGNED_EDGE, ARCFACE_TEMPLATE};
|
||||
pub use calibrate::{Calibration, Pairs, ReliabilityBand};
|
||||
pub use cluster::{cluster, split, Candidate, Cluster, 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 naming::{name_for_instance, name_instances, NamedFace};
|
||||
|
||||
/// What can go wrong between an image and a face.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FaceError {
|
||||
#[error("could not read model file: {0}")]
|
||||
ModelRead(#[source] std::io::Error),
|
||||
|
||||
#[cfg(feature = "inference")]
|
||||
#[error("inference failed: {0}")]
|
||||
Inference(#[source] ort::Error),
|
||||
|
||||
/// The graph is not the one this decoder was written for.
|
||||
///
|
||||
/// Worth a distinct variant rather than a generic failure: the models in
|
||||
/// this space have interchangeable *shapes* and incompatible *layouts*
|
||||
/// (a YuNet export also has twelve outputs), so the failure this catches
|
||||
/// is not a crash but a page of plausible numbers.
|
||||
#[error("model does not look like {expected}: {detail}")]
|
||||
WrongModel {
|
||||
expected: &'static str,
|
||||
detail: String,
|
||||
},
|
||||
|
||||
#[error("image buffer is {got} floats, expected {expected} (RGB, three per pixel)")]
|
||||
ImageShape { expected: usize, got: usize },
|
||||
}
|
||||
|
||||
/// Install tract as `ort`'s backend.
|
||||
///
|
||||
/// Idempotent, and it must happen before any other `ort` call: with
|
||||
/// `alternative-backend` there is no linked runtime to fall back on, so an
|
||||
/// un-set API is a panic rather than a slow path. Same helper as
|
||||
/// `dr-segment::semantic`, for the same reason.
|
||||
#[cfg(feature = "inference")]
|
||||
pub(crate) fn install_backend() {
|
||||
use std::sync::Once;
|
||||
static ONCE: Once = Once::new();
|
||||
ONCE.call_once(|| {
|
||||
let _ = ort::set_api(ort_tract::api());
|
||||
});
|
||||
}
|
||||
|
||||
/// [`install_backend`] for the M1 probe example, which drives `ort` directly
|
||||
/// rather than through [`detect::Detector`] so it can report the raw error.
|
||||
#[cfg(feature = "inference")]
|
||||
#[doc(hidden)]
|
||||
pub fn install_backend_for_probe() {
|
||||
install_backend();
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Naming a segmented person from the face inside it.
|
||||
//!
|
||||
//! `dr-segment` recognises *a person*; this crate recognises *which* person.
|
||||
//! Putting the two together costs one containment test and turns "person" in
|
||||
//! the mask list into "Anna" — which is the difference between a vocabulary of
|
||||
//! eighty COCO classes and a vocabulary that includes the user's family.
|
||||
//!
|
||||
//! Pure geometry: no model, no catalog, no Slint. The caller supplies boxes and
|
||||
//! names from wherever it keeps them.
|
||||
//!
|
||||
//! # Why containment and not overlap
|
||||
//!
|
||||
//! A face is a small part of the person it belongs to, and it is *inside* them.
|
||||
//! Intersection-over-union would be near zero for a correct match — the face is
|
||||
//! perhaps a twentieth of the person's area — so IoU is the wrong measure
|
||||
//! entirely here and would reject every true pairing.
|
||||
|
||||
/// A face box with a name attached.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NamedFace<'a> {
|
||||
/// `(x0, y0, x1, y1)`, in the same space as the instance boxes.
|
||||
pub bbox: (f32, f32, f32, f32),
|
||||
pub name: &'a str,
|
||||
}
|
||||
|
||||
/// How much of a face must lie inside an instance to belong to it.
|
||||
///
|
||||
/// Not 1.0: the detector's face box and the segmenter's person box come from
|
||||
/// different models and disagree at the edges, most visibly around hair and
|
||||
/// chin. A face 85% inside a person is that person's face.
|
||||
const MIN_CONTAINMENT: f32 = 0.7;
|
||||
|
||||
/// The name to show for one segmented instance, if a face identifies it.
|
||||
///
|
||||
/// `None` leaves the instance labelled as the model found it. That is the right
|
||||
/// default in every uncertain case: a mask list saying "person" is merely
|
||||
/// unhelpful, where one saying "Anna" about her brother is wrong, and the user
|
||||
/// has no way to tell which they are looking at.
|
||||
///
|
||||
/// Where several named faces sit inside one instance — two people the segmenter
|
||||
/// merged into one blob — the largest face wins, on the grounds that it is the
|
||||
/// nearer subject and the one the box is mostly about. If two are within a
|
||||
/// whisker of each other the instance stays unnamed, because at that point the
|
||||
/// box genuinely covers two people and picking either is a coin toss.
|
||||
pub fn name_for_instance<'a>(
|
||||
instance: (f32, f32, f32, f32),
|
||||
faces: &[NamedFace<'a>],
|
||||
) -> Option<&'a str> {
|
||||
let instance_area = area(instance);
|
||||
if instance_area <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut candidates: Vec<(f32, &'a str)> = faces
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let fa = area(f.bbox);
|
||||
if fa <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let inside = intersection(instance, f.bbox);
|
||||
if inside / fa < MIN_CONTAINMENT {
|
||||
return None;
|
||||
}
|
||||
Some((fa, f.name))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
candidates.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
|
||||
// Two comparably sized faces in one box: the segmenter has merged two
|
||||
// people and there is no honest way to pick. Distinct names only — the
|
||||
// same person detected twice (a mirror, a reflection) is not ambiguous.
|
||||
if let [(first, a), (second, b), ..] = candidates.as_slice() {
|
||||
if a != b && *second > *first * 0.8 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(candidates[0].1)
|
||||
}
|
||||
|
||||
/// Relabel a list of instances, in place, from the faces found in the image.
|
||||
///
|
||||
/// `is_person` decides which classes are eligible. Only person-like classes
|
||||
/// should be: a face inside a `tv` or a `laptop` is a photograph of someone on
|
||||
/// a screen, and renaming the television to "Anna" would be worse than leaving
|
||||
/// it alone.
|
||||
///
|
||||
/// Returns how many instances gained a name.
|
||||
pub fn name_instances<T>(
|
||||
instances: &mut [T],
|
||||
faces: &[NamedFace<'_>],
|
||||
bbox_of: impl Fn(&T) -> (f32, f32, f32, f32),
|
||||
is_person: impl Fn(&T) -> bool,
|
||||
set_name: impl Fn(&mut T, &str),
|
||||
) -> usize {
|
||||
let mut named = 0;
|
||||
for inst in instances.iter_mut() {
|
||||
if !is_person(inst) {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = name_for_instance(bbox_of(inst), faces) {
|
||||
let name = name.to_string();
|
||||
set_name(inst, &name);
|
||||
named += 1;
|
||||
}
|
||||
}
|
||||
named
|
||||
}
|
||||
|
||||
fn area(b: (f32, f32, f32, f32)) -> f32 {
|
||||
((b.2 - b.0).max(0.0)) * ((b.3 - b.1).max(0.0))
|
||||
}
|
||||
|
||||
fn intersection(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 {
|
||||
let w = (a.2.min(b.2) - a.0.max(b.0)).max(0.0);
|
||||
let h = (a.3.min(b.3) - a.1.max(b.1)).max(0.0);
|
||||
w * h
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A person filling most of a portrait, with their face near the top.
|
||||
const PERSON: (f32, f32, f32, f32) = (100.0, 50.0, 400.0, 900.0);
|
||||
const FACE: (f32, f32, f32, f32) = (200.0, 80.0, 300.0, 220.0);
|
||||
|
||||
fn named(bbox: (f32, f32, f32, f32), name: &str) -> NamedFace<'_> {
|
||||
NamedFace { bbox, name }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_face_inside_a_person_names_them() {
|
||||
let faces = [named(FACE, "Anna")];
|
||||
assert_eq!(name_for_instance(PERSON, &faces), Some("Anna"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_face_elsewhere_in_the_frame_names_nothing() {
|
||||
let faces = [named((800.0, 80.0, 900.0, 220.0), "Anna")];
|
||||
assert_eq!(name_for_instance(PERSON, &faces), None);
|
||||
}
|
||||
|
||||
/// The measure has to be containment. A correct pairing has an IoU near
|
||||
/// zero, so anything IoU-based would reject every true match.
|
||||
#[test]
|
||||
fn a_tiny_face_in_a_large_person_still_matches() {
|
||||
let tall = (0.0, 0.0, 500.0, 2000.0);
|
||||
let small = (240.0, 40.0, 280.0, 100.0);
|
||||
assert_eq!(
|
||||
name_for_instance(tall, &[named(small, "Anna")]),
|
||||
Some("Anna")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_face_mostly_outside_the_person_is_not_theirs() {
|
||||
// Overlapping the person's edge, but only just.
|
||||
let straddling = (60.0, 80.0, 140.0, 220.0);
|
||||
assert_eq!(
|
||||
name_for_instance(PERSON, &[named(straddling, "Anna")]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// A tight portrait, where the segmenter's "person" is head and shoulders
|
||||
/// and the face is most of it.
|
||||
///
|
||||
/// This **is** named, and an earlier version of this module wrongly
|
||||
/// refused to on the grounds that a face filling its instance meant the
|
||||
/// two models disagreed. It does not: it means the photograph is a
|
||||
/// close-up, which is the case where naming the region is most useful and
|
||||
/// most certain. Left as a test because the reasoning is easy to get
|
||||
/// backwards a second time.
|
||||
#[test]
|
||||
fn a_tight_portrait_is_named_rather_than_treated_as_suspicious() {
|
||||
let head = (100.0, 50.0, 400.0, 400.0);
|
||||
let face = (110.0, 60.0, 390.0, 390.0);
|
||||
assert_eq!(
|
||||
name_for_instance(head, &[named(face, "Anna")]),
|
||||
Some("Anna")
|
||||
);
|
||||
}
|
||||
|
||||
/// A face box *larger* than the instance is a genuine disagreement, and
|
||||
/// containment rejects it without needing a size rule: most of the face
|
||||
/// lies outside the box it is supposed to belong to.
|
||||
#[test]
|
||||
fn a_face_larger_than_the_instance_does_not_name_it() {
|
||||
let small_instance = (200.0, 200.0, 260.0, 260.0);
|
||||
let huge_face = (100.0, 100.0, 500.0, 500.0);
|
||||
assert_eq!(
|
||||
name_for_instance(small_instance, &[named(huge_face, "Anna")]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// Two people merged into one blob: naming either would be a coin toss,
|
||||
/// and a mask list saying "Anna" about her brother is worse than one
|
||||
/// saying "person".
|
||||
#[test]
|
||||
fn two_comparable_faces_in_one_instance_leave_it_unnamed() {
|
||||
let wide = (0.0, 0.0, 800.0, 900.0);
|
||||
let faces = [
|
||||
named((100.0, 80.0, 200.0, 220.0), "Anna"),
|
||||
named((500.0, 85.0, 605.0, 230.0), "Bob"),
|
||||
];
|
||||
assert_eq!(name_for_instance(wide, &faces), None);
|
||||
}
|
||||
|
||||
/// But a clearly nearer subject wins: the box is mostly about them.
|
||||
#[test]
|
||||
fn a_much_larger_face_wins_over_someone_in_the_background() {
|
||||
let wide = (0.0, 0.0, 800.0, 900.0);
|
||||
let faces = [
|
||||
named((100.0, 80.0, 300.0, 360.0), "Anna"),
|
||||
named((600.0, 85.0, 640.0, 140.0), "distant"),
|
||||
];
|
||||
assert_eq!(name_for_instance(wide, &faces), Some("Anna"));
|
||||
}
|
||||
|
||||
/// The same person found twice — a mirror, a reflection — is not ambiguous
|
||||
/// even though the two faces are comparable.
|
||||
#[test]
|
||||
fn the_same_name_twice_is_not_an_ambiguity() {
|
||||
let wide = (0.0, 0.0, 800.0, 900.0);
|
||||
let faces = [
|
||||
named((100.0, 80.0, 200.0, 220.0), "Anna"),
|
||||
named((500.0, 85.0, 605.0, 230.0), "Anna"),
|
||||
];
|
||||
assert_eq!(name_for_instance(wide, &faces), Some("Anna"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_boxes_name_nothing_rather_than_panicking() {
|
||||
assert_eq!(
|
||||
name_for_instance((0.0, 0.0, 0.0, 0.0), &[named(FACE, "A")]),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
name_for_instance(PERSON, &[named((5.0, 5.0, 5.0, 5.0), "A")]),
|
||||
None
|
||||
);
|
||||
assert_eq!(name_for_instance(PERSON, &[]), None);
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Inst {
|
||||
class: String,
|
||||
bbox: (f32, f32, f32, f32),
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_person_instances_are_renamed() {
|
||||
let mut instances = vec![
|
||||
Inst {
|
||||
class: "person".into(),
|
||||
bbox: PERSON,
|
||||
},
|
||||
// A face on a screen must not rename the television.
|
||||
Inst {
|
||||
class: "tv".into(),
|
||||
bbox: PERSON,
|
||||
},
|
||||
];
|
||||
let faces = [named(FACE, "Anna")];
|
||||
|
||||
let n = name_instances(
|
||||
&mut instances,
|
||||
&faces,
|
||||
|i| i.bbox,
|
||||
|i| i.class == "person",
|
||||
|i, name| i.class = name.to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(instances[0].class, "Anna");
|
||||
assert_eq!(instances[1].class, "tv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrecognised_person_keeps_the_models_own_label() {
|
||||
let mut instances = vec![Inst {
|
||||
class: "person".into(),
|
||||
bbox: PERSON,
|
||||
}];
|
||||
let n = name_instances(
|
||||
&mut instances,
|
||||
&[],
|
||||
|i| i.bbox,
|
||||
|i| i.class == "person",
|
||||
|i, name| i.class = name.to_string(),
|
||||
);
|
||||
assert_eq!(n, 0);
|
||||
assert_eq!(instances[0].class, "person");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
//! TRACES: FR-CULL-9 | FR-CULL-10
|
||||
//! Finding the face pairs that could possibly be the same person.
|
||||
//!
|
||||
//! [`crate::cluster`] used to begin by computing every pairwise cosine and
|
||||
//! holding the lot as an `n²` matrix of `f32`. At 1,800 faces that is 13 MB,
|
||||
//! which is why it survived; at 25,000 it is 2.5 GB, which is why it could not
|
||||
//! keep surviving.
|
||||
//!
|
||||
//! Almost all of that matrix is thrown away unread. Clustering only ever asks
|
||||
//! whether a pair is *above* the merge threshold, and in a real library the
|
||||
//! answer is no for well over 99% of pairs — the reference library's 1,813
|
||||
//! faces produced 7,875 qualifying pairs out of 1.6 million. So this module
|
||||
//! answers the only question that is actually asked — **which pairs clear the
|
||||
//! bar** — and returns that sparse list. Memory goes from `O(n²)` to `O(edges)`
|
||||
//! and the caller never has to hold a matrix at all.
|
||||
//!
|
||||
//! # Exact, not approximate
|
||||
//!
|
||||
//! The usual way to make this fast is an approximate nearest-neighbour index,
|
||||
//! which trades recall for speed: it *misses* some true neighbours, and a
|
||||
//! missed neighbour here is a face that silently never joins its person.
|
||||
//! Nothing surfaces that — the screen just quietly shows one person as two —
|
||||
//! so it is a poor trade for a feature whose whole job is to be trusted. This
|
||||
//! module is exact, and the clusters it produces are identical to those from a
|
||||
//! full scan.
|
||||
//!
|
||||
//! # An exact index was tried, measured, and removed
|
||||
//!
|
||||
//! Worth recording so it is not rediscovered as a good idea. The obvious exact
|
||||
//! index is IVF with a triangle-inequality bound: group the embeddings into
|
||||
//! cells, and skip a whole cell **pair** when the geometry proves no member of
|
||||
//! one can reach any member of the other. On the sphere,
|
||||
//!
|
||||
//! ```text
|
||||
//! angle(x, y) >= angle(c_P, c_Q) - radius(P) - radius(Q)
|
||||
//! ```
|
||||
//!
|
||||
//! so a cell pair is impossible when `cos` of that lower bound falls below the
|
||||
//! threshold. Exact, no recall loss, and it prunes beautifully on synthetic
|
||||
//! clusters.
|
||||
//!
|
||||
//! It prunes **nothing at all** on real face embeddings. Measured over the
|
||||
//! 1,813-face reference library, at √n = 43 cells:
|
||||
//!
|
||||
//! | quantity | measured |
|
||||
//! |---|---|
|
||||
//! | median pair angle | 88.5° (cosine 0.026) |
|
||||
//! | merge threshold | 66.2° (cosine 0.403) |
|
||||
//! | median cell radius | 80.4° |
|
||||
//! | median centroid separation | 85.0° |
|
||||
//! | cell pairs surviving the bound | **946 of 946 — 100%** |
|
||||
//!
|
||||
//! The arithmetic is not close. For the bound to exclude a typical cell pair it
|
||||
//! needs `radius(P) + radius(Q) < 85° - 66° = 19°`, so cells of radius under
|
||||
//! ~10°. But two photographs of the *same person* sit 36–60° apart, so even a
|
||||
//! perfect single-identity cell has a radius three times too large. No
|
||||
//! ball-based partition of this space can have cells tight enough for the
|
||||
//! inequality to bite — 512-d embeddings are near-orthogonal, and that is the
|
||||
//! curse of dimensionality doing exactly what it says.
|
||||
//!
|
||||
//! So the scan stayed exhaustive, and the effort went where it actually pays:
|
||||
//! not materialising the matrix, an unrolled dot product, and spreading the
|
||||
//! blocks across cores. That is `O(n²)` time and `O(edges)` memory, which for
|
||||
//! this problem is the honest answer.
|
||||
|
||||
use crate::calibrate::Calibration;
|
||||
|
||||
/// Rows of the similarity triangle handed to one thread at a time.
|
||||
///
|
||||
/// Small enough that the tail of the triangle divides evenly across cores —
|
||||
/// row `i` does `n - i` comparisons, so equal *row counts* are very unequal
|
||||
/// work — and large enough that the per-block overhead disappears.
|
||||
const BLOCK: 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.
|
||||
const THREADS_ABOVE: usize = 2048;
|
||||
|
||||
/// One face pair that clears the merge threshold.
|
||||
///
|
||||
/// `i < j` always, and the probability is carried because the caller would
|
||||
/// otherwise recompute the sigmoid it took a dot product to reach.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Pair {
|
||||
pub i: usize,
|
||||
pub j: usize,
|
||||
pub probability: f32,
|
||||
}
|
||||
|
||||
/// What the caller has to tell us about each face.
|
||||
///
|
||||
/// Deliberately not [`crate::cluster::Candidate`]: this module has no business
|
||||
/// 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>],
|
||||
/// 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],
|
||||
}
|
||||
|
||||
/// Every pair whose calibrated probability reaches `min_probability`.
|
||||
///
|
||||
/// Excludes pairs from the same photograph, which the clusterer would refuse
|
||||
/// anyway — dropping them here keeps them out of the graph the caller builds
|
||||
/// and out of the connected components it derives from it.
|
||||
///
|
||||
/// 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();
|
||||
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 blocks: Vec<(usize, usize)> = (0..n)
|
||||
.step_by(BLOCK)
|
||||
.map(|start| (start, (start + BLOCK).min(n)))
|
||||
.collect();
|
||||
|
||||
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);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// One worker per core bar one. This runs on a background thread behind a
|
||||
// button the user pressed, and NFR-ARCH-2 puts it behind the UI: taking
|
||||
// every core would stall the window it is reporting progress to.
|
||||
let workers = std::thread::available_parallelism()
|
||||
.map(|p| p.get().saturating_sub(1).max(1))
|
||||
.unwrap_or(1)
|
||||
.min(blocks.len());
|
||||
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let mut parts: Vec<Vec<Vec<Pair>>> = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..workers)
|
||||
.map(|_| {
|
||||
let next = &next;
|
||||
let blocks = &blocks;
|
||||
scope.spawn(move || {
|
||||
// Results stay tagged with their block index, so the order
|
||||
// of the output does not depend on which thread got there
|
||||
// first. Determinism is a promise this module keeps.
|
||||
let mut mine: Vec<(usize, Vec<Pair>)> = Vec::new();
|
||||
loop {
|
||||
let b = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let Some(&(from, to)) = blocks.get(b) else {
|
||||
break;
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
scan_block(faces, cal, min_probability, tau, from, to, &mut out);
|
||||
mine.push((b, out));
|
||||
}
|
||||
mine
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut slots: Vec<Vec<Vec<Pair>>> = vec![Vec::new(); blocks.len()];
|
||||
for h in handles {
|
||||
for (b, pairs) in h.join().unwrap_or_default() {
|
||||
slots[b].push(pairs);
|
||||
}
|
||||
}
|
||||
slots
|
||||
});
|
||||
|
||||
let mut out = Vec::new();
|
||||
for slot in &mut parts {
|
||||
for pairs in slot.drain(..) {
|
||||
out.extend(pairs);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compare rows `from..to` against everything after them.
|
||||
///
|
||||
/// 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,
|
||||
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] {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest cosine that could yield `min_probability` for any pair in the set.
|
||||
///
|
||||
/// The calibration is `sigmoid(a·cos + b + w_size·log2(crop))`, so for a fixed
|
||||
/// size term the cosine boundary is exact. The size term is *not* fixed — it
|
||||
/// varies per pair with the smaller of the two faces — so the safe bound uses
|
||||
/// whichever face size pushes the boundary lowest: the largest face when
|
||||
/// `w_size` is positive, the smallest when it is negative.
|
||||
///
|
||||
/// Returns [`f32::NEG_INFINITY`] — a filter that rejects nothing — where no
|
||||
/// boundary exists: a non-positive steepness, for which probability does not
|
||||
/// increase with cosine, or a threshold at the ends of the sigmoid. Those are
|
||||
/// degenerate calibrations rather than impossible ones, and the right response
|
||||
/// is to stop pruning, not to guess.
|
||||
fn loosest_cosine(crop_px: &[f32], cal: &Calibration, min_probability: f32) -> f32 {
|
||||
if cal.a <= 0.0 || !(min_probability > 0.0 && min_probability < 1.0) {
|
||||
return f32::NEG_INFINITY;
|
||||
}
|
||||
|
||||
let (mut lo, mut hi) = (f32::INFINITY, 0.0_f32);
|
||||
for &c in crop_px {
|
||||
let c = c.max(1.0);
|
||||
lo = lo.min(c);
|
||||
hi = hi.max(c);
|
||||
}
|
||||
if !lo.is_finite() {
|
||||
return f32::NEG_INFINITY;
|
||||
}
|
||||
|
||||
let extreme = if cal.w_size >= 0.0 { hi } else { lo };
|
||||
let tau = cal.boundary_at(min_probability, extreme, 0.0);
|
||||
if tau.is_nan() {
|
||||
return f32::NEG_INFINITY;
|
||||
}
|
||||
// Cosines never exceed 1, so a boundary above it legitimately rejects
|
||||
// everything. Clamped rather than left free so the comparison stays cheap.
|
||||
tau.min(1.0)
|
||||
}
|
||||
|
||||
/// Cosine of two L2-normalised embeddings.
|
||||
///
|
||||
/// Eight accumulators rather than one. Floating-point addition is not
|
||||
/// associative, so the compiler may not re-associate a single running total and
|
||||
/// the loop serialises on the adder's latency; eight independent chains give it
|
||||
/// 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.
|
||||
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;
|
||||
|
||||
for c in 0..chunks {
|
||||
let base = c * LANES;
|
||||
for (l, slot) in acc.iter_mut().enumerate() {
|
||||
*slot += a[base + l] * b[base + l];
|
||||
}
|
||||
}
|
||||
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() {
|
||||
total += a[k] * b[k];
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DIM: usize = 64;
|
||||
|
||||
fn cal() -> Calibration {
|
||||
Calibration {
|
||||
a: 30.0,
|
||||
b: -30.0 * 0.35,
|
||||
w_size: 0.0,
|
||||
valid: true,
|
||||
positive_pairs: 1000,
|
||||
negative_pairs: 10_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit vector in a reproducible pseudo-random direction.
|
||||
///
|
||||
/// Hashed from the seed rather than drawn from an RNG, so a failure is
|
||||
/// reproducible from the test alone.
|
||||
fn vector(seed: u64) -> Vec<f32> {
|
||||
let mut s = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
|
||||
let mut v = Vec::with_capacity(DIM);
|
||||
for _ in 0..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 n = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
for x in &mut v {
|
||||
*x /= n;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A vector a known cosine away from `base`.
|
||||
fn near(base: &[f32], other: &[f32], cosine: f32) -> Vec<f32> {
|
||||
let d = dot(base, other);
|
||||
let mut perp: Vec<f32> = other.iter().zip(base).map(|(o, b)| o - d * b).collect();
|
||||
let n = perp.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
for x in &mut perp {
|
||||
*x /= n;
|
||||
}
|
||||
let s = (1.0 - cosine * cosine).max(0.0).sqrt();
|
||||
normalise(
|
||||
base.iter()
|
||||
.zip(&perp)
|
||||
.map(|(b, p)| cosine * b + s * p)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
struct Set {
|
||||
embeddings: Vec<Vec<f32>>,
|
||||
crop_px: Vec<f32>,
|
||||
images: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Set {
|
||||
fn faces(&self) -> Faces<'_> {
|
||||
Faces {
|
||||
embeddings: &self.embeddings,
|
||||
crop_px: &self.crop_px,
|
||||
images: &self.images,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `groups` identities, `per` faces each, every face in its own photograph.
|
||||
fn population(groups: usize, per: usize, tightness: f32) -> Set {
|
||||
let mut embeddings = Vec::new();
|
||||
let mut images = Vec::new();
|
||||
let mut image = 0u64;
|
||||
for g in 0..groups {
|
||||
let base = vector(g as u64 + 1);
|
||||
let off = vector(g as u64 + 9_999);
|
||||
for m in 0..per {
|
||||
embeddings.push(if m == 0 {
|
||||
base.clone()
|
||||
} else {
|
||||
near(&base, &off, tightness)
|
||||
});
|
||||
images.push(image);
|
||||
image += 1;
|
||||
}
|
||||
}
|
||||
let crop_px = vec![150.0; embeddings.len()];
|
||||
Set {
|
||||
embeddings,
|
||||
crop_px,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 mut out = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in i + 1..n {
|
||||
if faces.images[i] == faces.images[j] {
|
||||
continue;
|
||||
}
|
||||
let cos: f32 = faces.embeddings[i]
|
||||
.iter()
|
||||
.zip(&faces.embeddings[j])
|
||||
.map(|(x, y)| x * y)
|
||||
.sum();
|
||||
let p = cal.probability(cos, faces.crop_px[i].min(faces.crop_px[j]), 0.0);
|
||||
if p >= min_probability {
|
||||
out.push(Pair {
|
||||
i,
|
||||
j,
|
||||
probability: p,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn same_pairs(a: &[Pair], b: &[Pair]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.i == y.i && x.j == y.j)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_pair_is_no_pairs() {
|
||||
let s = population(1, 1, 0.9);
|
||||
assert!(above_threshold(&s.faces(), &cal(), 0.9).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blocked_scan_finds_exactly_what_the_reference_does() {
|
||||
let s = population(60, 8, 0.97);
|
||||
let f = s.faces();
|
||||
let got = above_threshold(&f, &cal(), 0.9);
|
||||
let want = reference(&f, &cal(), 0.9);
|
||||
assert!(!want.is_empty(), "the reference found nothing to check");
|
||||
assert!(same_pairs(&got, &want), "{} vs {}", got.len(), want.len());
|
||||
}
|
||||
|
||||
/// Past `THREADS_ABOVE` the work is split across cores and stitched back
|
||||
/// together, and the stitching is where an order bug would live.
|
||||
#[test]
|
||||
fn the_threaded_scan_finds_exactly_what_the_reference_does() {
|
||||
let s = population(300, 8, 0.97);
|
||||
assert!(
|
||||
s.embeddings.len() > THREADS_ABOVE,
|
||||
"population is below the threading cutoff"
|
||||
);
|
||||
let f = s.faces();
|
||||
let got = above_threshold(&f, &cal(), 0.9);
|
||||
let want = reference(&f, &cal(), 0.9);
|
||||
assert!(!want.is_empty());
|
||||
assert!(same_pairs(&got, &want), "{} vs {}", got.len(), want.len());
|
||||
}
|
||||
|
||||
/// The size term moves the cosine boundary per pair, so the pre-filter has
|
||||
/// to be built from the most permissive size in the set or it will drop a
|
||||
/// pair that would have qualified.
|
||||
#[test]
|
||||
fn a_size_weighted_calibration_still_matches_the_reference() {
|
||||
let mut s = population(60, 8, 0.97);
|
||||
for (i, c) in s.crop_px.iter_mut().enumerate() {
|
||||
*c = 40.0 + (i % 17) as f32 * 30.0;
|
||||
}
|
||||
let sized = Calibration {
|
||||
w_size: 0.5,
|
||||
b: -30.0 * 0.35 - 0.5 * 7.0,
|
||||
..cal()
|
||||
};
|
||||
let f = s.faces();
|
||||
assert!(same_pairs(
|
||||
&above_threshold(&f, &sized, 0.9),
|
||||
&reference(&f, &sized, 0.9)
|
||||
));
|
||||
}
|
||||
|
||||
/// A negative size weight flips which extreme is permissive. Cheap to get
|
||||
/// wrong and silent when it is, so it gets its own case.
|
||||
#[test]
|
||||
fn a_negative_size_weight_prunes_from_the_other_end() {
|
||||
let mut s = population(60, 8, 0.97);
|
||||
for (i, c) in s.crop_px.iter_mut().enumerate() {
|
||||
*c = 40.0 + (i % 17) as f32 * 30.0;
|
||||
}
|
||||
let sized = Calibration {
|
||||
w_size: -0.5,
|
||||
b: -30.0 * 0.35 + 0.5 * 7.0,
|
||||
..cal()
|
||||
};
|
||||
let f = s.faces();
|
||||
assert!(same_pairs(
|
||||
&above_threshold(&f, &sized, 0.9),
|
||||
&reference(&f, &sized, 0.9)
|
||||
));
|
||||
}
|
||||
|
||||
/// A degenerate calibration has no cosine boundary to prune against, and
|
||||
/// must stop pruning rather than prune on a bound that does not hold.
|
||||
#[test]
|
||||
fn a_flat_calibration_prunes_nothing_and_still_agrees() {
|
||||
let flat = Calibration {
|
||||
a: 0.0,
|
||||
b: 4.0,
|
||||
..cal()
|
||||
};
|
||||
assert_eq!(loosest_cosine(&[150.0], &flat, 0.9), f32::NEG_INFINITY);
|
||||
let s = population(20, 6, 0.97);
|
||||
let f = s.faces();
|
||||
assert!(same_pairs(
|
||||
&above_threshold(&f, &flat, 0.9),
|
||||
&reference(&f, &flat, 0.9)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_faces_in_one_photograph_are_never_paired() {
|
||||
let mut s = population(1, 2, 1.0);
|
||||
s.images = vec![7, 7];
|
||||
assert!(above_threshold(&s.faces(), &cal(), 0.9).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairs_come_back_in_index_order() {
|
||||
let s = population(300, 8, 0.97);
|
||||
let pairs = above_threshold(&s.faces(), &cal(), 0.9);
|
||||
assert!(pairs
|
||||
.windows(2)
|
||||
.all(|w| (w[0].i, w[0].j) < (w[1].i, w[1].j)));
|
||||
assert!(pairs.iter().all(|p| p.i < p.j));
|
||||
}
|
||||
|
||||
/// Threads must not make the answer depend on which one finished first.
|
||||
#[test]
|
||||
fn the_same_input_yields_the_same_pairs() {
|
||||
let s = population(300, 8, 0.97);
|
||||
assert_eq!(
|
||||
above_threshold(&s.faces(), &cal(), 0.9),
|
||||
above_threshold(&s.faces(), &cal(), 0.9)
|
||||
);
|
||||
}
|
||||
|
||||
/// The unrolled dot has to agree with the obvious one, tail included — the
|
||||
/// lengths here are deliberately not multiples of the lane count.
|
||||
#[test]
|
||||
fn the_unrolled_dot_matches_the_naive_one() {
|
||||
for len in [1usize, 7, 8, 9, 63, 64, 65, 512] {
|
||||
let a: Vec<f32> = (0..len).map(|i| (i as f32 * 0.37).sin()).collect();
|
||||
let b: Vec<f32> = (0..len).map(|i| (i as f32 * 0.11).cos()).collect();
|
||||
let naive: f32 = a.iter().zip(&b).map(|(x, y)| x * y).sum();
|
||||
assert!(
|
||||
(dot(&a, &b) - naive).abs() < 1e-4,
|
||||
"len {len}: {} vs {naive}",
|
||||
dot(&a, &b)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-filter is the whole speed story, so it is worth asserting it
|
||||
/// actually rejects the bulk of the population rather than trusting it to.
|
||||
#[test]
|
||||
fn the_threshold_filter_rejects_almost_everything() {
|
||||
let s = population(60, 8, 0.97);
|
||||
let n = s.embeddings.len();
|
||||
let total = n * (n - 1) / 2;
|
||||
let kept = above_threshold(&s.faces(), &cal(), 0.9).len();
|
||||
assert!(
|
||||
kept * 20 < total,
|
||||
"kept {kept} of {total} pairs, which is not sparse"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! TRACES: FR-DEV-3f
|
||||
//! Grain as a Boolean model — grains that overlap, rather than noise that does
|
||||
//! not.
|
||||
//!
|
||||
//! # Why the counting model was not enough
|
||||
//!
|
||||
//! [`crate::grain`] gets the *variance* of a developed density right: a count
|
||||
//! of independent yes/no events, `D(Dmax − uD)/N`, calibrated from published
|
||||
//! granularity. What it cannot get right is the **structure**, because it
|
||||
//! treats every pixel as an independent draw.
|
||||
//!
|
||||
//! Measured at 35 mm, a pixel of a 5472-wide frame covers about 6.6 µm and
|
||||
//! holds some 300 crystals of ~370 nm. Three hundred independent events per
|
||||
//! pixel average almost flat, and the little that survives has no spatial
|
||||
//! extent — which is exactly why it reads as sensor noise rather than as film.
|
||||
//!
|
||||
//! Real grain is visible because it *clumps*. A crystal is far smaller than a
|
||||
//! pixel, but crystals overlap into structures that are not, and those survive
|
||||
//! the filtering that averages independent noise away.
|
||||
//!
|
||||
//! # The model
|
||||
//!
|
||||
//! Newson, Delon & Galerne, *A Stochastic Film Grain Model for
|
||||
//! Resolution-Independent Rendering* (Computer Graphics Forum, 2017).
|
||||
//!
|
||||
//! Grain centres are a Poisson process of local intensity `λ(y)`; each centre
|
||||
//! carries a disc. The developed film is the **union** of those discs, and a
|
||||
//! point is opaque exactly when some disc covers it. Because a disc covers a
|
||||
//! whole neighbourhood, nearby points are *correlated* — and that correlation
|
||||
//! is the clumping, which arrives for free rather than being added.
|
||||
//!
|
||||
//! # Why it couples to density and not to a grey level
|
||||
//!
|
||||
//! The paper drives `λ` from an image's grey level, because it renders grain
|
||||
//! onto a finished picture. We are not doing that: we have a *density* per
|
||||
//! layer, from a measured characteristic curve, and a dye that absorbs through
|
||||
//! it.
|
||||
//!
|
||||
//! The two meet exactly. In a Boolean model the chance a point is left
|
||||
//! uncovered is
|
||||
//!
|
||||
//! ```text
|
||||
//! P(uncovered) = exp(−λ · E[A])
|
||||
//! ```
|
||||
//!
|
||||
//! which is Beer–Lambert. So the model's coverage *is* optical density, and
|
||||
//!
|
||||
//! ```text
|
||||
//! λ = D · ln(10) / E[A]
|
||||
//! ```
|
||||
//!
|
||||
//! puts our measured densities straight into it — with `E[A]`, the mean grain
|
||||
//! area, already computed from published RMS granularity in
|
||||
//! [`crate::grain`]. Nothing here is tuned by eye.
|
||||
//!
|
||||
//! # What it costs
|
||||
//!
|
||||
//! Monte Carlo per pixel, against the counting model's two hashes and a square
|
||||
//! root. The shader form stores no grains: space is cut into cells, a
|
||||
//! generator is seeded from each cell's index, and only the cells a sample
|
||||
//! could reach are visited. That keeps it inside the fused pass — no
|
||||
//! neighbouring *pixel* is read — but it is emphatically not free, and
|
||||
//! [`BooleanGrain::samples_for`] is where that trade is made explicit.
|
||||
|
||||
use crate::grain::Grain;
|
||||
|
||||
/// Mean grain area, in µm², recovered from the counting model's calibration.
|
||||
///
|
||||
/// The two models are the same emulsion seen two ways, so they must not
|
||||
/// disagree about how big a crystal is: `Grain` already inverts published RMS
|
||||
/// granularity for exactly this number, and taking it from there is what stops
|
||||
/// a Boolean render and a counting render describing different films.
|
||||
pub fn mean_grain_area_um2(grain: &Grain, pixel_size_um: f32) -> [f32; 3] {
|
||||
let pixel_area = (pixel_size_um * pixel_size_um).max(1e-6);
|
||||
grain.particles.map(|n| pixel_area / n.max(1e-6))
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// What the shader needs to render the Boolean model.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct BooleanGrain {
|
||||
/// Grain radius per layer, in *pixels* at the current sampling scale.
|
||||
///
|
||||
/// In pixels rather than micrometres because that is the unit the shader
|
||||
/// works in, and converting once here keeps the conversion out of the
|
||||
/// inner loop.
|
||||
pub radius_px: [f32; 3],
|
||||
/// `ln(10) / E[A]`, per layer: the factor taking a density to a Poisson
|
||||
/// intensity. Precomputed because it is constant per bake and the shader
|
||||
/// would otherwise recompute a logarithm per pixel per layer.
|
||||
pub lambda_per_density: [f32; 3],
|
||||
/// The density each layer saturates at, as in the counting model.
|
||||
pub density_max: [f32; 3],
|
||||
/// Monte Carlo samples per pixel.
|
||||
pub samples: u32,
|
||||
/// Standard deviation of the sampling kernel, in pixels.
|
||||
///
|
||||
/// The pixel's own footprint: what a scanner or an eye integrates over.
|
||||
/// Too small and the render is binary salt and pepper; too large and the
|
||||
/// grain is blurred out of existence.
|
||||
pub sigma_px: f32,
|
||||
}
|
||||
|
||||
impl BooleanGrain {
|
||||
/// Derive the parameters for a stock at a given sampling scale.
|
||||
pub fn new(grain: &Grain, pixel_size_um: f32, samples: u32) -> Self {
|
||||
let area = mean_grain_area_um2(grain, pixel_size_um);
|
||||
let pixel_area = (pixel_size_um * pixel_size_um).max(1e-6);
|
||||
|
||||
let mut radius_px = [0.0f32; 3];
|
||||
let mut lambda_per_density = [0.0f32; 3];
|
||||
for l in 0..3 {
|
||||
// A disc of this area, expressed as a fraction of a pixel.
|
||||
let area_px = area[l] / pixel_area;
|
||||
radius_px[l] = (area_px / std::f32::consts::PI).sqrt();
|
||||
// Beer-Lambert, read backwards: coverage exp(-lambda*E[A]) is
|
||||
// transmittance 10^-D, so lambda = D * ln(10) / E[A].
|
||||
lambda_per_density[l] = std::f32::consts::LN_10 / area_px.max(1e-9);
|
||||
}
|
||||
|
||||
Self {
|
||||
radius_px,
|
||||
lambda_per_density,
|
||||
density_max: grain.density_max,
|
||||
samples: samples.max(1),
|
||||
// Half a pixel: the footprint of one sample of a sensor whose
|
||||
// pixels abut. Wider would be a soft scanner, narrower a sharper
|
||||
// one than exists.
|
||||
sigma_px: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many Monte Carlo samples a given quality asks for.
|
||||
///
|
||||
/// The estimator's own noise falls as `1/sqrt(N)`, so this trades one kind
|
||||
/// of grain against another: too few samples and the *sampling* shows as a
|
||||
/// second, wrong texture on top of the film's.
|
||||
pub fn samples_for(quality: Quality) -> u32 {
|
||||
match quality {
|
||||
Quality::Preview => 16,
|
||||
Quality::Export => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Expected coverage at a density — what the render must average to.
|
||||
///
|
||||
/// The Boolean model's mean is analytic even though its texture is not,
|
||||
/// which is what makes it testable without rendering anything: whatever
|
||||
/// the grain does locally, across a flat patch it has to come back to the
|
||||
/// density the characteristic curve asked for.
|
||||
pub fn expected_coverage(&self, layer: usize, density: f32) -> f32 {
|
||||
let d = density.clamp(0.0, self.density_max[layer]);
|
||||
1.0 - 10f32.powf(-d)
|
||||
}
|
||||
}
|
||||
|
||||
/// How hard to work at the Monte Carlo.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Quality {
|
||||
/// Interactive. Some sampling noise, which at preview scale is hidden
|
||||
/// under the grain it is sampling.
|
||||
Preview,
|
||||
/// Final render, where the sampling noise must be well below the grain.
|
||||
Export,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile::Profile;
|
||||
|
||||
fn portra() -> Profile {
|
||||
Profile::parse(include_str!("../profiles/kodak_portra_400.yaml")).unwrap()
|
||||
}
|
||||
|
||||
fn model(pixel_size_um: f32) -> BooleanGrain {
|
||||
let g = Grain::for_pixel_size(&portra(), pixel_size_um);
|
||||
BooleanGrain::new(&g, pixel_size_um, 16)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_is_beer_lambert() {
|
||||
// The identity the whole coupling rests on: a Boolean model's uncovered
|
||||
// fraction is exp(-lambda E[A]), and transmittance is 10^-D, so the
|
||||
// model's coverage *is* the film's opacity. If this drifts, the grain
|
||||
// is no longer rendering the density the curve asked for.
|
||||
let m = model(6.6);
|
||||
// Inside the layer's own range. Past its Dmax the coverage clamps —
|
||||
// correctly, since a film cannot develop denser than its maximum — and
|
||||
// an earlier version of this test probed 2.0 against a layer that
|
||||
// reaches 1.798, then blamed the model for the clamp.
|
||||
for d in [0.0f32, 0.3, 1.0, 1.7] {
|
||||
let coverage = m.expected_coverage(1, d);
|
||||
let transmittance = 1.0 - coverage;
|
||||
assert!(
|
||||
(transmittance - 10f32.powf(-d)).abs() < 1e-5,
|
||||
"at density {d}: transmittance {transmittance}, expected {}",
|
||||
10f32.powf(-d)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_film_has_no_grains_and_fully_developed_film_is_nearly_solid() {
|
||||
let m = model(6.6);
|
||||
assert!(m.expected_coverage(1, 0.0) < 1e-6);
|
||||
// At its own maximum, not at some density it never reaches: Portra's
|
||||
// green layer tops out near 1.8, which transmits about 1.6% — dense,
|
||||
// and not opaque. A film that went fully black would be one whose
|
||||
// shadows carried no detail at all.
|
||||
let dmax = m.density_max[1];
|
||||
assert!(
|
||||
m.expected_coverage(1, dmax) > 0.98,
|
||||
"{}",
|
||||
m.expected_coverage(1, dmax)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_clamps_at_the_layers_own_maximum() {
|
||||
// The property the two tests above tripped over, asserted directly:
|
||||
// asking for more density than the emulsion has gives the emulsion's
|
||||
// own ceiling rather than extrapolating one.
|
||||
let m = model(6.6);
|
||||
let dmax = m.density_max[1];
|
||||
assert_eq!(
|
||||
m.expected_coverage(1, dmax),
|
||||
m.expected_coverage(1, dmax + 5.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_models_describe_the_same_crystal() {
|
||||
// The counting model and this one are one emulsion seen two ways. If
|
||||
// they disagreed about grain size they would render as different
|
||||
// films, and the difference would look like a modelling choice rather
|
||||
// than the bug it is.
|
||||
let px = 6.6;
|
||||
let g = Grain::for_pixel_size(&portra(), px);
|
||||
let area = mean_grain_area_um2(&g, px);
|
||||
// Portra's green layer: ~0.14 um^2, about 370 nm across.
|
||||
assert!(
|
||||
(0.10..0.20).contains(&area[1]),
|
||||
"grain area {} um^2 is not what the counting model calibrated",
|
||||
area[1]
|
||||
);
|
||||
let m = BooleanGrain::new(&g, px, 16);
|
||||
// And the radius in pixels must match that area at this scale.
|
||||
let area_px = area[1] / (px * px);
|
||||
let expect_r = (area_px / std::f32::consts::PI).sqrt();
|
||||
assert!((m.radius_px[1] - expect_r).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_in_makes_the_grains_bigger_in_pixels() {
|
||||
// Resolution independence, which is the paper's headline claim and the
|
||||
// thing the counting model can only approximate: a grain is a fixed
|
||||
// size *on the film*, so looking closer must resolve it, not merely
|
||||
// reduce the variance.
|
||||
let close = model(2.0);
|
||||
let far = model(12.0);
|
||||
assert!(
|
||||
close.radius_px[1] > far.radius_px[1] * 3.0,
|
||||
"close {} far {}",
|
||||
close.radius_px[1],
|
||||
far.radius_px[1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finer_stock_has_smaller_grains() {
|
||||
let mut fine = portra();
|
||||
let mut coarse = portra();
|
||||
fine.rms_granularity = [4.0; 3];
|
||||
coarse.rms_granularity = [16.0; 3];
|
||||
|
||||
let f = BooleanGrain::new(&Grain::for_pixel_size(&fine, 6.6), 6.6, 16);
|
||||
let c = BooleanGrain::new(&Grain::for_pixel_size(&coarse, 6.6), 6.6, 16);
|
||||
assert!(
|
||||
c.radius_px[1] > f.radius_px[1],
|
||||
"coarse {} is not larger than fine {}",
|
||||
c.radius_px[1],
|
||||
f.radius_px[1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_samples_more_than_preview() {
|
||||
assert!(
|
||||
BooleanGrain::samples_for(Quality::Export)
|
||||
> BooleanGrain::samples_for(Quality::Preview)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -76,13 +76,65 @@ const RMS_APERTURE_AREA_UM2: f32 = std::f32::consts::PI * 24.0 * 24.0;
|
||||
/// The net density the granularity figure is quoted at.
|
||||
const RMS_REFERENCE_NET_DENSITY: f32 = 1.0;
|
||||
|
||||
/// A 35 mm frame's width, in micrometres.
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// The frame a photograph is being simulated on.
|
||||
///
|
||||
/// What turns a pixel count into a grain size. A photograph has no inherent
|
||||
/// film format, so simulating one means choosing what the frame *would have
|
||||
/// been*; 35 mm is the choice that makes the numbers mean what a photographer
|
||||
/// expects, since published granularity and every intuition about how grainy a
|
||||
/// stock looks come from 35 mm.
|
||||
/// **Grain is a function of enlargement, and this is the half of it the
|
||||
/// photograph cannot supply.** A crystal is a fixed size in micrometres, so
|
||||
/// how grainy a picture looks depends entirely on how much the frame was
|
||||
/// magnified to make it — and that is film size against output size.
|
||||
///
|
||||
/// The same emulsion on 4x5 packs about 3,800 crystals into the pixel that
|
||||
/// holds 300 on 35 mm, so it renders roughly 3.5 times smoother at the same
|
||||
/// output size. Treating everything as 35 mm, as this did, made every
|
||||
/// photograph as grainy as the smallest common format.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
Mm35,
|
||||
Format645,
|
||||
Format6x6,
|
||||
Format6x7,
|
||||
Sheet4x5,
|
||||
Sheet8x10,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// The formats, in the order the picker offers them.
|
||||
///
|
||||
/// Smallest first, so index zero is 35 mm — the commonest frame, and the
|
||||
/// one whose grain every published figure and every photographer's
|
||||
/// intuition is calibrated against.
|
||||
pub const ALL: [Format; 6] = [
|
||||
Format::Mm35,
|
||||
Format::Format645,
|
||||
Format::Format6x6,
|
||||
Format::Format6x7,
|
||||
Format::Sheet4x5,
|
||||
Format::Sheet8x10,
|
||||
];
|
||||
|
||||
/// The frame's width in micrometres — the *image* area, not the sheet.
|
||||
pub fn width_um(self) -> f32 {
|
||||
match self {
|
||||
Format::Mm35 => 36_000.0,
|
||||
// 6x4.5 and 6x6 share a 56 mm gate; only the other axis differs,
|
||||
// and grain scales with the linear magnification of the axis being
|
||||
// enlarged.
|
||||
Format::Format645 | Format::Format6x6 => 56_000.0,
|
||||
Format::Format6x7 => 70_000.0,
|
||||
// The image area of a sheet, which is smaller than the nominal
|
||||
// inches: a "4x5" exposes about 121 x 97 mm.
|
||||
Format::Sheet4x5 => 121_000.0,
|
||||
Format::Sheet8x10 => 248_000.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_index(i: usize) -> Self {
|
||||
Self::ALL.get(i).copied().unwrap_or(Format::Mm35)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 35 mm frame's width, in micrometres. The default format.
|
||||
pub const FRAME_WIDTH_UM: f32 = 36_000.0;
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
@@ -245,6 +297,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_larger_format_is_less_grainy_at_the_same_output_size() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// The point of the whole control, and a fact about photography rather
|
||||
// than about this code: enlarge 35 mm and 4x5 to the same print and the
|
||||
// sheet is visibly smoother, because each of its pixels averages far
|
||||
// more crystals. Treating every frame as 35 mm made a large-format
|
||||
// photograph as grainy as a small one.
|
||||
let p = portra();
|
||||
let out_px = 5472.0;
|
||||
let small = Grain::for_pixel_size(&p, Format::Mm35.width_um() / out_px);
|
||||
let large = Grain::for_pixel_size(&p, Format::Sheet4x5.width_um() / out_px);
|
||||
|
||||
let d = small.density_max[1] * 0.5;
|
||||
let ratio = small.sigma(1, d) / large.sigma(1, d);
|
||||
// Linear magnification is 121/36, so the crystal count per pixel goes
|
||||
// as its square and sigma as its reciprocal: about 3.4x.
|
||||
assert!(
|
||||
(2.5..4.5).contains(&ratio),
|
||||
"35mm is {ratio:.2}x grainier than 4x5, which is not the enlargement"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formats_are_ordered_smallest_first() {
|
||||
// Index zero has to be the neutral choice — 35 mm, which is what every
|
||||
// published granularity figure is calibrated against.
|
||||
let widths: Vec<f32> = Format::ALL.iter().map(|f| f.width_um()).collect();
|
||||
assert_eq!(widths[0], FRAME_WIDTH_UM);
|
||||
assert!(
|
||||
widths.windows(2).all(|w| w[0] <= w[1]),
|
||||
"formats are not ordered by size: {widths:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pixel_never_holds_less_than_one_grain() {
|
||||
// Past this the model describes a pixel smaller than a crystal, where
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
//! auditable. The sRGB reflectance basis is Mallett & Yuksel (2019).
|
||||
|
||||
pub mod bake;
|
||||
pub mod boolean_grain;
|
||||
mod built_in;
|
||||
pub mod grain;
|
||||
pub mod profile;
|
||||
@@ -45,7 +46,8 @@ pub mod spectrum;
|
||||
pub mod tables;
|
||||
|
||||
pub use bake::{bake, Baked, Recipe};
|
||||
pub use grain::Grain;
|
||||
pub use boolean_grain::BooleanGrain;
|
||||
pub use grain::{Format, Grain};
|
||||
pub use profile::{Kind, Profile, Stage, Support};
|
||||
|
||||
use built_in::BUILT_IN;
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
//! What a frame actually costs — the measurement FR-DSP-2 is waiting on.
|
||||
//!
|
||||
//! `docs/display-and-extension.md` §2 argues that tiled computation predates
|
||||
//! the fused-shader design and may not need to exist: the composer folds every
|
||||
//! active operation into **one dispatch over a viewport-sized target**, so the
|
||||
//! problem tiles were invented to solve may already be solved. That argument
|
||||
//! is only worth as much as the numbers behind it, and the decision rule was
|
||||
//! fixed in advance — if the 99th percentile sits inside 16 ms, FR-DSP-2 is
|
||||
//! rewritten as a scheduling concern for export rather than implemented on the
|
||||
//! interactive path.
|
||||
//!
|
||||
//! This is the instrument that decides it. Three measurements:
|
||||
//!
|
||||
//! - **M1** — the fused pass at proxy resolution, at three viewport sizes and
|
||||
//! three chain lengths.
|
||||
//! - **M2** — the same with the view zoomed to 1:1 on a 60 MP source, which is
|
||||
//! the case FR-DSP-5 names.
|
||||
//! - **M3** — the neighbourhood stage on its own. It is the only part of the
|
||||
//! chain that is not one read and one write, so it is the plausible
|
||||
//! budget-breaker and deserves to be measured apart from the fused pass
|
||||
//! rather than hidden inside its total.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --release -p dr-gpu --example frame_budget
|
||||
//! ```
|
||||
//!
|
||||
//! **Release, always.** A debug build measures rustc's shadow, not the GPU's:
|
||||
//! the per-frame CPU half is dominated by shader-source assembly, which is
|
||||
//! string formatting and is several times slower unoptimised.
|
||||
//!
|
||||
//! The committed numbers live in `docs/frame-budget.md`. Rerun this and diff
|
||||
//! that file; a regression should be a diff rather than somebody's memory.
|
||||
//!
|
||||
//! # Why the 99th percentile and not the mean
|
||||
//!
|
||||
//! A slider drag is judged by its worst frame. A chain averaging 4 ms with one
|
||||
//! frame in fifty at 30 ms reads as a stutter, and the mean says nothing about
|
||||
//! it. Percentiles are nearest-rank over the samples with the warm-up already
|
||||
//! discarded — see [`Percentiles`].
|
||||
//!
|
||||
//! # What is being timed
|
||||
//!
|
||||
//! Each frame is `compose` (CPU: assemble the WGSL and its uniforms) followed
|
||||
//! by `render_detailed` and a `poll` that waits for the device to go idle.
|
||||
//! Waiting serialises the GPU work into the frame it belongs to, which is
|
||||
//! pessimistic — a real presentation pipeline overlaps a frame's tail with the
|
||||
//! next frame's head — and pessimistic is the right direction for a budget.
|
||||
//!
|
||||
//! The compose half is reported separately because it is *not* GPU work and
|
||||
//! would otherwise be invisible: it happens on the UI thread on every slider
|
||||
//! event, and if it were the expensive half then tiling could not help at all.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use dr_film::bake::{bake, Recipe};
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::ParamKind;
|
||||
use dr_pipeline::ops::{
|
||||
blacks_whites, contrast, exposure, highlights_shadows, local_contrast, noise_reduction,
|
||||
vibrance, FilmTables,
|
||||
};
|
||||
use dr_pipeline::{Attribute, CropRect, EditGraph, OpId, ParamId};
|
||||
|
||||
/// The synthetic source: 9504 × 6336 is 60.2 MP, which is a Sony A7R V or a
|
||||
/// Fujifilm GFX 100 with a little taken off. §2's M2 names 60 MP, so this is
|
||||
/// that number rather than a round one.
|
||||
const SOURCE: (u32, u32) = (9504, 6336);
|
||||
|
||||
/// Frames measured per row, after the warm-up. Enough that the 99th percentile
|
||||
/// means something (nearest-rank picks the second-worst of 100) without the
|
||||
/// whole matrix taking minutes.
|
||||
const FRAMES: usize = 100;
|
||||
|
||||
/// Frames discarded before measurement begins.
|
||||
///
|
||||
/// The first frame at a new size allocates a render target, the first frame
|
||||
/// with a new chain compiles a pipeline, and the first frame of a detail stage
|
||||
/// allocates its ping-pong pair. None of those recur during a drag, so
|
||||
/// including them would measure the wrong thing — but they are worth knowing
|
||||
/// about, so [`Run::first_frame_ms`] reports the very first one separately.
|
||||
const WARMUP: usize = 12;
|
||||
|
||||
/// The viewport sizes from §2's M1: a laptop panel, a 16:10 desktop, and 4K.
|
||||
const SIZES: [(u32, u32); 3] = [(1920, 1200), (2560, 1600), (3840, 2160)];
|
||||
|
||||
/// The frame budget FR-DSP-3 is asserting against, in milliseconds. 60 Hz.
|
||||
const BUDGET_MS: f64 = 16.0;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let ctx = match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("no GPU adapter: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
println!("adapter {} ({:?})", ctx.adapter_name(), ctx.backend());
|
||||
|
||||
let limit = DemosaicedImage::max_dimension(&ctx);
|
||||
if SOURCE.0 > limit {
|
||||
eprintln!(
|
||||
"this adapter caps textures at {limit} px; {} × {} will not fit",
|
||||
SOURCE.0, SOURCE.1
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let t = Instant::now();
|
||||
let source = synthetic_source(&ctx);
|
||||
println!(
|
||||
"source {} × {} ({:.1} MP, {:.0} MB as rgba16f), built in {:.1} s",
|
||||
SOURCE.0,
|
||||
SOURCE.1,
|
||||
(SOURCE.0 as f64 * SOURCE.1 as f64) / 1e6,
|
||||
(SOURCE.0 as f64 * SOURCE.1 as f64 * 8.0) / 1e6,
|
||||
t.elapsed().as_secs_f64()
|
||||
);
|
||||
println!("budget {BUDGET_MS:.0} ms (FR-DSP-3)\n");
|
||||
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
|
||||
m1_and_m2(&ctx, &mut adjust, &source);
|
||||
m3(&ctx, &mut adjust, &source);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M1 and M2 — the fused pass, fit and at 1:1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The chain lengths §2 asks for.
|
||||
///
|
||||
/// "Every operation" means every operation in [`EditGraph::default_chain`],
|
||||
/// which is what `ops/*.yaml` generates. The lens corrections are not in it —
|
||||
/// they are constructed from a matched profile rather than being part of the
|
||||
/// default chain — and neither are masks or spot repairs, which are stacks of
|
||||
/// their own. So this is an upper bound on the *declared* chain and a lower
|
||||
/// bound on the worst edit a photograph can carry; §7 of the spec asks for
|
||||
/// honesty about exactly this sort of thing.
|
||||
///
|
||||
/// [`Chain::AllPoint`] is not in §2's list and is the row that decides the
|
||||
/// question. §2 asks whether *one dispatch over a viewport-sized target* is
|
||||
/// fast enough; "every operation" mixes that dispatch together with the
|
||||
/// neighbourhood stage, which is not one dispatch and is measured separately in
|
||||
/// M3. Splitting the two apart is what lets M1 answer the question it was
|
||||
/// written to answer instead of a different, harder one.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Chain {
|
||||
One,
|
||||
Five,
|
||||
/// Every operation that contributes a fragment to the fused shader — the
|
||||
/// full point-operation chain, and nothing with a kernel.
|
||||
AllPoint,
|
||||
Everything,
|
||||
}
|
||||
|
||||
impl Chain {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Chain::One => "one",
|
||||
Chain::Five => "five",
|
||||
Chain::AllPoint => "point",
|
||||
Chain::Everything => "all",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn m1_and_m2(ctx: &GpuContext, adjust: &mut AdjustPass, source: &DemosaicedImage) {
|
||||
for (title, zoomed) in [
|
||||
(
|
||||
"M1 — fused frame at proxy resolution (fit; the develop view)",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"M2 — the same view zoomed to 1:1 on the 60 MP source (FR-DSP-5)",
|
||||
true,
|
||||
),
|
||||
] {
|
||||
println!("{title}");
|
||||
header();
|
||||
for size in SIZES {
|
||||
for chain in [Chain::One, Chain::Five, Chain::AllPoint, Chain::Everything] {
|
||||
let mut graph = build_chain(chain);
|
||||
if zoomed {
|
||||
graph.framing_mut().set_view(one_to_one(size));
|
||||
}
|
||||
if matches!(chain, Chain::AllPoint | Chain::Everything) {
|
||||
adjust.set_film(Some(&film_tables()));
|
||||
}
|
||||
// The drag: exposure, moved by a hundredth of a stop per frame.
|
||||
// A real slider does exactly this, and it is what stops the
|
||||
// fused dispatch being skipped by `render_detailed`'s colour
|
||||
// reuse — which would turn M1 into a measurement of the detail
|
||||
// stage by accident.
|
||||
let run = measure(ctx, adjust, source, &mut graph, size, |g, i| {
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 0.30 + i as f32 * 0.01);
|
||||
});
|
||||
adjust.set_film(None);
|
||||
row(size, chain.label(), &run);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
println!(" `shader` is `EditGraph::compose` alone; `cpu` adds the detail chain and the");
|
||||
println!(" invalidation hash, which is everything the develop view does per frame before");
|
||||
println!(" it dispatches. `gpu` is submit plus wait-for-idle. `TOTAL` ranks cpu + gpu");
|
||||
println!(" summed within each frame — the column the {BUDGET_MS:.0} ms budget is judged on.");
|
||||
println!();
|
||||
println!(" `point` is every operation that contributes a fragment to the fused shader;");
|
||||
println!(" `all` is that plus the four neighbourhood operations, which is why the two");
|
||||
println!(" differ by roughly what M3 charges for the detail stage on its own.");
|
||||
println!();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M3 — the detail stage alone
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Neighbourhood operations, timed with the fused dispatch deliberately reused.
|
||||
///
|
||||
/// `render_detailed` skips the fused pass when the colour key, the shader, its
|
||||
/// uniforms and the size are all unchanged — that is FR-DEV-3d, and it is also
|
||||
/// the lever that isolates M3: move only a detail operation's amount and the
|
||||
/// timing covers the convolutions and nothing else. The `colour` column proves
|
||||
/// the isolation held rather than asserting it: it counts fused dispatches over
|
||||
/// the measured frames, and a zero there is what makes the number mean "detail
|
||||
/// alone".
|
||||
fn m3(ctx: &GpuContext, adjust: &mut AdjustPass, source: &DemosaicedImage) {
|
||||
println!("M3 — the neighbourhood stage alone (fused dispatch reused; FR-DEV-3d)");
|
||||
println!(
|
||||
" {:>11} {:>8} {:>5} {:>4} {:>6} {:>6} {:>8} {:>7} {:>7} {:>7}",
|
||||
"size", "stage", "view", "pass", "radius", "colour", "cpu p99", "p50", "p99", "max"
|
||||
);
|
||||
|
||||
for size in SIZES {
|
||||
for zoomed in [false, true] {
|
||||
for (label, build) in DETAIL_CHAINS {
|
||||
let mut graph = build();
|
||||
if zoomed {
|
||||
graph.framing_mut().set_view(one_to_one(size));
|
||||
}
|
||||
let composed = graph.compose_detail(source.size(), size);
|
||||
let (passes, radius) = (composed.len(), composed.radius());
|
||||
// Only the detail parameter moves, so the colour half of the
|
||||
// chain is bit-identical frame to frame and gets reused.
|
||||
let run = measure(ctx, adjust, source, &mut graph, size, |g, i| {
|
||||
let drift = (i % 20) as f32;
|
||||
g.set_param(
|
||||
local_contrast::CLARITY,
|
||||
local_contrast::AMOUNT,
|
||||
60.0 + drift,
|
||||
);
|
||||
g.set_param(
|
||||
local_contrast::TEXTURE,
|
||||
local_contrast::AMOUNT,
|
||||
60.0 + drift,
|
||||
);
|
||||
g.set_param(
|
||||
noise_reduction::ID,
|
||||
noise_reduction::LUMINANCE,
|
||||
60.0 + drift,
|
||||
);
|
||||
g.set_param(noise_reduction::ID, noise_reduction::CHROMA, 60.0 + drift);
|
||||
});
|
||||
println!(
|
||||
" {:>5}x{:<5} {:>8} {:>5} {:>4} {:>6} {:>6} {:>6.2}ms {:>5.2}ms {:>5.2}ms {:>5.2}ms",
|
||||
size.0,
|
||||
size.1,
|
||||
label,
|
||||
if zoomed { "1:1" } else { "fit" },
|
||||
passes,
|
||||
radius,
|
||||
run.colour_dispatches,
|
||||
run.cpu.p99,
|
||||
run.frame.p50,
|
||||
run.frame.p99,
|
||||
run.frame.max
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!(" `pass` is dispatches in the detail chain and `radius` the widest halo any of");
|
||||
println!(" them reads, in render pixels. `colour` is fused dispatches over the {FRAMES}");
|
||||
println!(" measured frames, and must be 0 for the row to mean what it says.");
|
||||
println!();
|
||||
println!(" Clarity's kernel is a fraction of the *frame*, so it grows with the viewport");
|
||||
println!(" and not with the zoom. Noise reduction's is a fraction of the *sensor*, so it");
|
||||
println!(" grows with the zoom and not with the viewport. That is why both are here.");
|
||||
}
|
||||
|
||||
/// The detail chains M3 walks: the widest kernel alone, then all four
|
||||
/// neighbourhood operations at once.
|
||||
///
|
||||
/// Clarity is first because §2 names it — "a separable blur at a large radius
|
||||
/// is the plausible budget-breaker" — and its σ is 1.2% of the shorter edge,
|
||||
/// which at 4K is a 52-pixel radius and the widest kernel anywhere in the
|
||||
/// pipeline.
|
||||
/// A named detail chain: a label for the table, and the graph it builds.
|
||||
type DetailChain = (&'static str, fn() -> EditGraph);
|
||||
|
||||
const DETAIL_CHAINS: [DetailChain; 2] = [
|
||||
("clarity", || {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(local_contrast::CLARITY, local_contrast::AMOUNT, 60.0);
|
||||
g
|
||||
}),
|
||||
("all four", || {
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(local_contrast::CLARITY, local_contrast::AMOUNT, 60.0);
|
||||
g.set_param(local_contrast::TEXTURE, local_contrast::AMOUNT, 60.0);
|
||||
g.set_param(noise_reduction::ID, noise_reduction::LUMINANCE, 60.0);
|
||||
g.set_param(noise_reduction::ID, noise_reduction::CHROMA, 60.0);
|
||||
g.set_param(
|
||||
dr_pipeline::ops::capture_sharpen::ID,
|
||||
dr_pipeline::ops::capture_sharpen::AMOUNT,
|
||||
60.0,
|
||||
);
|
||||
g
|
||||
}),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The measurement itself
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Nearest-rank percentiles over a set of frame times, in milliseconds.
|
||||
///
|
||||
/// Nearest-rank rather than an interpolating definition because the samples
|
||||
/// *are* the population — there is no distribution being estimated, only a
|
||||
/// hundred frames that either happened inside the budget or did not. At
|
||||
/// `FRAMES = 100` the 99th percentile is the second-worst frame, which is the
|
||||
/// honest reading of "one stutter in a hundred is one too many" without
|
||||
/// letting a single scheduler hiccup on an unrelated process decide the
|
||||
/// verdict.
|
||||
struct Percentiles {
|
||||
p50: f64,
|
||||
p99: f64,
|
||||
max: f64,
|
||||
}
|
||||
|
||||
impl Percentiles {
|
||||
fn of(mut samples: Vec<f64>) -> Self {
|
||||
samples.sort_by(f64::total_cmp);
|
||||
let rank = |p: f64| {
|
||||
let n = samples.len();
|
||||
let i = ((p * n as f64).ceil() as usize).clamp(1, n) - 1;
|
||||
samples[i]
|
||||
};
|
||||
Self {
|
||||
p50: rank(0.50),
|
||||
p99: rank(0.99),
|
||||
max: samples[samples.len() - 1],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Run {
|
||||
/// CPU: assembling the fused shader alone.
|
||||
shader: Percentiles,
|
||||
/// CPU: everything `DevelopSession::render` does before it dispatches —
|
||||
/// the fused shader, the detail chain, and the invalidation hash the
|
||||
/// colour key comes from. Split out from [`Self::shader`] because if the
|
||||
/// expensive half of a frame turns out to be string formatting on the UI
|
||||
/// thread, no amount of tiling helps and the conclusion is a different
|
||||
/// one.
|
||||
cpu: Percentiles,
|
||||
/// Submit plus wait for the device to go idle.
|
||||
frame: Percentiles,
|
||||
/// CPU and GPU summed **per frame**, then ranked.
|
||||
///
|
||||
/// Not the sum of the two percentiles above, which would be a number no
|
||||
/// frame ever took: the CPU's worst frame and the GPU's worst frame are
|
||||
/// not generally the same frame, and adding them invents a stutter that
|
||||
/// did not happen. This is the column the budget is judged on.
|
||||
total: Percentiles,
|
||||
/// The very first frame of all, warm-up included — pipeline compilation
|
||||
/// and target allocation. Reported because it is real (it is what a
|
||||
/// photograph opening costs) and because it must not be inside the
|
||||
/// percentiles.
|
||||
first_frame_ms: f64,
|
||||
/// Fused dispatches over the measured frames. `FRAMES` when the colour
|
||||
/// chain is moving, 0 when only a detail parameter is.
|
||||
colour_dispatches: usize,
|
||||
}
|
||||
|
||||
/// Render `FRAMES` frames, moving a parameter between each, and time them.
|
||||
///
|
||||
/// `drag` receives the frame index and is expected to move whatever this row
|
||||
/// is measuring the drag of. It is called for the warm-up frames too, so that
|
||||
/// nothing measured is the first of its kind.
|
||||
fn measure(
|
||||
ctx: &GpuContext,
|
||||
adjust: &mut AdjustPass,
|
||||
source: &DemosaicedImage,
|
||||
graph: &mut EditGraph,
|
||||
size: (u32, u32),
|
||||
drag: impl Fn(&mut EditGraph, usize),
|
||||
) -> Run {
|
||||
// A constant, because every row here renders the same photograph. In the
|
||||
// app this is the `VersionId` mixed with the colour invalidation — see
|
||||
// `render_detailed`'s note on why the caller owns it.
|
||||
const COLOUR_KEY: u64 = 0x0dd_ba11;
|
||||
|
||||
let src = source.size();
|
||||
let mut first_frame_ms = f64::NAN;
|
||||
|
||||
for i in 0..WARMUP {
|
||||
drag(graph, i);
|
||||
let t = Instant::now();
|
||||
frame(ctx, adjust, source, graph, src, size, COLOUR_KEY);
|
||||
if i == 0 {
|
||||
first_frame_ms = t.elapsed().as_secs_f64() * 1e3;
|
||||
}
|
||||
}
|
||||
|
||||
let dispatches_before = adjust.colour_dispatches();
|
||||
let mut shader_ms = Vec::with_capacity(FRAMES);
|
||||
let mut cpu_ms = Vec::with_capacity(FRAMES);
|
||||
let mut gpu_ms = Vec::with_capacity(FRAMES);
|
||||
let mut total_ms = Vec::with_capacity(FRAMES);
|
||||
|
||||
for i in 0..FRAMES {
|
||||
drag(graph, WARMUP + i);
|
||||
|
||||
// The same three calls `DevelopSession::render` makes, in the same
|
||||
// order, so that this is the develop view's frame and not an
|
||||
// idealisation of it.
|
||||
let t0 = Instant::now();
|
||||
let shader = graph.compose();
|
||||
let after_shader = t0.elapsed();
|
||||
let detail = graph.compose_detail(src, size);
|
||||
let colour_key = graph.invalidation().through(dr_pipeline::Affects::Colour);
|
||||
let cpu = t0.elapsed();
|
||||
|
||||
let t1 = Instant::now();
|
||||
adjust
|
||||
.render_detailed(
|
||||
source,
|
||||
&shader,
|
||||
size.0,
|
||||
size.1,
|
||||
None,
|
||||
&detail,
|
||||
colour_key ^ COLOUR_KEY,
|
||||
)
|
||||
.expect("render");
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
let gpu = t1.elapsed();
|
||||
|
||||
shader_ms.push(after_shader.as_secs_f64() * 1e3);
|
||||
cpu_ms.push(cpu.as_secs_f64() * 1e3);
|
||||
gpu_ms.push(gpu.as_secs_f64() * 1e3);
|
||||
total_ms.push((cpu + gpu).as_secs_f64() * 1e3);
|
||||
}
|
||||
|
||||
Run {
|
||||
shader: Percentiles::of(shader_ms),
|
||||
cpu: Percentiles::of(cpu_ms),
|
||||
frame: Percentiles::of(gpu_ms),
|
||||
total: Percentiles::of(total_ms),
|
||||
first_frame_ms,
|
||||
colour_dispatches: adjust.colour_dispatches() - dispatches_before,
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame, composition included, with nothing timed. The warm-up path.
|
||||
fn frame(
|
||||
ctx: &GpuContext,
|
||||
adjust: &mut AdjustPass,
|
||||
source: &DemosaicedImage,
|
||||
graph: &EditGraph,
|
||||
src: (u32, u32),
|
||||
size: (u32, u32),
|
||||
colour_key: u64,
|
||||
) {
|
||||
let shader = graph.compose();
|
||||
let detail = graph.compose_detail(src, size);
|
||||
let key = graph.invalidation().through(dr_pipeline::Affects::Colour) ^ colour_key;
|
||||
adjust
|
||||
.render_detailed(source, &shader, size.0, size.1, None, &detail, key)
|
||||
.expect("render");
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
}
|
||||
|
||||
fn header() {
|
||||
println!(
|
||||
" {:>11} {:>5} {:>8} {:>8} {:>8} {:>8} {:>8} {:>7}",
|
||||
"size", "chain", "shader", "cpu p99", "gpu p50", "gpu p99", "TOTAL", "first"
|
||||
);
|
||||
}
|
||||
|
||||
fn row(size: (u32, u32), chain: &str, run: &Run) {
|
||||
println!(
|
||||
" {:>5}x{:<5} {:>5} {:>6.2}ms {:>6.2}ms {:>6.2}ms {:>6.2}ms {:>6.2}ms {:>5.0}ms{}",
|
||||
size.0,
|
||||
size.1,
|
||||
chain,
|
||||
run.shader.p99,
|
||||
run.cpu.p99,
|
||||
run.frame.p50,
|
||||
run.frame.p99,
|
||||
run.total.p99,
|
||||
run.first_frame_ms,
|
||||
if run.total.p99 > BUDGET_MS {
|
||||
" OVER"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The view rect that puts one render pixel on one source pixel.
|
||||
///
|
||||
/// This is FR-DSP-5's mechanism stated as arithmetic: the render target keeps
|
||||
/// its size while the sampled region shrinks, so a view covering exactly
|
||||
/// `render / source` of the frame samples one-for-one. Nothing anywhere
|
||||
/// switches to a "full resolution path"; the zoom *is* the full-resolution
|
||||
/// path.
|
||||
fn one_to_one(render: (u32, u32)) -> CropRect {
|
||||
let w = render.0 as f32 / SOURCE.0 as f32;
|
||||
let h = render.1 as f32 / SOURCE.1 as f32;
|
||||
CropRect {
|
||||
// Centred, so the sampled region is somewhere a person would actually
|
||||
// look and not a corner the caches treat differently.
|
||||
x: (1.0 - w) * 0.5,
|
||||
y: (1.0 - h) * 0.5,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// A 60 MP source with detail at every scale.
|
||||
///
|
||||
/// Not flat and not noise. A flat frame lets the memory system serve every
|
||||
/// sample from one cache line, which flatters the wide kernels of M3 by an
|
||||
/// amount that has nothing to do with photographs; pure noise does the
|
||||
/// opposite. This is a coarse gradient with a fine dither on top, which is
|
||||
/// closer to a real frame's spectrum than either and costs one multiply per
|
||||
/// pixel to generate.
|
||||
fn synthetic_source(ctx: &GpuContext) -> DemosaicedImage {
|
||||
let (w, h) = SOURCE;
|
||||
let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
|
||||
for y in 0..h as usize {
|
||||
let row = y * (w as usize) * 4;
|
||||
for x in 0..w as usize {
|
||||
// A cheap integer hash for the fine structure, so neighbouring
|
||||
// pixels differ and a bilateral filter has something to reject.
|
||||
let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 13;
|
||||
let dither = (n & 0x1f) as u32;
|
||||
let gx = (x * 200 / w as usize) as u32;
|
||||
let gy = (y * 55 / h as usize) as u32;
|
||||
let px = &mut rgba[row + x * 4..row + x * 4 + 4];
|
||||
px[0] = (30 + gx + dither).min(255) as u8;
|
||||
px[1] = (40 + gy + dither).min(255) as u8;
|
||||
px[2] = (60 + gx / 2 + gy + dither).min(255) as u8;
|
||||
px[3] = 255;
|
||||
}
|
||||
}
|
||||
DemosaicedImage::from_rgba8(ctx, &rgba, w, h).expect("upload source")
|
||||
}
|
||||
|
||||
fn build_chain(chain: Chain) -> EditGraph {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
match chain {
|
||||
Chain::One => {
|
||||
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.3);
|
||||
}
|
||||
Chain::Five => {
|
||||
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.3);
|
||||
graph.set_param(contrast::ID, contrast::CONTRAST, 25.0);
|
||||
graph.set_param(
|
||||
highlights_shadows::ID,
|
||||
highlights_shadows::HIGHLIGHTS,
|
||||
-40.0,
|
||||
);
|
||||
graph.set_param(blacks_whites::ID, blacks_whites::BLACKS, -20.0);
|
||||
graph.set_param(vibrance::ID, vibrance::VIBRANCE, 35.0);
|
||||
}
|
||||
Chain::AllPoint => {
|
||||
activate_everything(&mut graph, false);
|
||||
}
|
||||
Chain::Everything => {
|
||||
activate_everything(&mut graph, true);
|
||||
}
|
||||
}
|
||||
graph
|
||||
}
|
||||
|
||||
/// Move every parameter of every operation off its default.
|
||||
///
|
||||
/// Written against [`EditGraph::capabilities`] rather than as a list of
|
||||
/// operations, for the same reason the panel is: this bench must not need
|
||||
/// editing when an operation is added, or it will quietly stop measuring "all
|
||||
/// of them" on the first day somebody declares a new node.
|
||||
///
|
||||
/// A quarter of the way from the default towards the maximum, which is a
|
||||
/// setting a photographer might plausibly reach and — far more importantly —
|
||||
/// is *active*, since an operation sitting at its neutral contributes no
|
||||
/// fragment at all and would make this row a shorter chain wearing a longer
|
||||
/// chain's label.
|
||||
///
|
||||
/// `neighbourhood` selects whether the operations declaring
|
||||
/// [`Attribute::Detail`] are moved as well. Left off, what remains is exactly
|
||||
/// the set that contributes a fragment to the fused shader — see [`Chain`] for
|
||||
/// why that distinction is the point of this bench.
|
||||
fn activate_everything(graph: &mut EditGraph, neighbourhood: bool) {
|
||||
let moves: Vec<(OpId, ParamId, f32)> = graph
|
||||
.capabilities()
|
||||
.iter()
|
||||
.filter(|op| neighbourhood || !op.attributes.contains(&Attribute::Detail))
|
||||
.flat_map(|op| {
|
||||
op.params.iter().map(move |p| {
|
||||
let value = match p.kind {
|
||||
ParamKind::Scalar { min, max, .. } => {
|
||||
// Towards whichever end is further away, so a
|
||||
// parameter defaulting to its maximum still moves.
|
||||
let far = if (max - p.default).abs() >= (p.default - min).abs() {
|
||||
max
|
||||
} else {
|
||||
min
|
||||
};
|
||||
p.default + (far - p.default) * 0.25
|
||||
}
|
||||
ParamKind::Bool => 1.0,
|
||||
// The second variant when there is one. The first is the
|
||||
// default by construction — see `ParamDescriptor::choice`.
|
||||
// Bound by reference: a descriptor's variants became an
|
||||
// owned `Vec` when descriptors stopped being `&'static`,
|
||||
// and this arm only ever reads the length.
|
||||
ParamKind::Enum { ref variants } => {
|
||||
if variants.len() > 1 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
};
|
||||
(op.id, p.id, value)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for (op, param, value) in moves {
|
||||
graph.set_param(op, param, value);
|
||||
}
|
||||
|
||||
// The film stock is not a slider and so is not reachable through the loop
|
||||
// above: `FilmSim::is_active` is true when it has tables and false
|
||||
// otherwise (see `graph::Film`). Without this the "all" row would be the
|
||||
// whole chain minus its single most expensive node, which is a 3D lookup
|
||||
// and a pair of curve textures.
|
||||
graph.set_film(Some(dr_pipeline::graph::Film {
|
||||
stock: STOCK.into(),
|
||||
print: None,
|
||||
tables: film_tables(),
|
||||
}));
|
||||
}
|
||||
|
||||
/// The stock the "every operation" chain renders through. A colour negative
|
||||
/// viewed directly, which is the more expensive of the two paths: the LUT is
|
||||
/// consulted either way, and skipping the print step is one fewer thing that
|
||||
/// could be mistaken for the measurement.
|
||||
const STOCK: &str = "kodak_portra_400";
|
||||
|
||||
/// The stock the "all operations" row renders through, in the layout the pass
|
||||
/// binds. Baked once per row; baking is milliseconds and happens off the frame
|
||||
/// path in the app too.
|
||||
fn film_tables() -> FilmTables {
|
||||
let film = dr_film::find(STOCK).expect("stock");
|
||||
let baked = bake(&Recipe::new(film, None));
|
||||
FilmTables {
|
||||
exposure_matrix: baked.exposure_matrix,
|
||||
curves: baked.curves.clone(),
|
||||
curve_log_min: baked.curve_log_min,
|
||||
curve_log_max: baked.curve_log_max,
|
||||
lut: baked.lut.clone(),
|
||||
density_max: baked.density_max,
|
||||
lut_size: baked.lut_size,
|
||||
// Grain off. It is a per-pixel hash and would be measured; it is also
|
||||
// not part of every edit, and the chain being measured here is "every
|
||||
// operation active", not "every option of every operation".
|
||||
grain_particles: [0.0; 3],
|
||||
grain_density_max: [baked.density_max; 3],
|
||||
grain_uniformity: 0.97,
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, Subj
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::spot::SpotSet;
|
||||
use dr_pipeline::{ops, EditGraph, Framing};
|
||||
use dr_segment::{SemanticModel, SemanticOptions, Shaped};
|
||||
use dr_types::ColourSpace;
|
||||
@@ -55,7 +56,13 @@ fn main() {
|
||||
// ---- the photograph ---------------------------------------------------
|
||||
let bytes = std::fs::read(&path).expect("read file");
|
||||
let raw = dr_decode::decode(&bytes).expect("decode");
|
||||
// The tag, because the model reads photographs and the sensor stores
|
||||
// scanlines. See `stand_up` below: this example exists to be the shipping
|
||||
// path with pictures attached, so it has to make the same turn the
|
||||
// develop session makes.
|
||||
let orientation = dr_decode::orientation(&bytes).unwrap_or_default();
|
||||
println!("source {} × {}", raw.crop.width, raw.crop.height);
|
||||
println!("turns {}", orientation.quarter_turns);
|
||||
let source = Demosaicer::new(&ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&raw)
|
||||
@@ -93,10 +100,16 @@ fn main() {
|
||||
.collect();
|
||||
|
||||
// ---- find the subject -------------------------------------------------
|
||||
//
|
||||
// Stood up first. A model trained on upright photographs is very bad at
|
||||
// sideways ones, and the proxy above is in the sensor's own orientation
|
||||
// — see `stand_up`.
|
||||
let (upright, uw, uh) = stand_up(&rgb, pw as usize, ph as usize, orientation);
|
||||
|
||||
let t = std::time::Instant::now();
|
||||
let mut model = SemanticModel::embedded().expect("model");
|
||||
let instances = model
|
||||
.detect(&rgb, pw as usize, ph as usize, &SemanticOptions::default())
|
||||
.detect(&upright, uw, uh, &SemanticOptions::default())
|
||||
.expect("detect");
|
||||
println!(
|
||||
"detect {} found in {:.0} ms",
|
||||
@@ -118,10 +131,11 @@ fn main() {
|
||||
subject.class_name, subject.score
|
||||
);
|
||||
|
||||
// Quantised exactly as the develop session does, so this example exercises
|
||||
// the shipping path rather than a shortcut around it.
|
||||
let alpha: Vec<u8> = subject
|
||||
.mask
|
||||
// Laid back down, then quantised exactly as the develop session does, so
|
||||
// this example exercises the shipping path rather than a shortcut around
|
||||
// it. The mask has to end up in *source* space: the composed shader
|
||||
// samples the mask array after the framing map.
|
||||
let alpha: Vec<u8> = lay_down(&subject.mask, uw, uh, orientation)
|
||||
.iter()
|
||||
.map(|&v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
.collect();
|
||||
@@ -315,12 +329,42 @@ fn render_stack(
|
||||
.render(stack, None, Some(&subjects), pw, ph)
|
||||
.expect("rasterise masks");
|
||||
|
||||
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
);
|
||||
adjust
|
||||
.render_masked(source, &shader, ow, oh, Some(array))
|
||||
.expect("render");
|
||||
}
|
||||
|
||||
/// Turn the proxy the way the photographer is looking at it, so the model
|
||||
/// reads a photograph rather than a scanline order — and turn the mask it
|
||||
/// answers with back again, because the composed shader samples masks in
|
||||
/// source space, after the framing map.
|
||||
///
|
||||
/// Both are `dr_types::Orientation`, which is the one place the permutation
|
||||
/// is written: the grid's thumbnails, the develop session's segmentation and
|
||||
/// this example all go through it, so "upright" means one thing across the
|
||||
/// application. A local copy here would be a fourth opinion, and this example
|
||||
/// exists to be the shipping path rather than an imitation of it.
|
||||
fn stand_up(
|
||||
rgb: &[f32],
|
||||
width: usize,
|
||||
height: usize,
|
||||
o: dr_types::Orientation,
|
||||
) -> (Vec<f32>, usize, usize) {
|
||||
let (out, w, h) = o.into_shown(rgb, width as u32, height as u32, 3);
|
||||
(out, w as usize, h as usize)
|
||||
}
|
||||
|
||||
fn lay_down(mask: &[f32], dw: usize, dh: usize, o: dr_types::Orientation) -> Vec<f32> {
|
||||
o.into_stored(mask, dw as u32, dh as u32, 1).0
|
||||
}
|
||||
|
||||
fn fit(w: u32, h: u32, longest: u32) -> (u32, u32) {
|
||||
let s = (longest as f32 / w.max(h) as f32).min(1.0);
|
||||
(
|
||||
|
||||
@@ -1367,8 +1367,7 @@ mod tests {
|
||||
// instead of the kernel under test. The chain still
|
||||
// carries the resolve pass that finishes the render, and
|
||||
// that generated source is worth compiling too.
|
||||
let scale = g.render_scale(img.size(), (16, 16));
|
||||
let detail = g.compose_detail(scale);
|
||||
let detail = g.compose_detail(img.size(), (16, 16));
|
||||
let key = g.invalidation().through(dr_pipeline::Affects::Colour);
|
||||
pass.render_detailed(&img, &shader, 16, 16, None, &detail, key)
|
||||
.unwrap_or_else(|e| {
|
||||
@@ -1774,8 +1773,7 @@ mod tests {
|
||||
// find those operations in neither stage and fail for a reason that is
|
||||
// not a defect. Shadows the smaller size deliberately.
|
||||
let (w, h) = g.output_size(512, 512);
|
||||
let scale = g.render_scale((512, 512), (w, h));
|
||||
let detail = g.compose_detail_for(scale, dr_types::ColourSpace::Srgb);
|
||||
let detail = g.compose_detail_for((512, 512), (w, h), dr_types::ColourSpace::Srgb);
|
||||
assert!(
|
||||
!detail.is_empty(),
|
||||
"the detail half composed nothing, so nothing of it was compiled"
|
||||
|
||||
@@ -149,6 +149,8 @@ pub(crate) struct DetailRunner {
|
||||
/// Compiled pipelines by pass structure hash.
|
||||
cache: HashMap<u64, wgpu::ComputePipeline>,
|
||||
pool: Intermediates,
|
||||
/// See [`placeholder_instances`].
|
||||
no_instances: wgpu::Buffer,
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
@@ -156,6 +158,22 @@ struct Layout {
|
||||
pipeline: wgpu::PipelineLayout,
|
||||
}
|
||||
|
||||
/// What binding 3 holds for a pass that declared no instance list.
|
||||
///
|
||||
/// One zeroed element, allocated once. Zero-length storage buffers cannot be
|
||||
/// bound, and the passes that read this binding are exactly the ones that
|
||||
/// uploaded something of their own, so nothing ever reads the placeholder's
|
||||
/// contents — it exists to keep one bind group layout serving both kinds of
|
||||
/// pass.
|
||||
fn placeholder_instances(ctx: &GpuContext) -> wgpu::Buffer {
|
||||
ctx.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("detail-instances-placeholder"),
|
||||
contents: bytemuck::cast_slice(&[[0.0f32; 4]]),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
})
|
||||
}
|
||||
|
||||
impl DetailRunner {
|
||||
pub(crate) fn new(ctx: &GpuContext) -> Self {
|
||||
Self {
|
||||
@@ -164,6 +182,7 @@ impl DetailRunner {
|
||||
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
|
||||
cache: HashMap::new(),
|
||||
pool: Intermediates::new(),
|
||||
no_instances: placeholder_instances(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +249,24 @@ impl DetailRunner {
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
|
||||
// TRACES: FR-DEV-8
|
||||
// The instance list, uploaded only by the passes that have one. A
|
||||
// kernel pass — which is every pass that is a convolution — is
|
||||
// handed the placeholder allocated once in `new`, because a storage
|
||||
// buffer of length zero is not bindable and allocating a fresh
|
||||
// sixteen bytes per pass per frame is the per-frame allocation this
|
||||
// module's documentation exists to refuse.
|
||||
let instances = (!pass.storage.is_empty()).then(|| {
|
||||
self.ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("detail-instances"),
|
||||
contents: bytemuck::cast_slice(pass.storage.as_slice()),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
})
|
||||
});
|
||||
let instances = instances.as_ref().unwrap_or(&self.no_instances);
|
||||
|
||||
let bind_group = self
|
||||
.ctx
|
||||
.device
|
||||
@@ -249,6 +286,10 @@ impl DetailRunner {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(destination),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: instances.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -338,6 +379,20 @@ impl DetailRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// A read-only storage buffer entry, as `mask.rs` declares its strokes.
|
||||
fn storage_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
fn new(ctx: &GpuContext, format: wgpu::TextureFormat, label: &str) -> Self {
|
||||
let bind_group = ctx
|
||||
@@ -376,6 +431,14 @@ impl Layout {
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
// TRACES: FR-DEV-8
|
||||
// The instance list, for a pass whose work is a list rather
|
||||
// than a kernel (`DetailPass::storage`). Every other pass
|
||||
// gets `Intermediates`' placeholder here — one entry on both
|
||||
// layouts rather than two more layouts, since a convolution
|
||||
// that never reads the buffer costs nothing for it being
|
||||
// bound.
|
||||
storage_entry(3),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -87,7 +87,8 @@ fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
|
||||
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! TRACES: FR-DEV-8
|
||||
//! The instance binding: a detail pass whose work is a list, not a kernel.
|
||||
//!
|
||||
//! Spot removal needs a detail pass to read a variable number of records —
|
||||
//! sixty-four repairs and one repair are the same shader with a different
|
||||
//! buffer behind it. That is binding 3, and this file proves the three things
|
||||
//! about it that a picture would not tell you clearly:
|
||||
//!
|
||||
//! - the data uploaded is the data the shader reads, in order;
|
||||
//! - a pass that declares no list still runs, bound to the placeholder;
|
||||
//! - the same shader with a *different* list does not recompile, which is what
|
||||
//! keeps placing a spot as cheap as moving a slider.
|
||||
//!
|
||||
//! The passes here are synthetic on purpose. `spot_removal.rs` asserts the
|
||||
//! repair; this asserts the plumbing, so a failure in one does not have to be
|
||||
//! read to work out which of the two broke.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::detail::{ComposedDetail, ComposedDetailPass};
|
||||
use dr_pipeline::{Affects, EditGraph};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
const SIZE: u32 = 8;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat mid-grey frame, so anything the pass adds is the whole answer.
|
||||
fn grey(ctx: &GpuContext) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..SIZE * SIZE).flat_map(|_| [0u8, 0, 0, 255]).collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
|
||||
}
|
||||
|
||||
/// A pass that sums the instance list into the red channel and writes the
|
||||
/// output. Deliberately trivial: the value on screen is then a direct readout
|
||||
/// of what arrived in the buffer.
|
||||
fn summing_pass(storage: Vec<[f32; 4]>, structure: u64) -> ComposedDetailPass {
|
||||
let source = "
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
struct Params { detail_base: vec4<f32> }
|
||||
@group(0) @binding(1) var<uniform> u: Params;
|
||||
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
|
||||
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let dims = textureDimensions(output);
|
||||
if (gid.x >= dims.x || gid.y >= dims.y) { return; }
|
||||
|
||||
// Weighted by index, so a buffer read back to front fails this rather
|
||||
// than passing by symmetry.
|
||||
var total = 0.0;
|
||||
let n = arrayLength(&instances);
|
||||
for (var i = 0u; i < n; i = i + 1u) {
|
||||
total = total + instances[i].x * f32(i + 1u);
|
||||
}
|
||||
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(total, f32(n) / 255.0, 0.0, 1.0));
|
||||
}
|
||||
"
|
||||
.to_string();
|
||||
|
||||
ComposedDetailPass {
|
||||
label: "test/instances".to_string(),
|
||||
source,
|
||||
uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0],
|
||||
storage,
|
||||
radius: 0,
|
||||
writes_output: true,
|
||||
// Any distinct number: the hash is a cache key, and these tests are
|
||||
// what decide whether two chains share a pipeline.
|
||||
structure_hash: structure,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(pass: &mut AdjustPass, source: &DemosaicedImage, chain: &ComposedDetail) -> Vec<u8> {
|
||||
// The fused half has to be composed knowing a detail stage follows it, or
|
||||
// it encodes its own output and the chain would quantise twice — a mismatch
|
||||
// `render_detailed` refuses outright. The probe is the graph that says so;
|
||||
// its own passes are not used, since the chain here is hand-built.
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
graph.set_param(
|
||||
dr_pipeline::descriptor::OpId("detail_probe"),
|
||||
dr_pipeline::descriptor::ParamId("radius"),
|
||||
0.05,
|
||||
);
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, SIZE, SIZE, None, chain, key)
|
||||
.expect("render");
|
||||
pass.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
/// The list arrives whole, in order, and the shader can tell how long it is.
|
||||
#[test]
|
||||
fn a_pass_reads_the_list_it_was_given() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = grey(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
// 0.1·1 + 0.2·2 + 0.3·3 = 1.4, which clips to 1.0 — so instead: values
|
||||
// chosen to land at a quarter, unambiguously distinguishable from both the
|
||||
// "read nothing" answer of 0 and the "read them unweighted" answer of 0.15.
|
||||
let chain = ComposedDetail {
|
||||
passes: vec![summing_pass(
|
||||
vec![[0.05, 0.0, 0.0, 0.0], [0.1, 0.0, 0.0, 0.0]],
|
||||
1,
|
||||
)],
|
||||
};
|
||||
|
||||
let pixels = render(&mut pass, &source, &chain);
|
||||
let (red, green) = (pixels[0], pixels[1]);
|
||||
|
||||
// 0.05·1 + 0.1·2 = 0.25, written straight to an rgba8 target.
|
||||
assert!(
|
||||
red.abs_diff((0.25 * 255.0) as u8) <= 1,
|
||||
"the shader summed {red}, not the list it was handed"
|
||||
);
|
||||
assert_eq!(green, 2, "arrayLength saw both entries");
|
||||
}
|
||||
|
||||
/// A convolution declares no list and must still run: it is bound to the
|
||||
/// placeholder rather than to nothing, because a zero-length storage buffer
|
||||
/// cannot be bound at all and a second bind group layout for the difference
|
||||
/// would be two layouts to keep in step.
|
||||
#[test]
|
||||
fn a_pass_with_no_list_still_runs() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = grey(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let chain = ComposedDetail {
|
||||
passes: vec![summing_pass(Vec::new(), 2)],
|
||||
};
|
||||
|
||||
let pixels = render(&mut pass, &source, &chain);
|
||||
assert_eq!(pixels[0], 0, "the placeholder is zeroed");
|
||||
assert_eq!(pixels[1], 1, "and is exactly one element long");
|
||||
}
|
||||
|
||||
/// The property that makes placing the tenth spot as cheap as moving a slider:
|
||||
/// the list is in the buffer, not in the source, so the pipeline is compiled
|
||||
/// once however many entries arrive.
|
||||
#[test]
|
||||
fn changing_the_list_does_not_recompile() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = grey(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
for count in 1..=6 {
|
||||
let list = (0..count).map(|_| [0.01, 0.0, 0.0, 0.0]).collect();
|
||||
let chain = ComposedDetail {
|
||||
passes: vec![summing_pass(list, 3)],
|
||||
};
|
||||
render(&mut pass, &source, &chain);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
pass.cached_detail_pipelines(),
|
||||
1,
|
||||
"six different lists, one compiled pipeline"
|
||||
);
|
||||
}
|
||||
@@ -101,7 +101,8 @@ fn render(
|
||||
let _ = ctx;
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
@@ -402,7 +403,8 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
|
||||
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
|
||||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
assert!(detail.is_empty());
|
||||
|
||||
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
//! The frame budget, asserted rather than hoped for.
|
||||
//!
|
||||
//! FR-DSP-3 says a slider updates the visible region within one frame budget at
|
||||
//! proxy resolution. Until this file existed nothing checked it, which made it
|
||||
//! a wish — `docs/display-and-extension.md` §3 is blunt about that, and §7 is
|
||||
//! blunt about what tagging an unchecked requirement does to the coverage
|
||||
//! figure.
|
||||
//!
|
||||
//! The measurements this guards are in [`docs/frame-budget.md`], produced by
|
||||
//! `examples/frame_budget.rs`. This file is the part of them that has to keep
|
||||
//! being true: it renders the **whole point-operation chain** through the real
|
||||
//! `render_detailed` for a hundred frames, moving a slider between each, and
|
||||
//! fails if the 99th percentile leaves the budget.
|
||||
//!
|
||||
//! # What it does not cover, said out loud
|
||||
//!
|
||||
//! **The neighbourhood stage is deliberately not in the asserted chain.** It is
|
||||
//! over the budget today — clarity alone is 34 ms at 4K, because its kernel is
|
||||
//! a fraction of the frame and reaches a 52-pixel radius there — and
|
||||
//! `docs/frame-budget.md` records that, names the fix (a base computed at
|
||||
//! reduced resolution) and does not pretend otherwise. Asserting a budget the
|
||||
//! code does not meet would produce a red suite that everyone learns to ignore;
|
||||
//! asserting it on a chain that quietly excluded the expensive stage *without
|
||||
//! saying so* would be the coverage overstatement §7 warns about. So it is
|
||||
//! excluded, loudly, here.
|
||||
//!
|
||||
//! What is asserted is exactly the claim the FR-DSP-2 recommendation rests on:
|
||||
//! that **one fused dispatch over a viewport-sized target is comfortably inside
|
||||
//! the budget**, at a full chain, fit and at 1:1. If that stops being true, the
|
||||
//! recommendation to strike tiled computation from the interactive path stops
|
||||
//! being supported, and this test is what says so.
|
||||
//!
|
||||
//! # Why the percentile and not the mean
|
||||
//!
|
||||
//! A drag is judged by its worst frame. Nearest-rank over 100 frames puts the
|
||||
//! 99th percentile at the second-worst, which is strict enough to catch a
|
||||
//! stutter and forgiving enough that one scheduler hiccup from an unrelated
|
||||
//! process does not decide the verdict.
|
||||
//!
|
||||
//! # Why the CPU half is asserted only in an optimised build
|
||||
//!
|
||||
//! Composing the shader is per-frame work on the UI thread and belongs in the
|
||||
//! budget — `DevelopSession::render` calls `compose` on every frame, and on a
|
||||
//! full chain it is milliseconds of string formatting. But the workspace builds
|
||||
//! its own crates at `opt-level = 0` in dev (see the root `Cargo.toml`), and
|
||||
//! `cargo test` is a dev build, so that formatting runs unoptimised here and
|
||||
//! measures rustc rather than the pipeline. The GPU half is unaffected: a
|
||||
//! shader is compiled by the driver either way.
|
||||
//!
|
||||
//! So the GPU half is always asserted, and the composition is folded in only
|
||||
//! when `debug_assertions` is off. Running `cargo test --release -p dr-gpu`
|
||||
//! therefore checks strictly more than the default run does, and the numbers
|
||||
//! printed on failure say which of the two halves was over.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::ParamKind;
|
||||
use dr_pipeline::ops::exposure;
|
||||
use dr_pipeline::{Affects, Attribute, CropRect, EditGraph, OpId, ParamId};
|
||||
use std::time::Instant;
|
||||
|
||||
/// 60 Hz. FR-DSP-3 does not name a number; this is the one every interactive
|
||||
/// application means by "one frame".
|
||||
const BUDGET_MS: f64 = 16.0;
|
||||
|
||||
/// Measured frames per case. Nearest-rank p99 of 100 is the second-worst.
|
||||
const FRAMES: usize = 100;
|
||||
|
||||
/// Discarded before measurement: the first frame at a size allocates a render
|
||||
/// target and the first frame of a chain compiles a pipeline. Neither recurs
|
||||
/// during a drag, so neither belongs in a drag's percentile.
|
||||
const WARMUP: usize = 12;
|
||||
|
||||
/// A 24 MP source — a full-frame camera, and large enough that a 1:1 view of it
|
||||
/// is a genuine zoom rather than a rounding error.
|
||||
///
|
||||
/// Smaller than the bench's 60 MP on purpose. The fused pass costs what the
|
||||
/// *output* costs, so the source size barely moves these numbers, and 24 MP
|
||||
/// keeps the fixture inside a second even at `opt-level = 0`.
|
||||
const SOURCE: (u32, u32) = (6000, 4000);
|
||||
|
||||
/// The viewport the budget is asserted at: a 16:10 desktop display.
|
||||
///
|
||||
/// Not 4K, and the reason is worth stating. At 4K the fused chain still passes
|
||||
/// with room to spare (4.5 ms of GPU; see `docs/frame-budget.md`), but a test
|
||||
/// that renders 8.3 M pixels a hundred times twice over is four seconds of
|
||||
/// suite time to re-establish a conclusion 4.1 M pixels already establishes.
|
||||
const VIEWPORT: (u32, u32) = (2560, 1600);
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
// CI runners and headless machines may have no usable adapter. Skip rather
|
||||
// than fail, exactly as the rest of this crate's device tests do.
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-3 | FR-DSP-5
|
||||
/// A slider drag on the full point-operation chain stays inside one frame —
|
||||
/// fit, and at 1:1.
|
||||
///
|
||||
/// The develop view's ordinary case, at a full chain rather than a flattering
|
||||
/// one: every operation that contributes a fragment to the fused shader is
|
||||
/// active, and exposure moves between frames exactly as a drag moves it.
|
||||
///
|
||||
/// The 1:1 case is the one FR-DSP-5 names and the one FR-DSP-3's asynchronous
|
||||
/// clause was written for. It is asserted here because the measurement found
|
||||
/// that clause unnecessary rather than merely unimplemented: a 1:1 view is
|
||||
/// *cheaper* than a fit view of the same file, since the dispatch is the same
|
||||
/// size and the reads are contiguous rather than strided. If that ever inverts,
|
||||
/// the argument for striking the clause weakens, and this is what would notice.
|
||||
///
|
||||
/// # One test and not two, deliberately
|
||||
///
|
||||
/// The two cases were two `#[test]` functions until the numbers said otherwise.
|
||||
/// Cargo runs a binary's tests on a thread each, both of these want the same
|
||||
/// GPU, and contending for it took the 1:1 case from 2.5 ms to 14.9 ms — a
|
||||
/// measurement of the test harness that would have flickered either side of the
|
||||
/// budget forever. A timing assertion has to own the device while it runs, and
|
||||
/// the only way to say that in a test binary is to be the only test in it.
|
||||
#[test]
|
||||
fn a_slider_drag_stays_inside_the_frame_budget() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = synthetic_source(&ctx);
|
||||
|
||||
let mut fit = full_point_chain();
|
||||
drag(&ctx, &source, &mut fit, VIEWPORT).assert_inside_budget("proxy resolution, fit", VIEWPORT);
|
||||
|
||||
let mut zoomed = full_point_chain();
|
||||
zoomed.framing_mut().set_view(one_to_one(VIEWPORT));
|
||||
drag(&ctx, &source, &mut zoomed, VIEWPORT)
|
||||
.assert_inside_budget("1:1 on a 24 MP source", VIEWPORT);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The measurement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Run {
|
||||
/// Per frame: compose, compose the detail chain, hash the invalidation.
|
||||
cpu_p99: f64,
|
||||
/// Per frame: submit and wait for the device to go idle.
|
||||
gpu_p99: f64,
|
||||
/// `cpu + gpu` summed within each frame, then ranked. Not the sum of the
|
||||
/// two percentiles above, which would be a frame that never happened.
|
||||
total_p99: f64,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// Fail if the budget was missed, saying which half missed it.
|
||||
///
|
||||
/// In a dev build only the GPU half is judged — see the module
|
||||
/// documentation for why — and the CPU figure is still printed, because a
|
||||
/// reader looking at a failure wants both numbers even when only one of
|
||||
/// them is the verdict.
|
||||
fn assert_inside_budget(&self, case: &str, viewport: (u32, u32)) {
|
||||
let judged = if cfg!(debug_assertions) {
|
||||
self.gpu_p99
|
||||
} else {
|
||||
self.total_p99
|
||||
};
|
||||
assert!(
|
||||
judged <= BUDGET_MS,
|
||||
"{case} at {}x{}: p99 of {FRAMES} frames was {judged:.2} ms, over the \
|
||||
{BUDGET_MS:.0} ms budget (cpu {:.2} ms, gpu {:.2} ms, total {:.2} ms). \
|
||||
FR-DSP-3 is what this violates; docs/frame-budget.md holds the \
|
||||
numbers it used to be.",
|
||||
viewport.0,
|
||||
viewport.1,
|
||||
self.cpu_p99,
|
||||
self.gpu_p99,
|
||||
self.total_p99,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `FRAMES` frames with the exposure slider moving between each.
|
||||
///
|
||||
/// The three calls before the dispatch are the three `DevelopSession::render`
|
||||
/// makes, in the same order, so this is the develop view's frame rather than an
|
||||
/// idealisation of it.
|
||||
fn drag(
|
||||
ctx: &GpuContext,
|
||||
source: &DemosaicedImage,
|
||||
graph: &mut EditGraph,
|
||||
viewport: (u32, u32),
|
||||
) -> Run {
|
||||
// Stands for the `VersionId` the app mixes in. Constant because every frame
|
||||
// here is the same photograph.
|
||||
const PHOTOGRAPH: u64 = 0x0dd_ba11;
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
let src = source.size();
|
||||
let (w, h) = viewport;
|
||||
|
||||
let mut cpu = Vec::with_capacity(FRAMES);
|
||||
let mut gpu = Vec::with_capacity(FRAMES);
|
||||
let mut total = Vec::with_capacity(FRAMES);
|
||||
|
||||
for i in 0..WARMUP + FRAMES {
|
||||
// A hundredth of a stop per frame: what a drag does, and what stops
|
||||
// `render_detailed` reusing the previous frame's colour result and
|
||||
// turning this into a measurement of nothing.
|
||||
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.30 + i as f32 * 0.01);
|
||||
|
||||
let t0 = Instant::now();
|
||||
let shader = graph.compose();
|
||||
let detail = graph.compose_detail(src, viewport);
|
||||
let colour_key = graph.invalidation().through(Affects::Colour) ^ PHOTOGRAPH;
|
||||
let cpu_elapsed = t0.elapsed();
|
||||
|
||||
let t1 = Instant::now();
|
||||
adjust
|
||||
.render_detailed(source, &shader, w, h, None, &detail, colour_key)
|
||||
.expect("render");
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
let gpu_elapsed = t1.elapsed();
|
||||
|
||||
if i >= WARMUP {
|
||||
cpu.push(cpu_elapsed.as_secs_f64() * 1e3);
|
||||
gpu.push(gpu_elapsed.as_secs_f64() * 1e3);
|
||||
total.push((cpu_elapsed + gpu_elapsed).as_secs_f64() * 1e3);
|
||||
}
|
||||
}
|
||||
|
||||
Run {
|
||||
cpu_p99: p99(cpu),
|
||||
gpu_p99: p99(gpu),
|
||||
total_p99: p99(total),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest-rank 99th percentile.
|
||||
///
|
||||
/// Nearest-rank because the samples *are* the population: there is no
|
||||
/// distribution being estimated, only a hundred frames that either fitted in
|
||||
/// the budget or did not.
|
||||
fn p99(mut samples: Vec<f64>) -> f64 {
|
||||
samples.sort_by(f64::total_cmp);
|
||||
let n = samples.len();
|
||||
samples[((0.99 * n as f64).ceil() as usize).clamp(1, n) - 1]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every operation that contributes a fragment to the fused shader, active.
|
||||
///
|
||||
/// Built from [`EditGraph::capabilities`] rather than from a list of operation
|
||||
/// names, for the same reason the develop panel is: declaring a new node must
|
||||
/// not silently shrink what this test calls "the full chain". A quarter of the
|
||||
/// way from each parameter's default towards whichever end is further from it,
|
||||
/// which is a plausible setting and — the part that matters — is never the
|
||||
/// neutral, since a neutral operation contributes nothing at all.
|
||||
///
|
||||
/// The film stock is not reachable this way (it is a choice of material, not a
|
||||
/// slider) and is left off. It is one texture lookup and two curve reads; the
|
||||
/// bench includes it and it is worth about a millisecond at this size.
|
||||
fn full_point_chain() -> EditGraph {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let moves: Vec<(OpId, ParamId, f32)> = graph
|
||||
.capabilities()
|
||||
.iter()
|
||||
// The neighbourhood operations. See the module documentation for why
|
||||
// they are not here.
|
||||
.filter(|op| !op.attributes.contains(&Attribute::Detail))
|
||||
.flat_map(|op| {
|
||||
op.params.iter().map(move |p| {
|
||||
let value = match p.kind {
|
||||
ParamKind::Scalar { min, max, .. } => {
|
||||
let far = if (max - p.default).abs() >= (p.default - min).abs() {
|
||||
max
|
||||
} else {
|
||||
min
|
||||
};
|
||||
p.default + (far - p.default) * 0.25
|
||||
}
|
||||
ParamKind::Bool => 1.0,
|
||||
// Bound by reference: a descriptor's variants became an
|
||||
// owned `Vec` when descriptors stopped being `&'static`,
|
||||
// and this arm only ever reads the length.
|
||||
ParamKind::Enum { ref variants } => {
|
||||
if variants.len() > 1 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
};
|
||||
(op.id, p.id, value)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for (op, param, value) in moves {
|
||||
graph.set_param(op, param, value);
|
||||
}
|
||||
graph
|
||||
}
|
||||
|
||||
/// The view rect that puts one render pixel on one source pixel, centred.
|
||||
fn one_to_one(render: (u32, u32)) -> CropRect {
|
||||
let w = render.0 as f32 / SOURCE.0 as f32;
|
||||
let h = render.1 as f32 / SOURCE.1 as f32;
|
||||
CropRect {
|
||||
x: (1.0 - w) * 0.5,
|
||||
y: (1.0 - h) * 0.5,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
/// A source with structure at every scale.
|
||||
///
|
||||
/// Not flat: a flat frame lets the memory system serve every sample of every
|
||||
/// pixel from one cache line, which flatters a bandwidth-bound pass by an amount
|
||||
/// that has nothing to do with photographs.
|
||||
fn synthetic_source(ctx: &GpuContext) -> DemosaicedImage {
|
||||
let (w, h) = SOURCE;
|
||||
let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
|
||||
for y in 0..h as usize {
|
||||
let row = y * (w as usize) * 4;
|
||||
for x in 0..w as usize {
|
||||
let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 13;
|
||||
let dither = (n & 0x1f) as u32;
|
||||
let gx = (x * 200 / w as usize) as u32;
|
||||
let gy = (y * 55 / h as usize) as u32;
|
||||
let px = &mut rgba[row + x * 4..row + x * 4 + 4];
|
||||
px[0] = (30 + gx + dither).min(255) as u8;
|
||||
px[1] = (40 + gy + dither).min(255) as u8;
|
||||
px[2] = (60 + gx / 2 + gy + dither).min(255) as u8;
|
||||
px[3] = 255;
|
||||
}
|
||||
}
|
||||
DemosaicedImage::from_rgba8(ctx, &rgba, w, h).expect("upload")
|
||||
}
|
||||
@@ -14,6 +14,7 @@ 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::spot::SpotSet;
|
||||
use dr_pipeline::{ops, EditGraph, Framing};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
@@ -65,7 +66,13 @@ fn render_at(
|
||||
h: u32,
|
||||
) -> Vec<u8> {
|
||||
let source = grey_at(ctx, w, h);
|
||||
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
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");
|
||||
|
||||
@@ -108,7 +108,8 @@ fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> {
|
||||
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
@@ -596,7 +597,8 @@ fn texture_contributes_nothing_where_its_scale_does_not_exist() {
|
||||
// description of "a two-pixel surface structure is not present in a
|
||||
// 128-pixel rendering".
|
||||
let scale = graph.render_scale(source.size(), (128, 128));
|
||||
let composed = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
let composed =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
assert_eq!(
|
||||
composed.len(),
|
||||
1,
|
||||
|
||||
@@ -14,6 +14,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass, SubjectMasks};
|
||||
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_segment::Shaped;
|
||||
use dr_types::ColourSpace;
|
||||
@@ -83,7 +84,13 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
||||
.render(stack, None, Some(&subjects), PROXY, PROXY)
|
||||
.expect("rasterise");
|
||||
|
||||
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render_masked(&source, &shader, out, out, Some(array))
|
||||
@@ -95,7 +102,13 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
||||
/// thumbnail were doing.
|
||||
fn render_unmasked(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
||||
let source = grey(ctx, 64);
|
||||
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust.render(&source, &shader, out, out).expect("render");
|
||||
adjust.export_pixels().expect("readback").0
|
||||
@@ -211,7 +224,13 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks.render(stack, None, None, w, h).expect("rasterise");
|
||||
|
||||
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render_masked(&source, &shader, w, h, Some(array))
|
||||
|
||||
@@ -73,7 +73,8 @@ fn render_at(
|
||||
scale: RenderScale,
|
||||
) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
|
||||
.expect("render");
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
//! TRACES: FR-DEV-8
|
||||
//! Repairs, drawn on a real device.
|
||||
//!
|
||||
//! `dr-pipeline`'s tests assert the model and the record packing; nothing there
|
||||
//! can say whether the disc lands where the photographer put it. That is what
|
||||
//! this file is for, and the cases it covers are the ones where a repair goes
|
||||
//! wrong *quietly*:
|
||||
//!
|
||||
//! - the mark is still there, because the disc landed beside it;
|
||||
//! - the repair works on screen and not in the export, because a length was
|
||||
//! converted in the wrong units;
|
||||
//! - the repair works until the photograph is cropped or rotated, because the
|
||||
//! framing was applied to the pixels and not to the spot.
|
||||
//!
|
||||
//! The frame is a flat grey field with one black mark on it, which makes every
|
||||
//! assertion here countable: a repair either leaves dark pixels or it does not.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::spot::{Spot, SpotMode};
|
||||
use dr_pipeline::{Affects, EditGraph};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
const SIZE: u32 = 128;
|
||||
const GREY: u8 = 128;
|
||||
|
||||
/// Where the mark is, in normalised coordinates, and how big it is in frame
|
||||
/// units. Off-centre on both axes so that a repair landing on a mirrored or
|
||||
/// transposed position fails rather than passing by symmetry.
|
||||
const MARK: (f32, f32) = (0.3, 0.65);
|
||||
const MARK_RADIUS: f32 = 0.03;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat grey frame with one black mark on it — a dust spot, idealised.
|
||||
fn marked_frame(ctx: &GpuContext) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..SIZE * SIZE)
|
||||
.flat_map(|i| {
|
||||
let (x, y) = ((i % SIZE) as f32, (i / SIZE) as f32);
|
||||
let (cx, cy) = (MARK.0 * SIZE as f32, MARK.1 * SIZE as f32);
|
||||
let r = MARK_RADIUS * SIZE as f32;
|
||||
let v = if (x - cx).hypot(y - cy) <= r {
|
||||
0u8
|
||||
} else {
|
||||
GREY
|
||||
};
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
|
||||
}
|
||||
|
||||
/// A repair covering the mark, reading from clean grey to its right.
|
||||
///
|
||||
/// The disc is twice the mark, so the mark sits entirely inside the solid core
|
||||
/// and none of it falls in the feathered rim — otherwise this file would be
|
||||
/// asserting a blend rather than a repair.
|
||||
fn repair(mode: SpotMode) -> Spot {
|
||||
let mut spot = Spot::new(MARK, (0.3, 0.0), MARK_RADIUS * 2.0);
|
||||
spot.mode = mode;
|
||||
spot
|
||||
}
|
||||
|
||||
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let (w, h) = graph.output_size(source.size().0, source.size().1);
|
||||
let (w, h) = (w.min(out), h.min(out));
|
||||
let detail = graph.compose_detail_for(source.size(), (w, h), ColourSpace::Srgb);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, w, h, None, &detail, key)
|
||||
.expect("render");
|
||||
pass.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
/// How many pixels are darker than anything a grey field contains.
|
||||
///
|
||||
/// The mark is the only dark thing in the frame, so this counts what is left of
|
||||
/// it — and counts it wherever it ended up, which is what makes the same
|
||||
/// assertion work after a crop or a rotation.
|
||||
fn dark_pixels(pixels: &[u8]) -> usize {
|
||||
pixels.chunks_exact(4).filter(|p| p[0] < GREY - 24).count()
|
||||
}
|
||||
|
||||
/// The whole feature in one assertion: the mark is there, and then it is not.
|
||||
#[test]
|
||||
fn a_clone_removes_the_mark() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = marked_frame(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let before = dark_pixels(&render(
|
||||
&mut pass,
|
||||
&EditGraph::default_chain(),
|
||||
&source,
|
||||
SIZE,
|
||||
));
|
||||
assert!(before > 20, "the frame is supposed to have a mark on it");
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.spots_mut().place(repair(SpotMode::Clone));
|
||||
|
||||
let after = dark_pixels(&render(&mut pass, &graph, &source, SIZE));
|
||||
assert_eq!(after, 0, "{before} dark pixels before, {after} after");
|
||||
}
|
||||
|
||||
/// The grey the repair lays down has to be the *photograph's* grey. A repair
|
||||
/// that removes the mark by darkening or brightening the disc passes the count
|
||||
/// above and is still visibly a disc.
|
||||
#[test]
|
||||
fn the_patch_is_the_photograph_and_not_an_approximation_of_it() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = marked_frame(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.spots_mut().place(repair(SpotMode::Clone));
|
||||
let pixels = render(&mut pass, &graph, &source, SIZE);
|
||||
|
||||
let centre =
|
||||
((MARK.1 * SIZE as f32) as u32 * SIZE + (MARK.0 * SIZE as f32) as u32) as usize * 4;
|
||||
assert!(
|
||||
pixels[centre].abs_diff(GREY) <= 2,
|
||||
"the repaired centre reads {}, the field is {GREY}",
|
||||
pixels[centre]
|
||||
);
|
||||
}
|
||||
|
||||
/// A repair is stored as a fraction of the frame, so it must land in the same
|
||||
/// *place* whatever size the frame is drawn at — which is the difference
|
||||
/// between a preview that tells the truth and an export that does not
|
||||
/// (FR-DSP-1).
|
||||
#[test]
|
||||
fn a_proxy_and_an_export_repair_the_same_thing() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = marked_frame(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.spots_mut().place(repair(SpotMode::Clone));
|
||||
|
||||
assert_eq!(dark_pixels(&render(&mut pass, &graph, &source, SIZE)), 0);
|
||||
assert_eq!(
|
||||
dark_pixels(&render(&mut pass, &graph, &source, SIZE / 4)),
|
||||
0,
|
||||
"the repair missed the mark at a quarter size"
|
||||
);
|
||||
}
|
||||
|
||||
/// The framing is applied to the spot, not to the pixels afterwards. If the
|
||||
/// centre were mapped and the offset were not, this is the test that fails: the
|
||||
/// disc would land on the mark and read from the wrong side of the frame.
|
||||
#[test]
|
||||
fn a_rotated_photograph_carries_its_repairs_round_with_it() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = marked_frame(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.spots_mut().place(repair(SpotMode::Clone));
|
||||
graph.rotate_quarters(1);
|
||||
|
||||
assert_eq!(
|
||||
dark_pixels(&render(&mut pass, &graph, &source, SIZE)),
|
||||
0,
|
||||
"the mark came back when the frame was turned"
|
||||
);
|
||||
}
|
||||
|
||||
/// And a crop, which moves the origin and the scale at once.
|
||||
#[test]
|
||||
fn a_crop_carries_its_repairs_with_it() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = marked_frame(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.spots_mut().place(repair(SpotMode::Clone));
|
||||
graph.set_crop(dr_pipeline::CropRect {
|
||||
x: 0.1,
|
||||
y: 0.4,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
dark_pixels(&render(&mut pass, &graph, &source, SIZE)),
|
||||
0,
|
||||
"the mark is inside this crop and the repair no longer covers it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Opacity is a real control: at zero the repair is off, and the mark is
|
||||
/// exactly as it was.
|
||||
#[test]
|
||||
fn a_transparent_repair_draws_nothing() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = marked_frame(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let bare = dark_pixels(&render(
|
||||
&mut pass,
|
||||
&EditGraph::default_chain(),
|
||||
&source,
|
||||
SIZE,
|
||||
));
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let mut spot = repair(SpotMode::Clone);
|
||||
spot.set_opacity(0.0);
|
||||
graph.spots_mut().place(spot);
|
||||
|
||||
assert_eq!(dark_pixels(&render(&mut pass, &graph, &source, SIZE)), bare);
|
||||
}
|
||||
|
||||
/// Placing repairs must not recompile: the list is in a storage buffer and the
|
||||
/// shader never learns how long it is, so a photographer working through a
|
||||
/// dusty sky pays one compilation.
|
||||
#[test]
|
||||
fn placing_repairs_compiles_one_pipeline() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = marked_frame(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
for i in 0..6 {
|
||||
let y = 0.1 + 0.1 * i as f32;
|
||||
graph
|
||||
.spots_mut()
|
||||
.place(Spot::new((0.5, y), (0.1, 0.0), 0.02));
|
||||
render(&mut pass, &graph, &source, SIZE);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
pass.cached_detail_pipelines(),
|
||||
1,
|
||||
"six repairs, one compiled pipeline"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heal (FR-DEV-8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A frame whose brightness ramps across it, with one black mark on it.
|
||||
///
|
||||
/// This is the case that separates the two modes. A clone copies a patch from
|
||||
/// somewhere else on the ramp, so it arrives at the wrong level and leaves a
|
||||
/// disc of the right texture and the wrong tone; a heal carries the difference
|
||||
/// across from the boundary and leaves nothing.
|
||||
fn ramped_frame(ctx: &GpuContext) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..SIZE * SIZE)
|
||||
.flat_map(|i| {
|
||||
let (x, y) = ((i % SIZE) as f32, (i / SIZE) as f32);
|
||||
let (cx, cy) = (MARK.0 * SIZE as f32, MARK.1 * SIZE as f32);
|
||||
let r = MARK_RADIUS * SIZE as f32;
|
||||
// 64 at the left edge to 192 at the right: a ramp steep enough that
|
||||
// a clone from a third of a frame away is unmistakably wrong, and
|
||||
// shallow enough to stay well inside the range.
|
||||
let ramp = 64.0 + 128.0 * x / SIZE as f32;
|
||||
let v = if (x - cx).hypot(y - cy) <= r {
|
||||
0u8
|
||||
} else {
|
||||
ramp as u8
|
||||
};
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
|
||||
}
|
||||
|
||||
/// What the ramp says at a column, as the byte the renderer should produce.
|
||||
fn ramp_at(x: u32) -> u8 {
|
||||
(64.0 + 128.0 * x as f32 / SIZE as f32) as u8
|
||||
}
|
||||
|
||||
/// The worst a repair is wrong by, over the disc it covers.
|
||||
///
|
||||
/// Measured against the ramp the photograph would have had if the mark had
|
||||
/// never been there, which is the only definition of "repaired" worth
|
||||
/// asserting: a repair that removes the mark and leaves the wrong tone has not
|
||||
/// repaired anything, it has drawn a different mark.
|
||||
fn worst_error(pixels: &[u8]) -> u8 {
|
||||
let (cx, cy) = (MARK.0 * SIZE as f32, MARK.1 * SIZE as f32);
|
||||
let r = MARK_RADIUS * SIZE as f32;
|
||||
let mut worst = 0u8;
|
||||
for y in 0..SIZE {
|
||||
for x in 0..SIZE {
|
||||
if (x as f32 - cx).hypot(y as f32 - cy) > r {
|
||||
continue;
|
||||
}
|
||||
let got = pixels[((y * SIZE + x) * 4) as usize];
|
||||
worst = worst.max(got.abs_diff(ramp_at(x)));
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
/// The measurement the mode exists for, and the one that decides
|
||||
/// `RIM_SAMPLES`: on a gradient, a heal is right and a clone is not.
|
||||
#[test]
|
||||
fn a_heal_takes_the_tone_from_the_hole_it_fills() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = ramped_frame(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut cloned = EditGraph::default_chain();
|
||||
cloned.spots_mut().place(repair(SpotMode::Clone));
|
||||
let clone_error = worst_error(&render(&mut pass, &cloned, &source, SIZE));
|
||||
|
||||
let mut healed = EditGraph::default_chain();
|
||||
healed.spots_mut().place(repair(SpotMode::Heal));
|
||||
let heal_error = worst_error(&render(&mut pass, &healed, &source, SIZE));
|
||||
|
||||
// The clone is wrong by roughly the ramp across the source offset — about
|
||||
// 38 levels here — and it is wrong across the whole disc.
|
||||
assert!(
|
||||
clone_error > 20,
|
||||
"the clone was supposed to be visibly wrong, and is off by {clone_error}"
|
||||
);
|
||||
// Measured at 0 on the reference device — the ramp is linear, so the
|
||||
// boundary difference is constant all the way round and the membrane
|
||||
// reproduces it exactly. The tolerance is for the rounding a different
|
||||
// driver may do, not for the method being approximate here.
|
||||
assert!(
|
||||
heal_error <= 1,
|
||||
"the heal is off by {heal_error} levels; the clone it has to beat is off by {clone_error}"
|
||||
);
|
||||
}
|
||||
|
||||
/// And the repair still has to remove the mark: a membrane that matched the
|
||||
/// boundary while leaving the black disc underneath would pass the measurement
|
||||
/// above by averaging its way past it.
|
||||
#[test]
|
||||
fn a_heal_removes_the_mark_as_well_as_matching_the_tone() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = ramped_frame(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.spots_mut().place(repair(SpotMode::Heal));
|
||||
let pixels = render(&mut pass, &graph, &source, SIZE);
|
||||
|
||||
let mark = (MARK.0 * SIZE as f32) as u32;
|
||||
for x in mark - 2..=mark + 2 {
|
||||
let got = pixels[(((MARK.1 * SIZE as f32) as u32 * SIZE + x) * 4) as usize];
|
||||
assert!(
|
||||
got.abs_diff(ramp_at(x)) <= 3,
|
||||
"column {x} reads {got}, the ramp says {}",
|
||||
ramp_at(x)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A heal on a flat field is a clone: the boundary difference is zero all the
|
||||
/// way round, so the membrane is zero and neither mode has anything to add.
|
||||
/// Worth pinning, because a membrane that quietly tinted a flat repair would
|
||||
/// be invisible in the gradient test above.
|
||||
#[test]
|
||||
fn a_heal_on_a_flat_field_changes_nothing_a_clone_would_not() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = marked_frame(&ctx);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut cloned = EditGraph::default_chain();
|
||||
cloned.spots_mut().place(repair(SpotMode::Clone));
|
||||
let a = render(&mut pass, &cloned, &source, SIZE);
|
||||
|
||||
let mut healed = EditGraph::default_chain();
|
||||
healed.spots_mut().place(repair(SpotMode::Heal));
|
||||
let b = render(&mut pass, &healed, &source, SIZE);
|
||||
|
||||
let worst = a
|
||||
.iter()
|
||||
.zip(&b)
|
||||
.map(|(x, y)| x.abs_diff(*y))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
assert!(
|
||||
worst <= 1,
|
||||
"heal and clone differ by {worst} on a flat field"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! TRACES: FR-DSP-5
|
||||
//! Zooming to 1:1 samples the source, pixel for pixel.
|
||||
//!
|
||||
//! FR-DSP-5: *"Fit, 1:1, and arbitrary zoom levels. At 1:1 and above, the
|
||||
//! pipeline operates on the visible crop at full source resolution."*
|
||||
//!
|
||||
//! `Framing::view` shrinks the sampled region while the render target keeps its
|
||||
//! size, so zooming *raises* the resolution the pipeline works at rather than
|
||||
//! magnifying pixels it has already drawn. There is no second full-resolution
|
||||
//! code path — the zoom is the full-resolution path — which is why the
|
||||
//! requirement has been satisfied for some time without anyone tagging it.
|
||||
//!
|
||||
//! `docs/display-and-extension.md` §7 is the reason this file exists rather
|
||||
//! than a tag on `framing.rs`: a requirement counts as covered when a `TRACES`
|
||||
//! comment names it, and nothing checks that the code under the tag does the
|
||||
//! thing. `FR-DEV-8` is tagged against plumbing a future operation would use.
|
||||
//! So the rule that document sets is that a requirement is closed by **a test
|
||||
//! that would fail if the behaviour were removed**, and these are written to
|
||||
//! fail in exactly that case: delete the view from `Framing::visible_rect` and
|
||||
//! the 1:1 render collapses into the fit render, which
|
||||
//! [`a_proxy_cannot_resolve_the_finest_detail_in_the_source`] establishes
|
||||
//! carries none of the information the 1:1 render reproduces.
|
||||
//!
|
||||
//! # Why the fixture is alternating columns
|
||||
//!
|
||||
//! Because it makes "sampled at source resolution" a *pixel* assertion rather
|
||||
//! than a "something got sharper" one.
|
||||
//!
|
||||
//! The source is one pixel black, one pixel white, all the way across. That is
|
||||
//! the highest spatial frequency the image can hold, and it is exactly what a
|
||||
//! proxy render throws away: a 1024-wide source in a 128-wide viewport maps
|
||||
//! output column `x` to source column `8x + 4`, every one of which has the same
|
||||
//! parity, so the whole proxy comes out flat. No amount of resampling that flat
|
||||
//! image recovers the stripes. If the 1:1 render shows them — and shows them in
|
||||
//! the right phase, from the right place in the source — then it read the
|
||||
//! source and did not magnify the proxy. There is no third explanation.
|
||||
//!
|
||||
//! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it
|
||||
//! non-linear, so the fused shader decodes sRGB before the chain and re-encodes
|
||||
//! after it. Bytes 0 and 255 are fixed points of that round trip, which is why
|
||||
//! the pattern is black and white and why the comparison can be for equality
|
||||
//! rather than within a tolerance.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::{CropRect, EditGraph};
|
||||
|
||||
/// A power of two, so every view rect below is exact in binary32 and the
|
||||
/// mapping from output column to source column is exact arithmetic rather than
|
||||
/// something that happens to round the right way.
|
||||
const SOURCE: u32 = 1024;
|
||||
|
||||
/// The viewport. `SOURCE / RENDER` is 8, so a fit render steps eight source
|
||||
/// columns per output column — four full periods of the pattern.
|
||||
const RENDER: u32 = 128;
|
||||
|
||||
/// Where the 1:1 window sits in the source. **Odd on both axes on purpose**: a
|
||||
/// view that honoured `width` but dropped `x` would land on the opposite phase
|
||||
/// of the stripes and produce an exactly inverted image, which is the most
|
||||
/// likely way for this to be subtly wrong and the one an assertion about
|
||||
/// "contrast" or "variance" would sail straight past.
|
||||
const WINDOW: (u32, u32) = (301, 157);
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
// CI runners and headless machines may have no usable adapter. Skip rather
|
||||
// than fail, exactly as the rest of this crate's device tests do.
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Black and white alternating every column: the finest detail an image can
|
||||
/// carry.
|
||||
fn stripes(ctx: &GpuContext) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..SOURCE * SOURCE)
|
||||
.flat_map(|i| {
|
||||
let v = if (i % SOURCE).is_multiple_of(2) {
|
||||
0u8
|
||||
} else {
|
||||
255
|
||||
};
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, SOURCE, SOURCE).expect("upload")
|
||||
}
|
||||
|
||||
/// What the source holds at `(x, y)` — the same rule [`stripes`] wrote.
|
||||
fn source_byte(x: u32, _y: u32) -> u8 {
|
||||
if x.is_multiple_of(2) {
|
||||
0
|
||||
} else {
|
||||
255
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a neutral edit through `view` and hand back the bytes and the size.
|
||||
///
|
||||
/// Neutral because this is a test about *which pixel* is read, and any active
|
||||
/// operation would put a colour transform between the source byte and the
|
||||
/// rendered one for no gain.
|
||||
fn render(ctx: &GpuContext, source: &DemosaicedImage, view: CropRect) -> (Vec<u8>, u32, u32) {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.framing_mut().set_view(view);
|
||||
let shader = graph.compose();
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render(source, &shader, RENDER, RENDER)
|
||||
.expect("render");
|
||||
adjust.export_pixels().expect("readback")
|
||||
}
|
||||
|
||||
/// The view rect that puts one render pixel on one source pixel, with its
|
||||
/// top-left corner at `WINDOW`.
|
||||
fn one_to_one() -> CropRect {
|
||||
CropRect {
|
||||
x: WINDOW.0 as f32 / SOURCE as f32,
|
||||
y: WINDOW.1 as f32 / SOURCE as f32,
|
||||
width: RENDER as f32 / SOURCE as f32,
|
||||
height: RENDER as f32 / SOURCE as f32,
|
||||
}
|
||||
}
|
||||
|
||||
/// The red channel of one row of a rendered frame.
|
||||
fn row(pixels: &[u8], width: u32, y: u32) -> Vec<u8> {
|
||||
(0..width)
|
||||
.map(|x| pixels[((y * width + x) * 4) as usize])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-5
|
||||
/// The proxy render carries none of the source's finest detail.
|
||||
///
|
||||
/// Half of the argument, and the half that makes the other half mean something:
|
||||
/// if the fit render already showed the stripes, a 1:1 render showing them would
|
||||
/// prove nothing at all. It comes out uniform, so whatever the 1:1 render
|
||||
/// contains cannot have come from resampling it.
|
||||
#[test]
|
||||
fn a_proxy_cannot_resolve_the_finest_detail_in_the_source() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = stripes(&ctx);
|
||||
|
||||
let (pixels, w, h) = render(&ctx, &source, CropRect::default());
|
||||
assert_eq!((w, h), (RENDER, RENDER));
|
||||
|
||||
let first = pixels[0];
|
||||
let uniform = pixels
|
||||
.chunks_exact(4)
|
||||
.all(|px| px[0] == first && px[1] == first && px[2] == first);
|
||||
assert!(
|
||||
uniform,
|
||||
"the fit render of a one-pixel stripe pattern should be flat — a 1024 px \
|
||||
source in a {RENDER} px viewport steps 8 source columns per output \
|
||||
column, so every sample lands on the same phase. It was not: row 0 is \
|
||||
{:?}. Either the sampling changed or the fixture no longer says what it \
|
||||
is meant to, and the 1:1 test below is worthless until this is true \
|
||||
again.",
|
||||
&row(&pixels, w, 0)[..16.min(w as usize)]
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-5
|
||||
/// At 1:1 the render *is* the source region, byte for byte.
|
||||
///
|
||||
/// The requirement's actual content — "at 1:1 and above, the pipeline operates
|
||||
/// on the visible crop at full source resolution" — stated as the strongest
|
||||
/// thing that could be true of it: not that the result is sharper, but that
|
||||
/// output pixel `(x, y)` is source pixel `(WINDOW.0 + x, WINDOW.1 + y)` and
|
||||
/// nothing has been interpolated, averaged or magnified on the way.
|
||||
#[test]
|
||||
fn a_one_to_one_view_reproduces_the_source_pixel_for_pixel() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = stripes(&ctx);
|
||||
|
||||
let (pixels, w, h) = render(&ctx, &source, one_to_one());
|
||||
|
||||
// The render target keeps its size while the sampled region shrinks. That
|
||||
// is the whole mechanism, and a zoom that resized the target would be
|
||||
// magnification rather than resolution.
|
||||
assert_eq!(
|
||||
(w, h),
|
||||
(RENDER, RENDER),
|
||||
"zooming must not change the size of the render target"
|
||||
);
|
||||
|
||||
let mut mismatches = Vec::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let got = pixels[((y * w + x) * 4) as usize];
|
||||
let want = source_byte(WINDOW.0 + x, WINDOW.1 + y);
|
||||
if got != want && mismatches.len() < 8 {
|
||||
mismatches.push((x, y, got, want));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
mismatches.is_empty(),
|
||||
"a 1:1 view starting at {WINDOW:?} must reproduce the source exactly. \
|
||||
First mismatches (x, y, got, want): {mismatches:?}\n\
|
||||
rendered row 0: {:?}\n\
|
||||
source row 0: {:?}\n\
|
||||
An exactly inverted row means the view's *offset* was dropped while its \
|
||||
width was honoured; a flat row means the view was ignored altogether \
|
||||
and the pipeline is still rendering the proxy.",
|
||||
&row(&pixels, w, 0)[..12],
|
||||
(0..12)
|
||||
.map(|x| source_byte(WINDOW.0 + x, WINDOW.1))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-5
|
||||
/// An arbitrary zoom between fit and 1:1 samples at the ratio it asks for.
|
||||
///
|
||||
/// FR-DSP-5 says "fit, 1:1, **and arbitrary zoom levels**", and the two tests
|
||||
/// above only pin the ends. This one takes the middle: a view a quarter of the
|
||||
/// frame wide, which puts four source pixels behind each output pixel, and
|
||||
/// checks that the pipeline reports and samples at that ratio rather than
|
||||
/// snapping to one of the two cases anybody would have special-cased.
|
||||
///
|
||||
/// `render_scale` is asserted alongside the pixels because it is what the
|
||||
/// neighbourhood stage converts kernel radii through: a zoom that moved the
|
||||
/// pixels but not the scale would silently sharpen at the wrong radius, which
|
||||
/// is invisible until somebody compares a preview against an export.
|
||||
#[test]
|
||||
fn an_arbitrary_zoom_samples_at_the_ratio_it_asks_for() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let source = stripes(&ctx);
|
||||
|
||||
// A quarter of the frame: 256 source columns across 128 output columns.
|
||||
let view = CropRect {
|
||||
x: 0.25,
|
||||
y: 0.25,
|
||||
width: 0.25,
|
||||
height: 0.25,
|
||||
};
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.framing_mut().set_view(view);
|
||||
|
||||
// The region on screen is 256 source pixels wide, rendered into 128, so the
|
||||
// ratio is one render pixel per two source pixels.
|
||||
let scale = graph.render_scale((SOURCE, SOURCE), (RENDER, RENDER));
|
||||
assert_eq!(scale.full_size(), (256, 256));
|
||||
assert!(
|
||||
(scale.ratio() - 0.5).abs() < 1e-6,
|
||||
"ratio {}",
|
||||
scale.ratio()
|
||||
);
|
||||
|
||||
let (pixels, w, _) = render(&ctx, &source, view);
|
||||
|
||||
// Where output column `x` reads from, worked through rather than asserted
|
||||
// from a previous run — a test that recomputed this with the shader's own
|
||||
// expression would agree with a bug in it.
|
||||
//
|
||||
// uv = 0.25 + (x + 0.5) / 128 * 0.25 = (128 + x + 0.5) / 512
|
||||
// col = floor(uv * 1024) = floor(256 + 2x + 1.0) = 257 + 2x
|
||||
//
|
||||
// Odd for every `x`, so this zoom lands flat — as the fit render does, and
|
||||
// for the same reason. **The 1.0 is the interesting part.** At a two-to-one
|
||||
// downsample an output pixel's centre falls exactly on the boundary between
|
||||
// the two source pixels it covers, and truncation takes the right-hand one.
|
||||
// That is nearest-neighbour behaving correctly and not an off-by-one; a
|
||||
// reader checking this file by hand will get 256 on the first attempt, so
|
||||
// it is written out.
|
||||
const SAMPLED: u32 = 257;
|
||||
|
||||
let first = pixels[0];
|
||||
assert!(
|
||||
pixels.chunks_exact(4).all(|px| px[0] == first),
|
||||
"a two-to-one zoom steps two source columns per output column, so every \
|
||||
sample has the same parity and the frame should be flat. row 0: {:?}",
|
||||
&row(&pixels, w, 0)[..12]
|
||||
);
|
||||
// And it is flat on the *right* phase. A pipeline that had ignored the view
|
||||
// entirely would also be flat — but its `ratio` would not be 0.5, which the
|
||||
// assertion above already rules out — and one that had snapped to 1:1 would
|
||||
// show the stripes instead. Between them, only sampling the window the view
|
||||
// actually asked for produces this.
|
||||
assert_eq!(
|
||||
first,
|
||||
source_byte(SAMPLED, SAMPLED),
|
||||
"a view starting a quarter of the way across a {SOURCE} px source should \
|
||||
sample from source column {SAMPLED}"
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,12 @@ license.workspace = true
|
||||
dr-types.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
# A dependency of the library, not only of the build script, since FR-PLG-2:
|
||||
# `src/declared/` reads the same declaration format at *load* time, so that an
|
||||
# operation found in a file at startup is the same kind of thing as one found
|
||||
# at compile time. The reader itself is one file shared by both.
|
||||
serde_norway.workspace = true
|
||||
|
||||
# Nodes are declared in `ops/*.yaml` and compiled to Rust by `build.rs`
|
||||
# (ARCH §5.7). The same reasoning as `ui/dr-ui`'s style.yaml: the declaration
|
||||
# is the source of truth, the Rust is generated into OUT_DIR where it cannot
|
||||
|
||||
+216
-1295
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,23 @@ directory — there is no list to extend, no shader to edit, and no UI change.
|
||||
`../build.rs` compiles each declaration into Rust implementing
|
||||
[`Operation`](../src/operation.rs), generated into `OUT_DIR`. The result is
|
||||
indistinguishable downstream from a hand-written operation: the same
|
||||
`&'static OpDescriptor`, the same fused-shader composition, the same sidecar
|
||||
`Arc<OpDescriptor>`, the same fused-shader composition, the same sidecar
|
||||
round-trip.
|
||||
|
||||
**The same declaration also runs without being compiled** (FR-PLG-2).
|
||||
[`DeclaredOp`](../src/declared/mod.rs) reads this format at *load* time and
|
||||
implements `Operation` from it directly — one interpreter over many
|
||||
declarations, where `build.rs` emits generated code per node. Both paths exist
|
||||
on purpose: the built-ins stay compiled, because a generated `match` is faster
|
||||
than an interpreted one and because the `tests:` blocks below have to run under
|
||||
`cargo test`.
|
||||
|
||||
The reading half is one file, [`src/declared/decl.rs`](../src/declared/decl.rs),
|
||||
shared by both — so everything documented here means exactly one thing, and
|
||||
`tests/declared_parity.rs` asserts the two backends compose byte-identical WGSL
|
||||
for every node in this directory. Nothing in this document is specific to the
|
||||
build-time path.
|
||||
|
||||
## `attributes:` — what the operation is about
|
||||
|
||||
Required, one or more of `tone`, `colour`, `detail`, `optics`, `geometry`,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,447 @@
|
||||
//! TRACES: FR-PLG-2
|
||||
//! The little expression language a node declaration derives its uniforms in.
|
||||
//!
|
||||
//! Uniforms are functions of parameters — `exp2(exposure)`, `blacks / 100 *
|
||||
//! 0.02` — and that derivation is the one piece of a node that is genuinely
|
||||
//! computation rather than description. The language is arithmetic over the
|
||||
//! node's own parameters plus a fixed set of maths functions: enough for every
|
||||
//! operation in the chain, and small enough that a reader of the YAML can see
|
||||
//! exactly what will happen.
|
||||
//!
|
||||
//! # Why this file is compiled twice
|
||||
//!
|
||||
//! It is `#[path]`-included by `build.rs` as well as being a module of the
|
||||
//! crate, and it deliberately depends on nothing but `std` so that it can be.
|
||||
//!
|
||||
//! There are two backends over one grammar. `build.rs` renders an [`Expr`] to
|
||||
//! Rust source, so a built-in node's arithmetic costs nothing at run time and
|
||||
//! an unknown name is a build error naming the file. [`Expr::eval`] evaluates
|
||||
//! the same tree directly, which is what lets a declaration loaded at run time
|
||||
//! produce uniforms without a compiler.
|
||||
//!
|
||||
//! **The two must agree bit for bit**, or a plugin is not the same kind of
|
||||
//! thing as a built-in and `tests/declared_parity.rs` says so. Sharing the
|
||||
//! tokeniser and the parser removes the larger half of the ways they could
|
||||
//! drift; the remaining half is the pair of backends, and each entry in
|
||||
//! [`FUNCTIONS`] below is written twice on purpose, once here and once in
|
||||
//! `build.rs`, with the parity test standing between them.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// A number in a declaration, as the `f32` the arithmetic will actually use.
|
||||
///
|
||||
/// **The route through the decimal string is load-bearing, not clumsiness.**
|
||||
/// `build.rs` renders a number as a Rust literal — `0.02f32` — and `rustc`
|
||||
/// rounds that decimal text to the nearest `f32` exactly once. Writing
|
||||
/// `n as f32` here would instead round the `f64` YAML parsed to the nearest
|
||||
/// `f32`, which is a *second* rounding on top of the one that produced the
|
||||
/// `f64`, and double rounding does not always land where single rounding does.
|
||||
///
|
||||
/// So this reproduces what the compiler sees: `{:?}` is the shortest decimal
|
||||
/// that round-trips the `f64`, which is exactly the literal `build.rs` emits,
|
||||
/// and parsing it as `f32` is exactly what `rustc` does with it.
|
||||
pub fn as_f32(n: f64) -> f32 {
|
||||
// Infallible in practice: `{:?}` on a finite `f64` is always a parseable
|
||||
// decimal, and the infinities and NaN it can also print all parse back.
|
||||
// The fallback is the direct cast rather than a panic, because a bad
|
||||
// number in a declaration is the reader's error to report, not this
|
||||
// function's to crash on.
|
||||
format!("{n:?}").parse::<f32>().unwrap_or(n as f32)
|
||||
}
|
||||
|
||||
/// The maths functions a declaration may call, and how many arguments each
|
||||
/// takes.
|
||||
///
|
||||
/// A closed list rather than a passthrough to `f32`: a node is a description,
|
||||
/// and letting it name arbitrary Rust would make the YAML a second, worse
|
||||
/// place to write code. Closed for the same reason `WidgetKind` and
|
||||
/// `attributes` are closed (FR-PLG-2d) — a typo must be an error rather than a
|
||||
/// silent new category of one.
|
||||
///
|
||||
/// Shared by both backends so that the *set* of callable functions cannot
|
||||
/// drift even though the two translations of each must be written separately.
|
||||
pub const FUNCTIONS: &[(&str, usize)] = &[
|
||||
("exp2", 1),
|
||||
("log2", 1),
|
||||
("exp", 1),
|
||||
("sqrt", 1),
|
||||
("abs", 1),
|
||||
("floor", 1),
|
||||
("ceil", 1),
|
||||
("round", 1),
|
||||
("pow", 2),
|
||||
("min", 2),
|
||||
("max", 2),
|
||||
("clamp", 3),
|
||||
("mix", 3),
|
||||
];
|
||||
|
||||
/// A parsed expression over a node's parameters.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Expr {
|
||||
Num(f64),
|
||||
Param(String),
|
||||
Neg(Box<Expr>),
|
||||
Bin(char, Box<Expr>, Box<Expr>),
|
||||
Call(String, Vec<Expr>),
|
||||
}
|
||||
|
||||
/// Parse and validate an expression against the parameters a node declares.
|
||||
///
|
||||
/// Validation happens here rather than in either backend, so that "this names
|
||||
/// a parameter that does not exist" is one error message in one place and
|
||||
/// cannot be reported at build time but missed at load time.
|
||||
pub fn parse(src: &str, params: &BTreeSet<&str>) -> Result<Expr, String> {
|
||||
let tokens = tokenise(src)?;
|
||||
let mut parser = Parser { tokens, at: 0 };
|
||||
let expr = parser.expr()?;
|
||||
if parser.at < parser.tokens.len() {
|
||||
return Err(format!(
|
||||
"unexpected `{}` after the end of the expression",
|
||||
parser.tokens[parser.at]
|
||||
));
|
||||
}
|
||||
check(&expr, params)?;
|
||||
Ok(expr)
|
||||
}
|
||||
|
||||
/// Every name and arity in the tree resolves.
|
||||
fn check(expr: &Expr, params: &BTreeSet<&str>) -> Result<(), String> {
|
||||
match expr {
|
||||
Expr::Num(_) => Ok(()),
|
||||
Expr::Param(name) => {
|
||||
if params.contains(name.as_str()) {
|
||||
return Ok(());
|
||||
}
|
||||
let known: Vec<&str> = params.iter().copied().collect();
|
||||
Err(format!(
|
||||
"`{name}` is not a parameter of this node. Its parameters \
|
||||
are: {}",
|
||||
known.join(", ")
|
||||
))
|
||||
}
|
||||
Expr::Neg(inner) => check(inner, params),
|
||||
Expr::Bin(_, l, r) => {
|
||||
check(l, params)?;
|
||||
check(r, params)
|
||||
}
|
||||
Expr::Call(name, args) => {
|
||||
let Some((_, arity)) = FUNCTIONS.iter().find(|(f, _)| *f == name) else {
|
||||
let known: Vec<&str> = FUNCTIONS.iter().map(|(f, _)| *f).collect();
|
||||
return Err(format!(
|
||||
"`{name}` is not one of the maths functions a node may \
|
||||
call. Available: {}",
|
||||
known.join(", ")
|
||||
));
|
||||
};
|
||||
if args.len() != *arity {
|
||||
return Err(format!(
|
||||
"`{name}` takes {arity} argument(s), given {}",
|
||||
args.len()
|
||||
));
|
||||
}
|
||||
for a in args {
|
||||
check(a, params)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Expr {
|
||||
/// TRACES: FR-PLG-2
|
||||
/// Evaluate this expression for a set of parameter values.
|
||||
///
|
||||
/// **Every step is an `f32` operation in the same order `build.rs` renders
|
||||
/// it**, which is what makes the interpreted result bit-identical to the
|
||||
/// compiled one rather than merely close. Rust's `f32` arithmetic is IEEE
|
||||
/// 754 with no excess precision, so `(a * b) + c` here and `(a * b) + c`
|
||||
/// in generated source are the same number down to the last bit — and the
|
||||
/// parity test asserts exactly that rather than an epsilon, because a
|
||||
/// tolerance is how a real divergence gets to hide.
|
||||
///
|
||||
/// `param` is asked for a parameter's current value. It is a closure
|
||||
/// rather than a map so the caller can serve the values out of whatever it
|
||||
/// already has, which for [`super::DeclaredOp`] is a plain `Vec<f32>`
|
||||
/// indexed in declaration order.
|
||||
///
|
||||
/// Infallible: [`parse`] has already established that every name resolves
|
||||
/// and every call has the right arity. An unknown parameter reaching here
|
||||
/// would be a reader that let one through, so `param` decides what to do
|
||||
/// about it rather than this returning a `Result` every caller would
|
||||
/// unwrap.
|
||||
pub fn eval(&self, param: &dyn Fn(&str) -> f32) -> f32 {
|
||||
match self {
|
||||
Expr::Num(n) => as_f32(*n),
|
||||
Expr::Param(name) => param(name),
|
||||
Expr::Neg(inner) => -inner.eval(param),
|
||||
Expr::Bin(op, l, r) => {
|
||||
let (l, r) = (l.eval(param), r.eval(param));
|
||||
match op {
|
||||
'+' => l + r,
|
||||
'-' => l - r,
|
||||
'*' => l * r,
|
||||
'/' => l / r,
|
||||
// `tokenise` only ever produces these four as binary
|
||||
// operators, and `Parser` only ever builds `Bin` from what
|
||||
// `tokenise` produced.
|
||||
_ => unreachable!("`{op}` is not a binary operator"),
|
||||
}
|
||||
}
|
||||
Expr::Call(name, args) => {
|
||||
let a = |i: usize| args[i].eval(param);
|
||||
match name.as_str() {
|
||||
"exp2" => f32::exp2(a(0)),
|
||||
"log2" => f32::log2(a(0)),
|
||||
"exp" => f32::exp(a(0)),
|
||||
"sqrt" => f32::sqrt(a(0)),
|
||||
"abs" => f32::abs(a(0)),
|
||||
"floor" => f32::floor(a(0)),
|
||||
"ceil" => f32::ceil(a(0)),
|
||||
"round" => f32::round(a(0)),
|
||||
"pow" => f32::powf(a(0), a(1)),
|
||||
"min" => f32::min(a(0), a(1)),
|
||||
"max" => f32::max(a(0), a(1)),
|
||||
"clamp" => f32::clamp(a(0), a(1), a(2)),
|
||||
// Spelled out rather than called, matching what `build.rs`
|
||||
// renders: Rust has no `mix`, and this linear form is what
|
||||
// WGSL's `mix` means. The association matters — `a + (b -
|
||||
// a) * t` and `a * (1 - t) + b * t` are the same value in
|
||||
// real arithmetic and different ones in `f32`.
|
||||
"mix" => {
|
||||
let (x, y, t) = (a(0), a(1), a(2));
|
||||
x + (y - x) * t
|
||||
}
|
||||
// `parse` rejects anything not in `FUNCTIONS`.
|
||||
_ => unreachable!("`{name}` is not a declared maths function"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Tok {
|
||||
Num(f64),
|
||||
Ident(String),
|
||||
Sym(char),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Tok {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Tok::Num(n) => write!(f, "{n}"),
|
||||
Tok::Ident(s) => write!(f, "{s}"),
|
||||
Tok::Sym(c) => write!(f, "{c}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tokenise(src: &str) -> Result<Vec<Tok>, String> {
|
||||
let bytes: Vec<char> = src.chars().collect();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let c = bytes[i];
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
} else if c.is_ascii_digit()
|
||||
|| (c == '.' && bytes.get(i + 1).is_some_and(char::is_ascii_digit))
|
||||
{
|
||||
let start = i;
|
||||
while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == '.') {
|
||||
i += 1;
|
||||
}
|
||||
let text: String = bytes[start..i].iter().collect();
|
||||
let n = text
|
||||
.parse::<f64>()
|
||||
.map_err(|_| format!("`{text}` is not a number"))?;
|
||||
out.push(Tok::Num(n));
|
||||
} else if c.is_ascii_alphabetic() || c == '_' {
|
||||
let start = i;
|
||||
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == '_') {
|
||||
i += 1;
|
||||
}
|
||||
out.push(Tok::Ident(bytes[start..i].iter().collect()));
|
||||
} else if "+-*/(),".contains(c) {
|
||||
out.push(Tok::Sym(c));
|
||||
i += 1;
|
||||
} else {
|
||||
return Err(format!(
|
||||
"`{c}` is not valid in an expression; the language is \
|
||||
arithmetic (+ - * /), parentheses, numbers, this node's \
|
||||
parameters, and the maths functions"
|
||||
));
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err("the expression is empty".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
struct Parser {
|
||||
tokens: Vec<Tok>,
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn peek(&self) -> Option<&Tok> {
|
||||
self.tokens.get(self.at)
|
||||
}
|
||||
|
||||
fn eat(&mut self, sym: char) -> bool {
|
||||
if self.peek() == Some(&Tok::Sym(sym)) {
|
||||
self.at += 1;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn expr(&mut self) -> Result<Expr, String> {
|
||||
let mut left = self.term()?;
|
||||
loop {
|
||||
if self.eat('+') {
|
||||
left = Expr::Bin('+', Box::new(left), Box::new(self.term()?));
|
||||
} else if self.eat('-') {
|
||||
left = Expr::Bin('-', Box::new(left), Box::new(self.term()?));
|
||||
} else {
|
||||
return Ok(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn term(&mut self) -> Result<Expr, String> {
|
||||
let mut left = self.unary()?;
|
||||
loop {
|
||||
if self.eat('*') {
|
||||
left = Expr::Bin('*', Box::new(left), Box::new(self.unary()?));
|
||||
} else if self.eat('/') {
|
||||
left = Expr::Bin('/', Box::new(left), Box::new(self.unary()?));
|
||||
} else {
|
||||
return Ok(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unary(&mut self) -> Result<Expr, String> {
|
||||
if self.eat('-') {
|
||||
return Ok(Expr::Neg(Box::new(self.unary()?)));
|
||||
}
|
||||
self.primary()
|
||||
}
|
||||
|
||||
fn primary(&mut self) -> Result<Expr, String> {
|
||||
match self.peek().cloned() {
|
||||
Some(Tok::Num(n)) => {
|
||||
self.at += 1;
|
||||
Ok(Expr::Num(n))
|
||||
}
|
||||
Some(Tok::Ident(name)) => {
|
||||
self.at += 1;
|
||||
if !self.eat('(') {
|
||||
return Ok(Expr::Param(name));
|
||||
}
|
||||
let mut args = Vec::new();
|
||||
if !self.eat(')') {
|
||||
loop {
|
||||
args.push(self.expr()?);
|
||||
if self.eat(',') {
|
||||
continue;
|
||||
}
|
||||
if self.eat(')') {
|
||||
break;
|
||||
}
|
||||
return Err(format!("expected `,` or `)` in the call to `{name}`"));
|
||||
}
|
||||
}
|
||||
Ok(Expr::Call(name, args))
|
||||
}
|
||||
Some(Tok::Sym('(')) => {
|
||||
self.at += 1;
|
||||
let inner = self.expr()?;
|
||||
if !self.eat(')') {
|
||||
return Err("unclosed `(`".into());
|
||||
}
|
||||
Ok(inner)
|
||||
}
|
||||
Some(t) => Err(format!("unexpected `{t}`")),
|
||||
None => Err("the expression ends early".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn params() -> BTreeSet<&'static str> {
|
||||
["a", "b"].into_iter().collect()
|
||||
}
|
||||
|
||||
fn eval(src: &str, a: f32, b: f32) -> f32 {
|
||||
parse(src, ¶ms())
|
||||
.expect("parses")
|
||||
.eval(&|name| match name {
|
||||
"a" => a,
|
||||
"b" => b,
|
||||
other => panic!("no parameter {other}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_follows_the_usual_precedence() {
|
||||
assert_eq!(eval("a + b * 2", 1.0, 3.0), 7.0);
|
||||
assert_eq!(eval("(a + b) * 2", 1.0, 3.0), 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unary_minus_binds_tighter_than_addition() {
|
||||
// `-(a + b)` and `-a + b` are different numbers, and the parser has to
|
||||
// agree with the renderer about which one `-a + b` is.
|
||||
assert_eq!(eval("-a + b", 1.0, 3.0), 2.0);
|
||||
assert_eq!(eval("-(a + b)", 1.0, 3.0), -4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_number_is_rounded_once_the_way_the_compiler_rounds_a_literal() {
|
||||
// The whole reason `as_f32` goes through the decimal string. If this
|
||||
// ever regresses to `n as f32`, the values a declared node produces
|
||||
// drift from the generated one in the last bit, and every parity
|
||||
// assertion has to become a tolerance to keep passing — which is
|
||||
// exactly the silent disagreement the test exists to catch.
|
||||
assert_eq!(as_f32(0.02), 0.02f32);
|
||||
assert_eq!(as_f32(0.1), 0.1f32);
|
||||
// A third: eight significant digits, which is past what an `f32`
|
||||
// resolves, so this is the case where a second rounding could land
|
||||
// somewhere the compiler's single one does not.
|
||||
assert_eq!(as_f32(1.0 / 3.0), 0.333_333_34_f32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_is_the_linear_form_wgsl_means() {
|
||||
assert_eq!(eval("mix(a, b, 0.25)", 0.0, 4.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_parameter_names_the_ones_that_exist() {
|
||||
// The error is the whole value of validating in the parser: whoever
|
||||
// wrote the typo needs the list, and needs it identically whether the
|
||||
// declaration was read by the build script or at load time.
|
||||
let err = parse("c * 2", ¶ms()).unwrap_err();
|
||||
assert!(err.contains("`c` is not a parameter"), "{err}");
|
||||
assert!(err.contains("a, b"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_function_is_rejected_rather_than_passed_through() {
|
||||
let err = parse("tan(a)", ¶ms()).unwrap_err();
|
||||
assert!(err.contains("not one of the maths functions"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_arity_is_caught_where_it_is_written() {
|
||||
let err = parse("pow(a)", ¶ms()).unwrap_err();
|
||||
assert!(err.contains("takes 2 argument(s), given 1"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
//! TRACES: FR-PLG-2 | FR-PLG-2d
|
||||
//! Running a node declaration without compiling it.
|
||||
//!
|
||||
//! # The format already existed
|
||||
//!
|
||||
//! `ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the
|
||||
//! declarative nodes landed — it was simply resolved at build time:
|
||||
//!
|
||||
//! ```text
|
||||
//! ops/exposure.yaml ──build.rs──▶ generated impl Operation ──▶ fused shader
|
||||
//! ```
|
||||
//!
|
||||
//! Nothing about that requires the declaration to be present when the compiler
|
||||
//! runs. Everything a declaration produces is *data plus a WGSL string*, and
|
||||
//! the composer already assembles WGSL at run time from whichever operations
|
||||
//! are active. So this module is not a new mechanism; it is the existing one,
|
||||
//! loaded later.
|
||||
//!
|
||||
//! [`DeclaredOp`] is **one interpreter over many declarations**, where
|
||||
//! `build.rs` emits generated code per node. It implements [`Operation`] from
|
||||
//! an owned [`Declaration`], which is only possible because descriptors became
|
||||
//! owned — see [`crate::descriptor::OpDescriptor`] for why a `&'static`
|
||||
//! descriptor made a run-time node impossible.
|
||||
//!
|
||||
//! # Both paths stay
|
||||
//!
|
||||
//! The generated path is not removed and should not be. FR-PLG-2 says so, and
|
||||
//! the reasons are good ones: a generated `match` is faster than an
|
||||
//! interpreted one, the built-ins' declared tests have to run under `cargo
|
||||
//! test`, and generated source is *inspectable* in a way an interpreter's
|
||||
//! internal state is not.
|
||||
//!
|
||||
//! What matters is that the two are **indistinguishable downstream**, and that
|
||||
//! is a test rather than an intention: `tests/declared_parity.rs` parses every
|
||||
//! built-in `ops/*.yaml` at run time and asserts the composed WGSL is
|
||||
//! byte-for-byte identical to what the generated implementation produces, for
|
||||
//! the same parameter values. If the two ever disagree, a plugin is not the
|
||||
//! same kind of thing as a built-in and the premise of the whole plugin plan
|
||||
//! has failed quietly.
|
||||
//!
|
||||
//! # What this is not, yet
|
||||
//!
|
||||
//! Not load-time WGSL validation (FR-PLG-11), not id namespacing (FR-PLG-2's
|
||||
//! `author.name`), and not a plugin directory read at startup. Those are
|
||||
//! separate work and are deliberately absent — a declaration reaching
|
||||
//! [`DeclaredOp`] here is one that ships in this repository, so its WGSL has
|
||||
//! already been compiled by the build and its id has already been checked for
|
||||
//! collisions.
|
||||
|
||||
pub mod decl;
|
||||
pub mod expr;
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
pub use decl::{Declaration, Node, SharedHelpers};
|
||||
|
||||
use crate::descriptor::{
|
||||
intern, Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation,
|
||||
Scale, Unit, WidgetDemand, WidgetKind,
|
||||
};
|
||||
use crate::operation::{Helper, Operation, Uniform};
|
||||
use expr::{as_f32, Expr};
|
||||
|
||||
/// The shared WGSL helper library, as the built-in nodes see it.
|
||||
///
|
||||
/// The very same `_helpers.yaml` `build.rs` reads, embedded rather than read
|
||||
/// from disk: there is no file path an installed application could look it up
|
||||
/// at, and embedding is what makes it impossible for the compiled helpers and
|
||||
/// the interpreted ones to be two different files.
|
||||
///
|
||||
/// Panics if the bundled file does not parse, which is a build-time fact about
|
||||
/// this repository rather than anything a user can cause — `build.rs` reads the
|
||||
/// same text on the same build and fails first.
|
||||
pub fn builtin_helpers() -> &'static SharedHelpers {
|
||||
static LIBRARY: LazyLock<SharedHelpers> = LazyLock::new(|| {
|
||||
decl::read_helpers(include_str!("../../ops/_helpers.yaml"))
|
||||
.unwrap_or_else(|e| panic!("the bundled {}: {e}", decl::HELPERS_FILE))
|
||||
});
|
||||
&LIBRARY
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLG-2
|
||||
/// An operation built from a declaration at run time.
|
||||
///
|
||||
/// Holds everything the trait has to answer with, resolved once when the
|
||||
/// declaration is read: the descriptor, the uniform expressions, the helper
|
||||
/// list and the fragment text. Nothing is re-parsed per call, so the per-frame
|
||||
/// cost of an interpreted node is the arithmetic in [`Expr::eval`] and nothing
|
||||
/// else — the same arithmetic the generated node does, simply walked rather
|
||||
/// than inlined.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeclaredOp {
|
||||
descriptor: Arc<OpDescriptor>,
|
||||
/// Parameter ids in declaration order, parallel to `defaults` and
|
||||
/// `values`.
|
||||
///
|
||||
/// Three parallel `Vec`s rather than one of triples because the hot read
|
||||
/// is `values` alone, and because `set_param` writes exactly one of them.
|
||||
/// They are built together and never resized.
|
||||
params: Vec<ParamId>,
|
||||
defaults: Vec<f32>,
|
||||
values: Vec<f32>,
|
||||
uniforms: Vec<DeclaredUniform>,
|
||||
/// The declared `active:` rule, or `None` for the default one.
|
||||
active: Option<Expr>,
|
||||
wgsl: String,
|
||||
helpers: Vec<Helper>,
|
||||
presentation: Option<Presentation>,
|
||||
order: i64,
|
||||
}
|
||||
|
||||
/// One uniform: the name the fragment reads it by, and how to compute it.
|
||||
#[derive(Debug, Clone)]
|
||||
struct DeclaredUniform {
|
||||
/// Interned when the declaration was read, not on every `uniforms()` call.
|
||||
/// See [`crate::operation::Uniform::name`].
|
||||
name: &'static str,
|
||||
expr: Expr,
|
||||
}
|
||||
|
||||
impl DeclaredOp {
|
||||
/// Read one `ops/<id>.yaml` and build the operation it declares.
|
||||
///
|
||||
/// `ctx` names the source in error messages — a path, usually.
|
||||
///
|
||||
/// A `rust:` node is an error here rather than a silent `None`: it names a
|
||||
/// hand-written type in this crate, which is by definition not something a
|
||||
/// declaration can produce, and a caller that got one back as "nothing to
|
||||
/// do" would drop an operation out of the chain without saying so.
|
||||
pub fn from_yaml(text: &str, ctx: &str, library: &SharedHelpers) -> Result<Self, String> {
|
||||
let names = library.names();
|
||||
match decl::read_node(text, ctx, &names)? {
|
||||
Node::Declared(d) => Self::new(&d, library),
|
||||
Node::Rust { id, ty, .. } => Err(format!(
|
||||
"`{id}` declares `rust: {ty}`, which names a hand-written type \
|
||||
rather than describing an operation. Only a full declaration \
|
||||
can be read at run time."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the operation a parsed declaration describes.
|
||||
pub fn new(declaration: &Declaration, library: &SharedHelpers) -> Result<Self, String> {
|
||||
let params: Vec<ParamId> = declaration
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| ParamId::interned(&p.id))
|
||||
.collect();
|
||||
let defaults: Vec<f32> = declaration
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| as_f32(p.default))
|
||||
.collect();
|
||||
|
||||
// Helpers in the order the composer will see them: the shared ones the
|
||||
// node asked for, in the order it asked, then its own definitions.
|
||||
// Order decides emission order in the generated shader, so it is part
|
||||
// of the output rather than an implementation detail.
|
||||
let mut helpers =
|
||||
Vec::with_capacity(declaration.shared_helpers.len() + declaration.local_helpers.len());
|
||||
for name in &declaration.shared_helpers {
|
||||
// `decl::read_node` has already rejected a name the library does
|
||||
// not define, so this is a library that changed underneath a
|
||||
// declaration rather than a declaration with a typo in it.
|
||||
let helper = library.get(name).ok_or_else(|| {
|
||||
format!(
|
||||
"`{}` asks for the shared helper `{name}`, which this \
|
||||
helper library does not define",
|
||||
declaration.id
|
||||
)
|
||||
})?;
|
||||
helpers.push(Helper {
|
||||
name: intern(&helper.name),
|
||||
source: intern(&helper.source()),
|
||||
});
|
||||
}
|
||||
for helper in &declaration.local_helpers {
|
||||
helpers.push(Helper {
|
||||
name: intern(&helper.name),
|
||||
source: intern(&helper.source()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
descriptor: Arc::new(OpDescriptor {
|
||||
id: OpId::interned(&declaration.id),
|
||||
label: LocalizedKey::interned(&declaration.label),
|
||||
params: declaration.params.iter().map(param_descriptor).collect(),
|
||||
attributes: declaration
|
||||
.attributes
|
||||
.iter()
|
||||
.copied()
|
||||
.map(attribute)
|
||||
.collect(),
|
||||
}),
|
||||
values: defaults.clone(),
|
||||
params,
|
||||
defaults,
|
||||
uniforms: declaration
|
||||
.uniforms
|
||||
.iter()
|
||||
.map(|u| DeclaredUniform {
|
||||
name: intern(&u.name),
|
||||
expr: u.expr.clone(),
|
||||
})
|
||||
.collect(),
|
||||
active: declaration.active.clone(),
|
||||
wgsl: declaration.wgsl_body(),
|
||||
helpers,
|
||||
presentation: declaration.presentation.as_deref().map(presentation),
|
||||
order: declaration.order,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where this node sits in the chain, from its `order:`.
|
||||
///
|
||||
/// Not part of [`Operation`] — the graph holds operations in a `Vec` and
|
||||
/// order *is* that position (ARCH §3.4). Exposed so whoever assembles a
|
||||
/// chain out of declarations can sort them, which is what `build.rs` does
|
||||
/// at the other end.
|
||||
pub fn order(&self) -> i64 {
|
||||
self.order
|
||||
}
|
||||
|
||||
fn index_of(&self, id: ParamId) -> Option<usize> {
|
||||
self.params.iter().position(|p| *p == id)
|
||||
}
|
||||
|
||||
/// One parameter's current value, by the name an expression calls it.
|
||||
///
|
||||
/// Returns 0.0 for a name that is not a parameter, matching what the
|
||||
/// generated `param()` does with an unknown id. It cannot happen —
|
||||
/// [`expr::parse`] rejects a name that is not declared — but a silent zero
|
||||
/// is a better failure here than a panic inside a render.
|
||||
fn value_named(&self, name: &str) -> f32 {
|
||||
self.params
|
||||
.iter()
|
||||
.position(|p| p.0 == name)
|
||||
.map_or(0.0, |i| self.values[i])
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for DeclaredOp {
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
self.descriptor.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match self.index_of(id) {
|
||||
Some(i) => self.values[i] = value,
|
||||
// The same complaint the generated `set_param` makes, for the same
|
||||
// reason: a parameter that does not exist is a sidecar or a UI
|
||||
// naming something this build does not have, and dropping it
|
||||
// silently is how an edit comes to be half-applied.
|
||||
None => log::warn!("{}: unknown parameter {id}", self.descriptor.id),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
self.index_of(id).map_or(0.0, |i| self.values[i])
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
match &self.active {
|
||||
Some(expr) => expr.eval(&|name| self.value_named(name)) != 0.0,
|
||||
// The default rule, and the honest one: the operation is doing
|
||||
// something exactly when a parameter has moved off its default.
|
||||
// Short-circuiting in declaration order, which is what the
|
||||
// generated `a != d || b != d` does.
|
||||
None => self.values.iter().zip(&self.defaults).any(|(v, d)| v != d),
|
||||
}
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
self.wgsl.clone()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
self.uniforms
|
||||
.iter()
|
||||
.map(|u| Uniform {
|
||||
name: u.name,
|
||||
value: u.expr.eval(&|name| self.value_named(name)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &[Helper] {
|
||||
&self.helpers
|
||||
}
|
||||
|
||||
fn presentation(&self) -> Option<Presentation> {
|
||||
self.presentation.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A declared parameter as the descriptor the panel reads.
|
||||
///
|
||||
/// Every arm calls the constructor `build.rs` renders a call to, so the two
|
||||
/// produce the same `ParamDescriptor` by construction rather than by
|
||||
/// coincidence.
|
||||
fn param_descriptor(p: &decl::ParamDef) -> ParamDescriptor {
|
||||
let id = intern(&p.id);
|
||||
let label = intern(&p.label);
|
||||
match &p.kind {
|
||||
decl::Kind::Stops { min, max } => {
|
||||
ParamDescriptor::stops(id, label, as_f32(*min), as_f32(*max))
|
||||
}
|
||||
decl::Kind::Amount => ParamDescriptor::amount(id, label),
|
||||
decl::Kind::Switch => ParamDescriptor::switch(id, label),
|
||||
decl::Kind::Fraction { default } => ParamDescriptor::fraction(id, label, as_f32(*default)),
|
||||
decl::Kind::Scalar {
|
||||
min,
|
||||
max,
|
||||
default,
|
||||
unit,
|
||||
scale,
|
||||
precision,
|
||||
} => ParamDescriptor::scalar(
|
||||
id,
|
||||
label,
|
||||
as_f32(*min),
|
||||
as_f32(*max),
|
||||
as_f32(*default),
|
||||
match unit {
|
||||
decl::Unit::None => Unit::None,
|
||||
decl::Unit::Stops => Unit::Stops,
|
||||
decl::Unit::Kelvin => Unit::Kelvin,
|
||||
decl::Unit::Percent => Unit::Percent,
|
||||
},
|
||||
match scale {
|
||||
decl::Scale::Linear => Scale::Linear,
|
||||
decl::Scale::Perceptual => Scale::Perceptual,
|
||||
},
|
||||
// `decl::read_kind` refuses a precision that does not fit, so this
|
||||
// cast cannot lose anything.
|
||||
*precision as u8,
|
||||
),
|
||||
decl::Kind::Enum { variants } => ParamDescriptor::choice(
|
||||
id,
|
||||
label,
|
||||
variants.iter().map(|v| LocalizedKey::interned(v)).collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn attribute(a: decl::Attr) -> Attribute {
|
||||
match a {
|
||||
decl::Attr::Tone => Attribute::Tone,
|
||||
decl::Attr::Colour => Attribute::Colour,
|
||||
decl::Attr::Detail => Attribute::Detail,
|
||||
decl::Attr::Optics => Attribute::Optics,
|
||||
decl::Attr::Geometry => Attribute::Geometry,
|
||||
decl::Attr::Effect => Attribute::Effect,
|
||||
}
|
||||
}
|
||||
|
||||
fn widget(w: decl::Widget) -> WidgetKind {
|
||||
match w {
|
||||
decl::Widget::ToneCurve => WidgetKind::ToneCurve,
|
||||
decl::Widget::ColourWheel => WidgetKind::ColourWheel,
|
||||
decl::Widget::CropOverlay => WidgetKind::CropOverlay,
|
||||
decl::Widget::GradientHandle => WidgetKind::GradientHandle,
|
||||
decl::Widget::BrushMask => WidgetKind::BrushMask,
|
||||
decl::Widget::WhitePoint => WidgetKind::WhitePoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn presentation(p: &decl::PresentationDef) -> Presentation {
|
||||
Presentation {
|
||||
widgets: p.widgets.iter().copied().map(widget).collect(),
|
||||
demand: WidgetDemand {
|
||||
two_dimensional: p.two_dimensional,
|
||||
precise_pointing: p.precise_pointing,
|
||||
},
|
||||
params: p.params.iter().map(|n| ParamId::interned(n)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// TRACES: FR-PLG-2d
|
||||
/// The two spellings of the attribute vocabulary are one vocabulary.
|
||||
///
|
||||
/// `decl::Attr` exists because the reader is compiled by `build.rs`, which
|
||||
/// cannot see `crate::descriptor`. That is a duplicated closed list, and a
|
||||
/// duplicated closed list is exactly the thing FR-PLG-2d warns about: an
|
||||
/// attribute added to one and not the other would put an operation in a
|
||||
/// category the panel does not know it has.
|
||||
#[test]
|
||||
fn the_attribute_vocabulary_is_the_same_on_both_sides() {
|
||||
assert_eq!(decl::Attr::ALL.len(), Attribute::ALL.len());
|
||||
for (a, b) in decl::Attr::ALL.iter().zip(Attribute::ALL) {
|
||||
// Same order, so an index into one indexes the other.
|
||||
assert_eq!(attribute(*a), b);
|
||||
// And the name a declaration writes resolves to the same variant.
|
||||
assert_eq!(Attribute::from_name(a.name()), Some(b), "{}", a.name());
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLG-2d
|
||||
/// Every `WidgetKind` is nameable from a declaration.
|
||||
///
|
||||
/// A widget the core can ask for but a declaration cannot name is a widget
|
||||
/// only a hand-written operation may have, which would make the two kinds
|
||||
/// of node unequal in exactly the way FR-PLG-2 forbids.
|
||||
#[test]
|
||||
fn every_widget_kind_can_be_declared() {
|
||||
assert_eq!(decl::Widget::ALL.len(), 6);
|
||||
let named: Vec<WidgetKind> = decl::Widget::ALL.iter().copied().map(widget).collect();
|
||||
for kind in [
|
||||
WidgetKind::ToneCurve,
|
||||
WidgetKind::ColourWheel,
|
||||
WidgetKind::CropOverlay,
|
||||
WidgetKind::GradientHandle,
|
||||
WidgetKind::BrushMask,
|
||||
WidgetKind::WhitePoint,
|
||||
] {
|
||||
assert!(named.contains(&kind), "{kind:?} cannot be declared");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bundled_helper_library_parses() {
|
||||
// It is `include_str!`'d, so a syntax error in it is a panic at first
|
||||
// use rather than a build failure. This is the first use.
|
||||
assert!(!builtin_helpers().helpers.is_empty());
|
||||
}
|
||||
|
||||
fn exposure() -> DeclaredOp {
|
||||
DeclaredOp::from_yaml(
|
||||
include_str!("../../ops/exposure.yaml"),
|
||||
"ops/exposure.yaml",
|
||||
builtin_helpers(),
|
||||
)
|
||||
.expect("exposure declares an operation")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declaration_becomes_an_operation_with_its_declared_descriptor() {
|
||||
let op = exposure();
|
||||
let d = op.descriptor();
|
||||
assert_eq!(d.id, OpId("exposure"));
|
||||
assert_eq!(d.label, LocalizedKey("op.exposure"));
|
||||
assert_eq!(d.params.len(), 1);
|
||||
assert_eq!(d.params[0].id, ParamId("exposure"));
|
||||
assert_eq!(d.attributes, vec![Attribute::Tone]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_operation_is_neutral_until_a_parameter_moves() {
|
||||
let mut op = exposure();
|
||||
assert!(!op.is_active());
|
||||
assert_eq!(op.uniforms()[0].value, 1.0);
|
||||
|
||||
op.set_param(ParamId("exposure"), 1.0);
|
||||
assert!(op.is_active());
|
||||
// A stop is a doubling — the same assertion `exposure.yaml`'s own
|
||||
// declared test makes against the generated implementation.
|
||||
assert_eq!(op.uniforms()[0].value, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interned_id_matches_a_literal_one() {
|
||||
// The property that lets a declared operation be addressed by the same
|
||||
// `ParamId` constants the generated code matches on. If interning ever
|
||||
// stopped deduplicating, this would still pass — `ParamId` compares
|
||||
// string contents — but the point is that the two are interchangeable
|
||||
// at every call site.
|
||||
let mut op = exposure();
|
||||
op.set_param(ParamId::interned("exposure"), 2.0);
|
||||
assert_eq!(op.param(ParamId("exposure")), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_node_is_refused_rather_than_silently_dropped() {
|
||||
let err = DeclaredOp::from_yaml(
|
||||
include_str!("../../ops/tone_curve.yaml"),
|
||||
"ops/tone_curve.yaml",
|
||||
builtin_helpers(),
|
||||
)
|
||||
.expect_err("a `rust:` node is not a declaration");
|
||||
assert!(err.contains("hand-written type"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,75 @@
|
||||
//! Labels are keys, not strings: resolving them needs a localiser, and
|
||||
//! `core/` must not depend on one (NFR-A11Y-1).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
/// TRACES: FR-PLG-2
|
||||
/// Give a string read at run time the `'static` lifetime the identifier types
|
||||
/// carry.
|
||||
///
|
||||
/// # Why the identifiers stayed `&'static str` when the descriptors did not
|
||||
///
|
||||
/// [`OpDescriptor`] became owned so a declaration read at *load* time can
|
||||
/// produce one (FR-PLG-2). The three identifier newtypes below deliberately
|
||||
/// did not follow it.
|
||||
///
|
||||
/// An id is not content; it is a key. [`ParamId`] is `Copy`, is compared in
|
||||
/// `match` arms against the constants `build.rs` generates, is a map key in
|
||||
/// the sidecar and in history, and is threaded through `dr-ui` into Slint
|
||||
/// model rows. An `Arc<str>` there would put a refcount on every one of those
|
||||
/// and would take `match id { EXPOSURE => .. }` away from the generated code —
|
||||
/// which is precisely the inspectability of the built-in chain that keeping
|
||||
/// the generated path was for.
|
||||
///
|
||||
/// So ids are interned instead, and interning is honest about its lifetime
|
||||
/// rather than pretending to one. The set of interned ids is:
|
||||
///
|
||||
/// - **Bounded.** One entry per *distinct* string, deduplicated on the way in.
|
||||
/// Parsing the same declaration a thousand times adds nothing after the
|
||||
/// first.
|
||||
/// - **Process-lifetime by construction.** A loaded declaration's vocabulary
|
||||
/// is never withdrawn. Nothing unloads a plugin, and nothing could: the
|
||||
/// sidecar on disk stores parameters by `(op_id, param_id)`, so an id has to
|
||||
/// stay resolvable for as long as any edit naming it can be opened.
|
||||
///
|
||||
/// A leak whose bound is "the distinct ids this process has ever seen" is a
|
||||
/// different thing from one that grows with use, and this is the first.
|
||||
pub fn intern(s: &str) -> &'static str {
|
||||
static POOL: LazyLock<Mutex<HashSet<&'static str>>> =
|
||||
LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||
|
||||
// A poisoned pool is still a correct pool: every entry in it is a
|
||||
// `&'static str` that was interned successfully, and a panic elsewhere
|
||||
// while the lock was held cannot have made one invalid. Refusing to
|
||||
// intern here would turn an unrelated panic into an application that can
|
||||
// no longer read a declaration.
|
||||
let mut pool = POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(found) = pool.get(s) {
|
||||
return found;
|
||||
}
|
||||
let leaked: &'static str = Box::leak(s.to_owned().into_boxed_str());
|
||||
pool.insert(leaked);
|
||||
leaked
|
||||
}
|
||||
|
||||
/// Identifies a parameter within an operation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct ParamId(pub &'static str);
|
||||
|
||||
impl ParamId {
|
||||
/// The same id, from a name read out of a declaration at load time.
|
||||
///
|
||||
/// Equal to `ParamId("exposure")` when the name is `"exposure"`: the
|
||||
/// derived `PartialEq` compares the `str` contents, not the pointer, which
|
||||
/// is what lets an interned id match a generated `match` arm. See
|
||||
/// [`intern`].
|
||||
pub fn interned(name: &str) -> Self {
|
||||
Self(intern(name))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ParamId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.0)
|
||||
@@ -24,6 +87,13 @@ impl fmt::Display for ParamId {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct OpId(pub &'static str);
|
||||
|
||||
impl OpId {
|
||||
/// The same id, from a declaration read at load time. See [`intern`].
|
||||
pub fn interned(name: &str) -> Self {
|
||||
Self(intern(name))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for OpId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.0)
|
||||
@@ -34,6 +104,13 @@ impl fmt::Display for OpId {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LocalizedKey(pub &'static str);
|
||||
|
||||
impl LocalizedKey {
|
||||
/// The same key, from a declaration read at load time. See [`intern`].
|
||||
pub fn interned(key: &str) -> Self {
|
||||
Self(intern(key))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a slider's travel means.
|
||||
///
|
||||
/// Photographic controls are rarely linear in their underlying quantity:
|
||||
@@ -170,7 +247,11 @@ pub enum ParamKind {
|
||||
Enum {
|
||||
/// In index order. The label is a localisation key, resolved by the
|
||||
/// frontend — `core/` must not depend on a localiser (NFR-A11Y-1).
|
||||
variants: &'static [LocalizedKey],
|
||||
///
|
||||
/// Owned rather than `&'static`, for the reason [`OpDescriptor`]
|
||||
/// gives: a declaration parsed at load time has nowhere to put a
|
||||
/// `'static` slice.
|
||||
variants: Vec<LocalizedKey>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -204,7 +285,7 @@ pub struct Presentation {
|
||||
/// pair of sliders, and an operation that can say so gets a good control
|
||||
/// on a workstation and a usable one on a phone without the core knowing
|
||||
/// which it is talking to.
|
||||
pub widgets: &'static [WidgetKind],
|
||||
pub widgets: Vec<WidgetKind>,
|
||||
/// What the preferred widget needs in order to be worth drawing.
|
||||
///
|
||||
/// Applies to the list as a whole rather than per entry: a frontend that
|
||||
@@ -215,7 +296,7 @@ pub struct Presentation {
|
||||
///
|
||||
/// Parameters absent from this list are presented normally, so an
|
||||
/// operation can pair a curve with an ordinary strength slider.
|
||||
pub params: &'static [ParamId],
|
||||
pub params: Vec<ParamId>,
|
||||
}
|
||||
|
||||
impl Presentation {
|
||||
@@ -328,11 +409,13 @@ impl ParamDescriptor {
|
||||
/// holds the way it does for every other kind: index 0 is the neutral
|
||||
/// choice, and an operation whose default is not its first variant has
|
||||
/// listed them in the wrong order.
|
||||
pub const fn choice(
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
variants: &'static [LocalizedKey],
|
||||
) -> Self {
|
||||
//
|
||||
// Not `const`, unlike its four siblings, and the reason is the `Vec` in
|
||||
// [`ParamKind::Enum`]: a heap allocation cannot happen in a const context.
|
||||
// Nothing is lost — every descriptor now lives inside a `LazyLock`
|
||||
// initialiser rather than a `static`, because `descriptor()` hands out an
|
||||
// `Arc` and an `Arc` is not const-constructible either.
|
||||
pub fn choice(id: &'static str, label: &'static str, variants: Vec<LocalizedKey>) -> Self {
|
||||
Self {
|
||||
id: ParamId(id),
|
||||
label: LocalizedKey(label),
|
||||
@@ -366,13 +449,15 @@ impl ParamDescriptor {
|
||||
|
||||
/// A general scalar with an explicit range and default.
|
||||
//
|
||||
// Eight arguments, and a builder would be the usual answer — but this has
|
||||
// to be `const` so descriptors can be `static`, which rules out the
|
||||
// `&mut self` builder the pattern usually takes. (A `self`-by-value step
|
||||
// *is* const-callable — `faceted` below is one — but eight of them would
|
||||
// be eight methods to say what one call already says.) The two common
|
||||
// shapes have their own constructors above; this is the escape hatch for
|
||||
// the rest.
|
||||
// Eight arguments, and a builder would be the usual answer — but eight
|
||||
// `&mut self` steps would be eight methods to say what one call already
|
||||
// says, and each one would be a place for a caller to forget a field. The
|
||||
// two common shapes have their own constructors above; this is the escape
|
||||
// hatch for the rest.
|
||||
//
|
||||
// Still `const` although no descriptor is a `static` any more: it costs
|
||||
// nothing, and it keeps the five constructors uniform where only `choice`
|
||||
// genuinely cannot be.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub const fn scalar(
|
||||
id: &'static str,
|
||||
@@ -404,10 +489,13 @@ impl ParamDescriptor {
|
||||
/// A method rather than a sixth constructor, because a facet is orthogonal
|
||||
/// to the shape of the value: a faceted parameter is still an amount, or
|
||||
/// still a scalar in stops, and pairing every constructor with a faceted
|
||||
/// twin would double the list above to say one thing. Taking `self` by
|
||||
/// value is what keeps it usable in the `static` descriptors — a `&mut
|
||||
/// self` builder is what cannot be `const`.
|
||||
pub const fn faceted(mut self, facet: Facet) -> Self {
|
||||
/// twin would double the list above to say one thing.
|
||||
//
|
||||
// No longer `const`: a `ParamDescriptor` can now carry a `Vec` (an enum's
|
||||
// variants), which gives the type drop glue, and assigning over a field of
|
||||
// such a type is not something a const function may do. Nothing is lost —
|
||||
// every descriptor is built inside a `LazyLock` initialiser now.
|
||||
pub fn faceted(mut self, facet: Facet) -> Self {
|
||||
self.facet = Some(facet);
|
||||
self
|
||||
}
|
||||
@@ -418,10 +506,10 @@ impl ParamDescriptor {
|
||||
/// bounds, or a sidecar written by a newer version with a wider range,
|
||||
/// must not produce out-of-range uniforms.
|
||||
pub fn clamp(&self, value: f32) -> f32 {
|
||||
match self.kind {
|
||||
match &self.kind {
|
||||
ParamKind::Scalar { min, max, .. } => {
|
||||
if value.is_finite() {
|
||||
value.clamp(min, max)
|
||||
value.clamp(*min, *max)
|
||||
} else {
|
||||
// A NaN from a corrupt sidecar would otherwise poison the
|
||||
// uniform block and blank the image.
|
||||
@@ -544,20 +632,45 @@ impl Attribute {
|
||||
}
|
||||
}
|
||||
|
||||
/// The static description of an operation.
|
||||
/// TRACES: FR-PLG-2
|
||||
/// The description of an operation.
|
||||
///
|
||||
/// # Owned, not `&'static`
|
||||
///
|
||||
/// This used to be a `static` with `&'static [ParamDescriptor]` inside it, and
|
||||
/// [`crate::Operation::descriptor`] used to hand out a reference to it. That
|
||||
/// shape made a build-time node free and a run-time node **impossible**: a
|
||||
/// declaration parsed at startup has nothing to borrow from, so no amount of
|
||||
/// interpreting `ops/*.yaml` at load time could ever produce a descriptor the
|
||||
/// rest of the application would accept. FR-PLG-2 says a bundled operation and
|
||||
/// a third-party plugin are the same kind of thing, differing only in where
|
||||
/// the file was found — and a lifetime that only a compile-time literal can
|
||||
/// satisfy is exactly a second, weaker format for outsiders.
|
||||
///
|
||||
/// So the descriptor owns its contents and is handed out as an
|
||||
/// `Arc<OpDescriptor>`. The `Arc` rather than a `&self`-borrowed reference
|
||||
/// because the callers want to *keep* it: the develop panel collects
|
||||
/// descriptors and then mutates the graph, and a borrow would tie the
|
||||
/// descriptor's lifetime to a borrow of the operation it came from — which is
|
||||
/// the one thing `&'static` was doing right.
|
||||
///
|
||||
/// The cost is a refcount per read, on a path that reads descriptors when a
|
||||
/// panel is built rather than per pixel. See `Operation::descriptor` for the
|
||||
/// one place that is read per composition and why it does not matter.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OpDescriptor {
|
||||
pub id: OpId,
|
||||
pub label: LocalizedKey,
|
||||
pub params: &'static [ParamDescriptor],
|
||||
pub params: Vec<ParamDescriptor>,
|
||||
/// What this operation is about (ARCH §4.3a).
|
||||
///
|
||||
/// **Never empty**, and `build.rs` refuses to generate an operation that
|
||||
/// declares none. An operation with no attribute would be invisible to a
|
||||
/// frontend that filters by them, and a control that silently does not
|
||||
/// exist is a worse failure than a build that stops — particularly when
|
||||
/// the cause would be a missing line in a YAML file nobody looked at.
|
||||
pub attributes: &'static [Attribute],
|
||||
/// **Never empty**, and both the build-time and the load-time reader
|
||||
/// refuse an operation that declares none. An operation with no attribute
|
||||
/// would be invisible to a frontend that filters by them, and a control
|
||||
/// that silently does not exist is a worse failure than a build that stops
|
||||
/// — particularly when the cause would be a missing line in a YAML file
|
||||
/// nobody looked at.
|
||||
pub attributes: Vec<Attribute>,
|
||||
}
|
||||
|
||||
impl OpDescriptor {
|
||||
|
||||
@@ -359,6 +359,29 @@ pub struct DetailPass {
|
||||
|
||||
/// Uniform values this pass's body reads.
|
||||
pub uniforms: Vec<Uniform>,
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// Per-instance data, for a pass whose work is a *list* rather than a
|
||||
/// kernel.
|
||||
///
|
||||
/// Reaches the body as `instances: array<vec4<f32>>`, with
|
||||
/// `instance_count` in scope as a `u32`. Empty for every pass that is a
|
||||
/// convolution, which is every pass that existed before spot removal.
|
||||
///
|
||||
/// # Why not the uniform block
|
||||
///
|
||||
/// Because the uniform block is fixed by the pass's *structure*, and this
|
||||
/// is not: sixty-four repairs and one repair are the same shader with a
|
||||
/// different buffer behind it. Packing the list into uniforms would need a
|
||||
/// fixed maximum, paid for on every frame whether the photograph carries
|
||||
/// one spot or none, and it would need the composer to emit `vec4` fields —
|
||||
/// a WGSL uniform array has a stride of 16 whatever it holds.
|
||||
///
|
||||
/// The property that matters more: with the list in storage the generated
|
||||
/// source does not mention how many there are, so placing the tenth spot
|
||||
/// uploads 512 bytes and reuses the compiled pipeline, exactly as moving a
|
||||
/// slider does for the fused pass.
|
||||
pub storage: Vec<[f32; 4]>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-DEV-8
|
||||
@@ -396,6 +419,9 @@ pub struct ComposedDetailPass {
|
||||
pub source: String,
|
||||
/// Uniform values in the order the generated struct declares them.
|
||||
pub uniforms: Vec<f32>,
|
||||
/// TRACES: FR-DEV-8
|
||||
/// The instance list, if this pass declared one. See [`DetailPass::storage`].
|
||||
pub storage: Vec<[f32; 4]>,
|
||||
/// See [`DetailPass::radius`].
|
||||
pub radius: u32,
|
||||
/// Whether this pass writes the display/export texture rather than another
|
||||
@@ -463,10 +489,51 @@ pub fn compose_detail(
|
||||
ops: &[Box<dyn Operation>],
|
||||
scale: RenderScale,
|
||||
output: ColourSpace,
|
||||
) -> ComposedDetail {
|
||||
compose_detail_with(ops, &[], scale, output)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// The detail stage with a set of repairs ahead of the operations.
|
||||
///
|
||||
/// `spots` are already-built passes, from [`crate::SpotSet::passes`], and they
|
||||
/// go **first** — before sharpening, before noise reduction, before every
|
||||
/// kernel in `ops`.
|
||||
///
|
||||
/// That placement is a decision rather than an ordering convenience. A
|
||||
/// sharpening kernel reads a neighbourhood, so sharpening a dust mark before
|
||||
/// removing it smears the mark's edge into pixels the repair's own disc does
|
||||
/// not cover: what is left afterwards is a faint over-sharpened ring around an
|
||||
/// otherwise perfect patch, which is exactly the artefact that reads as broken
|
||||
/// software. Removing the mark first means every later pass sees the
|
||||
/// photograph the photographer thinks they are sharpening.
|
||||
///
|
||||
/// It also means ARCH §5.2's stage list, which draws spot removal after
|
||||
/// texture and clarity, is not what this does — see `docs/spot-removal.md`
|
||||
/// §5.1, which is where the disagreement is written down.
|
||||
pub fn compose_detail_with(
|
||||
ops: &[Box<dyn Operation>],
|
||||
spots: &[DetailPass],
|
||||
scale: RenderScale,
|
||||
output: ColourSpace,
|
||||
) -> ComposedDetail {
|
||||
// Every pass of every active detail operation, flattened, carrying the
|
||||
// operation it came from for the uniform prefix and the helper set.
|
||||
let mut planned: Vec<(&'static str, &'static [Helper], DetailPass, usize)> = Vec::new();
|
||||
//
|
||||
// The helper slice borrows from the operation rather than being `'static`:
|
||||
// `Operation::helpers` hands out a slice owned by the operation now, so
|
||||
// that a node built from a declaration at load time can own its list
|
||||
// (FR-PLG-2). The borrow lasts as long as `ops`, which outlives this
|
||||
// function's body.
|
||||
let mut planned: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::new();
|
||||
for (index, pass) in spots.iter().enumerate() {
|
||||
planned.push((
|
||||
crate::spot::SPOT_ID,
|
||||
crate::spot::SPOT_HELPERS,
|
||||
pass.clone(),
|
||||
index,
|
||||
));
|
||||
}
|
||||
for op in ops {
|
||||
if !op.is_active() {
|
||||
continue;
|
||||
@@ -509,6 +576,7 @@ pub fn compose_detail(
|
||||
radius: 0,
|
||||
wgsl: String::new(),
|
||||
uniforms: Vec::new(),
|
||||
storage: Vec::new(),
|
||||
},
|
||||
0,
|
||||
scale,
|
||||
@@ -651,6 +719,10 @@ struct Params {{
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var<uniform> u: Params;
|
||||
@group(0) @binding(2) var output: texture_storage_2d<{store_format}, write>;
|
||||
// A pass whose work is a list rather than a kernel reads it here; every other
|
||||
// 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>>;
|
||||
|
||||
// A neighbour, clamped to the edge of the image.
|
||||
//
|
||||
@@ -681,6 +753,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
// What this render is, relative to the export it has to match.
|
||||
let render_dims = u.detail_base.xy;
|
||||
let render_scale = u.detail_base.z;
|
||||
// How many entries `instances` actually holds, read from the buffer itself
|
||||
// rather than from a uniform so the two cannot disagree. A pass that
|
||||
// declared no list is bound to a one-element placeholder and never asks.
|
||||
let instance_count = arrayLength(&instances);
|
||||
|
||||
var c = tap(coord, vec2<i32>(0));
|
||||
// One scalar per pixel that survives the hand-off from one pass to the
|
||||
@@ -708,6 +784,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
label,
|
||||
source,
|
||||
uniforms: uniform_values,
|
||||
storage: pass.storage.clone(),
|
||||
radius: pass.radius,
|
||||
writes_output,
|
||||
structure_hash,
|
||||
@@ -793,6 +870,7 @@ mod tests {
|
||||
&crate::Framing::new(),
|
||||
dr_types::ColourSpace::Srgb,
|
||||
&crate::mask::MaskStack::new(),
|
||||
&crate::spot::SpotSet::new(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
//! was built for: a horizontal pass then a vertical one is mathematically a 2D
|
||||
//! box average, so if the ping-pong is wired backwards or a pass reads its own
|
||||
//! output the result is visibly not a box blur rather than subtly wrong.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Scale, Unit,
|
||||
@@ -36,28 +37,30 @@ use crate::descriptor::{
|
||||
use crate::detail::{DetailPass, DetailStage, RenderScale};
|
||||
use crate::operation::{Affects, Operation, Uniform};
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: OpId("detail_probe"),
|
||||
label: LocalizedKey("op.detail_probe"),
|
||||
params: &[ParamDescriptor {
|
||||
id: ParamId("radius"),
|
||||
label: LocalizedKey("param.detail_probe.radius"),
|
||||
// A fraction of the frame's shorter edge, which is the unit
|
||||
// `RenderScale::frame_fraction` converts and the unit a mask feather
|
||||
// is already stored in. Stating it in pixels is the mistake this
|
||||
// whole stage is arranged to make impossible.
|
||||
kind: ParamKind::Scalar {
|
||||
min: 0.0,
|
||||
max: 0.25,
|
||||
scale: Scale::Linear,
|
||||
unit: Unit::None,
|
||||
precision: 4,
|
||||
},
|
||||
default: 0.0,
|
||||
facet: None,
|
||||
}],
|
||||
attributes: &[Attribute::Detail],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId("detail_probe"),
|
||||
label: LocalizedKey("op.detail_probe"),
|
||||
params: vec![ParamDescriptor {
|
||||
id: ParamId("radius"),
|
||||
label: LocalizedKey("param.detail_probe.radius"),
|
||||
// A fraction of the frame's shorter edge, which is the unit
|
||||
// `RenderScale::frame_fraction` converts and the unit a mask feather
|
||||
// is already stored in. Stating it in pixels is the mistake this
|
||||
// whole stage is arranged to make impossible.
|
||||
kind: ParamKind::Scalar {
|
||||
min: 0.0,
|
||||
max: 0.25,
|
||||
scale: Scale::Linear,
|
||||
unit: Unit::None,
|
||||
precision: 4,
|
||||
},
|
||||
default: 0.0,
|
||||
facet: None,
|
||||
}],
|
||||
attributes: vec![Attribute::Detail],
|
||||
})
|
||||
});
|
||||
|
||||
/// A separable box blur whose radius is a fraction of the frame's shorter edge.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
@@ -85,8 +88,8 @@ impl BoxBlur {
|
||||
}
|
||||
|
||||
impl Operation for BoxBlur {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||
@@ -141,6 +144,8 @@ impl DetailStage for BoxBlur {
|
||||
.map(|(axis, _)| DetailPass {
|
||||
label: if axis == 0 { "horizontal" } else { "vertical" },
|
||||
radius: r,
|
||||
// A convolution, not a list: nothing to bind at binding 3.
|
||||
storage: Vec::new(),
|
||||
uniforms: vec![
|
||||
Uniform {
|
||||
name: "radius",
|
||||
|
||||
+134
-63
@@ -40,6 +40,7 @@
|
||||
|
||||
use std::f32::consts::PI;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale,
|
||||
@@ -73,50 +74,52 @@ static FRAMING_PARAMS: [ParamId; 8] = [
|
||||
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, ROTATION, FLIP_H, FLIP_V,
|
||||
];
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
// The shape of the frame, and the only operation that changes the
|
||||
// output's dimensions.
|
||||
attributes: &[Attribute::Geometry],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.framing"),
|
||||
params: &[
|
||||
// Straightening. Degrees rather than a normalised amount because a
|
||||
// photographer reading "-1.4°" off a horizon knows what it means.
|
||||
ParamDescriptor::scalar(
|
||||
"angle",
|
||||
"param.angle",
|
||||
-MAX_STRAIGHTEN,
|
||||
MAX_STRAIGHTEN,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
// Quarter turns, 0..3. Separate from `angle` because these are exact
|
||||
// and lossless, and because reorienting a frame is a different
|
||||
// gesture from nudging a horizon.
|
||||
ParamDescriptor::scalar(
|
||||
"rotation",
|
||||
"param.rotation",
|
||||
0.0,
|
||||
3.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::switch("flip_h", "param.flip_h"),
|
||||
ParamDescriptor::switch("flip_v", "param.flip_v"),
|
||||
// The crop rect, in fractions of the source. Normalised rather than
|
||||
// in pixels so a crop survives being applied to a proxy, a full
|
||||
// resolution render, or an export at another size — the same reason
|
||||
// the viewport renders at display resolution (FR-DSP-1).
|
||||
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
|
||||
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
|
||||
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
|
||||
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
|
||||
],
|
||||
};
|
||||
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],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.framing"),
|
||||
params: vec![
|
||||
// Straightening. Degrees rather than a normalised amount because a
|
||||
// photographer reading "-1.4°" off a horizon knows what it means.
|
||||
ParamDescriptor::scalar(
|
||||
"angle",
|
||||
"param.angle",
|
||||
-MAX_STRAIGHTEN,
|
||||
MAX_STRAIGHTEN,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
// Quarter turns, 0..3. Separate from `angle` because these are exact
|
||||
// and lossless, and because reorienting a frame is a different
|
||||
// gesture from nudging a horizon.
|
||||
ParamDescriptor::scalar(
|
||||
"rotation",
|
||||
"param.rotation",
|
||||
0.0,
|
||||
3.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::switch("flip_h", "param.flip_h"),
|
||||
ParamDescriptor::switch("flip_v", "param.flip_v"),
|
||||
// The crop rect, in fractions of the source. Normalised rather than
|
||||
// in pixels so a crop survives being applied to a proxy, a full
|
||||
// resolution render, or an export at another size — the same reason
|
||||
// the viewport renders at display resolution (FR-DSP-1).
|
||||
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
|
||||
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
|
||||
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
|
||||
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
/// A normalised crop rectangle, in fractions of the source image.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -252,8 +255,8 @@ impl Framing {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
pub fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a | FR-DEV-3b | FR-UI-7
|
||||
@@ -280,7 +283,7 @@ impl Framing {
|
||||
/// into the generated panel underneath a crop control that already exists.
|
||||
pub fn presentation(&self) -> Option<Presentation> {
|
||||
Some(Presentation {
|
||||
widgets: &[WidgetKind::CropOverlay],
|
||||
widgets: vec![WidgetKind::CropOverlay],
|
||||
demand: WidgetDemand {
|
||||
// A crop rect is dragged by its corners; nothing about that
|
||||
// reduces to one axis at a time.
|
||||
@@ -290,7 +293,7 @@ impl Framing {
|
||||
// modality, so a thumb is as workable as a mouse.
|
||||
precise_pointing: false,
|
||||
},
|
||||
params: &FRAMING_PARAMS,
|
||||
params: FRAMING_PARAMS.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -359,6 +362,7 @@ impl Framing {
|
||||
self.baseline = orientation;
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-DEV-3h
|
||||
/// The baseline and the user's turns and mirrors, collapsed into one.
|
||||
///
|
||||
/// Everything that renders or measures the frame goes through here; only
|
||||
@@ -370,7 +374,16 @@ impl Framing {
|
||||
/// axes when that turn is odd — which is exactly the case that a naive
|
||||
/// "add the turns, or the flags" gets wrong, and gets wrong silently,
|
||||
/// since the result is still a valid-looking orientation.
|
||||
fn effective(&self) -> (u8, bool, bool) {
|
||||
///
|
||||
/// Returned as an [`dr_types::Orientation`] because that is what it *is*
|
||||
/// — a quarter turn and two mirrors — and because saying so lets a
|
||||
/// caller outside the render reuse
|
||||
/// [`dr_types::Orientation::source_pixel`] rather than write the
|
||||
/// permutation out a second time. `dr-ui`'s segmentation is that caller:
|
||||
/// the detector has to read the photograph the way the photographer does,
|
||||
/// and the thumbnail path already turns its pixels with the same
|
||||
/// function.
|
||||
pub fn effective_orientation(&self) -> dr_types::Orientation {
|
||||
let b = self.baseline;
|
||||
// The user's mirrors, seen from the far side of the baseline's turn.
|
||||
let (ux, uy) = if b.swaps_axes() {
|
||||
@@ -378,11 +391,16 @@ impl Framing {
|
||||
} else {
|
||||
(self.flip_h, self.flip_v)
|
||||
};
|
||||
(
|
||||
(b.quarter_turns + self.quarter_turns) % 4,
|
||||
b.flip_h != ux,
|
||||
b.flip_v != uy,
|
||||
)
|
||||
dr_types::Orientation {
|
||||
quarter_turns: (b.quarter_turns + self.quarter_turns) % 4,
|
||||
flip_h: b.flip_h != ux,
|
||||
flip_v: b.flip_v != uy,
|
||||
}
|
||||
}
|
||||
|
||||
fn effective(&self) -> (u8, bool, bool) {
|
||||
let o = self.effective_orientation();
|
||||
(o.quarter_turns, o.flip_h, o.flip_v)
|
||||
}
|
||||
|
||||
/// Whether this stage currently changes the image.
|
||||
@@ -906,6 +924,61 @@ mod tests {
|
||||
/// turn combined with a user flip — a phone portrait that the user then
|
||||
/// mirrors. Naive flag-ORing renders it mirrored about the wrong axis,
|
||||
/// which still looks like a photograph.
|
||||
/// TRACES: FR-DEV-3h
|
||||
/// The render's map and `dr_types::Orientation`'s must be the same map.
|
||||
///
|
||||
/// There are two ways to ask "where does this output pixel come from":
|
||||
/// the shader prologue (via [`Framing::source_at`], its CPU twin), and
|
||||
/// [`Orientation::source_pixel`], which the grid's thumbnails and the
|
||||
/// segmentation both go through. They are written independently and they
|
||||
/// have to agree, or the photograph and everything drawn over it turn
|
||||
/// different ways.
|
||||
///
|
||||
/// A wrong direction is what this catches, and it is worth stating what
|
||||
/// that looks like: a quarter turn applied backwards is 180° from right,
|
||||
/// which reads as a deliberate transform rather than as a mistake. Checked
|
||||
/// over every EXIF tag and every user rotation on top of it, because the
|
||||
/// composition is where the two could agree singly and disagree together.
|
||||
#[test]
|
||||
fn the_render_and_the_orientation_map_agree() {
|
||||
const SW: u32 = 8;
|
||||
const SH: u32 = 5;
|
||||
|
||||
for tag in 1..=8u16 {
|
||||
for user_turns in 0..4u8 {
|
||||
for user_flip_h in [false, true] {
|
||||
let mut f = Framing::new();
|
||||
f.set_baseline(Orientation::from_exif(tag));
|
||||
f.rotate_quarters(i32::from(user_turns));
|
||||
f.set_param(FLIP_H, f32::from(u8::from(user_flip_h)));
|
||||
|
||||
let effective = f.effective_orientation();
|
||||
let (dw, dh) = effective.oriented_size(SW, SH);
|
||||
assert_eq!(f.output_size(SW, SH), (dw, dh), "tag {tag}/{user_turns}");
|
||||
|
||||
for y in 0..dh {
|
||||
for x in 0..dw {
|
||||
let want = effective.source_pixel(x, y, dw, dh);
|
||||
|
||||
let out = ((x as f32 + 0.5) / dw as f32, (y as f32 + 0.5) / dh as f32);
|
||||
let (u, v) = f.source_at(out, SW, SH);
|
||||
let got = (
|
||||
(u * SW as f32).floor().clamp(0.0, (SW - 1) as f32) as u32,
|
||||
(v * SH as f32).floor().clamp(0.0, (SH - 1) as f32) as u32,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
want, got,
|
||||
"tag {tag}, user {user_turns} turn(s), flip_h {user_flip_h}: \
|
||||
output ({x},{y}) — orientation says {want:?}, the render says {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baseline_and_a_user_rotation_compose_into_one_permutation() {
|
||||
// Non-square and coprime, so no accidental symmetry hides an error.
|
||||
@@ -931,12 +1004,10 @@ mod tests {
|
||||
f.set_param(FLIP_H, f32::from(u8::from(user_flip_h)));
|
||||
f.set_param(FLIP_V, f32::from(u8::from(user_flip_v)));
|
||||
|
||||
let (t, fh, fv) = f.effective();
|
||||
let combined = Orientation {
|
||||
quarter_turns: t,
|
||||
flip_h: fh,
|
||||
flip_v: fv,
|
||||
};
|
||||
// The public accessor, not the private tuple: this is
|
||||
// the permutation `dr-ui` turns the detector's input
|
||||
// by, so it is the one that has to be right.
|
||||
let combined = f.effective_orientation();
|
||||
|
||||
// The output size the composed transform produces must
|
||||
// be the one the two stages produce in sequence.
|
||||
@@ -1358,7 +1429,7 @@ mod tests {
|
||||
fn every_parameter_at_its_extremes_is_survivable() {
|
||||
// The whole descriptor driven to both ends, which is what a codegen
|
||||
// test does and what a corrupt sidecar can do.
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
for value in [-1e9, -1.0, 0.0, 1.0, 1e9, f32::NAN] {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(p.id, p.clamp(value));
|
||||
@@ -1542,7 +1613,7 @@ mod tests {
|
||||
// The same contract the operations honour, checked against the
|
||||
// descriptor rather than a literal.
|
||||
let mut f = Framing::new();
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
f.set_param(p.id, p.default);
|
||||
}
|
||||
assert!(!f.is_active(), "descriptor defaults must be neutral");
|
||||
@@ -1550,7 +1621,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_default_is_within_its_declared_range() {
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
assert_eq!(p.clamp(p.default), p.default, "{} is out of range", p.id);
|
||||
}
|
||||
}
|
||||
|
||||
+156
-18
@@ -7,6 +7,8 @@
|
||||
//! Order is data, not code: operations run in the sequence this holds them,
|
||||
//! so reordering the pipeline needs no code change.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
|
||||
};
|
||||
@@ -14,6 +16,9 @@ use crate::framing::{CropRect, Framing};
|
||||
use crate::mask::MaskStack;
|
||||
use crate::operation::{compose_full, ComposedShader, Operation};
|
||||
use crate::ops;
|
||||
use crate::preset::{Preset, Scope};
|
||||
use crate::spot::SpotSet;
|
||||
use crate::state::{EditState, FilmRebake, FilmRef};
|
||||
|
||||
/// TRACES: FR-DEV-3a
|
||||
/// What one operation offers, as plain data.
|
||||
@@ -46,8 +51,8 @@ pub struct OpCapability {
|
||||
/// other, so a new operation joins the right group by declaring what it
|
||||
/// is — which is the only thing its author is well placed to say.
|
||||
///
|
||||
/// Never empty; `build.rs` refuses an operation that declares none.
|
||||
pub attributes: &'static [Attribute],
|
||||
/// Never empty; both readers refuse an operation that declares none.
|
||||
pub attributes: Vec<Attribute>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a | FR-DEV-3b
|
||||
@@ -92,7 +97,7 @@ pub struct EditGraph {
|
||||
/// layer *contains* a chain of its own. Folding the stack into the global
|
||||
/// list would make the list recursive and every consumer that walks it
|
||||
/// have to know that some entries are really sub-graphs.
|
||||
masks: MaskStack,
|
||||
masks: Arc<MaskStack>,
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// The film stock this edit renders through, if any.
|
||||
///
|
||||
@@ -105,6 +110,15 @@ pub struct EditGraph {
|
||||
/// that render — because they are one fact, and holding them apart is how
|
||||
/// a sidecar comes to name one stock while the shader draws another.
|
||||
film: Option<Film>,
|
||||
/// TRACES: FR-DEV-8
|
||||
/// The repairs (`docs/spot-removal.md`).
|
||||
///
|
||||
/// Apart from `ops` for the third time and the same reason: a spot is not
|
||||
/// a scalar, and a list of them is not a slider. It sits beside the masks
|
||||
/// rather than among them because it is not a mask either — a mask says
|
||||
/// *where* an adjustment applies, and a spot says where a piece of the
|
||||
/// photograph comes from.
|
||||
spots: SpotSet,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
@@ -146,8 +160,9 @@ impl EditGraph {
|
||||
Self {
|
||||
ops: ops::chain(),
|
||||
framing: Framing::new(),
|
||||
masks: MaskStack::new(),
|
||||
masks: Arc::new(MaskStack::new()),
|
||||
film: None,
|
||||
spots: SpotSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,13 +188,30 @@ impl EditGraph {
|
||||
graph
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// The repairs.
|
||||
pub fn spots(&self) -> &SpotSet {
|
||||
&self.spots
|
||||
}
|
||||
|
||||
pub fn spots_mut(&mut self) -> &mut SpotSet {
|
||||
&mut self.spots
|
||||
}
|
||||
|
||||
/// The local adjustment stack.
|
||||
pub fn masks(&self) -> &MaskStack {
|
||||
&self.masks
|
||||
}
|
||||
|
||||
/// The stack, to modify.
|
||||
///
|
||||
/// Clones on write. The stack is shared with every [`EditState`] snapshot
|
||||
/// taken since it last changed — the undo stack holds a run of them — so
|
||||
/// this is where a shared stack becomes this graph's own again. Callers
|
||||
/// see no difference; what it buys is that recording a slider drag does
|
||||
/// not deep-copy a painted mask once a frame.
|
||||
pub fn masks_mut(&mut self) -> &mut MaskStack {
|
||||
&mut self.masks
|
||||
Arc::make_mut(&mut self.masks)
|
||||
}
|
||||
|
||||
/// The framing — crop, straighten, rotation and flips.
|
||||
@@ -211,7 +243,7 @@ impl EditGraph {
|
||||
/// because this is what the codegen tests count `---- ` shader blocks
|
||||
/// against, and framing generates a prologue rather than a colour block.
|
||||
/// A UI wanting everything should read [`Self::capabilities`] (FR-DEV-3a).
|
||||
pub fn descriptors(&self) -> Vec<&'static OpDescriptor> {
|
||||
pub fn descriptors(&self) -> Vec<Arc<OpDescriptor>> {
|
||||
self.ops.iter().map(|o| o.descriptor()).collect()
|
||||
}
|
||||
|
||||
@@ -249,7 +281,7 @@ impl EditGraph {
|
||||
})
|
||||
.collect(),
|
||||
presentation: op.presentation(),
|
||||
attributes: desc.attributes,
|
||||
attributes: desc.attributes.clone(),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -278,7 +310,7 @@ impl EditGraph {
|
||||
// sliders for framing *without naming framing* — see
|
||||
// `Framing::presentation`.
|
||||
presentation: self.framing.presentation(),
|
||||
attributes: desc.attributes,
|
||||
attributes: desc.attributes.clone(),
|
||||
};
|
||||
|
||||
ops.chain(std::iter::once(framing)).collect()
|
||||
@@ -310,9 +342,96 @@ impl EditGraph {
|
||||
self.film.as_ref()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5 | FR-CAT-8
|
||||
/// The whole edit, as data — what an undo step and a sidecar are both
|
||||
/// made of.
|
||||
///
|
||||
/// **The pattern below is exhaustive on purpose.** It is the only thing
|
||||
/// standing between a new kind of graph state and an undo that quietly
|
||||
/// ignores it, which is exactly how the mask stack came to be missing
|
||||
/// from the history for as long as it was. Never add `..` to it: a field
|
||||
/// added to this struct should fail to compile here until somebody has
|
||||
/// decided whether stepping backwards has to put it back. See
|
||||
/// [`crate::state`].
|
||||
pub fn state(&self) -> EditState {
|
||||
let Self {
|
||||
// Both reached through `capabilities`, which is the one walk the
|
||||
// develop panel, the clipboard and the sidecar already make — so
|
||||
// an operation is undoable by virtue of being in the chain, with
|
||||
// nothing to register (FR-DEV-3c).
|
||||
ops: _,
|
||||
framing: _,
|
||||
masks,
|
||||
film,
|
||||
spots,
|
||||
} = self;
|
||||
|
||||
EditState {
|
||||
params: Preset::capture(self),
|
||||
// A refcount bump. See `EditState::masks` for why that matters on
|
||||
// a path called once a frame.
|
||||
masks: Arc::clone(masks),
|
||||
// The names travel; the tables do not. They are derived, and this
|
||||
// crate cannot rebuild them — hence `FilmRebake`.
|
||||
film: film.as_ref().map(|f| FilmRef {
|
||||
stock: f.stock.clone(),
|
||||
print: f.print.clone(),
|
||||
}),
|
||||
spots: spots.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5 | FR-CAT-8
|
||||
/// Put `state` back, replacing whatever this graph held.
|
||||
///
|
||||
/// A *replacement*, not an overlay: a parameter absent from the state
|
||||
/// means default, and a state with no mask blocks means an edit with no
|
||||
/// local adjustments rather than an edit that keeps whatever was on
|
||||
/// screen. That is the same rule [`Preset::apply`] and
|
||||
/// [`crate::Version::apply`] keep, and for the same reason — "the
|
||||
/// photograph is now as it was" is the whole claim the call makes.
|
||||
///
|
||||
/// The **viewport survives**, because [`Preset::apply`] preserves it. Zoom
|
||||
/// says where the user is looking rather than what the picture is, and an
|
||||
/// undo that refitted the frame would read as having navigated somewhere.
|
||||
///
|
||||
/// The pattern below is exhaustive for the reason [`Self::state`]'s is.
|
||||
pub fn set_state(&mut self, state: &EditState) -> FilmRebake {
|
||||
let EditState {
|
||||
params,
|
||||
masks,
|
||||
film,
|
||||
spots,
|
||||
} = state;
|
||||
|
||||
// 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);
|
||||
|
||||
self.masks = Arc::clone(masks);
|
||||
self.spots = spots.clone();
|
||||
|
||||
// Cleared either way, and when a stock is named the caller bakes it.
|
||||
// Left standing, the tables now in the graph would be the ones baked
|
||||
// from the film node's *previous* exposure sliders — and those sliders
|
||||
// were just replaced, so the restored state would render through the
|
||||
// film of the state it replaced. Clearing is the conservative half of
|
||||
// that; `Wanted` is the half that gets it back.
|
||||
self.set_film(None);
|
||||
match film {
|
||||
None => FilmRebake::NotNeeded,
|
||||
Some(want) => FilmRebake::Wanted(want.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
|
||||
if op == crate::framing::ID {
|
||||
let Some(desc) = self.framing.descriptor().param(param) else {
|
||||
// Bound rather than chained: `descriptor()` hands back an owned
|
||||
// `Arc` now, so a `param()` borrowed straight out of the call
|
||||
// would outlive the temporary it came from.
|
||||
let descriptor = self.framing.descriptor();
|
||||
let Some(desc) = descriptor.param(param) else {
|
||||
log::warn!("unknown parameter {param} on {op}; ignoring");
|
||||
return;
|
||||
};
|
||||
@@ -354,7 +473,7 @@ impl EditGraph {
|
||||
/// Reset every parameter of every operation, and the framing, to default.
|
||||
pub fn reset(&mut self) {
|
||||
for op in &mut self.ops {
|
||||
for p in op.descriptor().params {
|
||||
for p in &op.descriptor().params {
|
||||
op.set_param(p.id, p.default);
|
||||
}
|
||||
}
|
||||
@@ -362,7 +481,7 @@ impl EditGraph {
|
||||
// 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
|
||||
// local adjustments, not an edit that keeps whatever was on screen.
|
||||
self.masks = MaskStack::new();
|
||||
self.masks = Arc::new(MaskStack::new());
|
||||
// The film goes too, for the reason the masks do. Restoring it is the
|
||||
// *caller's* job rather than `Version::apply`'s: a sidecar names a
|
||||
// stock, and turning a name into tables needs the profile database,
|
||||
@@ -402,6 +521,7 @@ impl EditGraph {
|
||||
!self.ops.iter().any(|o| o.is_active())
|
||||
&& !self.framing.edits_image()
|
||||
&& self.masks.is_neutral()
|
||||
&& self.spots.is_neutral()
|
||||
}
|
||||
|
||||
/// Generate the fused shader for the current state, encoded to sRGB.
|
||||
@@ -421,7 +541,7 @@ 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)
|
||||
compose_full(&self.ops, &self.framing, output, &self.masks, &self.spots)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-1
|
||||
@@ -462,9 +582,10 @@ impl EditGraph {
|
||||
/// single encoded dispatch it always has.
|
||||
pub fn compose_detail(
|
||||
&self,
|
||||
scale: crate::detail::RenderScale,
|
||||
source: (u32, u32),
|
||||
render: (u32, u32),
|
||||
) -> crate::detail::ComposedDetail {
|
||||
self.compose_detail_for(scale, dr_types::ColourSpace::Srgb)
|
||||
self.compose_detail_for(source, render, dr_types::ColourSpace::Srgb)
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-2
|
||||
@@ -475,12 +596,21 @@ impl EditGraph {
|
||||
/// transform — the fused pass stops at linear working values. Composing
|
||||
/// the two halves for different spaces would encode the edit twice, or
|
||||
/// not at all.
|
||||
/// `source` is the demosaiced image's size and `render` the size being
|
||||
/// drawn. The scale is worked out here rather than handed in, because the
|
||||
/// repairs need the *source* size as well — a spot is stored in normalised
|
||||
/// source coordinates and has to be put through the framing to find out
|
||||
/// where it lands on this render, and a [`crate::detail::RenderScale`]
|
||||
/// describes the region on screen rather than the photograph.
|
||||
pub fn compose_detail_for(
|
||||
&self,
|
||||
scale: crate::detail::RenderScale,
|
||||
source: (u32, u32),
|
||||
render: (u32, u32),
|
||||
output: dr_types::ColourSpace,
|
||||
) -> crate::detail::ComposedDetail {
|
||||
crate::detail::compose_detail(&self.ops, scale, output)
|
||||
let scale = self.render_scale(source, render);
|
||||
let spots = self.spots.passes(&self.framing, source, scale);
|
||||
crate::detail::compose_detail_with(&self.ops, &spots, scale, output)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3d
|
||||
@@ -499,7 +629,7 @@ impl EditGraph {
|
||||
// structure key deliberately omits because they do not recompile a
|
||||
// shader. Both matter to a cached *result*, so both are here.
|
||||
let mut geometry = mix(FNV_OFFSET, self.framing.structure_key());
|
||||
for p in self.framing.descriptor().params {
|
||||
for p in &self.framing.descriptor().params {
|
||||
geometry = hash_bytes(geometry, p.id.0.as_bytes());
|
||||
geometry = mix(
|
||||
geometry,
|
||||
@@ -552,6 +682,14 @@ impl EditGraph {
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-8
|
||||
// The repairs belong to the detail stage, because that is where they
|
||||
// run. Moving a spot therefore re-runs the neighbourhood passes and
|
||||
// leaves the fused colour dispatch and the demosaic alone, which is
|
||||
// the difference between a spot that follows the finger and one that
|
||||
// stutters (FR-DEV-3d).
|
||||
detail = self.spots.hash(detail);
|
||||
|
||||
crate::Invalidation::new(geometry, colour, detail)
|
||||
}
|
||||
}
|
||||
@@ -810,7 +948,7 @@ mod tests {
|
||||
.capabilities()
|
||||
.iter()
|
||||
.flat_map(|c| {
|
||||
c.params.iter().map(move |p| match p.kind {
|
||||
c.params.iter().map(move |p| match &p.kind {
|
||||
ParamKind::Scalar {
|
||||
min,
|
||||
max,
|
||||
|
||||
+737
-62
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,7 @@
|
||||
//! wearing the same lens, which defeats the point of a lens profile.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::descriptor::{OpDescriptor, ParamId};
|
||||
use crate::operation::{Helper, Uniform};
|
||||
@@ -62,8 +63,10 @@ use crate::operation::{Helper, Uniform};
|
||||
/// Object-safe for the same reason [`crate::operation::Operation`] is: the
|
||||
/// graph holds `Box<dyn Warp>` in order, so the geometry chain is data.
|
||||
pub trait Warp: Send + Sync {
|
||||
/// Static description, driving UI generation exactly as for an operation.
|
||||
fn descriptor(&self) -> &'static OpDescriptor;
|
||||
/// This warp's description, driving UI generation exactly as for an
|
||||
/// operation — including being owned rather than `&'static`, for the
|
||||
/// reason [`crate::descriptor::OpDescriptor`] gives.
|
||||
fn descriptor(&self) -> Arc<OpDescriptor>;
|
||||
|
||||
/// Set a parameter. Values arrive already clamped to the descriptor.
|
||||
fn set_param(&mut self, id: ParamId, value: f32);
|
||||
@@ -204,32 +207,38 @@ fn sanitise(id: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use super::*;
|
||||
use crate::descriptor::Attribute;
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor};
|
||||
|
||||
static DESC_A: OpDescriptor = OpDescriptor {
|
||||
id: OpId("warp_a"),
|
||||
label: LocalizedKey("a"),
|
||||
params: &[ParamDescriptor::amount("amount", "a.amount")],
|
||||
attributes: &[Attribute::Tone],
|
||||
};
|
||||
static DESC_B: OpDescriptor = OpDescriptor {
|
||||
id: OpId("warp_b"),
|
||||
label: LocalizedKey("b"),
|
||||
params: &[ParamDescriptor::amount("amount", "b.amount")],
|
||||
attributes: &[Attribute::Tone],
|
||||
};
|
||||
static DESC_A: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId("warp_a"),
|
||||
label: LocalizedKey("a"),
|
||||
params: vec![ParamDescriptor::amount("amount", "a.amount")],
|
||||
attributes: vec![Attribute::Tone],
|
||||
})
|
||||
});
|
||||
static DESC_B: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId("warp_b"),
|
||||
label: LocalizedKey("b"),
|
||||
params: vec![ParamDescriptor::amount("amount", "b.amount")],
|
||||
attributes: vec![Attribute::Tone],
|
||||
})
|
||||
});
|
||||
|
||||
struct Fake {
|
||||
desc: &'static OpDescriptor,
|
||||
desc: Arc<OpDescriptor>,
|
||||
amount: f32,
|
||||
splits: bool,
|
||||
}
|
||||
|
||||
impl Warp for Fake {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
self.desc
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
self.desc.clone()
|
||||
}
|
||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||
self.amount = value;
|
||||
@@ -254,7 +263,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn fake(desc: &'static OpDescriptor, amount: f32, splits: bool) -> Box<dyn Warp> {
|
||||
fn fake(desc: Arc<OpDescriptor>, amount: f32, splits: bool) -> Box<dyn Warp> {
|
||||
Box::new(Fake {
|
||||
desc,
|
||||
amount,
|
||||
@@ -266,7 +275,7 @@ mod tests {
|
||||
fn no_active_warp_composes_to_nothing() {
|
||||
// The property that keeps the common case free: an image with no lens
|
||||
// correction must not pay for a bilinear sample.
|
||||
let composed = compose_warps(&[fake(&DESC_A, 0.0, false)]);
|
||||
let composed = compose_warps(&[fake(DESC_A.clone(), 0.0, false)]);
|
||||
assert!(!composed.is_active());
|
||||
assert!(composed.uniforms.is_empty());
|
||||
assert!(!composed.splits_channels);
|
||||
@@ -274,7 +283,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_active_warp_appears_once() {
|
||||
let composed = compose_warps(&[fake(&DESC_A, 2.0, false)]);
|
||||
let composed = compose_warps(&[fake(DESC_A.clone(), 2.0, false)]);
|
||||
assert!(composed.is_active());
|
||||
assert!(composed.body.contains("---- warp: warp_a ----"));
|
||||
assert!(composed.body.contains("u.warp_a_amount"));
|
||||
@@ -284,7 +293,10 @@ mod tests {
|
||||
fn uniforms_are_prefixed_so_warps_cannot_collide() {
|
||||
// Both fakes declare `amount`; without prefixing the generated struct
|
||||
// would carry a duplicate field and fail to compile.
|
||||
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 2.0, false)]);
|
||||
let composed = compose_warps(&[
|
||||
fake(DESC_A.clone(), 1.0, false),
|
||||
fake(DESC_B.clone(), 2.0, false),
|
||||
]);
|
||||
assert!(composed.uniform_fields.contains("warp_a_amount: f32"));
|
||||
assert!(composed.uniform_fields.contains("warp_b_amount: f32"));
|
||||
assert_eq!(composed.uniforms, vec![1.0, 2.0]);
|
||||
@@ -294,7 +306,10 @@ mod tests {
|
||||
fn channel_splitting_is_requested_by_any_active_warp() {
|
||||
// One CA warp among several must switch the whole stage to the
|
||||
// three-sample path.
|
||||
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, true)]);
|
||||
let composed = compose_warps(&[
|
||||
fake(DESC_A.clone(), 1.0, false),
|
||||
fake(DESC_B.clone(), 1.0, true),
|
||||
]);
|
||||
assert!(composed.splits_channels);
|
||||
}
|
||||
|
||||
@@ -302,14 +317,20 @@ mod tests {
|
||||
fn an_inactive_splitting_warp_does_not_force_three_samples() {
|
||||
// CA present but at neutral must cost nothing — otherwise every image
|
||||
// with the panel visible pays triple bandwidth.
|
||||
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 0.0, true)]);
|
||||
let composed = compose_warps(&[
|
||||
fake(DESC_A.clone(), 1.0, false),
|
||||
fake(DESC_B.clone(), 0.0, true),
|
||||
]);
|
||||
assert!(composed.is_active());
|
||||
assert!(!composed.splits_channels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warps_compose_in_order() {
|
||||
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
|
||||
let composed = compose_warps(&[
|
||||
fake(DESC_A.clone(), 1.0, false),
|
||||
fake(DESC_B.clone(), 1.0, false),
|
||||
]);
|
||||
let a = composed.body.find("warp_a").expect("a present");
|
||||
let b = composed.body.find("warp_b").expect("b present");
|
||||
assert!(a < b, "warps must chain in graph order");
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
//! single multiply and white balance a per-channel scale; on gamma-encoded
|
||||
//! data neither would be physically meaningful (ARCH §5.2).
|
||||
|
||||
pub mod declared;
|
||||
pub mod descriptor;
|
||||
pub mod detail;
|
||||
pub mod framing;
|
||||
@@ -42,7 +43,10 @@ pub mod operation;
|
||||
pub mod ops;
|
||||
pub mod preset;
|
||||
pub mod sidecar;
|
||||
pub mod spot;
|
||||
pub mod state;
|
||||
|
||||
pub use declared::{Declaration, DeclaredOp};
|
||||
pub use descriptor::{
|
||||
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
|
||||
Presentation, Scale, Unit, WidgetDemand, WidgetKind,
|
||||
@@ -52,7 +56,7 @@ pub use detail::{
|
||||
};
|
||||
pub use framing::{CropRect, Framing};
|
||||
pub use graph::{EditGraph, OpCapability, ParamCapability};
|
||||
pub use history::{Edit, History};
|
||||
pub use history::{Edit, Entry as HistoryEntry, History, Step};
|
||||
pub use lens::{compose_warps, ComposedWarp, Warp};
|
||||
pub use operation::{
|
||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
||||
@@ -60,6 +64,8 @@ pub use operation::{
|
||||
};
|
||||
pub use preset::{Preset, Scope};
|
||||
pub use sidecar::{Sidecar, Version};
|
||||
pub use spot::{Spot, SpotMode, SpotSet};
|
||||
pub use state::{EditState, FilmRebake, FilmRef};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -77,13 +83,13 @@ mod tests {
|
||||
let mut g = EditGraph::default_chain();
|
||||
for desc in g.descriptors() {
|
||||
for (i, p) in desc.params.iter().enumerate() {
|
||||
let v = match p.kind {
|
||||
let v = match &p.kind {
|
||||
ParamKind::Scalar { min, max, .. } => {
|
||||
// A fraction that differs per parameter, so no two
|
||||
// move in lockstep.
|
||||
let fraction = 0.15 + 0.05 * (i % 4) as f32;
|
||||
let step = (max - min) * fraction;
|
||||
if p.default + step <= max {
|
||||
if p.default + step <= *max {
|
||||
p.default + step
|
||||
} else {
|
||||
p.default - step
|
||||
@@ -150,8 +156,7 @@ mod tests {
|
||||
// source pixels, so on a proxy it may honestly decline to draw at all
|
||||
// (`RenderScale::resolves`) — which would put it in neither half and
|
||||
// make the assertion fail for a reason that is not a defect.
|
||||
let scale = g.render_scale((4000, 3000), (4000, 3000));
|
||||
let detail = g.compose_detail(scale);
|
||||
let detail = g.compose_detail((4000, 3000), (4000, 3000));
|
||||
let mut fused_blocks = 0;
|
||||
for desc in g.descriptors() {
|
||||
let id = desc.id.0;
|
||||
@@ -214,7 +219,7 @@ mod tests {
|
||||
// A default outside its own range would mean a fresh image opens
|
||||
// with a value the UI cannot represent.
|
||||
for desc in EditGraph::default_chain().descriptors() {
|
||||
for p in desc.params {
|
||||
for p in &desc.params {
|
||||
assert_eq!(
|
||||
p.clamp(p.default),
|
||||
p.default,
|
||||
@@ -233,7 +238,7 @@ mod tests {
|
||||
// operation must read its own default as neutral.
|
||||
let g = EditGraph::default_chain();
|
||||
for desc in g.descriptors() {
|
||||
for p in desc.params {
|
||||
for p in &desc.params {
|
||||
assert_eq!(
|
||||
g.param(desc.id, p.id),
|
||||
Some(p.default),
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
//! see there for what happens when it does not match.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::descriptor::{OpDescriptor, ParamId};
|
||||
use crate::operation::Operation;
|
||||
@@ -692,7 +693,7 @@ impl Clone for MaskLayer {
|
||||
fn clone(&self) -> Self {
|
||||
let mut ops = layer_chain();
|
||||
for (dst, src) in ops.iter_mut().zip(&self.ops) {
|
||||
for p in src.descriptor().params {
|
||||
for p in &src.descriptor().params {
|
||||
dst.set_param(p.id, src.param(p.id));
|
||||
}
|
||||
}
|
||||
@@ -947,7 +948,7 @@ impl MaskLayer {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn descriptors(&self) -> Vec<&'static OpDescriptor> {
|
||||
pub fn descriptors(&self) -> Vec<Arc<OpDescriptor>> {
|
||||
self.ops.iter().map(|o| o.descriptor()).collect()
|
||||
}
|
||||
|
||||
@@ -995,7 +996,7 @@ impl MaskLayer {
|
||||
})
|
||||
.collect(),
|
||||
presentation: op.presentation(),
|
||||
attributes: desc.attributes,
|
||||
attributes: desc.attributes.clone(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -1007,7 +1008,7 @@ impl MaskLayer {
|
||||
/// arrive at — so "start this layer's edit again" must not throw it away.
|
||||
pub fn reset_adjustments(&mut self) {
|
||||
for op in &mut self.ops {
|
||||
for p in op.descriptor().params {
|
||||
for p in &op.descriptor().params {
|
||||
op.set_param(p.id, p.default);
|
||||
}
|
||||
}
|
||||
@@ -1026,10 +1027,18 @@ impl MaskLayer {
|
||||
/// Every non-default parameter, for the sidecar.
|
||||
pub fn params(&self) -> impl Iterator<Item = (&'static str, &'static str, f32)> + '_ {
|
||||
self.ops.iter().flat_map(|o| {
|
||||
let id = o.descriptor().id.0;
|
||||
o.descriptor().params.iter().filter_map(move |p| {
|
||||
let v = o.param(p.id);
|
||||
(v != p.default).then_some((id, p.id.0, v))
|
||||
let desc = o.descriptor();
|
||||
let id = desc.id.0;
|
||||
// Collected rather than borrowed from `desc`: a descriptor is an
|
||||
// `Arc` handed over by value now (FR-PLG-2), so it would be
|
||||
// dropped at the end of this closure and the lazy iterator would
|
||||
// outlive it. The ids and defaults are all this needs, and there
|
||||
// are a handful of them.
|
||||
let params: Vec<(ParamId, f32)> =
|
||||
desc.params.iter().map(|p| (p.id, p.default)).collect();
|
||||
params.into_iter().filter_map(move |(param, default)| {
|
||||
let v = o.param(param);
|
||||
(v != default).then_some((id, param.0, v))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
//! generator emits readable, commented output — see [`compose`].
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use dr_types::{ColourSpace, Transfer};
|
||||
|
||||
@@ -171,7 +172,7 @@ impl Invalidation {
|
||||
pub(crate) fn hash_op(h: u64, op: &dyn Operation) -> u64 {
|
||||
let desc = op.descriptor();
|
||||
let mut h = hash_bytes(h, desc.id.0.as_bytes());
|
||||
for p in desc.params {
|
||||
for p in &desc.params {
|
||||
h = hash_bytes(h, p.id.0.as_bytes());
|
||||
h = mix(h, u64::from(canonical_bits(op.param(p.id))));
|
||||
}
|
||||
@@ -213,6 +214,12 @@ pub(crate) const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
pub struct Uniform {
|
||||
/// Field name as it appears in WGSL. Prefixed with the op id by the
|
||||
/// composer, so two operations may both declare `amount`.
|
||||
///
|
||||
/// `&'static str` for the reason [`crate::descriptor::intern`] gives about
|
||||
/// ids: a uniform name is a small, deduplicated, process-lifetime piece of
|
||||
/// vocabulary, and a declared operation interns its names once when it is
|
||||
/// parsed rather than allocating them on every `uniforms()` call — which
|
||||
/// happens per composition, and composition happens per frame.
|
||||
pub name: &'static str,
|
||||
pub value: f32,
|
||||
}
|
||||
@@ -222,8 +229,32 @@ pub struct Uniform {
|
||||
/// Object-safe: the pipeline holds `Box<dyn Operation>` in graph order, so
|
||||
/// order is data rather than code (ARCH §3.4).
|
||||
pub trait Operation: Send + Sync {
|
||||
/// Static description, driving UI generation (FR-DEV-3a).
|
||||
fn descriptor(&self) -> &'static OpDescriptor;
|
||||
/// TRACES: FR-DEV-3a | FR-PLG-2
|
||||
/// This operation's description, driving UI generation (FR-DEV-3a).
|
||||
///
|
||||
/// **Shared and owned rather than `&'static`.** See [`OpDescriptor`] for
|
||||
/// why — in short, a `&'static` descriptor is one a compile-time literal
|
||||
/// can produce and a load-time declaration cannot, which would make a
|
||||
/// plugin a second-class kind of operation for a reason that is purely an
|
||||
/// artefact of how the built-ins happen to be written.
|
||||
///
|
||||
/// # What this costs, and where
|
||||
///
|
||||
/// One `Arc` clone and drop per call. Descriptors are read when a panel is
|
||||
/// built (`EditGraph::capabilities`), when a sidecar is written or read,
|
||||
/// and when the history names what changed — none of which is a per-frame
|
||||
/// path.
|
||||
///
|
||||
/// There is **one** exception, and it is worth stating plainly rather than
|
||||
/// letting somebody discover it with a profiler: [`compose_full`] reads
|
||||
/// `descriptor().id` once per *active* operation to prefix its uniforms,
|
||||
/// and `dr-ui` composes on every frame it draws. That is a handful of
|
||||
/// atomic increments — a dozen or so, against a composition that is
|
||||
/// already building several kilobytes of WGSL text from scratch on the
|
||||
/// same call. If composition ever stops being a per-frame operation, this
|
||||
/// stops being a question at all; while it is one, the refcount is not
|
||||
/// what makes it expensive.
|
||||
fn descriptor(&self) -> Arc<OpDescriptor>;
|
||||
|
||||
/// Set a parameter. Values arrive already clamped to the descriptor.
|
||||
fn set_param(&mut self, id: ParamId, value: f32);
|
||||
@@ -321,7 +352,13 @@ pub trait Operation: Send + Sync {
|
||||
/// Emitted once per *distinct* function name even if several operations
|
||||
/// request it, so shared helpers (luminance, soft clipping) are declared
|
||||
/// exactly once.
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
///
|
||||
/// Borrowed from `self` rather than `'static`, for the reason
|
||||
/// [`Self::descriptor`] is owned: a generated operation returns a
|
||||
/// `&'static [Helper]` and coerces, while an operation built from a
|
||||
/// declaration at load time owns its list. The [`Helper`] *strings*
|
||||
/// themselves stay `&'static` — they are interned, like the ids.
|
||||
fn helpers(&self) -> &[Helper] {
|
||||
&[]
|
||||
}
|
||||
|
||||
@@ -467,7 +504,13 @@ pub fn compose_with_framing(
|
||||
framing: &Framing,
|
||||
output: ColourSpace,
|
||||
) -> ComposedShader {
|
||||
compose_full(ops, framing, output, &MaskStack::new())
|
||||
compose_full(
|
||||
ops,
|
||||
framing,
|
||||
output,
|
||||
&MaskStack::new(),
|
||||
&crate::spot::SpotSet::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
@@ -487,6 +530,7 @@ pub fn compose_full(
|
||||
framing: &Framing,
|
||||
output: ColourSpace,
|
||||
masks: &MaskStack,
|
||||
spots: &crate::spot::SpotSet,
|
||||
) -> ComposedShader {
|
||||
// Active *point* operations. A neighbourhood operation is filtered out
|
||||
// here rather than asked for a fragment it cannot write: it reads pixels
|
||||
@@ -506,11 +550,18 @@ pub fn compose_full(
|
||||
// than from a flag the caller sets, because a caller that got the flag
|
||||
// wrong would produce a shader whose storage format does not match the
|
||||
// texture bound to it.
|
||||
let output_mode = if ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
|
||||
OutputMode::LinearWorking
|
||||
} else {
|
||||
OutputMode::Encoded
|
||||
};
|
||||
//
|
||||
// TRACES: FR-DEV-8
|
||||
// The repairs count too, and they are the reason this takes a spot set at
|
||||
// all: a photograph with a spot on it and no sharpening still has a detail
|
||||
// stage, and a fused pass that encoded its own output there would quantise
|
||||
// twice and be bound to a texture of the wrong format.
|
||||
let output_mode =
|
||||
if ops.iter().any(|o| o.is_active() && o.detail().is_some()) || !spots.is_neutral() {
|
||||
OutputMode::LinearWorking
|
||||
} else {
|
||||
OutputMode::Encoded
|
||||
};
|
||||
|
||||
// Whether an operation has taken over the rendering. Decided from the
|
||||
// operations for the same reason `output_mode` is: a caller that got it
|
||||
@@ -1170,31 +1221,37 @@ pub(crate) fn sanitise(id: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::descriptor::Attribute;
|
||||
use crate::descriptor::{LocalizedKey, OpId, ParamDescriptor};
|
||||
|
||||
static DESC_A: OpDescriptor = OpDescriptor {
|
||||
id: OpId("op_a"),
|
||||
label: LocalizedKey("a"),
|
||||
params: &[ParamDescriptor::amount("amount", "a.amount")],
|
||||
attributes: &[Attribute::Tone],
|
||||
};
|
||||
static DESC_B: OpDescriptor = OpDescriptor {
|
||||
id: OpId("op_b"),
|
||||
label: LocalizedKey("b"),
|
||||
params: &[ParamDescriptor::amount("amount", "b.amount")],
|
||||
attributes: &[Attribute::Tone],
|
||||
};
|
||||
static DESC_A: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId("op_a"),
|
||||
label: LocalizedKey("a"),
|
||||
params: vec![ParamDescriptor::amount("amount", "a.amount")],
|
||||
attributes: vec![Attribute::Tone],
|
||||
})
|
||||
});
|
||||
static DESC_B: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId("op_b"),
|
||||
label: LocalizedKey("b"),
|
||||
params: vec![ParamDescriptor::amount("amount", "b.amount")],
|
||||
attributes: vec![Attribute::Tone],
|
||||
})
|
||||
});
|
||||
|
||||
struct Fake {
|
||||
desc: &'static OpDescriptor,
|
||||
desc: Arc<OpDescriptor>,
|
||||
amount: f32,
|
||||
helper: Option<Helper>,
|
||||
}
|
||||
|
||||
impl Operation for Fake {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
self.desc
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
self.desc.clone()
|
||||
}
|
||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||
self.amount = value;
|
||||
@@ -1227,7 +1284,7 @@ mod tests {
|
||||
source: "fn luma(c: vec3<f32>) -> f32 { return c.g; }",
|
||||
}];
|
||||
|
||||
fn fake(desc: &'static OpDescriptor, amount: f32, helper: bool) -> Box<dyn Operation> {
|
||||
fn fake(desc: Arc<OpDescriptor>, amount: f32, helper: bool) -> Box<dyn Operation> {
|
||||
Box::new(Fake {
|
||||
desc,
|
||||
amount,
|
||||
@@ -1239,7 +1296,7 @@ mod tests {
|
||||
fn an_inactive_operation_contributes_nothing() {
|
||||
// The point of composing rather than branching: an op at neutral
|
||||
// must not appear in the source at all.
|
||||
let ops = vec![fake(&DESC_A, 0.0, false)];
|
||||
let ops = vec![fake(DESC_A.clone(), 0.0, false)];
|
||||
let shader = compose(&ops);
|
||||
assert!(
|
||||
!shader.source.contains("op_a"),
|
||||
@@ -1260,7 +1317,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_active_operation_appears_once() {
|
||||
let ops = vec![fake(&DESC_A, 2.0, false)];
|
||||
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
|
||||
let shader = compose(&ops);
|
||||
assert!(shader.source.contains("---- op_a ----"));
|
||||
assert!(shader.source.contains("u.op_a_amount"));
|
||||
@@ -1271,7 +1328,10 @@ mod tests {
|
||||
// Both fakes declare a uniform called `amount`. Without prefixing,
|
||||
// the generated struct would have a duplicate field and fail to
|
||||
// compile — the failure mode that makes naive concatenation fragile.
|
||||
let ops = vec![fake(&DESC_A, 1.0, false), fake(&DESC_B, 2.0, false)];
|
||||
let ops = vec![
|
||||
fake(DESC_A.clone(), 1.0, false),
|
||||
fake(DESC_B.clone(), 2.0, false),
|
||||
];
|
||||
let shader = compose(&ops);
|
||||
assert!(shader.source.contains("op_a_amount: f32"));
|
||||
assert!(shader.source.contains("op_b_amount: f32"));
|
||||
@@ -1281,7 +1341,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn uniform_values_follow_declaration_order() {
|
||||
let ops = vec![fake(&DESC_A, 1.5, false), fake(&DESC_B, 2.5, false)];
|
||||
let ops = vec![
|
||||
fake(DESC_A.clone(), 1.5, false),
|
||||
fake(DESC_B.clone(), 2.5, false),
|
||||
];
|
||||
let shader = compose(&ops);
|
||||
assert_eq!(shader.uniforms[PREAMBLE_FIELDS], 1.5);
|
||||
assert_eq!(shader.uniforms[PREAMBLE_FIELDS + 1], 2.5);
|
||||
@@ -1291,7 +1354,10 @@ mod tests {
|
||||
fn a_shared_helper_is_emitted_once() {
|
||||
// Two operations wanting the same helper must not produce a
|
||||
// duplicate function definition.
|
||||
let ops = vec![fake(&DESC_A, 1.0, true), fake(&DESC_B, 1.0, true)];
|
||||
let ops = vec![
|
||||
fake(DESC_A.clone(), 1.0, true),
|
||||
fake(DESC_B.clone(), 1.0, true),
|
||||
];
|
||||
let shader = compose(&ops);
|
||||
assert_eq!(
|
||||
shader.source.matches("fn luma(").count(),
|
||||
@@ -1305,7 +1371,17 @@ mod tests {
|
||||
// WGSL rejects a uniform struct whose size is not a multiple of 16.
|
||||
for n in 0..6 {
|
||||
let ops: Vec<Box<dyn Operation>> = (0..n)
|
||||
.map(|i| fake(if i % 2 == 0 { &DESC_A } else { &DESC_B }, 1.0, false))
|
||||
.map(|i| {
|
||||
fake(
|
||||
if i % 2 == 0 {
|
||||
DESC_A.clone()
|
||||
} else {
|
||||
DESC_B.clone()
|
||||
},
|
||||
1.0,
|
||||
false,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let shader = compose(&ops);
|
||||
assert_eq!(
|
||||
@@ -1321,11 +1397,14 @@ mod tests {
|
||||
fn structure_hash_ignores_values_but_tracks_the_op_set() {
|
||||
// The property the shader cache depends on: moving a slider must not
|
||||
// trigger a recompile, but enabling an operation must.
|
||||
let a1 = compose(&[fake(&DESC_A, 1.0, false)]).structure_hash;
|
||||
let a2 = compose(&[fake(&DESC_A, 9.0, false)]).structure_hash;
|
||||
let a1 = compose(&[fake(DESC_A.clone(), 1.0, false)]).structure_hash;
|
||||
let a2 = compose(&[fake(DESC_A.clone(), 9.0, false)]).structure_hash;
|
||||
assert_eq!(a1, a2, "a value change must reuse the compiled pipeline");
|
||||
|
||||
let both = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
|
||||
let both = compose(&[
|
||||
fake(DESC_A.clone(), 1.0, false),
|
||||
fake(DESC_B.clone(), 1.0, false),
|
||||
]);
|
||||
assert_ne!(a1, both.structure_hash, "a different op-set must recompile");
|
||||
}
|
||||
|
||||
@@ -1333,8 +1412,14 @@ mod tests {
|
||||
fn structure_hash_is_order_sensitive() {
|
||||
// Operation order is data (ARCH §3.4); two orders are different
|
||||
// shaders and must not share a cache entry.
|
||||
let ab = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
|
||||
let ba = compose(&[fake(&DESC_B, 1.0, false), fake(&DESC_A, 1.0, false)]);
|
||||
let ab = compose(&[
|
||||
fake(DESC_A.clone(), 1.0, false),
|
||||
fake(DESC_B.clone(), 1.0, false),
|
||||
]);
|
||||
let ba = compose(&[
|
||||
fake(DESC_B.clone(), 1.0, false),
|
||||
fake(DESC_A.clone(), 1.0, false),
|
||||
]);
|
||||
assert_ne!(ab.structure_hash, ba.structure_hash);
|
||||
}
|
||||
|
||||
@@ -1398,7 +1483,7 @@ mod tests {
|
||||
// Exposure and the tonal controls act on white-balanced values; if
|
||||
// the multiply came afterwards, every operation would be reasoning
|
||||
// about a green-cast image.
|
||||
let ops = vec![fake(&DESC_A, 2.0, false)];
|
||||
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
|
||||
let source = compose(&ops).source;
|
||||
let wb = source.find("u.as_shot_wb").expect("wb applied");
|
||||
let op = source.find("---- op_a ----").expect("op present");
|
||||
@@ -1458,7 +1543,7 @@ mod tests {
|
||||
// The other half, and the one that would fail silently: a bug that
|
||||
// suppressed the tail unconditionally renders every ordinary edit
|
||||
// flat and uncorrected, which reads as a broken camera profile.
|
||||
let source = compose(&[fake(&DESC_A, 2.0, false)]).source;
|
||||
let source = compose(&[fake(DESC_A.clone(), 2.0, false)]).source;
|
||||
assert!(source.contains("base_curve_last.z > 0.5"));
|
||||
assert!(source.contains("Camera space -> linear sRGB"));
|
||||
}
|
||||
@@ -1470,7 +1555,7 @@ mod tests {
|
||||
// stock loaded is the default state of every photograph in the
|
||||
// catalogue, and it must not disturb the camera's own rendering.
|
||||
let film: Box<dyn Operation> = Box::new(crate::ops::FilmSim::new());
|
||||
let source = compose(&[film, fake(&DESC_A, 2.0, false)]).source;
|
||||
let source = compose(&[film, fake(DESC_A.clone(), 2.0, false)]).source;
|
||||
assert!(source.contains("base_curve_last.z > 0.5"));
|
||||
assert!(source.contains("Camera space -> linear sRGB"));
|
||||
}
|
||||
@@ -1479,7 +1564,7 @@ mod tests {
|
||||
fn the_camera_matrix_is_applied_after_the_operations() {
|
||||
// Adjustments are meaningful in sensor-native space, where highlight
|
||||
// headroom still exists; converting first would clip it away.
|
||||
let ops = vec![fake(&DESC_A, 2.0, false)];
|
||||
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
|
||||
let source = compose(&ops).source;
|
||||
let op = source.find("---- op_a ----").expect("op present");
|
||||
let matrix = source.find("u.cam_to_srgb_0").expect("matrix applied");
|
||||
@@ -1500,7 +1585,7 @@ mod tests {
|
||||
// Before the matrix: the curve was tuned against this body's own
|
||||
// primaries. Applied after the conversion it would be a Canon
|
||||
// rendering acting on sRGB values, which is a different curve.
|
||||
let ops = vec![fake(&DESC_A, 2.0, false)];
|
||||
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
|
||||
let source = compose(&ops).source;
|
||||
let op = source.find("---- op_a ----").expect("op present");
|
||||
let curve = source
|
||||
@@ -1578,7 +1663,7 @@ mod tests {
|
||||
// it must not pick up an identity matrix multiply for the sake of
|
||||
// generality. Asserted against the source rather than against timing,
|
||||
// which would not fail reliably.
|
||||
let ops = vec![fake(&DESC_A, 1.0, false)];
|
||||
let ops = vec![fake(DESC_A.clone(), 1.0, false)];
|
||||
let srgb = compose_to(&ops, ColourSpace::Srgb).source;
|
||||
assert!(
|
||||
!srgb.contains("Linear sRGB -> linear sRGB"),
|
||||
@@ -1593,7 +1678,7 @@ mod tests {
|
||||
// in linear sRGB, the primaries conversion carries it into the wider
|
||||
// space, and only then is it clipped — clipping first would discard
|
||||
// exactly the colours the wider space was chosen to keep.
|
||||
let source = compose_to(&[fake(&DESC_A, 1.0, false)], ColourSpace::DisplayP3).source;
|
||||
let source = compose_to(&[fake(DESC_A.clone(), 1.0, false)], ColourSpace::DisplayP3).source;
|
||||
let camera = source.find("u.cam_to_srgb_0").expect("camera matrix");
|
||||
let convert = source
|
||||
.find("Linear sRGB -> linear Display P3")
|
||||
@@ -1645,7 +1730,7 @@ mod tests {
|
||||
// an export that came out sRGB and claimed to be Display P3.
|
||||
let mut seen: Vec<u64> = Vec::new();
|
||||
for space in ColourSpace::ALL {
|
||||
let h = compose_to(&[fake(&DESC_A, 1.0, false)], space).structure_hash;
|
||||
let h = compose_to(&[fake(DESC_A.clone(), 1.0, false)], space).structure_hash;
|
||||
assert!(!seen.contains(&h), "{space:?} collides with another space");
|
||||
seen.push(h);
|
||||
}
|
||||
@@ -1655,7 +1740,7 @@ mod tests {
|
||||
fn generated_source_carries_a_do_not_edit_banner() {
|
||||
// Someone will eventually find this in a debugger and try to fix it
|
||||
// in place.
|
||||
let shader = compose(&[fake(&DESC_A, 1.0, false)]);
|
||||
let shader = compose(&[fake(DESC_A.clone(), 1.0, false)]);
|
||||
assert!(shader.source.starts_with("// GENERATED"));
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
//! untouched means a mis-set correction shifts the channels that contribute
|
||||
//! least to perceived sharpness. Scaling all three about a virtual reference
|
||||
//! would soften the image even when the correction is right.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||
@@ -49,36 +50,38 @@ pub const BLUE: ParamId = ParamId("blue");
|
||||
/// resolution left to tune by eye at 100%.
|
||||
const MAX_SCALE: f32 = 0.005;
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
attributes: &[Attribute::Optics],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.aberration"),
|
||||
params: &[
|
||||
// Two independent controls rather than one: the red and blue
|
||||
// displacements are caused by different ends of the spectrum and are
|
||||
// not symmetric, so a single "fringing" slider could not remove both.
|
||||
ParamDescriptor::scalar(
|
||||
"red",
|
||||
"param.aberration.red",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::scalar(
|
||||
"blue",
|
||||
"param.aberration.blue",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
attributes: vec![Attribute::Optics],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.aberration"),
|
||||
params: vec![
|
||||
// Two independent controls rather than one: the red and blue
|
||||
// displacements are caused by different ends of the spectrum and are
|
||||
// not symmetric, so a single "fringing" slider could not remove both.
|
||||
ParamDescriptor::scalar(
|
||||
"red",
|
||||
"param.aberration.red",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::scalar(
|
||||
"blue",
|
||||
"param.aberration.blue",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Aberration {
|
||||
@@ -113,8 +116,8 @@ impl Aberration {
|
||||
}
|
||||
|
||||
impl Warp for Aberration {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
@@ -300,7 +303,7 @@ mod tests {
|
||||
#[test]
|
||||
fn every_default_is_neutral() {
|
||||
let mut a = Aberration::new();
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
a.set_param(p.id, p.default);
|
||||
}
|
||||
assert!(!a.is_active(), "descriptor defaults must be neutral");
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
//! would be a guess dressed as a number; `resolves` is the line the stage
|
||||
//! already draws, and drawing it in two places differently is worse than a
|
||||
//! visible step.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||
@@ -163,46 +164,48 @@ const MAX_KERNEL: f32 = 48.0;
|
||||
/// every editor's capture sharpening starts.
|
||||
const DEFAULT_RADIUS: f32 = 1.0;
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.capture_sharpen"),
|
||||
params: &[
|
||||
// Amount carries the neutral, which is why it is first: the operation
|
||||
// is off when this is zero regardless of the other two, so a reset is
|
||||
// one control and the panel's ordering matches the way it is used.
|
||||
ParamDescriptor::amount("amount", "param.amount"),
|
||||
// In **source pixels** — see the module documentation. Half a photosite
|
||||
// is the smallest radius that means anything on a Bayer sensor, and
|
||||
// three is already past the point where an unsharp mask is sharpening
|
||||
// rather than adding local contrast; a photographer wanting the latter
|
||||
// wants clarity, which is a different operation with a different unit.
|
||||
ParamDescriptor::scalar(
|
||||
"radius",
|
||||
"param.radius",
|
||||
0.5,
|
||||
3.0,
|
||||
DEFAULT_RADIUS,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
// A fraction, but declared as a scalar rather than through
|
||||
// `ParamDescriptor::fraction` for its precision alone: four decimal
|
||||
// places on a control whose whole useful travel is a dozen steps
|
||||
// reads as noise, and invites fiddling with digits that do nothing.
|
||||
ParamDescriptor::scalar(
|
||||
"threshold",
|
||||
"param.threshold",
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
],
|
||||
attributes: &[Attribute::Detail],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.capture_sharpen"),
|
||||
params: vec![
|
||||
// Amount carries the neutral, which is why it is first: the operation
|
||||
// is off when this is zero regardless of the other two, so a reset is
|
||||
// one control and the panel's ordering matches the way it is used.
|
||||
ParamDescriptor::amount("amount", "param.amount"),
|
||||
// In **source pixels** — see the module documentation. Half a photosite
|
||||
// is the smallest radius that means anything on a Bayer sensor, and
|
||||
// three is already past the point where an unsharp mask is sharpening
|
||||
// rather than adding local contrast; a photographer wanting the latter
|
||||
// wants clarity, which is a different operation with a different unit.
|
||||
ParamDescriptor::scalar(
|
||||
"radius",
|
||||
"param.radius",
|
||||
0.5,
|
||||
3.0,
|
||||
DEFAULT_RADIUS,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
// A fraction, but declared as a scalar rather than through
|
||||
// `ParamDescriptor::fraction` for its precision alone: four decimal
|
||||
// places on a control whose whole useful travel is a dozen steps
|
||||
// reads as noise, and invites fiddling with digits that do nothing.
|
||||
ParamDescriptor::scalar(
|
||||
"threshold",
|
||||
"param.threshold",
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
],
|
||||
attributes: vec![Attribute::Detail],
|
||||
})
|
||||
});
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Capture sharpening: a separable unsharp mask with a contrast threshold.
|
||||
@@ -275,8 +278,8 @@ impl CaptureSharpen {
|
||||
}
|
||||
|
||||
impl Operation for CaptureSharpen {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
@@ -358,6 +361,8 @@ impl DetailStage for CaptureSharpen {
|
||||
.map(|label| DetailPass {
|
||||
label,
|
||||
radius: extent,
|
||||
// A convolution, not a list: nothing to bind at binding 3.
|
||||
storage: Vec::new(),
|
||||
uniforms: vec![
|
||||
Uniform {
|
||||
// −100…100 as a gain around zero. A hundred percent is
|
||||
@@ -410,6 +415,8 @@ fn nothing_to_sharpen() -> DetailPass {
|
||||
label: "unresolved",
|
||||
// Reads only the pixel it writes, so a tile needs no halo at all.
|
||||
radius: 0,
|
||||
// A convolution, not a list: nothing to bind at binding 3.
|
||||
storage: Vec::new(),
|
||||
uniforms: Vec::new(),
|
||||
wgsl: "// The chosen radius is finer than one pixel of this render, so the detail
|
||||
// it would act on is not in this texture — it was lost to the downscale
|
||||
@@ -537,7 +544,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn chain_at(graph: &EditGraph, scale: RenderScale) -> crate::detail::ComposedDetail {
|
||||
graph.compose_detail_for(scale, ColourSpace::Srgb)
|
||||
// The scale is what these tests vary, so it is rebuilt into the two
|
||||
// sizes it stands for rather than handed over: a render of the full
|
||||
// frame at `render_size`, from a source of `full_size`.
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
//! the band acts at full strength right up to a hard edge; and with two bands
|
||||
//! adjusted, each one's share depends on what the other is set to, so turning
|
||||
//! up one colour's saturation quietly weakened its neighbour's hue shift.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId,
|
||||
@@ -123,8 +124,9 @@ impl Channel {
|
||||
}
|
||||
|
||||
// Parameter descriptors, one per band per channel. Written out rather than
|
||||
// generated because `ParamDescriptor` must be `const` to live in a `static`,
|
||||
// and a const loop cannot build a slice. The macro keeps it honest.
|
||||
// looped because `concat!` needs literals: every id is built from its band's
|
||||
// key, and a runtime loop has no way to spell `orange_sat`. The macro keeps it
|
||||
// honest.
|
||||
//
|
||||
// **Every one of them is faceted**, and that is what makes the operation
|
||||
// legible in a panel. Thirty-six parameters presented as a flat list are
|
||||
@@ -137,7 +139,7 @@ impl Channel {
|
||||
// §4.3a); this only says the parameter acts on the band centred there.
|
||||
macro_rules! band_params {
|
||||
($(($key:literal, $hue:literal)),* $(,)?) => {
|
||||
&[
|
||||
vec![
|
||||
$(
|
||||
ParamDescriptor::amount(
|
||||
concat!($key, "_hue"),
|
||||
@@ -175,25 +177,27 @@ macro_rules! band_params {
|
||||
// the same reason as those: `concat!` needs literals, so the keys and hues
|
||||
// cannot be read out of `BANDS` here. `facets_match_their_bands` below is
|
||||
// what keeps them from drifting.
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
attributes: &[Attribute::Colour],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.colour_mixer"),
|
||||
params: band_params![
|
||||
("red", 0.0),
|
||||
("orange", 30.0),
|
||||
("yellow", 60.0),
|
||||
("chartreuse", 90.0),
|
||||
("green", 120.0),
|
||||
("spring", 150.0),
|
||||
("cyan", 180.0),
|
||||
("azure", 210.0),
|
||||
("blue", 240.0),
|
||||
("violet", 270.0),
|
||||
("magenta", 300.0),
|
||||
("rose", 330.0),
|
||||
],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
attributes: vec![Attribute::Colour],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.colour_mixer"),
|
||||
params: band_params![
|
||||
("red", 0.0),
|
||||
("orange", 30.0),
|
||||
("yellow", 60.0),
|
||||
("chartreuse", 90.0),
|
||||
("green", 120.0),
|
||||
("spring", 150.0),
|
||||
("cyan", 180.0),
|
||||
("azure", 210.0),
|
||||
("blue", 240.0),
|
||||
("violet", 270.0),
|
||||
("magenta", 300.0),
|
||||
("rose", 330.0),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
static MIXER_HELPERS: &[Helper] = &[
|
||||
helpers::LUMINANCE,
|
||||
@@ -306,8 +310,8 @@ impl ColourMixer {
|
||||
}
|
||||
|
||||
impl Operation for ColourMixer {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
@@ -505,7 +509,7 @@ mod tests {
|
||||
fn every_descriptor_id_resolves_to_a_band_and_channel() {
|
||||
// The link between the descriptor list and the value array. A
|
||||
// mismatch would make a slider silently adjust nothing.
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
assert!(
|
||||
ColourMixer::index_of(p.id).is_some(),
|
||||
"{} does not map to a band",
|
||||
@@ -547,7 +551,7 @@ mod tests {
|
||||
// and a hue mistyped there would put a row's swatch on a colour the
|
||||
// band does not act on — a control that lies about what it edits,
|
||||
// which is worse than one with no swatch at all.
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
let facet = p.facet.expect("every mixer parameter is faceted");
|
||||
let (band_key, _) = p.id.0.rsplit_once('_').expect("id is band_channel");
|
||||
let band = BANDS
|
||||
@@ -577,7 +581,7 @@ mod tests {
|
||||
// the aspect keyed per band, grouping by it would produce thirty-six
|
||||
// groups of one and nothing would have been gained.
|
||||
let mut per_aspect = std::collections::BTreeMap::new();
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
let facet = p.facet.expect("faceted");
|
||||
*per_aspect.entry(facet.aspect.0).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
//! whole composition scheme rests on (ARCH §5.6).
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation,
|
||||
@@ -341,11 +342,12 @@ const fn facet_of(aspect: &'static str, channel: Channel) -> Facet {
|
||||
|
||||
/// One channel's ten descriptors, defaulted onto the identity diagonal.
|
||||
///
|
||||
/// Written out per point rather than looped because a `ParamDescriptor` has to
|
||||
/// be `const` to live in a `static`, and a const loop cannot build a slice.
|
||||
/// Written out per point rather than looped because `concat!` needs literals:
|
||||
/// the parameter ids are built from the channel's prefix, and a runtime loop
|
||||
/// has no way to spell `r_p0_x`.
|
||||
macro_rules! channel_params {
|
||||
($(($prefix:literal, $channel:expr)),* $(,)?) => {
|
||||
&[$(
|
||||
vec![$(
|
||||
coord(concat!($prefix, "p0_x"), "param.curve.p0_x", 0.0)
|
||||
.faceted(facet_of("param.curve.p0_x", $channel)),
|
||||
coord(concat!($prefix, "p0_y"), "param.curve.p0_y", 0.0)
|
||||
@@ -370,26 +372,28 @@ macro_rules! channel_params {
|
||||
};
|
||||
}
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
// Both, and this is the case the plural exists for: the master 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: &[Attribute::Tone, Attribute::Colour],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.tone_curve"),
|
||||
// Defaults lie on y = x, so a fresh curve is the identity and the
|
||||
// operation reports itself inactive — on every channel.
|
||||
//
|
||||
// The master's ten come first, and stay first: a frontend addresses a
|
||||
// point by its offset from the first parameter of the run it is drawing,
|
||||
// and this is also the order one falling back to sliders reads them in.
|
||||
params: channel_params![
|
||||
("", Channel::Master),
|
||||
("r_", Channel::Red),
|
||||
("g_", Channel::Green),
|
||||
("b_", Channel::Blue),
|
||||
],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
// Both, and this is the case the plural exists for: the master 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: vec![Attribute::Tone, Attribute::Colour],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.tone_curve"),
|
||||
// Defaults lie on y = x, so a fresh curve is the identity and the
|
||||
// operation reports itself inactive — on every channel.
|
||||
//
|
||||
// The master's ten come first, and stay first: a frontend addresses a
|
||||
// point by its offset from the first parameter of the run it is drawing,
|
||||
// and this is also the order one falling back to sliders reads them in.
|
||||
params: channel_params![
|
||||
("", Channel::Master),
|
||||
("r_", Channel::Red),
|
||||
("g_", Channel::Green),
|
||||
("b_", Channel::Blue),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
/// One span of a monotone cubic Hermite spline. Shared by all four curves.
|
||||
const CURVE_SPAN: Helper = Helper {
|
||||
@@ -699,8 +703,8 @@ impl ToneCurve {
|
||||
}
|
||||
|
||||
impl Operation for ToneCurve {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
@@ -727,7 +731,7 @@ impl Operation for ToneCurve {
|
||||
Some(Presentation {
|
||||
// One entry: there is no second way to draw a tone curve that is
|
||||
// better than the sliders the frontend falls back to anyway.
|
||||
widgets: &[WidgetKind::ToneCurve],
|
||||
widgets: vec![WidgetKind::ToneCurve],
|
||||
demand: WidgetDemand {
|
||||
// A point is dragged in x and y together — that is what a
|
||||
// curve *is*, and a frontend that can only move one axis at a
|
||||
@@ -739,7 +743,7 @@ impl Operation for ToneCurve {
|
||||
// leave the other thirty stranded as sliders beneath the plot;
|
||||
// which of the four it draws at a time is its own affair, and the
|
||||
// facets are what let it decide without naming a channel.
|
||||
params: &CURVE_PARAMS,
|
||||
params: CURVE_PARAMS.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -910,7 +914,7 @@ mod tests {
|
||||
// Opening an unedited image must show the image.
|
||||
let c = ToneCurve::new();
|
||||
assert!(!c.is_active());
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
assert_eq!(c.param(p.id), p.default);
|
||||
}
|
||||
}
|
||||
@@ -927,7 +931,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_parameter_id_maps_to_a_point() {
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
assert!(
|
||||
ToneCurve::index_of(p.id).is_some(),
|
||||
"{} does not map to a point",
|
||||
@@ -1196,7 +1200,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(presentation.choose(|_| false), None);
|
||||
assert_eq!(presentation.params.len(), DESCRIPTOR.params.len());
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
assert!(
|
||||
presentation.params.contains(&p.id),
|
||||
"{} is not owned by the widget",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
//! set three correlated coefficients, and hand-correcting a lens with no
|
||||
//! profile is a "make the horizon straight" task, which one term does well.
|
||||
//! The full triple is reachable by loading a profile.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||
@@ -35,25 +36,27 @@ use crate::operation::{Helper, Uniform};
|
||||
pub const ID: OpId = OpId("distortion");
|
||||
pub const AMOUNT: ParamId = ParamId("amount");
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
attributes: &[Attribute::Optics],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.distortion"),
|
||||
// ±100 maps to a ±0.25 cubic coefficient. That covers an uncorrected
|
||||
// fisheye at one end and strong pincushion at the other; beyond it the
|
||||
// inverse mapping stops being single-valued near the corners and the
|
||||
// correction folds the image over itself.
|
||||
params: &[ParamDescriptor::scalar(
|
||||
"amount",
|
||||
"param.distortion.amount",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
)],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
attributes: vec![Attribute::Optics],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.distortion"),
|
||||
// ±100 maps to a ±0.25 cubic coefficient. That covers an uncorrected
|
||||
// fisheye at one end and strong pincushion at the other; beyond it the
|
||||
// inverse mapping stops being single-valued near the corners and the
|
||||
// correction folds the image over itself.
|
||||
params: vec![ParamDescriptor::scalar(
|
||||
"amount",
|
||||
"param.distortion.amount",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
)],
|
||||
})
|
||||
});
|
||||
|
||||
/// The cubic coefficient at full slider travel.
|
||||
const MAX_COEFF: f32 = 0.25;
|
||||
@@ -108,8 +111,8 @@ impl Distortion {
|
||||
}
|
||||
|
||||
impl Warp for Distortion {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
//! Declared as a plain struct here rather than imported, so that dr-pipeline
|
||||
//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does
|
||||
//! with `Pa`.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Operation, Uniform};
|
||||
@@ -37,6 +38,23 @@ pub const ID: OpId = OpId("film_sim");
|
||||
pub const EXPOSURE: ParamId = ParamId("exposure");
|
||||
pub const PRINT_EXPOSURE: ParamId = ParamId("print_exposure");
|
||||
pub const PUSH: ParamId = ParamId("push");
|
||||
pub const FORMAT: ParamId = ParamId("format");
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// The frames a photograph can be simulated on, smallest first.
|
||||
///
|
||||
/// A genuinely fixed list, unlike the stocks: nobody invents a film format, so
|
||||
/// this is a declared `enum` parameter and gets its control, its place in the
|
||||
/// sidecar and its undo step for free. The *sizes* live in `dr_film::Format`;
|
||||
/// this crate carries only the names, in the same order.
|
||||
static FORMATS: [LocalizedKey; 6] = [
|
||||
LocalizedKey("param.film_sim.format.35mm"),
|
||||
LocalizedKey("param.film_sim.format.645"),
|
||||
LocalizedKey("param.film_sim.format.6x6"),
|
||||
LocalizedKey("param.film_sim.format.6x7"),
|
||||
LocalizedKey("param.film_sim.format.4x5"),
|
||||
LocalizedKey("param.film_sim.format.8x10"),
|
||||
];
|
||||
|
||||
/// How many samples a characteristic curve carries.
|
||||
///
|
||||
@@ -56,22 +74,31 @@ static MATRIX_FIELDS: [[&str; 3]; 3] = [
|
||||
["m20", "m21", "m22"],
|
||||
];
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = 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: &[Attribute::Tone, Attribute::Colour],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.film_sim"),
|
||||
params: &[
|
||||
ParamDescriptor::stops("exposure", "param.film_sim.exposure", -3.0, 3.0),
|
||||
ParamDescriptor::stops("print_exposure", "param.film_sim.print_exposure", -3.0, 3.0),
|
||||
// TRACES: FR-DEV-3f
|
||||
// Development, in stops of push. Bounded by what the manufacturers
|
||||
// actually published: Double-X's measured axis spans about -1 to +2,
|
||||
// and beyond a range like that a curve would have to be invented.
|
||||
ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0),
|
||||
],
|
||||
};
|
||||
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],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.film_sim"),
|
||||
params: vec![
|
||||
ParamDescriptor::stops("exposure", "param.film_sim.exposure", -3.0, 3.0),
|
||||
ParamDescriptor::stops("print_exposure", "param.film_sim.print_exposure", -3.0, 3.0),
|
||||
// TRACES: FR-DEV-3f
|
||||
// Development, in stops of push. Bounded by what the manufacturers
|
||||
// actually published: Double-X's measured axis spans about -1 to +2,
|
||||
// and beyond a range like that a curve would have to be invented.
|
||||
ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0),
|
||||
// TRACES: FR-DEV-3f
|
||||
// Which frame this was taken on — the half of the enlargement a
|
||||
// photograph cannot supply. A crystal is a fixed size in micrometres,
|
||||
// so how grainy a picture looks is film size against output size, and
|
||||
// the same emulsion on 4x5 renders about three times smoother than on
|
||||
// 35mm at the same print.
|
||||
ParamDescriptor::choice("format", "param.film_sim.format", FORMATS.to_vec()),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
/// A stock reduced to what a shader runs, as `dr-film` bakes it.
|
||||
///
|
||||
@@ -129,6 +156,8 @@ pub struct FilmSim {
|
||||
exposure: f32,
|
||||
print_exposure: f32,
|
||||
push: f32,
|
||||
/// Index into `FORMATS`. Zero is 35 mm, which is the neutral choice.
|
||||
format: f32,
|
||||
tables: Option<FilmTables>,
|
||||
}
|
||||
|
||||
@@ -164,8 +193,8 @@ impl FilmSim {
|
||||
}
|
||||
|
||||
impl Operation for FilmSim {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
@@ -173,6 +202,7 @@ impl Operation for FilmSim {
|
||||
EXPOSURE => self.exposure = value,
|
||||
PRINT_EXPOSURE => self.print_exposure = value,
|
||||
PUSH => self.push = value,
|
||||
FORMAT => self.format = value,
|
||||
_ => log::warn!("film_sim: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
@@ -182,6 +212,7 @@ impl Operation for FilmSim {
|
||||
EXPOSURE => self.exposure,
|
||||
PRINT_EXPOSURE => self.print_exposure,
|
||||
PUSH => self.push,
|
||||
FORMAT => self.format,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
//! this file.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::detail::{DetailPass, DetailStage, RenderScale};
|
||||
@@ -181,7 +182,12 @@ const TRUNCATION: f32 = 2.0;
|
||||
/// between clarity and texture can be read side by side, which is the one
|
||||
/// thing a reader comes to this file to do.
|
||||
pub struct Recipe {
|
||||
descriptor: &'static OpDescriptor,
|
||||
/// The static this operation's descriptor is built in. A `LazyLock`
|
||||
/// rather than a reference to a descriptor, because a descriptor is an
|
||||
/// owned value handed out as an `Arc` now (FR-PLG-2), and a `const`
|
||||
/// recipe cannot hold an `Arc` — only a reference to the static that
|
||||
/// makes one.
|
||||
descriptor: &'static LazyLock<Arc<OpDescriptor>>,
|
||||
helpers: &'static [Helper],
|
||||
/// The Gaussian's σ, as a fraction of the frame's shorter edge.
|
||||
sigma: f32,
|
||||
@@ -253,19 +259,23 @@ impl Band for Fine {
|
||||
};
|
||||
}
|
||||
|
||||
static CLARITY_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: CLARITY,
|
||||
label: LocalizedKey("op.clarity"),
|
||||
params: &[ParamDescriptor::amount("amount", "param.clarity.amount")],
|
||||
attributes: &[Attribute::Detail],
|
||||
};
|
||||
static CLARITY_DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: CLARITY,
|
||||
label: LocalizedKey("op.clarity"),
|
||||
params: vec![ParamDescriptor::amount("amount", "param.clarity.amount")],
|
||||
attributes: vec![Attribute::Detail],
|
||||
})
|
||||
});
|
||||
|
||||
static TEXTURE_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: TEXTURE,
|
||||
label: LocalizedKey("op.texture"),
|
||||
params: &[ParamDescriptor::amount("amount", "param.texture.amount")],
|
||||
attributes: &[Attribute::Detail],
|
||||
};
|
||||
static TEXTURE_DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: TEXTURE,
|
||||
label: LocalizedKey("op.texture"),
|
||||
params: vec![ParamDescriptor::amount("amount", "param.texture.amount")],
|
||||
attributes: vec![Attribute::Detail],
|
||||
})
|
||||
});
|
||||
|
||||
/// Luminance as a position on a logarithmic scale, floored.
|
||||
///
|
||||
@@ -394,8 +404,8 @@ impl<B: Band> LocalContrast<B> {
|
||||
}
|
||||
|
||||
impl<B: Band> Operation for LocalContrast<B> {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
B::RECIPE.descriptor
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
Arc::clone(B::RECIPE.descriptor)
|
||||
}
|
||||
|
||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||
@@ -475,12 +485,16 @@ impl<B: Band> DetailStage for LocalContrast<B> {
|
||||
DetailPass {
|
||||
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 {
|
||||
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),
|
||||
},
|
||||
|
||||
@@ -179,7 +179,7 @@ mod tests {
|
||||
// perfectly and silently breaks the sidecar.
|
||||
for mut op in chain() {
|
||||
let descriptor = op.descriptor();
|
||||
for p in descriptor.params {
|
||||
for p in &descriptor.params {
|
||||
let crate::descriptor::ParamKind::Scalar { min, max, .. } = p.kind else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
//! lie. The chroma radius, ten times larger, still resolves — which is also
|
||||
//! true of the fault it treats, since a blotch twenty pixels across survives
|
||||
//! being halved.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||
@@ -158,38 +159,40 @@ pub const ID: OpId = OpId("noise_reduction");
|
||||
pub const LUMINANCE: ParamId = ParamId("luminance");
|
||||
pub const CHROMA: ParamId = ParamId("chroma");
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.noise_reduction"),
|
||||
attributes: &[Attribute::Detail],
|
||||
// Zero to a hundred rather than the symmetric `amount` shape the tonal
|
||||
// controls use. There is no meaningful negative: "minus fifty noise
|
||||
// reduction" would be adding grain, which is a look rather than a repair
|
||||
// and belongs to a different operation carrying `Attribute::Effect`. A
|
||||
// control whose left half does nothing is worse than one that stops.
|
||||
params: &[
|
||||
ParamDescriptor::scalar(
|
||||
"luminance",
|
||||
"param.noise_reduction.luminance",
|
||||
0.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::scalar(
|
||||
"chroma",
|
||||
"param.noise_reduction.chroma",
|
||||
0.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.noise_reduction"),
|
||||
attributes: vec![Attribute::Detail],
|
||||
// Zero to a hundred rather than the symmetric `amount` shape the tonal
|
||||
// controls use. There is no meaningful negative: "minus fifty noise
|
||||
// reduction" would be adding grain, which is a look rather than a repair
|
||||
// and belongs to a different operation carrying `Attribute::Effect`. A
|
||||
// control whose left half does nothing is worse than one that stops.
|
||||
params: vec![
|
||||
ParamDescriptor::scalar(
|
||||
"luminance",
|
||||
"param.noise_reduction.luminance",
|
||||
0.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::scalar(
|
||||
"chroma",
|
||||
"param.noise_reduction.chroma",
|
||||
0.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
/// The luminance radius at the lowest and the highest amount, in **source**
|
||||
/// pixels.
|
||||
@@ -365,8 +368,8 @@ fn inv_spatial(kernel: u32) -> f32 {
|
||||
}
|
||||
|
||||
impl Operation for NoiseReduction {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
@@ -436,6 +439,8 @@ impl DetailStage for NoiseReduction {
|
||||
passes.push(DetailPass {
|
||||
label: "luminance",
|
||||
radius: luma,
|
||||
// A convolution, not a list: nothing to bind at binding 3.
|
||||
storage: Vec::new(),
|
||||
uniforms: vec![
|
||||
Uniform {
|
||||
name: "radius",
|
||||
@@ -474,6 +479,8 @@ impl DetailStage for NoiseReduction {
|
||||
"chroma-vertical"
|
||||
},
|
||||
radius: chroma,
|
||||
// A convolution, not a list: nothing to bind at binding 3.
|
||||
storage: Vec::new(),
|
||||
uniforms: vec![
|
||||
Uniform {
|
||||
name: "radius",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
//! division is the whole reason this operation must run before the tonal
|
||||
//! stages: a corner recovered by two stops has to be recovered while the
|
||||
//! highlight headroom to hold it still exists (ARCH §5.2).
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Helper, Operation, Uniform};
|
||||
@@ -45,19 +46,21 @@ pub const AMOUNT: ParamId = ParamId("amount");
|
||||
/// fast prime wide open — the case that actually needs correcting.
|
||||
const MAX_K1: f32 = -0.5;
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
// Optics rather than effect: this carries lens-profile coefficients
|
||||
// and corrects what the lens did. A *creative* vignette is a different
|
||||
// operation that does not exist yet, and would be `Effect`.
|
||||
attributes: &[Attribute::Optics],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.vignetting"),
|
||||
// Bidirectional deliberately. Negative values *add* falloff, which is a
|
||||
// legitimate creative choice as well as a correction, and a control that
|
||||
// only removed vignetting would need a second one beside it to put any
|
||||
// back.
|
||||
params: &[ParamDescriptor::amount("amount", "param.vignetting.amount")],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
// Optics rather than effect: this carries lens-profile coefficients
|
||||
// and corrects what the lens did. A *creative* vignette is a different
|
||||
// operation that does not exist yet, and would be `Effect`.
|
||||
attributes: vec![Attribute::Optics],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.vignetting"),
|
||||
// Bidirectional deliberately. Negative values *add* falloff, which is a
|
||||
// legitimate creative choice as well as a correction, and a control that
|
||||
// only removed vignetting would need a second one beside it to put any
|
||||
// back.
|
||||
params: vec![ParamDescriptor::amount("amount", "param.vignetting.amount")],
|
||||
})
|
||||
});
|
||||
|
||||
/// The `pa` polynomial coefficients, as Lensfun stores them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -107,8 +110,8 @@ impl Vignetting {
|
||||
}
|
||||
|
||||
impl Operation for Vignetting {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
@@ -371,7 +374,7 @@ mod tests {
|
||||
#[test]
|
||||
fn every_default_is_neutral() {
|
||||
let mut v = Vignetting::new();
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
v.set_param(p.id, p.default);
|
||||
}
|
||||
assert!(!v.is_active());
|
||||
|
||||
+378
-68
@@ -68,7 +68,9 @@ use std::fmt::Write as _;
|
||||
|
||||
use crate::graph::EditGraph;
|
||||
use crate::mask::{Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER};
|
||||
use crate::preset::{resolve, Preset};
|
||||
use crate::preset::Preset;
|
||||
use crate::spot::{Spot, SpotMode, SpotSet};
|
||||
use crate::state::{EditState, FilmRebake};
|
||||
|
||||
/// Format version of the document itself.
|
||||
///
|
||||
@@ -108,14 +110,10 @@ pub struct Sidecar {
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// The stock and paper a version names, without the tables they bake to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct FilmRef {
|
||||
pub stock: String,
|
||||
/// The paper, if the negative is printed. Absent means the film is viewed
|
||||
/// as it comes — which for a colour negative is the scan, orange and
|
||||
/// inverted, and is a legitimate thing to ask for.
|
||||
pub print: Option<String>,
|
||||
}
|
||||
///
|
||||
/// Re-exported rather than defined here: a sidecar is one of the things an
|
||||
/// edit is written to, not where an edit is defined. See [`crate::state`].
|
||||
pub use crate::state::FilmRef;
|
||||
|
||||
/// TRACES: FR-CAT-12 | FR-NC-8
|
||||
/// One named edit variant.
|
||||
@@ -182,6 +180,15 @@ pub struct Version {
|
||||
/// database, which this crate does not link, so [`Self::apply`] leaves the
|
||||
/// graph's film cleared and the caller re-bakes — see `EditGraph::set_film`.
|
||||
pub film: Option<FilmRef>,
|
||||
/// TRACES: FR-DEV-8 | FR-NC-9
|
||||
/// The repairs (`docs/spot-removal.md`).
|
||||
///
|
||||
/// A line per spot, keyed `spot.<id>`, rather than a block per spot as a
|
||||
/// mask gets: a spot is eight numbers, and sixty-four blocks would bury the
|
||||
/// rest of the file. A line *per spot* rather than one line for the set,
|
||||
/// because the line is the unit of merge and of a readable diff — the same
|
||||
/// reasoning [`write_strokes`] gives for a line per stroke.
|
||||
pub spots: SpotSet,
|
||||
/// Keys this build did not recognise, kept verbatim.
|
||||
///
|
||||
/// An operation this build lacks would otherwise be deleted the moment an
|
||||
@@ -191,8 +198,22 @@ pub struct Version {
|
||||
}
|
||||
|
||||
impl Version {
|
||||
/// A new version holding the non-default parameters of `graph`.
|
||||
/// A new version holding everything `graph`'s edit consists of.
|
||||
///
|
||||
/// The destructuring is exhaustive on purpose — see [`crate::state`]. A
|
||||
/// new part of an edit must not reach the file only by somebody
|
||||
/// remembering to add a line here, which is how [`Self::update`] came to
|
||||
/// write the masks and forget the film.
|
||||
pub fn from_graph(uuid: impl Into<String>, name: impl Into<String>, graph: &EditGraph) -> Self {
|
||||
let EditState {
|
||||
params,
|
||||
masks,
|
||||
film,
|
||||
spots,
|
||||
} = graph.state();
|
||||
let params = params.into_params();
|
||||
let masks = (*masks).clone();
|
||||
|
||||
Self {
|
||||
uuid: uuid.into(),
|
||||
name: name.into(),
|
||||
@@ -206,40 +227,40 @@ impl Version {
|
||||
// it in the "not yet looked at" state a cull resumes from.
|
||||
rating: 0,
|
||||
flag: 0,
|
||||
params: capture(graph),
|
||||
masks: graph.masks().clone(),
|
||||
film: graph.film().map(|f| FilmRef {
|
||||
stock: f.stock.clone(),
|
||||
print: f.print.clone(),
|
||||
}),
|
||||
params,
|
||||
masks,
|
||||
film,
|
||||
spots,
|
||||
unknown: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply this version's parameters to a graph.
|
||||
/// Apply this version's edit to a graph, returning the film it still owes.
|
||||
///
|
||||
/// The graph is reset first, so loading is a *replacement* rather than an
|
||||
/// overlay: a parameter absent from the file means default, and would
|
||||
/// otherwise silently inherit whatever the graph happened to hold.
|
||||
/// Loading is a *replacement* rather than an overlay: a parameter absent
|
||||
/// from the file means default, and would otherwise silently inherit
|
||||
/// whatever the graph happened to hold.
|
||||
///
|
||||
/// Unknown operations and parameters are skipped with a warning by
|
||||
/// [`EditGraph::set_param`], and values are clamped there, so a corrupt
|
||||
/// or newer file cannot reach a shader.
|
||||
pub fn apply(&self, graph: &mut EditGraph) {
|
||||
///
|
||||
/// The [`FilmRebake`] is not a new obligation — restoring a stock always
|
||||
/// needed the profile database this crate does not link (ARCH §6.5a), and
|
||||
/// callers were already doing it from a comment. It is the same debt made
|
||||
/// impossible to walk past.
|
||||
pub fn apply(&self, graph: &mut EditGraph) -> FilmRebake {
|
||||
// Reset first for the *viewport's* sake, and only that: `set_state`
|
||||
// deliberately preserves the view so an undo does not read as
|
||||
// navigation, whereas opening a photograph should show it fitted
|
||||
// rather than at the zoom the previous one was inspected at.
|
||||
graph.reset();
|
||||
for ((op, param), value) in &self.params {
|
||||
// `OpId` and `ParamId` hold `&'static str` because descriptors
|
||||
// are statics, and a sidecar's strings are not. `resolve` matches
|
||||
// the file's names against the descriptors and hands back the
|
||||
// static ids, so no string read from disk is ever leaked to get
|
||||
// a lifetime it did not earn.
|
||||
let Some((op, param)) = resolve(graph, op, param) else {
|
||||
log::warn!("sidecar: unknown parameter {op}.{param}; ignoring");
|
||||
continue;
|
||||
};
|
||||
graph.set_param(op, param, *value);
|
||||
}
|
||||
*graph.masks_mut() = self.masks.clone();
|
||||
graph.set_state(&EditState {
|
||||
params: Preset::from_params(self.params.clone()),
|
||||
masks: std::sync::Arc::new(self.masks.clone()),
|
||||
film: self.film.clone(),
|
||||
spots: self.spots.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Record `graph` into this version, bumping the revision.
|
||||
@@ -248,8 +269,22 @@ impl Version {
|
||||
/// setter: FR-NC-9 resolves conflicts by revision, so a local edit that
|
||||
/// did not bump it is a local edit a remote one will silently win.
|
||||
pub fn update(&mut self, graph: &EditGraph, device: &str, now: i64) {
|
||||
self.params = capture(graph);
|
||||
self.masks = graph.masks().clone();
|
||||
// Exhaustive, and this is the call site that proves why it has to be:
|
||||
// this method wrote the parameters, the masks and the repairs and
|
||||
// silently dropped the film, so saving an edit developed on a stock
|
||||
// lost the stock. Nothing here can be forgotten now without failing to
|
||||
// compile.
|
||||
let EditState {
|
||||
params,
|
||||
masks,
|
||||
film,
|
||||
spots,
|
||||
} = graph.state();
|
||||
self.params = params.into_params();
|
||||
self.masks = (*masks).clone();
|
||||
self.film = film;
|
||||
self.spots = spots;
|
||||
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
self.device = device.to_string();
|
||||
self.modified = now;
|
||||
@@ -314,6 +349,89 @@ impl Version {
|
||||
conflicts
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8 | FR-NC-9
|
||||
/// Merge the spot sets, returning the repairs that genuinely conflicted.
|
||||
///
|
||||
/// By id, exactly as [`Self::merge_masks`] does, and for the same reason
|
||||
/// one level down: a repair made on the phone and a repair made on the
|
||||
/// desktop are different ids, so both survive and neither is a conflict.
|
||||
/// That is most of why [`crate::spot::Spot::derive_id`] hashes the position
|
||||
/// rather than counting — with counted ids the two would collide here and
|
||||
/// one would be lost.
|
||||
///
|
||||
/// A spot *both* sides moved resolves wholesale to the higher revision.
|
||||
/// Half of one device's offset with the other's radius is a repair neither
|
||||
/// photographer made, and unlike a mask there is not even a case for
|
||||
/// interleaving: eight numbers describe one disc.
|
||||
fn merge_spots(
|
||||
&mut self,
|
||||
remote: &Version,
|
||||
base: Option<&Version>,
|
||||
remote_wins: bool,
|
||||
) -> Vec<(String, String)> {
|
||||
let empty = SpotSet::new();
|
||||
let base_spots = base.map(|b| &b.spots).unwrap_or(&empty);
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
let ids: Vec<String> = self
|
||||
.spots
|
||||
.spots()
|
||||
.iter()
|
||||
.chain(remote.spots.spots())
|
||||
.map(|s| s.id.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
for id in ids {
|
||||
let ours = self.spots.get(&id);
|
||||
let theirs = remote.spots.get(&id);
|
||||
let was = base_spots.get(&id);
|
||||
|
||||
let we_changed = ours != was;
|
||||
let they_changed = theirs != was;
|
||||
|
||||
match (we_changed, they_changed) {
|
||||
// Only they touched it: take theirs, a deletion included.
|
||||
(false, true) => match theirs {
|
||||
Some(spot) => self.put_spot(spot.clone()),
|
||||
None => {
|
||||
self.spots.remove(&id);
|
||||
}
|
||||
},
|
||||
(true, true) if ours != theirs => {
|
||||
conflicts.push(("spot".to_string(), id.clone()));
|
||||
if remote_wins {
|
||||
match theirs {
|
||||
Some(spot) => self.put_spot(spot.clone()),
|
||||
None => {
|
||||
self.spots.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
conflicts
|
||||
}
|
||||
|
||||
/// Replace a repair of the same id, or append it.
|
||||
///
|
||||
/// Position is not merged, for the reason [`Self::put_mask`] gives and one
|
||||
/// more: the order only decides which of two *overlapping* repairs lands on
|
||||
/// top, and repairs that overlap are already a case the photographer will
|
||||
/// look at.
|
||||
fn put_spot(&mut self, spot: Spot) {
|
||||
match self.spots.get_mut(&spot.id) {
|
||||
Some(existing) => *existing = spot,
|
||||
None => {
|
||||
self.spots.place(spot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace a layer of the same id, or append it.
|
||||
///
|
||||
/// Position is not merged. Two devices that reordered the same stack have
|
||||
@@ -438,6 +556,7 @@ impl Version {
|
||||
// plus half of another's opacity is a layer neither of them made —
|
||||
// so the layer is the unit, exactly as the value is for a parameter.
|
||||
conflicts.extend(self.merge_masks(remote, base, remote_wins));
|
||||
conflicts.extend(self.merge_spots(remote, base, remote_wins));
|
||||
|
||||
// Unknown keys follow the same rule, so an operation neither side
|
||||
// understands is not dropped by the merge either.
|
||||
@@ -490,20 +609,6 @@ fn merge_judgement(ours: u8, theirs: u8, remote_wins: bool) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every non-default parameter in the graph, keyed by `(op, param)`.
|
||||
///
|
||||
/// Reads [`EditGraph::capabilities`] — the same list the UI builds controls
|
||||
/// from — so an operation is persisted by virtue of being in the chain, with
|
||||
/// nothing to register and nothing to forget.
|
||||
///
|
||||
/// Delegated to [`Preset::capture`] rather than reimplemented: a version's
|
||||
/// parameters and a copied preset are the same values taken from the same
|
||||
/// list, and two routines building the same map would be two places for the
|
||||
/// non-default rule to drift.
|
||||
fn capture(graph: &EditGraph) -> BTreeMap<(String, String), f32> {
|
||||
Preset::capture(graph).into_params()
|
||||
}
|
||||
|
||||
impl Sidecar {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
@@ -564,6 +669,15 @@ impl Sidecar {
|
||||
for ((op, param), value) in &v.params {
|
||||
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
|
||||
}
|
||||
// TRACES: FR-DEV-8
|
||||
// In the order they were made, which is the order they are drawn
|
||||
// in and the order that decides which repairs share a pass
|
||||
// (`SpotSet::rounds`). A `BTreeMap` here — sorting them by id —
|
||||
// would look tidier in the file and would silently reorder the
|
||||
// photograph.
|
||||
for spot in v.spots.spots() {
|
||||
write_spot(&mut out, spot);
|
||||
}
|
||||
for (k, raw) in &v.unknown {
|
||||
let _ = writeln!(out, "{k} = {raw}");
|
||||
}
|
||||
@@ -691,6 +805,21 @@ impl Sidecar {
|
||||
Some(value.to_string());
|
||||
}
|
||||
"flag" => version.flag = value.parse::<u8>().unwrap_or(0).min(MAX_FLAG),
|
||||
// TRACES: FR-DEV-8
|
||||
// Ahead of the `op.param` arm below, which would otherwise try
|
||||
// to read eight numbers as one float and drop the repair with a
|
||||
// warning about a corrupt value. The prefix is safe because a
|
||||
// spot is deliberately *not* an operation (see `crate::spot`),
|
||||
// so no `ops/` declaration can ever claim the name.
|
||||
k if k.starts_with("spot.") => {
|
||||
let id = &k["spot.".len()..];
|
||||
match parse_spot(id, value) {
|
||||
Some(spot) => {
|
||||
version.spots.place(spot);
|
||||
}
|
||||
None => log::warn!("sidecar: unreadable spot {id}; ignoring it"),
|
||||
}
|
||||
}
|
||||
_ => match key.split_once('.') {
|
||||
// An `op.param` line whose value does not parse is a
|
||||
// corrupt number, not an unknown key; dropping it lets
|
||||
@@ -730,6 +859,78 @@ impl Sidecar {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// Write one repair as one line.
|
||||
///
|
||||
/// Fields in order: centre, radius, feather, source offset, opacity, mode —
|
||||
/// and the word `disabled` when it is switched off, which is rare enough that
|
||||
/// it is a trailing marker rather than a ninth number every line carries.
|
||||
///
|
||||
/// Values are written at the precision they are held at (see `crate::spot`'s
|
||||
/// grid), so a round trip is exact and two devices that placed the same repair
|
||||
/// produce the same line rather than a diff of noise in the sixth decimal.
|
||||
fn write_spot(out: &mut String, spot: &Spot) {
|
||||
let _ = write!(
|
||||
out,
|
||||
"spot.{} = {} {} {} {} {} {} {} {}",
|
||||
spot.id,
|
||||
format_value(spot.centre.0),
|
||||
format_value(spot.centre.1),
|
||||
format_value(spot.radius),
|
||||
format_value(spot.feather),
|
||||
format_value(spot.offset.0),
|
||||
format_value(spot.offset.1),
|
||||
format_value(spot.opacity),
|
||||
spot.mode.name(),
|
||||
);
|
||||
if !spot.enabled {
|
||||
let _ = write!(out, " disabled");
|
||||
}
|
||||
let _ = writeln!(out);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// Read one `spot.<id> = …` line, or nothing if it cannot be trusted.
|
||||
///
|
||||
/// A malformed line costs that repair and not the file, which is the rule
|
||||
/// [`parse_stroke`] follows and for the sharper version of its reason: a spot
|
||||
/// read half-way is a patch of one part of the photograph copied over another
|
||||
/// part at random. A missing repair is noticed and re-made in a second; a
|
||||
/// repair in the wrong place looks like the file is damaged.
|
||||
fn parse_spot(id: &str, value: &str) -> Option<Spot> {
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut tokens = value.split_whitespace();
|
||||
let mut number = || {
|
||||
tokens
|
||||
.next()
|
||||
.and_then(|t| t.parse::<f32>().ok())
|
||||
.filter(|v| v.is_finite())
|
||||
};
|
||||
|
||||
let centre = (number()?, number()?);
|
||||
let radius = number()?;
|
||||
let feather = number()?;
|
||||
let offset = (number()?, number()?);
|
||||
let opacity = number()?;
|
||||
|
||||
let mode = SpotMode::from_name(tokens.next()?)?;
|
||||
// Anything after the mode that is not the one marker this format defines
|
||||
// is a newer build's business. Ignored rather than refused: the repair is
|
||||
// complete without it, and refusing would drop work over a field this
|
||||
// build simply does not know about yet.
|
||||
let enabled = !tokens.any(|t| t == "disabled");
|
||||
|
||||
let mut spot = Spot::new(centre, offset, radius);
|
||||
spot.id = id.to_string();
|
||||
spot.set_feather(feather);
|
||||
spot.set_opacity(opacity);
|
||||
spot.mode = mode;
|
||||
spot.enabled = enabled;
|
||||
Some(spot)
|
||||
}
|
||||
|
||||
/// Write one mask layer as its own block.
|
||||
///
|
||||
/// The version uuid is repeated in the header rather than relying on the
|
||||
@@ -1096,8 +1297,17 @@ impl PartialMask {
|
||||
.ops
|
||||
.iter()
|
||||
.find(|o| o.descriptor().id.0 == op)
|
||||
.and_then(|o| o.descriptor().params.iter().find(|p| p.id.0 == param))
|
||||
.map(|p| p.id)
|
||||
// The descriptor is bound inside the closure rather than
|
||||
// chained through: it is an owned `Arc` now, so a
|
||||
// `ParamDescriptor` borrowed out of it would not outlive the
|
||||
// expression. The `ParamId` is `Copy`, so it does.
|
||||
.and_then(|o| {
|
||||
o.descriptor()
|
||||
.params
|
||||
.iter()
|
||||
.find(|p| p.id.0 == param)
|
||||
.map(|p| p.id)
|
||||
})
|
||||
else {
|
||||
log::warn!("sidecar: unknown mask parameter {op}.{param}; ignoring");
|
||||
continue;
|
||||
@@ -1168,6 +1378,81 @@ mod tests {
|
||||
Version::from_graph("uuid-1", "Default", graph)
|
||||
}
|
||||
|
||||
/// A graph developing on a stock, with tables well-formed enough for the
|
||||
/// film node to keep them. The emulsion is invented; what is under test
|
||||
/// is whether the *choice* reaches the file.
|
||||
fn on_film(stock: &str) -> EditGraph {
|
||||
let mut g = edited();
|
||||
g.set_film(Some(crate::graph::Film {
|
||||
stock: stock.to_string(),
|
||||
print: None,
|
||||
tables: crate::ops::FilmTables {
|
||||
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
|
||||
curve_log_min: -3.0,
|
||||
curve_log_max: 1.0,
|
||||
lut: vec![[0.5, 0.5, 0.5]; 8],
|
||||
density_max: 2.0,
|
||||
lut_size: 2,
|
||||
grain_particles: [0.0; 3],
|
||||
grain_density_max: [2.0; 3],
|
||||
grain_uniformity: 1.0,
|
||||
},
|
||||
}));
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_an_edit_keeps_the_film_it_was_developed_on() {
|
||||
// `update` is the write path — the one an automatic save goes through
|
||||
// — and it used to copy the parameters and the masks and say nothing
|
||||
// about the film. A photograph developed on a stock was written back
|
||||
// without it, so the next time it opened, the emulsion was gone and
|
||||
// nothing had reported a failure.
|
||||
//
|
||||
// It reads as an oversight because it was one, and that is the point:
|
||||
// three routines captured "the edit" and each captured a different
|
||||
// subset. All three now destructure one `EditState`, so the next part
|
||||
// of an edit cannot be forgotten by anybody writing a line too few.
|
||||
let mut v = version_of(&on_film("kodak_portra_400"));
|
||||
v.film = None;
|
||||
|
||||
v.update(&on_film("kodak_portra_400"), "device-a", 1000);
|
||||
|
||||
assert_eq!(
|
||||
v.film,
|
||||
Some(FilmRef {
|
||||
stock: "kodak_portra_400".into(),
|
||||
print: None,
|
||||
}),
|
||||
"the stock did not survive the save"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_film_written_by_update_comes_back_off_the_disk() {
|
||||
// End to end, because the field being set is only half of it: the
|
||||
// stock has to reach the text and parse back out of it.
|
||||
let mut v = version_of(&EditGraph::default_chain());
|
||||
v.update(&on_film("ilford_hp5"), "device-a", 1000);
|
||||
|
||||
let mut sidecar = Sidecar::new();
|
||||
sidecar.put(v);
|
||||
let parsed = Sidecar::parse(&sidecar.to_text()).expect("re-read");
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let rebake = parsed
|
||||
.default_version()
|
||||
.expect("a version")
|
||||
.apply(&mut graph);
|
||||
|
||||
assert_eq!(
|
||||
rebake.wanted().map(|f| f.stock.as_str()),
|
||||
Some("ilford_hp5"),
|
||||
"reopening the photograph has to ask for its stock back"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_non_default_values_are_written() {
|
||||
// The property the whole format rests on: a neutral operation is
|
||||
@@ -1197,7 +1482,8 @@ mod tests {
|
||||
parsed
|
||||
.default_version()
|
||||
.expect("a version")
|
||||
.apply(&mut restored);
|
||||
.apply(&mut restored)
|
||||
.expect_no_film();
|
||||
|
||||
assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
|
||||
assert_eq!(
|
||||
@@ -1231,7 +1517,8 @@ mod tests {
|
||||
parsed
|
||||
.default_version()
|
||||
.expect("a version")
|
||||
.apply(&mut restored);
|
||||
.apply(&mut restored)
|
||||
.expect_no_film();
|
||||
|
||||
for cap in g.capabilities() {
|
||||
for p in &cap.params {
|
||||
@@ -1268,7 +1555,8 @@ mod tests {
|
||||
parsed
|
||||
.default_version()
|
||||
.expect("a version")
|
||||
.apply(&mut restored);
|
||||
.apply(&mut restored)
|
||||
.expect_no_film();
|
||||
|
||||
assert_eq!(restored.crop(), g.crop());
|
||||
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
|
||||
@@ -1308,7 +1596,8 @@ mod tests {
|
||||
.expect("valid")
|
||||
.default_version()
|
||||
.expect("a version")
|
||||
.apply(&mut sideways);
|
||||
.apply(&mut sideways)
|
||||
.expect_no_film();
|
||||
|
||||
assert_eq!(
|
||||
sideways.framing().baseline(),
|
||||
@@ -1326,7 +1615,11 @@ mod tests {
|
||||
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
||||
|
||||
let mut g = edited();
|
||||
parsed.default_version().expect("a version").apply(&mut g);
|
||||
parsed
|
||||
.default_version()
|
||||
.expect("a version")
|
||||
.apply(&mut g)
|
||||
.expect_no_film();
|
||||
assert!(g.is_neutral(), "a neutral version must clear the graph");
|
||||
}
|
||||
|
||||
@@ -1353,7 +1646,11 @@ mod tests {
|
||||
tone_curve.p1_y = 0.15\ntone_curve.p3_y = 0.85\n";
|
||||
let parsed = Sidecar::parse(text).expect("valid");
|
||||
let mut g = EditGraph::default_chain();
|
||||
parsed.default_version().expect("a version").apply(&mut g);
|
||||
parsed
|
||||
.default_version()
|
||||
.expect("a version")
|
||||
.apply(&mut g)
|
||||
.expect_no_film();
|
||||
|
||||
// The S-curve the file describes, on the master curve and nowhere
|
||||
// else.
|
||||
@@ -1420,7 +1717,8 @@ mod tests {
|
||||
parsed
|
||||
.default_version()
|
||||
.expect("a version")
|
||||
.apply(&mut restored);
|
||||
.apply(&mut restored)
|
||||
.expect_no_film();
|
||||
|
||||
assert_eq!(restored.param(curve::ID, blue), Some(0.08));
|
||||
assert_eq!(restored.param(curve::ID, red), Some(0.92));
|
||||
@@ -1449,7 +1747,11 @@ mod tests {
|
||||
time_machine.year = 1994\n";
|
||||
let parsed = Sidecar::parse(text).expect("valid");
|
||||
let mut g = EditGraph::default_chain();
|
||||
parsed.default_version().expect("a version").apply(&mut g);
|
||||
parsed
|
||||
.default_version()
|
||||
.expect("a version")
|
||||
.apply(&mut g)
|
||||
.expect_no_film();
|
||||
assert!(g.is_neutral());
|
||||
}
|
||||
|
||||
@@ -1459,7 +1761,11 @@ mod tests {
|
||||
exposure.exposure = NaN\nwhite_balance.temperature = 20\n";
|
||||
let parsed = Sidecar::parse(text).expect("valid");
|
||||
let mut g = EditGraph::default_chain();
|
||||
parsed.default_version().expect("a version").apply(&mut g);
|
||||
parsed
|
||||
.default_version()
|
||||
.expect("a version")
|
||||
.apply(&mut g)
|
||||
.expect_no_film();
|
||||
|
||||
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
||||
assert_eq!(
|
||||
@@ -1476,7 +1782,11 @@ mod tests {
|
||||
exposure.exposure = 99\n";
|
||||
let parsed = Sidecar::parse(text).expect("valid");
|
||||
let mut g = EditGraph::default_chain();
|
||||
parsed.default_version().expect("a version").apply(&mut g);
|
||||
parsed
|
||||
.default_version()
|
||||
.expect("a version")
|
||||
.apply(&mut g)
|
||||
.expect_no_film();
|
||||
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
|
||||
}
|
||||
|
||||
@@ -1510,7 +1820,7 @@ mod tests {
|
||||
assert_eq!(parsed.default_version().expect("default").name, "Colour");
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
parsed.versions["u2"].apply(&mut g);
|
||||
parsed.versions["u2"].apply(&mut g).expect_no_film();
|
||||
assert_eq!(
|
||||
g.param(saturation::ID, saturation::SATURATION),
|
||||
Some(-100.0)
|
||||
@@ -1555,7 +1865,7 @@ mod tests {
|
||||
assert!(conflicts.is_empty(), "disjoint edits must not conflict");
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
local.apply(&mut g);
|
||||
local.apply(&mut g).expect_no_film();
|
||||
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0));
|
||||
assert_eq!(g.crop().width, 0.5);
|
||||
}
|
||||
@@ -1579,7 +1889,7 @@ mod tests {
|
||||
assert_eq!(conflicts.len(), 1, "the same parameter, two values");
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
local.apply(&mut g);
|
||||
local.apply(&mut g).expect_no_film();
|
||||
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(2.0));
|
||||
}
|
||||
|
||||
@@ -1604,7 +1914,7 @@ mod tests {
|
||||
local.merge(&remote, Some(&base));
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
local.apply(&mut g);
|
||||
local.apply(&mut g).expect_no_film();
|
||||
assert_eq!(
|
||||
g.param(exposure::ID, exposure::EXPOSURE),
|
||||
Some(1.0),
|
||||
@@ -1626,7 +1936,7 @@ mod tests {
|
||||
local.merge(&remote, Some(&base));
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
local.apply(&mut g);
|
||||
local.apply(&mut g).expect_no_film();
|
||||
assert!(g.is_neutral(), "the remote reset must survive the merge");
|
||||
}
|
||||
|
||||
@@ -1935,7 +2245,7 @@ mod tests {
|
||||
|
||||
assert_eq!(local.rating, 4, "the tablet's cull arrived");
|
||||
let mut g = EditGraph::default_chain();
|
||||
local.apply(&mut g);
|
||||
local.apply(&mut g).expect_no_film();
|
||||
assert_eq!(
|
||||
g.param(exposure::ID, exposure::EXPOSURE),
|
||||
Some(1.5),
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
//! TRACES: FR-DEV-8
|
||||
//! Spot removal — the marks a photographer paints out, as parameters.
|
||||
//!
|
||||
//! A spot is a disc over something unwanted, a source offset saying where the
|
||||
//! replacement comes from, and the handful of numbers that decide how the two
|
||||
//! are blended. No pixels are stored, here or anywhere: the shader draws the
|
||||
//! repair from these numbers every time the photograph is rendered, which is
|
||||
//! what makes it non-destructive, cheap to sync, and undoable
|
||||
//! (`docs/spot-removal.md`).
|
||||
//!
|
||||
//! # Why this is not an operation
|
||||
//!
|
||||
//! [`crate::Operation`] is `ParamId -> f32`, and the generic machinery built on
|
||||
//! that — the develop panel, the sidecar, the presets — works precisely because
|
||||
//! it is true. A spot list is neither scalar nor of fixed length, so it lives
|
||||
//! beside `ops` in [`crate::EditGraph`], as `framing`, `masks` and `film`
|
||||
//! already do for the same reason. The trait says as much where it refuses a
|
||||
//! downcast for film tables: a thing that is not a slider should not pretend to
|
||||
//! be one.
|
||||
//!
|
||||
//! # Units, once, for all of a spot's lengths
|
||||
//!
|
||||
//! `centre` is in **normalised source coordinates**, the space every mask uses,
|
||||
//! so a spot survives a crop, a straighten, a zoom and an export at another
|
||||
//! size with no arithmetic to keep it where the dust was.
|
||||
//!
|
||||
//! Every *length* — the radius, the feather, the source offset — is in the
|
||||
//! frame's **isotropic units**, where y spans `0..1` and x spans `0..aspect`.
|
||||
//! That is [`crate::mask::MaskSource::Radial`]'s convention and it is chosen
|
||||
//! here for the same reason: only in those units is a disc a disc. Normalised
|
||||
//! coordinates would make a spot on a 3:2 frame an ellipse half again wider
|
||||
//! than it is tall, and the source offset would point somewhere other than
|
||||
//! where the photographer dragged it.
|
||||
//!
|
||||
//! It is deliberately *one* unit for all three. A radius in shorter-edge
|
||||
//! fractions beside an offset in frame units agrees on a landscape frame and
|
||||
//! silently disagrees on a portrait one, which is the kind of bug that stays
|
||||
//! invisible until somebody rotates a photograph.
|
||||
//!
|
||||
//! # What is not decided here
|
||||
//!
|
||||
//! How a spot is drawn. That is `dr-gpu`, from the passes [`crate::detail`]
|
||||
//! composes — this module holds the state and the two pieces of arithmetic
|
||||
//! nobody downstream should have to repeat: where a spot's source is
|
||||
//! ([`Spot::source`]), and which spots may share a pass ([`SpotSet::rounds`]).
|
||||
|
||||
use crate::operation::{canonical_bits, hash_bytes, mix, Helper, FNV_OFFSET};
|
||||
|
||||
/// The most spots one edit holds.
|
||||
///
|
||||
/// Past a few dozen marks the answer is to clean the sensor, and a bound is
|
||||
/// what keeps a sidecar a file a human can still read. Pushing past it refuses
|
||||
/// rather than dropping the oldest — the rule [`crate::mask::MaskStack::push`]
|
||||
/// follows, for the reason it gives: work the user can see on screen must not
|
||||
/// vanish without being told.
|
||||
pub const MAX_SPOTS: usize = 64;
|
||||
|
||||
/// The furthest a source may be dragged from what it repairs, in frame units.
|
||||
///
|
||||
/// Half the frame's height is well past any repair a photographer makes, and it
|
||||
/// bounds something that is otherwise unbounded: a detail pass declares how far
|
||||
/// it reads from the pixel it writes, and for a spot that is the offset plus
|
||||
/// the radius. An unbounded offset is an unbounded halo, which is a pass the
|
||||
/// tile scheduler cannot plan (ARCH §5.3, `docs/spot-removal.md` §5.3).
|
||||
pub const MAX_SOURCE_DISTANCE: f32 = 0.5;
|
||||
|
||||
/// The radius a new spot starts at, in frame units.
|
||||
///
|
||||
/// About 25 px on the short edge of a 24 MP frame — a dust mark. Small enough
|
||||
/// that the first click on a speck usually covers it, large enough to be worth
|
||||
/// clicking at all.
|
||||
pub const DEFAULT_RADIUS: f32 = 0.012;
|
||||
|
||||
/// The smallest radius a spot may be dragged to, in frame units.
|
||||
///
|
||||
/// Not zero: a spot with no radius repairs nothing and reads as the tool being
|
||||
/// broken rather than as a spot being small.
|
||||
pub const MIN_RADIUS: f32 = 0.001;
|
||||
|
||||
/// The largest radius a spot may be dragged to, in frame units.
|
||||
///
|
||||
/// A repair wider than half the frame is not a repair, and the same bound on
|
||||
/// the radius as on the offset keeps the halo arithmetic honest.
|
||||
pub const MAX_RADIUS: f32 = 0.5;
|
||||
|
||||
/// The fraction of the radius over which a new spot's edge falls away.
|
||||
///
|
||||
/// Soft by default because the common repair is dust on a gradient sky, where
|
||||
/// a hard edge shows as a disc even when the colour underneath it is right.
|
||||
pub const DEFAULT_FEATHER: f32 = 0.35;
|
||||
|
||||
/// How far a new repair's source starts from what it repairs, in radii.
|
||||
///
|
||||
/// Clear of the disc it is replacing — a source overlapping its own
|
||||
/// destination would copy the mark it is removing — and close enough that on a
|
||||
/// smoothly varying background it is the same background. Two and a half puts
|
||||
/// a full radius of untouched photograph between the two edges.
|
||||
const SOURCE_ARM: f32 = 2.5;
|
||||
|
||||
/// The grid every stored length and coordinate is rounded to, as a divisor.
|
||||
///
|
||||
/// The same value and the same reasoning as [`crate::mask::Stroke`]'s grid:
|
||||
/// values are snapped on the way in *and* written at that precision, so a
|
||||
/// sidecar round trip is exact rather than nearly exact, and two devices that
|
||||
/// placed the same spot produce the same line instead of a diff of noise in the
|
||||
/// sixth decimal — which under per-field merge (FR-NC-9) is a conflict over
|
||||
/// nothing.
|
||||
const SPOT_GRID: f32 = 10_000.0;
|
||||
|
||||
/// Round to the stored grid. See [`SPOT_GRID`].
|
||||
fn snap(v: f32) -> f32 {
|
||||
(v * SPOT_GRID).round() / SPOT_GRID
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// How a spot's patch meets what is already there.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SpotMode {
|
||||
/// Copy the source's texture and take the destination's colour and
|
||||
/// brightness from the boundary. The right answer for dust on a sky, and
|
||||
/// the default because that is the overwhelming majority of spots.
|
||||
#[default]
|
||||
Heal,
|
||||
/// Copy the source, unaltered.
|
||||
///
|
||||
/// Kept because heal is wrong on an edge: a spot straddling a horizon
|
||||
/// healed by interpolating its boundary smears the horizon's contrast
|
||||
/// across the disc, and the honest tool then is a straight copy from a
|
||||
/// matching part of the frame. FR-DEV-8 asks for both for this reason.
|
||||
Clone,
|
||||
}
|
||||
|
||||
impl SpotMode {
|
||||
/// The name this mode is stored under. Stable: it is in every sidecar.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Heal => "heal",
|
||||
Self::Clone => "clone",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"heal" => Some(Self::Heal),
|
||||
"clone" => Some(Self::Clone),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// One repair: what is covered, what covers it, and how the two meet.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Spot {
|
||||
/// Stable across devices — see [`Spot::derive_id`].
|
||||
pub id: String,
|
||||
/// What is being covered, in normalised source coordinates.
|
||||
pub centre: (f32, f32),
|
||||
/// Where the replacement comes from, as a displacement from `centre` in
|
||||
/// frame units.
|
||||
///
|
||||
/// A vector rather than a second point, so that nudging a spot half a pixel
|
||||
/// carries its source along instead of asking the photographer to place it
|
||||
/// again. Moving the source alone is an edit to this.
|
||||
pub offset: (f32, f32),
|
||||
/// The radius of the disc, in frame units.
|
||||
pub radius: f32,
|
||||
/// The fraction of the radius over which the edge falls away, `0.0..=1.0`.
|
||||
/// Zero is a hard disc.
|
||||
pub feather: f32,
|
||||
/// How much of the patch is laid down, `0.0..=1.0`.
|
||||
///
|
||||
/// Below one the repair is partial, which is how a mark is *reduced* rather
|
||||
/// than removed — worth having for a blemish that is part of the subject
|
||||
/// rather than dirt on the sensor.
|
||||
pub opacity: f32,
|
||||
pub mode: SpotMode,
|
||||
/// Whether this spot draws.
|
||||
///
|
||||
/// Kept rather than deleted so a photographer can see what a repair was
|
||||
/// doing without losing it, exactly as [`crate::mask::MaskLayer::enabled`]
|
||||
/// does for a layer.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Spot {
|
||||
/// A spot covering `centre`, sourced `offset` away, clamped to what the
|
||||
/// renderer can express.
|
||||
///
|
||||
/// The id is derived from the position — see [`Spot::derive_id`]. A caller
|
||||
/// adding to a set should go through [`SpotSet::place`], which is what
|
||||
/// resolves the case of two spots landing on the same point.
|
||||
pub fn new(centre: (f32, f32), offset: (f32, f32), radius: f32) -> Self {
|
||||
let centre = (snap(centre.0), snap(centre.1));
|
||||
Self {
|
||||
id: Self::derive_id(centre),
|
||||
centre,
|
||||
offset: clamp_offset(offset),
|
||||
radius: snap(radius.clamp(MIN_RADIUS, MAX_RADIUS)),
|
||||
feather: DEFAULT_FEATHER,
|
||||
opacity: 1.0,
|
||||
mode: SpotMode::default(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// Where a new repair reads from, before anybody has looked at it.
|
||||
///
|
||||
/// **FR-DEV-8 asks for automatic source placement, and this is the cheap
|
||||
/// half of it.** The good half searches the photograph for a patch whose
|
||||
/// surroundings match — a compute dispatch scoring candidate offsets, and
|
||||
/// one small readback when the spot is created (`docs/spot-removal.md`
|
||||
/// §8). This is what stands in for it, and it is worth having on its own
|
||||
/// terms rather than as a placeholder: dust sits on skies, skies are
|
||||
/// smooth, and a patch two and a half radii away is nearly always the same
|
||||
/// sky.
|
||||
///
|
||||
/// Towards the centre of the frame, because that is the direction with the
|
||||
/// most photograph in it: a mark near an edge sourced outwards reads from
|
||||
/// the border, or from outside it, where `spot_tap` clamps and the repair
|
||||
/// smears. A mark *at* the centre has no such direction and is sent right,
|
||||
/// which is as good as any other bearing and is at least predictable.
|
||||
///
|
||||
/// The result is what gets stored, and it is never recomputed: a repair
|
||||
/// whose source moved on its own when the file was reopened would be an
|
||||
/// edit changing itself, and non-destructive editing means the sidecar
|
||||
/// decides what the picture is.
|
||||
pub fn default_offset(centre: (f32, f32), radius: f32, aspect: f32) -> (f32, f32) {
|
||||
let aspect = if aspect > 0.0 { aspect } else { 1.0 };
|
||||
// In frame units, where a direction is a direction: normalised
|
||||
// coordinates would bend the bearing by the aspect ratio and send a
|
||||
// source off at an angle nobody chose.
|
||||
let to_centre = ((0.5 - centre.0) * aspect, 0.5 - centre.1);
|
||||
let length = to_centre.0.hypot(to_centre.1);
|
||||
let direction = if length > 1e-4 {
|
||||
(to_centre.0 / length, to_centre.1 / length)
|
||||
} else {
|
||||
(1.0, 0.0)
|
||||
};
|
||||
let distance = radius * SOURCE_ARM;
|
||||
(direction.0 * distance, direction.1 * distance)
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-9
|
||||
/// The id a spot at `centre` is given: a short base-36 hash of the position
|
||||
/// it was placed at.
|
||||
///
|
||||
/// **Derived rather than counted**, which is the opposite of what
|
||||
/// [`crate::mask::MaskStack::next_id`] does, and the difference is worth
|
||||
/// stating. A layer is a thing a user names and reorders, so a sequence is
|
||||
/// natural. A spot is not named, and two devices editing the same
|
||||
/// photograph offline would each mint `spot3` for different marks — after
|
||||
/// which the merge in [`crate::sidecar`] would treat two repairs as one and
|
||||
/// quietly keep whichever revision was higher.
|
||||
///
|
||||
/// From the position, two devices that removed *the same piece of dust*
|
||||
/// agree on the id and the merge resolves them as one spot — which is
|
||||
/// exactly right, because it is one spot. Two devices that removed
|
||||
/// different marks disagree, and both survive.
|
||||
///
|
||||
/// The id is minted once, at placement, and never re-derived: dragging a
|
||||
/// spot moves the repair, it does not make a different one.
|
||||
pub fn derive_id(centre: (f32, f32)) -> String {
|
||||
let mut h = FNV_OFFSET;
|
||||
h = mix(h, u64::from(canonical_bits(snap(centre.0))));
|
||||
h = mix(h, u64::from(canonical_bits(snap(centre.1))));
|
||||
base36(h)
|
||||
}
|
||||
|
||||
/// Where this spot reads from, in normalised source coordinates.
|
||||
///
|
||||
/// `aspect` is the source's width over its height. The offset is in frame
|
||||
/// units and the answer is in normalised ones, and this is the only place
|
||||
/// that conversion happens on this side — a caller doing it itself would be
|
||||
/// the second place, and the two would eventually disagree about which axis
|
||||
/// carries the aspect.
|
||||
pub fn source(&self, aspect: f32) -> (f32, f32) {
|
||||
let aspect = if aspect > 0.0 { aspect } else { 1.0 };
|
||||
(
|
||||
self.centre.0 + self.offset.0 / aspect,
|
||||
self.centre.1 + self.offset.1,
|
||||
)
|
||||
}
|
||||
|
||||
/// This spot's centre in frame units, where a disc is a disc.
|
||||
pub fn frame_centre(&self, aspect: f32) -> (f32, f32) {
|
||||
(self.centre.0 * aspect, self.centre.1)
|
||||
}
|
||||
|
||||
/// How far the source is from what it repairs, in frame units.
|
||||
pub fn distance(&self) -> f32 {
|
||||
self.offset.0.hypot(self.offset.1)
|
||||
}
|
||||
|
||||
/// Whether this spot changes the photograph.
|
||||
///
|
||||
/// A spot with no offset reads the pixel it is writing: a clone copies a
|
||||
/// pixel onto itself and a heal interpolates a boundary difference that is
|
||||
/// zero everywhere, so both are the identity and both would cost a pass. A
|
||||
/// spot just placed and not yet given a source is in exactly that state,
|
||||
/// which is why this is asked per spot rather than per set.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.enabled && self.radius > 0.0 && self.opacity > 0.0 && self.distance() > f32::EPSILON
|
||||
}
|
||||
|
||||
/// Move the whole repair, source and all, to a new centre.
|
||||
///
|
||||
/// The id does not move with it — see [`Spot::derive_id`].
|
||||
pub fn set_centre(&mut self, centre: (f32, f32)) {
|
||||
self.centre = (snap(centre.0), snap(centre.1));
|
||||
}
|
||||
|
||||
/// Move the source, leaving what is being repaired where it is.
|
||||
pub fn set_offset(&mut self, offset: (f32, f32)) {
|
||||
self.offset = clamp_offset(offset);
|
||||
}
|
||||
|
||||
pub fn set_radius(&mut self, radius: f32) {
|
||||
self.radius = snap(radius.clamp(MIN_RADIUS, MAX_RADIUS));
|
||||
}
|
||||
|
||||
pub fn set_feather(&mut self, feather: f32) {
|
||||
self.feather = snap(feather.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
pub fn set_opacity(&mut self, opacity: f32) {
|
||||
self.opacity = snap(opacity.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
/// Fold this spot into a running hash, for the detail stage's cache key.
|
||||
///
|
||||
/// Every value is a parameter — a position a finger left, a number from a
|
||||
/// sidecar — never a float that came back from the GPU, which is what makes
|
||||
/// hashing the bit patterns sound rather than reckless (ARCH §6.13). The id
|
||||
/// is in it too: two spots that swapped ids are a different edit to sync
|
||||
/// even though they draw the same picture.
|
||||
pub(crate) fn hash(&self, h: u64) -> u64 {
|
||||
let mut h = hash_bytes(h, self.id.as_bytes());
|
||||
for v in [
|
||||
self.centre.0,
|
||||
self.centre.1,
|
||||
self.offset.0,
|
||||
self.offset.1,
|
||||
self.radius,
|
||||
self.feather,
|
||||
self.opacity,
|
||||
] {
|
||||
h = mix(h, u64::from(canonical_bits(v)));
|
||||
}
|
||||
h = hash_bytes(h, self.mode.name().as_bytes());
|
||||
mix(h, u64::from(self.enabled))
|
||||
}
|
||||
}
|
||||
|
||||
/// An offset clamped to what the halo bound allows, snapped to the grid.
|
||||
///
|
||||
/// Clamped along its own direction rather than per axis, so a drag towards a
|
||||
/// corner stops at the bound instead of sliding along it — a per-axis clamp
|
||||
/// would turn a diagonal drag into an L-shaped one under the finger.
|
||||
fn clamp_offset(offset: (f32, f32)) -> (f32, f32) {
|
||||
let distance = offset.0.hypot(offset.1);
|
||||
if distance > MAX_SOURCE_DISTANCE {
|
||||
let scale = MAX_SOURCE_DISTANCE / distance;
|
||||
(snap(offset.0 * scale), snap(offset.1 * scale))
|
||||
} else {
|
||||
(snap(offset.0), snap(offset.1))
|
||||
}
|
||||
}
|
||||
|
||||
/// A hash as base-36 digits.
|
||||
///
|
||||
/// Short because it becomes a sidecar key that a human reads while debugging an
|
||||
/// edit that went wrong. Six digits is two thousand million ids against the
|
||||
/// sixty-four an edit may hold, so a collision is not a thing that happens by
|
||||
/// accident — and [`SpotSet::place`] resolves it anyway when it does.
|
||||
fn base36(mut h: u64) -> String {
|
||||
const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
let mut out = String::with_capacity(6);
|
||||
for _ in 0..6 {
|
||||
out.push(DIGITS[(h % 36) as usize] as char);
|
||||
h /= 36;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// Every repair on one photograph, in the order they were made.
|
||||
///
|
||||
/// The order is not decoration: it decides which spots may share a pass
|
||||
/// ([`Self::rounds`]) and which repair sits on top where two overlap.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct SpotSet {
|
||||
spots: Vec<Spot>,
|
||||
}
|
||||
|
||||
impl SpotSet {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn spots(&self) -> &[Spot] {
|
||||
&self.spots
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.spots.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.spots.is_empty()
|
||||
}
|
||||
|
||||
/// Whether this set draws nothing, and the detail stage may skip it
|
||||
/// entirely.
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
!self.spots.iter().any(Spot::is_active)
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Spot> {
|
||||
self.spots.iter().find(|s| s.id == id)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, id: &str) -> Option<&mut Spot> {
|
||||
self.spots.iter_mut().find(|s| s.id == id)
|
||||
}
|
||||
|
||||
/// The spots that draw, in order.
|
||||
pub fn active(&self) -> impl Iterator<Item = &Spot> {
|
||||
self.spots.iter().filter(|s| s.is_active())
|
||||
}
|
||||
|
||||
/// Add a spot, returning its id, or `None` if the set is full.
|
||||
///
|
||||
/// Full **refuses** rather than dropping the oldest: sixty-four repairs are
|
||||
/// sixty-four decisions, and silently discarding the first to make room for
|
||||
/// the sixty-fifth would undo work the photographer can see on screen.
|
||||
///
|
||||
/// A spot placed on top of an existing one is given a distinct id by
|
||||
/// salting the hash, so the set never holds two spots under one name. This
|
||||
/// is rare by construction — the same point to a ten-thousandth of the
|
||||
/// frame — and it is the one case [`Spot::derive_id`]'s determinism cannot
|
||||
/// resolve on its own.
|
||||
pub fn place(&mut self, mut spot: Spot) -> Option<String> {
|
||||
if self.spots.len() >= MAX_SPOTS {
|
||||
log::warn!("spots: {MAX_SPOTS} is the limit; refusing to place another");
|
||||
return None;
|
||||
}
|
||||
let mut salt: u64 = 0;
|
||||
while self.spots.iter().any(|s| s.id == spot.id) {
|
||||
salt += 1;
|
||||
let mut h = FNV_OFFSET;
|
||||
h = mix(h, u64::from(canonical_bits(spot.centre.0)));
|
||||
h = mix(h, u64::from(canonical_bits(spot.centre.1)));
|
||||
spot.id = base36(mix(h, salt));
|
||||
}
|
||||
let id = spot.id.clone();
|
||||
self.spots.push(spot);
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// Remove one repair.
|
||||
pub fn remove(&mut self, id: &str) -> Option<Spot> {
|
||||
let index = self.spots.iter().position(|s| s.id == id)?;
|
||||
Some(self.spots.remove(index))
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.spots.clear();
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// The active spots grouped into passes, as indices into the order
|
||||
/// [`Self::active`] yields.
|
||||
///
|
||||
/// # Why grouping is needed at all
|
||||
///
|
||||
/// A detail pass reads one texture and writes another, so every spot in one
|
||||
/// pass reads the photograph as it stood *before* that pass. A spot whose
|
||||
/// source sits on an earlier spot's destination therefore copies the very
|
||||
/// mark the earlier spot was removing, and the mark reappears a few hundred
|
||||
/// pixels away — which reads as the tool being broken rather than as two
|
||||
/// repairs that disagree.
|
||||
///
|
||||
/// The fix is not a pass per spot: sixty-four dispatches for a frame that
|
||||
/// needs one is a frame budget spent on a case that almost never arises.
|
||||
/// Instead a spot joins the round being built unless its source disc
|
||||
/// intersects the destination disc of a spot already in that round, in
|
||||
/// which case it opens a new one. Spots scattered over a sky with their
|
||||
/// sources beside them — the overwhelming majority — come out as a single
|
||||
/// round.
|
||||
///
|
||||
/// Only the round being built is consulted. Earlier rounds have already
|
||||
/// been applied by the time a later one runs, so reading their destinations
|
||||
/// is not a hazard: it is the repaired photograph, which is exactly what a
|
||||
/// source should see.
|
||||
///
|
||||
/// Destinations overlapping destinations is not a hazard either — both
|
||||
/// write the same output and the later spot lands on top, which is the
|
||||
/// order the photographer made them in.
|
||||
pub fn rounds(&self, aspect: f32) -> Vec<Vec<usize>> {
|
||||
let active: Vec<&Spot> = self.active().collect();
|
||||
let mut rounds: Vec<Vec<usize>> = Vec::new();
|
||||
let mut current: Vec<usize> = Vec::new();
|
||||
|
||||
for (index, spot) in active.iter().enumerate() {
|
||||
let source = spot.source(aspect);
|
||||
let source_frame = (source.0 * aspect, source.1);
|
||||
let conflicts = current.iter().any(|&earlier| {
|
||||
let other = active[earlier];
|
||||
let dest = other.frame_centre(aspect);
|
||||
let reach = spot.radius + other.radius;
|
||||
let dx = source_frame.0 - dest.0;
|
||||
let dy = source_frame.1 - dest.1;
|
||||
dx * dx + dy * dy < reach * reach
|
||||
});
|
||||
if conflicts {
|
||||
rounds.push(std::mem::take(&mut current));
|
||||
}
|
||||
current.push(index);
|
||||
}
|
||||
if !current.is_empty() {
|
||||
rounds.push(current);
|
||||
}
|
||||
rounds
|
||||
}
|
||||
|
||||
/// Fold the set into a running hash, for the detail stage's cache key.
|
||||
///
|
||||
/// The order is in it: two spots swapped is a different grouping in
|
||||
/// [`Self::rounds`] and a different picture where they overlap.
|
||||
pub(crate) fn hash(&self, mut h: u64) -> u64 {
|
||||
for spot in &self.spots {
|
||||
h = spot.hash(h);
|
||||
}
|
||||
mix(h, self.spots.len() as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// The id the generated passes are labelled and prefixed with.
|
||||
///
|
||||
/// Not an operation id — no `ops/*.yaml` declares it and nothing in the chain
|
||||
/// answers to it — for the same reason [`crate::detail`]'s resolve pass has one
|
||||
/// of its own: a label reading `spot/round0` sends a reader to this module
|
||||
/// rather than to whichever operation happened to lend its name.
|
||||
pub const SPOT_ID: &str = "spot";
|
||||
|
||||
/// Bilinear sampling, which the generated preamble does not offer.
|
||||
///
|
||||
/// `tap` takes an integer offset from the pixel being written, and a repair
|
||||
/// reads from wherever its source is — a fractional position in render space,
|
||||
/// because the offset was stored as a fraction of the frame and multiplied up.
|
||||
/// Sampling it nearest-neighbour would make a repair jitter by a pixel as the
|
||||
/// view is zoomed, which on a face is the difference between a repair and a
|
||||
/// smudge.
|
||||
pub const SPOT_HELPERS: &[Helper] = &[Helper {
|
||||
name: "spot_tap",
|
||||
source: "\
|
||||
// A bilinear sample at an arbitrary position, clamped to the edge.
|
||||
//
|
||||
// Clamped rather than zero-filled, exactly as `tap` is: a source dragged partly
|
||||
// off the frame must read the pixels that exist rather than fade into black,
|
||||
// which would draw a dark crescent inside the repair.
|
||||
fn spot_tap(p: vec2<f32>) -> vec3<f32> {
|
||||
let last = vec2<i32>(textureDimensions(source)) - vec2<i32>(1);
|
||||
// Pixel centres sit at half-integers, so the texel below and left of a
|
||||
// position is `floor(p - 0.5)`. Getting this wrong shifts every repair by
|
||||
// half a pixel — invisible in a test that checks a mean, obvious on a face.
|
||||
let q = p - vec2<f32>(0.5);
|
||||
let base = floor(q);
|
||||
let f = q - base;
|
||||
let i0 = clamp(vec2<i32>(base), vec2<i32>(0), last);
|
||||
let i1 = clamp(i0 + vec2<i32>(1), vec2<i32>(0), last);
|
||||
let s00 = textureLoad(source, vec2<i32>(i0.x, i0.y), 0).rgb;
|
||||
let s10 = textureLoad(source, vec2<i32>(i1.x, i0.y), 0).rgb;
|
||||
let s01 = textureLoad(source, vec2<i32>(i0.x, i1.y), 0).rgb;
|
||||
let s11 = textureLoad(source, vec2<i32>(i1.x, i1.y), 0).rgb;
|
||||
return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y);
|
||||
}",
|
||||
}];
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// How many points around a disc's rim a heal samples.
|
||||
///
|
||||
/// The membrane in [`SPOT_BODY`] is an interpolation of the boundary
|
||||
/// difference, so this is the resolution of the boundary it sees. Twenty-four
|
||||
/// puts a sample every fifteen degrees, which on a disc of any size a
|
||||
/// photographer draws is finer than the tone it is interpolating.
|
||||
///
|
||||
/// A uniform rather than a constant in the source, so tuning it uploads a
|
||||
/// buffer instead of recompiling — and so a future control could trade it for
|
||||
/// speed on a large repair without a second shader.
|
||||
pub const RIM_SAMPLES: f32 = 24.0;
|
||||
|
||||
/// The WGSL every spot pass runs. See [`SpotSet::passes`] for the record layout
|
||||
/// it reads, which is where the meaning of each lane is written down.
|
||||
const SPOT_BODY: &str = "\
|
||||
// Each repair is two records: the disc it covers, and where it reads from.
|
||||
let repairs = instance_count / 2u;
|
||||
// The centre of this pixel. Half-integer, because a disc of radius 1.5 centred
|
||||
// on a pixel should cover that pixel whole rather than half of it.
|
||||
let here = vec2<f32>(f32(coord.x) + 0.5, f32(coord.y) + 0.5);
|
||||
|
||||
for (var i = 0u; i < repairs; i = i + 1u) {
|
||||
let disc = instances[i * 2u];
|
||||
let src = instances[i * 2u + 1u];
|
||||
|
||||
// The rejection test. It is what every pixel outside every repair pays,
|
||||
// and a repair covers a few thousand pixels of a few million.
|
||||
let delta = here - disc.xy;
|
||||
let dist = length(delta);
|
||||
if (dist >= disc.z) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// One inside the solid core, falling to zero at the rim. `disc.w` is where
|
||||
// the fall begins, worked out on the CPU so the shader never divides by a
|
||||
// feather that might be zero.
|
||||
let cover = (1.0 - smoothstep(disc.w, disc.z, dist)) * src.z;
|
||||
if (cover <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The same displacement within the disc, read from beside it: the patch is
|
||||
// a translation of the photograph, so its texture arrives unrotated and
|
||||
// unscaled.
|
||||
//
|
||||
// `replacement`, not `patch`: WGSL reserves that word, and a reserved
|
||||
// keyword in generated code is a compile error a long way from its cause.
|
||||
var replacement = spot_tap(src.xy + delta);
|
||||
|
||||
// Heal: carry the source's texture, but the destination's tone.
|
||||
//
|
||||
// What a clone gets wrong is not the texture, it is the level. Dust on a
|
||||
// gradient sky is cloned from a patch a little lighter or darker than the
|
||||
// hole it fills, and the repair reads as a disc even though every grain in
|
||||
// it is right. The fix is the difference between the two neighbourhoods,
|
||||
// interpolated across the disc — a membrane, in the sense the Poisson
|
||||
// literature means, approximated here in closed form rather than solved.
|
||||
//
|
||||
// Solving it properly is tens of Jacobi iterations, and an iteration in
|
||||
// this architecture is a dispatch: sixty dispatches to remove a dust spot
|
||||
// is not a frame budget. Interpolating the boundary difference by inverse
|
||||
// square distance costs one loop over the rim and no state at all, and on
|
||||
// the case that actually matters — a smooth background, where the
|
||||
// difference around the rim is near enough constant — it lands on the same
|
||||
// answer the solve would.
|
||||
if (src.w > 0.5) {
|
||||
var weighted = vec3<f32>(0.0);
|
||||
var total = 0.0;
|
||||
let samples = i32(rim_samples);
|
||||
for (var k = 0; k < samples; k = k + 1) {
|
||||
// Offset by half a step so no sample sits exactly on an axis,
|
||||
// where a rim that crosses a hard edge would align with it.
|
||||
let angle = (f32(k) + 0.5) * 6.283185307 / rim_samples;
|
||||
let arm = vec2<f32>(cos(angle), sin(angle)) * disc.z;
|
||||
// What the photograph says here, minus what the source says at the
|
||||
// matching point of its own rim.
|
||||
let boundary = spot_tap(disc.xy + arm) - spot_tap(src.xy + arm);
|
||||
// Inverse square distance, floored so a pixel that lands on a
|
||||
// sample is a large weight rather than an infinite one.
|
||||
let w = 1.0 / max(dot(here - (disc.xy + arm), here - (disc.xy + arm)), 1.0);
|
||||
weighted = weighted + boundary * w;
|
||||
total = total + w;
|
||||
}
|
||||
replacement = replacement + weighted / max(total, 1e-6);
|
||||
}
|
||||
|
||||
c = mix(c, replacement, cover);
|
||||
}";
|
||||
|
||||
impl SpotSet {
|
||||
/// TRACES: FR-DEV-8 | FR-DSP-1
|
||||
/// The passes that draw these repairs at this size.
|
||||
///
|
||||
/// Shaped like [`crate::detail::DetailStage::passes`] and called in the
|
||||
/// same place for the same reason, but deliberately not an implementation
|
||||
/// of it: that trait belongs to operations, and a spot set is not one.
|
||||
///
|
||||
/// # Everything the shader sees is in render pixels
|
||||
///
|
||||
/// The conversion happens here, where the [`crate::Framing`] is in scope,
|
||||
/// and never in WGSL. That is what keeps the shader ignorant of crops,
|
||||
/// zooms, rotations and flips: a repair's centre and its source both go
|
||||
/// through [`crate::Framing::output_at`] — the same map the fused pass
|
||||
/// applies to every pixel — so a rotated photograph rotates the offset with
|
||||
/// no trigonometry here at all, and a repair panned off screen lands
|
||||
/// outside the target and draws nothing.
|
||||
///
|
||||
/// The radius goes through the same map rather than being multiplied by a
|
||||
/// ratio: a point one radius above the centre is mapped too, and the
|
||||
/// distance between the two answers *is* the radius in render pixels.
|
||||
/// Anything cheaper would need this function to know that the framing is a
|
||||
/// similarity, which is not its business to know.
|
||||
///
|
||||
/// # The record layout
|
||||
///
|
||||
/// Two `vec4`s per repair, because a repair does not fit in one:
|
||||
///
|
||||
/// | | x | y | z | w |
|
||||
/// |---|---|---|---|---|
|
||||
/// | 0 | centre x | centre y | radius | where the edge starts falling |
|
||||
/// | 1 | source x | source y | opacity | 1 for heal, 0 for clone |
|
||||
pub fn passes(
|
||||
&self,
|
||||
framing: &crate::Framing,
|
||||
source: (u32, u32),
|
||||
scale: crate::detail::RenderScale,
|
||||
) -> Vec<crate::detail::DetailPass> {
|
||||
use crate::detail::DetailPass;
|
||||
|
||||
let (sw, sh) = (source.0.max(1), source.1.max(1));
|
||||
let aspect = sw as f32 / sh as f32;
|
||||
let (rw, rh) = scale.render_size();
|
||||
let (rw, rh) = (rw as f32, rh as f32);
|
||||
let to_px = |uv: (f32, f32)| (uv.0 * rw, uv.1 * rh);
|
||||
|
||||
let active: Vec<&Spot> = self.active().collect();
|
||||
let mut passes = Vec::new();
|
||||
|
||||
for (round, group) in self.rounds(aspect).into_iter().enumerate() {
|
||||
let mut storage: Vec<[f32; 4]> = Vec::with_capacity(group.len() * 2);
|
||||
let mut reach: f32 = 0.0;
|
||||
|
||||
for index in group {
|
||||
let spot = active[index];
|
||||
let centre = to_px(framing.output_at(spot.centre, sw, sh));
|
||||
let from = to_px(framing.output_at(spot.source(aspect), sw, sh));
|
||||
|
||||
// One radius along y in frame units is one radius along y in
|
||||
// normalised coordinates, which is why the probe point is built
|
||||
// this way rather than from the offset.
|
||||
let rim = (spot.centre.0, spot.centre.1 + spot.radius);
|
||||
let rim_px = to_px(framing.output_at(rim, sw, sh));
|
||||
let radius = (rim_px.0 - centre.0).hypot(rim_px.1 - centre.1);
|
||||
|
||||
// Where the edge begins to fall away. Always at least half a
|
||||
// pixel inside the rim: a disc with a genuinely hard edge
|
||||
// aliases into a visible polygon, and half a pixel of ramp is
|
||||
// finer than any feather control can ask for anyway.
|
||||
let inner = (radius * (1.0 - spot.feather)).min(radius - 0.5).max(0.0);
|
||||
|
||||
storage.push([centre.0, centre.1, radius, inner]);
|
||||
storage.push([
|
||||
from.0,
|
||||
from.1,
|
||||
spot.opacity,
|
||||
match spot.mode {
|
||||
SpotMode::Heal => 1.0,
|
||||
SpotMode::Clone => 0.0,
|
||||
},
|
||||
]);
|
||||
|
||||
// How far this pass reads from a pixel it writes: across to the
|
||||
// source, plus the disc it reads there. Stated honestly even
|
||||
// though it is large — an understated radius shows as a seam at
|
||||
// every tile boundary, which reads as a driver bug (ARCH §5.3).
|
||||
let across = (from.0 - centre.0).hypot(from.1 - centre.1);
|
||||
reach = reach.max(across + radius);
|
||||
}
|
||||
|
||||
if storage.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
passes.push(DetailPass {
|
||||
label: round_label(round),
|
||||
radius: reach.ceil() as u32,
|
||||
wgsl: SPOT_BODY.to_string(),
|
||||
uniforms: vec![crate::operation::Uniform {
|
||||
name: "rim_samples",
|
||||
value: RIM_SAMPLES,
|
||||
}],
|
||||
storage,
|
||||
});
|
||||
}
|
||||
|
||||
passes
|
||||
}
|
||||
}
|
||||
|
||||
/// A static label for round `n`.
|
||||
///
|
||||
/// Static because a pass label is a `&'static str`, and rounds past the few
|
||||
/// named here are rare enough — each one needs a source deliberately placed
|
||||
/// over an earlier repair — that sharing a label between them costs nothing but
|
||||
/// a slightly vaguer line in a profiler.
|
||||
fn round_label(round: usize) -> &'static str {
|
||||
match round {
|
||||
0 => "round0",
|
||||
1 => "round1",
|
||||
2 => "round2",
|
||||
3 => "round3",
|
||||
_ => "round",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! TRACES: FR-DEV-5 | FR-CAT-8
|
||||
//! The whole of an edit, gathered so that nothing can be left out of it.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! An edit used to be a bag of scalars, and [`Preset`] is that bag. It was a
|
||||
//! true description while the graph held only operations and a framing, and it
|
||||
//! stopped being true the moment a mask stack and a film stock arrived —
|
||||
//! because a layer is not a scalar and a stock is not a scalar, which is
|
||||
//! precisely why [`EditGraph`] holds both of them apart from `ops`.
|
||||
//!
|
||||
//! Nothing announced the change. What happened instead is that three places
|
||||
//! each captured "the edit" and each captured a different subset of it:
|
||||
//! [`Preset::capture`] took the parameters, `Version::from_graph` took the
|
||||
//! parameters and the masks and the film, and `Version::update` took the
|
||||
//! parameters and the masks and dropped the film on the floor. The visible
|
||||
//! symptom was quieter still — the history snapshots a `Preset`, so drawing a
|
||||
//! mask opened no undo step at all, and `record` returned `false` while the
|
||||
//! interface went on calling it in good faith.
|
||||
//!
|
||||
//! # What keeps it complete
|
||||
//!
|
||||
//! Not vigilance, and not a checklist in a comment. [`EditGraph::state`]
|
||||
//! destructures the graph **exhaustively** and [`EditGraph::set_state`]
|
||||
//! destructures this type exhaustively — no `..` in either pattern, and this
|
||||
//! type's fields are public so that every construction site is a struct
|
||||
//! literal naming all of them. A fifth kind of state added to the graph does
|
||||
//! not compile until somebody has decided whether an undo has to put it back.
|
||||
//!
|
||||
//! FR-DEV-8's spot removal was named here as the one already asked for, and it
|
||||
//! arrived. It did not compile, which is the whole of what this was for: the
|
||||
//! repairs are in [`Self::spots`] because the build refused to proceed without
|
||||
//! an answer about them, rather than because anyone remembered to look.
|
||||
//!
|
||||
//! That is the whole mechanism. It is deliberately a compiler error rather
|
||||
//! than a runtime check, because the failure being prevented is *silence*: the
|
||||
//! mask bug produced no panic, no warning and no failing test, and only a
|
||||
//! diagnostic that fires before the code runs at all could have caught it.
|
||||
//!
|
||||
//! # What is deliberately not in here
|
||||
//!
|
||||
//! - **The viewport.** Zoom and pan say where the photographer is looking, not
|
||||
//! what the photograph becomes. An undo that also moved the view would read
|
||||
//! as navigation — the rule [`Preset::apply`] already keeps for a paste.
|
||||
//! - **The orientation baseline.** How the camera stored its rows is part of
|
||||
//! reading the file, not a decision anyone made (see [`crate::framing`]).
|
||||
//! - **The film tables.** Derived from the stock, the paper and the film
|
||||
//! node's own exposure sliders, and re-baked in milliseconds. Turning a name
|
||||
//! back into tables needs the profile database this crate does not link
|
||||
//! (ARCH §6.5a), which is why [`FilmRebake`] exists.
|
||||
//! - **The rating and the flag.** Judgements about the photograph rather than
|
||||
//! edits to it; they change no pixel, and `sidecar::Version` keeps them for
|
||||
//! that reason.
|
||||
|
||||
use crate::graph::EditGraph;
|
||||
use crate::mask::MaskStack;
|
||||
use crate::preset::Preset;
|
||||
use crate::spot::SpotSet;
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// The stock and paper an edit names, without the tables they bake to.
|
||||
///
|
||||
/// The **id, not an index**. Stocks are files that users add
|
||||
/// (`core/dr-film/profiles`), so an index would mean installing a profile
|
||||
/// silently changed which film every existing photograph was developed on.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct FilmRef {
|
||||
pub stock: String,
|
||||
/// The paper, if the negative is printed. Absent means the film is viewed
|
||||
/// as it comes — which for a colour negative is the scan, orange and
|
||||
/// inverted, and is a legitimate thing to ask for.
|
||||
pub print: Option<String>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5 | FR-CAT-8
|
||||
/// Everything about a photograph that an edit decides.
|
||||
///
|
||||
/// Fields are public on purpose: a struct literal is exhaustive, so every
|
||||
/// place that builds one has to account for every part of an edit. See the
|
||||
/// module note — that is the entire safety mechanism, and hiding these behind
|
||||
/// a constructor with positional arguments would trade it for two `String`s
|
||||
/// that can be passed in the wrong order.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct EditState {
|
||||
/// Every non-default parameter — the operations' and the framing's alike,
|
||||
/// since the framing is an entry in [`EditGraph::capabilities`] like any
|
||||
/// other.
|
||||
pub params: Preset,
|
||||
/// The local adjustments (FR-DEV-3).
|
||||
///
|
||||
/// Shared rather than owned, and that is a decision about the *drag path*
|
||||
/// rather than about memory. `state` is called on every parameter change,
|
||||
/// which during a slider drag is once a frame; a mask stack holding a
|
||||
/// painted brush is thousands of stroke points, and deep-copying it sixty
|
||||
/// times a second to record an exposure move that did not touch it would
|
||||
/// be a cost paid for nothing. [`EditGraph::masks_mut`] is the single door
|
||||
/// through which a stack is modified, and it clones on write, so snapshots
|
||||
/// share until one of them is actually edited.
|
||||
pub masks: std::sync::Arc<MaskStack>,
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// The stock this edit develops on, named.
|
||||
pub film: Option<FilmRef>,
|
||||
/// TRACES: FR-DEV-8
|
||||
/// The repairs (see [`crate::spot`]).
|
||||
///
|
||||
/// Owned rather than shared, where [`Self::masks`] is shared, and the
|
||||
/// difference is a fact about the two rather than an inconsistency: a
|
||||
/// repair is a handful of numbers and a photographer places tens of them,
|
||||
/// while a painted mask is an unbounded path of stroke points. Copying the
|
||||
/// first once a frame is nothing; copying the second is the reason
|
||||
/// `masks_mut` clones on write.
|
||||
pub spots: SpotSet,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// What a caller still owes the graph after its state was replaced.
|
||||
///
|
||||
/// `dr-pipeline` can hold a film but cannot bake one: the tables come from the
|
||||
/// profile database, and this crate does not link it (ARCH §6.5a). So
|
||||
/// restoring an edit that names a stock is necessarily two steps, and this is
|
||||
/// the second one made impossible to forget rather than left in a comment.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[must_use = "an ignored rebake leaves the photograph rendering without its film"]
|
||||
pub enum FilmRebake {
|
||||
/// The graph's film is already right — either the state named none, in
|
||||
/// which case it has been cleared, or there was nothing to restore.
|
||||
NotNeeded,
|
||||
/// Bake this stock and hand the result back through
|
||||
/// [`EditGraph::set_film`].
|
||||
Wanted(FilmRef),
|
||||
}
|
||||
|
||||
impl FilmRebake {
|
||||
/// The stock to bake, if one is owed.
|
||||
pub fn wanted(&self) -> Option<&FilmRef> {
|
||||
match self {
|
||||
Self::NotNeeded => None,
|
||||
Self::Wanted(film) => Some(film),
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert that nothing is owed, for a caller that knows this edit names
|
||||
/// no film — a test fixture, in practice.
|
||||
///
|
||||
/// It *checks* rather than discards, and that is the point of it existing
|
||||
/// at all. `let _ = …` would make the same claim silently and go on making
|
||||
/// it the day the fixture grows a stock, at which point the test would
|
||||
/// pass while rendering the wrong picture — which is precisely the failure
|
||||
/// this type was introduced to stop.
|
||||
#[track_caller]
|
||||
pub fn expect_no_film(self) {
|
||||
if let Self::Wanted(film) = self {
|
||||
panic!(
|
||||
"this edit develops on {:?}, which has to be baked back before \
|
||||
the photograph can be rendered",
|
||||
film.stock
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EditState {
|
||||
/// The state `graph` is in. The same call as [`EditGraph::state`], for a
|
||||
/// caller that reads better this way round.
|
||||
pub fn capture(graph: &EditGraph) -> Self {
|
||||
graph.state()
|
||||
}
|
||||
|
||||
/// Whether this edit does anything to the photograph at all.
|
||||
///
|
||||
/// A neutral state is a *decision* rather than a missing one — applying it
|
||||
/// returns the photograph to its defaults — so this answers "is there
|
||||
/// anything to show", not "is there anything to store".
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
self.params.is_empty()
|
||||
&& self.masks.is_empty()
|
||||
&& self.film.is_none()
|
||||
&& self.spots.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::framing;
|
||||
use crate::graph::Film;
|
||||
use crate::mask::{MaskLayer, MaskSource};
|
||||
use crate::ops::{curve, exposure, saturation};
|
||||
use crate::spot::Spot;
|
||||
use crate::CropRect;
|
||||
|
||||
/// A graph with one of *every* kind of state moved off its default: an
|
||||
/// operation's parameter, a framing, a mask layer, a film, a repair. One
|
||||
/// of each is the point — a round trip that only exercises the scalars is
|
||||
/// the test that was already passing while the masks went missing.
|
||||
fn thoroughly_edited() -> EditGraph {
|
||||
let mut g = EditGraph::default_chain();
|
||||
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 0.75);
|
||||
g.set_param(saturation::ID, saturation::SATURATION, -30.0);
|
||||
g.set_param(curve::ID, curve::P1_X, 0.3);
|
||||
g.set_param(framing::ID, framing::ANGLE, 2.5);
|
||||
g.set_crop(CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.6,
|
||||
height: 0.6,
|
||||
});
|
||||
|
||||
let mut layer = MaskLayer::new(
|
||||
"l1",
|
||||
MaskSource::Radial {
|
||||
centre: (0.4, 0.6),
|
||||
radii: (0.25, 0.3),
|
||||
angle: 0.2,
|
||||
feather: 0.15,
|
||||
},
|
||||
);
|
||||
layer.opacity = 0.6;
|
||||
layer.invert = true;
|
||||
g.masks_mut().push(layer);
|
||||
|
||||
g.spots_mut()
|
||||
.place(Spot::new((0.3, 0.7), (0.05, 0.0), 0.02))
|
||||
.expect("the repair was placed");
|
||||
|
||||
g.set_film(Some(Film {
|
||||
stock: "kodak_portra_400".into(),
|
||||
print: Some("kodak_endura".into()),
|
||||
tables: crate::ops::FilmTables {
|
||||
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
|
||||
curve_log_min: -3.0,
|
||||
curve_log_max: 1.0,
|
||||
lut: vec![[0.5, 0.5, 0.5]; 8],
|
||||
density_max: 2.0,
|
||||
lut_size: 2,
|
||||
grain_particles: [0.0; 3],
|
||||
grain_density_max: [2.0; 3],
|
||||
grain_uniformity: 1.0,
|
||||
},
|
||||
}));
|
||||
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_survives_being_taken_off_a_graph_and_put_back() {
|
||||
// The property every undo rests on. Checked over the whole state
|
||||
// rather than the values that were set, because the interesting
|
||||
// failure is not a value coming back wrong — it is a *kind* of value
|
||||
// that was never carried at all, and a narrower assertion is exactly
|
||||
// what missed the masks.
|
||||
let edited = thoroughly_edited();
|
||||
let state = edited.state();
|
||||
|
||||
let mut fresh = EditGraph::default_chain();
|
||||
let rebake = fresh.set_state(&state);
|
||||
|
||||
assert_eq!(
|
||||
rebake,
|
||||
FilmRebake::Wanted(FilmRef {
|
||||
stock: "kodak_portra_400".into(),
|
||||
print: Some("kodak_endura".into()),
|
||||
}),
|
||||
"the stock has to be asked for, since this crate cannot bake it"
|
||||
);
|
||||
|
||||
// The film is the one part `set_state` deliberately does not restore,
|
||||
// so it is put back the way a caller would before comparing.
|
||||
fresh.set_film(edited.film().cloned());
|
||||
|
||||
assert_eq!(
|
||||
fresh.state(),
|
||||
state,
|
||||
"something about the edit did not survive the round trip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn putting_a_state_back_replaces_rather_than_overlays() {
|
||||
// A part absent from the state means *default*, not "leave whatever
|
||||
// was there". Otherwise undoing to the floor would leave the last
|
||||
// mask standing, which is the shape of the bug this type exists for.
|
||||
let mut g = EditGraph::default_chain();
|
||||
let floor = g.state();
|
||||
|
||||
let edited = thoroughly_edited();
|
||||
assert!(g.set_state(&edited.state()).wanted().is_some());
|
||||
// A caller bakes at this point. Standing in for it here is what makes
|
||||
// the assertion below mean anything: without a film on the graph,
|
||||
// "the film was cleared" would be true of a graph that never had one.
|
||||
g.set_film(edited.film().cloned());
|
||||
assert!(g.film().is_some() && !g.masks().is_empty() && !g.spots().is_empty());
|
||||
|
||||
g.set_state(&floor).expect_no_film();
|
||||
|
||||
assert!(
|
||||
g.masks().is_empty(),
|
||||
"a layer outlived the state holding it"
|
||||
);
|
||||
assert!(g.film().is_none(), "the film outlived the state holding it");
|
||||
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
||||
assert!(g.crop().is_full(), "the crop outlived it: {:?}", g.crop());
|
||||
assert_eq!(g.state(), floor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neutral_state_is_a_decision_rather_than_a_missing_one() {
|
||||
assert!(EditGraph::default_chain().state().is_neutral());
|
||||
assert!(!thoroughly_edited().state().is_neutral());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! TRACES: FR-PLG-2
|
||||
//! The generated path and the interpreted path produce the same operation.
|
||||
//!
|
||||
//! # Why this test is the point
|
||||
//!
|
||||
//! FR-PLG-2 says a bundled operation and a third-party plugin are the same
|
||||
//! kind of thing, differing only in where the file was found. Two
|
||||
//! implementations sit behind that claim: `build.rs` compiles `ops/*.yaml`
|
||||
//! into Rust, and [`DeclaredOp`] interprets the same declaration at run time.
|
||||
//!
|
||||
//! **If the two ever disagree, the claim fails quietly.** A plugin would be a
|
||||
//! second-class kind of node — one whose colours come out fractionally
|
||||
//! different, or whose fragment lands in the shader with a different comment,
|
||||
//! or whose uniform arrives in a different slot — and nothing would say so.
|
||||
//! The photographer would see a look they could not reproduce with a built-in
|
||||
//! and would have no way to find out why.
|
||||
//!
|
||||
//! So this parses every built-in declaration at run time and asserts the
|
||||
//! composed WGSL is **byte for byte** what the generated implementation
|
||||
//! produces, with the uniform block bit for bit identical, at a spread of
|
||||
//! parameter values.
|
||||
//!
|
||||
//! # Byte-for-byte, and bit-for-bit, on purpose
|
||||
//!
|
||||
//! Not "equivalent", not "within an epsilon". A tolerance is where a real
|
||||
//! divergence hides: the arithmetic in a declaration is `f32` at both ends and
|
||||
//! there is no reason for a single bit to differ, so any difference at all is
|
||||
//! a bug in one of the two backends and should read as one. The one place
|
||||
//! this bites is number literals, which is why `expr::as_f32` rounds a decimal
|
||||
//! exactly once — see its own documentation.
|
||||
//!
|
||||
//! # What is not covered, and why that is honest
|
||||
//!
|
||||
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
|
||||
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture` — names a
|
||||
//! hand-written type and has no declaration to interpret. It is not skipped
|
||||
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
|
||||
//! `ops/` between them, so a node that stops being declared cannot quietly
|
||||
//! drop out of this file's coverage.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use dr_pipeline::declared::{builtin_helpers, decl, DeclaredOp, Node};
|
||||
use dr_pipeline::descriptor::ParamKind;
|
||||
use dr_pipeline::operation::{compose, ComposedShader, Operation};
|
||||
use dr_pipeline::ops;
|
||||
use dr_pipeline::ParamId;
|
||||
|
||||
/// The declarations this crate ships, read from disk rather than embedded.
|
||||
///
|
||||
/// From disk deliberately: `build.rs` reads these very files, so reading the
|
||||
/// same bytes is what makes the comparison a comparison of the two *readers*
|
||||
/// rather than of two snapshots that were taken at different times.
|
||||
fn ops_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("ops")
|
||||
}
|
||||
|
||||
/// Every `<id>.yaml` in `ops/`, keyed by id, excluding `_helpers.yaml`.
|
||||
fn declaration_files() -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
for entry in std::fs::read_dir(ops_dir()).expect("ops/ is readable") {
|
||||
let path = entry.expect("a directory entry").path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
|
||||
continue;
|
||||
}
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.expect("a file name")
|
||||
.to_string();
|
||||
if stem.starts_with('_') {
|
||||
continue;
|
||||
}
|
||||
out.insert(stem, std::fs::read_to_string(&path).expect("readable"));
|
||||
}
|
||||
assert!(!out.is_empty(), "ops/ declares no nodes");
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse one declaration the way both backends do.
|
||||
fn read(id: &str, text: &str) -> Node {
|
||||
let library = builtin_helpers();
|
||||
decl::read_node(text, &format!("ops/{id}.yaml"), &library.names())
|
||||
.unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}"))
|
||||
}
|
||||
|
||||
/// The generated implementation of one node, taken out of the default chain.
|
||||
///
|
||||
/// Out of `chain()` rather than constructed by name, because that is how the
|
||||
/// application gets one: if the two ever differed, the chain's version is the
|
||||
/// one a photograph would be developed with.
|
||||
fn generated(id: &str) -> Box<dyn Operation> {
|
||||
let chain = ops::chain();
|
||||
let index = chain
|
||||
.iter()
|
||||
.position(|o| o.descriptor().id.0 == id)
|
||||
.unwrap_or_else(|| panic!("`{id}` is not in the default chain"));
|
||||
let mut chain = chain;
|
||||
chain.remove(index)
|
||||
}
|
||||
|
||||
/// Values worth setting a parameter to, spanning its declared range.
|
||||
///
|
||||
/// Both ends, because that is where a rounding difference in a `min:` or a
|
||||
/// `max:` would show; the default, because that is the neutral every
|
||||
/// `is_active` is written against; and two interior points that are not
|
||||
/// round numbers, because a value like 37.5 exercises the arithmetic in a way
|
||||
/// 0 and 100 do not.
|
||||
fn probe_values(kind: &ParamKind, default: f32) -> Vec<f32> {
|
||||
match kind {
|
||||
ParamKind::Scalar { min, max, .. } => {
|
||||
let span = max - min;
|
||||
vec![
|
||||
default,
|
||||
*min,
|
||||
*max,
|
||||
min + span * 0.375,
|
||||
min + span * 0.8125,
|
||||
// A value that is not representable as a short decimal, to
|
||||
// catch a backend that round-trips a uniform through text.
|
||||
min + span / 3.0,
|
||||
]
|
||||
}
|
||||
ParamKind::Bool => vec![0.0, 1.0],
|
||||
ParamKind::Enum { variants } => (0..variants.len()).map(|i| i as f32).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Put every parameter back where it started.
|
||||
fn reset(op: &mut dyn Operation) {
|
||||
let descriptor = op.descriptor();
|
||||
for p in &descriptor.params {
|
||||
op.set_param(p.id, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert the two operations are indistinguishable at their current settings.
|
||||
fn assert_same(id: &str, setting: &str, generated: &dyn Operation, declared: &dyn Operation) {
|
||||
let g = generated.descriptor();
|
||||
let d = declared.descriptor();
|
||||
assert_eq!(*g, *d, "{id} [{setting}]: the descriptors differ");
|
||||
|
||||
assert_eq!(
|
||||
generated.is_active(),
|
||||
declared.is_active(),
|
||||
"{id} [{setting}]: the two disagree about whether the operation is doing anything"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
generated.wgsl_body(),
|
||||
declared.wgsl_body(),
|
||||
"{id} [{setting}]: the fragment bodies differ"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
generated.helpers(),
|
||||
declared.helpers(),
|
||||
"{id} [{setting}]: the helper sets differ"
|
||||
);
|
||||
|
||||
let (gu, du) = (generated.uniforms(), declared.uniforms());
|
||||
assert_eq!(
|
||||
gu.len(),
|
||||
du.len(),
|
||||
"{id} [{setting}]: different numbers of uniforms"
|
||||
);
|
||||
for (a, b) in gu.iter().zip(&du) {
|
||||
assert_eq!(
|
||||
a.name, b.name,
|
||||
"{id} [{setting}]: uniforms in a different order"
|
||||
);
|
||||
// Bits, not values: `assert_eq!` on `f32` would call two NaNs unequal
|
||||
// and would call `0.0` and `-0.0` equal, and both of those are
|
||||
// differences worth failing on.
|
||||
assert_eq!(
|
||||
a.value.to_bits(),
|
||||
b.value.to_bits(),
|
||||
"{id} [{setting}]: uniform `{}` is {} generated and {} declared",
|
||||
a.name,
|
||||
a.value,
|
||||
b.value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert two compositions are the same shader.
|
||||
fn assert_same_shader(what: &str, a: &ComposedShader, b: &ComposedShader) {
|
||||
// The source first, and compared as whole strings: a diff in the middle of
|
||||
// several kilobytes of WGSL is unreadable as an assertion message, so the
|
||||
// failure below points at the first differing line instead.
|
||||
if a.source != b.source {
|
||||
let line = a
|
||||
.source
|
||||
.lines()
|
||||
.zip(b.source.lines())
|
||||
.position(|(x, y)| x != y);
|
||||
match line {
|
||||
Some(n) => panic!(
|
||||
"{what}: the composed WGSL differs at line {}:\n generated: {:?}\n declared: {:?}",
|
||||
n + 1,
|
||||
a.source.lines().nth(n).unwrap_or(""),
|
||||
b.source.lines().nth(n).unwrap_or(""),
|
||||
),
|
||||
None => panic!(
|
||||
"{what}: the composed WGSL differs in length: {} generated, {} declared",
|
||||
a.source.len(),
|
||||
b.source.len()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
a.uniforms.len(),
|
||||
b.uniforms.len(),
|
||||
"{what}: different uniform block sizes"
|
||||
);
|
||||
for (i, (x, y)) in a.uniforms.iter().zip(&b.uniforms).enumerate() {
|
||||
assert_eq!(
|
||||
x.to_bits(),
|
||||
y.to_bits(),
|
||||
"{what}: uniform slot {i} is {x} generated and {y} declared"
|
||||
);
|
||||
}
|
||||
|
||||
// The hash is taken over the source, so it follows — but it is what the
|
||||
// pipeline cache keys on, and asserting it says that a declared node and
|
||||
// its generated twin would share a compiled pipeline rather than quietly
|
||||
// splitting the cache in two.
|
||||
assert_eq!(a.structure_hash, b.structure_hash, "{what}: structure hash");
|
||||
assert_eq!(a.output_mode, b.output_mode, "{what}: output mode");
|
||||
}
|
||||
|
||||
/// Compose a single operation, the way the display path composes a chain.
|
||||
fn compose_one(op: Box<dyn Operation>) -> (ComposedShader, Box<dyn Operation>) {
|
||||
let shader = compose(std::slice::from_ref(&op));
|
||||
(shader, op)
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLG-2
|
||||
/// Every declared built-in composes to the same shader either way.
|
||||
#[test]
|
||||
fn a_declaration_read_at_run_time_composes_byte_for_byte_as_the_generated_one() {
|
||||
let library = builtin_helpers();
|
||||
let mut checked = 0;
|
||||
|
||||
for (id, text) in declaration_files() {
|
||||
let Node::Declared(declaration) = read(&id, &text) else {
|
||||
continue;
|
||||
};
|
||||
let mut declared =
|
||||
DeclaredOp::new(&declaration, library).unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}"));
|
||||
let mut generated = generated(&id);
|
||||
|
||||
// The neutral state first: it is the state every image starts in, and
|
||||
// an operation that is inactive in one path and active in the other
|
||||
// would put a whole fragment into one shader and not the other.
|
||||
assert_same(&id, "neutral", generated.as_ref(), &declared);
|
||||
|
||||
let descriptor = declared.descriptor();
|
||||
for p in &descriptor.params {
|
||||
for value in probe_values(&p.kind, p.default) {
|
||||
reset(generated.as_mut());
|
||||
reset(&mut declared);
|
||||
generated.set_param(p.id, value);
|
||||
declared.set_param(p.id, value);
|
||||
|
||||
let setting = format!("{} = {value}", p.id);
|
||||
assert_same(&id, &setting, generated.as_ref(), &declared);
|
||||
|
||||
let (gs, back) = compose_one(generated);
|
||||
generated = back;
|
||||
let (ds, _) = compose_one(Box::new(declared.clone()));
|
||||
assert_same_shader(&format!("{id} [{setting}]"), &gs, &ds);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// And every parameter moved at once, which is the only case that
|
||||
// exercises the *order* uniforms are emitted in.
|
||||
reset(generated.as_mut());
|
||||
reset(&mut declared);
|
||||
for p in &descriptor.params {
|
||||
let value =
|
||||
probe_values(&p.kind, p.default)[3.min(probe_values(&p.kind, p.default).len() - 1)];
|
||||
generated.set_param(p.id, value);
|
||||
declared.set_param(p.id, value);
|
||||
}
|
||||
assert_same(&id, "all parameters moved", generated.as_ref(), &declared);
|
||||
let (gs, _) = compose_one(generated);
|
||||
let (ds, _) = compose_one(Box::new(declared));
|
||||
assert_same_shader(&format!("{id} [all parameters moved]"), &gs, &ds);
|
||||
checked += 1;
|
||||
}
|
||||
|
||||
// A test that silently checked nothing would pass forever. There are eight
|
||||
// declared nodes and several settings each, so this is a floor rather than
|
||||
// a count anybody has to maintain.
|
||||
assert!(
|
||||
checked > 20,
|
||||
"only {checked} comparisons ran; the declarations were not found"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLG-2
|
||||
/// The whole chain composes identically with the declared nodes swapped in.
|
||||
///
|
||||
/// The single-operation test above is the sharper one — it isolates each node
|
||||
/// — but it cannot see an interaction. This composes the *default develop
|
||||
/// chain*, with every declared node replaced by its interpreted twin and the
|
||||
/// `rust:` nodes left alone, so it covers uniform slot ordering across
|
||||
/// operations, helper de-duplication between them, and the order the fragments
|
||||
/// land in the shader.
|
||||
#[test]
|
||||
fn the_whole_chain_composes_identically_with_interpreted_nodes() {
|
||||
let library = builtin_helpers();
|
||||
let files = declaration_files();
|
||||
|
||||
let mut generated_chain = ops::chain();
|
||||
let mut declared_chain = ops::chain();
|
||||
let mut swapped = 0;
|
||||
|
||||
for i in 0..declared_chain.len() {
|
||||
let id = declared_chain[i].descriptor().id.0.to_string();
|
||||
let text = files
|
||||
.get(&id)
|
||||
.unwrap_or_else(|| panic!("`{id}` is in the chain but has no ops/{id}.yaml"));
|
||||
if let Node::Declared(declaration) = read(&id, text) {
|
||||
declared_chain[i] = Box::new(
|
||||
DeclaredOp::new(&declaration, library)
|
||||
.unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}")),
|
||||
);
|
||||
swapped += 1;
|
||||
}
|
||||
// Move every operation off neutral, declared or not, so the chain is
|
||||
// not a list of fragments that were all omitted. A neutral chain
|
||||
// composes to a shader with no operation blocks in it at all, which
|
||||
// would make this test pass while asserting nothing.
|
||||
let descriptor = generated_chain[i].descriptor();
|
||||
for p in &descriptor.params {
|
||||
let value = probe_values(&p.kind, p.default)[1];
|
||||
generated_chain[i].set_param(p.id, value);
|
||||
declared_chain[i].set_param(p.id, value);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
swapped >= 8,
|
||||
"only {swapped} nodes were swapped for declared ones"
|
||||
);
|
||||
assert_same_shader(
|
||||
"the default chain",
|
||||
&compose(&generated_chain),
|
||||
&compose(&declared_chain),
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLG-2
|
||||
/// Nothing in `ops/` escapes this file unnoticed.
|
||||
///
|
||||
/// The coverage guard. A declaration that stopped parsing, or a node that
|
||||
/// quietly became `rust:`, would otherwise reduce what the parity test covers
|
||||
/// without anything failing — which is exactly the silent divergence the whole
|
||||
/// file exists to prevent.
|
||||
#[test]
|
||||
fn every_declared_node_is_checked() {
|
||||
let files = declaration_files();
|
||||
let mut declared = Vec::new();
|
||||
let mut hand_written = Vec::new();
|
||||
|
||||
for (id, text) in &files {
|
||||
match read(id, text) {
|
||||
Node::Declared(_) => declared.push(id.clone()),
|
||||
Node::Rust { ty, .. } => hand_written.push((id.clone(), ty)),
|
||||
}
|
||||
}
|
||||
|
||||
// Every file is one or the other, and the chain holds exactly them.
|
||||
assert_eq!(declared.len() + hand_written.len(), files.len());
|
||||
assert_eq!(
|
||||
ops::DECLARED_IDS.len(),
|
||||
files.len(),
|
||||
"the chain and ops/ hold different numbers of nodes"
|
||||
);
|
||||
for id in ops::DECLARED_IDS {
|
||||
assert!(
|
||||
files.contains_key(*id),
|
||||
"`{id}` is in the chain but not in ops/"
|
||||
);
|
||||
}
|
||||
|
||||
// Named rather than counted, so that a node changing sides is a failure
|
||||
// somebody reads rather than a number they update.
|
||||
let hand: Vec<&str> = hand_written.iter().map(|(id, _)| id.as_str()).collect();
|
||||
assert_eq!(
|
||||
hand,
|
||||
[
|
||||
"capture_sharpen",
|
||||
"clarity",
|
||||
"colour_mixer",
|
||||
"film_sim",
|
||||
"noise_reduction",
|
||||
"texture",
|
||||
"tone_curve",
|
||||
],
|
||||
"the set of hand-written nodes changed; if that is deliberate, update \
|
||||
this list and the module documentation above"
|
||||
);
|
||||
assert!(
|
||||
declared.len() >= 8,
|
||||
"only {} declared nodes: {declared:?}",
|
||||
declared.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLG-2
|
||||
/// A declared operation is addressed by the ids the generated one uses.
|
||||
///
|
||||
/// The practical form of "indistinguishable downstream": the sidecar stores
|
||||
/// parameters by `(op_id, param_id)` text, so a declared node whose interned
|
||||
/// ids did not compare equal to the generated constants would load an edit
|
||||
/// that silently did nothing.
|
||||
#[test]
|
||||
fn an_interpreted_node_answers_to_the_generated_parameter_ids() {
|
||||
let mut declared = DeclaredOp::from_yaml(
|
||||
&std::fs::read_to_string(ops_dir().join("exposure.yaml")).expect("readable"),
|
||||
"ops/exposure.yaml",
|
||||
builtin_helpers(),
|
||||
)
|
||||
.expect("exposure is a declaration");
|
||||
|
||||
declared.set_param(ops::exposure::EXPOSURE, 1.5);
|
||||
assert_eq!(declared.param(ParamId("exposure")), 1.5);
|
||||
assert_eq!(declared.descriptor().id, ops::exposure::ID);
|
||||
}
|
||||
@@ -39,7 +39,8 @@ fn round_trip(graph: &EditGraph) -> EditGraph {
|
||||
.versions
|
||||
.get("default")
|
||||
.expect("version survived")
|
||||
.apply(&mut restored);
|
||||
.apply(&mut restored)
|
||||
.expect_no_film();
|
||||
restored
|
||||
}
|
||||
|
||||
@@ -239,7 +240,7 @@ fn applying_a_maskless_version_clears_existing_masks() {
|
||||
assert_eq!(graph.masks().len(), 1);
|
||||
|
||||
let plain = Version::from_graph("clean", "Clean", &EditGraph::default_chain());
|
||||
plain.apply(&mut graph);
|
||||
plain.apply(&mut graph).expect_no_film();
|
||||
assert!(graph.masks().is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! TRACES: FR-DEV-8 | FR-NC-9
|
||||
//! Repairs must survive the sidecar, and survive two devices.
|
||||
//!
|
||||
//! The sidecar is the authoritative store (ARCH §6.12), so a spot that does not
|
||||
//! round-trip is not a persistence bug but lost work — and a spot that
|
||||
//! round-trips *nearly* is worse than one that fails, because a disc copied
|
||||
//! from slightly the wrong place looks like a damaged file rather than like a
|
||||
//! feature that did not run.
|
||||
|
||||
use dr_pipeline::spot::{Spot, SpotMode, DEFAULT_RADIUS};
|
||||
use dr_pipeline::{EditGraph, Sidecar, Version};
|
||||
|
||||
fn spot_at(centre: (f32, f32), offset: (f32, f32)) -> Spot {
|
||||
Spot::new(centre, offset, DEFAULT_RADIUS)
|
||||
}
|
||||
|
||||
/// A graph carrying three repairs: the default kind, a clone, and one switched
|
||||
/// off — which between them cover every field the line format carries.
|
||||
fn graph_with_spots() -> EditGraph {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
|
||||
graph
|
||||
.spots_mut()
|
||||
.place(spot_at((0.25, 0.75), (0.08, -0.02)));
|
||||
|
||||
let mut cloned = spot_at((0.6, 0.4), (-0.12, 0.05));
|
||||
cloned.mode = SpotMode::Clone;
|
||||
cloned.set_feather(0.0);
|
||||
cloned.set_opacity(0.5);
|
||||
graph.spots_mut().place(cloned);
|
||||
|
||||
let mut off = spot_at((0.1, 0.1), (0.2, 0.2));
|
||||
off.enabled = false;
|
||||
graph.spots_mut().place(off);
|
||||
|
||||
graph
|
||||
}
|
||||
|
||||
fn round_trip(graph: &EditGraph) -> EditGraph {
|
||||
let mut sidecar = Sidecar::new();
|
||||
sidecar.put(Version::from_graph("default", "Default", graph));
|
||||
|
||||
let text = sidecar.to_text();
|
||||
let parsed = Sidecar::parse(&text).expect("reparse");
|
||||
|
||||
let mut restored = EditGraph::default_chain();
|
||||
parsed
|
||||
.versions
|
||||
.get("default")
|
||||
.expect("version survived")
|
||||
.apply(&mut restored)
|
||||
.expect_no_film();
|
||||
restored
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_of_every_repair_comes_back() {
|
||||
let graph = graph_with_spots();
|
||||
let restored = round_trip(&graph);
|
||||
|
||||
assert_eq!(
|
||||
restored.spots().spots(),
|
||||
graph.spots().spots(),
|
||||
"a repair is eight numbers and every one of them matters"
|
||||
);
|
||||
}
|
||||
|
||||
/// The order is the order they were made in, which decides which repair lands
|
||||
/// on top where two overlap and which of them may share a pass. Sorting by id
|
||||
/// on the way out would look tidier and would reorder the photograph.
|
||||
#[test]
|
||||
fn the_order_they_were_made_in_survives() {
|
||||
let graph = graph_with_spots();
|
||||
let ids: Vec<&str> = graph
|
||||
.spots()
|
||||
.spots()
|
||||
.iter()
|
||||
.map(|s| s.id.as_str())
|
||||
.collect();
|
||||
let restored = round_trip(&graph);
|
||||
let back: Vec<&str> = restored
|
||||
.spots()
|
||||
.spots()
|
||||
.iter()
|
||||
.map(|s| s.id.as_str())
|
||||
.collect();
|
||||
|
||||
assert_eq!(ids, back);
|
||||
}
|
||||
|
||||
/// What lets a caller skip an upload by comparing content: the same edit must
|
||||
/// produce the same bytes, or every save looks like a change to sync.
|
||||
#[test]
|
||||
fn writing_the_same_repairs_twice_is_byte_identical() {
|
||||
let graph = graph_with_spots();
|
||||
|
||||
let mut a = Sidecar::new();
|
||||
a.put(Version::from_graph("default", "Default", &graph));
|
||||
let mut b = Sidecar::new();
|
||||
b.put(Version::from_graph("default", "Default", &graph));
|
||||
|
||||
assert_eq!(a.to_text(), b.to_text());
|
||||
}
|
||||
|
||||
/// A frame nobody has repaired must not grow a line, in the same way an
|
||||
/// operation at neutral contributes nothing.
|
||||
#[test]
|
||||
fn an_unrepaired_frame_writes_no_spot_lines() {
|
||||
let mut sidecar = Sidecar::new();
|
||||
sidecar.put(Version::from_graph(
|
||||
"default",
|
||||
"Default",
|
||||
&EditGraph::default_chain(),
|
||||
));
|
||||
|
||||
assert!(!sidecar.to_text().contains("spot."));
|
||||
}
|
||||
|
||||
/// The file is meant to be readable by a human debugging an edit that went
|
||||
/// wrong, and hand-editable by one who knows what they are doing.
|
||||
#[test]
|
||||
fn a_hand_written_line_loads() {
|
||||
let text = "drsc 1\n\
|
||||
\n\
|
||||
[version default]\n\
|
||||
name = Default\n\
|
||||
default = 1\n\
|
||||
revision = 1\n\
|
||||
spot.abc123 = 0.4 0.6 0.02 0.5 0.1 -0.05 1 heal\n";
|
||||
|
||||
let sidecar = Sidecar::parse(text).expect("parse");
|
||||
let spots = &sidecar.versions["default"].spots;
|
||||
|
||||
assert_eq!(spots.len(), 1);
|
||||
let spot = &spots.spots()[0];
|
||||
assert_eq!(spot.id, "abc123");
|
||||
assert_eq!(spot.centre, (0.4, 0.6));
|
||||
assert_eq!(spot.radius, 0.02);
|
||||
assert_eq!(spot.feather, 0.5);
|
||||
assert_eq!(spot.offset, (0.1, -0.05));
|
||||
assert_eq!(spot.mode, SpotMode::Heal);
|
||||
assert!(spot.enabled);
|
||||
}
|
||||
|
||||
/// A truncated or hand-mangled line costs that repair and not the file. The
|
||||
/// alternative — refusing the version — throws away every other repair, the
|
||||
/// crop and the exposure over one bad line.
|
||||
#[test]
|
||||
fn a_malformed_line_costs_one_repair() {
|
||||
let text = "drsc 1\n\
|
||||
\n\
|
||||
[version default]\n\
|
||||
revision = 1\n\
|
||||
exposure.exposure = 0.75\n\
|
||||
spot.good11 = 0.4 0.6 0.02 0.5 0.1 -0.05 1 heal\n\
|
||||
spot.short1 = 0.4 0.6 0.02\n\
|
||||
spot.nomode = 0.4 0.6 0.02 0.5 0.1 -0.05 1 smudge\n\
|
||||
spot.notnum = x y 0.02 0.5 0.1 -0.05 1 heal\n";
|
||||
|
||||
let sidecar = Sidecar::parse(text).expect("parse");
|
||||
let version = &sidecar.versions["default"];
|
||||
|
||||
assert_eq!(version.spots.len(), 1);
|
||||
assert_eq!(version.spots.spots()[0].id, "good11");
|
||||
assert_eq!(
|
||||
version.params.get(&("exposure".into(), "exposure".into())),
|
||||
Some(&0.75),
|
||||
"the rest of the edit still loaded"
|
||||
);
|
||||
}
|
||||
|
||||
/// A field a newer build added must not cost the repair. The disc is complete
|
||||
/// without it, and refusing would be a device running behind deleting work it
|
||||
/// merely does not understand.
|
||||
#[test]
|
||||
fn a_trailing_field_from_a_newer_build_is_ignored_not_refused() {
|
||||
let text = "drsc 1\n\
|
||||
\n\
|
||||
[version default]\n\
|
||||
revision = 1\n\
|
||||
spot.abc123 = 0.4 0.6 0.02 0.5 0.1 -0.05 1 heal rotation=0.5\n";
|
||||
|
||||
let sidecar = Sidecar::parse(text).expect("parse");
|
||||
assert_eq!(sidecar.versions["default"].spots.len(), 1);
|
||||
}
|
||||
|
||||
/// A spot switched off is state the photographer set, not an absence.
|
||||
#[test]
|
||||
fn a_disabled_repair_stays_disabled() {
|
||||
let graph = graph_with_spots();
|
||||
let restored = round_trip(&graph);
|
||||
let off = restored.spots().spots().last().expect("three repairs");
|
||||
|
||||
assert!(!off.enabled);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync merge (FR-NC-9)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn version_with(uuid: &str, revision: u64, build: impl FnOnce(&mut EditGraph)) -> Version {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
build(&mut graph);
|
||||
let mut v = Version::from_graph(uuid, "Default", &graph);
|
||||
v.revision = revision;
|
||||
v
|
||||
}
|
||||
|
||||
/// The case the id derivation exists for. Two devices, offline, each removing a
|
||||
/// different mark: with counted ids both would be `spot3` and one would be
|
||||
/// lost here without a word.
|
||||
#[test]
|
||||
fn repairs_from_two_devices_both_survive() {
|
||||
let base = version_with("default", 1, |_| {});
|
||||
|
||||
let mut ours = version_with("default", 2, |g| {
|
||||
g.spots_mut().place(spot_at((0.2, 0.2), (0.05, 0.0)));
|
||||
});
|
||||
let theirs = version_with("default", 2, |g| {
|
||||
g.spots_mut().place(spot_at((0.8, 0.8), (-0.05, 0.0)));
|
||||
});
|
||||
|
||||
let conflicts = ours.merge(&theirs, Some(&base));
|
||||
|
||||
assert!(conflicts.is_empty(), "different marks are not a conflict");
|
||||
assert_eq!(ours.spots.len(), 2);
|
||||
}
|
||||
|
||||
/// And the other half of that: two devices that removed *the same* piece of
|
||||
/// dust agree on the id, so the merge sees one repair — which is right, because
|
||||
/// it is one repair, and the alternative is the same disc drawn twice.
|
||||
#[test]
|
||||
fn the_same_mark_removed_on_both_devices_stays_one_repair() {
|
||||
let base = version_with("default", 1, |_| {});
|
||||
let mut ours = version_with("default", 2, |g| {
|
||||
g.spots_mut().place(spot_at((0.5, 0.5), (0.06, 0.0)));
|
||||
});
|
||||
let theirs = version_with("default", 3, |g| {
|
||||
g.spots_mut().place(spot_at((0.5, 0.5), (0.06, 0.0)));
|
||||
});
|
||||
|
||||
let conflicts = ours.merge(&theirs, Some(&base));
|
||||
|
||||
assert!(
|
||||
conflicts.is_empty(),
|
||||
"the same repair is not a disagreement"
|
||||
);
|
||||
assert_eq!(ours.spots.len(), 1);
|
||||
}
|
||||
|
||||
/// Both devices dragging one repair's source is genuinely ambiguous: the two
|
||||
/// offsets cannot be averaged into a third that either photographer wanted, so
|
||||
/// the higher revision takes the spot whole.
|
||||
#[test]
|
||||
fn both_moving_one_repair_is_a_conflict_resolved_by_revision() {
|
||||
let placed = spot_at((0.5, 0.5), (0.06, 0.0));
|
||||
let id = placed.id.clone();
|
||||
|
||||
let base = version_with("default", 1, |g| {
|
||||
g.spots_mut().place(placed.clone());
|
||||
});
|
||||
let mut ours = version_with("default", 2, |g| {
|
||||
g.spots_mut().place(placed.clone());
|
||||
g.spots_mut().get_mut(&id).unwrap().set_offset((0.2, 0.0));
|
||||
});
|
||||
let theirs = version_with("default", 5, |g| {
|
||||
g.spots_mut().place(placed.clone());
|
||||
g.spots_mut().get_mut(&id).unwrap().set_offset((0.0, -0.2));
|
||||
});
|
||||
|
||||
let conflicts = ours.merge(&theirs, Some(&base));
|
||||
|
||||
assert_eq!(conflicts, vec![("spot".to_string(), id.clone())]);
|
||||
assert_eq!(
|
||||
ours.spots.get(&id).map(|s| s.offset),
|
||||
Some((0.0, -0.2)),
|
||||
"the higher revision wins the repair whole"
|
||||
);
|
||||
}
|
||||
|
||||
/// A repair deleted on one device and untouched on the other stays deleted —
|
||||
/// the disjoint case again, in the direction that is easy to get backwards.
|
||||
#[test]
|
||||
fn a_deletion_propagates() {
|
||||
let placed = spot_at((0.5, 0.5), (0.06, 0.0));
|
||||
let id = placed.id.clone();
|
||||
|
||||
let base = version_with("default", 1, |g| {
|
||||
g.spots_mut().place(placed.clone());
|
||||
});
|
||||
let mut ours = version_with("default", 2, |g| {
|
||||
g.spots_mut().place(placed.clone());
|
||||
});
|
||||
let theirs = version_with("default", 3, |_| {});
|
||||
|
||||
assert!(ours.merge(&theirs, Some(&base)).is_empty());
|
||||
assert!(ours.spots.get(&id).is_none());
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! TRACES: FR-DEV-8
|
||||
//! The spot model: identity, bounds, and which repairs may share a pass.
|
||||
//!
|
||||
//! Everything here is arithmetic and bookkeeping, which is exactly why it is
|
||||
//! tested without a device: the three ways this model can be wrong — an id that
|
||||
//! is not stable, a bound that drops work silently, a grouping that lets a
|
||||
//! source read a destination — all produce a *picture* that is subtly wrong and
|
||||
//! no error anywhere.
|
||||
|
||||
use dr_pipeline::spot::{
|
||||
Spot, SpotMode, SpotSet, DEFAULT_RADIUS, MAX_SOURCE_DISTANCE, MAX_SPOTS, MIN_RADIUS,
|
||||
};
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
/// A 3:2 frame, which is the shape that catches a unit confusion. On a square
|
||||
/// one every wrong answer happens to be right.
|
||||
const ASPECT: f32 = 1.5;
|
||||
|
||||
fn spot_at(centre: (f32, f32), offset: (f32, f32)) -> Spot {
|
||||
Spot::new(centre, offset, DEFAULT_RADIUS)
|
||||
}
|
||||
|
||||
/// Two devices that remove the same piece of dust must agree on its id, or the
|
||||
/// sidecar merge treats one repair as two and both survive — a spot drawn
|
||||
/// twice, which is visible.
|
||||
#[test]
|
||||
fn the_same_placement_mints_the_same_id() {
|
||||
let a = spot_at((0.25, 0.75), (0.05, 0.0));
|
||||
let b = spot_at((0.25, 0.75), (-0.02, 0.03));
|
||||
assert_eq!(a.id, b.id, "the id is the position, not the whole spot");
|
||||
|
||||
let elsewhere = spot_at((0.26, 0.75), (0.05, 0.0));
|
||||
assert_ne!(a.id, elsewhere.id);
|
||||
}
|
||||
|
||||
/// And the id must not move when the repair does: dragging a spot is an edit to
|
||||
/// a spot, not the deletion of one and the creation of another. If the id
|
||||
/// followed the centre, a drag on one device and a radius change on the other
|
||||
/// would merge as two unrelated spots.
|
||||
#[test]
|
||||
fn dragging_a_spot_keeps_its_id() {
|
||||
let mut spot = spot_at((0.25, 0.75), (0.05, 0.0));
|
||||
let id = spot.id.clone();
|
||||
spot.set_centre((0.9, 0.1));
|
||||
spot.set_offset((0.1, 0.1));
|
||||
spot.set_radius(0.2);
|
||||
assert_eq!(spot.id, id);
|
||||
}
|
||||
|
||||
/// Two spots placed on the same point are still two repairs, and a set that
|
||||
/// held them both under one id would lose one of them at the next save.
|
||||
#[test]
|
||||
fn a_second_spot_on_the_same_point_gets_its_own_id() {
|
||||
let mut set = SpotSet::new();
|
||||
let first = set.place(spot_at((0.5, 0.5), (0.05, 0.0))).unwrap();
|
||||
let second = set.place(spot_at((0.5, 0.5), (0.0, 0.05))).unwrap();
|
||||
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(set.len(), 2);
|
||||
assert!(set.get(&first).is_some() && set.get(&second).is_some());
|
||||
}
|
||||
|
||||
/// The bound refuses rather than dropping. A set that quietly discarded the
|
||||
/// oldest repair would remove work already on screen, with nothing said.
|
||||
#[test]
|
||||
fn the_limit_refuses_and_keeps_what_is_there() {
|
||||
let mut set = SpotSet::new();
|
||||
for i in 0..MAX_SPOTS {
|
||||
let y = i as f32 / MAX_SPOTS as f32;
|
||||
assert!(set.place(spot_at((0.5, y), (0.05, 0.0))).is_some());
|
||||
}
|
||||
let first = set.spots()[0].clone();
|
||||
|
||||
assert!(set.place(spot_at((0.1, 0.1), (0.05, 0.0))).is_none());
|
||||
assert_eq!(set.len(), MAX_SPOTS);
|
||||
assert_eq!(
|
||||
set.spots()[0],
|
||||
first,
|
||||
"the oldest repair survives the refusal"
|
||||
);
|
||||
}
|
||||
|
||||
/// The offset bound is what keeps the detail pass's halo finite, so it has to
|
||||
/// hold along the diagonal and not merely per axis — and it must keep the
|
||||
/// direction the photographer dragged in.
|
||||
#[test]
|
||||
fn a_source_dragged_too_far_stops_in_the_direction_it_was_going() {
|
||||
let spot = spot_at((0.5, 0.5), (3.0, 4.0));
|
||||
let distance = spot.distance();
|
||||
assert!(
|
||||
(distance - MAX_SOURCE_DISTANCE).abs() < 1e-3,
|
||||
"clamped to the bound, got {distance}"
|
||||
);
|
||||
// 3:4 in, 3:4 out.
|
||||
assert!((spot.offset.0 / spot.offset.1 - 0.75).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radius_cannot_be_dragged_to_nothing() {
|
||||
let mut spot = spot_at((0.5, 0.5), (0.05, 0.0));
|
||||
spot.set_radius(0.0);
|
||||
assert!(spot.radius >= MIN_RADIUS);
|
||||
}
|
||||
|
||||
/// The offset is in frame units and the source is in normalised ones, and the
|
||||
/// aspect goes on exactly one of the two axes. Getting this backwards puts the
|
||||
/// source somewhere the photographer did not drag it, by a third of the frame
|
||||
/// on a 3:2 — visible, and easy to write.
|
||||
#[test]
|
||||
fn the_source_converts_frame_units_to_normalised_ones() {
|
||||
let spot = spot_at((0.5, 0.5), (0.15, 0.15));
|
||||
let (sx, sy) = spot.source(ASPECT);
|
||||
|
||||
assert!((sx - (0.5 + 0.15 / ASPECT)).abs() < 1e-4);
|
||||
assert!((sy - 0.65).abs() < 1e-4);
|
||||
|
||||
// The displacement is equal on both axes in frame units, so it must be
|
||||
// *unequal* in normalised ones on a frame that is not square.
|
||||
assert!((sx - 0.5) < (sy - 0.5));
|
||||
}
|
||||
|
||||
/// A spot with no source reads the pixel it writes: the identity, at the cost
|
||||
/// of a dispatch. A freshly placed spot is in that state until a source is
|
||||
/// found for it, which is why the question is asked per spot.
|
||||
#[test]
|
||||
fn a_spot_with_no_offset_draws_nothing() {
|
||||
let mut set = SpotSet::new();
|
||||
let id = set.place(spot_at((0.5, 0.5), (0.0, 0.0))).unwrap();
|
||||
assert!(set.is_neutral());
|
||||
assert_eq!(set.rounds(ASPECT).len(), 0);
|
||||
|
||||
set.get_mut(&id).unwrap().set_offset((0.08, 0.0));
|
||||
assert!(!set.is_neutral());
|
||||
assert_eq!(set.rounds(ASPECT), vec![vec![0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_spot_draws_nothing_but_is_kept() {
|
||||
let mut set = SpotSet::new();
|
||||
let id = set.place(spot_at((0.5, 0.5), (0.08, 0.0))).unwrap();
|
||||
set.get_mut(&id).unwrap().enabled = false;
|
||||
|
||||
assert!(set.is_neutral());
|
||||
assert_eq!(set.len(), 1, "disabling is not deleting");
|
||||
}
|
||||
|
||||
/// Spots scattered over a sky with their sources beside them are the
|
||||
/// overwhelming majority, and they must cost one dispatch.
|
||||
#[test]
|
||||
fn repairs_that_do_not_interfere_share_one_pass() {
|
||||
let mut set = SpotSet::new();
|
||||
for i in 0..8 {
|
||||
let y = 0.1 + 0.1 * i as f32;
|
||||
set.place(spot_at((0.5, y), (0.04, 0.0)));
|
||||
}
|
||||
assert_eq!(set.rounds(ASPECT), vec![(0..8).collect::<Vec<_>>()]);
|
||||
}
|
||||
|
||||
/// The case the grouping exists for: the second spot reads from where the first
|
||||
/// one is repairing. In one pass it would copy the mark the first spot is
|
||||
/// removing, and the mark would reappear somewhere else in the frame.
|
||||
#[test]
|
||||
fn a_source_over_an_earlier_repair_opens_a_new_pass() {
|
||||
let mut set = SpotSet::new();
|
||||
// Repairs (0.30, 0.50) from (0.40, 0.50) — both in frame units on x.
|
||||
set.place(spot_at((0.2, 0.5), (0.1, 0.0)));
|
||||
// Repairs (0.60, 0.50) by reading (0.30, 0.50): exactly the first
|
||||
// destination.
|
||||
set.place(spot_at((0.4, 0.5), (-0.3, 0.0)));
|
||||
|
||||
assert_eq!(set.rounds(ASPECT), vec![vec![0], vec![1]]);
|
||||
}
|
||||
|
||||
/// Two repairs landing on top of each other is not a hazard — they write the
|
||||
/// same output and the later one lands on top, which is the order they were
|
||||
/// made in. Splitting a pass for it would cost a dispatch for nothing.
|
||||
#[test]
|
||||
fn overlapping_destinations_stay_in_one_pass() {
|
||||
let mut set = SpotSet::new();
|
||||
set.place(spot_at((0.5, 0.5), (0.2, 0.0)));
|
||||
set.place(spot_at((0.505, 0.5), (0.2, 0.05)));
|
||||
|
||||
assert_eq!(set.rounds(ASPECT).len(), 1);
|
||||
}
|
||||
|
||||
/// A spot is an edit like any other, so a graph holding one is not clean — and
|
||||
/// a graph whose only spot draws nothing is.
|
||||
#[test]
|
||||
fn a_placed_repair_makes_the_graph_dirty() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
assert!(graph.is_neutral());
|
||||
|
||||
graph.spots_mut().place(spot_at((0.5, 0.5), (0.0, 0.0)));
|
||||
assert!(graph.is_neutral(), "a spot with no source is not an edit");
|
||||
|
||||
graph.spots_mut().place(spot_at((0.2, 0.2), (0.08, 0.0)));
|
||||
assert!(!graph.is_neutral());
|
||||
}
|
||||
|
||||
/// Moving a spot must re-run the neighbourhood passes and nothing before them.
|
||||
/// If it moved the colour key, dragging a spot would re-run the fused dispatch
|
||||
/// and every mask on the frame with it (FR-DEV-3d).
|
||||
#[test]
|
||||
fn a_repair_moves_the_detail_key_alone() {
|
||||
use dr_pipeline::Affects;
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let before = graph.invalidation();
|
||||
|
||||
let id = graph
|
||||
.spots_mut()
|
||||
.place(spot_at((0.5, 0.5), (0.08, 0.0)))
|
||||
.unwrap();
|
||||
let after = graph.invalidation();
|
||||
|
||||
assert_eq!(
|
||||
before.through(Affects::Colour),
|
||||
after.through(Affects::Colour),
|
||||
"a repair is not a colour change"
|
||||
);
|
||||
assert_ne!(
|
||||
before.through(Affects::Detail),
|
||||
after.through(Affects::Detail)
|
||||
);
|
||||
|
||||
// And a change *to* a spot moves it again.
|
||||
let placed = graph.invalidation();
|
||||
graph.spots_mut().get_mut(&id).unwrap().set_radius(0.05);
|
||||
assert_ne!(
|
||||
placed.through(Affects::Detail),
|
||||
graph.invalidation().through(Affects::Detail)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_survive_their_own_names() {
|
||||
for mode in [SpotMode::Heal, SpotMode::Clone] {
|
||||
assert_eq!(SpotMode::from_name(mode.name()), Some(mode));
|
||||
}
|
||||
assert_eq!(SpotMode::from_name("smudge"), None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Undo (FR-DEV-5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The press a photographer reaches for first: place a repair, dislike it,
|
||||
/// take it back. Before the history carried the spot set this stepped some
|
||||
/// unrelated slider and left the repair on the photograph, which reads as undo
|
||||
/// being broken rather than absent.
|
||||
#[test]
|
||||
fn undo_takes_a_repair_back() {
|
||||
use dr_pipeline::history::{Edit, History};
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let mut history = History::new(&graph);
|
||||
|
||||
graph.spots_mut().place(spot_at((0.5, 0.5), (0.08, 0.0)));
|
||||
assert!(history.record(
|
||||
&graph,
|
||||
Edit::Action(dr_pipeline::LocalizedKey("history.spot"))
|
||||
));
|
||||
assert_eq!(graph.spots().len(), 1);
|
||||
|
||||
assert!(history.undo(&mut graph).moved());
|
||||
assert_eq!(graph.spots().len(), 0, "the repair is still on the frame");
|
||||
|
||||
assert!(history.redo(&mut graph).moved());
|
||||
assert_eq!(graph.spots().len(), 1, "and redo could not put it back");
|
||||
}
|
||||
|
||||
/// Moving a repair is undoable too, and separately from placing it: the two
|
||||
/// are different decisions and a photographer who nudges a source expects one
|
||||
/// press to return it, not to lose the spot entirely.
|
||||
#[test]
|
||||
fn undo_steps_back_through_a_moved_source() {
|
||||
use dr_pipeline::history::{Edit, History};
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let id = graph
|
||||
.spots_mut()
|
||||
.place(spot_at((0.5, 0.5), (0.08, 0.0)))
|
||||
.unwrap();
|
||||
let mut history = History::new(&graph);
|
||||
|
||||
graph
|
||||
.spots_mut()
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.set_offset((0.2, 0.1));
|
||||
history.record(
|
||||
&graph,
|
||||
Edit::Action(dr_pipeline::LocalizedKey("history.spot")),
|
||||
);
|
||||
|
||||
assert!(history.undo(&mut graph).moved());
|
||||
assert_eq!(
|
||||
graph.spots().get(&id).map(|s| s.offset),
|
||||
Some((0.08, 0.0)),
|
||||
"the source did not go back where it was"
|
||||
);
|
||||
assert_eq!(graph.spots().len(), 1, "and the repair itself survived");
|
||||
}
|
||||
@@ -26,7 +26,8 @@ fn apply(text: &str) -> EditGraph {
|
||||
sidecar
|
||||
.default_version()
|
||||
.expect("a default version")
|
||||
.apply(&mut graph);
|
||||
.apply(&mut graph)
|
||||
.expect_no_film();
|
||||
graph
|
||||
}
|
||||
|
||||
|
||||
@@ -637,11 +637,23 @@ pub fn http_client(user_agent: &str) -> Result<reqwest::Client, RemoteError> {
|
||||
// webpki-roots set is still installed alongside.
|
||||
.tls_certs_only(extra_roots())
|
||||
// A request that hangs forever is indistinguishable from a worker that
|
||||
// died, and cost a long time to tell apart once. Connect and total
|
||||
// timeouts turn that into an error the UI can show. Generous enough for
|
||||
// a slow phone on mobile data; the login poll has its own deadline.
|
||||
// died, and cost a long time to tell apart once. These turn that into
|
||||
// an error the UI can show.
|
||||
.connect_timeout(std::time::Duration::from_secs(15))
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
// **Inactivity, not duration.** This was a 60-second *total* timeout,
|
||||
// which is not a hang detector at all — it is a floor on link speed.
|
||||
// A face shard runs to 25 MB, so it demanded a sustained 425 KB/s or
|
||||
// the transfer failed; and having failed it was retried on the next
|
||||
// pass, and failed again, for ever. A tablet on ordinary wifi could
|
||||
// therefore never finish adopting a library's faces, and nothing said
|
||||
// why: each attempt looked like a network blip rather than an
|
||||
// arithmetic impossibility.
|
||||
//
|
||||
// `read_timeout` fires when *no bytes arrive* for the given period,
|
||||
// which is the condition actually worth failing on. A slow transfer
|
||||
// that is still moving now finishes, however long it takes, while a
|
||||
// connection that has genuinely died is still caught in a minute.
|
||||
.read_timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(|e| RemoteError::Network(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -39,6 +39,22 @@ pub struct Chromaticities {
|
||||
pub white: [f64; 2],
|
||||
}
|
||||
|
||||
impl Chromaticities {
|
||||
/// This gamut's linear RGB to the ICC profile connection space, row-major.
|
||||
///
|
||||
/// The same three columns [`ColourSpace::to_pcs_xyz`] produces, for a set
|
||||
/// of primaries that is *not* one of the four the pipeline knows. That is
|
||||
/// the only reason this is public: a display's profile describes primaries
|
||||
/// nobody chose, and the only way to ask which of the four it is nearest
|
||||
/// is to put both through the same reduction and compare the numbers
|
||||
/// (see `dr_plat::display`). Comparing raw xy pairs instead would be
|
||||
/// wrong, because a profile's colorants have already been adapted to D50
|
||||
/// and a space's published chromaticities have not.
|
||||
pub fn to_pcs_xyz(&self) -> [f32; 9] {
|
||||
narrow(mul(adaptation(white_xyz(self), PCS_D50), rgb_to_xyz(self)))
|
||||
}
|
||||
}
|
||||
|
||||
/// How a space maps linear light onto the numbers stored in a file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Transfer {
|
||||
@@ -183,8 +199,7 @@ impl ColourSpace {
|
||||
/// defined at D50 and nowhere else — a profile carrying unadapted D65
|
||||
/// colorants describes a space nobody asked for.
|
||||
pub fn to_pcs_xyz(self) -> [f32; 9] {
|
||||
let c = self.chromaticities();
|
||||
narrow(mul(adaptation(white_xyz(&c), PCS_D50), rgb_to_xyz(&c)))
|
||||
self.chromaticities().to_pcs_xyz()
|
||||
}
|
||||
|
||||
/// The white-point adaptation folded into [`Self::to_pcs_xyz`], row-major.
|
||||
|
||||
@@ -441,6 +441,214 @@ impl Orientation {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3h
|
||||
/// A rectangle in the image **as stored** — the sensor's own scanline order.
|
||||
///
|
||||
/// Normalised: fractions of the stored image, origin top-left.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct StoredRect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3h
|
||||
/// A rectangle in the image **as shown** — the photograph the right way up.
|
||||
///
|
||||
/// Normalised, like [`StoredRect`], and deliberately a different type. The two
|
||||
/// are the same four numbers and mean different things, which is precisely why
|
||||
/// mixing them up is silent: a stored rect used against shown dimensions
|
||||
/// produces a plausible rectangle in the wrong place. See [`Orientation`]'s
|
||||
/// module note on why nothing here is named "clockwise".
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ShownRect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3h
|
||||
/// Moving pixels, rectangles and points between the stored image and the shown
|
||||
/// one.
|
||||
///
|
||||
/// # Why these are named after spaces and not after rotations
|
||||
///
|
||||
/// Every orientation bug this codebase has had was somebody applying a turn
|
||||
/// the correct size in the wrong direction — and a quarter turn applied
|
||||
/// backwards lands 180° from right, which looks like a deliberate transform
|
||||
/// rather than a mistake. "Rotate 90° clockwise" cannot be checked by reading
|
||||
/// it, because the reader has to hold in their head which image is being
|
||||
/// rotated and which way the y axis points.
|
||||
///
|
||||
/// So no function below says clockwise, anticlockwise, horizontal or vertical.
|
||||
/// They say **which space they take and which space they return**, the two
|
||||
/// spaces are different Rust types where a mistake would otherwise be silent,
|
||||
/// and the direction is then something the compiler checks rather than
|
||||
/// something the author remembers.
|
||||
///
|
||||
/// The one primitive underneath all of it is
|
||||
/// [`Orientation::source_pixel`] — the backward map the shader prologue and
|
||||
/// the thumbnail path already share. Everything here is that map read forwards
|
||||
/// or backwards, so there is one permutation in the codebase and no second
|
||||
/// opinion to drift.
|
||||
impl Orientation {
|
||||
/// The transform that puts a shown image back into stored order.
|
||||
///
|
||||
/// Not simply `4 - turns`: the mirrors are applied *after* the turn, so
|
||||
/// undoing means undoing them first, and a mirror seen from the far side
|
||||
/// of a turn may be about the other axis. Getting this wrong is the
|
||||
/// diagonal-mirror case (tags 5 and 7), which renders as a 180° error.
|
||||
pub fn inverse(self) -> Self {
|
||||
// Mirrors are involutions, so the inverse's mirrors are the same two;
|
||||
// what changes is the turn they are read against.
|
||||
let (flip_h, flip_v) = if self.quarter_turns % 2 == 1 {
|
||||
(self.flip_v, self.flip_h)
|
||||
} else {
|
||||
(self.flip_h, self.flip_v)
|
||||
};
|
||||
Self {
|
||||
quarter_turns: (4 - self.quarter_turns) % 4,
|
||||
flip_h,
|
||||
flip_v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a **stored** pixel lands in the **shown** image.
|
||||
///
|
||||
/// [`Self::source_pixel`] run the other way, and stated the same way:
|
||||
/// each takes the dimensions of the space it *reads from*. So this one is
|
||||
/// handed the stored size and `source_pixel` is handed the shown size,
|
||||
/// and neither caller has to work out which pair it is holding.
|
||||
pub fn shown_pixel(self, sx: u32, sy: u32, width: u32, height: u32) -> (u32, u32) {
|
||||
self.inverse().source_pixel(sx, sy, width, height)
|
||||
}
|
||||
|
||||
/// Read a **stored** buffer into **shown** order.
|
||||
///
|
||||
/// `channels` values per pixel, tightly packed. Generic because the same
|
||||
/// permutation serves an RGB proxy of floats, an RGBA overlay of bytes and
|
||||
/// a single-channel mask, and three hand-written copies of one loop is how
|
||||
/// two of them come to disagree.
|
||||
///
|
||||
/// Exact. A quarter turn and its mirrors are a permutation of the pixel
|
||||
/// grid, so nothing is filtered, nothing is resampled, and the round trip
|
||||
/// through [`Self::into_stored`] returns what went in.
|
||||
pub fn into_shown<T: Copy + Default>(
|
||||
self,
|
||||
stored: &[T],
|
||||
width: u32,
|
||||
height: u32,
|
||||
channels: usize,
|
||||
) -> (Vec<T>, u32, u32) {
|
||||
if self.is_normal() || width == 0 || height == 0 {
|
||||
return (stored.to_vec(), width, height);
|
||||
}
|
||||
let (dw, dh) = self.oriented_size(width, height);
|
||||
let mut out = vec![T::default(); (dw as usize) * (dh as usize) * channels];
|
||||
for y in 0..dh {
|
||||
for x in 0..dw {
|
||||
let (sx, sy) = self.source_pixel(x, y, dw, dh);
|
||||
let s = (sy as usize * width as usize + sx as usize) * channels;
|
||||
let d = (y as usize * dw as usize + x as usize) * channels;
|
||||
out[d..d + channels].copy_from_slice(&stored[s..s + channels]);
|
||||
}
|
||||
}
|
||||
(out, dw, dh)
|
||||
}
|
||||
|
||||
/// Read a **shown** buffer back into **stored** order.
|
||||
///
|
||||
/// `(dw, dh)` are the shown dimensions. The exact inverse of
|
||||
/// [`Self::into_shown`], and written as the same map rather than as a
|
||||
/// second one: a bijection read as a scatter fills every stored pixel once
|
||||
/// and leaves no hole.
|
||||
pub fn into_stored<T: Copy + Default>(
|
||||
self,
|
||||
shown: &[T],
|
||||
dw: u32,
|
||||
dh: u32,
|
||||
channels: usize,
|
||||
) -> (Vec<T>, u32, u32) {
|
||||
if self.is_normal() || dw == 0 || dh == 0 {
|
||||
return (shown.to_vec(), dw, dh);
|
||||
}
|
||||
let (sw, sh) = self.oriented_size(dw, dh);
|
||||
let mut out = vec![T::default(); (sw as usize) * (sh as usize) * channels];
|
||||
for y in 0..dh {
|
||||
for x in 0..dw {
|
||||
let (sx, sy) = self.source_pixel(x, y, dw, dh);
|
||||
let s = (y as usize * dw as usize + x as usize) * channels;
|
||||
let d = (sy as usize * sw as usize + sx as usize) * channels;
|
||||
out[d..d + channels].copy_from_slice(&shown[s..s + channels]);
|
||||
}
|
||||
}
|
||||
(out, sw, sh)
|
||||
}
|
||||
|
||||
/// [`Self::source_pixel`] in normalised continuous coordinates.
|
||||
///
|
||||
/// The pixel map says `dw - 1 - x` where this says `1 - x`, because a
|
||||
/// pixel *centre* at `x + 0.5` has to land at `dw - x - 0.5`. Everything
|
||||
/// else is the same transform in the same order — turn first, mirrors
|
||||
/// after, in the stored image's own axes — and it is written next to its
|
||||
/// integer twin so the two cannot drift apart unnoticed.
|
||||
fn source_point(self, x: f32, y: f32) -> (f32, f32) {
|
||||
let (mut sx, mut sy) = match self.quarter_turns {
|
||||
1 => (y, 1.0 - x),
|
||||
2 => (1.0 - x, 1.0 - y),
|
||||
3 => (1.0 - y, x),
|
||||
_ => (x, y),
|
||||
};
|
||||
if self.flip_h {
|
||||
sx = 1.0 - sx;
|
||||
}
|
||||
if self.flip_v {
|
||||
sy = 1.0 - sy;
|
||||
}
|
||||
(sx, sy)
|
||||
}
|
||||
|
||||
/// A normalised rectangle, **stored** to **shown**.
|
||||
///
|
||||
/// Through the *inverse* map, because [`Self::source_point`] runs shown to
|
||||
/// stored — and that asymmetry is written once, here, rather than being
|
||||
/// rediscovered at each call site. The corners are mapped and the extremes
|
||||
/// taken afterwards, since a turn exchanges which corner is which and a
|
||||
/// rectangle has to keep its width positive.
|
||||
pub fn into_shown_rect(self, r: StoredRect) -> ShownRect {
|
||||
let [x, y, x1, y1] = self
|
||||
.inverse()
|
||||
.corners([r.x, r.y, r.x + r.width, r.y + r.height]);
|
||||
ShownRect {
|
||||
x,
|
||||
y,
|
||||
width: x1 - x,
|
||||
height: y1 - y,
|
||||
}
|
||||
}
|
||||
|
||||
/// A normalised rectangle, **shown** to **stored**.
|
||||
pub fn into_stored_rect(self, r: ShownRect) -> StoredRect {
|
||||
let [x, y, x1, y1] = self.corners([r.x, r.y, r.x + r.width, r.y + r.height]);
|
||||
StoredRect {
|
||||
x,
|
||||
y,
|
||||
width: x1 - x,
|
||||
height: y1 - y,
|
||||
}
|
||||
}
|
||||
|
||||
/// Both corners of a normalised rect through [`Self::source_point`], left
|
||||
/// in `(x0, y0, x1, y1)` order.
|
||||
fn corners(self, [ax, ay, bx, by]: [f32; 4]) -> [f32; 4] {
|
||||
let (p0x, p0y) = self.source_point(ax, ay);
|
||||
let (p1x, p1y) = self.source_point(bx, by);
|
||||
[p0x.min(p1x), p0y.min(p1y), p0x.max(p1x), p0y.max(p1y)]
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-8
|
||||
/// Where a photograph was taken.
|
||||
///
|
||||
@@ -778,4 +986,137 @@ mod tests {
|
||||
assert!(Availability::Original > Availability::Preview);
|
||||
assert!(Availability::Preview > Availability::MetadataOnly);
|
||||
}
|
||||
|
||||
// ---- the orientation space maps (FR-DEV-3h) --------------------------
|
||||
|
||||
/// Every pixel its own value, non-square and coprime, so no symmetry can
|
||||
/// hide a permutation that is not the intended one.
|
||||
fn ramp(w: u32, h: u32, channels: usize) -> Vec<u32> {
|
||||
(0..(w * h) as usize)
|
||||
.flat_map(|i| (0..channels).map(move |c| (i * 10 + c) as u32))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The property everything else rests on. A quarter turn applied backwards
|
||||
/// lands 180° from right and still looks like a transform, so "it came
|
||||
/// back" is the only check worth having.
|
||||
#[test]
|
||||
fn a_buffer_survives_the_round_trip_through_shown_space() {
|
||||
for tag in 1..=8u16 {
|
||||
let o = Orientation::from_exif(tag);
|
||||
for channels in [1usize, 3, 4] {
|
||||
let src = ramp(5, 3, channels);
|
||||
let (shown, dw, dh) = o.into_shown(&src, 5, 3, channels);
|
||||
assert_eq!((dw, dh), o.oriented_size(5, 3), "tag {tag}");
|
||||
let (back, bw, bh) = o.into_stored(&shown, dw, dh, channels);
|
||||
assert_eq!((bw, bh), (5, 3), "tag {tag}: stored size");
|
||||
assert_eq!(back, src, "tag {tag}, {channels}ch: did not come back");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `shown_pixel` must be the true inverse of `source_pixel`, not merely
|
||||
/// something that looks like it. This is where a wrong direction shows up
|
||||
/// as a fixed point that should have moved.
|
||||
#[test]
|
||||
fn the_two_pixel_maps_invert_each_other() {
|
||||
const W: u32 = 7;
|
||||
const H: u32 = 4;
|
||||
for tag in 1..=8u16 {
|
||||
let o = Orientation::from_exif(tag);
|
||||
let (dw, dh) = o.oriented_size(W, H);
|
||||
for y in 0..dh {
|
||||
for x in 0..dw {
|
||||
let (sx, sy) = o.source_pixel(x, y, dw, dh);
|
||||
assert!(sx < W && sy < H, "tag {tag}: ({x},{y}) left the image");
|
||||
assert_eq!(
|
||||
o.shown_pixel(sx, sy, W, H),
|
||||
(x, y),
|
||||
"tag {tag}: ({x},{y}) did not survive the return trip"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An inverse that is not a group inverse is the diagonal-mirror bug: tags
|
||||
/// 5 and 7 differ only in which axis the mirror is about, and getting it
|
||||
/// wrong renders as a 180° error rather than as anything obviously broken.
|
||||
#[test]
|
||||
fn the_inverse_undoes_the_transform() {
|
||||
for tag in 1..=8u16 {
|
||||
let o = Orientation::from_exif(tag);
|
||||
assert_eq!(o.inverse().inverse(), o, "tag {tag}: not an involution");
|
||||
|
||||
// Composed on the grid, the pair must be the identity.
|
||||
let (dw, dh) = o.oriented_size(6, 4);
|
||||
let src = ramp(6, 4, 1);
|
||||
let (shown, _, _) = o.into_shown(&src, 6, 4, 1);
|
||||
let (back, _, _) = o.inverse().into_shown(&shown, dw, dh, 1);
|
||||
assert_eq!(back, src, "tag {tag}: inverse did not undo it");
|
||||
}
|
||||
}
|
||||
|
||||
/// The rect maps have to agree with the pixel map, or a clip lands
|
||||
/// somewhere the pixels did not — which is exactly the fault that put the
|
||||
/// segmentation overlay on its side over an upright photograph.
|
||||
#[test]
|
||||
fn a_rect_lands_where_its_pixels_land() {
|
||||
const W: u32 = 8;
|
||||
const H: u32 = 4;
|
||||
for tag in 1..=8u16 {
|
||||
let o = Orientation::from_exif(tag);
|
||||
let (dw, dh) = o.oriented_size(W, H);
|
||||
|
||||
// A stored rect covering a known block of pixels.
|
||||
let r = StoredRect {
|
||||
x: 0.25,
|
||||
y: 0.0,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
};
|
||||
let shown = o.into_shown_rect(r);
|
||||
|
||||
// Every stored pixel inside `r` must map into `shown`, and the
|
||||
// rect must not have grown: a permutation moves a block, it does
|
||||
// not resize one.
|
||||
assert!(
|
||||
(shown.width * shown.height - r.width * r.height).abs() < 1e-5,
|
||||
"tag {tag}: the rect changed size"
|
||||
);
|
||||
for sy in 0..H {
|
||||
for sx in 0..W {
|
||||
let inside = (sx as f32 + 0.5) / W as f32 >= r.x
|
||||
&& (sx as f32 + 0.5) / W as f32 <= r.x + r.width
|
||||
&& (sy as f32 + 0.5) / H as f32 >= r.y
|
||||
&& (sy as f32 + 0.5) / H as f32 <= r.y + r.height;
|
||||
if !inside {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = o.shown_pixel(sx, sy, W, H);
|
||||
let (u, v) = ((x as f32 + 0.5) / dw as f32, (y as f32 + 0.5) / dh as f32);
|
||||
assert!(
|
||||
u >= shown.x - 1e-4
|
||||
&& u <= shown.x + shown.width + 1e-4
|
||||
&& v >= shown.y - 1e-4
|
||||
&& v <= shown.y + shown.height + 1e-4,
|
||||
"tag {tag}: stored pixel ({sx},{sy}) -> shown ({x},{y}) fell outside {shown:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// And back again.
|
||||
let round = o.into_stored_rect(shown);
|
||||
assert!(
|
||||
(round.x - r.x).abs() < 1e-5,
|
||||
"tag {tag}: x {round:?} vs {r:?}"
|
||||
);
|
||||
assert!(
|
||||
(round.y - r.y).abs() < 1e-5,
|
||||
"tag {tag}: y {round:?} vs {r:?}"
|
||||
);
|
||||
assert!((round.width - r.width).abs() < 1e-5, "tag {tag}: w");
|
||||
assert!((round.height - r.height).abs() < 1e-5, "tag {tag}: h");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,17 @@ automatically. To force a rebuild without a content change, run the workflow fro
|
||||
The job runs on the host runner rather than in a container, because it needs the Docker daemon and
|
||||
the runner host's cached registry credentials.
|
||||
|
||||
## Face models
|
||||
|
||||
`package.sh` bundles `models/face/` into the APK, and the entry point unpacks it on first launch.
|
||||
That directory is shared with the Arch package rather than owned by this platform. **The models are in LFS**, so a clone without `git lfs pull` has 130-byte
|
||||
pointers there; the build detects that and stops rather than shipping them.
|
||||
|
||||
This exists because Android offers no other route to a model: the directory the app reads from is
|
||||
inside app-private storage, `run-as` needs a debuggable build, and the app has no picker and no
|
||||
fetch. docs/faces.md §2.2a is the decision and its limits — these files come back out before anything
|
||||
is published.
|
||||
|
||||
## Pinned versions
|
||||
|
||||
| Component | Version | Why this one |
|
||||
|
||||
@@ -139,6 +139,25 @@ fi
|
||||
--dir "${REPO}/apps/darkroom-android/android/res" \
|
||||
-o "${OUT}/res.zip"
|
||||
|
||||
# `android:debuggable`, when asked for, and never otherwise.
|
||||
#
|
||||
# Without it `adb shell run-as` refuses — "package not debuggable" — and the
|
||||
# app's own storage cannot be looked at from the host at all. That storage is
|
||||
# where the face shards, the thumbnail store and the catalog live, so when a
|
||||
# device disagrees with the desktop about what it has synced, there is no way
|
||||
# to find out which of them is wrong.
|
||||
#
|
||||
# Set through aapt2 rather than in `AndroidManifest.xml` deliberately: the flag
|
||||
# then exists only for the build that opted in, and a release build cannot
|
||||
# inherit it by someone forgetting to take it back out again. A debuggable APK
|
||||
# lets any process on the device read this app's private files, so it is a
|
||||
# thing to install on a test tablet and not a thing to publish.
|
||||
DEBUG_FLAG=()
|
||||
if [ -n "${DARKROOM_DEBUGGABLE:-}" ]; then
|
||||
echo "==> debuggable build (run-as enabled; do not publish)"
|
||||
DEBUG_FLAG=(--debug-mode)
|
||||
fi
|
||||
|
||||
"${BT}/aapt2" link \
|
||||
-I "${ANDROID_JAR}" \
|
||||
--manifest "${REPO}/apps/darkroom-android/android/AndroidManifest.xml" \
|
||||
@@ -147,12 +166,46 @@ fi
|
||||
--target-sdk-version "${TARGET_API}" \
|
||||
--version-name "${VERSION_NAME}" \
|
||||
--version-code "${VERSION_CODE}" \
|
||||
"${DEBUG_FLAG[@]}" \
|
||||
-o "${OUT}/base.apk" \
|
||||
--auto-add-overlay
|
||||
|
||||
cp "${SO}" "${OUT}/staging/lib/${ABI}/libdarkroom.so"
|
||||
cp "${DEX}" "${OUT}/staging/classes.dex"
|
||||
|
||||
# The face models. Android has no other route to one — app-private storage is
|
||||
# not user-reachable and the in-app fetch is unbuilt (docs/faces.md §2.2a) — so
|
||||
# they go in the APK and `android_main` unpacks them on first launch. The
|
||||
# source is `models/face/`, shared with the Arch package rather than living
|
||||
# under this one platform's directory.
|
||||
#
|
||||
# Through the staging directory rather than aapt2's `-A`: the .so and the dex
|
||||
# already go in with `zip` below, and one mechanism for "extra files in the
|
||||
# APK" is easier to follow than two.
|
||||
ASSETS="${REPO}/models/face"
|
||||
# Cleared first: a previous run that died between staging and cleanup would
|
||||
# otherwise leave models in the APK that are no longer in the tree.
|
||||
rm -rf "${OUT}/staging/assets"
|
||||
if compgen -G "${ASSETS}/*.onnx" >/dev/null; then
|
||||
# An LFS pointer is ~130 bytes and looks exactly like a model to `cp`. Left
|
||||
# unchecked it reaches the device and fails inside tract, which reports a
|
||||
# broken graph rather than a clone that needs `git lfs pull`. Same guard
|
||||
# dr-segment's build script applies to yolo26n-seg.onnx, and the same
|
||||
# reason.
|
||||
for m in "${ASSETS}"/*.onnx; do
|
||||
if [[ "$(stat -c%s "${m}")" -lt 100000 ]]; then
|
||||
echo "error: $(basename "${m}") is $(stat -c%s "${m}") bytes — an LFS pointer, not a model." >&2
|
||||
echo " run: git lfs pull" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
mkdir -p "${OUT}/staging/assets/models"
|
||||
cp "${ASSETS}"/*.onnx "${OUT}/staging/assets/models/"
|
||||
echo " assets: $(ls "${ASSETS}" | grep '\.onnx$' | tr '\n' ' ')"
|
||||
else
|
||||
echo " assets: no face models found (face indexing will be off on the device)"
|
||||
fi
|
||||
|
||||
# -0 "" stores the .so without compression so Android can mmap it directly
|
||||
# (extractNativeLibs=false territory); for a 37 MB library that also keeps
|
||||
# install times sane.
|
||||
@@ -160,6 +213,13 @@ cd "${OUT}/staging"
|
||||
cp "${OUT}/base.apk" "${OUT}/unaligned.apk"
|
||||
zip -q -0 -X "${OUT}/unaligned.apk" "lib/${ABI}/libdarkroom.so"
|
||||
zip -q -X "${OUT}/unaligned.apk" classes.dex
|
||||
# Stored, not deflated: an ONNX graph is mostly incompressible float data, so
|
||||
# deflating it buys a few percent and costs the whole file being inflated into
|
||||
# RAM on the way out. AAssetManager reads a stored entry straight from the
|
||||
# mapped APK.
|
||||
if [[ -d assets ]]; then
|
||||
zip -q -0 -X -r "${OUT}/unaligned.apk" assets
|
||||
fi
|
||||
|
||||
# zipalign before signing: apksigner preserves alignment, the reverse order
|
||||
# invalidates the signature.
|
||||
|
||||
@@ -71,6 +71,7 @@ SO="${CACHE}/target/jniLibs/${ABI}/libdarkroom.so"
|
||||
echo "==> packaging APK"
|
||||
"${HERE}/build.sh" env \
|
||||
ABI="${ABI}" RUST_TARGET="${RUST_TARGET}" \
|
||||
DARKROOM_DEBUGGABLE="${DARKROOM_DEBUGGABLE:-}" \
|
||||
/work/docker/android/assemble-apk.sh
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Check that every command the workflows invoke exists in the image that will
|
||||
# run it.
|
||||
#
|
||||
# This exists because two CI failures in a row were the same shape: the image
|
||||
# was missing a command, and finding out took a 28-minute cross-compile each
|
||||
# time because the step that would fail ran last. git-lfs and file(1) were both
|
||||
# absent for as long as the build panicked before ever reaching them.
|
||||
#
|
||||
# Nothing here runs the workflow. It answers one question -- is the toolchain
|
||||
# the steps assume actually installed -- in about ten seconds.
|
||||
#
|
||||
# ./docker/ci-preflight.sh # every job
|
||||
# ./docker/ci-preflight.sh android # one job
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO="$(cd "${HERE}/.." && pwd)"
|
||||
WANT="${1:-}"
|
||||
FAILED=0
|
||||
|
||||
# Commands a `run:` block invokes that are worth asserting: the ones a minimal
|
||||
# image plausibly lacks. Shell builtins and coreutils are not interesting; a
|
||||
# missing `cd` is not the failure mode anybody has.
|
||||
readonly INTERESTING='^(git|git-lfs|file|zip|unzip|keytool|curl|cargo|rustup|apt-get|find|sed|awk|base64|shred|adb|python3|node|jq)$'
|
||||
|
||||
jobs_and_images() {
|
||||
python3 - "$REPO" <<'PY'
|
||||
import sys, pathlib, yaml
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
for wf in sorted((root / ".gitea/workflows").glob("*.yml")):
|
||||
doc = yaml.safe_load(wf.read_text()) or {}
|
||||
for job, spec in (doc.get("jobs") or {}).items():
|
||||
image = ((spec.get("container") or {}).get("image"))
|
||||
if not image:
|
||||
continue
|
||||
cmds = set()
|
||||
for step in (spec.get("steps") or []):
|
||||
run = step.get("run")
|
||||
if not run:
|
||||
continue
|
||||
for line in run.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# first word of the line, and of anything after a pipe
|
||||
for part in line.split("|"):
|
||||
word = part.strip().split(" ")[0].strip()
|
||||
if word.isidentifier() or "-" in word:
|
||||
cmds.add(word)
|
||||
# `git lfs` is a subcommand, so the first word is `git` and the
|
||||
# thing that can actually be absent never appears. This is the
|
||||
# exact bug that shipped an image with no git-lfs in it.
|
||||
if "git lfs" in line:
|
||||
cmds.add("git-lfs")
|
||||
print(f"{job}\t{image}\t{' '.join(sorted(cmds))}")
|
||||
PY
|
||||
}
|
||||
|
||||
while IFS=$'\t' read -r job image cmds; do
|
||||
[[ -n "${WANT}" && "${job}" != "${WANT}" ]] && continue
|
||||
checked=()
|
||||
for c in ${cmds}; do
|
||||
[[ "${c}" =~ ${INTERESTING} ]] && checked+=("${c}")
|
||||
done
|
||||
# `git lfs` is a git subcommand, not a binary on PATH — ask git about it.
|
||||
printf '\n== %s (%s)\n' "${job}" "${image}"
|
||||
if [[ ${#checked[@]} -eq 0 ]]; then
|
||||
echo " nothing to check"
|
||||
continue
|
||||
fi
|
||||
docker image inspect "${image}" >/dev/null 2>&1 || {
|
||||
echo " image not present locally — docker pull ${image}"
|
||||
FAILED=1
|
||||
continue
|
||||
}
|
||||
out=$(docker run --rm --entrypoint bash "${image}" -c '
|
||||
for c in '"${checked[*]}"'; do
|
||||
if [ "$c" = git-lfs ]; then
|
||||
git lfs version >/dev/null 2>&1 && echo "ok git lfs" || echo "MISSING git lfs"
|
||||
elif command -v "$c" >/dev/null 2>&1; then
|
||||
echo "ok $c"
|
||||
else
|
||||
echo "MISSING $c"
|
||||
fi
|
||||
done' 2>&1)
|
||||
echo "${out}" | sed 's/^/ /'
|
||||
grep -q MISSING <<<"${out}" && FAILED=1
|
||||
done < <(jobs_and_images)
|
||||
|
||||
echo
|
||||
if [[ ${FAILED} -eq 0 ]]; then
|
||||
echo "preflight: every command the workflows invoke is present"
|
||||
else
|
||||
echo "preflight: something the workflows invoke is not in the image — fix the Dockerfile before pushing"
|
||||
fi
|
||||
exit ${FAILED}
|
||||
+11
-2
@@ -10,8 +10,10 @@ and records the decisions and constraints behind the design.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
DarkRoom is a Rust application with a Slint interface, rendering through wgpu to Vulkan on both
|
||||
Linux and Android. The design is organised around four ideas, each of which the rest of this
|
||||
DarkRoom is a Rust application with a Slint interface. On Linux it renders through wgpu to Vulkan.
|
||||
On Android it renders through Skia to OpenGL — not by preference but because wgpu's Vulkan
|
||||
swapchain cannot pre-rotate, which tears a portrait window on a landscape-mounted panel
|
||||
([technical-debt.md TD-1](technical-debt.md)). The compute passes are wgpu on both. The design is organised around four ideas, each of which the rest of this
|
||||
document elaborates:
|
||||
|
||||
1. **Pixels stay on the GPU.** From decode to display, image data never round-trips through the
|
||||
@@ -980,6 +982,13 @@ At 4K the shader finishes in 0.28 ms and then 7.15 ms is spent moving pixels thr
|
||||
26× overhead that scales with area, which is why an uncapped window resize falls off a cliff. The
|
||||
constraint is not a stylistic preference; it is the dominant cost in the frame.
|
||||
|
||||
**One exception, on Android only, and it is debt rather than a revision.** The develop view there
|
||||
reads the frame back rather than handing over a texture, because zero-copy requires Slint to draw
|
||||
with wgpu and wgpu's Android swapchain tears a portrait window. The reasoning, the measurements
|
||||
that forced it and what would remove it are in [technical-debt.md TD-1](technical-debt.md). The
|
||||
constraint above still governs every other path, including the desktop develop view and the export
|
||||
pipeline, and the Android exception is expected to be temporary.
|
||||
|
||||
### 6.2 Tiling from day one
|
||||
|
||||
Mobile GPUs have far less memory. Retrofitting tiling into a whole-image pipeline is a rewrite.
|
||||
|
||||
@@ -716,6 +716,9 @@ Specified by FR-CULL-8 … FR-CULL-12, NFR-SEC-5, [architecture.md §6.4](archit
|
||||
spike S14 and decision D13 — the runtime and the model licences are unresolved, so this is the shape
|
||||
of the subsystem, not a build order.
|
||||
|
||||
[faces.md](faces.md) names the models this shape is filled in with, and adds one column to §10.1's
|
||||
`faces` table (`crop_px`) that the calibration in its §8 depends on.
|
||||
|
||||
### 10.1 Schema (a v5 migration)
|
||||
|
||||
```sql
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
# DarkRoom — Code health and the cost of a contribution
|
||||
|
||||
**Status:** Audit · 2026-08-27
|
||||
**Companion to:** [architecture.md](architecture.md), [technical-debt.md](technical-debt.md),
|
||||
[view-composition.md](view-composition.md)
|
||||
|
||||
What it costs to add something to this codebase, measured rather than estimated, and the work that
|
||||
would lower the price.
|
||||
|
||||
[technical-debt.md](technical-debt.md) records compromises that were *chosen* — each one has a
|
||||
reason that outlived the person who took it. This document records the opposite: friction nobody
|
||||
chose, which accumulated because no single commit was responsible for it. The distinction matters
|
||||
when deciding what to touch. A TD entry is load-bearing until its "done when" is met; an entry here
|
||||
is not defending anything.
|
||||
|
||||
It is also not a bug list. Everything below compiles, passes 2,042 tests and ships.
|
||||
|
||||
---
|
||||
|
||||
## 1. What was measured
|
||||
|
||||
Every figure in this document is reproducible from a clean checkout. Worktrees under `.claude/` and
|
||||
build output under `target/` are excluded from all counts — including them roughly triples the line
|
||||
totals and was the first thing to get wrong.
|
||||
|
||||
```bash
|
||||
# Lines of Rust per crate
|
||||
for d in core/* ui/* platform/* apps/* tools/*; do
|
||||
[ -d "$d/src" ] && echo "$(find $d/src -name '*.rs' -exec cat {} + | wc -l) $d"
|
||||
done | sort -rn
|
||||
|
||||
# unwrap() in production code only — split each file at its #[cfg(test)] marker
|
||||
# (a naive grep counts ~1,500 and tells you nothing)
|
||||
|
||||
# Distinct window properties written per UI module
|
||||
for f in ui/dr-ui/src/*.rs; do
|
||||
echo "$(grep -oP '\b(w|window|win|ui)\.\Kset_[a-z0-9_]+(?=\()' "$f" | sort -u | wc -l) $f"
|
||||
done | sort -rn
|
||||
```
|
||||
|
||||
| Measure | Value |
|
||||
|---|---|
|
||||
| Rust across 19 crates | ~112,000 lines; ~78,000 after comments and blanks |
|
||||
| Comment density | 28% overall, 20–34% per crate |
|
||||
| Test functions | 2,042, plus 21 integration test files |
|
||||
| `.unwrap()` in production code | **3** — one in `dr-gpu`, two in `dr-ingest` |
|
||||
| `.unwrap()` in test code | ~1,500, which is where it belongs |
|
||||
| `unsafe` blocks | 6 |
|
||||
| `TRACES` tags / orphan tags | 793 / 0 |
|
||||
| Resolved dependencies | 826 |
|
||||
| Largest function | `dr-ui::run` — 1,855 lines |
|
||||
| `AppWindow` members | 282 properties + 190 callbacks |
|
||||
|
||||
CI gates on `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
|
||||
`cargo test --workspace`, a release build, and an Android cross-check. The traceability matrix is
|
||||
regenerated and compared, with a pre-commit hook that keeps it in step.
|
||||
|
||||
---
|
||||
|
||||
## 2. The seams, graded
|
||||
|
||||
Six things a contributor might plausibly want to add. Each grade was checked against the tree.
|
||||
|
||||
| Feature | What you touch | Cost |
|
||||
|---|---|---|
|
||||
| A develop operation<br>*split toning, channel mixer* | One file in `core/dr-pipeline/ops/`. Nothing else. | **Trivial** |
|
||||
| A RAW format | A `Format` variant and magic-byte recognition. `dr-decode` is a generic TIFF walker carrying only 3 format-specific branches. | **Easy** |
|
||||
| A neighbourhood operation<br>*dehaze, a sharpener* | Rust in `dr-pipeline/src/ops/` implementing `Operation` + `DetailStage`, plus a stub YAML declaring `rust:` and `order:`. | **Moderate** |
|
||||
| A parameter widget<br>*a colour wheel* | A `WidgetKind` variant, `develop::supported()`, the panel model, a Slint component. | **Moderate** |
|
||||
| A second sync backend<br>*S3, WebDAV, a local folder* | `RemoteBackend` is the easy half. Seven UI files construct `NextcloudBackend` directly and ten signatures take it concretely — see [CH-2](#ch-2). | **Hard** |
|
||||
| Anything with its own UI | `app.slint`'s root component, a ~1,000-line `wire()`, and `run()` at 1,855 lines — see [CH-1](#ch-1). | **Hard** |
|
||||
|
||||
The top half of that table is the good half, and it is very good. The bottom half is one problem
|
||||
wearing two hats: **`dr-ui` has no seams, so every UI feature lands in the same three files.**
|
||||
|
||||
---
|
||||
|
||||
## 3. What is load-bearing, and must not be "tidied"
|
||||
|
||||
Listed before the findings deliberately. A remediation document that only enumerates problems invites
|
||||
someone to fix something that was right.
|
||||
|
||||
**The operation declaration format.** `ops/*.yaml` + `build.rs` is a working plugin system that
|
||||
happens to resolve at build time — see [display-and-extension.md §6](display-and-extension.md). Its
|
||||
deliberate smallness is the point: the expression grammar is restricted so a declaration cannot
|
||||
become a second, worse place to write code. Do not "improve" it by letting a node name arbitrary
|
||||
Rust.
|
||||
|
||||
**No operation is named in `ui/`.** Verified: all fifteen built-in op ids grepped across every `.rs`
|
||||
and `.slint` file in `ui/` yield exactly one hit, a localisation test in `labels.rs:353`. This is
|
||||
what makes "a new operation is one file" true rather than aspirational, and it is the single
|
||||
property most likely to be destroyed by a well-meaning special case in the panel. See [CH-5](#ch-5).
|
||||
|
||||
**Unimplemented widgets degrade rather than break.** `develop::supported()` lists every `WidgetKind`
|
||||
explicitly instead of using a wildcard, so a new kind added to the core surfaces as a compile error
|
||||
rather than as silence, and a widget nothing draws falls back to sliders with the edit still
|
||||
working (ARCH §4.3a).
|
||||
|
||||
**The mpsc-plus-timer worker shape.** Slint's event loop must never block (NFR-P9). Threading is not
|
||||
what any finding below proposes changing.
|
||||
|
||||
**`sync_rows` mutating rows in place.** Replacing the model breaks slider dragging.
|
||||
|
||||
---
|
||||
|
||||
## <a id="ch-1"></a>CH-1 — `dr-ui` has no view layer, and the cost is compounding
|
||||
|
||||
**Where:** `ui/dr-ui/src/lib.rs:836`, `library_ui.rs:4374`, `collections_ui.rs:1457`,
|
||||
`ui/dr-ui/ui/app.slint`
|
||||
|
||||
### What it is
|
||||
|
||||
Every UI feature lands in the same three places: the Slint root component, one of the `wire()`
|
||||
functions, and `run()`.
|
||||
|
||||
| Function | Lines |
|
||||
|---|---|
|
||||
| `lib.rs::run` | 1,855 |
|
||||
| `library_ui::wire` | 998 |
|
||||
| `collections_ui::wire` | 964 |
|
||||
| `identity_ui::wire` | 478 |
|
||||
| `masks_ui::wire` | 357 |
|
||||
| `settings_ui::wire` | 317 |
|
||||
|
||||
Above them sits one `AppWindow` carrying 282 properties and 190 callbacks, with exactly one Slint
|
||||
global in the whole `ui/` directory — and it is not exported. Cross-view state therefore has nowhere
|
||||
to live except the root component, and every interaction is a callback registered inside a `wire`.
|
||||
|
||||
The core crates are healthy by the same measure: their largest functions are `compose_full` at 437
|
||||
lines and `build.rs::emit_node` at 558, both of which earn their length. This is specific to `dr-ui`.
|
||||
|
||||
### Why it matters more than it did
|
||||
|
||||
**[view-composition.md](view-composition.md) already diagnosed this and specified the fix**, in
|
||||
three independently landable stages, on 2026-08-09. None of the three has landed. In the eighteen
|
||||
days since, the numbers that document itself used have moved:
|
||||
|
||||
| Its measure | 2026-08-09 | 2026-08-27 |
|
||||
|---|---|---|
|
||||
| `run()` | 500 lines | **1,855** |
|
||||
| `library_ui` distinct window properties | 24 | **54** |
|
||||
| `lib.rs` distinct window properties | 23 | **52** |
|
||||
| `collections_ui` | 9 | 11 |
|
||||
| `launch_ui` | 16 | 16 |
|
||||
| `set_show_*` call sites | 7 | 9 |
|
||||
| View-state booleans on `AppWindow` | 2 | **5** |
|
||||
|
||||
Four modules it did not list now write window properties too: `settings_ui` (39), `import_ui` (24),
|
||||
`identity_ui` (18), `masks_ui` (16).
|
||||
|
||||
The prediction it made has also come true literally. It described `app.slint` compensating for two
|
||||
mutually exclusive booleans with `if !root.show-launch && root.show-library` chains. There are now
|
||||
five such booleans — `show-launch`, `show-library`, `show-identity`, `show-settings`, `show-import` —
|
||||
and the chains at `app.slint:1405`, `:1445` and `:1654` are five-term conjunctions. Each new view
|
||||
multiplies the conjunctions rather than adding to them.
|
||||
|
||||
None of this is difficult work. It is simply work that every feature must now do, in files every
|
||||
other feature is also editing — which is why two contributors working in parallel conflict by
|
||||
construction, and why a newcomer must read a 1,855-line startup sequence with real ordering
|
||||
constraints before safely inserting a line into it.
|
||||
|
||||
### What to do
|
||||
|
||||
Execute [view-composition.md](view-composition.md) as written. Its analysis holds and its staging is
|
||||
right; stage 1 alone removes the nullable-callback knot and the duplicated post-load sequence, and
|
||||
is worth landing whether or not stages 2 and 3 follow.
|
||||
|
||||
One thing to add to it, because it was not in scope there: the `wire()` functions. They are already
|
||||
sectioned internally by comment, so lifting each section into `fn wire_ratings(window, ctl)`,
|
||||
`fn wire_keywords(...)` and so on is mechanical, checked entirely by the compiler, and can go one
|
||||
section per commit. It gives a feature a *function* to own rather than a region of one, which is
|
||||
what removes the conflict, and it does not wait on stage 1.
|
||||
|
||||
Do the extraction behind new features rather than as a big-bang refactor. The pile grows either way;
|
||||
the question is only whether each new feature adds to it or subtracts.
|
||||
|
||||
**Done when:** no function in `dr-ui` exceeds 300 lines, a new view registers itself instead of
|
||||
adding a boolean to `AppWindow`, and `active-view` has replaced the boolean set so both-true is
|
||||
unrepresentable.
|
||||
|
||||
---
|
||||
|
||||
## <a id="ch-2"></a>CH-2 — `RemoteBackend` is an abstraction nothing above `dr-sync` uses
|
||||
|
||||
**Where:** `core/dr-sync/src/lib.rs:46`; seven files in `ui/dr-ui/src`
|
||||
|
||||
### What it is
|
||||
|
||||
The trait is carefully built. Capability negotiation decides the sync strategy; range reads are
|
||||
documented as a hint rather than a guarantee so correctness holds either way; chunked upload is
|
||||
deliberately kept internal so one server's protocol cannot leak into the interface. Every choice is
|
||||
explained where it is made.
|
||||
|
||||
And then `trash.rs`, `import.rs`, `export.rs`, `derived_sync.rs`, `launch_ui.rs`, `library.rs` and
|
||||
`settings_ui.rs` each construct `NextcloudBackend` directly — 34 references — and ten functions take
|
||||
`&NextcloudBackend` rather than `&dyn RemoteBackend`. Exactly **two** sites in the tree take the
|
||||
trait object, both inside `dr-sync` itself.
|
||||
|
||||
### Why it matters
|
||||
|
||||
The abstraction currently buys nothing it was designed for. Worse, it reads as though it does: a
|
||||
contributor who wants a WebDAV or local-folder backend will find a well-documented trait, implement
|
||||
it correctly, and only then discover that nothing above `dr-sync` can be handed the result.
|
||||
|
||||
There is no defence of this in the tree, which is what makes it an entry here rather than in
|
||||
`technical-debt.md`. It is what a single-backend application looks like when the second backend has
|
||||
not yet been attempted.
|
||||
|
||||
### What to do
|
||||
|
||||
Change the ten signatures to `&dyn RemoteBackend` and construct the backend once, behind something
|
||||
the UI does not name — the same discipline `develop.rs` already applies to operations. The trait is
|
||||
already correct, so this is a mechanical change, and it is much cheaper now than during a second
|
||||
backend when it would be entangled with that backend's own problems.
|
||||
|
||||
Worth doing even if no second backend is ever written: it makes the sync layer testable against a
|
||||
fake, which today it is not.
|
||||
|
||||
**Done when:** `NextcloudBackend` is named in at most one file in `ui/`, and a stub backend can be
|
||||
substituted in a test without touching the UI.
|
||||
|
||||
**Done**, with one half deliberately left. `ui/dr-ui/src/remote.rs` is now the only file in the
|
||||
interface that names a connector; the ten worker functions take `&dyn RemoteBackend` and will accept
|
||||
a stub. Every method the UI ever called on a backend — `get`, `put`, `list`, `delete`, `create_dir`,
|
||||
`move_to` — was already on the trait, so nothing had to be added to it.
|
||||
|
||||
What remains is **credentials**. `AppCredentials` is an app password obtained through Login Flow v2,
|
||||
which is a Nextcloud protocol rather than a general notion of how one authenticates to a remote, and
|
||||
seven files still name it. Abstracting it needs a decision about what an account *is* across
|
||||
backends — an OAuth token, a bucket key pair and an app password have no useful common shape — and
|
||||
making that decision before a second backend exists would produce a confident wrong answer. It is a
|
||||
design problem rather than a mechanical one, and it should wait for the backend that forces it.
|
||||
|
||||
---
|
||||
|
||||
## <a id="ch-3"></a>CH-3 — There is no path in for a contributor who is not already here
|
||||
|
||||
**Where:** repository root
|
||||
|
||||
### What it is
|
||||
|
||||
No `CONTRIBUTING.md`. No `rust-toolchain.toml`, though CI pins 1.92.0 exactly. No issue or PR
|
||||
templates.
|
||||
|
||||
The documentation that exists is excellent — 7,990 lines across 14 files, including 177 numbered
|
||||
requirements — and all of it is written for someone who has already decided to work on this. Nothing
|
||||
tells a newcomer which document to read first, that `core/dr-pipeline/ops/README.md` is the door
|
||||
with the lowest bar, or that a clone without `git-lfs` needs one command before the build succeeds.
|
||||
|
||||
That last point is handled well in the code: `dr-segment`'s build script detects an LFS pointer file
|
||||
and fails with an instruction rather than embedding 130 bytes and dying at inference time. It is
|
||||
simply not written anywhere a first-time cloner would look.
|
||||
|
||||
### Why it matters
|
||||
|
||||
It is the cheapest item in this document and it gates every other contribution. A person who cannot
|
||||
get a first build is not going to reach the parts that are good.
|
||||
|
||||
### What to do
|
||||
|
||||
Write `CONTRIBUTING.md` and point the first door at the operation format. "Add a develop operation"
|
||||
is a genuinely one-file contribution with declared tests that run under `cargo test` — the best
|
||||
first experience this codebase can offer, and it happens to teach the architecture's central idea on
|
||||
the way through.
|
||||
|
||||
Then state the three things that are currently folklore: `git lfs` is a prerequisite, the first
|
||||
build resolves 826 crates and takes a while (saying so stops it reading as a hang), and the
|
||||
toolchain is 1.92.0. Add `rust-toolchain.toml` so that last one is enforced rather than documented —
|
||||
a contributor on an older stable currently gets confusing type errors instead of a version message.
|
||||
|
||||
**Done when:** someone who has never seen the repository can clone it, build it, and land a new
|
||||
`ops/*.yaml` node without asking a question.
|
||||
|
||||
---
|
||||
|
||||
## <a id="ch-4"></a>CH-4 — Coverage is counted by tagging, not by behaviour
|
||||
|
||||
**Where:** `docs/traceability.md`, `tools/traceability`
|
||||
|
||||
### What it is
|
||||
|
||||
Not a new finding — [display-and-extension.md §7](display-and-extension.md) states it plainly, and
|
||||
`traceability.md` itself says coverage is the intersection of tagged and defined IDs. The tooling is
|
||||
genuinely good: 732 tags, zero orphans, denominators parsed from `requirements.md` at run time
|
||||
rather than hardcoded, regenerated in CI and guarded by a pre-commit hook.
|
||||
|
||||
What it cannot do is check that the code under a tag does the thing. `FR-DEV-8` is currently tagged
|
||||
against instance-buffer plumbing a future spot-removal operation *would* use; `FR-DEV-7` against a
|
||||
history row for a frontend that does not exist. Both read as covered.
|
||||
|
||||
### Why it matters
|
||||
|
||||
The 55.4% figure is an overstatement of unknown size, and the risk is that it is used as a planning
|
||||
input. It is recorded here so that the number keeps its asterisk when read outside the document that
|
||||
already qualified it.
|
||||
|
||||
### What to do
|
||||
|
||||
Nothing structural — the honest framing already exists in two places. Adopt
|
||||
display-and-extension.md's rule going forward: **close a requirement with a test that would fail if
|
||||
the behaviour were removed**, and let the percentage move slowly and mean something.
|
||||
|
||||
**Done when:** the rule is stated in `CONTRIBUTING.md` alongside the tag syntax, so it reaches
|
||||
someone adding their first tag.
|
||||
|
||||
---
|
||||
|
||||
## <a id="ch-5"></a>CH-5 — The best invariant in the codebase is unprotected
|
||||
|
||||
**Where:** `ui/dr-ui/src`, `ui/dr-ui/ui`
|
||||
|
||||
### What it is
|
||||
|
||||
"No code in `ui/` names an operation" (FR-DEV-3a) is what makes the whole declarative pipeline pay
|
||||
off, and it is currently maintained by discipline alone. Nothing fails if someone special-cases
|
||||
`exposure` in the panel to fix a layout problem at five in the evening.
|
||||
|
||||
### Why it matters
|
||||
|
||||
It is one grep, it would take an hour, and it protects the property this audit rates highest. The
|
||||
failure mode is silent and cumulative: the first special case is defensible, and by the fifth the
|
||||
panel names half the chain and "a new operation is one file" has quietly stopped being true.
|
||||
|
||||
### What to do
|
||||
|
||||
A test that greps `ui/dr-ui/src` and `ui/dr-ui/ui` for every id in `ops/*.yaml` and fails on a hit,
|
||||
with the current `labels.rs` localisation test as its one allowed exception. It belongs in CI beside
|
||||
the traceability check, which is the existing precedent for a structural gate.
|
||||
|
||||
**Done when:** adding `window.set_exposure_slider(...)` to the panel fails CI with a message naming
|
||||
FR-DEV-3a.
|
||||
|
||||
---
|
||||
|
||||
## 4. Order of work
|
||||
|
||||
Ordered by value per hour rather than by size.
|
||||
|
||||
| | Item | Effort | Why first |
|
||||
|---|---|---|---|
|
||||
| 1 | [CH-3](#ch-3) — `CONTRIBUTING.md` + `rust-toolchain.toml` | Half a day | Gates everything else; unblocks the trivial seam that already works |
|
||||
| 2 | [CH-5](#ch-5) — CI gate on operation names in `ui/` | An hour | Protects the property everything else in the pipeline rests on |
|
||||
| 3 | [CH-2](#ch-2) — make `RemoteBackend` load-bearing | 1–2 days | Mechanical now, entangled later; also makes sync testable |
|
||||
| 4 | [CH-1](#ch-1) — split the `wire` functions | Incremental | No behaviour change, compiler-checked, one section per commit |
|
||||
| 5 | [CH-1](#ch-1) — [view-composition.md](view-composition.md) stages 1–3 | Sustained | Largest and most invasive; every deferred month adds to the pile |
|
||||
|
||||
Items 1, 2 and 3 are independent of each other and of the rest. Item 4 does not wait on item 5.
|
||||
|
||||
---
|
||||
|
||||
## 5. What this document does not claim
|
||||
|
||||
It measures structure, discipline and coupling. It does **not** assess runtime correctness, GPU
|
||||
shader behaviour, security posture, or whether any tagged requirement is actually implemented —
|
||||
[CH-4](#ch-4) is precisely the observation that the last of those is unmeasured.
|
||||
|
||||
Line counts and function sizes are proxies. `run()` being 1,855 lines is a real problem because
|
||||
every feature must edit it, not because 1,855 is a bad number; `build.rs::emit_node` at 558 lines is
|
||||
not a problem at all. Where a figure appears above, the sentence around it says which of the two it
|
||||
is.
|
||||
|
||||
The audit was first measured against `origin/master` at `d4a34ef`, then re-measured after
|
||||
`android-bundled-face-models` merged in: `run()` moved from 1,810 lines to 1,855, and the whole-
|
||||
library face sweep added its fetching path to `library.rs`. The seam grades are unchanged by that
|
||||
merge — the work went into the seams that already existed rather than cutting new ones, which is
|
||||
itself the pressure CH-1 describes.
|
||||
@@ -0,0 +1,253 @@
|
||||
# Finishing the display contract, and opening the pipeline
|
||||
|
||||
Spec for two pieces of work that turn out to be one conversation: closing **FR-DSP**, which is the
|
||||
architecture's central performance claim, and reaching **FR-PLG**, which is the only requirement
|
||||
family at zero.
|
||||
|
||||
They belong in one document because the same property decides both. The pipeline composes its work
|
||||
from *declarations* — an operation says what its parameters are and contributes a WGSL fragment,
|
||||
and the composer fuses the active ones into a single dispatch. That is why the display path is fast,
|
||||
and it is also, already, most of a plugin format. Finishing one and opening the other are the same
|
||||
seam approached from two sides.
|
||||
|
||||
---
|
||||
|
||||
## 1. What is actually true today
|
||||
|
||||
Stated first because both halves of this document are smaller than the requirement numbers suggest,
|
||||
and the reason is that some of the work is done and untagged.
|
||||
|
||||
| Requirement | Reality |
|
||||
|---|---|
|
||||
| FR-DSP-1 proxy rendering | **Done.** The develop view renders at viewport resolution, not source. |
|
||||
| FR-DSP-2 tiled computation | **Absent, and §2 now says it should stay that way.** Measured: the fused pass is inside the budget everywhere. See [frame-budget.md](frame-budget.md). |
|
||||
| FR-DSP-3 interactive latency | **Measured and asserted** for the fused path — `core/dr-gpu/tests/frame_budget.rs`. Missed by one operation, clarity, for the reason recorded as TD-4. |
|
||||
| FR-DSP-4 progressive refinement | **Absent**, and §4's condition did not fire. Every render is full quality and can afford to be. |
|
||||
| FR-DSP-5 zoom and pan | **Done and tagged**, against tests that fail if the behaviour is removed — `core/dr-gpu/tests/zoom_resolution.rs`. `Framing::view` shrinks the sampled region while the render target keeps its size, so zooming *raises* the resolution the pipeline works at. That is FR-DSP-5's requirement, arrived at without tiles. |
|
||||
| FR-DSP-6 colour management | **Done.** Output space is a parameter of composition. |
|
||||
| FR-DSP-7 histogram and clipping | **Done**, GPU-side, no per-frame readback. |
|
||||
| FR-DSP-8 per-display colour | **Done**, with one caveat named in §5.4. Acquisition per display server, an sRGB fallback that is visible in About, and the canvas rendered at physical pixel size. |
|
||||
| FR-PLG-* | **Zero tagged.** But `ops/*.yaml` + `build.rs` is already the class-1 plugin format compiled at build time rather than loaded. |
|
||||
|
||||
Two of the five uncovered display requirements are therefore *measurement and tagging*, not
|
||||
construction. That is worth knowing before anyone plans a quarter around them.
|
||||
|
||||
**Both have since been done.** [frame-budget.md](frame-budget.md) holds the measurements §2 asks
|
||||
for and the reading of its decision rule; the table above is updated to match. The rest of this
|
||||
document is left as it was written, because a plan that has been overtaken by its own evidence is
|
||||
more useful read in order than quietly edited into agreement.
|
||||
|
||||
---
|
||||
|
||||
## 2. Measure before building tiles
|
||||
|
||||
**FR-DSP-2 is the one requirement in this document that may not be worth satisfying as written.**
|
||||
|
||||
> **Resolved.** M1–M3 were run; the numbers and the verdict are in
|
||||
> [frame-budget.md](frame-budget.md). The rule below fired for *rewrite*: every point-operation
|
||||
> chain is inside 16 ms at the 99th percentile at every viewport size, fit and at 1:1, the widest
|
||||
> being 4.5 ms of GPU at 4K. The measurement did find a stage that misses the budget — clarity's
|
||||
> 52-pixel kernel, 34 ms at 4K — and tiling makes that stage *worse*, since a tiled convolution
|
||||
> reads a halo per tile. It is recorded as TD-4 with the fix its own module already names.
|
||||
|
||||
|
||||
The requirement predates the fused-shader design. It assumes the pipeline is a chain of passes over
|
||||
a large buffer, where recomputing everything on each frame would be ruinous and tiles are the way
|
||||
out. What was built instead composes every active operation into **one dispatch over a
|
||||
viewport-sized target** — at 2000×1300 that is 2.6 M pixels, once, for the whole chain.
|
||||
|
||||
So the question tiling was invented to answer may already be answered. Before any tile scheduler is
|
||||
written:
|
||||
|
||||
**M1 — Frame cost at proxy resolution.** Time `render_detailed` at 1920×1200, 2560×1600 and
|
||||
3840×2160, with a chain of one operation, five, and every operation active. Report the 99th
|
||||
percentile, not the mean; a slider drag is judged by its worst frame.
|
||||
|
||||
**M2 — Frame cost at 1:1 on a large file.** The same, with `Framing::view` zoomed to 1:1 on a 60 MP
|
||||
frame, which is the case FR-DSP-5 names and the one where the sampled region is smallest but the
|
||||
detail chain's kernels are widest.
|
||||
|
||||
**M3 — Cost of the detail stage separately.** Neighbourhood operations dispatch per pass and are the
|
||||
only part of the chain whose cost is not one read and one write. A separable blur at a large radius
|
||||
is the plausible budget-breaker, not the fused pass.
|
||||
|
||||
**The decision rule, fixed in advance.** If M1 and M2 sit inside 16 ms at the 99th percentile,
|
||||
**FR-DSP-2 is rewritten rather than implemented**: tiling stops being an interactive-path
|
||||
requirement and becomes what it actually is for this architecture — a *scheduling* concern for
|
||||
export and thumbnailing, which already run off the frame path. If they do not, the measurement tells
|
||||
us which stage to tile, which is a far better starting point than tiling everything on principle.
|
||||
|
||||
Writing a tile scheduler that the design does not need would be the most expensive way to discover
|
||||
this. ARCH §5.3's tile cache keyed by `(VersionId, tile, zoom, graph_hash_prefix)` is a good design
|
||||
for a pipeline that needs it; the burden of proof is that this one does.
|
||||
|
||||
---
|
||||
|
||||
## 3. FR-DSP-3 — make the budget a test, not an aspiration
|
||||
|
||||
A latency requirement that nothing asserts is a wish. The work is:
|
||||
|
||||
**3.1** A bench in `dr-gpu` that renders a fixed chain at a fixed size and reports percentiles.
|
||||
Committed with its numbers, so a regression is a diff rather than a memory.
|
||||
|
||||
**3.2** A test that *fails* when a frame exceeds the budget on the reference desktop, skipping where
|
||||
there is no adapter — the pattern the GPU tests already use. It should assert the 99th percentile of
|
||||
a hundred frames, because the failure mode being guarded against is a stutter, not an average.
|
||||
|
||||
**3.3** The asynchronous half of the requirement: "when a full-resolution result is needed it is
|
||||
computed asynchronously, and the proxy result remains on screen until it is ready." Nothing does
|
||||
this today because nothing needs a full-resolution result on the frame path — export renders its
|
||||
own. This clause should be **narrowed to export and 1:1 zoom** or struck, and struck is defensible.
|
||||
|
||||
---
|
||||
|
||||
## 4. FR-DSP-4 — progressive refinement
|
||||
|
||||
The one genuinely new piece of interactive work, and it is small because the pipeline is already
|
||||
resolution-parametric.
|
||||
|
||||
During a drag, render at a fraction of the viewport and let the compositor scale; when the gesture
|
||||
settles, render at full viewport size. `DevelopSession` already knows when a drag is in flight —
|
||||
`drag-changed` exists on every slider and is what stands the Flickable down.
|
||||
|
||||
Two things decide whether this is worth having, and M1 answers both. If a full-quality frame is
|
||||
already inside budget, reduced-quality rendering buys nothing and costs a visible softness during
|
||||
every drag — which the requirement itself warns against ("refinement is visually smooth, not a
|
||||
jarring swap"). **This requirement is conditional on M1 failing.** If M1 passes, FR-DSP-4 is
|
||||
satisfied vacuously: there is no rapid interaction the app cannot render at full quality, which is a
|
||||
stronger outcome than refining.
|
||||
|
||||
---
|
||||
|
||||
## 5. FR-DSP-8 — per-display colour
|
||||
|
||||
The only display requirement needing platform work rather than pipeline work, and the only one where
|
||||
being wrong is a correctness defect rather than a slow frame: a second monitor with a different
|
||||
profile shows wrong colours, silently.
|
||||
|
||||
**5.1 Acquisition, per display server.** X11 has `_ICC_PROFILE` atoms per output. Wayland's
|
||||
colour-management protocol is not universally available, and the requirement already anticipates
|
||||
this by demanding "a defined fallback where Wayland provides no profile" — that fallback is sRGB,
|
||||
stated in the About page beside the other diagnostics so a photographer can see which path they are
|
||||
on rather than wonder.
|
||||
|
||||
**5.2 Reacting to a move.** The transform is selected per the display currently showing the canvas
|
||||
and updates when the window moves. The composed output space is already a parameter of composition
|
||||
(`compose_with_framing(..., output)`), so a display change is a recomposition, not a pipeline
|
||||
change. This is the part the existing design makes cheap.
|
||||
|
||||
Slint turned out *not* to report window moves — there is no `on_moved` on any backend — so the
|
||||
window's position and scale factor are sampled twice a second and the platform re-surveyed only when
|
||||
they differ. See §5.4 for the display server where the position itself is unavailable.
|
||||
|
||||
**5.3 Fractional scaling.** "Handled without resampling artefacts in the canvas" — the canvas is a
|
||||
wgpu texture handed to the compositor, so the requirement is that we render at the *physical* pixel
|
||||
size rather than the logical one and let the compositor present 1:1. Worth an explicit test, since
|
||||
the failure is subtle: a slightly soft canvas that looks like a bad demosaic.
|
||||
|
||||
### 5.4 What landed, and the one thing that did not
|
||||
|
||||
`dr_plat::display` surveys the session's displays; `dr_ui::display_ui` decides which one is showing
|
||||
the canvas and keeps `DevelopSession`'s output space pointed at it. Composition was already
|
||||
parameterised on the space, so the pixel path changed by one argument.
|
||||
|
||||
Two things are worth recording because they are trades rather than omissions.
|
||||
|
||||
**A profile is matched to the nearest of four spaces, not applied.** A measured panel is none of
|
||||
`Srgb`, `DisplayP3`, `AdobeRgb` or `ProPhoto`, and a general ICC engine is a much larger piece of
|
||||
work — a CMM, rendering intents, LUT-based profiles, and a per-frame cost to argue about. The
|
||||
profile is reduced to its D50-adapted colorants and matched against the four; a match that is merely
|
||||
nearest is marked as such, and About says "nearest to Display P3" rather than "Display P3". A
|
||||
LUT-based profile, which is what a hardware calibrator often writes, is declined by shape and falls
|
||||
back to sRGB with that stated. An approximation the photographer can see beats a silent one.
|
||||
|
||||
**On Wayland the canvas follows the first output, not the window.** A Wayland client is never told
|
||||
where its window is — `xdg_toplevel` carries no position, deliberately — so the "which display"
|
||||
question cannot be answered by geometry there. The protocol's own answer is
|
||||
`wp_color_management_surface_feedback_v1`, which hands a client the preferred image description for
|
||||
*its surface* and re-sends it on a move; it needs the application's `wl_surface`, which Slint owns
|
||||
and does not expose. So the profiles are read correctly for every output and the *selection* among
|
||||
them is right on X11 and on any single-monitor Wayland session, which is most of them. Closing the
|
||||
gap is a Slint surface handle, not a change to any of this.
|
||||
|
||||
---
|
||||
|
||||
## 6. Extensibility: the format already exists
|
||||
|
||||
`FR-PLG-2` says "the node declaration is the plugin format". That is already true — it is simply
|
||||
resolved at build time:
|
||||
|
||||
```
|
||||
ops/exposure.yaml ──build.rs──▶ generated Rust impl Operation ──▶ fused shader
|
||||
```
|
||||
|
||||
A declaration names its parameters, their ranges and units, its attributes, its WGSL body and its
|
||||
neutral. `build.rs` compiles that into something indistinguishable from a hand-written operation.
|
||||
**Nothing about that requires the declaration to be present at compile time** — everything it
|
||||
produces is data plus a WGSL string, and the composer already assembles WGSL at run time from
|
||||
whatever operations are active.
|
||||
|
||||
So class 1 is not a new mechanism. It is the existing one, loaded later.
|
||||
|
||||
### 6.1 What has to change
|
||||
|
||||
**6.1.1 Descriptors become owned, not `&'static`.** `Operation::descriptor()` returns
|
||||
`&'static OpDescriptor` today, which is what makes a build-time node free and a run-time node
|
||||
impossible. This is the one invasive change in the whole plan and everything else waits behind it.
|
||||
`Arc<OpDescriptor>` is the obvious shape; the cost is one refcount per descriptor read, on a path
|
||||
that reads descriptors when the panel is built rather than per frame.
|
||||
|
||||
**6.1.2 A run-time node type.** One `DeclaredOp` implementing `Operation` from an owned
|
||||
declaration, replacing *generated code per node* with *one interpreter over many declarations*. The
|
||||
generated path can stay for the built-in chain — it costs nothing and keeps the built-ins
|
||||
inspectable — but the two must produce identical behaviour, which is a test: parse each built-in
|
||||
`ops/*.yaml` at run time and assert the composed WGSL matches the generated one byte for byte.
|
||||
|
||||
**6.1.3 WGSL validation at load, not at dispatch.** A plugin's fragment is a string from a stranger.
|
||||
`compose` already builds a full shader and `naga` will reject bad source, but the failure currently
|
||||
surfaces as a broken render. A plugin's source must be compiled and rejected at *load*, with the
|
||||
error naming the plugin, because the alternative is an app that draws nothing and blames itself.
|
||||
|
||||
**6.1.4 Order and identity.** `order:` decides chain position and `build.rs` already refuses
|
||||
duplicates — that guard becomes load-time. Plugin ids need a namespace (`author.name`) so two
|
||||
plugins cannot collide, and the sidecar stores parameters by `(op_id, param_id)`, so an id collision
|
||||
is a *wrong edit silently applied*, exactly the failure `MaskSource::Regions`' signature exists to
|
||||
prevent.
|
||||
|
||||
### 6.2 What this buys immediately
|
||||
|
||||
The features enumerated as missing against Lightroom that are *pure point operations* become
|
||||
declarations rather than code: split toning, colour zones, selective colour, creative vignette,
|
||||
channel mixer variants. A photographer-author can write one without a Rust toolchain, and the
|
||||
existing `ops/README.md` is already its documentation.
|
||||
|
||||
**It does not buy the neighbourhood operations** — dehaze, spot removal, liquify — because those are
|
||||
`DetailStage` implementations with kernels and per-render scale conversion, which FR-PLG-2a
|
||||
anticipates by naming "fragment nodes and pass nodes" as two templates. Pass nodes are a second
|
||||
phase and should not gate the first.
|
||||
|
||||
### 6.3 Order of work
|
||||
|
||||
1. Owned descriptors (6.1.1) — invasive, unblocks everything, no user-visible change
|
||||
2. `DeclaredOp` + byte-identical parity test against the generated built-ins (6.1.2)
|
||||
3. Load-time WGSL validation and id namespacing (6.1.3, 6.1.4)
|
||||
4. A directory that is read at startup, and one shipped example that is not a built-in
|
||||
5. Pass nodes (FR-PLG-2a's second template), once 1–4 are load-bearing
|
||||
|
||||
Classes 2 and 3 — view plugins and computational plugins — are deliberately not in this plan.
|
||||
FR-PLG-3a's "a view plugin cannot be trusted with the UI thread" and FR-PLG-4a's capability grants
|
||||
are both larger design problems than class 1, and class 1 is where the requested features live.
|
||||
|
||||
---
|
||||
|
||||
## 7. What this document does not claim
|
||||
|
||||
Traceability counts a requirement as covered when a `TRACES` tag names it. It does not check that
|
||||
the code under the tag does the thing — `FR-DEV-8` is currently tagged against instance-buffer
|
||||
plumbing that a future spot-removal operation would use, and `FR-DEV-7` against a history row for a
|
||||
frontend that does not exist. Both read as covered.
|
||||
|
||||
So the 51% figure is an overstatement of unknown size, and closing FR-DSP by tagging what already
|
||||
works would make it a larger one. **Every requirement closed by this plan should be closed by a
|
||||
test that would fail if the behaviour were removed**, which is the only kind of coverage worth
|
||||
counting.
|
||||
+942
@@ -0,0 +1,942 @@
|
||||
# Faces and identity — SCRFD and MobileFaceNet
|
||||
|
||||
Spec for **S14**, the spike that decides whether §3.9.1 is buildable, and the build that follows it.
|
||||
|
||||
FR-CULL-8 … FR-CULL-12 specify the subsystem in terms of "a 512-dimension embedding from a stated
|
||||
model" and stop there, deliberately: [catalog.md §10.4](catalog.md) records that the model was left
|
||||
abstract because D13 was open. This document names the two models, fixes the pre- and
|
||||
post-processing they need, specifies the calibration and clustering that sit on top, and states what
|
||||
S14 has to measure before any of it is trusted.
|
||||
|
||||
**It does not close the licensing half of D13.** §2 is the reason, and it comes first because
|
||||
[segmentation.md §7](segmentation.md) established the precedent that reading the grant is cheaper
|
||||
than discovering it at packaging time.
|
||||
|
||||
---
|
||||
|
||||
## 1. The two models, and why these two
|
||||
|
||||
**Detector — SCRFD.** *Sample and Computation Redistribution for Efficient Face Detection* (Guo et
|
||||
al., ICLR 2022). A single-stage anchor-based detector whose contribution is where the FLOPs go, not
|
||||
a new architecture: the shallow stages carry more capacity because that is where small faces are
|
||||
decided. It emits, per face, a box, a confidence, and **five landmarks in the same forward pass** —
|
||||
which is the property that matters here, because FR-CULL-8 requires landmarks and because §4's
|
||||
alignment step is not optional. A detector without landmarks would need a second network to supply
|
||||
them.
|
||||
|
||||
The `500M` variant is the one to ship: 500 MFLOPs at VGA, against 10 GFLOPs for `10G`. Face indexing
|
||||
is a background sweep over a whole library on a phone as well as a desktop (NFR-RES-2), so the cheap
|
||||
variant's accuracy loss on tiny faces is the right trade — a face too small for `500M` to find is
|
||||
also too small for §5 to embed usefully.
|
||||
|
||||
**Embedder — MobileFaceNet trained with ArcFace loss ("MBF").** ~1M parameters, ~0.45 GFLOPs for one
|
||||
112×112 crop, 512-d output. ArcFace's additive angular margin is what makes the output space one
|
||||
where *cosine similarity means something* — the loss explicitly optimises angular separation between
|
||||
identities, which is what §6's calibration then has to convert into a probability.
|
||||
|
||||
**Why not the larger ResNet50 embedder** (`w600k_r50`, the other half of InsightFace's `buffalo_l`):
|
||||
because it is not measurably better here, and it is 13× the file. §1.1's reference project fitted the
|
||||
same Platt calibration (§8) to all four candidates over a 2,418-identity gallery, which is a clean
|
||||
read on the embedding space alone with no tracking or matching logic in it:
|
||||
|
||||
| Model | File | Steepness `a` | Boundary at P=0.5 | Held-out macro F1 |
|
||||
|---|---|---|---|---|
|
||||
| LVFace-B (Glint360K) | 455 MB | 17.7 | sim 0.228 | 67.4% |
|
||||
| **ArcFace w600k-MBF** | **13 MB** | **16.2** | **sim 0.267** | **64.4%** |
|
||||
| ArcFace w600k-R50 | 174 MB | 15.4 | sim 0.301 | — |
|
||||
| ArcFace R18 | 48 MB | 15.3 | sim 0.309 | 63.1% |
|
||||
|
||||
**MBF's calibration curve is steeper than R50's and R18's**, separating same-identity from
|
||||
different-identity pairs more confidently and at a lower boundary, at a fraction of the size. LVFace-B
|
||||
is genuinely better and is 35× the file — a defensible choice for a desktop-only feature and not one
|
||||
for a subsystem that has to index a library on a phone (NFR-RES-2).
|
||||
|
||||
Two caveats on that table, both from the source: the R50 gallery was built with ~30% fewer reference
|
||||
images per identity, which is why it carries no F1 figure — the calibration row does not depend on the
|
||||
image count and is comparable, the benchmark row would not have been. And the F1 column measures a
|
||||
*film-cast identification* task, not this one. It ranks the models; it does not predict DarkRoom's
|
||||
accuracy.
|
||||
|
||||
**Both are pure convolutional graphs**, which matters for §3: the runtime is tract, and tract's
|
||||
operator coverage is the thing that decides whether a graph runs at all.
|
||||
|
||||
### 1.1 A working reference implementation exists
|
||||
|
||||
`../scene-actor-extraction` is a C++ pipeline by the same author that runs **exactly this model
|
||||
pair** — SCRFD-500MF then ArcFace over 5-point-aligned 112×112 crops — over feature films, with a
|
||||
fitted Platt calibration and a scored benchmark behind it. It is **MIT licensed**, so porting from it
|
||||
into GPL-3.0-or-later is straightforward and needs attribution, not permission.
|
||||
|
||||
That changes what S14 is. The open questions are no longer "does this pair work" and "what are the
|
||||
magic numbers" — they are *does tract load these graphs* (§12 M1, and §4.1 says why that is in
|
||||
genuine doubt) and *does the accuracy hold on family snapshots rather than film frames*. The
|
||||
pre-processing constants, the decode layout, and the calibration algorithm are all readable rather
|
||||
than rediscoverable, and several of them are not what the published Python would lead you to write.
|
||||
|
||||
| What | Reference | Ports to |
|
||||
|---|---|---|
|
||||
| ArcFace 5-point template and warp | `src/face_utils.hpp` | `align.rs` (§5) |
|
||||
| SCRFD pre-process, decode, NMS | `src/backends/ort_backend.cpp` `SCRFDDecoder` | `detect.rs` (§4) |
|
||||
| ArcFace pre-process and L2 normalise | same file, `ArcFaceEmbedder` | `embed.rs` (§6) |
|
||||
| Platt fit over a similarity histogram | `src/gallery/gallery_calibration.hpp` | `calibrate.rs` (§8) |
|
||||
| Model-free unit tests for both | `tests/test_face_utils.cpp`, `tests/test_calibration.cpp` | the §3 feature split |
|
||||
|
||||
That last row is worth noticing: the reference already separates the geometry and arithmetic from the
|
||||
inference well enough to unit-test them with no model on the machine. §3's `inference` feature flag is
|
||||
the same boundary, enforced by Cargo rather than by discipline.
|
||||
|
||||
**What does not port.** The reference matches faces against a *known gallery* of named identities;
|
||||
DarkRoom clusters an unlabelled library (§9). And it is a video pipeline, so it has a tracker, a
|
||||
`max_faces` cap, and frame-to-frame identity annealing that have no analogue here.
|
||||
|
||||
---
|
||||
|
||||
## 2. Licensing — the open half of D13
|
||||
|
||||
The architectures are published research. **The weights everyone actually uses are not
|
||||
redistributable by this project.**
|
||||
|
||||
InsightFace's code is MIT. Its *pretrained models* — `buffalo_l`, `buffalo_s`, `buffalo_sc`, and
|
||||
every `det_*` and `w600k_*` checkpoint inside them — carry a **non-commercial research-only** grant,
|
||||
stated in the model-zoo README and applying to both the auto-downloaded and the manually downloaded
|
||||
artefacts. `w600k_mbf` is trained on WebFace600K, whose own terms are research-only as well, so the
|
||||
restriction has two independent sources rather than one that might be renegotiated.
|
||||
|
||||
These are the exact artefacts in question — §1.1's reference project fetches them from InsightFace's
|
||||
own release page, which is where the grant attaches:
|
||||
|
||||
| File | Source |
|
||||
|---|---|
|
||||
| `det_500m.onnx` (2.5 MB) | `insightface/releases/download/v0.7/buffalo_sc.zip` |
|
||||
| `w600k_mbf.onnx` (13 MB) | `insightface/releases/download/v0.7/buffalo_s.zip` |
|
||||
|
||||
DarkRoom is GPL-3.0-or-later. A non-commercial field-of-use restriction is not a GPL-compatible
|
||||
grant, so:
|
||||
|
||||
- these weights are **not redistributable under this project's licence**, so they cannot go into a
|
||||
*published* build — an APK, a Flatpak, or an F-Droid entry — and could not go into a repository
|
||||
intended to be one (§2.2a records what was actually decided here, and why the two differ);
|
||||
- and the restriction binds the *user*, not only the project — a professional photographer using
|
||||
DarkRoom commercially is outside the grant even if they fetched the file themselves.
|
||||
|
||||
That last point is the one that is easy to miss and dishonest to leave implicit. It is why §2.2's
|
||||
recommended route puts the licence text in front of the user rather than in a footnote.
|
||||
|
||||
### 2.1 The routes, priced
|
||||
|
||||
| Route | What ships | Cost |
|
||||
|---|---|---|
|
||||
| **A — commit the weights** | Everything works out of the box, one `git lfs pull` | **Not available.** Licence-incompatible with GPL-3.0 and with all three distribution channels (NFR-COMPAT-2). Listed only so it is visibly rejected rather than quietly assumed. |
|
||||
| **B — permissively licensed weights** | Same, legally | Requires weights that do not exist off the shelf in this architecture pair. Either found (§2.3) or trained, and training an ArcFace embedder needs a face dataset whose own terms permit redistribution — the harder half of the problem. |
|
||||
| **C — the user fetches them** | The app ships the *code*, not the weights; face indexing is off until the user obtains a model | Works today, keeps the repository clean, and is honest. Costs a first-run flow, an F-Droid anti-feature declaration, and a licence notice the user has to read. |
|
||||
|
||||
**Recommendation: C now, B when it becomes possible.** §1.1's project already works this way — a
|
||||
`scripts/download_models.sh` that fetches the weights rather than a repository that carries them —
|
||||
so route C is a practised pattern here rather than a theory. Two things DarkRoom must do that the
|
||||
reference does not: pin a checksum for **every** model (the reference verifies only one of the four),
|
||||
and put the licence in front of the user, because a photo editor's users are not all researchers.
|
||||
|
||||
The schema already forces this to be a survivable choice — `faces.model_id` (catalog.md §10.1) exists precisely so that a model change is
|
||||
detectable and re-indexable rather than silently poisoning every similarity in the library. Under C,
|
||||
swapping in a permissive model later is a new `model_id` and a re-index, not a migration.
|
||||
|
||||
### 2.2 What route C requires of the code
|
||||
|
||||
- **The weights are never a build input.** `dr-face` (§3) takes bytes; it has no `embedded-model`
|
||||
feature and no `models/` directory. This is the one structural difference from `dr-segment`, and
|
||||
it is deliberate — a feature flag that *could* embed weights is a feature flag someone eventually
|
||||
turns on in a packaging script.
|
||||
- **The fetch does not live in `dr-face`.** It lives in the app layer, so the inference crate keeps
|
||||
no network dependency at all. §11 makes that a checkable property rather than a convention.
|
||||
- **The licence is shown, not linked.** Before the first download, the app states in plain language
|
||||
that the weights are research-only, that commercial use is outside the grant, and who the grantor
|
||||
is. NFR-SEC-5's last bullet already requires the models to be named, versioned and licensed in the
|
||||
about screen; this is the same obligation moved to the moment where it can still change a
|
||||
decision.
|
||||
- **A checksum is pinned.** The app verifies the digest of what it fetched against a value compiled
|
||||
in, and refuses a mismatch. An unverified model file is an arbitrary graph executed over the
|
||||
user's family photographs.
|
||||
- **Indexing stays off until a model is present and verified**, and disabling it deletes nothing —
|
||||
that is a separate control (NFR-SEC-5, §11).
|
||||
|
||||
### 2.2a Android, which route C forgot
|
||||
|
||||
Route C says "the user obtains the model and the app loads it". On a desktop that is a real gesture:
|
||||
the files go in `~/.local/share/darkroom/models/` and face indexing starts working. **On Android that
|
||||
gesture does not exist.** `internal_data_path` is app-private, `adb shell run-as` needs a debuggable
|
||||
build, there is no picker and no fetch in the app, and so a phone could not acquire a model by any
|
||||
means at all. Face indexing was not "off until the user supplies weights" there; it was off, full
|
||||
stop, and the settings page said so on every launch with no action available behind the message.
|
||||
|
||||
**Decision, 2026-08-27: the shape-fixed pair is committed to LFS at
|
||||
`apps/darkroom-android/android/assets/models/`, and `assemble-apk.sh` bundles it into the APK.**
|
||||
`android_main` unpacks it to the shared models directory on first launch, before anything asks
|
||||
whether a model is present. This is a personal project on a private Gitea; the grant restricts
|
||||
redistribution, and a private repository and a self-installed APK are not that.
|
||||
|
||||
What that does and does not settle:
|
||||
|
||||
- **It does not make §2.1's route A available.** The moment this project publishes — an F-Droid
|
||||
entry, a release APK, a Flatpak — these files come back out and route C's unbuilt half (the fetch,
|
||||
the licence screen, the pinned checksum) has to exist first. §2.1's table stands as the answer for
|
||||
a *published* build; this is the answer for the author's own phone.
|
||||
- **It does not make the weights a build input.** `dr-face` still has no `models/` directory and no
|
||||
`embedded-model` feature, and nothing in the cargo build reads these files — §2.2's first bullet
|
||||
guards against a flag someone flips in a packaging script, and that remains guarded. The APK
|
||||
assembly step copies two files; it is the only thing in the tree that knows they exist.
|
||||
- **It does not bind only the project.** §2's last bullet is unchanged and is the one with teeth: the
|
||||
research-only grant restricts the *user*, so commercial photography with a DarkRoom that has these
|
||||
weights in it is outside the grant no matter who put them there.
|
||||
|
||||
The in-app fetch, the licence screen, and the pinned checksum that §2.2 specifies are still unbuilt,
|
||||
on every platform. When they are built, this becomes redundant and the assets directory empties.
|
||||
|
||||
### 2.3 Before S14 writes any code
|
||||
|
||||
Three questions to answer by reading, in this order, and to record with the date they were checked —
|
||||
the same discipline [segmentation.md §7](segmentation.md) applied:
|
||||
|
||||
1. **Is there a permissively licensed SCRFD export?** Third-party ONNX exports of SCRFD are
|
||||
plentiful; a re-export inherits the original weights' grant, so the question is whether anyone has
|
||||
*trained* SCRFD on a redistributable dataset, not whether they have converted the InsightFace one.
|
||||
2. **Is there a permissively licensed 512-d MobileFaceNet/ArcFace checkpoint?** Candidates worth
|
||||
reading the terms on rather than assuming: EdgeFace (Idiap), the ONNX Model Zoo's ArcFace entry
|
||||
(MIT, but a ResNet100 at ~250 MB — licence-clean and the wrong size), and any MobileFaceNet
|
||||
trained on a dataset with distribution terms.
|
||||
3. **What does a permissive *detector* alternative cost in accuracy?** YuNet (OpenCV Zoo) is 232 KB,
|
||||
emits five landmarks, and comes from a permissively licensed repository. If it is close enough,
|
||||
the detector half of the licence problem disappears and only the embedder remains — a materially
|
||||
better position than either half alone.
|
||||
|
||||
Question 3 is nearly free to answer: `face_detection_yunet_2023mar.onnx` is **already sitting in
|
||||
§1.1's `models/` directory**, fetched from `opencv/opencv_zoo`. It needs its own decode path (its
|
||||
output layout is different, which is why the reference's `SCRFDDecoder` explicitly rejects it at load
|
||||
rather than silently misreading it), and then it is one more row in §12's table.
|
||||
|
||||
---
|
||||
|
||||
## 3. Crate shape — `core/dr-face`
|
||||
|
||||
A new workspace member, modelled on `dr-segment` and for the same reason: everything that reasons
|
||||
about faces is testable with no GPU adapter and no model present (ARCH §6.5a).
|
||||
|
||||
```
|
||||
core/dr-face/
|
||||
src/lib.rs FaceError, re-exports
|
||||
src/detect.rs SCRFD: preprocess, decode, NMS
|
||||
src/align.rs 5-point similarity transform → 112×112 crop
|
||||
src/embed.rs MBF: preprocess, forward, L2 normalise
|
||||
src/calibrate.rs cosine → P(same person) (FR-CULL-9)
|
||||
src/cluster.rs constrained agglomeration (FR-CULL-10)
|
||||
```
|
||||
|
||||
```toml
|
||||
[features]
|
||||
# No `embedded-model`. §2.2 — the weights are not a build input, ever.
|
||||
default = []
|
||||
# The ONNX runtime. Off by default so `calibrate` and `cluster` — which are
|
||||
# arithmetic over embeddings and have no model in them — stay testable in a
|
||||
# build that carries no inference engine at all.
|
||||
inference = ["dep:ort", "dep:ort-tract", "dep:ndarray"]
|
||||
```
|
||||
|
||||
The `ort` + `ort-tract` pairing is settled by D13's 2026-08-21 update and already in the workspace
|
||||
manifest: `ort`'s API, tract's pure-Rust engine, no C under the NDK.
|
||||
|
||||
**The split between `inference` and the rest is load-bearing.** Calibration and clustering are where
|
||||
the subsystem's accuracy actually lives, they are pure functions of embeddings and labels, and they
|
||||
must be testable against synthetic embedding sets with no weights on the machine. A test suite that
|
||||
needs a research-licensed download to run is a test suite that does not run in CI.
|
||||
|
||||
### 3.1 The API
|
||||
|
||||
```rust
|
||||
/// A loaded detector. Fixed input shape — see §4.
|
||||
pub struct Detector { /* session, input edge */ }
|
||||
|
||||
impl Detector {
|
||||
pub fn from_bytes(onnx: &[u8], id: ModelId) -> Result<Self, FaceError>;
|
||||
/// `rgb` is f32 0..=1, row-major, three per pixel.
|
||||
pub fn detect(&self, rgb: &[f32], w: usize, h: usize, opts: &DetectOptions)
|
||||
-> Result<Vec<Detection>, FaceError>;
|
||||
}
|
||||
|
||||
pub struct Detection {
|
||||
/// Normalised to the image's long edge (catalog.md §10.1).
|
||||
pub bbox: (f32, f32, f32, f32),
|
||||
/// Five points, same normalisation, in the model's own order (§5).
|
||||
pub landmarks: [(f32, f32); 5],
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
pub struct Embedder { /* session */ }
|
||||
|
||||
impl Embedder {
|
||||
pub fn from_bytes(onnx: &[u8], id: ModelId) -> Result<Self, FaceError>;
|
||||
/// Takes an *aligned* 112×112 crop. Alignment is `align::warp`; passing a
|
||||
/// raw bbox crop here is the silent-accuracy-loss bug §5 exists to prevent.
|
||||
pub fn embed(&self, aligned: &Aligned112) -> Result<Embedding, FaceError>;
|
||||
}
|
||||
|
||||
/// L2-normalised, 512-d. Carries its model id so a comparison across models
|
||||
/// is a type error rather than a plausible-looking number (catalog.md §10.1).
|
||||
pub struct Embedding { pub model: ModelId, pub v: [f32; 512] }
|
||||
```
|
||||
|
||||
`Aligned112` is a newtype over the pixel buffer that only `align::warp` can construct. That is the
|
||||
whole defence against §5's failure mode, and it costs nothing.
|
||||
|
||||
---
|
||||
|
||||
## 4. Detection
|
||||
|
||||
### 4.1 Fixed input, and how the image is fitted to it
|
||||
|
||||
**`det_500m.onnx` has a dynamic H/W input, and this is the largest single risk in the document.**
|
||||
§1.1's reference had to use ONNX Runtime rather than OpenCV's `dnn` module precisely because OpenCV
|
||||
could not load SCRFD's dynamic `Shape` nodes. tract is in the same family of problem: `dr-segment`
|
||||
exists in its current shape because tract failed shape inference on YOLO's dynamic export, which is
|
||||
why `tools/export-seg-model.sh` passes `dynamic=False` and why `semantic.rs` has a fixed
|
||||
`INPUT_EDGE`.
|
||||
|
||||
So the graph almost certainly needs its input dimensions frozen before tract will touch it. That is a
|
||||
known, cheap operation — `onnxruntime.tools.make_dynamic_shape_fixed` rewrites the declared dims
|
||||
without retraining or re-exporting from PyTorch — but it is a step, it produces an artefact that must
|
||||
itself be reproducible (a `tools/fix-face-model-shapes.sh`, in the spirit of the existing export
|
||||
script), and **it must be tried before anything else in this document is scheduled.** §12's M1.
|
||||
|
||||
Fixed at **640**, with 320 available as a faster, blinder option to measure.
|
||||
|
||||
**Letterbox.** Scale by `min(640/w, 640/h)` preserving aspect, then paste into a 640×640 canvas. The
|
||||
reference centres the image and fills the margin with grey `114`, inverting with
|
||||
`x_src = (x_model − pad_x) / scale`. What matters is not *where* the padding goes but that the
|
||||
forward and inverse agree and that the fill value is treated as part of the contract: a mismatch
|
||||
between them offsets every box and landmark the model returns by the padding, which yields detections
|
||||
that look plausible, landmarks that align badly, and an embedding-quality loss that only shows up as
|
||||
poor clustering three stages later. Port the reference's convention rather than inventing one, and
|
||||
assert it with a round-trip test.
|
||||
|
||||
Normalisation is `(x·255 − 127.5) / 128`, RGB, NCHW — note `/128`, not `/127.5`; see §6.
|
||||
|
||||
### 4.2 Outputs, and decoding them
|
||||
|
||||
**Nine tensors for three strides `{8, 16, 32}`, twelve for four `{8, 16, 32, 64}`.** Which one a
|
||||
given export produces is discovered at load — `strides = output_count / 3` — not assumed, because
|
||||
both variants exist and hardcoding three silently ignores the largest faces in a twelve-output model.
|
||||
With two anchors per location and `N_s = (640/s)²` locations:
|
||||
|
||||
| Tensor | Shape | Meaning |
|
||||
|---|---|---|
|
||||
| `score_s` | `[N_s·2, 1]` | sigmoid already applied inside the graph |
|
||||
| `bbox_s` | `[N_s·2, 4]` | distances left, top, right, bottom **in units of the stride** |
|
||||
| `kps_s` | `[N_s·2, 10]` | five `(dx, dy)` offsets, same units |
|
||||
|
||||
Anchor centres are `(x·s, y·s)` for each grid cell, repeated once per anchor. Decoding is therefore
|
||||
|
||||
```
|
||||
x1 = cx − l·s x2 = cx + r·s kx_i = cx + dx_i·s
|
||||
y1 = cy − t·s y2 = cy + b·s ky_i = cy + dy_i·s
|
||||
```
|
||||
|
||||
then divide by the letterbox scale to return to source pixels, then normalise by the long edge before
|
||||
storage.
|
||||
|
||||
Flat index within a stride is `(row · fw + col) · 2 + anchor`.
|
||||
|
||||
**A shape assertion at load time, not a decode-time surprise.** `dr-segment` already learned this —
|
||||
`SegmentError::OutputShape` exists because a different export of the same model produces confidently
|
||||
wrong results otherwise. The reference implements exactly this and it is worth porting verbatim:
|
||||
output count divisible by three and between 9 and 12, then the **last dimension of each group checked
|
||||
against `{1, 4, 10}`**. That single check is what catches a YuNet file passed where an SCRFD one was
|
||||
meant — YuNet also has twelve outputs, so the count alone does not distinguish them, and the failure
|
||||
without the check is not an error but a page of plausible garbage.
|
||||
|
||||
### 4.3 Thresholds
|
||||
|
||||
Score ≥ **0.5**, NMS IoU **0.4**, plain greedy NMS per image across all strides together.
|
||||
|
||||
Deliberately *not* the low threshold `dr-segment` chose. There, a false positive costs one spurious
|
||||
entry in a list the user is picking from. Here it costs an entry in the People view that the user has
|
||||
to reject, in a library with thousands of images — and worse, a garbage embedding that participates
|
||||
in clustering and can bridge two real clusters into one. False negatives are recoverable by a later
|
||||
re-index with a better model; a polluted cluster graph is not, once the user has confirmed faces
|
||||
inside it.
|
||||
|
||||
A minimum box size of **40 px on the source proxy** is applied on top — the reference's figure,
|
||||
chosen there for the same reason it holds here: below it there is not enough face left to align
|
||||
reliably, and §7's table shows what the embedder actually receives at that size. §12's M4 is what
|
||||
moves this number, if measurement says it should move.
|
||||
|
||||
**One thing not to port:** the reference caps detections at ten per frame, largest first. That is
|
||||
right for a film frame, where the extras in the background are noise. It is wrong for a photo
|
||||
library, where a group shot with thirty faces in it is precisely the picture the user wants indexed.
|
||||
No cap; the min-size floor is the only filter.
|
||||
|
||||
---
|
||||
|
||||
## 5. Alignment — the step that is silently wrong when skipped
|
||||
|
||||
ArcFace embeddings are trained on faces warped to a canonical 112×112 arrangement. Feeding the model
|
||||
a plain bounding-box crop *works* — it produces 512 numbers, they are unit-norm, and cosine
|
||||
similarities between them look entirely reasonable. They are just much worse, and nothing in the
|
||||
system reports it.
|
||||
|
||||
The transform is a **similarity transform** — rotation, uniform scale, translation, four degrees of
|
||||
freedom — from the five detected landmarks to this template, which is the arrangement the weights were
|
||||
trained against:
|
||||
|
||||
```
|
||||
(38.2946, 51.6963) subject's right eye ─┐ image-left of centre
|
||||
(73.5318, 51.5014) subject's left eye ─┘
|
||||
(56.0252, 71.7366) nose tip
|
||||
(41.5493, 92.3655) subject's right mouth corner
|
||||
(70.7299, 92.2041) subject's left mouth corner
|
||||
```
|
||||
|
||||
Similarity, not affine: a full affine fit to five noisy points will happily shear a face, and shear is
|
||||
exactly the deformation the embedder was never shown.
|
||||
|
||||
**The naming is a trap and the order is not.** Point 0 sits at x=38 on a 112-wide canvas — left of
|
||||
centre *in the image*, which is the subject's **right** eye. Both namings are in circulation and they
|
||||
are opposite. What matters is that SCRFD emits its five points in this same order, so the correct
|
||||
amount of reordering between detector and template is **none**; the reference states this explicitly
|
||||
in `types.hpp` for the benefit of whoever next reads it and doubts it. A future detector with a
|
||||
different order carries its own permutation next to its `model_id`, rather than this file growing an
|
||||
assumption.
|
||||
|
||||
**One divergence from the reference to settle by measurement.** It fits the transform with OpenCV's
|
||||
`estimateAffinePartial2D` under **RANSAC** at a 3-pixel threshold, and drops the detection when the
|
||||
fit fails. RANSAC over five points is a strange fit — the minimal sample for a similarity is two, so
|
||||
it can discard landmarks it judges outliers and solve from a subset, which on a profile face is as
|
||||
likely to be the correct geometry as the wrong one. InsightFace's own pipeline uses a plain
|
||||
least-squares (Umeyama) similarity over all five points, which cannot silently drop anything and is
|
||||
the one to write first. §12's M5 measures whether the choice matters; the degenerate-input path still
|
||||
has to exist either way, because collinear landmarks do occur.
|
||||
|
||||
**A trick worth keeping.** The reference retries detection on a frame where nothing was found, after
|
||||
replicate-padding by 25% and applying CLAHE to the L channel. Padding gives a face at the very edge a
|
||||
context the detector needs; CLAHE rescues underexposed frames. Both are plausible on scanned negatives
|
||||
and backlit photographs — worth trying at low priority, since a retry doubles the cost of exactly the
|
||||
images that yielded nothing.
|
||||
|
||||
Sampling is bilinear from the *source proxy*, in one step — never crop-then-warp, which resamples
|
||||
twice and throws away detail the warp could have used. Pixels falling outside the source are black.
|
||||
|
||||
**The landmark order must match the template order.** The template above is written in the detector's
|
||||
own output order; if a future detector emits them differently, the template is reordered with it and
|
||||
that mapping belongs next to the model id, not compiled in as an assumption.
|
||||
|
||||
*Test:* warp a synthetic image with a known rotation and scale, and assert the five points land on
|
||||
the template within a fraction of a pixel. This is testable with no model present, which is why
|
||||
`align` sits outside the `inference` feature.
|
||||
|
||||
---
|
||||
|
||||
## 6. Embedding
|
||||
|
||||
112×112 **RGB** — the crop comes out of the warp in whatever order the source was in, and the
|
||||
reference converts BGR→RGB explicitly before the blob rather than relying on a flag, which is the
|
||||
readable way to do it.
|
||||
|
||||
Normalisation is `(x·255 − 127.5) / 128`. **Note `/128`, not `/127.5`:** InsightFace's published
|
||||
Python uses `1.0/127.5` for the recognition model, the reference uses `1.0/128` for both models, and
|
||||
every measured number in §1's table was produced with `/128`. The difference is 0.4% of scale and
|
||||
almost certainly immaterial, but "almost certainly" is not a reason to pick silently — write `/128` to
|
||||
match the numbers we have, and settle it with one back-to-back run in §12.
|
||||
|
||||
Output is 512 floats; **L2-normalise before storing**, so every downstream comparison is a dot product
|
||||
and no code path has to remember to normalise. The reference clamps the norm at 1e-6 before dividing,
|
||||
which costs nothing and removes a NaN path.
|
||||
|
||||
Some exports of these graphs are fp16 in, fp16 out. The reference detects this from the graph's
|
||||
declared element type rather than from the filename; worth porting, because the alternative failure is
|
||||
a silent garbage tensor.
|
||||
|
||||
Storage is `512 × f16` (catalog.md §10.1) — 1 KB per face, 25 MB for a 25,000-face library. The f16
|
||||
round-trip perturbs a unit vector by ~1e-3 in cosine, three orders of magnitude below the separation
|
||||
between a match and a non-match, and the halving matters because these rows are the ones NFR-SEC-5
|
||||
contemplates optionally syncing.
|
||||
|
||||
Re-normalise on load after the f16 widen. It is one pass over 512 floats and it removes a class of
|
||||
drift that is otherwise invisible.
|
||||
|
||||
---
|
||||
|
||||
## 7. Which pixels the pipeline actually sees
|
||||
|
||||
FR-CULL-8 pins indexing to the FR-CULL-2 ladder: **the proxy tier, never a full decode.** The
|
||||
relevant tier is `ThumbSize::Large` — 1024 px on the long edge (`dr-thumbs`).
|
||||
|
||||
That has a consequence worth stating in numbers rather than discovering in a clustering report. On a
|
||||
1024 px proxy:
|
||||
|
||||
| Face size in frame | Pixels across | What §6 receives |
|
||||
|---|---|---|
|
||||
| A portrait, face fills a third of the frame | ~340 | Downsampled to 112. Ideal. |
|
||||
| Two people, half-length | ~120 | Roughly native. Good. |
|
||||
| A group of eight | ~50 | **Upsampled** to 112. Degraded, and usable. |
|
||||
| A figure in a landscape | ~20 | At or below §4.3's floor. Rejected. |
|
||||
|
||||
So `faces` records one column beyond catalog.md §10.1's schema:
|
||||
|
||||
```sql
|
||||
ALTER TABLE faces ADD COLUMN crop_px INTEGER NOT NULL; -- source pixels across the aligned crop
|
||||
```
|
||||
|
||||
`crop_px` earns its place three times over. It is the honest quality signal for the UI; it is a
|
||||
**feature in §8's calibration**, which FR-CULL-9 explicitly demands ("a raw cosine means something
|
||||
different for every model, every population, and *every face size*"); and it is what a future
|
||||
higher-resolution re-embedding pass would select on, so that pass becomes a query rather than a
|
||||
re-index of everything.
|
||||
|
||||
**No RAW decode is added.** Where the Large proxy is missing, the job enqueues a `Thumbnail` job at
|
||||
background priority and re-queues itself, exactly as FR-CULL-8 requires.
|
||||
|
||||
---
|
||||
|
||||
## 7a. Recording that detection *ran*
|
||||
|
||||
The spec above assumed the `faces` table could answer "has this image been indexed". **It cannot**,
|
||||
and the difference is the one that decides whether a background pass ever finishes.
|
||||
|
||||
A photograph with no face in it produces no rows. So does one that has never been looked at. Asking
|
||||
`faces` therefore re-queues every landscape, still life and document scan on every pass, for ever —
|
||||
and in a personal library that is most of it. Measured on the reference library: of the first 110
|
||||
images indexed, **64 contain no face at all**.
|
||||
|
||||
So `face_index` records the *run*: one row per `(image, model)` carrying the timestamp, the number of
|
||||
faces found — zero is the interesting value — and the long edge of the proxy it read. Keyed on the
|
||||
model, so a model change puts every image back in the queue without anyone having to remember to
|
||||
clear anything.
|
||||
|
||||
Three things fall out of it that were not otherwise available:
|
||||
|
||||
- **A coverage figure.** "4,812 of 5,000 indexed" is what a user wants to see; counting face rows can
|
||||
only ever report how many faces exist, which is a different number that never reaches the image
|
||||
count.
|
||||
- **A reason for the ones outstanding.** The audit splits them by whether a proxy exists, because
|
||||
*waiting on the thumbnail sweep* and *waiting on face indexing* are different problems and only one
|
||||
of them is fixed by running this again. On the reference library the first check reported 110 ready
|
||||
and 23,417 awaiting a proxy — which is the real state of that library, and not something the face
|
||||
subsystem can do anything about.
|
||||
- **Something to sync.** §14's shards carry the marker with the faces, so an adopted image is not
|
||||
re-detected on the receiving device.
|
||||
|
||||
---
|
||||
|
||||
## 8. Calibration — cosine to probability
|
||||
|
||||
FR-CULL-9 makes this a hard requirement: no code path may threshold a bare cosine, every threshold
|
||||
in the subsystem is stated as a probability, and the fit is per library and reports its own validity.
|
||||
This section is how that is obtained, and the interesting part is where the training pairs come from
|
||||
when the user has labelled nothing yet.
|
||||
|
||||
### 8.1 Where the pairs come from
|
||||
|
||||
**Negatives are free and abundant.** Two faces detected in *the same photograph* are almost never the
|
||||
same person. That gives every multi-face image in the library a full set of negative pairs at no
|
||||
labelling cost — and they are *hard* negatives, drawn from the same camera, lighting, and processing,
|
||||
which is exactly the population where a threshold tuned on easy negatives fails. The known exceptions
|
||||
— mirrors, photographs of photographs, collages, a spliced panorama — are rare enough to be noise at
|
||||
this scale and worth naming so the exception is not mistaken for a bug later.
|
||||
|
||||
**Positives, in order of trustworthiness:**
|
||||
|
||||
1. **User confirmations** (FR-CULL-10). Every pair of faces confirmed to the same person. The gold
|
||||
standard, and empty on day one.
|
||||
2. **Burst siblings.** FR-CULL-5 already groups bursts. Two faces in adjacent frames of one burst,
|
||||
in nearly the same position, are near-certainly the same person. Free, requires no labelling, and
|
||||
available immediately on any library with continuous-shooting frames in it. **Their purity is an
|
||||
S14 measurement** (§12), not an assumption — if bursts turn out to be dirtier than expected, this
|
||||
source is dropped and the calibration simply stays invalid for longer.
|
||||
3. Nothing else. Bootstrapping positives from high cosine similarity is circular — it fits the
|
||||
calibration to the belief it was supposed to test.
|
||||
|
||||
### 8.2 The fit
|
||||
|
||||
§1.1's reference already implements this and its shape should be ported rather than reinvented:
|
||||
|
||||
```
|
||||
P(same | cos) = σ(a·cos + b + log_prior_odds)
|
||||
```
|
||||
|
||||
Four details in it are the difference between working and nearly working.
|
||||
|
||||
**Fit against a histogram, not against pairs.** A 25,000-face library has 3×10⁸ pairs; no gradient
|
||||
descent is running over that. The reference buckets every pair into **200 bins over cos ∈ [−1, 1]**,
|
||||
carrying a positive and a negative count per bin, and fits the two parameters against the per-bin
|
||||
counts. The cost becomes the similarity matrix plus 200 numbers, and the matrix is a single GEMM
|
||||
(§9). This is the trick that makes a per-library fit affordable at all, and it is not obvious from
|
||||
the outside.
|
||||
|
||||
**Balance the classes explicitly.** `w_pos = total/(2·n_pos)`, `w_neg = total/(2·n_neg)`. Negatives
|
||||
outnumber positives by orders of magnitude; an unweighted fit produces a well-shaped curve sitting at
|
||||
the wrong height — precisely the "plausible number all the way to the user interface" failure
|
||||
FR-CULL-9 describes.
|
||||
|
||||
**The base rate is a runtime argument, not part of the fit.** `log_prior_odds` is added at evaluation
|
||||
time, so the balanced fit is stored once and the prior varies per query — the odds that two faces
|
||||
drawn from a 40-image holiday album match are not the odds for a 40,000-image archive. Its inverse,
|
||||
`boundary_at(p)`, gives the cosine at which the probability crosses `p`, which is what turns §9's
|
||||
"merge above 0.9" into an actual comparison. Baking a prior into `a` and `b` would need a refit per
|
||||
context and would make the stored parameters mean something different depending on where they came
|
||||
from.
|
||||
|
||||
**Deduplicate before pairing.** Near-identical embeddings (cos > 1 − 1e-7) are the same photograph
|
||||
counted twice; the reference drops them per identity first. In a photo library the equivalent is
|
||||
duplicates and virtual copies (FR-CAT-11), and leaving them in stacks the positive histogram at
|
||||
cos ≈ 1 with pairs that teach the fit nothing about hard cases.
|
||||
|
||||
**A third feature this design adds.** The reference fits on cosine alone; DarkRoom should carry
|
||||
`crop_px` too:
|
||||
|
||||
```
|
||||
logit P(same) = w₀ + w₁·cos + w₂·log₂(min(crop_px_a, crop_px_b))
|
||||
```
|
||||
|
||||
FR-CULL-9 explicitly names face size as an axis along which an uncalibrated similarity misbehaves,
|
||||
§7 shows a real library spans 40 px to 340 px of face, and `crop_px` is already in hand. The
|
||||
*minimum* of the pair, because a comparison is only as good as its worse crop. It generalises the
|
||||
histogram to a small 2-D grid of bins, which changes nothing structural. If M7 says the extra feature
|
||||
buys nothing, drop it and match the reference exactly — but a film pipeline working from broadcast
|
||||
frames had far less size variation to explain than this one does.
|
||||
|
||||
### 8.3 Validity, and saying so
|
||||
|
||||
Stored per catalog.md §10.3: parameters, a validity flag, and a hash of the face set fitted from.
|
||||
|
||||
Invalid when fewer than **200 positive pairs** or **2,000 negative pairs** are available, or when the
|
||||
reliability check fails.
|
||||
|
||||
Deliberately far stricter than the reference'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
|
||||
early confirmations (§8.1) and the whole risk is fitting confidently to a handful of them. The
|
||||
reference also refuses to draw positives from an identity with fewer than five distinct embeddings,
|
||||
letting it contribute negatives only — the same asymmetry applies to a thinly-confirmed person and is
|
||||
worth keeping. In that state the UI says confidence is unavailable and the People view
|
||||
still works — clustering falls back to a documented default operating point, labelled in the
|
||||
interface as an untuned default, and no probability is displayed. That is FR-CULL-9's requirement
|
||||
read literally: not presenting an untuned default *as though it were measured*.
|
||||
|
||||
Refit is triggered by the same debounce as clustering (§9), and when the confirmed-pair count grows
|
||||
materially.
|
||||
|
||||
*Acceptance (FR-CULL-9):* a reliability diagram over ten probability bins, on a held-out labelled
|
||||
split, with observed match rate within a stated tolerance of the predicted probability in each
|
||||
populated bin. A single accuracy figure is not an answer to this requirement.
|
||||
|
||||
---
|
||||
|
||||
## 9. Clustering
|
||||
|
||||
Not a job kind, for the reason catalog.md §10.2 gives: it is a whole-library operation with no
|
||||
natural `subject_id`. It runs as a debounced library pass when detection has been idle and the face
|
||||
count has moved materially.
|
||||
|
||||
**The graph.** For each face, its `k = 20` nearest neighbours by cosine, then each candidate edge
|
||||
scored through §8 to a probability.
|
||||
|
||||
Brute force is honest arithmetic here, and §1.1's project is the evidence rather than the estimate: it
|
||||
computes the full similarity matrix as **one GEMM** (`E · Eᵀ` over L2-normalised rows) at gallery
|
||||
scale, on CPU, and instruments it well enough to have compared CPU against OpenCL and found the
|
||||
question worth asking rather than urgent. For DarkRoom that is a 25,000 × 512 matrix at 25 MB in and
|
||||
~2.5 GB out — so the matrix is computed **in row blocks**, with each block reduced to its top-k and
|
||||
its histogram contribution before the next is started, and never materialised whole. Once, in the
|
||||
background, after an indexing sweep that took an hour. An approximate index is an optimisation to
|
||||
reach for when §12 says it is needed, not before.
|
||||
|
||||
**Constraints, not just thresholds:**
|
||||
|
||||
- **Cannot-link on co-occurrence.** Two faces in the same image are never merged. This is the same
|
||||
observation §8.1 mines for negatives, used here as a hard constraint, and it is the single
|
||||
cheapest defence against the over-merging FR-CULL-10 warns about.
|
||||
- **Confirmed faces are anchors.** A confirmation is user data (FR-CULL-12) and clustering never
|
||||
moves it. Two clusters each containing confirmations of *different* people cannot merge; a cluster
|
||||
containing confirmations of one person absorbs suggestions but never reassigns the confirmed.
|
||||
|
||||
**The algorithm.** Constrained average-link agglomeration over the probability graph, merging while
|
||||
the average pairwise probability exceeds **0.9** and no cannot-link is violated. Average-link rather
|
||||
than single-link because single-link chains — one bad edge welds two identities together, which is
|
||||
the documented way face clustering fails on families.
|
||||
|
||||
**Incremental by default.** A new face joins the existing cluster whose average probability against
|
||||
it is highest, if that clears the threshold; otherwise it starts an unnamed group. A full
|
||||
re-agglomeration happens on model change, on recalibration, and on explicit user request. Suggestions
|
||||
are recomputed freely; confirmations survive all of it (FR-CULL-10).
|
||||
|
||||
**Splitting** re-agglomerates within one person at a raised threshold and offers the resulting groups
|
||||
as the split. FR-CULL-10 requires splitting to be as easy as merging, and a split that hands the user
|
||||
a pile of loose faces to re-sort is not that.
|
||||
|
||||
---
|
||||
|
||||
## 10. Catalog and jobs
|
||||
|
||||
**Schema** is catalog.md §10.1's v5 migration, plus `faces.crop_px` (§7) and the calibration table
|
||||
(§8.3). Nothing else changes.
|
||||
|
||||
**One new job kind:**
|
||||
|
||||
```rust
|
||||
/// Detect and embed faces in one image, from its proxy (FR-CULL-8).
|
||||
DetectFaces = 8,
|
||||
```
|
||||
|
||||
Background priority, coalesced per `image_id`, interruptible, resumable — it inherits FR-CAT-3's
|
||||
properties with no exceptions, which is what FR-CULL-8's acceptance criterion is about. It is not a
|
||||
network job.
|
||||
|
||||
One job does detection *and* embedding for every face in the image, rather than splitting them.
|
||||
Splitting would double the queue's row count for no benefit: the proxy is already decoded and in
|
||||
memory, and the natural unit of resumable work is one photograph.
|
||||
|
||||
**Selector term** (FR-CULL-11):
|
||||
|
||||
```rust
|
||||
Person { id: PersonId, include_suggested: bool }, // defaults to false
|
||||
```
|
||||
|
||||
Confirmed-only by default, so a saved smart collection does not silently change membership when a
|
||||
later indexing pass revises a guess.
|
||||
|
||||
---
|
||||
|
||||
## 11. Privacy obligations that constrain the code's shape
|
||||
|
||||
NFR-SEC-5 is not a policy to remember; it is a set of properties the code either has structurally or
|
||||
does not have at all. The checkable ones:
|
||||
|
||||
- **`dr-face` has no network dependency.** No `reqwest`, no `ureq`, transitively. Worth a CI check
|
||||
over `cargo tree`, alongside the existing lints — the crate that holds the embeddings should be
|
||||
provably unable to send them anywhere.
|
||||
- **The model fetch (§2.2) is in the app layer**, which is why the API in §3.1 takes bytes.
|
||||
- **The diagnostics bundle is an allowlist** (NFR-OPS-1), so `faces`, `face_person` and the
|
||||
calibration table are excluded by not being named, and a future table cannot become uploadable by
|
||||
existing.
|
||||
- **Delete-all is one transaction and one control**: faces, links, people, calibration, and the
|
||||
cached crops if any. Separately, a switch that stops indexing so no such data is produced. The two
|
||||
are different actions and are not collapsed into one.
|
||||
- **The about screen reads the model metadata from the loader** — id, version, licence — rather than
|
||||
from a hardcoded string that will drift from what is actually running.
|
||||
|
||||
---
|
||||
|
||||
## 12. What S14 measures
|
||||
|
||||
The corpus is a real personal library — the ~2,000-image sample S14 already specifies — with a
|
||||
hand-labelled identity subset. Fixed in advance, so the result is a measurement rather than an
|
||||
argument:
|
||||
|
||||
| # | Measure | Why it decides something |
|
||||
|---|---|---|
|
||||
| **M1** | **Does tract load both graphs?** As shipped, then with the input dims frozen | Go/no-go, and it is *first*. `det_500m.onnx` has a **dynamic H/W input** (§4.1), which is the exact thing tract failed on for YOLO and which OpenCV's `dnn` could not load either — so the as-shipped answer is expected to be no, and the real question is whether `make_dynamic_shape_fixed` is enough or whether a PyTorch re-export is needed. Costs an afternoon; everything below is void without it. |
|
||||
| **M2** | Detection latency per image at 640, and at 320, on the reference desktop and one Android device | Whether a 17k library indexes in a background sweep or a weekend. The one measured datapoint we have — YOLO26n-seg, ~470 ms at 640×640 in tract — suggests SCRFD-500M lands well under it, but tract is not ORT and an extrapolation is not a measurement. |
|
||||
| **M3** | Embedding latency per face, and faces per image in a real library | The multiplier on M2. A library averaging 1.5 faces per image at 100 ms per face is an hour for 25k faces; at 400 ms it is four. |
|
||||
| **M4** | Detection recall against hand-labelled faces, bucketed by `crop_px` | Where §4.3's floor should actually sit, and what proportion of a real library's faces are in the degraded bucket §7 predicts. |
|
||||
| **M5** | **Aligned versus unaligned embeddings**, same corpus, same clustering. Then two A/Bs that cost one run each: least-squares versus RANSAC alignment (§5), and `/128` versus `/127.5` normalisation (§6) | Quantifies §5. If the gap is small the alignment code is still correct; if it is large, this is the measurement that stops someone "simplifying" it later. The two A/Bs close divergences between the reference and the published Python that are currently settled by assertion. |
|
||||
| **M6** | Cluster purity and completeness against the labelled subset | Whether the subsystem is worth building at all. §1's F1 figures rank the models on *film frames against a known cast*; nothing yet says how MBF behaves on family snapshots it must cluster blind. |
|
||||
| **M7** | Calibration reliability, and **the sample size at which the fit becomes valid** | FR-CULL-9's acceptance criterion, plus the practical question of whether a 2,000-image library ever gets a valid fit or whether §8.3's "unavailable" is the normal state. |
|
||||
| **M8** | Burst-derived positive-pair purity (§8.1) | Whether the free positives are usable or the fit waits for user confirmations. |
|
||||
| **M9** | YuNet as a drop-in detector: M4 and M6, re-run | §2.3's question 3, and the model file is already on disk. If the answer is "close enough", half the licence problem disappears. Needs its own decode path — its outputs are not SCRFD's. |
|
||||
| **M10** | Peak RSS during an indexing sweep | NFR-RES-2. Two loaded graphs plus a proxy plus a batch of crops, on a phone. |
|
||||
|
||||
### 12.1 M1 result — **PASS, conditionally** · 2026-08-26
|
||||
|
||||
Measured, not extrapolated. Both InsightFace graphs **fail to load in tract as shipped**, exactly as
|
||||
§4.1 predicted and for the reason it gave:
|
||||
|
||||
```
|
||||
scrfd_500m_bnkps.onnx Translating node #0 "input.1" Source ToTypedTranslator
|
||||
arcface_w600k_mbf.onnx Failed analyse for node #139 "Conv_0" ConvHir
|
||||
```
|
||||
|
||||
Both **load cleanly once their input dimensions are pinned** — SCRFD's unnamed H/W to 640, ArcFace's
|
||||
`None` batch to 1 — by `tools/fix-face-model-shapes.sh`, which rewrites the declared dims and touches
|
||||
no weights. The frozen SCRFD reports the layout §4.2 specifies, which is the second half of the
|
||||
answer: nine outputs, three strides, last dims 1/4/10, and `12800 = 80 × 80 × 2` confirming two
|
||||
anchors per location at stride 8.
|
||||
|
||||
Two things worth carrying forward:
|
||||
|
||||
**SCRFD's outputs were already static.** The export was made at 640 and only its input forgot to say
|
||||
so, so pinning to 640 is not a choice this project is making — it is the shape the graph was always
|
||||
going to run at. §12's "320 as a faster option" would need a different export, not a different flag.
|
||||
|
||||
**YuNet loads with no intervention at all**, at a fixed `[1, 3, 640, 640]`, with twelve outputs in
|
||||
three strides — `cls`/`obj`/`bbox`/`kps`, which is a *different layout* from SCRFD's and confirms why
|
||||
§4.2's load-time check has to look at shapes rather than count outputs. Combined with its permissive
|
||||
licence (§2.3) that makes M9 more interesting than it looked: the permissive detector is also the one
|
||||
with no shape-fixing step in front of it.
|
||||
|
||||
|
||||
### 12.2 First end-to-end run · 2026-08-26
|
||||
|
||||
The Rust port produces the separation it is supposed to. Three distinct portraits of one identity
|
||||
against two of another, from §1.1's labelled gallery:
|
||||
|
||||
| Pair | Cosine |
|
||||
|---|---|
|
||||
| Same identity, different photographs | **0.596** |
|
||||
| Same identity, byte-identical duplicate files | 1.000 |
|
||||
| Different identities | **0.049 – 0.050** |
|
||||
|
||||
Against the reference's fitted MBF boundary of cos 0.267 (§1), 0.596 and 0.05 fall either side with
|
||||
room to spare — which is the check that the port's pre-processing, letterbox inversion and alignment
|
||||
are right, since any of them being wrong degrades the same-identity number first.
|
||||
|
||||
The duplicate row is not a curiosity: several files in that gallery are byte-identical under
|
||||
different names, which is exactly the case §8.2's dedup step exists for, and it would otherwise stack
|
||||
the positive histogram at cos ≈ 1 with pairs that teach the fit nothing.
|
||||
|
||||
**M2/M3 in a debug build:** detection ~1.0–1.4 s per image, embedding ~160–280 ms per face.
|
||||
|
||||
**M2/M3 in release, over a real library — the number that counts: 3.5 images/second**, end to end,
|
||||
including the JPEG decode and the catalog write. 110 images with 125 faces in 30 seconds on the
|
||||
reference desktop. That is roughly **4× the debug figure**, and it moves a 23.5k-image library from
|
||||
the "seven hours" the debug numbers implied to about **110 minutes**.
|
||||
|
||||
Worth stating plainly because the debug measurement was nearly a wrong conclusion: it was on the
|
||||
edge of making the pure-Rust runtime look unaffordable for a large library, and it was measuring the
|
||||
profile rather than the pipeline. Any future timing of this subsystem is a release timing.
|
||||
|
||||
Still unmeasured: the same pass on a phone (NFR-RES-2), which does not follow from this one.
|
||||
|
||||
---
|
||||
|
||||
## 13. Order
|
||||
|
||||
1. **M1** — load both graphs in tract, freezing the input dims if needed (§4.1). Go/no-go, and it
|
||||
now comes first: it is an afternoon, and §2.3's reading is wasted effort if the graphs will not
|
||||
run at all. This is a change from the S14 brief's ordering, made because §1.1 removed the
|
||||
uncertainty that justified reading first — the models are known to work, so the open question is
|
||||
the runtime, not the choice.
|
||||
2. **Licence reading** (§2.3), in parallel with 3. Before anything *ships*, per D13.
|
||||
3. `dr-face` skeleton, `detect` + `align` + `embed`, ported from §1.1's table, with an example binary
|
||||
that draws boxes and landmarks on a JPEG — the same shape as `dr-segment`'s `examples/detect.rs`,
|
||||
and for the same reason: the thing worth looking at is whether the landmarks land on a real
|
||||
photograph. Port `tests/test_face_utils.cpp`'s cases first; they are model-free and they fail
|
||||
loudly on exactly the mistakes §5 describes.
|
||||
4. M2–M5 on the corpus. Alignment is validated here, before anything depends on embedding quality.
|
||||
5. `calibrate` + `cluster` against the labelled subset. M6–M8. The Platt fit ports from
|
||||
`gallery_calibration.hpp`; the pair *sourcing* (§8.1) is new and is the part to get wrong.
|
||||
6. The v5 migration, the `DetectFaces` job, the debounced clustering pass.
|
||||
7. UI: People view, confirm and reject, merge and split, the `Person` selector term.
|
||||
8. The route-C first-run flow, the about-screen entry, and the delete-all control — with the feature
|
||||
shipped disabled until they exist.
|
||||
|
||||
Steps 1–5 are the spike. Steps 6–8 are the build, and they are only justified if §12 says so.
|
||||
|
||||
### 13.1 Where this had got to · 2026-08-26
|
||||
|
||||
- **1 — done.** M1 passes conditionally; §12.1.
|
||||
- **2 — outstanding.** §2.3's three questions are unanswered and gate *shipping*, not building.
|
||||
- **3 — done.** `core/dr-face`: `align`, `detect`, `embed`, `embedding`, and `examples/faces.rs`.
|
||||
§12.2 is its first end-to-end run.
|
||||
- **4 — partly.** M2/M3 have provisional numbers; M4/M5 need the labelled corpus.
|
||||
- **5 — built, not yet measured.** `calibrate` and `cluster` exist with 34 model-free tests behind
|
||||
them; M6–M8 are a run over a real library, which is what the corpus in §1.1's `images/` is for.
|
||||
- **6 — done for storage.** Catalog schema v8 — `people`, `faces`, `face_person`,
|
||||
`face_person_rejected`, `face_calibration` — with the identity operations FR-CULL-10 requires.
|
||||
The `DetectFaces` job kind and the debounced clustering pass are not wired yet.
|
||||
- **7 — done.** The Identity screen: a third top-level mode beside library and develop, reached from
|
||||
the library header. People rail, face grid with per-face confirm/reject, rename in place, confirm
|
||||
all, split off a multi-selection, regroup, and the NFR-SEC-5 delete-everything control.
|
||||
- **8 — not started.** The route-C first-run flow: no model fetch, no checksum pin, no licence
|
||||
notice. The screen says "No face model installed" and stops, which is honest but is not the
|
||||
feature. The models go in `<catalog dir>/models/` as the shape-fixed exports, by hand for now.
|
||||
- **9 — done, and not previously in this plan.** The run marker (§10a), the coverage audit, the
|
||||
`face_index` batch job, and cross-device sync of face shards (§14).
|
||||
|
||||
The screen taught the design one thing worth recording. **Splitting has to reject before it
|
||||
confirms.** Moving faces to a new person is not enough on its own: the next clustering pass sees a
|
||||
face that still looks like the person it left, suggests it back, and the user's correction becomes an
|
||||
argument they keep having. §9's cannot-link constraint handles co-occurrence; this is the same idea
|
||||
applied to a judgement the user made by hand.
|
||||
|
||||
Two things the build changed in this document's own design:
|
||||
|
||||
**`crop_px` reached the schema** as §7 argued it should, and the calibration carries a `w_size` term
|
||||
for it.
|
||||
|
||||
**A rejection table was added**, which §8 and §9 did not contemplate. Rejection is not the absence of
|
||||
an assignment: without storing it, the next clustering pass re-suggests exactly the face the user
|
||||
just pushed away. It is user data in the same sense a confirmation is (FR-CULL-12), just negative.
|
||||
|
||||
---
|
||||
|
||||
## 14. What this does not settle
|
||||
|
||||
- **Whether a permissive model pair exists.** §2.3. If it does, route B replaces route C and step 8's
|
||||
first-run flow shrinks to nothing.
|
||||
- **Whether tract runs these graphs at all.** §12 M1, and §4.1 says why the answer is in real doubt.
|
||||
Every other line of this document is conditional on it.
|
||||
- **Faces in trashed images.** catalog.md §10.4's open question, unchanged: probably excluded from
|
||||
suggestions but not deleted, so a restore does not re-index.
|
||||
- ~~**Whether embeddings sync.**~~ **Answered: they do**, as sealed shards
|
||||
(`dr_catalog::face_shard`). Indexing is hours of CPU and its result is byte-identical on every
|
||||
device, so paying for it once per account rather than once per device is the whole argument.
|
||||
§12.2's 3.5 images/second is also what makes the case: it is fast enough to be worth doing and slow
|
||||
enough to be worth not repeating.
|
||||
|
||||
**Shards rather than the catalog snapshot**, which is the design decision worth recording. The
|
||||
snapshot uploads whole on every sync, and a fully indexed 23.5k library carries ~30 MB of
|
||||
embeddings — exactly the cost `dr_thumbs`'s 25 MB cap exists to bound. So the split follows the one
|
||||
already in the tree: bulk immutable data in sealed shards, small mutable data in the snapshot.
|
||||
Faces, landmarks, embeddings and run markers shard; people, names and assignments ride the catalog
|
||||
and merge by uuid. The cap is *imported* from `dr_thumbs` rather than restated, because it is a
|
||||
statement about transfer cost and two copies of it would drift.
|
||||
|
||||
Everything is keyed on `oc:fileid`, never `image_id`: a row id means nothing on another device.
|
||||
- **Re-embedding at higher resolution.** §7's `crop_px` makes it a query rather than a full re-index,
|
||||
but whether it is worth doing is an M4 question.
|
||||
- **Approximate nearest neighbours.** §9 says brute force until measured otherwise. A 100k-face
|
||||
library is where this stops being true.
|
||||
|
||||
---
|
||||
|
||||
## 15. Register entries
|
||||
|
||||
**D13** — *face inference runtime and model licensing* · the runtime half stays answered (`ort` +
|
||||
`ort-tract`, unchanged since 2026-08-21). **The licensing half is answered conditionally by §2:**
|
||||
route C — ship the code, not the weights — unblocks the build without breaking GPL-3.0 or any
|
||||
distribution channel, and §2.3's reading may yet convert it to route B. Closing D13 outright waits on
|
||||
that reading.
|
||||
|
||||
**D17** — *face model pair and distribution route* · **PROPOSED**. SCRFD-500M for detection,
|
||||
MobileFaceNet/ArcFace for embedding, weights obtained by the user rather than redistributed, with a
|
||||
pinned checksum and a licence notice shown before the first fetch. The alternative considered and
|
||||
rejected is committing the InsightFace weights (§2.1 route A), which is not available at any price.
|
||||
The model *pair* is better evidenced than a proposal usually is — §1's table is a measured comparison
|
||||
over a 2,418-identity gallery, not a literature reading — so the open half of this decision is the
|
||||
route, not the pair. Relates to: D13, D14, NFR-COMPAT-2, FR-CULL-8.
|
||||
|
||||
**D18** — *porting from `scene-actor-extraction`* · **PROPOSED, and the easy half of a decision**.
|
||||
That project is MIT and by this project's author, so MIT-into-GPL-3.0-or-later is a compatible
|
||||
one-way combination requiring attribution, not permission. Ported files carry a header naming the
|
||||
origin. Worth recording because "we already have this working in another language" is exactly the
|
||||
provenance that goes undocumented and then cannot be answered three years later.
|
||||
|
||||
**S14** — *face pipeline in Rust* · scope sharpened, and **substantially de-risked**, by this
|
||||
document. §12 replaces "measure per-image latency, cluster purity and calibration convergence" with
|
||||
ten specific measures. Two changes to the brief itself: its instruction to resolve the licence
|
||||
question *before writing any of it* is relaxed to "before shipping any of it", because M1 is an
|
||||
afternoon and is worth knowing first (§13); and its central question — whether the pipeline works at
|
||||
all — is largely answered in advance by §1.1, leaving the runtime and the domain shift from film
|
||||
frames to family snapshots as the real unknowns.
|
||||
|
||||
---
|
||||
|
||||
## 16. Requirements touched
|
||||
|
||||
| ID | How this document addresses it |
|
||||
|---|---|
|
||||
| FR-CULL-8 | §4 detection, §6 embedding, §7 the proxy tier and its consequences, §10 the `DetectFaces` job |
|
||||
| FR-CULL-9 | §8 — pairs, fit, validity, and the reliability-diagram acceptance test |
|
||||
| FR-CULL-10 | §9 constrained agglomeration, confirmations as anchors, split by re-agglomeration |
|
||||
| FR-CULL-11 | §10 the `Person` selector term, confirmed-only by default |
|
||||
| FR-CULL-12 | §10 schema unchanged from catalog.md §10.1: embeddings derived, names to the sidecar |
|
||||
| NFR-SEC-5 | §11 — the obligations restated as structural properties, one of them CI-checkable |
|
||||
| NFR-COMPAT-2 | §2 — why the obvious weights cannot ship, and what does instead |
|
||||
| NFR-RES-2 | §1 the cheap model pair, §12 M2/M3/M10 on a phone |
|
||||
| NFR-ARCH-2 | §10 background priority, preempted by visible work |
|
||||
@@ -0,0 +1,297 @@
|
||||
# What a frame costs
|
||||
|
||||
**Status:** Measured · 2026-08-27
|
||||
**Companion to:** [display-and-extension.md](display-and-extension.md) §2–3 ·
|
||||
[requirements.md](requirements.md) §3.4 FR-DSP-2, FR-DSP-3, FR-DSP-4
|
||||
**Instrument:** [`core/dr-gpu/examples/frame_budget.rs`](../core/dr-gpu/examples/frame_budget.rs)
|
||||
**Guard:** [`core/dr-gpu/tests/frame_budget.rs`](../core/dr-gpu/tests/frame_budget.rs)
|
||||
|
||||
[display-and-extension.md](display-and-extension.md) §2 fixed a decision rule in
|
||||
advance and made three measurements the thing that settles it. This file is
|
||||
those measurements, and the recommendation they support.
|
||||
|
||||
Rerun with:
|
||||
|
||||
```sh
|
||||
cargo run --release -p dr-gpu --example frame_budget
|
||||
```
|
||||
|
||||
and diff this file. That is the whole point of committing numbers: a regression
|
||||
should be a diff rather than somebody's recollection of how fast it used to be.
|
||||
|
||||
---
|
||||
|
||||
## The answer, first
|
||||
|
||||
**FR-DSP-2 should be rewritten, not implemented.** M1 and M2 sit inside the
|
||||
16 ms budget at the 99th percentile for every chain of point operations at every
|
||||
viewport size measured, fit and at 1:1 — the widest case, every operation that
|
||||
contributes a fragment to the fused shader at 4K, costs **4.5 ms** on the GPU and
|
||||
**8.2 ms** including the composition that precedes it. Tiling the interactive
|
||||
path would be optimising something that is already using a quarter of its budget.
|
||||
|
||||
**But the measurement did find a budget-breaker, and it is not the one tiling
|
||||
fixes.** The neighbourhood stage — clarity in particular — costs **34 ms at 4K
|
||||
on its own**, twice the whole budget, and tiles do not help it: a tile of a
|
||||
convolution has to read its halo, so tiling raises the total tap count rather
|
||||
than lowering it. §2 predicted this exactly ("a separable blur at a large radius
|
||||
is the plausible budget-breaker, not the fused pass"), and the fix it needs is
|
||||
the one `local_contrast`'s own module documentation already names — a base
|
||||
computed at reduced resolution — which is a change to `crate::detail`, not a
|
||||
tile scheduler.
|
||||
|
||||
There is a third finding nobody was looking for: **shader composition costs
|
||||
3–5 ms of CPU per frame on a full chain**, on the UI thread, before any GPU work
|
||||
is submitted. That is a fifth to a third of the budget spent formatting strings,
|
||||
and it is invisible to any amount of tiling.
|
||||
|
||||
---
|
||||
|
||||
## Conditions
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Adapter | NVIDIA GeForce RTX 3050 6GB Laptop GPU (Vulkan) |
|
||||
| Source | 9504 × 6336 synthetic (60.2 MP, 482 MB as `rgba16f`) |
|
||||
| Frames | 100 measured per row, 12 warm-up frames discarded |
|
||||
| Percentile | Nearest-rank, so p99 of 100 frames is the second-worst frame |
|
||||
| Build | `--release` |
|
||||
| Date | 2026-08-27 |
|
||||
|
||||
`shader` is `EditGraph::compose` alone. `cpu` adds the detail chain and the
|
||||
invalidation hash — everything `DevelopSession::render` does per frame before it
|
||||
dispatches. `gpu` is submit plus wait-for-idle, which serialises the GPU work
|
||||
into the frame that caused it and is therefore pessimistic. `TOTAL` ranks
|
||||
`cpu + gpu` summed **within each frame**, which is the column the budget is
|
||||
judged on; adding two percentiles instead would invent a stutter that no frame
|
||||
actually had.
|
||||
|
||||
Chains: `one` is exposure. `five` is exposure, contrast, highlights/shadows,
|
||||
blacks/whites, vibrance. `point` is every operation in the default chain that
|
||||
contributes a fragment to the fused shader, film stock included. `all` is `point`
|
||||
plus the four neighbourhood operations — noise reduction, capture sharpening,
|
||||
clarity and texture.
|
||||
|
||||
---
|
||||
|
||||
## M1 — the fused pass at proxy resolution
|
||||
|
||||
The develop view: the whole frame fit to the viewport.
|
||||
|
||||
| size | chain | shader | cpu p99 | gpu p50 | gpu p99 | TOTAL | |
|
||||
|------------:|------:|-------:|--------:|--------:|--------:|--------:|:-----|
|
||||
| 1920 × 1200 | one | 0.08ms | 0.10ms | 1.02ms | 1.23ms | 1.31ms | |
|
||||
| 1920 × 1200 | five | 0.18ms | 0.20ms | 1.01ms | 1.20ms | 1.36ms | |
|
||||
| 1920 × 1200 | point | 2.79ms | 2.82ms | 1.98ms | 2.18ms | 4.83ms | |
|
||||
| 1920 × 1200 | all | 3.65ms | 4.73ms | 6.86ms | 7.37ms | 12.02ms | |
|
||||
| 2560 × 1600 | one | 0.08ms | 0.10ms | 1.73ms | 2.00ms | 2.12ms | |
|
||||
| 2560 × 1600 | five | 0.24ms | 0.27ms | 1.73ms | 2.26ms | 2.46ms | |
|
||||
| 2560 × 1600 | point | 2.82ms | 2.85ms | 2.37ms | 2.65ms | 5.38ms | |
|
||||
| 2560 × 1600 | all | 3.37ms | 4.35ms | 14.31ms | 15.65ms | 18.42ms | OVER |
|
||||
| 3840 × 2160 | one | 0.10ms | 0.14ms | 3.09ms | 3.31ms | 3.42ms | |
|
||||
| 3840 × 2160 | five | 0.30ms | 0.32ms | 3.03ms | 3.40ms | 3.61ms | |
|
||||
| 3840 × 2160 | point | 3.62ms | 3.65ms | 4.12ms | 4.52ms | 8.23ms | |
|
||||
| 3840 × 2160 | all | 4.12ms | 5.07ms | 37.73ms | 40.17ms | 43.24ms | OVER |
|
||||
|
||||
Read the `point` rows: **the fused dispatch scales with pixels and almost not at
|
||||
all with chain length.** Going from one operation to the entire point chain at
|
||||
4K costs 1.2 ms of GPU. Going from 2.3 M pixels to 8.3 M costs 2.3 ms. Both are
|
||||
small, and the second is the one tiling would address.
|
||||
|
||||
The `all` rows go over, and the `point` rows in the same block are what say why:
|
||||
the difference between them is the neighbourhood stage, measured on its own in
|
||||
M3 and arriving at almost exactly the same figure.
|
||||
|
||||
## M2 — the same, zoomed to 1:1 on the 60 MP source
|
||||
|
||||
FR-DSP-5's case. `Framing::view` shrinks the sampled region while the render
|
||||
target keeps its size, so one render pixel lands on one source pixel.
|
||||
|
||||
| size | chain | shader | cpu p99 | gpu p50 | gpu p99 | TOTAL | |
|
||||
|------------:|------:|-------:|--------:|--------:|--------:|--------:|:-----|
|
||||
| 1920 × 1200 | one | 0.12ms | 0.14ms | 0.42ms | 0.66ms | 0.75ms | |
|
||||
| 1920 × 1200 | five | 0.27ms | 0.30ms | 0.49ms | 1.14ms | 1.22ms | |
|
||||
| 1920 × 1200 | point | 3.49ms | 3.52ms | 1.18ms | 1.39ms | 4.85ms | |
|
||||
| 1920 × 1200 | all | 3.64ms | 5.18ms | 8.96ms | 9.55ms | 14.30ms | |
|
||||
| 2560 × 1600 | one | 0.11ms | 0.12ms | 0.56ms | 0.99ms | 1.06ms | |
|
||||
| 2560 × 1600 | five | 0.22ms | 0.25ms | 0.77ms | 1.02ms | 1.17ms | |
|
||||
| 2560 × 1600 | point | 2.96ms | 2.99ms | 2.03ms | 2.52ms | 5.61ms | |
|
||||
| 2560 × 1600 | all | 5.24ms | 7.35ms | 18.80ms | 21.62ms | 25.81ms | OVER |
|
||||
| 3840 × 2160 | one | 0.10ms | 0.12ms | 1.31ms | 1.52ms | 1.63ms | |
|
||||
| 3840 × 2160 | five | 0.14ms | 0.27ms | 1.39ms | 1.64ms | 1.75ms | |
|
||||
| 3840 × 2160 | point | 3.14ms | 3.16ms | 4.04ms | 4.50ms | 7.21ms | |
|
||||
| 3840 × 2160 | all | 4.84ms | 6.78ms | 47.22ms | 48.79ms | 54.47ms | OVER |
|
||||
|
||||
**A 1:1 view of a 60 MP file is cheaper than the fit view of the same file**, for
|
||||
every point chain and at every size — 1.52 ms against 3.31 ms for one operation
|
||||
at 4K. That is not a rounding artefact and it is worth stating plainly, because
|
||||
it is the opposite of what "full resolution" sounds like it should cost. The
|
||||
dispatch is the same number of pixels either way; what changes is where those
|
||||
pixels read from. A fit view walks the whole 482 MB texture on a stride, and a
|
||||
1:1 view reads a contiguous window of it that fits comfortably in cache.
|
||||
|
||||
So the resolution FR-DSP-5 promises costs nothing extra on the fused path.
|
||||
Zooming is not an expensive mode to be dreaded and progressively refined into;
|
||||
it is the cheap one.
|
||||
|
||||
The `all` rows are worse at 1:1 than fit, and that is the detail stage again for
|
||||
a specific reason: noise reduction's radius is stated in *source* pixels, so
|
||||
`RenderScale::ratio` climbing to 1.0 widens its kernel. Clarity's is stated as a
|
||||
fraction of the frame and does not move. M3 separates the two.
|
||||
|
||||
## M3 — the neighbourhood stage alone
|
||||
|
||||
Timed with the fused dispatch deliberately reused: only a detail parameter moves,
|
||||
so `render_detailed` skips the colour pass (FR-DEV-3d) and what remains is the
|
||||
convolutions. `colour` counts fused dispatches over the measured frames and is
|
||||
zero on every row, which is what makes these numbers mean "detail alone" rather
|
||||
than asserting it.
|
||||
|
||||
| size | stage | view | pass | radius | colour | cpu p99 | p50 | p99 |
|
||||
|------------:|---------:|:-----|-----:|-------:|-------:|--------:|--------:|--------:|
|
||||
| 1920 × 1200 | clarity | fit | 2 | 29 | 0 | 1.60ms | 5.42ms | 5.99ms |
|
||||
| 1920 × 1200 | all four | fit | 7 | 29 | 0 | 1.71ms | 5.78ms | 6.16ms |
|
||||
| 1920 × 1200 | clarity | 1:1 | 2 | 29 | 0 | 1.12ms | 7.51ms | 8.01ms |
|
||||
| 1920 × 1200 | all four | 1:1 | 9 | 29 | 0 | 2.71ms | 8.25ms | 9.11ms |
|
||||
| 2560 × 1600 | clarity | fit | 2 | 38 | 0 | 1.03ms | 12.02ms | 12.44ms |
|
||||
| 2560 × 1600 | all four | fit | 7 | 38 | 0 | 1.87ms | 12.49ms | 13.16ms |
|
||||
| 2560 × 1600 | clarity | 1:1 | 2 | 38 | 0 | 1.87ms | 15.82ms | 16.60ms |
|
||||
| 2560 × 1600 | all four | 1:1 | 9 | 38 | 0 | 2.71ms | 17.24ms | 18.06ms |
|
||||
| 3840 × 2160 | clarity | fit | 2 | 52 | 0 | 1.75ms | 33.11ms | 33.89ms |
|
||||
| 3840 × 2160 | all four | fit | 7 | 52 | 0 | 1.76ms | 34.21ms | 35.03ms |
|
||||
| 3840 × 2160 | clarity | 1:1 | 2 | 52 | 0 | 1.08ms | 40.39ms | 41.86ms |
|
||||
| 3840 × 2160 | all four | 1:1 | 9 | 52 | 0 | 2.37ms | 43.29ms | 44.72ms |
|
||||
|
||||
`radius` is the widest halo any pass reads, in render pixels.
|
||||
|
||||
Clarity alone is 97% of the cost of all four neighbourhood operations together,
|
||||
at every size. Its σ is 1.2% of the shorter edge and it truncates at 2σ, so its
|
||||
radius is 29 px on a 1200 px viewport and **52 px at 4K** — two separable passes
|
||||
of 105 taps each, over 8.3 M pixels, which is 1.7 billion texture reads. That is
|
||||
the whole of the problem, and the numbers scale as `radius × pixels` exactly as
|
||||
that description predicts: 5.99 → 12.44 → 33.89 ms for radii of 29 → 38 → 52 over
|
||||
2.3 → 4.1 → 8.3 M pixels.
|
||||
|
||||
The extra cost at 1:1 is noise reduction and capture sharpening, whose radii are
|
||||
properties of the sensor rather than of the frame. That is the correct behaviour
|
||||
— it is why `RenderScale` has two units — and it is bounded by the kernel caps
|
||||
those operations already declare.
|
||||
|
||||
---
|
||||
|
||||
## Reading this against §2's decision rule
|
||||
|
||||
§2: *"If M1 and M2 sit inside 16 ms at the 99th percentile, FR-DSP-2 is
|
||||
rewritten rather than implemented … If they do not, the measurement tells us
|
||||
which stage to tile."*
|
||||
|
||||
Both halves of the rule fire, on different stages, and the honest reading takes
|
||||
both.
|
||||
|
||||
### FR-DSP-2 — rewrite it
|
||||
|
||||
For the fused pass the rule passes with a wide margin. Every point chain at
|
||||
every size, fit and at 1:1, is inside 16 ms — the worst `TOTAL` is 8.23 ms and
|
||||
the worst GPU figure is 4.52 ms. There is no viewport size on a desktop display
|
||||
where recomputing the entire point chain over every visible pixel is a problem.
|
||||
|
||||
Two further reasons not to build the tile scheduler as written:
|
||||
|
||||
1. **Panning, which is the case ARCH §5.3's tile cache is designed for, gets no
|
||||
benefit here.** Reusing already-valid tiles saves recomputation. Recomputing
|
||||
the whole 4K viewport costs 4.5 ms, so a perfect tile cache could save at most
|
||||
4.5 ms of a 16 ms budget, at the price of a cache keyed by
|
||||
`(VersionId, tile, zoom, graph_hash_prefix)` that has to stay correct across
|
||||
every parameter change in the graph. That is a large correctness surface
|
||||
bought with a small number.
|
||||
|
||||
2. **It would make the actual problem worse.** The stage that misses the budget
|
||||
is a convolution, and a tiled convolution reads a halo per tile. At a 52-pixel
|
||||
radius, 256-pixel tiles would read (256+104)² instead of 256² — very nearly
|
||||
*twice* the taps. Tiling is the wrong tool for the one stage that needs a
|
||||
tool.
|
||||
|
||||
So FR-DSP-2 becomes what §2 said it actually is for this architecture: a
|
||||
scheduling concern for export and thumbnailing, both of which already run off
|
||||
the frame path. The interactive path does not tile.
|
||||
|
||||
### The stage that does need work — and it is not tiling
|
||||
|
||||
The measurement's real product is naming the stage. It is `local_contrast`, and
|
||||
the fix is stated in that module's own documentation:
|
||||
|
||||
> 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.
|
||||
|
||||
A Gaussian base at a quarter resolution is 1/16 the pixels at 1/4 the radius —
|
||||
about 1/64 of the work — and the result is visually identical because a base at
|
||||
σ = 26 px has no content above the quarter-resolution Nyquist to lose. That is a
|
||||
change to two files with a bounded blast radius, and it is what the 34 ms buys
|
||||
back. It should be tracked as its own item rather than smuggled in under a
|
||||
requirement about tiles.
|
||||
|
||||
### FR-DSP-3 — the clause that should be narrowed
|
||||
|
||||
§3.3 proposes narrowing "when a full-resolution result is needed it is computed
|
||||
asynchronously, and the proxy result remains on screen until it is ready" to
|
||||
export and 1:1 zoom, or striking it.
|
||||
|
||||
**M2 says strike it.** The clause exists to hide the latency of a
|
||||
full-resolution render behind a proxy. There is no such latency: the 1:1 view is
|
||||
*faster* than the fit view on the fused path, and there is no second
|
||||
full-resolution code path to be asynchronous about — `Framing::view` is the
|
||||
whole mechanism. Export renders its own frames on a worker already. Keeping the
|
||||
clause would mean building a progressive-swap machine to conceal a render that
|
||||
completes in 1.4 ms.
|
||||
|
||||
### FR-DSP-4 — satisfied vacuously, on the fused path
|
||||
|
||||
§4 makes progressive refinement conditional on M1 failing. On the fused path M1
|
||||
passes, so reduced-quality rendering during a drag would buy nothing and cost the
|
||||
visible softness the requirement itself warns against.
|
||||
|
||||
The neighbourhood stage is the exception, and it is worth being precise: what
|
||||
that stage needs is not *progressive* refinement — it is a permanently cheaper
|
||||
base, computed at reduced resolution and correct at any moment the user stops.
|
||||
"Render coarse while dragging, sharpen when it settles" would paper over the same
|
||||
34 ms with a visible swap. Fix the stage.
|
||||
|
||||
---
|
||||
|
||||
## What is not measured here
|
||||
|
||||
Stated because §7 of [display-and-extension.md](display-and-extension.md) asks
|
||||
for it, and because each of these could move the numbers.
|
||||
|
||||
- **Local adjustments.** The mask stack is a separate chain per layer and is not
|
||||
in any row above. `render_masked` takes them and the fused shader addresses
|
||||
them per layer, so a heavily masked edit costs more than `all`.
|
||||
- **Spot repairs.** These add detail passes, and their cost is per spot.
|
||||
- **Lens corrections.** Not part of `EditGraph::default_chain` — they are built
|
||||
from a matched profile — so the `point` row does not include the warp chain.
|
||||
- **Demosaic.** Once per photograph on a worker, not on the frame path.
|
||||
- **Presentation.** The bench waits for the device to go idle inside the frame it
|
||||
measures. A real compositor overlaps frames, so these figures are an upper
|
||||
bound rather than an estimate.
|
||||
- **One adapter.** A discrete laptop GPU. The Intel iGPU on the same machine, and
|
||||
Android, will be slower — which is an argument for the conclusion rather than
|
||||
against it: the stage with no headroom has none to lose.
|
||||
|
||||
## The CPU finding, which deserves its own item
|
||||
|
||||
`EditGraph::compose` costs 2.8–5.2 ms per frame on a full chain, at every
|
||||
resolution, because it is resolution-independent: it assembles a WGSL string and
|
||||
hashes it. On the `all` rows it is a third of what is left of the budget after
|
||||
the GPU has taken its share, and at 1920 × 1200 it is larger than the entire
|
||||
fused dispatch.
|
||||
|
||||
Nothing in this document's recommendations changes it, and it is the cheapest
|
||||
remaining win. The generated *source* depends only on the structure of the graph
|
||||
— that is what `structure_hash` already identifies, and it is precisely what does
|
||||
not change while a slider is being dragged, which is why the pipeline cache in
|
||||
`AdjustPass` does not recompile. The uniforms do change, but assembling them is a
|
||||
handful of floats per operation. So caching the source string against the
|
||||
structure hash and rebuilding only the uniforms would take these milliseconds to
|
||||
approximately nothing, on the path that needs them most. Worth its own entry in
|
||||
[technical-debt.md](technical-debt.md).
|
||||
@@ -0,0 +1,569 @@
|
||||
# Spot removal
|
||||
|
||||
**Status:** Draft · 2026-08-26
|
||||
**Companion to:** [requirements.md](requirements.md) §3.3 FR-DEV-8 · [architecture.md](architecture.md) §5.2
|
||||
|
||||
The last develop feature the requirements ask for that nothing in the tree
|
||||
implements. FR-DEV-8 states the shape — "non-destructive clone and heal spots
|
||||
stored as parameters in the edit graph (target, radius, feather, source offset,
|
||||
opacity, mode), with automatic source placement and manual override, plus a
|
||||
visualise-spots mode" — and this document is how that lands on the pipeline
|
||||
that exists now.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why it is worth the work
|
||||
|
||||
Sensor dust is unavoidable with interchangeable lenses, and a dust spot is the
|
||||
most common reason a photographer leaves a RAW editor for a pixel editor
|
||||
mid-workflow. Every other develop operation in this application can be the best
|
||||
one in its class and the workflow still breaks at the first frame with a mark on
|
||||
the sky.
|
||||
|
||||
It is also, unusually, a feature whose cost has already been paid twice over.
|
||||
The neighbourhood stage exists ([`crate::detail`](../core/dr-pipeline/src/detail.rs)),
|
||||
the convention for storing geometry in normalised source coordinates exists
|
||||
([`mask.rs`](../core/dr-pipeline/src/mask.rs)), the canvas-drag pattern exists
|
||||
([`gradient.rs`](../ui/dr-ui/src/gradient.rs)), and the merge-by-id rule exists
|
||||
([`sidecar.rs`](../core/dr-pipeline/src/sidecar.rs)). What is genuinely new is
|
||||
small and is named in §3.
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- **Not layer-based pixel editing.** §1.3 of the requirements excludes that and
|
||||
this does not reopen it. A spot is a handful of numbers in the edit graph; no
|
||||
pixels are stored, and the original file is never touched.
|
||||
- **Not content-aware fill.** The source is a patch from the same photograph,
|
||||
chosen by an offset. Synthesising texture that is not in the frame is a
|
||||
different problem with a different budget.
|
||||
- **Not a general clone brush.** A spot is a disc, not a stroke. A dragged
|
||||
clone brush is expressible on top of this (a stroke *is* a run of discs) and
|
||||
is deliberately left until the disc is finished and used.
|
||||
- **Not automatic dust detection.** Finding spots without being asked is a
|
||||
reasonable later feature and a bad first one: a false positive silently alters
|
||||
a photograph, which is the failure this application must not have.
|
||||
|
||||
## 3. What is new, precisely
|
||||
|
||||
Four things, and it is worth being blunt about them because everything else in
|
||||
this document is assembly of parts that already work:
|
||||
|
||||
1. **A detail pass with variable-length data.** Every [`DetailPass`] today
|
||||
carries a `Vec<f32>` of uniforms fixed by its own structure. A spot list is
|
||||
neither fixed nor small. §7.
|
||||
2. **A neighbourhood operation whose reach is not a small kernel.** Every
|
||||
existing pass declares a halo of a few pixels. A spot reads from wherever its
|
||||
source is, which may be a third of the frame away. §5.3.
|
||||
3. **Undo over something that is not a parameter.** [`History`] snapshots a
|
||||
[`Preset`], which is a map of scalars — so mask edits are already outside
|
||||
undo, and spots must not be. §10.4.
|
||||
4. **A canvas mode that *creates* objects.** Crop edits one rect; local selects
|
||||
a region; gradient drags an existing shape. Nothing yet makes a new thing
|
||||
where the pointer went down. §10.
|
||||
|
||||
## 4. The model
|
||||
|
||||
```rust
|
||||
/// TRACES: FR-DEV-8
|
||||
pub struct Spot {
|
||||
/// Stable across devices; see below.
|
||||
pub id: String,
|
||||
/// What is being covered, in normalised **source** coordinates.
|
||||
pub centre: (f32, f32),
|
||||
/// What covers it, as an offset from `centre` in **frame units**
|
||||
/// (y spans 0..1, x spans 0..aspect — the mask convention).
|
||||
pub offset: (f32, f32),
|
||||
/// The radius of the disc, in frame units.
|
||||
pub radius: f32,
|
||||
/// Fraction of `radius` over which the edge falls away. 0 is hard.
|
||||
pub feather: f32,
|
||||
/// How much of the patch is laid down. 1.0 is opaque.
|
||||
pub opacity: f32,
|
||||
pub mode: SpotMode, // Heal | Clone
|
||||
pub enabled: bool,
|
||||
}
|
||||
```
|
||||
|
||||
**Units follow the mask rule, for the mask reason.** A length stored in pixels
|
||||
is a length that means something different in the preview and in the export
|
||||
([`RenderScale`]'s whole documentation is this argument). `centre` is normalised
|
||||
source, so a crop, a zoom, a pan and a rotation move the spot with the
|
||||
photograph and no arithmetic is needed to keep it there.
|
||||
|
||||
Every *length* — radius, feather, offset — is in the frame's **isotropic
|
||||
units**, `MaskSource::Radial`'s convention, where y spans `0..1` and x spans
|
||||
`0..aspect`. Only in those units is a disc a disc: normalised coordinates would
|
||||
make a spot on a 3:2 frame an ellipse half again wider than it is tall. They are
|
||||
lengths against the *source* frame rather than the rendered region, so cropping
|
||||
does not resize a spot already placed — a dust mark is a fact about the sensor,
|
||||
not about the composition.
|
||||
|
||||
One unit for all three, deliberately. A radius in shorter-edge fractions beside
|
||||
an offset in frame units agrees on a landscape frame and silently disagrees on a
|
||||
portrait one, which is a bug that stays invisible until somebody rotates a
|
||||
photograph.
|
||||
|
||||
**`offset` is a vector, not a second point.** Dragging the destination moves the
|
||||
source with it, which is what a photographer expects when they nudge a spot half
|
||||
a pixel and do not want to re-place the source. Moving the source alone is
|
||||
editing `offset`.
|
||||
|
||||
**The id is derived, not counted.** `MaskStack::next_id` numbers layers, which
|
||||
is fine for a stack a user names, and wrong here: two devices that each place a
|
||||
spot offline would both produce `spot3`, and the merge in §11 would treat two
|
||||
different marks as one. So the id is a short base-36 hash of the centre at
|
||||
creation, and two devices that place a spot in the same place produce the same
|
||||
id — which is the correct outcome, because they removed the same piece of dust.
|
||||
|
||||
```rust
|
||||
pub struct SpotSet {
|
||||
spots: Vec<Spot>, // in creation order; the order matters, see §5.2
|
||||
}
|
||||
```
|
||||
|
||||
### 4.1 Why it lives beside `ops`, not in it
|
||||
|
||||
The [`Operation`] trait takes a `ParamId` and returns an `f32`, and the whole
|
||||
generic machinery above it — the panel, the sidecar, the presets, the history —
|
||||
is built on that being true. A spot list is not scalars, and the trait says so
|
||||
explicitly where it refuses a downcast for film tables.
|
||||
|
||||
[`EditGraph`] already holds three things that are not operations for exactly
|
||||
this reason: `framing`, `masks` and `film`. `spots` is the fourth, and the
|
||||
argument is the same one `masks` makes — a stack of layers is not a slider, and
|
||||
folding it into the list would make every consumer that walks `ops` know that
|
||||
some entries are not really operations.
|
||||
|
||||
Bounds, following `mask.rs`'s example of bounding what a sidecar can grow to:
|
||||
|
||||
| Constant | Value | Why |
|
||||
|---|---|---|
|
||||
| `MAX_SPOTS` | 64 | Beyond a few dozen the answer is to clean the sensor. Refuses rather than dropping, as `MaskStack::push` does. |
|
||||
| `MAX_SOURCE_DISTANCE` | 0.5 | Frame units. Bounds the halo in §5.3, which is otherwise unbounded. |
|
||||
| `DEFAULT_RADIUS` | 0.012 | Frame units — about 25 px on a 24 MP frame's short edge, which is a dust mark. |
|
||||
| `MIN_RADIUS` / `MAX_RADIUS` | 0.001 / 0.5 | Not zero, because a spot that repairs nothing reads as a broken tool; not larger, because the halo bound has to mean something. |
|
||||
| `DEFAULT_FEATHER` | 0.35 | Fraction of the radius. Soft enough that a heal on a gradient sky has no visible boundary. |
|
||||
|
||||
## 5. Where it runs
|
||||
|
||||
### 5.1 First in the detail chain
|
||||
|
||||
ARCH §5.2 draws spot removal *after* texture and clarity and *before* sharpen
|
||||
and NR. That diagram is already out of step with the operation set — the
|
||||
`order:` keys in `core/dr-pipeline/ops/` put noise reduction at 110 and capture
|
||||
sharpening at 120, ahead of clarity at 130 and texture at 140 — so it needs a
|
||||
correction anyway, and the correction should put spot removal **first among the
|
||||
neighbourhood passes**, at a notional order of 105.
|
||||
|
||||
The reason is the halo. Sharpening a dust spot before removing it amplifies its
|
||||
edge, and the amplified edge is wider than the spot: the sharpening kernel has
|
||||
already smeared a dark ring into pixels that the spot's own disc does not cover,
|
||||
so the heal leaves a faint circle of over-sharpened background around a patch
|
||||
that is otherwise perfect. Removing the mark first means every later pass sees a
|
||||
photograph with no mark in it, which is also the photograph the photographer
|
||||
thinks they are sharpening.
|
||||
|
||||
Since the spot set is not in `ops`, `EditGraph::compose_detail_for` splices its
|
||||
passes in front of the ops' passes rather than sorting by a declared order. That
|
||||
is a two-line change and it is stated here so nobody looks for a `spots.yaml`.
|
||||
|
||||
### 5.2 Rounds, because sources can read destinations
|
||||
|
||||
Every pass reads one texture and writes another. So within a single pass, every
|
||||
spot reads the *unhealed* image — and a spot whose source overlaps an earlier
|
||||
spot's destination copies the mark the earlier spot was removing.
|
||||
|
||||
The fix is not to run one pass per spot (64 dispatches for a frame that needs
|
||||
one). It is to group: walking the spots in creation order, a spot joins the
|
||||
current round unless its source disc intersects the destination disc of a spot
|
||||
already in that round, in which case it opens a new one. One pass per round, and
|
||||
the common case — spots scattered over a sky, sources near their own
|
||||
destinations — is a single round. The grouping is plain CPU code over at most 64
|
||||
discs and belongs in `SpotSet`, with a test that says an overlapping pair
|
||||
produces two rounds and a disjoint pair produces one.
|
||||
|
||||
### 5.3 The halo, honestly
|
||||
|
||||
[`DetailPass::radius`] is "the furthest this pass reads from the pixel it
|
||||
writes", and it exists so that ARCH §5.3's tile scheduler knows how far to grow
|
||||
a tile. For a spot pass that is `max(|offset| + radius)` over the pass's spots,
|
||||
in render pixels — which with `MAX_SOURCE_DISTANCE` at 0.5 can approach half the
|
||||
frame.
|
||||
|
||||
That is a real cost and it should be written down rather than discovered: a
|
||||
frame with a long-armed spot is close to untileable for that one pass, so the
|
||||
tiled path will compute it whole-frame. Two things keep it affordable. The pass
|
||||
is cheap per pixel (§6.4), and it is only the *spot* passes that carry the halo
|
||||
— the sharpening pass after it still declares its three pixels and still tiles.
|
||||
The alternative, clamping the source distance to something tile-sized, would
|
||||
make the tool useless exactly where it is most needed: a mark on a face is
|
||||
healed from the other cheek, and that is a long way.
|
||||
|
||||
## 6. What a spot does to the pixels
|
||||
|
||||
Both modes work on the same disc. For a pixel at render coordinate `p` inside a
|
||||
spot centred at `d` with radius `r`, with the source at `s = d + offset`:
|
||||
|
||||
```
|
||||
w = falloff(|p - d| / r) // 1 at the centre, 0 at the rim
|
||||
patch = bilinear(source_texture, p - d + s)
|
||||
c = mix(c, patch + membrane, w * opacity)
|
||||
```
|
||||
|
||||
`falloff` is a smoothstep over the outer `feather` fraction of the radius; a
|
||||
feather of 0 is a hard disc. `bilinear` is four `tap`s and two lerps, because
|
||||
the generated preamble offers `textureLoad` only and the offset is fractional in
|
||||
render space — it becomes a [`Helper`], deduplicated across passes like the
|
||||
existing luminance helper.
|
||||
|
||||
`membrane` is what separates the two modes, and it is zero for `Clone`.
|
||||
|
||||
### 6.1 Heal, without a Poisson solve — **implemented**
|
||||
|
||||
The classic heal is Poisson blending: copy the *gradients* of the source and
|
||||
solve for the image whose gradients they are, subject to matching the
|
||||
destination on the boundary. Solved properly that is an iterative linear system
|
||||
— tens of Jacobi passes over the disc — and each iteration is a dispatch in this
|
||||
architecture. Sixty dispatches to remove a dust spot is not a frame budget.
|
||||
|
||||
What that solve produces is a smooth membrane interpolating the boundary
|
||||
difference, and a membrane can be interpolated directly instead of solved. The
|
||||
shipped form samples the difference between destination and source at `K` points
|
||||
around the rim and interpolates them into the interior by inverse square
|
||||
distance:
|
||||
|
||||
```
|
||||
for k in 0..K:
|
||||
b_k = tap(rim_k) - tap(rim_k + offset) // boundary difference
|
||||
w_k = 1 / max(|p - rim_k|², 1)
|
||||
membrane = Σ w_k·b_k / Σ w_k
|
||||
```
|
||||
|
||||
`K = 24` — `RIM_SAMPLES` in `core/dr-pipeline/src/spot.rs`, a uniform rather
|
||||
than a constant in the source, so tuning it uploads a buffer instead of
|
||||
recompiling. The cost is `2K` bilinear samples per pixel *inside a disc*, and
|
||||
nothing at all outside one.
|
||||
|
||||
**What was originally specified here was mean-value seamless cloning** (Farbman
|
||||
et al., 2009), whose weights are half-angle tangents over the rim rather than
|
||||
inverse squares. The difference matters when the boundary difference varies
|
||||
sharply around the rim; on the case that actually arises — a repair on a
|
||||
smoothly varying background — both reduce to the same answer, and the inverse
|
||||
square form costs two transcendentals per sample fewer. The measurement in
|
||||
`core/dr-gpu/tests/spot_removal.rs` is what decides whether that trade stays
|
||||
good: on a linear ramp steep enough to make a clone wrong by 38 levels out of
|
||||
255, the heal is wrong by **0**. If a case turns up where it is not, the weights
|
||||
are four lines and the tests are already written.
|
||||
|
||||
### 6.2 Why not compute the boundary statistics on the CPU
|
||||
|
||||
Because that means reading the rendered image back, and FR-DEV-4 forbids it in
|
||||
the render path for reasons ARCH §6.1 spends a page on. A per-frame readback to
|
||||
find out what colour a sky is would reintroduce exactly the stall the whole
|
||||
architecture exists to avoid. The mean-value form needs no reduction at all,
|
||||
which is most of why it is the right answer here.
|
||||
|
||||
### 6.3 Clone
|
||||
|
||||
`membrane = 0`. Kept because heal is wrong on a boundary: a spot straddling a
|
||||
horizon healed by mean-value blending smears the horizon's contrast into the
|
||||
disc, and the honest tool then is a straight copy from a matching part of the
|
||||
frame. This is why FR-DEV-8 asks for both, and it costs one branch in the shader
|
||||
and one segmented control in the panel.
|
||||
|
||||
### 6.4 Cost
|
||||
|
||||
Per pixel, per pass: a rejection test per spot in the pass (a squared distance
|
||||
and a compare), and for the pixels actually inside a disc, `4 + 2K` taps. With
|
||||
64 spots the rejection cost is the dominant term and it is about 64 × 4 ALU ops
|
||||
on every pixel of the frame — call it a millisecond at 2 MP on integrated
|
||||
graphics, which is affordable but not free.
|
||||
|
||||
If it proves not to be, the fix is the one `mask.rs` already uses for strokes: a
|
||||
bounding box per spot and a dispatch sized to it. That needs the detail runner
|
||||
to dispatch something other than the whole frame, which is a change to its
|
||||
shape, and it is deliberately not being made until a measurement asks for it.
|
||||
|
||||
## 7. Getting a spot list to the GPU
|
||||
|
||||
[`DetailPass`] gains one field:
|
||||
|
||||
```rust
|
||||
/// Per-instance data too large or too variable for the uniform block.
|
||||
pub storage: Option<Vec<[f32; 4]>>,
|
||||
```
|
||||
|
||||
and the generated preamble gains one binding:
|
||||
|
||||
```wgsl
|
||||
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
|
||||
```
|
||||
|
||||
In `dr-gpu`, both bind group layouts gain a read-only storage entry at binding
|
||||
3, and a pass that declares no storage binds a shared one-element dummy buffer.
|
||||
wgpu permits a layout entry the shader does not use, so the two layouts stay two
|
||||
rather than four, and no existing pass changes at all.
|
||||
|
||||
**A spot is two `vec4`s**: `(centre.x, centre.y, radius, feather)` and
|
||||
`(offset.x, offset.y, opacity, flags)`, all in render pixels except `flags`,
|
||||
converted on the CPU in `compose_detail_for` where the framing is in scope. This
|
||||
matters: the shader never sees a normalised coordinate and never has to know
|
||||
about crop, rotation or zoom — `Framing::output_at` does that map on the way in,
|
||||
exactly as `gradient.rs` does it for handles. The framing is an affine
|
||||
similarity, so a disc stays a disc and one radius scales by one factor:
|
||||
`radius_px = radius × min(source_w, source_h) × scale.ratio()`.
|
||||
|
||||
**The source list does not recompile anything.** The WGSL is identical for one
|
||||
spot and for sixty-four — the count is a uniform and the loop is over the
|
||||
buffer — so `structure_hash` is unchanged as spots are placed, and placing the
|
||||
tenth spot re-uploads a 512-byte buffer. This is the same property the fused
|
||||
pass has for slider movement and it is worth a test that asserts
|
||||
`cached_pipelines()` does not grow while spots are added.
|
||||
|
||||
**Rejected: packing spots into the uniform block.** It would touch no bind group
|
||||
layout, which is genuinely attractive. It also requires the composer to emit
|
||||
`vec4` uniform fields (it emits scalars), forces a fixed `MAX_SPOTS`-sized array
|
||||
and its fixed upload cost into every spot pass, and gives the next operation
|
||||
that wants a table — a LUT, a curve, a lens grid — nothing to build on. The
|
||||
storage buffer is a few more lines once and useful again later.
|
||||
|
||||
**Invalidation.** The spot set folds into the `detail` key in
|
||||
`EditGraph::invalidation`, alongside the detail operations. Dragging a spot
|
||||
therefore re-runs the detail chain and *not* the fused colour pass or the
|
||||
demosaic, which is exactly the reuse FR-DEV-3d asks for and is the difference
|
||||
between a spot that follows the finger and one that stutters.
|
||||
|
||||
## 8. Automatic source placement
|
||||
|
||||
FR-DEV-8 asks for automatic placement with manual override. Two stages, because
|
||||
the useful half is much cheaper than the good half.
|
||||
|
||||
**Stage one — a placed default.** A new spot's source is offset by `2.5 × radius`
|
||||
in the direction that keeps it furthest inside the frame, biased towards the
|
||||
frame centre. For dust on a sky, which is the overwhelming majority of spots,
|
||||
this is right often enough to be worth having, and it is wrong in a way that is
|
||||
immediately visible and one drag from fixed.
|
||||
|
||||
**Stage two — a scored search.** A compute dispatch per new spot scores candidate
|
||||
offsets on two rings around the destination (say 32 candidates), each scored by
|
||||
the sum of squared differences over the annulus just outside the destination
|
||||
disc — the ring is what has to match, since the disc's interior is being
|
||||
replaced anyway. Penalise candidates whose disc overlaps another spot's
|
||||
destination, or the frame edge. The winner's offset is read back **once**, when
|
||||
the spot is created, through `readback.rs` — a few hundred bytes, which is the
|
||||
size the histogram already moves and completes in well under a frame — and
|
||||
written into the spot.
|
||||
|
||||
Two rules about that readback, both of which are the difference between a
|
||||
feature and a bug:
|
||||
|
||||
- It is **not** in the render loop. `readback.rs` blocks with a deadline, and
|
||||
that is tolerable exactly once per placement and intolerable per frame. It
|
||||
happens on the gesture, and the render that follows uses whatever the spot
|
||||
currently holds.
|
||||
- The result is **stored**, and the search is never re-run behind the user. A
|
||||
spot whose source moved on its own when the file was reopened would be an edit
|
||||
changing itself, and non-destructive editing means the sidecar decides what
|
||||
the picture is.
|
||||
|
||||
If the readback fails, the stage-one default stands. There is no state in which
|
||||
a spot has no source.
|
||||
|
||||
## 9. Visualising spots
|
||||
|
||||
FR-DEV-8's "visualise-spots mode" is two different things, and conflating them
|
||||
is how one of them ends up missing:
|
||||
|
||||
**The overlay** — where the spots *are*. Circles for the destination, a fainter
|
||||
circle for the source, a line between them for the selected spot. Drawn in Slint
|
||||
over the canvas, alongside the gradient handles and by the same coordinate map,
|
||||
so it costs the render path nothing and cannot leak into an export.
|
||||
|
||||
**The reveal** — where the spots *should be*. Lightroom's "Visualize Spots": a
|
||||
high-contrast, desaturated view of the frame's high-frequency content, in which
|
||||
sensor dust on a smooth sky is obvious and in a normal view is nearly invisible.
|
||||
It is a detail pass appended to the chain:
|
||||
|
||||
```
|
||||
c = abs(c - blur(c)) stretched by a threshold, greyscale, inverted
|
||||
```
|
||||
|
||||
It is a **view**, not an edit. So it is not in the graph and not in the sidecar:
|
||||
`compose_detail_for` takes a `DetailView` (`Normal` | `RevealSpots`) and the
|
||||
export path passes `Normal`. A flag on the session would work until the day
|
||||
somebody exports while the mode is on, and then it would produce a black-and-
|
||||
white file that looks like corruption. Making the export call site name it is
|
||||
what stops that from ever being possible.
|
||||
|
||||
## 10. Interaction
|
||||
|
||||
### 10.1 The mode
|
||||
|
||||
A third chip in `ModeStrip`, beside Crop and Local, and a `ViewMode::spots`.
|
||||
The strip's own documentation already predicted this shape for the brush; the
|
||||
spot tool is the same shape and arrives first.
|
||||
|
||||
### 10.2 Gestures
|
||||
|
||||
| Gesture | Effect |
|
||||
|---|---|
|
||||
| Tap / click on the photograph | Place a spot at the current radius, source auto-placed (§8), and select it |
|
||||
| Drag from a spot's centre | Move the destination; the source follows |
|
||||
| Drag from the source circle | Change the offset |
|
||||
| Drag *out* from a fresh placement | Set the source directly, without the auto-placement |
|
||||
| Scroll / pinch on a selected spot | Radius |
|
||||
| Tap a spot | Select it; the panel scopes to it |
|
||||
| `Delete` / `Backspace` | Remove the selected spot |
|
||||
| Alt-click a spot | Remove it without selecting first |
|
||||
| `Esc` | Leave the mode |
|
||||
|
||||
A drag is a displacement from the press, not a snap to the pointer — the rule
|
||||
`gradient.rs` states and for the same reason: a finger-sized touch target snapped
|
||||
to the pointer jumps by half a target the instant it is grabbed.
|
||||
|
||||
### 10.3 The panel
|
||||
|
||||
While a spot is selected, the adjust column shows radius, feather, opacity and
|
||||
a Heal/Clone control for *that* spot, exactly as selecting a mask layer
|
||||
re-scopes the column today. With nothing selected it shows the defaults new
|
||||
spots will be created with, plus the reveal toggle.
|
||||
|
||||
### 10.4 Undo
|
||||
|
||||
[`History`] snapshots a [`Preset`], which is a parameter map — so today mask
|
||||
edits are not undoable, and spot placement must not inherit that. `History`
|
||||
should hold `(Preset, SpotSet)` and restore both.
|
||||
|
||||
That is a narrow change with a wide benefit: the same door lets the mask stack
|
||||
join later, which closes a gap FR-DEV-5 has open right now. The coalescing rule
|
||||
needs one addition — a drag of one spot's handle is one step, keyed by the spot
|
||||
id in the same way a slider drag is keyed by its control — and placement,
|
||||
deletion and mode changes each open a step of their own.
|
||||
|
||||
### 10.5 Touch
|
||||
|
||||
Every handle is a `Theme.touch-target`, per FR-UI-3. On a phone the destination
|
||||
and source circles of a small spot overlap at that size, so the source handle is
|
||||
drawn at a minimum arm length from the centre while the stored offset is
|
||||
untouched — the trick `gradient.rs` uses with `MIN_ARM`, for the identical
|
||||
reason: a handle that cannot be grabbed again is a one-way edit.
|
||||
|
||||
## 11. Persistence
|
||||
|
||||
One line per spot in the version block:
|
||||
|
||||
```
|
||||
[version 8f04c0e2-…]
|
||||
exposure.exposure = 0.75
|
||||
spot.3f9k = 0.4213 0.2871 0.0120 0.35 0.0310 -0.0180 1 heal
|
||||
```
|
||||
|
||||
Fields in order: `centre.x centre.y radius feather offset.x offset.y opacity
|
||||
mode`. A line rather than a block because a spot is eight numbers and sixty-four
|
||||
blocks would bury the rest of the file; a line *per spot* rather than one line
|
||||
for the set because the line is the unit of merge and of a readable diff — the
|
||||
same reasoning `write_strokes` gives for a line per stroke.
|
||||
|
||||
Coordinates are written at the same precision they are held at, as strokes are,
|
||||
so a round trip is exact and two devices do not generate a diff of noise in the
|
||||
sixth decimal.
|
||||
|
||||
**A malformed line costs that spot and not the file.** A truncated line is
|
||||
dropped with a warning, exactly as `parse_stroke` drops a bad stroke: a spot
|
||||
that silently lands somewhere the user never put it is worse than a spot that is
|
||||
missing, because only one of the two is noticeable.
|
||||
|
||||
**Merge** follows `merge_masks` precisely: by id, disjoint survives, a spot both
|
||||
sides edited resolves wholesale to the higher revision. Half of one device's
|
||||
offset with the other's radius is a repair neither photographer made. Deletion
|
||||
propagates through the base comparison exactly as a layer's does.
|
||||
|
||||
**Presets do not carry spots** in the first version — a preset is a look, and a
|
||||
look does not include where the dust was. But dust is in the *same place on
|
||||
every frame from that body*, which makes "copy spot removal to the selection"
|
||||
genuinely valuable, and it is a stage of its own (§12, S7) rather than a
|
||||
surprise inside the existing paste.
|
||||
|
||||
## 12. Stages
|
||||
|
||||
Each stage is shippable and each has something to look at. Test names are the
|
||||
files they belong in.
|
||||
|
||||
**S1 — The model.** *(Done.)* `spot.rs` in `dr-pipeline`: `Spot`, `SpotMode`, `SpotSet`,
|
||||
the bounds, id derivation, round grouping (§5.2). No GPU, no UI.
|
||||
*Tests:* `core/dr-pipeline/tests/spots.rs` — id stability across two identical
|
||||
placements, `MAX_SPOTS` refuses rather than drops, overlapping sources produce
|
||||
two rounds, disjoint produce one.
|
||||
|
||||
**S2 — Persistence.** *(Done.)* Sidecar write, parse, round trip, merge.
|
||||
*Acceptance:* a hand-written sidecar with three spots survives a load/save round
|
||||
trip byte-identically, and two devices that each add a spot offline end with
|
||||
both.
|
||||
|
||||
**S3 — The storage binding.** *(Done.)* `DetailPass::storage`, the preamble's binding 3,
|
||||
the dummy buffer, the two layouts.
|
||||
*Acceptance:* every existing detail test still passes untouched, and a synthetic
|
||||
pass reading the buffer gets what was uploaded.
|
||||
|
||||
**S4 — Clone.** *(Done.)* The disc, the feather, the bilinear helper, the pass grouping,
|
||||
the halo declaration, spliced first into the chain.
|
||||
*Acceptance:* `core/dr-gpu/tests/spot_removal.rs` — a synthetic frame with a
|
||||
black disc on a flat grey field is clean to within a tolerance after one clone
|
||||
spot; the same edit at a one-quarter proxy and at full size land the disc in the
|
||||
same *normalised* place; adding spots does not grow `cached_pipelines()`.
|
||||
|
||||
**S5 — Heal.** The membrane, the mode switch. **Done** — §6.1, and the measurement came out at 0 levels of error against a clone's 38.
|
||||
*Acceptance:* a dark spot on a linear grey **gradient** — the case clone fails —
|
||||
is clean to within a tolerance, and the residual at the disc boundary is below
|
||||
the residual a clone leaves by an order of magnitude. This is the measurement
|
||||
that decides `K`.
|
||||
|
||||
**S6 — The tool.** `ViewMode::spots`, the chip, placement, handles, selection,
|
||||
the panel scope, deletion, history carrying the spot set, the stage-one source
|
||||
default. **Done, except the reveal view** — §9's second half is the one piece
|
||||
of S6 not built, and it is separable: it is a view mode over the detail chain
|
||||
rather than part of the tool.
|
||||
*Acceptance:* a dust mark on a real frame is gone in one click, the edit survives
|
||||
a restart, and undo takes it back.
|
||||
|
||||
*What was verified, and how.* Everything below the interface is under test —
|
||||
the model, the sidecar, the merge, the passes, both blend modes, and undo. The
|
||||
interface itself was compiled, laid out and photographed: the strip renders
|
||||
`Crop | Local | Repair` and the column re-scopes. It was **not** driven, because
|
||||
synthetic clicks do not reach this application (the compositor refuses them),
|
||||
so the gestures in §10 are as-written rather than as-felt. A first pass with a
|
||||
real pointer is the outstanding work on this stage.
|
||||
|
||||
**S7 — The rest of FR-DEV-8.** The scored source search (§8 stage two), and
|
||||
copying a spot set across a selection.
|
||||
|
||||
S1–S6 is the requirement met in the sense a photographer would recognise; S7 is
|
||||
the sentence in FR-DEV-8 about automatic placement met in the sense the document
|
||||
means it.
|
||||
|
||||
## 13. Documents to amend
|
||||
|
||||
- **requirements.md** — FR-DEV-8 has no *Acceptance:* line; every other
|
||||
requirement of its weight does. Proposed: *"a dust mark on a smooth sky is
|
||||
removed in one click with no visible boundary at 1:1, the spot survives a
|
||||
crop, a rotation and an export at another size, and the exported file matches
|
||||
the preview."*
|
||||
- **architecture.md §5.2** — the stage list is out of step with the `order:`
|
||||
keys in `ops/` and does not show spot removal first among the neighbourhood
|
||||
passes. §5.1 above is the correction.
|
||||
- **traceability.md** — regenerated, as ever, rather than edited. FR-DEV-8's row
|
||||
currently points only at two comments that mention it.
|
||||
|
||||
## 14. Open questions
|
||||
|
||||
1. ~~**`K = 24`?**~~ Settled by S5's measurement: 24 samples, inverse square weights, zero error on the case the mode exists for.
|
||||
2. **Does the reveal view belong to spot mode only,** or is it a view mode of
|
||||
its own that a photographer can turn on while doing something else? It is
|
||||
cheap to allow both; the risk is a mode nobody remembers turning on. Still
|
||||
open, and now the only part of §9 unbuilt.
|
||||
3. **Should a spot be clamped inside the crop?** A spot outside the current crop
|
||||
costs nothing to render and is invisible, and re-cropping should bring it
|
||||
back rather than find it deleted. Leaning strongly towards no clamp.
|
||||
4. **One radius, or an ellipse?** Lightroom's spot tool is circular and its
|
||||
users cope. An ellipse doubles the handle count for a case a second spot
|
||||
already covers.
|
||||
@@ -0,0 +1,247 @@
|
||||
# DarkRoom — Technical debt
|
||||
|
||||
**Status:** Living document · first written 2026-08-26
|
||||
**Companion to:** [architecture.md](architecture.md)
|
||||
|
||||
Deliberate compromises: things the code does knowing they are wrong, because the alternative was
|
||||
worse at the time. Each entry says what the debt is, what it cost to take on, what it would take to
|
||||
pay off, and how you would know it had been paid.
|
||||
|
||||
Not a bug list. A bug is something nobody chose. Everything here was chosen, and the point of
|
||||
writing it down is that the reasoning outlives whoever chose it — so the next person can tell a
|
||||
constraint from an accident, and does not "fix" something load-bearing or preserve something that
|
||||
has quietly stopped being necessary.
|
||||
|
||||
---
|
||||
|
||||
## TD-1 — The Android develop view reads pixels back through the CPU
|
||||
|
||||
**Breaks:** [architecture.md §12 / 6.1](architecture.md) — GPU results never round-trip through the
|
||||
CPU — and AC-8, on Android only. Desktop is unaffected and keeps the zero-copy path.
|
||||
|
||||
### What it does
|
||||
|
||||
`DevelopSession::render` on Android runs the compute passes on the GPU as usual, then calls
|
||||
`AdjustPass::export_pixels` and hands the frame to Slint as a `SharedPixelBuffer`. That is exactly
|
||||
the GPU→CPU→GPU transfer §6.1 exists to forbid, and it is on the frame path.
|
||||
|
||||
### Why
|
||||
|
||||
Zero-copy needs Slint to draw with wgpu. On Android that means wgpu's Vulkan swapchain, which
|
||||
hardcodes `preTransform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR`
|
||||
([gfx-rs/wgpu#3345](https://github.com/gfx-rs/wgpu/issues/3345)) — wgpu-hal says so in a comment
|
||||
beside the line.
|
||||
|
||||
On a tablet whose panel is mounted landscape, a portrait window then hands Android an unrotated
|
||||
buffer, every present returns `VK_SUBOPTIMAL_KHR`, and frames arrive torn. Measured on the device,
|
||||
same build, only the tablet rotated:
|
||||
|
||||
| orientation | `bufferTransform` | composition | result |
|
||||
|---|---|---|---|
|
||||
| landscape | `ROT_180` | `DEVICE (2)` | clean |
|
||||
| portrait | `ROT_270` | `CLIENT (1)` | torn |
|
||||
|
||||
Setting `preTransform` is not a fix available to us: the field is a *promise* that the content is
|
||||
already rotated, so honouring it needs the renderer to rotate what it draws, which wgpu cannot do
|
||||
on Skia's behalf.
|
||||
|
||||
So the choice was never fast-develop against slow-develop. It was a develop view that costs a
|
||||
readback against a grid that tears in the orientation a tablet is mostly held in.
|
||||
|
||||
### What it costs
|
||||
|
||||
Less than §6.1's headline numbers, because `render` fits the pass to the canvas before it runs — the
|
||||
readback is at viewport resolution, not sensor resolution. The 7.43 ms at 4K in §12 is the ceiling,
|
||||
not the bill. **It has not been measured on the device**, which is the first thing to do if the
|
||||
develop view feels heavy on the tablet; do not assume this is the cause without a number.
|
||||
|
||||
### Paying it off
|
||||
|
||||
Any one of these removes it:
|
||||
|
||||
- wgpu implements pre-rotation (#3345), and Android goes back on `unstable-wgpu-29`.
|
||||
- Slint's Skia Vulkan surface handles `preTransform` and Android uses that instead of OpenGL.
|
||||
- Skia over OpenGL grows a way to sample an external texture that wgpu can write.
|
||||
|
||||
**Done when:** `ui/dr-ui/Cargo.toml` no longer scopes `renderer-femtovg-wgpu` and
|
||||
`unstable-wgpu-29` to non-Android, the `#[cfg(target_os = "android")]` arm of
|
||||
`DevelopSession::render` is gone, and the tablet is clean in portrait.
|
||||
|
||||
---
|
||||
|
||||
## TD-2 — Thumbnails are fetched one at a time
|
||||
|
||||
**Where:** `library::spawn_thumbnails` — the `for req in to_fetch` loop.
|
||||
|
||||
### What it does
|
||||
|
||||
The interactive thumbnail batch fetches serially: one image at a time, and two HTTP round trips
|
||||
each (a header read, then the preview's byte range). A window of a few hundred cells is that many
|
||||
sequential round trips against the server.
|
||||
|
||||
### Why it is debt rather than a bug
|
||||
|
||||
It is correct, and it was fast enough when a window was one screenful. It is the *ordering* that
|
||||
kept it survivable: since `fetch_rank`, on-screen cells are requested first, so the cells a person
|
||||
is looking at arrive first even though the queue as a whole is slow.
|
||||
|
||||
Portrait makes it worse by construction — a narrow window means smaller cells, more rows, and two
|
||||
to three times as many cells on screen at once, all of them ahead of the ones below in a queue that
|
||||
never runs more than one request.
|
||||
|
||||
### Paying it off
|
||||
|
||||
`spawn_thumbnail_sweep` already has the pattern: `SWEEP_LANES` disjoint lanes over a chunk, joined,
|
||||
with the store written on the one thread that owns it. Striping a *priority-ordered* chunk across
|
||||
lanes keeps `fetch_rank`'s ordering while running several requests at once.
|
||||
|
||||
Not done yet because it multiplies concurrent requests against the user's Nextcloud during a
|
||||
scroll, and that is a behaviour change worth deciding on deliberately rather than inheriting from a
|
||||
performance fix.
|
||||
|
||||
**Done when:** the interactive batch runs on more than one lane, priority order is preserved
|
||||
across the lanes, and a slow server still cannot stall the visible cells behind offscreen ones.
|
||||
|
||||
---
|
||||
|
||||
## TD-3 — The thumbnail drain applies an unbounded batch on the UI thread
|
||||
|
||||
**Where:** `library_ui::drain_thumbnails` — the `loop` inside the timer callback.
|
||||
|
||||
### What it does
|
||||
|
||||
Every message queued when the timer fires is applied in that one callback, with no ceiling. On a
|
||||
library whose thumbnails are already in the store, the worker delivers a whole window at once, so a
|
||||
single callback can do hundreds of `to_slint_image` calls back to back — each an allocation and a
|
||||
full RGBA copy — while the grid is mid-flick.
|
||||
|
||||
The copy cannot move off the UI thread: `slint::SharedPixelBuffer` is not `Send`, so decoded bytes
|
||||
can only become an `Image` on the thread that draws. Only the *amount done per wake* is ours to
|
||||
choose, and right now it is "all of it".
|
||||
|
||||
### Cost
|
||||
|
||||
Measured with a temporary probe, **debug build**, so treat the shape rather than the size:
|
||||
|
||||
| class | per thumbnail | × a 280-cell window |
|
||||
|---|---|---|
|
||||
| grid, 256 px | 1.93 ms | 539 ms |
|
||||
| large, 512 px | 7.78 ms | 2.18 s |
|
||||
|
||||
A release measurement was started and never completed — do not quote these as release figures.
|
||||
|
||||
### Paying it off
|
||||
|
||||
A time budget per wake and a shorter interval: apply for a few milliseconds, return without
|
||||
stopping the timer, and finish on the next tick. A batch then lands in frame-sized slices rather
|
||||
than one lump between two frames. Draft written and discarded during the investigation; it is a
|
||||
small change.
|
||||
|
||||
**Done when:** one wake of the drain cannot exceed a frame, and a fully-cached window still fills
|
||||
in well under a second.
|
||||
|
||||
---
|
||||
|
||||
## TD-4 — The local-contrast base is computed at full render resolution
|
||||
|
||||
**Where:** `dr_pipeline::ops::local_contrast::LocalContrast::passes` — the `base` and `combine`
|
||||
passes, and the stage that dispatches them, `dr_pipeline::detail`.
|
||||
|
||||
Breaks **FR-DSP-3** at large viewports. Measured, and the numbers are in
|
||||
[frame-budget.md](frame-budget.md) §M3.
|
||||
|
||||
### What it does
|
||||
|
||||
Clarity's Gaussian σ is 1.2% of the frame's shorter edge, truncated at 2σ, so its kernel radius is
|
||||
a property of the *viewport*: 29 px at 1920 × 1200, 38 px at 2560 × 1600, **52 px at 4K**. The two
|
||||
separable passes therefore run 105 taps each over 8.3 M pixels at 4K, which is 1.7 billion texture
|
||||
reads for one control.
|
||||
|
||||
| viewport | radius | clarity alone, p99 |
|
||||
|---|---:|---:|
|
||||
| 1920 × 1200 | 29 | 5.99 ms |
|
||||
| 2560 × 1600 | 38 | 12.44 ms |
|
||||
| 3840 × 2160 | 52 | **33.89 ms** |
|
||||
|
||||
RTX 3050 laptop, `examples/frame_budget`, fused dispatch reused so this is the convolutions alone.
|
||||
Clarity is 97% of the cost of all four neighbourhood operations together at every size.
|
||||
|
||||
For scale: the entire fused chain — every point operation active, film stock included — costs
|
||||
4.5 ms at the same 4K viewport. **A single slider is seven times the rest of the pipeline.**
|
||||
|
||||
### Why
|
||||
|
||||
Because the stage cannot do otherwise yet. `dr_pipeline::detail` dispatches every pass at the
|
||||
render size; there is no way to express "read this target and write a smaller one". The module's own
|
||||
documentation has said so since it was written:
|
||||
|
||||
> 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.
|
||||
|
||||
It was the right call to ship the correct answer slowly rather than a fast approximation nobody had
|
||||
checked — the halo behaviour is the hard part of this operation and it is tested.
|
||||
|
||||
### Not a tiling problem
|
||||
|
||||
Worth saying because ARCH §5.3 offers a tile cache and this is the stage that looks like it wants
|
||||
one. It does not: a tiled convolution reads a halo per tile, so at a 52-pixel radius, 256-pixel
|
||||
tiles would read (256 + 104)² instead of 256² — very nearly **twice** the taps.
|
||||
[display-and-extension.md](display-and-extension.md) §2's decision rule was resolved on this
|
||||
evidence; see [frame-budget.md](frame-budget.md).
|
||||
|
||||
### Paying it off
|
||||
|
||||
A detail pass that declares an output scale, so the base can be computed at a quarter resolution and
|
||||
sampled back up in `combine`. A quarter-resolution base is 1/16 the pixels at 1/4 the radius —
|
||||
about **1/64 of the work** — and is visually identical, because a base at σ = 26 px holds no content
|
||||
above the quarter-resolution Nyquist to lose. Texture's σ is a decade finer and must stay at full
|
||||
resolution; the scale therefore belongs on the `DetailPass`, not on the stage.
|
||||
|
||||
**Done when:** clarity at 100% is inside the frame budget at 3840 × 2160, the halo tests in
|
||||
`tests/local_contrast.rs` still pass unchanged, and `examples/frame_budget`'s M3 table in
|
||||
[frame-budget.md](frame-budget.md) has been rerun and committed.
|
||||
|
||||
---
|
||||
|
||||
## TD-5 — The fused shader is reassembled from strings on every frame
|
||||
|
||||
**Where:** `dr_pipeline::operation::compose_full`, called from `DevelopSession::render`.
|
||||
|
||||
### What it does
|
||||
|
||||
`EditGraph::compose` walks the active operations and formats a WGSL source string, per frame, on
|
||||
the UI thread. On a full chain that is **2.8–5.2 ms** — at 1920 × 1200 it is larger than the entire
|
||||
fused dispatch it precedes, and on the chains that also carry a detail stage it is a third of what
|
||||
is left of the 16 ms budget after the GPU has taken its share. It does not vary with resolution,
|
||||
because it is not pixel work.
|
||||
|
||||
### Why
|
||||
|
||||
Because it was free until the chain got long. Composition was written when an edit was two or three
|
||||
operations, and the cost is roughly linear in generated source: the colour mixer emits twelve hue bands
|
||||
and the tone curve emits a spline evaluator, so a full chain is a large string built from scratch
|
||||
sixty times a second.
|
||||
|
||||
### Paying it off
|
||||
|
||||
The generated source depends only on the *structure* of the graph — which is precisely what
|
||||
`ComposedShader::structure_hash` already identifies, and precisely what does not change while a
|
||||
slider is being dragged. `AdjustPass` relies on that already: it caches compiled pipelines against
|
||||
that hash and does not recompile during a drag. Caching the source string against the same hash and
|
||||
rebuilding only the uniforms — a handful of floats per operation — takes this to approximately
|
||||
nothing on the path that needs it most.
|
||||
|
||||
The care needed is in what the hash covers. It deliberately excludes parameter *magnitudes*, so a
|
||||
cache keyed on it is sound for the source and would be wrong for anything else in `ComposedShader`.
|
||||
|
||||
**Done when:** the `shader` column of [frame-budget.md](frame-budget.md)'s M1 table is under a
|
||||
millisecond for the `point` and `all` chains, and the codegen tests still pass byte for byte.
|
||||
|
||||
---
|
||||
|
||||
## Related, and deliberately not here
|
||||
|
||||
The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed*
|
||||
rather than deferred — see the commits around `6d6ef8d`. They are mentioned only so that a reader
|
||||
looking for "why was the grid slow" finds the answer in the code and its comments rather than
|
||||
assuming it is still outstanding.
|
||||
+89
-89
@@ -9,18 +9,18 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Source files scanned | 227 |
|
||||
| TRACES tags found | 614 |
|
||||
| Source files scanned | 278 |
|
||||
| TRACES tags found | 811 |
|
||||
| Requirements defined | 177 |
|
||||
| Requirements covered | 91 |
|
||||
| **Coverage** | **51.4%** (91/177) |
|
||||
| Requirements covered | 106 |
|
||||
| **Coverage** | **59.9%** (106/177) |
|
||||
|
||||
### By type
|
||||
|
||||
| Type | Covered | Defined |
|
||||
|---|---|---|
|
||||
| FR | 72 | 122 |
|
||||
| NFR | 17 | 49 |
|
||||
| FR | 84 | 122 |
|
||||
| NFR | 20 | 49 |
|
||||
| R | 2 | 6 |
|
||||
|
||||
## Orphan tags
|
||||
@@ -33,139 +33,142 @@ _None._
|
||||
|
||||
| ID | Tagged in |
|
||||
|---|---|
|
||||
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
|
||||
| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1002`](../ui/dr-ui/src/lib.rs#L1002), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1007`](../ui/dr-ui/ui/library.slint#L1007), [`ui/dr-ui/ui/library.slint:1161`](../ui/dr-ui/ui/library.slint#L1161), [`ui/dr-ui/ui/library.slint:857`](../ui/dr-ui/ui/library.slint#L857) |
|
||||
| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1002`](../ui/dr-ui/src/lib.rs#L1002), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2122`](../ui/dr-ui/src/library.rs#L2122), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
||||
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:120`](../core/dr-pipeline/src/sidecar.rs#L120) |
|
||||
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
|
||||
| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1014`](../ui/dr-ui/ui/library.slint#L1014), [`ui/dr-ui/ui/library.slint:1176`](../ui/dr-ui/ui/library.slint#L1176), [`ui/dr-ui/ui/library.slint:863`](../ui/dr-ui/ui/library.slint#L863) |
|
||||
| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126), [`ui/dr-ui/src/library.rs:163`](../ui/dr-ui/src/library.rs#L163), [`ui/dr-ui/src/library.rs:2247`](../ui/dr-ui/src/library.rs#L2247), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
||||
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118) |
|
||||
| FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) |
|
||||
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:422`](../core/dr-catalog/src/schema.rs#L422), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1225`](../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1995`](../ui/dr-ui/src/collections_ui.rs#L1995), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:198`](../ui/dr-ui/src/library.rs#L198), [`ui/dr-ui/src/library.rs:3007`](../ui/dr-ui/src/library.rs#L3007), [`ui/dr-ui/src/library.rs:3041`](../ui/dr-ui/src/library.rs#L3041), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:757`](../ui/dr-ui/src/library_ui.rs#L757), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) |
|
||||
| FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) |
|
||||
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:641`](../core/dr-catalog/src/schema.rs#L641), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1225`](../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1995`](../ui/dr-ui/src/collections_ui.rs#L1995), [`ui/dr-ui/src/library.rs:163`](../ui/dr-ui/src/library.rs#L163), [`ui/dr-ui/src/library.rs:180`](../ui/dr-ui/src/library.rs#L180), [`ui/dr-ui/src/library.rs:209`](../ui/dr-ui/src/library.rs#L209), [`ui/dr-ui/src/library.rs:3661`](../ui/dr-ui/src/library.rs#L3661), [`ui/dr-ui/src/library.rs:3695`](../ui/dr-ui/src/library.rs#L3695), [`ui/dr-ui/src/library_ui.rs:185`](../ui/dr-ui/src/library_ui.rs#L185), [`ui/dr-ui/src/library_ui.rs:758`](../ui/dr-ui/src/library_ui.rs#L758), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) |
|
||||
| FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../platform/dr-plat/src/storage.rs#L46) |
|
||||
| FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
||||
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2598`](../ui/dr-ui/src/library.rs#L2598), [`ui/dr-ui/src/library.rs:2624`](../ui/dr-ui/src/library.rs#L2624), [`ui/dr-ui/src/library_ui.rs:171`](../ui/dr-ui/src/library_ui.rs#L171), [`ui/dr-ui/src/library_ui.rs:3608`](../ui/dr-ui/src/library_ui.rs#L3608), [`ui/dr-ui/src/library_ui.rs:4574`](../ui/dr-ui/src/library_ui.rs#L4574), [`ui/dr-ui/ui/app.slint:513`](../ui/dr-ui/ui/app.slint#L513), [`ui/dr-ui/ui/settings.slint:340`](../ui/dr-ui/ui/settings.slint#L340), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
||||
| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:292`](../core/dr-catalog/src/schema.rs#L292), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
|
||||
| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/schema.rs:840`](../core/dr-catalog/src/schema.rs#L840), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`ui/dr-ui/src/collections_ui.rs:1578`](../ui/dr-ui/src/collections_ui.rs#L1578), [`ui/dr-ui/src/collections_ui.rs:1597`](../ui/dr-ui/src/collections_ui.rs#L1597), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3055`](../ui/dr-ui/src/library.rs#L3055), [`ui/dr-ui/src/library_ui.rs:6255`](../ui/dr-ui/src/library_ui.rs#L6255), [`ui/dr-ui/src/library_ui.rs:6266`](../ui/dr-ui/src/library_ui.rs#L6266), [`ui/dr-ui/src/library_ui.rs:6279`](../ui/dr-ui/src/library_ui.rs#L6279), [`ui/dr-ui/src/library_ui.rs:628`](../ui/dr-ui/src/library_ui.rs#L628), [`ui/dr-ui/src/library_ui.rs:6294`](../ui/dr-ui/src/library_ui.rs#L6294), [`ui/dr-ui/src/library_ui.rs:6303`](../ui/dr-ui/src/library_ui.rs#L6303), [`ui/dr-ui/ui/app.slint:458`](../ui/dr-ui/ui/app.slint#L458), [`ui/dr-ui/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1212`](../ui/dr-ui/ui/library.slint#L1212), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:850`](../ui/dr-ui/ui/library.slint#L850), [`ui/dr-ui/ui/library.slint:901`](../ui/dr-ui/ui/library.slint#L901) |
|
||||
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:3301`](../ui/dr-ui/src/library.rs#L3301), [`ui/dr-ui/src/library_ui.rs:315`](../ui/dr-ui/src/library_ui.rs#L315), [`ui/dr-ui/src/library_ui.rs:387`](../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:5124`](../ui/dr-ui/src/library_ui.rs#L5124), [`ui/dr-ui/src/library_ui.rs:5177`](../ui/dr-ui/src/library_ui.rs#L5177), [`ui/dr-ui/src/library_ui.rs:6395`](../ui/dr-ui/src/library_ui.rs#L6395), [`ui/dr-ui/ui/app.slint:461`](../ui/dr-ui/ui/app.slint#L461), [`ui/dr-ui/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/app.slint:826`](../ui/dr-ui/ui/app.slint#L826), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1278`](../ui/dr-ui/ui/library.slint#L1278), [`ui/dr-ui/ui/library.slint:901`](../ui/dr-ui/ui/library.slint#L901), [`ui/dr-ui/ui/settings.slint:115`](../ui/dr-ui/ui/settings.slint#L115) |
|
||||
| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3466`](../ui/dr-ui/src/library_ui.rs#L3466), [`ui/dr-ui/src/library_ui.rs:4164`](../ui/dr-ui/src/library_ui.rs#L4164), [`ui/dr-ui/ui/app.slint:582`](../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2516`](../ui/dr-ui/ui/library.slint#L2516), [`ui/dr-ui/ui/library.slint:872`](../ui/dr-ui/ui/library.slint#L872), [`ui/dr-ui/ui/library.slint:891`](../ui/dr-ui/ui/library.slint#L891) |
|
||||
| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:136`](../core/dr-pipeline/src/ops/curve.rs#L136), [`core/dr-pipeline/src/ops/curve.rs:652`](../core/dr-pipeline/src/ops/curve.rs#L652), [`core/dr-pipeline/src/sidecar.rs:1343`](../core/dr-pipeline/src/sidecar.rs#L1343), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`core/dr-pipeline/tests/tone_curve.rs:33`](../core/dr-pipeline/tests/tone_curve.rs#L33), [`ui/dr-ui/src/develop.rs:2757`](../ui/dr-ui/src/develop.rs#L2757), [`ui/dr-ui/src/develop.rs:2785`](../ui/dr-ui/src/develop.rs#L2785), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1239`](../ui/dr-ui/src/lib.rs#L1239), [`ui/dr-ui/src/lib.rs:1579`](../ui/dr-ui/src/lib.rs#L1579), [`ui/dr-ui/src/lib.rs:1684`](../ui/dr-ui/src/lib.rs#L1684), [`ui/dr-ui/src/lib.rs:464`](../ui/dr-ui/src/lib.rs#L464), [`ui/dr-ui/src/lib.rs:849`](../ui/dr-ui/src/lib.rs#L849), [`ui/dr-ui/src/library.rs:1545`](../ui/dr-ui/src/library.rs#L1545), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library_ui.rs:4866`](../ui/dr-ui/src/library_ui.rs#L4866), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:394`](../core/dr-catalog/src/schema.rs#L394), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2325`](../ui/dr-ui/src/develop.rs#L2325), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1505`](../ui/dr-ui/src/library.rs#L1505), [`ui/dr-ui/src/library.rs:1582`](../ui/dr-ui/src/library.rs#L1582), [`ui/dr-ui/src/library.rs:220`](../ui/dr-ui/src/library.rs#L220), [`ui/dr-ui/src/library.rs:3408`](../ui/dr-ui/src/library.rs#L3408), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library_ui.rs:1564`](../ui/dr-ui/src/library_ui.rs#L1564), [`ui/dr-ui/src/library_ui.rs:1590`](../ui/dr-ui/src/library_ui.rs#L1590), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:1700`](../ui/dr-ui/src/library_ui.rs#L1700), [`ui/dr-ui/src/library_ui.rs:226`](../ui/dr-ui/src/library_ui.rs#L226), [`ui/dr-ui/src/library_ui.rs:2316`](../ui/dr-ui/src/library_ui.rs#L2316), [`ui/dr-ui/src/library_ui.rs:259`](../ui/dr-ui/src/library_ui.rs#L259), [`ui/dr-ui/src/library_ui.rs:2754`](../ui/dr-ui/src/library_ui.rs#L2754), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:3257`](../ui/dr-ui/src/library_ui.rs#L3257), [`ui/dr-ui/src/library_ui.rs:3329`](../ui/dr-ui/src/library_ui.rs#L3329), [`ui/dr-ui/src/library_ui.rs:3517`](../ui/dr-ui/src/library_ui.rs#L3517), [`ui/dr-ui/src/library_ui.rs:3635`](../ui/dr-ui/src/library_ui.rs#L3635), [`ui/dr-ui/src/library_ui.rs:440`](../ui/dr-ui/src/library_ui.rs#L440), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/library_ui.rs:5222`](../ui/dr-ui/src/library_ui.rs#L5222), [`ui/dr-ui/src/library_ui.rs:5337`](../ui/dr-ui/src/library_ui.rs#L5337), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
|
||||
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) |
|
||||
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364) |
|
||||
| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352) |
|
||||
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:154`](../core/dr-pipeline/src/graph.rs#L154), [`core/dr-pipeline/src/graph.rs:457`](../core/dr-pipeline/src/graph.rs#L457), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:473`](../core/dr-pipeline/src/operation.rs#L473), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1343`](../core/dr-pipeline/src/sidecar.rs#L1343), [`core/dr-pipeline/src/sidecar.rs:1399`](../core/dr-pipeline/src/sidecar.rs#L1399), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:2757`](../ui/dr-ui/src/develop.rs#L2757), [`ui/dr-ui/src/develop.rs:3189`](../ui/dr-ui/src/develop.rs#L3189), [`ui/dr-ui/src/develop.rs:3243`](../ui/dr-ui/src/develop.rs#L3243), [`ui/dr-ui/src/develop.rs:3287`](../ui/dr-ui/src/develop.rs#L3287), [`ui/dr-ui/src/develop.rs:3337`](../ui/dr-ui/src/develop.rs#L3337), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1275`](../ui/dr-ui/src/lib.rs#L1275), [`ui/dr-ui/src/lib.rs:1934`](../ui/dr-ui/src/lib.rs#L1934), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1915`](../ui/dr-ui/ui/app.slint#L1915), [`ui/dr-ui/ui/app.slint:913`](../ui/dr-ui/ui/app.slint#L913) |
|
||||
| FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:18`](../core/dr-pipeline/src/graph.rs#L18), [`core/dr-pipeline/src/graph.rs:218`](../core/dr-pipeline/src/graph.rs#L218), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1008`](../ui/dr-ui/src/develop.rs#L1008), [`ui/dr-ui/src/lib.rs:579`](../ui/dr-ui/src/lib.rs#L579) |
|
||||
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328) |
|
||||
| FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:218`](../core/dr-pipeline/src/graph.rs#L218), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:3916`](../ui/dr-ui/src/develop.rs#L3916) |
|
||||
| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:433`](../core/dr-gpu/tests/capture_sharpen.rs#L433), [`core/dr-gpu/tests/detail_stage.rs:241`](../core/dr-gpu/tests/detail_stage.rs#L241), [`core/dr-gpu/tests/local_contrast.rs:475`](../core/dr-gpu/tests/local_contrast.rs#L475), [`core/dr-gpu/tests/noise_reduction.rs:555`](../core/dr-gpu/tests/noise_reduction.rs#L555), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:486`](../core/dr-pipeline/src/graph.rs#L486), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) |
|
||||
| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1410`](../core/dr-pipeline/src/operation.rs#L1410), [`core/dr-pipeline/src/operation.rs:1491`](../core/dr-pipeline/src/operation.rs#L1491), [`core/dr-pipeline/src/operation.rs:1516`](../core/dr-pipeline/src/operation.rs#L1516), [`core/dr-pipeline/src/operation.rs:1531`](../core/dr-pipeline/src/operation.rs#L1531), [`core/dr-pipeline/src/operation.rs:1555`](../core/dr-pipeline/src/operation.rs#L1555), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/operation.rs:403`](../core/dr-pipeline/src/operation.rs#L403), [`core/dr-pipeline/src/operation.rs:413`](../core/dr-pipeline/src/operation.rs#L413), [`core/dr-pipeline/src/operation.rs:549`](../core/dr-pipeline/src/operation.rs#L549) |
|
||||
| FR-DEV-3f | [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:88`](../core/dr-film/src/grain.rs#L88), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:167`](../core/dr-film/src/profile.rs#L167), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:110`](../core/dr-pipeline/src/graph.rs#L110), [`core/dr-pipeline/src/graph.rs:292`](../core/dr-pipeline/src/graph.rs#L292), [`core/dr-pipeline/src/graph.rs:96`](../core/dr-pipeline/src/graph.rs#L96), [`core/dr-pipeline/src/operation.rs:1014`](../core/dr-pipeline/src/operation.rs#L1014), [`core/dr-pipeline/src/operation.rs:1043`](../core/dr-pipeline/src/operation.rs#L1043), [`core/dr-pipeline/src/operation.rs:1410`](../core/dr-pipeline/src/operation.rs#L1410), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/ops/film_sim.rs:120`](../core/dr-pipeline/src/ops/film_sim.rs#L120), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:291`](../core/dr-pipeline/src/ops/film_sim.rs#L291), [`core/dr-pipeline/src/ops/film_sim.rs:96`](../core/dr-pipeline/src/ops/film_sim.rs#L96), [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:1647`](../core/dr-pipeline/src/sidecar.rs#L1647), [`core/dr-pipeline/src/sidecar.rs:169`](../core/dr-pipeline/src/sidecar.rs#L169), [`core/dr-pipeline/src/sidecar.rs:1723`](../core/dr-pipeline/src/sidecar.rs#L1723), [`core/dr-pipeline/src/sidecar.rs:415`](../core/dr-pipeline/src/sidecar.rs#L415), [`core/dr-pipeline/src/sidecar.rs:555`](../core/dr-pipeline/src/sidecar.rs#L555), [`core/dr-pipeline/src/sidecar.rs:678`](../core/dr-pipeline/src/sidecar.rs#L678), [`ui/dr-ui/src/develop.rs:2340`](../ui/dr-ui/src/develop.rs#L2340), [`ui/dr-ui/src/develop.rs:2357`](../ui/dr-ui/src/develop.rs#L2357), [`ui/dr-ui/src/develop.rs:2388`](../ui/dr-ui/src/develop.rs#L2388), [`ui/dr-ui/src/develop.rs:2480`](../ui/dr-ui/src/develop.rs#L2480), [`ui/dr-ui/src/develop.rs:2794`](../ui/dr-ui/src/develop.rs#L2794), [`ui/dr-ui/src/lib.rs:1908`](../ui/dr-ui/src/lib.rs#L1908), [`ui/dr-ui/src/lib.rs:512`](../ui/dr-ui/src/lib.rs#L512), [`ui/dr-ui/src/lib.rs:570`](../ui/dr-ui/src/lib.rs#L570), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:879`](../ui/dr-ui/ui/adjust.slint#L879), [`ui/dr-ui/ui/adjust.slint:949`](../ui/dr-ui/ui/adjust.slint#L949), [`ui/dr-ui/ui/app.slint:2370`](../ui/dr-ui/ui/app.slint#L2370), [`ui/dr-ui/ui/app.slint:707`](../ui/dr-ui/ui/app.slint#L707) |
|
||||
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336) |
|
||||
| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
|
||||
| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2812`](../ui/dr-ui/src/develop.rs#L2812), [`ui/dr-ui/src/develop.rs:2822`](../ui/dr-ui/src/develop.rs#L2822), [`ui/dr-ui/src/develop.rs:488`](../ui/dr-ui/src/develop.rs#L488), [`ui/dr-ui/src/lib.rs:1267`](../ui/dr-ui/src/lib.rs#L1267) |
|
||||
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:2751`](../ui/dr-ui/src/develop.rs#L2751), [`ui/dr-ui/src/develop.rs:2772`](../ui/dr-ui/src/develop.rs#L2772), [`ui/dr-ui/src/lib.rs:1239`](../ui/dr-ui/src/lib.rs#L1239), [`ui/dr-ui/src/library.rs:1545`](../ui/dr-ui/src/library.rs#L1545), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:392`](../ui/dr-ui/src/library.rs#L392), [`ui/dr-ui/src/library_ui.rs:2576`](../ui/dr-ui/src/library_ui.rs#L2576), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:535`](../ui/dr-ui/src/settings_ui.rs#L535), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1305`](../ui/dr-ui/ui/library.slint#L1305), [`ui/dr-ui/ui/library.slint:831`](../ui/dr-ui/ui/library.slint#L831), [`ui/dr-ui/ui/library.slint:912`](../ui/dr-ui/ui/library.slint#L912), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) |
|
||||
| FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299) |
|
||||
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2090`](../core/dr-gpu/src/adjust.rs#L2090), [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:2252`](../core/dr-gpu/src/adjust.rs#L2252), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:199`](../core/dr-gpu/tests/capture_sharpen.rs#L199), [`core/dr-gpu/tests/detail_stage.rs:327`](../core/dr-gpu/tests/detail_stage.rs#L327), [`core/dr-gpu/tests/local_contrast.rs:262`](../core/dr-gpu/tests/local_contrast.rs#L262), [`core/dr-gpu/tests/noise_reduction.rs:377`](../core/dr-gpu/tests/noise_reduction.rs#L377), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:427`](../core/dr-pipeline/src/graph.rs#L427), [`core/dr-pipeline/src/graph.rs:457`](../core/dr-pipeline/src/graph.rs#L457), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:658`](../core/dr-pipeline/src/ops/local_contrast.rs#L658), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:2161`](../ui/dr-ui/src/develop.rs#L2161), [`ui/dr-ui/src/develop.rs:2978`](../ui/dr-ui/src/develop.rs#L2978), [`ui/dr-ui/src/develop.rs:3461`](../ui/dr-ui/src/develop.rs#L3461), [`ui/dr-ui/src/develop.rs:3495`](../ui/dr-ui/src/develop.rs#L3495), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:720`](../ui/dr-ui/src/lib.rs#L720) |
|
||||
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) |
|
||||
| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2206`](../ui/dr-ui/src/develop.rs#L2206), [`ui/dr-ui/src/develop.rs:4563`](../ui/dr-ui/src/develop.rs#L4563), [`ui/dr-ui/src/develop.rs:4595`](../ui/dr-ui/src/develop.rs#L4595), [`ui/dr-ui/src/develop.rs:499`](../ui/dr-ui/src/develop.rs#L499), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1326`](../ui/dr-ui/src/lib.rs#L1326), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) |
|
||||
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2724`](../ui/dr-ui/src/library.rs#L2724), [`ui/dr-ui/src/library.rs:3195`](../ui/dr-ui/src/library.rs#L3195), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4593`](../ui/dr-ui/src/library_ui.rs#L4593), [`ui/dr-ui/ui/app.slint:316`](../ui/dr-ui/ui/app.slint#L316), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
||||
| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:190`](../ui/dr-ui/src/library.rs#L190), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
|
||||
| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1059`](../core/dr-catalog/src/schema.rs#L1059), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/collections_ui.rs:1578`](../ui/dr-ui/src/collections_ui.rs#L1578), [`ui/dr-ui/src/collections_ui.rs:1597`](../ui/dr-ui/src/collections_ui.rs#L1597), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3709`](../ui/dr-ui/src/library.rs#L3709), [`ui/dr-ui/src/library_ui.rs:629`](../ui/dr-ui/src/library_ui.rs#L629), [`ui/dr-ui/src/library_ui.rs:6390`](../ui/dr-ui/src/library_ui.rs#L6390), [`ui/dr-ui/src/library_ui.rs:6401`](../ui/dr-ui/src/library_ui.rs#L6401), [`ui/dr-ui/src/library_ui.rs:6414`](../ui/dr-ui/src/library_ui.rs#L6414), [`ui/dr-ui/src/library_ui.rs:6429`](../ui/dr-ui/src/library_ui.rs#L6429), [`ui/dr-ui/src/library_ui.rs:6438`](../ui/dr-ui/src/library_ui.rs#L6438), [`ui/dr-ui/ui/app.slint:261`](../ui/dr-ui/ui/app.slint#L261), [`ui/dr-ui/ui/app.slint:442`](../ui/dr-ui/ui/app.slint#L442), [`ui/dr-ui/ui/library.slint:1225`](../ui/dr-ui/ui/library.slint#L1225), [`ui/dr-ui/ui/library.slint:1228`](../ui/dr-ui/ui/library.slint#L1228), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:856`](../ui/dr-ui/ui/library.slint#L856), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908) |
|
||||
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:214`](../ui/dr-ui/src/library.rs#L214), [`ui/dr-ui/src/library.rs:3955`](../ui/dr-ui/src/library.rs#L3955), [`ui/dr-ui/src/library_ui.rs:316`](../ui/dr-ui/src/library_ui.rs#L316), [`ui/dr-ui/src/library_ui.rs:388`](../ui/dr-ui/src/library_ui.rs#L388), [`ui/dr-ui/src/library_ui.rs:5204`](../ui/dr-ui/src/library_ui.rs#L5204), [`ui/dr-ui/src/library_ui.rs:5257`](../ui/dr-ui/src/library_ui.rs#L5257), [`ui/dr-ui/src/library_ui.rs:6530`](../ui/dr-ui/src/library_ui.rs#L6530), [`ui/dr-ui/ui/app.slint:264`](../ui/dr-ui/ui/app.slint#L264), [`ui/dr-ui/ui/app.slint:442`](../ui/dr-ui/ui/app.slint#L442), [`ui/dr-ui/ui/app.slint:687`](../ui/dr-ui/ui/app.slint#L687), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1301`](../ui/dr-ui/ui/library.slint#L1301), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908), [`ui/dr-ui/ui/settings.slint:147`](../ui/dr-ui/ui/settings.slint#L147) |
|
||||
| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3485`](../ui/dr-ui/src/library_ui.rs#L3485), [`ui/dr-ui/src/library_ui.rs:4183`](../ui/dr-ui/src/library_ui.rs#L4183), [`ui/dr-ui/ui/app.slint:437`](../ui/dr-ui/ui/app.slint#L437), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2564`](../ui/dr-ui/ui/library.slint#L2564), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) |
|
||||
| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:3374`](../ui/dr-ui/src/develop.rs#L3374), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1387`](../ui/dr-ui/src/lib.rs#L1387), [`ui/dr-ui/src/lib.rs:1788`](../ui/dr-ui/src/lib.rs#L1788), [`ui/dr-ui/src/lib.rs:1924`](../ui/dr-ui/src/lib.rs#L1924), [`ui/dr-ui/src/lib.rs:498`](../ui/dr-ui/src/lib.rs#L498), [`ui/dr-ui/src/lib.rs:923`](../ui/dr-ui/src/lib.rs#L923), [`ui/dr-ui/src/library.rs:1656`](../ui/dr-ui/src/library.rs#L1656), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460), [`ui/dr-ui/src/library.rs:507`](../ui/dr-ui/src/library.rs#L507), [`ui/dr-ui/src/library.rs:544`](../ui/dr-ui/src/library.rs#L544), [`ui/dr-ui/src/library.rs:792`](../ui/dr-ui/src/library.rs#L792), [`ui/dr-ui/src/library_ui.rs:4885`](../ui/dr-ui/src/library_ui.rs#L4885), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2870`](../ui/dr-ui/src/develop.rs#L2870), [`ui/dr-ui/src/library.rs:149`](../ui/dr-ui/src/library.rs#L149), [`ui/dr-ui/src/library.rs:1616`](../ui/dr-ui/src/library.rs#L1616), [`ui/dr-ui/src/library.rs:1693`](../ui/dr-ui/src/library.rs#L1693), [`ui/dr-ui/src/library.rs:234`](../ui/dr-ui/src/library.rs#L234), [`ui/dr-ui/src/library.rs:4062`](../ui/dr-ui/src/library.rs#L4062), [`ui/dr-ui/src/library.rs:544`](../ui/dr-ui/src/library.rs#L544), [`ui/dr-ui/src/library.rs:776`](../ui/dr-ui/src/library.rs#L776), [`ui/dr-ui/src/library.rs:792`](../ui/dr-ui/src/library.rs#L792), [`ui/dr-ui/src/library.rs:846`](../ui/dr-ui/src/library.rs#L846), [`ui/dr-ui/src/library_ui.rs:1565`](../ui/dr-ui/src/library_ui.rs#L1565), [`ui/dr-ui/src/library_ui.rs:1591`](../ui/dr-ui/src/library_ui.rs#L1591), [`ui/dr-ui/src/library_ui.rs:1607`](../ui/dr-ui/src/library_ui.rs#L1607), [`ui/dr-ui/src/library_ui.rs:1701`](../ui/dr-ui/src/library_ui.rs#L1701), [`ui/dr-ui/src/library_ui.rs:227`](../ui/dr-ui/src/library_ui.rs#L227), [`ui/dr-ui/src/library_ui.rs:2317`](../ui/dr-ui/src/library_ui.rs#L2317), [`ui/dr-ui/src/library_ui.rs:260`](../ui/dr-ui/src/library_ui.rs#L260), [`ui/dr-ui/src/library_ui.rs:2755`](../ui/dr-ui/src/library_ui.rs#L2755), [`ui/dr-ui/src/library_ui.rs:2979`](../ui/dr-ui/src/library_ui.rs#L2979), [`ui/dr-ui/src/library_ui.rs:3260`](../ui/dr-ui/src/library_ui.rs#L3260), [`ui/dr-ui/src/library_ui.rs:3348`](../ui/dr-ui/src/library_ui.rs#L3348), [`ui/dr-ui/src/library_ui.rs:3536`](../ui/dr-ui/src/library_ui.rs#L3536), [`ui/dr-ui/src/library_ui.rs:3654`](../ui/dr-ui/src/library_ui.rs#L3654), [`ui/dr-ui/src/library_ui.rs:441`](../ui/dr-ui/src/library_ui.rs#L441), [`ui/dr-ui/src/library_ui.rs:499`](../ui/dr-ui/src/library_ui.rs#L499), [`ui/dr-ui/src/library_ui.rs:5302`](../ui/dr-ui/src/library_ui.rs#L5302), [`ui/dr-ui/src/library_ui.rs:5417`](../ui/dr-ui/src/library_ui.rs#L5417), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) |
|
||||
| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/develop.rs:119`](../ui/dr-ui/src/develop.rs#L119), [`ui/dr-ui/src/develop.rs:128`](../ui/dr-ui/src/develop.rs#L128), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:194`](../ui/dr-ui/src/develop.rs#L194), [`ui/dr-ui/src/develop.rs:589`](../ui/dr-ui/src/develop.rs#L589), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1896`](../ui/dr-ui/src/lib.rs#L1896), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||
| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:255`](../ui/dr-ui/src/library.rs#L255), [`ui/dr-ui/src/library.rs:285`](../ui/dr-ui/src/library.rs#L285), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||
| FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) |
|
||||
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:214`](../ui/dr-ui/src/library.rs#L214), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460) |
|
||||
| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:401`](../core/dr-catalog/src/schema.rs#L401), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2725`](../ui/dr-ui/src/library.rs#L2725), [`ui/dr-ui/src/library.rs:2829`](../ui/dr-ui/src/library.rs#L2829), [`ui/dr-ui/ui/settings.slint:404`](../ui/dr-ui/ui/settings.slint#L404), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) |
|
||||
| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) |
|
||||
| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) |
|
||||
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1297`](../ui/dr-ui/src/develop.rs#L1297), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1775`](../ui/dr-ui/src/develop.rs#L1775), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:1807`](../ui/dr-ui/src/develop.rs#L1807), [`ui/dr-ui/src/develop.rs:1829`](../ui/dr-ui/src/develop.rs#L1829), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3909`](../ui/dr-ui/src/develop.rs#L3909), [`ui/dr-ui/src/develop.rs:3963`](../ui/dr-ui/src/develop.rs#L3963), [`ui/dr-ui/src/develop.rs:4007`](../ui/dr-ui/src/develop.rs#L4007), [`ui/dr-ui/src/develop.rs:4057`](../ui/dr-ui/src/develop.rs#L4057), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1472`](../ui/dr-ui/src/lib.rs#L1472), [`ui/dr-ui/src/lib.rs:2174`](../ui/dr-ui/src/lib.rs#L2174), [`ui/dr-ui/src/lib.rs:319`](../ui/dr-ui/src/lib.rs#L319), [`ui/dr-ui/src/library.rs:507`](../ui/dr-ui/src/library.rs#L507), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:1761`](../ui/dr-ui/ui/app.slint#L1761), [`ui/dr-ui/ui/app.slint:790`](../ui/dr-ui/ui/app.slint#L790), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) |
|
||||
| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1194`](../ui/dr-ui/src/develop.rs#L1194), [`ui/dr-ui/src/lib.rs:613`](../ui/dr-ui/src/lib.rs#L613), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) |
|
||||
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) |
|
||||
| FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4636`](../ui/dr-ui/src/develop.rs#L4636) |
|
||||
| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:476`](../core/dr-gpu/tests/local_contrast.rs#L476), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/graph.rs:616`](../core/dr-pipeline/src/graph.rs#L616), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) |
|
||||
| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) |
|
||||
| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2885`](../ui/dr-ui/src/develop.rs#L2885), [`ui/dr-ui/src/develop.rs:2902`](../ui/dr-ui/src/develop.rs#L2902), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:2952`](../ui/dr-ui/src/develop.rs#L2952), [`ui/dr-ui/src/develop.rs:2961`](../ui/dr-ui/src/develop.rs#L2961), [`ui/dr-ui/src/develop.rs:3064`](../ui/dr-ui/src/develop.rs#L3064), [`ui/dr-ui/src/develop.rs:3382`](../ui/dr-ui/src/develop.rs#L3382), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/lib.rs:2148`](../ui/dr-ui/src/lib.rs#L2148), [`ui/dr-ui/src/lib.rs:546`](../ui/dr-ui/src/lib.rs#L546), [`ui/dr-ui/src/lib.rs:604`](../ui/dr-ui/src/lib.rs#L604), [`ui/dr-ui/src/library.rs:498`](../ui/dr-ui/src/library.rs#L498), [`ui/dr-ui/src/library.rs:747`](../ui/dr-ui/src/library.rs#L747), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:1006`](../ui/dr-ui/ui/adjust.slint#L1006), [`ui/dr-ui/ui/adjust.slint:924`](../ui/dr-ui/ui/adjust.slint#L924), [`ui/dr-ui/ui/app.slint:2251`](../ui/dr-ui/ui/app.slint#L2251), [`ui/dr-ui/ui/app.slint:568`](../ui/dr-ui/ui/app.slint#L568) |
|
||||
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:205`](../core/dr-pipeline/src/framing.rs#L205), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:927`](../core/dr-pipeline/src/framing.rs#L927), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/lib.rs:456`](../core/dr-types/src/lib.rs#L456), [`core/dr-types/src/lib.rs:472`](../core/dr-types/src/lib.rs#L472), [`ui/dr-ui/src/develop.rs:138`](../ui/dr-ui/src/develop.rs#L138), [`ui/dr-ui/src/develop.rs:1992`](../ui/dr-ui/src/develop.rs#L1992), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) |
|
||||
| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) |
|
||||
| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/develop.rs:3427`](../ui/dr-ui/src/develop.rs#L3427), [`ui/dr-ui/src/develop.rs:3440`](../ui/dr-ui/src/develop.rs#L3440), [`ui/dr-ui/src/develop.rs:3452`](../ui/dr-ui/src/develop.rs#L3452), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/develop.rs:3504`](../ui/dr-ui/src/develop.rs#L3504), [`ui/dr-ui/src/develop.rs:3523`](../ui/dr-ui/src/develop.rs#L3523), [`ui/dr-ui/src/develop.rs:3539`](../ui/dr-ui/src/develop.rs#L3539), [`ui/dr-ui/src/develop.rs:610`](../ui/dr-ui/src/develop.rs#L610), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1420`](../ui/dr-ui/src/lib.rs#L1420), [`ui/dr-ui/src/lib.rs:1450`](../ui/dr-ui/src/lib.rs#L1450), [`ui/dr-ui/src/lib.rs:1458`](../ui/dr-ui/src/lib.rs#L1458), [`ui/dr-ui/src/lib.rs:2344`](../ui/dr-ui/src/lib.rs#L2344), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
|
||||
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/develop.rs:3360`](../ui/dr-ui/src/develop.rs#L3360), [`ui/dr-ui/src/lib.rs:1387`](../ui/dr-ui/src/lib.rs#L1387), [`ui/dr-ui/src/library.rs:1656`](../ui/dr-ui/src/library.rs#L1656), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460), [`ui/dr-ui/src/library.rs:488`](../ui/dr-ui/src/library.rs#L488), [`ui/dr-ui/src/library_ui.rs:2577`](../ui/dr-ui/src/library_ui.rs#L2577), [`ui/dr-ui/src/library_ui.rs:2979`](../ui/dr-ui/src/library_ui.rs#L2979), [`ui/dr-ui/src/library_ui.rs:467`](../ui/dr-ui/src/library_ui.rs#L467), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:547`](../ui/dr-ui/src/settings_ui.rs#L547), [`ui/dr-ui/ui/adjust.slint:613`](../ui/dr-ui/ui/adjust.slint#L613), [`ui/dr-ui/ui/library.slint:1328`](../ui/dr-ui/ui/library.slint#L1328), [`ui/dr-ui/ui/library.slint:837`](../ui/dr-ui/ui/library.slint#L837), [`ui/dr-ui/ui/library.slint:919`](../ui/dr-ui/ui/library.slint#L919), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) |
|
||||
| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/lib.rs:1458`](../ui/dr-ui/src/lib.rs#L1458), [`ui/dr-ui/src/lib.rs:2344`](../ui/dr-ui/src/lib.rs#L2344), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
|
||||
| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:685`](../core/dr-pipeline/src/graph.rs#L685), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2144`](../ui/dr-ui/src/develop.rs#L2144), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/develop.rs:2247`](../ui/dr-ui/src/develop.rs#L2247), [`ui/dr-ui/src/develop.rs:2330`](../ui/dr-ui/src/develop.rs#L2330), [`ui/dr-ui/src/develop.rs:2344`](../ui/dr-ui/src/develop.rs#L2344), [`ui/dr-ui/src/develop.rs:658`](../ui/dr-ui/src/develop.rs#L658), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1479`](../ui/dr-ui/src/lib.rs#L1479), [`ui/dr-ui/src/lib.rs:2441`](../ui/dr-ui/src/lib.rs#L2441), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:700`](../ui/dr-ui/ui/adjust.slint#L700), [`ui/dr-ui/ui/app.slint:108`](../ui/dr-ui/ui/app.slint#L108), [`ui/dr-ui/ui/app.slint:1666`](../ui/dr-ui/ui/app.slint#L1666), [`ui/dr-ui/ui/app.slint:1839`](../ui/dr-ui/ui/app.slint#L1839), [`ui/dr-ui/ui/app.slint:2157`](../ui/dr-ui/ui/app.slint#L2157), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) |
|
||||
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:657`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L657), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:672`](../core/dr-pipeline/src/ops/local_contrast.rs#L672), [`core/dr-pipeline/src/ops/noise_reduction.rs:698`](../core/dr-pipeline/src/ops/noise_reduction.rs#L698), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2668`](../ui/dr-ui/src/develop.rs#L2668), [`ui/dr-ui/src/develop.rs:3698`](../ui/dr-ui/src/develop.rs#L3698), [`ui/dr-ui/src/develop.rs:4181`](../ui/dr-ui/src/develop.rs#L4181), [`ui/dr-ui/src/develop.rs:4215`](../ui/dr-ui/src/develop.rs#L4215), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72), [`ui/dr-ui/src/lib.rs:754`](../ui/dr-ui/src/lib.rs#L754), [`ui/dr-ui/src/lib.rs:813`](../ui/dr-ui/src/lib.rs#L813) |
|
||||
| FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101) |
|
||||
| FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../core/dr-gpu/tests/zoom_resolution.rs#L216) |
|
||||
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/lib.rs:1435`](../ui/dr-ui/src/lib.rs#L1435), [`ui/dr-ui/src/lib.rs:2647`](../ui/dr-ui/src/lib.rs#L2647) |
|
||||
| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2747`](../ui/dr-ui/src/develop.rs#L2747), [`ui/dr-ui/src/develop.rs:5283`](../ui/dr-ui/src/develop.rs#L5283), [`ui/dr-ui/src/develop.rs:5315`](../ui/dr-ui/src/develop.rs#L5315), [`ui/dr-ui/src/develop.rs:621`](../ui/dr-ui/src/develop.rs#L621), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1535`](../ui/dr-ui/src/lib.rs#L1535), [`ui/dr-ui/src/lib.rs:314`](../ui/dr-ui/src/lib.rs#L314), [`ui/dr-ui/ui/app.slint:68`](../ui/dr-ui/ui/app.slint#L68), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) |
|
||||
| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/develop.rs:5519`](../ui/dr-ui/src/develop.rs#L5519), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/develop.rs:689`](../ui/dr-ui/src/develop.rs#L689), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1409`](../ui/dr-ui/src/lib.rs#L1409), [`ui/dr-ui/src/lib.rs:1435`](../ui/dr-ui/src/lib.rs#L1435), [`ui/dr-ui/src/lib.rs:2624`](../ui/dr-ui/src/lib.rs#L2624), [`ui/dr-ui/src/lib.rs:2647`](../ui/dr-ui/src/lib.rs#L2647), [`ui/dr-ui/ui/app.slint:1526`](../ui/dr-ui/ui/app.slint#L1526), [`ui/dr-ui/ui/app.slint:47`](../ui/dr-ui/ui/app.slint#L47), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:731`](../ui/dr-ui/ui/settings.slint#L731) |
|
||||
| FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2400`](../core/dr-gpu/src/adjust.rs#L2400), [`core/dr-pipeline/src/graph.rs:417`](../core/dr-pipeline/src/graph.rs#L417), [`core/dr-pipeline/src/graph.rs:470`](../core/dr-pipeline/src/graph.rs#L470), [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:537`](../core/dr-pipeline/src/graph.rs#L537), [`core/dr-pipeline/src/graph.rs:591`](../core/dr-pipeline/src/graph.rs#L591), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||
| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:590`](../ui/dr-ui/src/settings_ui.rs#L590) |
|
||||
| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175), [`ui/dr-ui/src/lib.rs:1850`](../ui/dr-ui/src/lib.rs#L1850), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/lib.rs:363`](../ui/dr-ui/src/lib.rs#L363), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library_ui.rs:3346`](../ui/dr-ui/src/library_ui.rs#L3346), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/library_ui.rs:6237`](../ui/dr-ui/src/library_ui.rs#L6237), [`ui/dr-ui/src/library_ui.rs:6314`](../ui/dr-ui/src/library_ui.rs#L6314), [`ui/dr-ui/src/library_ui.rs:6326`](../ui/dr-ui/src/library_ui.rs#L6326), [`ui/dr-ui/src/library_ui.rs:660`](../ui/dr-ui/src/library_ui.rs#L660), [`ui/dr-ui/src/library_ui.rs:717`](../ui/dr-ui/src/library_ui.rs#L717), [`ui/dr-ui/ui/app.slint:1004`](../ui/dr-ui/ui/app.slint#L1004), [`ui/dr-ui/ui/app.slint:1384`](../ui/dr-ui/ui/app.slint#L1384), [`ui/dr-ui/ui/library.slint:1313`](../ui/dr-ui/ui/library.slint#L1313), [`ui/dr-ui/ui/library.slint:835`](../ui/dr-ui/ui/library.slint#L835), [`ui/dr-ui/ui/library.slint:927`](../ui/dr-ui/ui/library.slint#L927) |
|
||||
| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:779`](../ui/dr-ui/src/export.rs#L779), [`ui/dr-ui/src/export.rs:796`](../ui/dr-ui/src/export.rs#L796), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1068`](../core/dr-gpu/src/adjust.rs#L1068), [`ui/dr-ui/src/develop.rs:2284`](../ui/dr-ui/src/develop.rs#L2284), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328) |
|
||||
| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:602`](../ui/dr-ui/src/settings_ui.rs#L602) |
|
||||
| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/lib.rs:204`](../ui/dr-ui/src/lib.rs#L204), [`ui/dr-ui/src/lib.rs:2090`](../ui/dr-ui/src/lib.rs#L2090), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/lib.rs:397`](../ui/dr-ui/src/lib.rs#L397), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library_ui.rs:3365`](../ui/dr-ui/src/library_ui.rs#L3365), [`ui/dr-ui/src/library_ui.rs:558`](../ui/dr-ui/src/library_ui.rs#L558), [`ui/dr-ui/src/library_ui.rs:6372`](../ui/dr-ui/src/library_ui.rs#L6372), [`ui/dr-ui/src/library_ui.rs:6449`](../ui/dr-ui/src/library_ui.rs#L6449), [`ui/dr-ui/src/library_ui.rs:6461`](../ui/dr-ui/src/library_ui.rs#L6461), [`ui/dr-ui/src/library_ui.rs:661`](../ui/dr-ui/src/library_ui.rs#L661), [`ui/dr-ui/src/library_ui.rs:718`](../ui/dr-ui/src/library_ui.rs#L718), [`ui/dr-ui/ui/app.slint:1367`](../ui/dr-ui/ui/app.slint#L1367), [`ui/dr-ui/ui/app.slint:926`](../ui/dr-ui/ui/app.slint#L926), [`ui/dr-ui/ui/library.slint:1336`](../ui/dr-ui/ui/library.slint#L1336), [`ui/dr-ui/ui/library.slint:841`](../ui/dr-ui/ui/library.slint#L841), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) |
|
||||
| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:652`](../core/dr-types/src/lib.rs#L652), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:779`](../ui/dr-ui/src/export.rs#L779), [`ui/dr-ui/src/export.rs:796`](../ui/dr-ui/src/export.rs#L796), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1068`](../core/dr-gpu/src/adjust.rs#L1068), [`ui/dr-ui/src/develop.rs:2829`](../ui/dr-ui/src/develop.rs#L2829), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362) |
|
||||
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) |
|
||||
| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library.rs:1582`](../ui/dr-ui/src/library.rs#L1582), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:947`](../ui/dr-ui/src/library.rs#L947), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:3346`](../ui/dr-ui/src/library_ui.rs#L3346), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) |
|
||||
| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) |
|
||||
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2598`](../ui/dr-ui/src/library.rs#L2598), [`ui/dr-ui/src/library.rs:2624`](../ui/dr-ui/src/library.rs#L2624), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3608`](../ui/dr-ui/src/library_ui.rs#L3608), [`ui/dr-ui/src/library_ui.rs:4574`](../ui/dr-ui/src/library_ui.rs#L4574), [`ui/dr-ui/ui/app.slint:513`](../ui/dr-ui/ui/app.slint#L513), [`ui/dr-ui/ui/settings.slint:340`](../ui/dr-ui/ui/settings.slint#L340), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
||||
| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/library.rs:1049`](../ui/dr-ui/src/library.rs#L1049), [`ui/dr-ui/src/library.rs:1693`](../ui/dr-ui/src/library.rs#L1693), [`ui/dr-ui/src/library.rs:544`](../ui/dr-ui/src/library.rs#L544), [`ui/dr-ui/src/library.rs:846`](../ui/dr-ui/src/library.rs#L846), [`ui/dr-ui/src/library_ui.rs:1607`](../ui/dr-ui/src/library_ui.rs#L1607), [`ui/dr-ui/src/library_ui.rs:3365`](../ui/dr-ui/src/library_ui.rs#L3365), [`ui/dr-ui/src/library_ui.rs:499`](../ui/dr-ui/src/library_ui.rs#L499), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:904`](../core/dr-sync-nextcloud/src/lib.rs#L904), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) |
|
||||
| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) |
|
||||
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2724`](../ui/dr-ui/src/library.rs#L2724), [`ui/dr-ui/src/library.rs:3195`](../ui/dr-ui/src/library.rs#L3195), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4593`](../ui/dr-ui/src/library_ui.rs#L4593), [`ui/dr-ui/ui/app.slint:316`](../ui/dr-ui/ui/app.slint#L316), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
||||
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) |
|
||||
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489) |
|
||||
| FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
|
||||
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:394`](../core/dr-catalog/src/schema.rs#L394), [`core/dr-catalog/src/schema.rs:795`](../core/dr-catalog/src/schema.rs#L795), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1610`](../ui/dr-ui/src/lib.rs#L1610), [`ui/dr-ui/src/lib.rs:2407`](../ui/dr-ui/src/lib.rs#L2407), [`ui/dr-ui/src/library.rs:1324`](../ui/dr-ui/src/library.rs#L1324), [`ui/dr-ui/src/library.rs:1347`](../ui/dr-ui/src/library.rs#L1347), [`ui/dr-ui/src/library.rs:1505`](../ui/dr-ui/src/library.rs#L1505), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1412`](../ui/dr-ui/src/library_ui.rs#L1412), [`ui/dr-ui/src/library_ui.rs:1531`](../ui/dr-ui/src/library_ui.rs#L1531), [`ui/dr-ui/src/library_ui.rs:2058`](../ui/dr-ui/src/library_ui.rs#L2058), [`ui/dr-ui/src/library_ui.rs:267`](../ui/dr-ui/src/library_ui.rs#L267), [`ui/dr-ui/src/library_ui.rs:278`](../ui/dr-ui/src/library_ui.rs#L278), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:298`](../ui/dr-ui/src/library_ui.rs#L298), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:399`](../ui/dr-ui/src/library_ui.rs#L399), [`ui/dr-ui/src/library_ui.rs:409`](../ui/dr-ui/src/library_ui.rs#L409), [`ui/dr-ui/src/library_ui.rs:451`](../ui/dr-ui/src/library_ui.rs#L451), [`ui/dr-ui/src/library_ui.rs:514`](../ui/dr-ui/src/library_ui.rs#L514), [`ui/dr-ui/src/library_ui.rs:5239`](../ui/dr-ui/src/library_ui.rs#L5239), [`ui/dr-ui/src/library_ui.rs:5257`](../ui/dr-ui/src/library_ui.rs#L5257), [`ui/dr-ui/src/library_ui.rs:5269`](../ui/dr-ui/src/library_ui.rs#L5269), [`ui/dr-ui/src/library_ui.rs:545`](../ui/dr-ui/src/library_ui.rs#L545), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2420`](../ui/dr-ui/ui/app.slint#L2420), [`ui/dr-ui/ui/app.slint:576`](../ui/dr-ui/ui/app.slint#L576), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:973`](../ui/dr-ui/ui/library.slint#L973) |
|
||||
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294) |
|
||||
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) |
|
||||
| FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:2624`](../ui/dr-ui/src/library.rs#L2624), [`ui/dr-ui/src/library_ui.rs:3608`](../ui/dr-ui/src/library_ui.rs#L3608), [`ui/dr-ui/ui/settings.slint:340`](../ui/dr-ui/ui/settings.slint#L340) |
|
||||
| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1002`](../ui/dr-ui/src/lib.rs#L1002), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
||||
| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:123`](../ui/dr-ui/src/import.rs#L123), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import.rs:585`](../ui/dr-ui/src/import.rs#L585), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1002`](../ui/dr-ui/src/lib.rs#L1002) |
|
||||
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:120`](../core/dr-pipeline/src/sidecar.rs#L120), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/lib.rs:1579`](../ui/dr-ui/src/lib.rs#L1579), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466) |
|
||||
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/src/sidecar.rs:1723`](../core/dr-pipeline/src/sidecar.rs#L1723), [`core/dr-pipeline/src/sidecar.rs:332`](../core/dr-pipeline/src/sidecar.rs#L332), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:880`](../ui/dr-ui/src/library.rs#L880) |
|
||||
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) |
|
||||
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:1014`](../core/dr-catalog/src/schema.rs#L1014), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1819`](../ui/dr-ui/src/lib.rs#L1819), [`ui/dr-ui/src/lib.rs:2710`](../ui/dr-ui/src/lib.rs#L2710), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1458`](../ui/dr-ui/src/library.rs#L1458), [`ui/dr-ui/src/library.rs:1616`](../ui/dr-ui/src/library.rs#L1616), [`ui/dr-ui/src/library_ui.rs:1066`](../ui/dr-ui/src/library_ui.rs#L1066), [`ui/dr-ui/src/library_ui.rs:1139`](../ui/dr-ui/src/library_ui.rs#L1139), [`ui/dr-ui/src/library_ui.rs:1240`](../ui/dr-ui/src/library_ui.rs#L1240), [`ui/dr-ui/src/library_ui.rs:1295`](../ui/dr-ui/src/library_ui.rs#L1295), [`ui/dr-ui/src/library_ui.rs:1413`](../ui/dr-ui/src/library_ui.rs#L1413), [`ui/dr-ui/src/library_ui.rs:1532`](../ui/dr-ui/src/library_ui.rs#L1532), [`ui/dr-ui/src/library_ui.rs:2059`](../ui/dr-ui/src/library_ui.rs#L2059), [`ui/dr-ui/src/library_ui.rs:268`](../ui/dr-ui/src/library_ui.rs#L268), [`ui/dr-ui/src/library_ui.rs:279`](../ui/dr-ui/src/library_ui.rs#L279), [`ui/dr-ui/src/library_ui.rs:287`](../ui/dr-ui/src/library_ui.rs#L287), [`ui/dr-ui/src/library_ui.rs:299`](../ui/dr-ui/src/library_ui.rs#L299), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:400`](../ui/dr-ui/src/library_ui.rs#L400), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:452`](../ui/dr-ui/src/library_ui.rs#L452), [`ui/dr-ui/src/library_ui.rs:515`](../ui/dr-ui/src/library_ui.rs#L515), [`ui/dr-ui/src/library_ui.rs:5319`](../ui/dr-ui/src/library_ui.rs#L5319), [`ui/dr-ui/src/library_ui.rs:5337`](../ui/dr-ui/src/library_ui.rs#L5337), [`ui/dr-ui/src/library_ui.rs:5349`](../ui/dr-ui/src/library_ui.rs#L5349), [`ui/dr-ui/src/library_ui.rs:546`](../ui/dr-ui/src/library_ui.rs#L546), [`ui/dr-ui/src/library_ui.rs:558`](../ui/dr-ui/src/library_ui.rs#L558), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2330`](../ui/dr-ui/ui/app.slint#L2330), [`ui/dr-ui/ui/app.slint:431`](../ui/dr-ui/ui/app.slint#L431), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:980`](../ui/dr-ui/ui/library.slint#L980) |
|
||||
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1295`](../ui/dr-ui/src/library_ui.rs#L1295) |
|
||||
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/library_ui.rs:1066`](../ui/dr-ui/src/library_ui.rs#L1066), [`ui/dr-ui/src/library_ui.rs:1139`](../ui/dr-ui/src/library_ui.rs#L1139), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) |
|
||||
| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3195`](../ui/dr-ui/src/library.rs#L3195), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372) |
|
||||
| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
||||
| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:123`](../ui/dr-ui/src/import.rs#L123), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import.rs:585`](../ui/dr-ui/src/import.rs#L585), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1126`](../ui/dr-ui/src/lib.rs#L1126) |
|
||||
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1788`](../ui/dr-ui/src/lib.rs#L1788), [`ui/dr-ui/src/library.rs:460`](../ui/dr-ui/src/library.rs#L460), [`ui/dr-ui/src/library_ui.rs:467`](../ui/dr-ui/src/library_ui.rs#L467) |
|
||||
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/library.rs:846`](../ui/dr-ui/src/library.rs#L846), [`ui/dr-ui/src/library.rs:976`](../ui/dr-ui/src/library.rs#L976) |
|
||||
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) |
|
||||
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
||||
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:789`](../ui/dr-ui/src/lib.rs#L789), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:863`](../ui/dr-ui/src/lib.rs#L863), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||
| FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1) |
|
||||
| FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:416`](../core/dr-pipeline/tests/declared_parity.rs#L416) |
|
||||
| FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:420`](../core/dr-pipeline/src/declared/decl.rs#L420), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) |
|
||||
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:129`](../core/dr-types/src/lib.rs#L129), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200) |
|
||||
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) |
|
||||
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175) |
|
||||
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:204`](../ui/dr-ui/src/lib.rs#L204) |
|
||||
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) |
|
||||
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2489`](../ui/dr-ui/src/lib.rs#L2489), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/library.slint:1031`](../ui/dr-ui/ui/library.slint#L1031) |
|
||||
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:5124`](../ui/dr-ui/src/library_ui.rs#L5124), [`ui/dr-ui/src/library_ui.rs:5269`](../ui/dr-ui/src/library_ui.rs#L5269), [`ui/dr-ui/src/library_ui.rs:6345`](../ui/dr-ui/src/library_ui.rs#L6345), [`ui/dr-ui/ui/app.slint:2142`](../ui/dr-ui/ui/app.slint#L2142), [`ui/dr-ui/ui/app.slint:262`](../ui/dr-ui/ui/app.slint#L262), [`ui/dr-ui/ui/app.slint:606`](../ui/dr-ui/ui/app.slint#L606), [`ui/dr-ui/ui/app.slint:613`](../ui/dr-ui/ui/app.slint#L613), [`ui/dr-ui/ui/library.slint:1186`](../ui/dr-ui/ui/library.slint#L1186), [`ui/dr-ui/ui/library.slint:1193`](../ui/dr-ui/ui/library.slint#L1193), [`ui/dr-ui/ui/library.slint:1199`](../ui/dr-ui/ui/library.slint#L1199), [`ui/dr-ui/ui/library.slint:2266`](../ui/dr-ui/ui/library.slint#L2266), [`ui/dr-ui/ui/library.slint:823`](../ui/dr-ui/ui/library.slint#L823), [`ui/dr-ui/ui/library.slint:872`](../ui/dr-ui/ui/library.slint#L872), [`ui/dr-ui/ui/settings.slint:93`](../ui/dr-ui/ui/settings.slint#L93) |
|
||||
| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/library_ui.rs:4369`](../ui/dr-ui/src/library_ui.rs#L4369), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/ui/app.slint:1915`](../ui/dr-ui/ui/app.slint#L1915), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) |
|
||||
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4369`](../ui/dr-ui/src/library_ui.rs#L4369), [`ui/dr-ui/src/library_ui.rs:4414`](../ui/dr-ui/src/library_ui.rs#L4414), [`ui/dr-ui/src/library_ui.rs:4517`](../ui/dr-ui/src/library_ui.rs#L4517), [`ui/dr-ui/src/library_ui.rs:4545`](../ui/dr-ui/src/library_ui.rs#L4545), [`ui/dr-ui/src/library_ui.rs:5257`](../ui/dr-ui/src/library_ui.rs#L5257), [`ui/dr-ui/src/library_ui.rs:5269`](../ui/dr-ui/src/library_ui.rs#L5269), [`ui/dr-ui/ui/app.slint:1611`](../ui/dr-ui/ui/app.slint#L1611), [`ui/dr-ui/ui/app.slint:582`](../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/app.slint:613`](../ui/dr-ui/ui/app.slint#L613), [`ui/dr-ui/ui/library.slint:1186`](../ui/dr-ui/ui/library.slint#L1186), [`ui/dr-ui/ui/library.slint:1193`](../ui/dr-ui/ui/library.slint#L1193), [`ui/dr-ui/ui/library.slint:1199`](../ui/dr-ui/ui/library.slint#L1199), [`ui/dr-ui/ui/library.slint:2266`](../ui/dr-ui/ui/library.slint#L2266), [`ui/dr-ui/ui/library.slint:823`](../ui/dr-ui/ui/library.slint#L823), [`ui/dr-ui/ui/library.slint:872`](../ui/dr-ui/ui/library.slint#L872), [`ui/dr-ui/ui/library.slint:891`](../ui/dr-ui/ui/library.slint#L891) |
|
||||
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2134`](../ui/dr-ui/src/lib.rs#L2134), [`ui/dr-ui/src/lib.rs:2523`](../ui/dr-ui/src/lib.rs#L2523), [`ui/dr-ui/src/lib.rs:2708`](../ui/dr-ui/src/lib.rs#L2708), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:297`](../ui/dr-ui/ui/app.slint#L297), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||
| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259) |
|
||||
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
||||
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1555`](../ui/dr-ui/src/export.rs#L1555), [`ui/dr-ui/src/export.rs:1581`](../ui/dr-ui/src/export.rs#L1581), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1884`](../ui/dr-ui/src/lib.rs#L1884), [`ui/dr-ui/ui/app.slint:1015`](../ui/dr-ui/ui/app.slint#L1015), [`ui/dr-ui/ui/library.slint:927`](../ui/dr-ui/ui/library.slint#L927) |
|
||||
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) |
|
||||
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2792`](../ui/dr-ui/src/lib.rs#L2792), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/identity.slint:165`](../ui/dr-ui/ui/identity.slint#L165), [`ui/dr-ui/ui/library.slint:1046`](../ui/dr-ui/ui/library.slint#L1046) |
|
||||
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/lib.rs:87`](../ui/dr-ui/src/lib.rs#L87), [`ui/dr-ui/src/library_ui.rs:287`](../ui/dr-ui/src/library_ui.rs#L287), [`ui/dr-ui/src/library_ui.rs:5204`](../ui/dr-ui/src/library_ui.rs#L5204), [`ui/dr-ui/src/library_ui.rs:5349`](../ui/dr-ui/src/library_ui.rs#L5349), [`ui/dr-ui/src/library_ui.rs:6480`](../ui/dr-ui/src/library_ui.rs#L6480), [`ui/dr-ui/ui/adjust.slint:506`](../ui/dr-ui/ui/adjust.slint#L506), [`ui/dr-ui/ui/adjust.slint:641`](../ui/dr-ui/ui/adjust.slint#L641), [`ui/dr-ui/ui/adjust.slint:962`](../ui/dr-ui/ui/adjust.slint#L962), [`ui/dr-ui/ui/app.slint:1945`](../ui/dr-ui/ui/app.slint#L1945), [`ui/dr-ui/ui/app.slint:461`](../ui/dr-ui/ui/app.slint#L461), [`ui/dr-ui/ui/app.slint:468`](../ui/dr-ui/ui/app.slint#L468), [`ui/dr-ui/ui/app.slint:54`](../ui/dr-ui/ui/app.slint#L54), [`ui/dr-ui/ui/app.slint:761`](../ui/dr-ui/ui/app.slint#L761), [`ui/dr-ui/ui/develop.slint:223`](../ui/dr-ui/ui/develop.slint#L223), [`ui/dr-ui/ui/histogram.slint:127`](../ui/dr-ui/ui/histogram.slint#L127), [`ui/dr-ui/ui/history.slint:118`](../ui/dr-ui/ui/history.slint#L118), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/masks.slint:304`](../ui/dr-ui/ui/masks.slint#L304), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106), [`ui/dr-ui/ui/spots.slint:88`](../ui/dr-ui/ui/spots.slint#L88) |
|
||||
| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/library_ui.rs:4388`](../ui/dr-ui/src/library_ui.rs#L4388), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:1761`](../ui/dr-ui/ui/app.slint#L1761), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) |
|
||||
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4388`](../ui/dr-ui/src/library_ui.rs#L4388), [`ui/dr-ui/src/library_ui.rs:4433`](../ui/dr-ui/src/library_ui.rs#L4433), [`ui/dr-ui/src/library_ui.rs:4536`](../ui/dr-ui/src/library_ui.rs#L4536), [`ui/dr-ui/src/library_ui.rs:4564`](../ui/dr-ui/src/library_ui.rs#L4564), [`ui/dr-ui/src/library_ui.rs:5337`](../ui/dr-ui/src/library_ui.rs#L5337), [`ui/dr-ui/src/library_ui.rs:5349`](../ui/dr-ui/src/library_ui.rs#L5349), [`ui/dr-ui/ui/app.slint:1603`](../ui/dr-ui/ui/app.slint#L1603), [`ui/dr-ui/ui/app.slint:437`](../ui/dr-ui/ui/app.slint#L437), [`ui/dr-ui/ui/app.slint:468`](../ui/dr-ui/ui/app.slint#L468), [`ui/dr-ui/ui/library.slint:1202`](../ui/dr-ui/ui/library.slint#L1202), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1215`](../ui/dr-ui/ui/library.slint#L1215), [`ui/dr-ui/ui/library.slint:2314`](../ui/dr-ui/ui/library.slint#L2314), [`ui/dr-ui/ui/library.slint:829`](../ui/dr-ui/ui/library.slint#L829), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:898`](../ui/dr-ui/ui/library.slint#L898) |
|
||||
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2406`](../ui/dr-ui/src/lib.rs#L2406), [`ui/dr-ui/src/lib.rs:2831`](../ui/dr-ui/src/lib.rs#L2831), [`ui/dr-ui/src/lib.rs:3016`](../ui/dr-ui/src/lib.rs#L3016), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:89`](../ui/dr-ui/ui/app.slint#L89), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||
| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262) |
|
||||
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2829`](../ui/dr-ui/src/library.rs#L2829) |
|
||||
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1555`](../ui/dr-ui/src/export.rs#L1555), [`ui/dr-ui/src/export.rs:1581`](../ui/dr-ui/src/export.rs#L1581), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:2124`](../ui/dr-ui/src/lib.rs#L2124), [`ui/dr-ui/ui/app.slint:937`](../ui/dr-ui/ui/app.slint#L937), [`ui/dr-ui/ui/library.slint:934`](../ui/dr-ui/ui/library.slint#L934) |
|
||||
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) |
|
||||
| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) |
|
||||
| NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
||||
| NFR-P13 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
|
||||
| NFR-P5 | [`core/dr-catalog/src/schema.rs:292`](../core/dr-catalog/src/schema.rs#L292), [`ui/dr-ui/src/library.rs:3141`](../ui/dr-ui/src/library.rs#L3141), [`ui/dr-ui/src/library.rs:3914`](../ui/dr-ui/src/library.rs#L3914), [`ui/dr-ui/src/library_ui.rs:4054`](../ui/dr-ui/src/library_ui.rs#L4054), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) |
|
||||
| NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) |
|
||||
| NFR-P5 | [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:3795`](../ui/dr-ui/src/library.rs#L3795), [`ui/dr-ui/src/library.rs:4568`](../ui/dr-ui/src/library.rs#L4568), [`ui/dr-ui/src/library_ui.rs:4073`](../ui/dr-ui/src/library_ui.rs#L4073), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) |
|
||||
| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) |
|
||||
| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302) |
|
||||
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:947`](../ui/dr-ui/src/library.rs#L947) |
|
||||
| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) |
|
||||
| NFR-PORT-3 | [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1) |
|
||||
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:1049`](../ui/dr-ui/src/library.rs#L1049) |
|
||||
| NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) |
|
||||
| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) |
|
||||
| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
||||
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
||||
| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59) |
|
||||
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:394`](../core/dr-catalog/src/schema.rs#L394), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2598`](../ui/dr-ui/src/library.rs#L2598) |
|
||||
| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72) |
|
||||
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2724`](../ui/dr-ui/src/library.rs#L2724) |
|
||||
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
|
||||
| NFR-SEC-2 | [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) |
|
||||
| NFR-SEC-5 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||
| R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) |
|
||||
| R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
|
||||
|
||||
## Not yet tagged
|
||||
|
||||
86 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
|
||||
71 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
|
||||
|
||||
<details><summary>Show untagged requirements</summary>
|
||||
|
||||
- FR-CAT-14
|
||||
- FR-CULL-10
|
||||
- FR-CULL-11
|
||||
- FR-CULL-12
|
||||
- FR-CULL-3
|
||||
- FR-CULL-5
|
||||
- FR-CULL-6
|
||||
- FR-CULL-7
|
||||
- FR-CULL-8
|
||||
- FR-CULL-9
|
||||
- FR-DEV-1
|
||||
- FR-DEV-3g
|
||||
- FR-DEV-7
|
||||
- FR-DSP-2
|
||||
- FR-DSP-3
|
||||
- FR-DSP-4
|
||||
- FR-DSP-5
|
||||
- FR-DSP-8
|
||||
- FR-NC-11
|
||||
- FR-PLAT-AND-2
|
||||
- FR-PLAT-AND-4
|
||||
- FR-PLAT-AND-5
|
||||
- FR-PLAT-AND-6
|
||||
- FR-PLAT-LIN-2
|
||||
- FR-PLAT-LIN-3
|
||||
- FR-PLG-1
|
||||
- FR-PLG-10
|
||||
- FR-PLG-11
|
||||
- FR-PLG-12
|
||||
- FR-PLG-1a
|
||||
- FR-PLG-2
|
||||
- FR-PLG-2a
|
||||
- FR-PLG-2b
|
||||
- FR-PLG-2c
|
||||
- FR-PLG-2d
|
||||
- FR-PLG-3
|
||||
- FR-PLG-3a
|
||||
- FR-PLG-4
|
||||
@@ -202,16 +205,13 @@ _None._
|
||||
- NFR-P7
|
||||
- NFR-P8
|
||||
- NFR-PORT-2
|
||||
- NFR-PORT-3
|
||||
- NFR-R3
|
||||
- NFR-R4
|
||||
- NFR-R6
|
||||
- NFR-RES-2
|
||||
- NFR-RES-3
|
||||
- NFR-SEC-2
|
||||
- NFR-SEC-3
|
||||
- NFR-SEC-4
|
||||
- NFR-SEC-5
|
||||
- NFR-SEC-6
|
||||
- R2
|
||||
- R3
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Face models
|
||||
|
||||
The shape-fixed SCRFD detector and ArcFace/MobileFaceNet embedder, in LFS. One copy, packaged by
|
||||
every platform:
|
||||
|
||||
| Platform | How it ships | Where it lands |
|
||||
|---|---|---|
|
||||
| Android | `assemble-apk.sh` bundles them as APK assets; `android_main` unpacks on first launch | the shared user data directory |
|
||||
| Arch | `PKGBUILD` installs them | `/usr/share/darkroom/models/` |
|
||||
|
||||
`library::face_models` searches the account's own directory, then the shared user directory, then
|
||||
`$XDG_DATA_DIRS` — so a pair the user placed by hand always outranks the packaged one.
|
||||
|
||||
scrfd_500m_640.onnx 2.5 MB
|
||||
arcface_mbf_b1.onnx 13 MB
|
||||
|
||||
**A clone without git-lfs gets a ~130-byte pointer where each model should be.** Both packagers check
|
||||
for exactly that and refuse, rather than shipping the pointer and failing inside tract on the user's
|
||||
machine. Fix it with `git lfs pull`.
|
||||
|
||||
These are not what InsightFace ships. They came from `buffalo_sc.zip` and `buffalo_s.zip` on the
|
||||
InsightFace v0.7 release with their input dimensions pinned, because tract cannot parse either graph
|
||||
while they are dynamic:
|
||||
|
||||
./tools/fix-face-model-shapes.sh det_500m.onnx models/face/scrfd_500m_640.onnx --input input.1=1,3,640,640
|
||||
./tools/fix-face-model-shapes.sh w600k_mbf.onnx models/face/arcface_mbf_b1.onnx --dim None=1
|
||||
|
||||
The weights carry a non-commercial research-only grant. They are here because this is a private
|
||||
repository and self-installed builds; they come back out before anything is published, and the
|
||||
restriction binds whoever uses the app, not only the project. docs/faces.md §2.2a is the decision.
|
||||
Binary file not shown.
Binary file not shown.
+19
-1
@@ -4,7 +4,9 @@
|
||||
# makes `makepkg -si` in this directory install what you are actually working
|
||||
# on. Swap `source` for a tagged tarball when there is something to release.
|
||||
pkgname=darkroom
|
||||
pkgver=0.6.0
|
||||
pkgver=0.8.0
|
||||
# 2: the package gained the face models (docs/faces.md §2.2a). Same version,
|
||||
# different contents, so makepkg must not reuse the 0.7.0-1 archive.
|
||||
pkgrel=1
|
||||
pkgdesc="Non-destructive RAW photo library and editor"
|
||||
arch=('x86_64')
|
||||
@@ -43,4 +45,20 @@ package() {
|
||||
"${pkgdir}/usr/share/icons/hicolor/256x256/apps/paris.tourolle.darkroom.png"
|
||||
|
||||
install -Dm644 "README.md" "${pkgdir}/usr/share/doc/${pkgname}/README.md"
|
||||
|
||||
# The face models, into the last directory the app searches. A pair the
|
||||
# user placed in their own data directory outranks these, so installing
|
||||
# them cannot override a deliberate choice of weights.
|
||||
#
|
||||
# These live in LFS; a checkout without `git lfs pull` has ~130-byte
|
||||
# pointers here. Installing one produces a package whose face indexing
|
||||
# fails inside the graph loader on the user's machine, so refuse instead.
|
||||
for _m in scrfd_500m_640.onnx arcface_mbf_b1.onnx; do
|
||||
_src="models/face/${_m}"
|
||||
if [[ "$(stat -c%s "${_src}")" -lt 100000 ]]; then
|
||||
echo "error: ${_m} is an LFS pointer, not a model — run: git lfs pull" >&2
|
||||
return 1
|
||||
fi
|
||||
install -Dm644 "${_src}" "${pkgdir}/usr/share/darkroom/models/${_m}"
|
||||
done
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ log.workspace = true
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "android")))'.dependencies]
|
||||
keyring.workspace = true
|
||||
# The two display servers FR-PLAT-LIN-2 names, each asked for the profile it
|
||||
# knows how to state (FR-DSP-8, `display.rs`). Both are already in the tree
|
||||
# via winit, so this adds compilation of nothing that was not already built.
|
||||
x11rb.workspace = true
|
||||
wayland-client.workspace = true
|
||||
wayland-protocols.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android-native-keyring-store.workspace = true
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user