Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf84cec96f | ||
|
|
f0e7b8e11c | ||
|
|
90c7cb65d3 | ||
|
|
70583b9b2f | ||
|
|
f426bb903a | ||
|
|
c41f99ea52 | ||
|
|
a6ea6ba83f | ||
|
|
b480de5bff | ||
|
|
a8043e6827 | ||
|
|
4b4c9e2e6d | ||
|
|
dc9da52651 | ||
|
|
dc1add9dbb | ||
|
|
b5ae5c2be1 | ||
|
|
aa2a88f655 | ||
|
|
f8737e3fda | ||
|
|
d489a34190 | ||
|
|
9d1e31ffbb | ||
|
|
150e53e878 | ||
|
|
7591738c73 | ||
|
|
352e59498b | ||
|
|
d8f26fb5cd | ||
|
|
10216355c1 | ||
|
|
d8e031888e | ||
|
|
114d979397 | ||
|
|
5a500118ae | ||
|
|
ff89a4fa21 | ||
|
|
04495afbfd | ||
|
|
96f1d5c896 | ||
|
|
f1db919b9d | ||
|
|
46f5b95828 | ||
|
|
d748527a4c | ||
|
|
89859d39d1 | ||
|
|
ade627a5d0 | ||
|
|
4642c77e18 | ||
|
|
7d0870c3fb | ||
|
|
c3d1f83b96 | ||
|
|
fc0ea8824d | ||
|
|
9772785f81 | ||
|
|
733a033274 | ||
|
|
414094bd38 | ||
|
|
d8fb382ce9 | ||
|
|
e8f68a92f8 | ||
|
|
8f3df7b68d | ||
|
|
5f0b7ac799 | ||
|
|
8cdad3863d | ||
|
|
229def0afc | ||
|
|
5569a066ff | ||
|
|
ea31791388 | ||
|
|
adade27de4 | ||
|
|
a3f3e188e1 | ||
|
|
031315bdb6 | ||
|
|
00c028c8c8 | ||
|
|
bddf3250c5 | ||
|
|
41486bd59b | ||
|
|
d6d27fb062 | ||
|
|
317a2f40bd | ||
|
|
949fe40d5b | ||
|
|
2be80d4203 | ||
|
|
9ebaa15099 | ||
|
|
aee355fada | ||
|
|
7fa3176f88 | ||
|
|
af162dd010 | ||
|
|
f5300a9f43 | ||
|
|
b19d470189 | ||
|
|
bfadd9c409 | ||
|
|
050b2a3914 | ||
|
|
e166b64ee9 | ||
|
|
a773ad5c27 | ||
|
|
1e472fd251 | ||
|
|
6bf67cefc4 | ||
|
|
00e2fe6aaf | ||
|
|
402dcdc24c | ||
|
|
94b39410bc | ||
|
|
2014c80e62 | ||
|
|
fbfa891296 | ||
|
|
aee62dc7f2 | ||
|
|
84fade99ec | ||
|
|
3bfa73d1e1 | ||
|
|
4616cb0a23 | ||
|
|
cc73ea3153 | ||
|
|
f6ff5eabd9 | ||
|
|
14ed1dc410 | ||
|
|
38819da222 | ||
|
|
d6e9c7dc94 | ||
|
|
9c8f21b754 | ||
|
|
bd7d75522d | ||
|
|
4ef1b74f2f | ||
|
|
e86edef47c | ||
|
|
0aff5e6c8c | ||
|
|
5458314083 | ||
|
|
cb7b9d71c4 | ||
|
|
beb7a5eac0 | ||
|
|
262ed2553c | ||
|
|
8d08ffd7b7 | ||
|
|
8540518022 | ||
|
|
b952f5976a | ||
|
|
a1d511fd4b | ||
|
|
050c2c9d16 | ||
|
|
bd3b993b90 | ||
|
|
59605f9fbb | ||
|
|
04949741c1 | ||
|
|
6b1aac477d |
+5
-2
@@ -23,6 +23,9 @@ fixtures/** filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# The manual's pictures live in LFS for the same reason the models do: a
|
||||
# screenshot or a GIF changes wholesale when the interface it shows changes,
|
||||
# and every re-recording would otherwise stay in every clone for good. CI's
|
||||
# pulls exclude the directory; nothing built or tested reads it.
|
||||
# and every re-recording would otherwise stay in every clone for good. The
|
||||
# desktop and benchmark legs exclude the directory, since nothing they build
|
||||
# or test reads it; the Android and Windows legs fetch it, because the APK
|
||||
# and the installer carry the manual (docs/manual/index.html) with its
|
||||
# pictures, and their packagers refuse a pointer.
|
||||
docs/manual/media/** filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Benchmarks
|
||||
|
||||
# The suite docs/requirements.md §8 has been promising since it was written:
|
||||
# The suite docs/dev/requirements.md §8 has been promising since it was written:
|
||||
# "an automated benchmark suite against a synthetic 50k catalog, run per-commit
|
||||
# … A regression beyond stated tolerance fails the build."
|
||||
#
|
||||
@@ -29,7 +29,7 @@ name: Benchmarks
|
||||
# every commit to establish, every time, that this runner has no GPU. It
|
||||
# runs on demand (Actions → Run workflow) so that a runner that *does*
|
||||
# have one can be pointed at it, and the numbers it produces belong in
|
||||
# docs/frame-budget.md by hand, as they already are.
|
||||
# docs/dev/frame-budget.md by hand, as they already are.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
- name: Frame budget (FR-DSP-3)
|
||||
run: cargo test --release -p dr-gpu --test frame_budget -- --nocapture
|
||||
|
||||
# The instrument behind docs/frame-budget.md. It exits non-zero with no
|
||||
# The instrument behind docs/dev/frame-budget.md. It exits non-zero with no
|
||||
# adapter, which is right for a tool a person runs deliberately and wrong
|
||||
# for a job that usually has none — hence continue-on-error. Its table is
|
||||
# in the log for whoever asked for this run; the committed numbers are
|
||||
|
||||
@@ -7,6 +7,10 @@ name: Build and test
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop]
|
||||
# A release tag builds again and publishes what it built (the `release`
|
||||
# job at the end). The master push of the same commit has usually filled
|
||||
# the caches, so the second run is the warm one.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main, master, develop]
|
||||
|
||||
@@ -96,7 +100,9 @@ jobs:
|
||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
||||
# The manual's pictures too: the APK carries the manual, and
|
||||
# assemble-apk.sh refuses a pointer where a picture should be.
|
||||
git lfs pull --exclude="fixtures/**"
|
||||
ls -lR models/
|
||||
|
||||
- name: Cache cargo
|
||||
@@ -154,6 +160,16 @@ jobs:
|
||||
- name: Build
|
||||
run: cargo build --workspace --release
|
||||
|
||||
# Only on a release tag: the binary is 150 MB and nothing but the
|
||||
# release job wants it.
|
||||
- name: Upload the desktop binary
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: darkroom-desktop-x86_64-linux
|
||||
path: target/release/darkroom-desktop
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Disk after
|
||||
if: always()
|
||||
run: df -h /workspace 2>/dev/null || df -h .
|
||||
@@ -213,7 +229,9 @@ jobs:
|
||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
||||
# The manual's pictures too: the APK carries the manual, and
|
||||
# assemble-apk.sh refuses a pointer where a picture should be.
|
||||
git lfs pull --exclude="fixtures/**"
|
||||
ls -lR models/
|
||||
|
||||
- name: Cache cargo
|
||||
@@ -322,7 +340,7 @@ jobs:
|
||||
env:
|
||||
CARGO_TARGET_DIR: target-android
|
||||
# Absent secrets mean a debug signature, which is what a fork or a
|
||||
# branch build should get. Set all three (see docs/android-signing.md)
|
||||
# branch build should get. Set all three (see docs/dev/android-signing.md)
|
||||
# and the same job produces a release-signed APK instead.
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
@@ -370,7 +388,7 @@ jobs:
|
||||
|
||||
# TRACES: FR-PLAT-WIN-3
|
||||
# The Windows executable and its installer, cross-built from Linux
|
||||
# (docs/windows.md §7). No Windows machine anywhere in this job: what it
|
||||
# (docs/dev/windows.md §7). No Windows machine anywhere in this job: what it
|
||||
# can prove is that the binary links, is a Windows executable with no
|
||||
# MinGW runtime imports, starts under Wine, and that the installer installs
|
||||
# and uninstalls under Wine. What it cannot prove — a Vulkan device, a
|
||||
@@ -406,7 +424,9 @@ jobs:
|
||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
||||
# The manual's pictures too: the installer carries the manual, and
|
||||
# package.sh refuses a pointer where a picture should be.
|
||||
git lfs pull --exclude="fixtures/**"
|
||||
ls -l models/face models/scene
|
||||
|
||||
- name: Cache cargo
|
||||
@@ -454,7 +474,17 @@ jobs:
|
||||
wine "$SETUP" /S 2>/dev/null
|
||||
INST=$(echo "$HOME"/.wine/drive_c/users/*/AppData/Local/Programs/DarkRoom)
|
||||
ls "$INST"
|
||||
[ "$(ls "$INST/models" | wc -l)" = 7 ] || { echo "FAIL: expected 7 model files"; exit 1; }
|
||||
# As many files as package.sh stages: everything but the READMEs in
|
||||
# the directories it copies. A literal here went stale the first
|
||||
# time a model was added.
|
||||
WANT=$(find models/face models/scene models/inpaint -maxdepth 1 -type f ! -name README.md | wc -l)
|
||||
GOT=$(ls "$INST/models" | wc -l)
|
||||
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT model files, installed $GOT"; exit 1; }
|
||||
# The manual, and every picture it shows, counted the same way.
|
||||
[ -f "$INST/manual/index.html" ] || { echo "FAIL: no manual installed"; exit 1; }
|
||||
WANT=$(ls docs/manual/media | wc -l)
|
||||
GOT=$(ls "$INST/manual/media" | wc -l)
|
||||
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT manual pictures, installed $GOT"; exit 1; }
|
||||
wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \
|
||||
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
|
||||
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
|
||||
@@ -514,3 +544,47 @@ jobs:
|
||||
fi
|
||||
done
|
||||
exit $FAILED
|
||||
|
||||
# A v* tag becomes a Gitea Release carrying the three builds and their
|
||||
# SHA256SUMS, titled and described by the tag's message. Until this job
|
||||
# existed every release was made by hand, and most tags never got one.
|
||||
#
|
||||
# It needs all three platform jobs, so a tag whose tests fail publishes
|
||||
# nothing; re-run the failed job and this one follows. The work is
|
||||
# tools/publish-release.sh, which is also how a release is finished by hand.
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [desktop, android, windows]
|
||||
runs-on: linux/amd64
|
||||
name: Publish the release
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch the builds
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: dist
|
||||
|
||||
# Named for the download page, with the version in each name the way
|
||||
# the hand-made releases had them. The installer already carries its
|
||||
# version from package.sh.
|
||||
- name: Publish
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -e
|
||||
V="${TAG#v}"
|
||||
ls -lR dist
|
||||
mkdir -p out
|
||||
cp dist/darkroom-arm64-v8a-apk/darkroom.apk "out/darkroom-${V}-arm64-v8a.apk"
|
||||
cp dist/darkroom-desktop-x86_64-linux/darkroom-desktop "out/darkroom-desktop-${V}-x86_64-linux"
|
||||
chmod +x "out/darkroom-desktop-${V}-x86_64-linux"
|
||||
cp dist/darkroom-windows-x86_64-setup/DarkRoom-${V}-x86_64-setup.exe out/
|
||||
bash tools/publish-release.sh "$TAG" out/*
|
||||
|
||||
@@ -7,7 +7,7 @@ name: Traceability
|
||||
# fail its own threshold. Two rules follow, and the extractor's own tests
|
||||
# enforce both:
|
||||
#
|
||||
# 1. Denominators are parsed from docs/requirements.md at run time.
|
||||
# 1. Denominators are parsed from docs/dev/requirements.md at run time.
|
||||
# 2. Coverage is |traced ∩ defined| / |defined|, never a raw traced count.
|
||||
#
|
||||
# This job is static analysis of source comments plus markdown parsing, so it
|
||||
@@ -67,6 +67,12 @@ jobs:
|
||||
# threshold: zero requirements parsed, zero files scanned, a ratio above
|
||||
# 100%, or any orphan tag all fail the build. A misconfigured run must not
|
||||
# report a plausible-looking 0%.
|
||||
# Every picture the manual shows is made by a scene in
|
||||
# tools/manual/scenes.py, and every picture a scene makes is shown.
|
||||
# Two files read; no app, no display.
|
||||
- name: Manual pictures have scenes
|
||||
run: tools/manual/record.sh --check
|
||||
|
||||
- name: Traceability gate
|
||||
run: cargo run -q -p traceability -- check
|
||||
|
||||
@@ -74,11 +80,11 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
cargo run -q -p traceability -- report
|
||||
if ! git diff --quiet docs/traceability.md; then
|
||||
if ! git diff --quiet docs/dev/traceability.md; then
|
||||
echo ""
|
||||
echo "docs/traceability.md is out of date."
|
||||
echo "docs/dev/traceability.md is out of date."
|
||||
echo "Run: cargo run -p traceability -- report"
|
||||
git diff --stat docs/traceability.md
|
||||
git diff --stat docs/dev/traceability.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -91,10 +97,19 @@ jobs:
|
||||
# they will conclude the application is broken rather than the page.
|
||||
#
|
||||
# This also fails on a malformed tag, so a typo costs a gesture its
|
||||
# desktop half loudly rather than silently.
|
||||
# desktop half loudly rather than silently — and on a key a Slint
|
||||
# handler binds that no tag names, or a key a tag names that no handler
|
||||
# binds (tools/traceability/src/keymap.rs).
|
||||
- name: Regenerate the gesture vocabulary and check it is committed
|
||||
run: cargo run -q -p traceability -- gestures-check
|
||||
|
||||
# The manual's page, which the packages carry and the help sheet links
|
||||
# into. Blocking for the gesture book's reason: it is shown to the user,
|
||||
# and a page that disagrees with the README is a manual describing an
|
||||
# application that no longer exists.
|
||||
- name: Regenerate the manual page and check it is committed
|
||||
run: cargo run -q -p traceability -- manual-check
|
||||
|
||||
# Advisory, not blocking: not every file implements a requirement, and a
|
||||
# tag on every function is noise that rots faster than it helps. Tag the
|
||||
# unit that decides.
|
||||
@@ -127,4 +142,4 @@ jobs:
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: head -30 docs/traceability.md || true
|
||||
run: head -30 docs/dev/traceability.md || true
|
||||
|
||||
+18
-4
@@ -26,7 +26,7 @@ fi
|
||||
# The artefacts are generated from the tree, so regenerating them because one
|
||||
# was itself edited would be circular.
|
||||
case "$(tr -d '[:space:]' <<< "${staged}")" in
|
||||
docs/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs)
|
||||
docs/dev/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs | docs/manual/index.html)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
@@ -41,9 +41,9 @@ if ! cargo run -q -p traceability -- report >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! git diff --quiet -- docs/traceability.md; then
|
||||
git add docs/traceability.md
|
||||
echo "pre-commit: regenerated docs/traceability.md and staged it"
|
||||
if ! git diff --quiet -- docs/dev/traceability.md; then
|
||||
git add docs/dev/traceability.md
|
||||
echo "pre-commit: regenerated docs/dev/traceability.md and staged it"
|
||||
fi
|
||||
|
||||
# The gesture vocabulary, same discipline.
|
||||
@@ -65,3 +65,17 @@ for f in docs/gestures.md ui/dr-ui/src/gesture_book.rs; do
|
||||
echo "pre-commit: regenerated ${f} and staged it"
|
||||
fi
|
||||
done
|
||||
|
||||
# The manual's page, when its source is part of the commit. Rendered from
|
||||
# nothing but the README, so there is no reason to pay for it otherwise.
|
||||
if grep -qx 'docs/manual/README.md' <<< "${staged}"; then
|
||||
if ! out="$(cargo run -q -p traceability -- manual 2>&1)"; then
|
||||
echo "pre-commit: the manual would not render" >&2
|
||||
echo "${out}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git diff --quiet -- docs/manual/index.html; then
|
||||
git add docs/manual/index.html
|
||||
echo "pre-commit: regenerated docs/manual/index.html and staged it"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
Notes for anyone — person or agent — changing this code. They record what
|
||||
went wrong once and what the fix looked like, so the same shape is not
|
||||
written again. Requirements live in `docs/requirements.md`; this file is
|
||||
written again. Requirements live in `docs/dev/requirements.md`; this file is
|
||||
about habits, not features.
|
||||
|
||||
## Catalog reads: work is proportional to what changed, never to library size
|
||||
|
||||
`docs/catalog.md §1` states the rule. These are the ways it was broken on
|
||||
`docs/dev/catalog.md §1` states the rule. These are the ways it was broken on
|
||||
the Identity screen, found when every confirm click cost half a second on a
|
||||
24k-image library (2026-09-19), and what each fix looked like.
|
||||
|
||||
|
||||
+11
-11
@@ -90,18 +90,18 @@ break it by accident:
|
||||
cargo run --release -p dr-bench -- check
|
||||
```
|
||||
|
||||
That is the benchmark suite (`docs/requirements.md` §8), which builds a
|
||||
That is the benchmark suite (`docs/dev/requirements.md` §8), which builds a
|
||||
synthetic 50,000-image catalog and fails the build if a performance target is
|
||||
missed or a measurement has drifted past its tolerance. It runs on every push in
|
||||
its own workflow. [`docs/benchmarks.md`](docs/benchmarks.md) says what it
|
||||
its own workflow. [`docs/dev/benchmarks.md`](docs/dev/benchmarks.md) says what it
|
||||
measures, what it deliberately does not, and how to read a failure. If you have
|
||||
touched the catalog, the decoder, the thumbnail store or the exporter, run it
|
||||
before you send.
|
||||
|
||||
## Requirements and traceability
|
||||
|
||||
[`requirements.md`](docs/requirements.md) is the register of record.
|
||||
[`traceability.md`](docs/traceability.md) is generated from `TRACES:` tags in
|
||||
[`requirements.md`](docs/dev/requirements.md) is the register of record.
|
||||
[`traceability.md`](docs/dev/traceability.md) is generated from `TRACES:` tags in
|
||||
the source and must never be hand-edited:
|
||||
|
||||
```rust
|
||||
@@ -124,7 +124,7 @@ 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`
|
||||
exists, not that the code under it does the thing — `docs/dev/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
|
||||
@@ -163,12 +163,12 @@ One commit per change. If you fixed two things, that is two commits.
|
||||
| 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/benchmarks.md`](docs/benchmarks.md) | A change that could plausibly cost time or memory |
|
||||
| [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen |
|
||||
| [`docs/distribution.md`](docs/distribution.md) | Packaging a build, or adding a permission to one |
|
||||
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading |
|
||||
| [`docs/dev/architecture.md`](docs/dev/architecture.md) | Anything touching the render path, catalog or sync |
|
||||
| [`docs/dev/code-health.md`](docs/dev/code-health.md) | Deciding what to work on; grades each seam by what it costs |
|
||||
| [`docs/dev/benchmarks.md`](docs/dev/benchmarks.md) | A change that could plausibly cost time or memory |
|
||||
| [`docs/dev/technical-debt.md`](docs/dev/technical-debt.md) | Something looks wrong — check it was not chosen |
|
||||
| [`docs/dev/distribution.md`](docs/dev/distribution.md) | Packaging a build, or adding a permission to one |
|
||||
| [`docs/dev/requirements.md`](docs/dev/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
|
||||
|
||||
Generated
+40
-29
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-android"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-plat",
|
||||
@@ -1234,7 +1234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-desktop"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-plat",
|
||||
@@ -1408,7 +1408,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dr-bench"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-catalog",
|
||||
@@ -1425,7 +1425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-catalog"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-face",
|
||||
"dr-plat",
|
||||
@@ -1440,7 +1440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-decode"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
@@ -1454,7 +1454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-export"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-gpu",
|
||||
@@ -1473,7 +1473,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-face"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-film"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1495,7 +1495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-gpu"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dr-decode",
|
||||
@@ -1513,7 +1513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-inference-engine"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"libloading",
|
||||
@@ -1528,7 +1528,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ingest"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
@@ -1540,7 +1540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-lens"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"lensfun",
|
||||
"log",
|
||||
@@ -1548,7 +1548,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pano"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-inference-engine",
|
||||
@@ -1562,7 +1562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pipeline"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -1571,7 +1571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-plat"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"android-native-keyring-store",
|
||||
"dr-types",
|
||||
@@ -1587,7 +1587,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-preset-xmp"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-pipeline",
|
||||
"log",
|
||||
@@ -1597,7 +1597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1610,7 +1610,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-plat",
|
||||
@@ -1624,7 +1624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-folder"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-sync",
|
||||
@@ -1636,7 +1636,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-nextcloud"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-decode",
|
||||
@@ -1658,7 +1658,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-thumbs"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"jpeg-encoder",
|
||||
@@ -1670,7 +1670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-types"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1679,7 +1679,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ui"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1704,6 +1704,7 @@ dependencies = [
|
||||
"dr-types",
|
||||
"dr-xmp",
|
||||
"env_logger",
|
||||
"i-slint-backend-testing",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"ndk-context",
|
||||
@@ -1717,12 +1718,13 @@ dependencies = [
|
||||
"slint-build",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"url",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-xmp"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -2780,6 +2782,18 @@ dependencies = [
|
||||
"i-slint-renderer-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "i-slint-backend-testing"
|
||||
version = "1.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521e901e3d47ab829c0ef500c63155776208707cd93259e6a7803ed627fa2786"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"i-slint-common",
|
||||
"i-slint-core",
|
||||
"vtable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "i-slint-backend-winit"
|
||||
version = "1.17.1"
|
||||
@@ -2960,8 +2974,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "i-slint-renderer-skia"
|
||||
version = "1.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b6eed7f3f0a9a3d3ca6e8b9d4ca233371d989351fdb2a7ab88ec368b99e7b57"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bytemuck",
|
||||
@@ -7023,9 +7035,10 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "traceability"
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -7923,8 +7936,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "wgpu-hal"
|
||||
version = "29.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
|
||||
+18
-2
@@ -27,9 +27,12 @@ members = [
|
||||
"tools/bench",
|
||||
"tools/traceability",
|
||||
]
|
||||
# Patched copies of upstream crates, not our code: see third_party/README.md.
|
||||
# Excluded so `--workspace` does not test, lint or format them as ours.
|
||||
exclude = ["third_party"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.6"
|
||||
version = "0.15.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -48,7 +51,7 @@ dr-export = { path = "core/dr-export" }
|
||||
dr-face = { path = "core/dr-face", default-features = false }
|
||||
dr-film = { path = "core/dr-film" }
|
||||
# `tract` on by default so a test binary can open a session with nothing
|
||||
# installed; the apps add `native` to look for a runtime file (docs/inference.md §3).
|
||||
# installed; the apps add `native` to look for a runtime file (docs/dev/inference.md §3).
|
||||
dr-inference-engine = { path = "core/dr-inference-engine" }
|
||||
dr-ingest = { path = "core/dr-ingest" }
|
||||
dr-gpu = { path = "core/dr-gpu" }
|
||||
@@ -127,6 +130,10 @@ url = "2.5"
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The manual's HTML rendering (tools/traceability). Already in the tree as
|
||||
# Slint's Markdown parser, so this adds a dependency edge and no crate; only
|
||||
# the HTML writer is needed, not the command-line front end.
|
||||
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
|
||||
base64 = "0.23"
|
||||
|
||||
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
|
||||
@@ -262,3 +269,12 @@ opt-level = 0
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# Two upstream crates carry a local patch so that the Android build can draw
|
||||
# with wgpu on a rotated display (technical-debt.md TD-1). Both are exact
|
||||
# copies of the version the lockfile already resolves, plus that patch;
|
||||
# third_party/README.md says what was changed and how to carry it forward
|
||||
# when Slint or wgpu moves.
|
||||
[patch.crates-io]
|
||||
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
|
||||
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
|
||||
|
||||
@@ -52,7 +52,7 @@ texture directly — no readback between the GPU and the screen.
|
||||
|---|---|---|
|
||||
| Arch Linux | [`packaging/PKGBUILD`](packaging/PKGBUILD) — `makepkg -si` | Built from every release |
|
||||
| Android | The APK from each CI run, or `./docker/android/package.sh --install` | Runs on a tablet; F-Droid not yet submitted |
|
||||
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/windows.md)) | Verified under Wine only; unsigned |
|
||||
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned |
|
||||
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; choosing a library does not yet work in the sandbox |
|
||||
|
||||
Or build it. Git LFS is required for the model weights, and the toolchain
|
||||
@@ -76,30 +76,30 @@ controls, its place in the chain and its tests.
|
||||
|
||||
## Where it stands
|
||||
|
||||
**0.13.6**, twenty tagged releases in. 188 numbered requirements in
|
||||
scope, 82% of them claimed by code and [traced to it](docs/traceability.md);
|
||||
**0.15.0**, twenty-three tagged releases in. 190 numbered requirements in
|
||||
scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
||||
the rest are written down rather than merely absent.
|
||||
|
||||
**Not built:** plugins (post-v1, [D12](docs/requirements.md)), compare and
|
||||
survey culling, AI denoise, tiled and progressive rendering, HDR merge and
|
||||
**Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
|
||||
survey culling, AI denoise, tiled rendering, HDR merge and
|
||||
focus stacking, most of the Android platform integration beyond running,
|
||||
and the Flatpak's library chooser. The performance targets are half
|
||||
verified: the per-commit benchmark suite §8 requires exists for everything
|
||||
that does not need a frame — the catalog, the scan, the thumbnails — and
|
||||
not yet for the render path, so a regression there fails nothing.
|
||||
[outstanding.md](docs/outstanding.md) is the list, with the reasoning for
|
||||
[outstanding.md](docs/dev/outstanding.md) is the list, with the reasoning for
|
||||
each.
|
||||
|
||||
**The one deliberate compromise worth knowing about before reading
|
||||
anything else:** the Android develop view reads its frame back through the
|
||||
CPU, because zero-copy there needs wgpu's Vulkan swapchain and that tears a
|
||||
portrait window on a tablet whose panel is mounted landscape. It is debt,
|
||||
not a revision of the rule — [technical-debt.md TD-1](docs/technical-debt.md)
|
||||
not a revision of the rule — [technical-debt.md TD-1](docs/dev/technical-debt.md)
|
||||
has the measurements and the three things any one of which would remove it.
|
||||
|
||||
## Documentation
|
||||
|
||||
For someone using it:
|
||||
[docs/README.md](docs/README.md) is the index. The short version, for someone using it:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -111,21 +111,21 @@ For someone changing it:
|
||||
| | |
|
||||
|---|---|
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
|
||||
| [requirements.md](docs/requirements.md) | What the software must do — the numbered register, and the decisions |
|
||||
| [architecture.md](docs/architecture.md) | How it is built — crates, the GPU pipeline, the data model, sync |
|
||||
| [technical-debt.md](docs/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
|
||||
| [outstanding.md](docs/outstanding.md) | What is not built, and whether that is a decision or a gap |
|
||||
| [code-health.md](docs/code-health.md) | What a contribution costs, per seam, measured |
|
||||
| [traceability.md](docs/traceability.md) | Generated: which requirement is claimed by which file |
|
||||
| [requirements.md](docs/dev/requirements.md) | What the software must do — the numbered register, and the decisions |
|
||||
| [architecture.md](docs/dev/architecture.md) | How it is built — crates, the GPU pipeline, the data model, sync |
|
||||
| [technical-debt.md](docs/dev/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
|
||||
| [outstanding.md](docs/dev/outstanding.md) | What is not built, and whether that is a decision or a gap |
|
||||
| [code-health.md](docs/dev/code-health.md) | What a contribution costs, per seam, measured |
|
||||
| [traceability.md](docs/dev/traceability.md) | Generated: which requirement is claimed by which file |
|
||||
|
||||
Designs, one per subsystem:
|
||||
[segmentation](docs/segmentation.md) and [mask editing](docs/mask-editing.md) ·
|
||||
[spot removal](docs/spot-removal.md) · [panorama](docs/panorama.md) ·
|
||||
[faces](docs/faces.md) · [inference](docs/inference.md) ·
|
||||
[storage and sync](docs/storage.md) · [catalog](docs/catalog.md) ·
|
||||
[display and extension](docs/display-and-extension.md) ·
|
||||
[navigation](docs/ui-navigation.md) · [distribution](docs/distribution.md) ·
|
||||
[windows](docs/windows.md) · [benchmarks](docs/benchmarks.md).
|
||||
[segmentation](docs/dev/segmentation.md) and [mask editing](docs/dev/mask-editing.md) ·
|
||||
[spot removal](docs/dev/spot-removal.md) · [panorama](docs/dev/panorama.md) ·
|
||||
[faces](docs/dev/faces.md) · [inference](docs/dev/inference.md) ·
|
||||
[storage and sync](docs/dev/storage.md) · [catalog](docs/dev/catalog.md) ·
|
||||
[display and extension](docs/dev/display-and-extension.md) ·
|
||||
[navigation](docs/dev/ui-navigation.md) · [distribution](docs/dev/distribution.md) ·
|
||||
[windows](docs/dev/windows.md) · [benchmarks](docs/dev/benchmarks.md).
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -141,6 +141,23 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- The manual (dr_ui::manual): a WebView over the copy the APK
|
||||
carries in assets/manual. See ManualActivity.java for why it is
|
||||
not the browser.
|
||||
|
||||
Not exported: nothing outside this app has a reason to start it,
|
||||
and dr_ui starts it by class name, which needs no intent filter.
|
||||
Its own task entry is not wanted either — it is a page over the
|
||||
app, and Back returns to the photograph it was opened from.
|
||||
configChanges so a rotation reflows the page rather than
|
||||
reloading it at the top. -->
|
||||
<activity
|
||||
android:name="paris.tourolle.darkroom.ManualActivity"
|
||||
android:exported="false"
|
||||
android:label="DarkRoom manual"
|
||||
android:theme="@style/ManualTheme"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
|
||||
|
||||
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
|
||||
between apps since API 24 — handing one out raises
|
||||
FileUriExposedException in *this* process — so an exported JPEG
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package paris.tourolle.darkroom;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
|
||||
/**
|
||||
* The manual that ships in the APK, shown in a WebView.
|
||||
*
|
||||
* <h2>Why an activity of our own rather than the browser</h2>
|
||||
*
|
||||
* <p>The desktop hands the manual to the system browser. Android leaves no
|
||||
* way to do the same: the page is an asset inside the APK, which is not a
|
||||
* file; an unpacked copy in app-private storage is a file no browser may
|
||||
* read; a {@code file:} URI handed to another app is refused since API 24;
|
||||
* and a {@code content:} URI serves the page but leaves the browser to fetch
|
||||
* every picture by a relative URL against the provider, which browsers do not
|
||||
* reliably do. A WebView reads {@code file:///android_asset/} straight from
|
||||
* the APK, pictures and section anchor included, and nothing is unpacked.
|
||||
*
|
||||
* <h2>What it is not</h2>
|
||||
*
|
||||
* <p>A browser. JavaScript stays off (the page has none), and a link that
|
||||
* leaves the manual — the design documents are on the forge — goes to the
|
||||
* user's browser rather than opening inside this view, so the only thing ever
|
||||
* shown here is the page the APK carries.
|
||||
*
|
||||
* <p>Started by {@code dr_ui::manual} with {@code Intent.setClassName}, so the
|
||||
* name here and there must agree; a test in lib.rs checks the manifest
|
||||
* declares it.
|
||||
*/
|
||||
public final class ManualActivity extends Activity {
|
||||
/** The section to open at, a heading's anchor. Absent opens the top. */
|
||||
public static final String EXTRA_ANCHOR = "anchor";
|
||||
|
||||
private static final String PAGE = "file:///android_asset/manual/index.html";
|
||||
|
||||
private WebView web;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle saved) {
|
||||
super.onCreate(saved);
|
||||
setTitle("DarkRoom manual");
|
||||
|
||||
web = new WebView(this);
|
||||
WebSettings settings = web.getSettings();
|
||||
settings.setJavaScriptEnabled(false);
|
||||
// Pinch to zoom into a screenshot, which is 1600 pixels wide and drawn
|
||||
// at the width of a phone.
|
||||
settings.setBuiltInZoomControls(true);
|
||||
settings.setDisplayZoomControls(false);
|
||||
web.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
||||
Uri uri = request.getUrl();
|
||||
if ("file".equals(uri.getScheme())) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
startActivity(new Intent(Intent.ACTION_VIEW, uri));
|
||||
} catch (ActivityNotFoundException e) {
|
||||
// No browser on the device: the link does nothing, which
|
||||
// is all it could do.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
setContentView(web);
|
||||
|
||||
if (saved != null) {
|
||||
web.restoreState(saved);
|
||||
} else {
|
||||
String anchor = getIntent().getStringExtra(EXTRA_ANCHOR);
|
||||
web.loadUrl(anchor == null || anchor.isEmpty() ? PAGE : PAGE + "#" + anchor);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(Bundle out) {
|
||||
super.onSaveInstanceState(out);
|
||||
web.saveState(out);
|
||||
}
|
||||
|
||||
/** Back walks back through the sections visited, then leaves. */
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (web.canGoBack()) {
|
||||
web.goBack();
|
||||
} else {
|
||||
super.onBackPressed();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
web.destroy();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Day or night as the system is; see values/themes.xml. -->
|
||||
<resources>
|
||||
<style name="ManualTheme" parent="@android:style/Theme.DeviceDefault.DayNight" />
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The manual's theme (ManualActivity). Light below API 29, which has no
|
||||
day-night theme in the platform; values-v29 follows the system from there.
|
||||
The WebView takes prefers-color-scheme from whether this theme is light, and
|
||||
the manual's stylesheet takes its colours from that.
|
||||
-->
|
||||
<resources>
|
||||
<style name="ManualTheme" parent="@android:style/Theme.DeviceDefault.Light" />
|
||||
</resources>
|
||||
@@ -240,7 +240,7 @@ fn android_main(app: slint::android::AndroidApp) {
|
||||
///
|
||||
/// **Face weights are absent from the repository by design.** The InsightFace
|
||||
/// grant is research-only and incompatible with this project's licence
|
||||
/// (docs/faces.md §2), so a desktop user fetches them, runs
|
||||
/// (docs/dev/faces.md §2), so a desktop user fetches them, runs
|
||||
/// `tools/fix-face-model-shapes.sh` over them, and drops the result in. A build
|
||||
/// that carries none is the ordinary case and face indexing simply stays off.
|
||||
///
|
||||
@@ -321,17 +321,17 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
// before it reports the tab available.
|
||||
//
|
||||
// Three detectors, because which one runs is a setting
|
||||
// (`FaceDetector`, docs/faces.md §12.3) and a tablet has no other way to
|
||||
// (`FaceDetector`, docs/dev/faces.md §12.3) and a tablet has no other way to
|
||||
// obtain the one it was not shipped with. Twenty megabytes of APK for
|
||||
// the choice; the embedder is the same for all three.
|
||||
//
|
||||
// Then the three eye-state models (docs/faces.md §17): landmarks, open
|
||||
// Then the three eye-state models (docs/dev/faces.md §17): landmarks, open
|
||||
// or closed, sunglasses. The app indexes without them; with them the
|
||||
// eyes-open filter has something to read, and a tablet has no other way
|
||||
// to get them either.
|
||||
//
|
||||
// The int8 forms beside the three detectors are what the Hexagon runs
|
||||
// (docs/inference.md §5); the engine loads the sibling when the probe
|
||||
// (docs/dev/inference.md §5); the engine loads the sibling when the probe
|
||||
// chose that rung and ignores it otherwise.
|
||||
const BUNDLED: [(&std::ffi::CStr, &str); 14] = [
|
||||
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
|
||||
@@ -420,7 +420,7 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
// on a first launch they were not on disk until this line. The runtime
|
||||
// is in the APK's native library directory beside `libdarkroom.so`,
|
||||
// which is also where Qualcomm's DSP loader has to be pointed for the
|
||||
// Hexagon skel (docs/inference.md §3, §8).
|
||||
// Hexagon skel (docs/dev/inference.md §3, §8).
|
||||
dr_ui::inference::init(native_library_dir().into_iter().collect());
|
||||
}
|
||||
|
||||
@@ -581,6 +581,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `dr_ui::manual` starts the manual by class name. A name the manifest
|
||||
/// does not declare is an `ActivityNotFoundException` on the device and a
|
||||
/// Manual button that does nothing, so the three spellings — dr_ui's, the
|
||||
/// manifest's and the Java file's — are checked to be one.
|
||||
#[test]
|
||||
fn the_manual_activity_dr_ui_starts_is_declared() {
|
||||
let manifest = manifest();
|
||||
let wanted = dr_ui::manual::ANDROID_ACTIVITY;
|
||||
let element = manifest
|
||||
.split("<activity")
|
||||
.skip(1)
|
||||
.find(|a| attribute(a, "android:name").as_deref() == Some(wanted))
|
||||
.unwrap_or_else(|| panic!("the manifest declares no activity {wanted}"));
|
||||
assert_eq!(
|
||||
attribute(element, "android:exported").as_deref(),
|
||||
Some("false"),
|
||||
"the manual activity has no reason to be startable by another app"
|
||||
);
|
||||
let java = include_str!("../android/java/paris/tourolle/darkroom/ManualActivity.java");
|
||||
let (package, class) = wanted.rsplit_once('.').expect("unqualified class name");
|
||||
assert!(java.contains(&format!("package {package};")));
|
||||
assert!(java.contains(&format!("class {class} ")));
|
||||
assert!(
|
||||
java.contains(&format!(
|
||||
"EXTRA_ANCHOR = \"{}\"",
|
||||
dr_ui::manual::ANDROID_EXTRA_ANCHOR
|
||||
)),
|
||||
"ManualActivity reads the section from a different extra than dr_ui writes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() {
|
||||
let manifest = manifest();
|
||||
|
||||
@@ -25,3 +25,6 @@ winresource = "0.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# The manual's recording hook (dr-ui's `automation`); tools/manual/record.sh
|
||||
# builds with it, nothing else does.
|
||||
automation = ["dr-ui/automation"]
|
||||
|
||||
@@ -21,7 +21,7 @@ fn main() -> anyhow::Result<()> {
|
||||
// build made on a machine that cannot run the application — the Linux CI
|
||||
// producing the Windows binary, checked under Wine — has an exit that
|
||||
// proves the executable starts without opening a window or touching the
|
||||
// user's directories (docs/windows.md §6).
|
||||
// user's directories (docs/dev/windows.md §6).
|
||||
if std::env::args().nth(1).as_deref() == Some("--version") {
|
||||
println!("darkroom-desktop {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
@@ -63,7 +63,7 @@ fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Before the window: the probe runs on its own thread and the first
|
||||
// frame does not wait for it, but the models a background job asks for
|
||||
// should already know where the runtime is (docs/inference.md §4).
|
||||
// should already know where the runtime is (docs/dev/inference.md §4).
|
||||
dr_ui::inference::init(runtime_dirs());
|
||||
|
||||
dr_ui::run(paths)?;
|
||||
@@ -78,7 +78,7 @@ fn main() -> anyhow::Result<()> {
|
||||
|
||||
/// Where a desktop package may have put `libonnxruntime`, most specific
|
||||
/// first. None of these existing is the tract build, which is a complete
|
||||
/// application and not an error (docs/inference.md §3).
|
||||
/// application and not an error (docs/dev/inference.md §3).
|
||||
///
|
||||
/// `DARKROOM_ORT_DIR` is for a developer pointing at a runtime that is not
|
||||
/// installed — the wheel's `capi` directory, say. Then beside the executable
|
||||
|
||||
@@ -315,7 +315,7 @@ fn full_library(
|
||||
|
||||
// The three phases, separately, because "a regroup takes n seconds" does
|
||||
// not tell anyone which half to optimise — and the answer differs between
|
||||
// a desktop and a tablet (docs/faces.md §9).
|
||||
// a desktop and a tablet (docs/dev/faces.md §9).
|
||||
{
|
||||
let dim = candidates.first().map(|c| c.embedding.len()).unwrap_or(0);
|
||||
let flat: Vec<f32> = candidates
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
//! Grouping has no natural `subject_id`: it is a property of a *run* of frames,
|
||||
//! so a per-image job would rebuild the world once per photograph. It is
|
||||
//! therefore a debounced library-level pass, for exactly the reasons
|
||||
//! docs/catalog.md §10.2 gives for face clustering, and [`regroup`] is the whole
|
||||
//! docs/dev/catalog.md §10.2 gives for face clustering, and [`regroup`] is the whole
|
||||
//! of it — one ordered walk, no per-pair comparison beyond adjacent frames.
|
||||
//!
|
||||
//! # Grouping is not hiding
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Face data as sealed shards, so a second device does not re-index the library.
|
||||
//!
|
||||
//! Indexing a 23,500-image library is on the order of two hours of CPU
|
||||
//! (docs/faces.md §12.2). It is also **byte-identical on every device**: the
|
||||
//! (docs/dev/faces.md §12.2). It is also **byte-identical on every device**: the
|
||||
//! same model over the same proxy produces the same embedding. Paying for it
|
||||
//! once per account rather than once per device is the whole point of this
|
||||
//! module, and it is the same bargain the thumbnail store already makes.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
|
||||
//! People and faces: what was detected, who it is, and who said so.
|
||||
//!
|
||||
//! The storage half of docs/faces.md. `dr-face` finds faces and turns them into
|
||||
//! The storage half of docs/dev/faces.md. `dr-face` finds faces and turns them into
|
||||
//! 512 numbers; this module is where those numbers acquire an identity, and
|
||||
//! where the user's corrections outrank the model's guesses.
|
||||
//!
|
||||
@@ -110,7 +110,7 @@ pub struct DetectedFace {
|
||||
/// Raw rather than unit length, so the length ([`Self::quality`]) is in
|
||||
/// the blob and not only beside it. Readers re-normalise on load.
|
||||
pub embedding: Vec<u8>,
|
||||
/// Source pixels across the aligned crop (docs/faces.md §7).
|
||||
/// Source pixels across the aligned crop (docs/dev/faces.md §7).
|
||||
pub crop_px: f32,
|
||||
/// Length of the raw embedding before normalisation — the model's own
|
||||
/// reading of how recognisable the crop was, and the gate on whether
|
||||
@@ -211,7 +211,7 @@ pub use dr_face::Calibration;
|
||||
/// the same face in the same photograph, for carrying an identity across a
|
||||
/// re-detection.
|
||||
///
|
||||
/// Set at the reference library's P≈0.95 line (docs/faces.md §9's table:
|
||||
/// Set at the reference library's P≈0.95 line (docs/dev/faces.md §9's table:
|
||||
/// 0.449), which is far above anything two different people in one frame
|
||||
/// reach and below what one face re-embedded from a better crop of itself
|
||||
/// does. The number is only ever asked about *overlapping* boxes on *one*
|
||||
@@ -2486,7 +2486,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The reference implementation's fitted MBF curve puts the P=0.5 boundary
|
||||
/// at cosine 0.267 (docs/faces.md §1). Our own first end-to-end run scored
|
||||
/// at cosine 0.267 (docs/dev/faces.md §1). Our own first end-to-end run scored
|
||||
/// 0.596 between distinct photographs of one person and 0.05 between
|
||||
/// different people, so those two must land either side.
|
||||
#[test]
|
||||
|
||||
@@ -789,7 +789,7 @@ fn attached_has_table(conn: &Connection, schema: &str, table: &str) -> Result<bo
|
||||
/// # 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
|
||||
/// judgements travel, inference is rebuilt. Concretely (docs/dev/faces.md, and the
|
||||
/// asymmetry `crate::faces` opens with):
|
||||
///
|
||||
/// - **People** — uuid, name, and whether the user set them aside. Merged by
|
||||
|
||||
+171
-14
@@ -49,6 +49,12 @@ pub struct Judgement {
|
||||
/// 0..=5. Zero means *unrated*, which is a state in its own right.
|
||||
pub rating: u8,
|
||||
pub flag: FlagState,
|
||||
/// TRACES: FR-CAT-5
|
||||
/// The colour label, or `None`. Not part of [`Judgement::is_judged`]:
|
||||
/// a label sorts photographs into piles of the photographer's own
|
||||
/// meaning — "to print", "send to Anna" — and says nothing about whether
|
||||
/// a frame has been culled, which is the question "unjudged" asks.
|
||||
pub label: Option<ColourLabel>,
|
||||
}
|
||||
|
||||
impl Judgement {
|
||||
@@ -267,14 +273,6 @@ pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogEr
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// The default version's row id for an image, creating one if it has none.
|
||||
///
|
||||
/// Every write path goes through this rather than assuming a version exists.
|
||||
/// An image can arrive without one in two ways that are not worth trying to
|
||||
/// prevent: a row inserted by a build predating this module, and a scan whose
|
||||
/// version pass was interrupted between the image insert and the commit.
|
||||
/// Failing a rating because of either would be the wrong answer — the user
|
||||
/// pressed a key and expects a star.
|
||||
/// TRACES: FR-CAT-13
|
||||
/// How `versions.label` encodes a colour label, and back.
|
||||
///
|
||||
@@ -304,6 +302,14 @@ pub fn label_from_code(code: Option<i64>) -> Option<ColourLabel> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The default version's row id for an image, creating one if it has none.
|
||||
///
|
||||
/// Every write path goes through this rather than assuming a version exists.
|
||||
/// An image can arrive without one in two ways that are not worth trying to
|
||||
/// prevent: a row inserted by a build predating this module, and a scan whose
|
||||
/// version pass was interrupted between the image insert and the commit.
|
||||
/// Failing a rating because of either would be the wrong answer — the user
|
||||
/// pressed a key and expects a star.
|
||||
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
@@ -387,6 +393,88 @@ pub fn set_flag_many(
|
||||
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
/// Set or clear the colour label for one image.
|
||||
pub fn set_label(
|
||||
conn: &Connection,
|
||||
image: ImageId,
|
||||
label: Option<ColourLabel>,
|
||||
) -> Result<(), CatalogError> {
|
||||
let version = default_version_id(conn, image)?;
|
||||
conn.execute(
|
||||
"UPDATE versions SET label = ?2 WHERE id = ?1",
|
||||
rusqlite::params![version, label.map(label_code)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
/// Set or clear a label on many images in one transaction — one keystroke
|
||||
/// over a selection is one commit, as for [`set_rating_many`].
|
||||
pub fn set_label_many(
|
||||
conn: &Connection,
|
||||
images: &[ImageId],
|
||||
label: Option<ColourLabel>,
|
||||
) -> Result<usize, CatalogError> {
|
||||
apply_many(conn, images, |conn, id| set_label(conn, id, label))
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
/// What a label key does to a set of images: Lightroom's toggle.
|
||||
///
|
||||
/// Pressing the key for the label every one of them already carries takes it
|
||||
/// off; otherwise every one of them gets it. Decided over the whole set
|
||||
/// rather than per image, so a selection that was half red comes out all red
|
||||
/// rather than inverted — the photographer pressed "red", and a key that
|
||||
/// turned half of them red and the other half plain would be two answers to
|
||||
/// one question.
|
||||
pub fn toggled_label(
|
||||
current: impl IntoIterator<Item = Option<ColourLabel>>,
|
||||
pressed: ColourLabel,
|
||||
) -> Option<ColourLabel> {
|
||||
let mut any = false;
|
||||
for label in current {
|
||||
any = true;
|
||||
if label != Some(pressed) {
|
||||
return Some(pressed);
|
||||
}
|
||||
}
|
||||
if any {
|
||||
None
|
||||
} else {
|
||||
Some(pressed)
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||||
/// How the library divides by colour label, for the filter chips' counts.
|
||||
///
|
||||
/// Index 0 is unlabelled and index `n` the label whose code is `n`. One
|
||||
/// grouped statement — the same shape as [`rating_histogram`], and for the
|
||||
/// same reason it LEFT JOINs: an image without a version row is unlabelled,
|
||||
/// not missing.
|
||||
pub fn label_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
|
||||
let mut out = [0usize; 6];
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT coalesce(v.label, 0) AS l, count(*)
|
||||
FROM images i
|
||||
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||||
GROUP BY l",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
for (code, count) in rows.flatten() {
|
||||
// A code this build does not know counts as unlabelled, which is how
|
||||
// `label_from_code` reads it everywhere else.
|
||||
let slot = if label_from_code(Some(code)).is_some() {
|
||||
code as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
out[slot] += count as usize;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Shared bulk wrapper, so the two axes cannot drift in their commit
|
||||
/// behaviour — a partially-committed rating and a fully-committed flag from
|
||||
/// the same keystroke would be hard to explain and harder to notice.
|
||||
@@ -411,21 +499,22 @@ fn apply_many(
|
||||
/// An image with no version reads as unrated and unflagged rather than as an
|
||||
/// error: that is exactly what it is.
|
||||
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
|
||||
let row: Option<(i64, i64)> = conn
|
||||
let row: Option<(i64, i64, Option<i64>)> = conn
|
||||
.query_row(
|
||||
"SELECT rating, flag FROM versions
|
||||
"SELECT rating, flag, label FROM versions
|
||||
WHERE image_id = ?1
|
||||
ORDER BY is_default DESC, id ASC
|
||||
LIMIT 1",
|
||||
[image.0 as i64],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
Ok(match row {
|
||||
Some((rating, flag)) => Judgement {
|
||||
Some((rating, flag, label)) => Judgement {
|
||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||
flag: flag_from_code(flag),
|
||||
label: label_from_code(label),
|
||||
},
|
||||
None => Judgement::default(),
|
||||
})
|
||||
@@ -452,7 +541,7 @@ pub fn judgements(
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!(
|
||||
"SELECT image_id, rating, flag FROM versions
|
||||
"SELECT image_id, rating, flag, label FROM versions
|
||||
WHERE image_id IN ({placeholders}) AND is_default = 1"
|
||||
);
|
||||
|
||||
@@ -467,15 +556,17 @@ pub fn judgements(
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
r.get::<_, Option<i64>>(3)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
for (image, rating, flag) in rows.flatten() {
|
||||
for (image, rating, flag, label) in rows.flatten() {
|
||||
out.insert(
|
||||
ImageId(image as u64),
|
||||
Judgement {
|
||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||
flag: flag_from_code(flag),
|
||||
label: label_from_code(label),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -686,6 +777,72 @@ mod tests {
|
||||
assert_eq!(distinct, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_round_trips_and_clears() {
|
||||
// TRACES: FR-CAT-5
|
||||
let cat = with_images(1);
|
||||
let id = ids(&cat)[0];
|
||||
set_label(cat.connection(), id, Some(ColourLabel::Green)).unwrap();
|
||||
assert_eq!(
|
||||
judgement(cat.connection(), id).unwrap().label,
|
||||
Some(ColourLabel::Green)
|
||||
);
|
||||
set_label(cat.connection(), id, None).unwrap();
|
||||
assert_eq!(judgement(cat.connection(), id).unwrap().label, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_is_not_a_judgement() {
|
||||
// "Unjudged" is the cull's resume point; a label is a pile of the
|
||||
// photographer's own, and labelling a frame must not hide it there.
|
||||
let cat = with_images(1);
|
||||
let id = ids(&cat)[0];
|
||||
set_label(cat.connection(), id, Some(ColourLabel::Red)).unwrap();
|
||||
assert!(!judgement(cat.connection(), id).unwrap().is_judged());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labelling_a_selection_is_one_commit_and_reaches_every_image() {
|
||||
// TRACES: FR-CAT-5
|
||||
let cat = with_images(4);
|
||||
let all = ids(&cat);
|
||||
assert_eq!(
|
||||
set_label_many(cat.connection(), &all, Some(ColourLabel::Blue)).unwrap(),
|
||||
4
|
||||
);
|
||||
let found = judgements(cat.connection(), &all).unwrap();
|
||||
assert!(all
|
||||
.iter()
|
||||
.all(|id| found[id].label == Some(ColourLabel::Blue)));
|
||||
assert_eq!(
|
||||
label_histogram(cat.connection()).unwrap(),
|
||||
[0, 0, 0, 0, 4, 0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_key_toggles_only_when_every_image_already_has_it() {
|
||||
// TRACES: FR-CAT-5
|
||||
use ColourLabel::*;
|
||||
assert_eq!(toggled_label([Some(Red), Some(Red)], Red), None);
|
||||
assert_eq!(toggled_label([Some(Red), None], Red), Some(Red));
|
||||
assert_eq!(toggled_label([Some(Blue)], Red), Some(Red));
|
||||
assert_eq!(toggled_label([], Red), Some(Red));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_label_histogram_sums_to_the_library() {
|
||||
// TRACES: FR-CAT-6
|
||||
// Images without a version row count as unlabelled rather than
|
||||
// vanishing, as the rating histogram's do.
|
||||
let cat = with_images(3);
|
||||
let first = ids(&cat)[0];
|
||||
set_label(cat.connection(), first, Some(ColourLabel::Purple)).unwrap();
|
||||
let h = label_histogram(cat.connection()).unwrap();
|
||||
assert_eq!(h, [2, 0, 0, 0, 0, 1]);
|
||||
assert_eq!(h.iter().sum::<usize>(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rating_round_trips() {
|
||||
let cat = with_images(1);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! # The one thing a rebuild does not recover
|
||||
//!
|
||||
//! **Collections.** A manual collection is a set of images the user assembled
|
||||
//! by hand and nothing in the filesystem records it (`docs/catalog.md` §8.1) —
|
||||
//! by hand and nothing in the filesystem records it (`docs/dev/catalog.md` §8.1) —
|
||||
//! which is the whole reason the catalog file itself syncs. So the two offers
|
||||
//! are not interchangeable, and the interface must not present them as if they
|
||||
//! were: a restore keeps the user's collections, a rebuild does not.
|
||||
@@ -570,7 +570,7 @@ mod tests {
|
||||
// The first NFR-R6 branch, asserted on the thing that distinguishes it
|
||||
// from the second: a collection exists nowhere but the catalog, so it
|
||||
// is the evidence that the *contents* came back and not merely a
|
||||
// readable file (docs/catalog.md §8.1).
|
||||
// readable file (docs/dev/catalog.md §8.1).
|
||||
let dir = tempdir("restore");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
fixture(&path, 500);
|
||||
|
||||
@@ -1009,7 +1009,7 @@ 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).
|
||||
-- People and faces (docs/dev/faces.md, docs/dev/catalog.md §10).
|
||||
--
|
||||
-- Everything here is **derived data** except one column. Faces, landmarks,
|
||||
-- embeddings, cluster assignments and suggestions are all reproducible by
|
||||
@@ -1046,7 +1046,7 @@ CREATE TABLE faces (
|
||||
landmarks BLOB NOT NULL, -- 5 x (x, y) f32, normalised likewise
|
||||
detector_confidence REAL NOT NULL,
|
||||
embedding BLOB NOT NULL, -- 512 x f16; unit length until V14, raw since
|
||||
-- Source pixels across the aligned 112x112 crop (docs/faces.md §7).
|
||||
-- Source pixels across the aligned 112x112 crop (docs/dev/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
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! TRACES: FR-RAW-2
|
||||
//! The seam a second decoder plugs into.
|
||||
//!
|
||||
//! D2 keeps LibRaw as the fallback for bodies rawler does not cover. Adding
|
||||
//! it later should be a new `impl Decoder`, not an edit to every caller that
|
||||
//! reads a header, cuts a thumbnail or opens a photograph for export — which
|
||||
//! is what the free functions alone would have made it. So the callers take a
|
||||
//! `&dyn Decoder`, and only the places that start a job name [`default`].
|
||||
//!
|
||||
//! Bytes in, always. Nothing here takes a path or a `SourceRef`: resolving a
|
||||
//! file to bytes is `Storage`'s job at the caller, so the same decoder serves a
|
||||
//! local file, an Android document and a range fetched from Nextcloud. The
|
||||
//! decoder's part in that is to say how much of a file it needs
|
||||
//! ([`Decoder::header_bytes`]) and where its preview sits
|
||||
//! ([`Decoder::locate_preview`]); the storage layer fetches exactly that.
|
||||
//!
|
||||
//! What stays a free function is what is not a decoder's to vary: recognising
|
||||
//! a JPEG ([`crate::probe`]), decoding one ([`crate::decode_jpeg`]) and
|
||||
//! checking one is whole ([`crate::is_complete_jpeg`]). A second RAW decoder
|
||||
//! would not read a JPEG differently.
|
||||
|
||||
use dr_types::Orientation;
|
||||
|
||||
use crate::{DecodeError, Metadata, Preview, PreviewLocation, PreviewSize, RawImage};
|
||||
|
||||
/// TRACES: FR-RAW-2
|
||||
/// A RAW decoder, over bytes.
|
||||
///
|
||||
/// Object-safe so a caller can hold `&dyn Decoder` without becoming generic,
|
||||
/// `Send + Sync` because the callers that need one most — the thumbnail
|
||||
/// lanes, the export worker — run off the UI thread, and `Debug` so a job
|
||||
/// description that carries one can still be printed.
|
||||
pub trait Decoder: Send + Sync + std::fmt::Debug {
|
||||
/// How much of the start of a file [`Self::metadata`] and
|
||||
/// [`Self::locate_preview`] need. A caller reading over a network fetches
|
||||
/// this range and no more.
|
||||
fn header_bytes(&self) -> u64;
|
||||
|
||||
/// Capture metadata, from a header or a whole file, without touching
|
||||
/// sensor data.
|
||||
fn metadata(&self, bytes: &[u8]) -> Result<Metadata, DecodeError>;
|
||||
|
||||
/// How the stored pixels are turned, from a header. `None` where the file
|
||||
/// does not say, which callers take as upright.
|
||||
fn orientation(&self, header: &[u8]) -> Option<Orientation>;
|
||||
|
||||
/// Where the embedded preview best suited to a thumbnail sits in the file,
|
||||
/// from its header, so a remote caller can fetch that range alone.
|
||||
fn locate_preview(&self, header: &[u8], file_len: u64) -> Option<PreviewLocation>;
|
||||
|
||||
/// The embedded preview at the size asked for, falling through the ladder
|
||||
/// to the next size where the file lacks it.
|
||||
fn preview(&self, bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError>;
|
||||
|
||||
/// Sensor data, for develop and export. The expensive path.
|
||||
fn decode(&self, bytes: &[u8]) -> Result<RawImage, DecodeError>;
|
||||
}
|
||||
|
||||
/// TRACES: FR-RAW-2
|
||||
/// The decoder the application ships: rawler for sensor data and the
|
||||
/// previews it knows, DarkRoom's own container walk for headers and ranges.
|
||||
///
|
||||
/// Its methods are the crate's free functions, unchanged. They stay public
|
||||
/// for the tools and examples that read one file and have no caller to keep
|
||||
/// decoder-agnostic.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Rawler;
|
||||
|
||||
impl Decoder for Rawler {
|
||||
fn header_bytes(&self) -> u64 {
|
||||
crate::HEADER_BYTES
|
||||
}
|
||||
|
||||
fn metadata(&self, bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||||
crate::metadata(bytes)
|
||||
}
|
||||
|
||||
fn orientation(&self, header: &[u8]) -> Option<Orientation> {
|
||||
crate::orientation(header)
|
||||
}
|
||||
|
||||
fn locate_preview(&self, header: &[u8], file_len: u64) -> Option<PreviewLocation> {
|
||||
crate::locate_preview(header, file_len)
|
||||
}
|
||||
|
||||
fn preview(&self, bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError> {
|
||||
crate::extract_preview(bytes, size)
|
||||
}
|
||||
|
||||
fn decode(&self, bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||
crate::decode(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-RAW-2
|
||||
/// The decoder a job uses unless it was handed another.
|
||||
///
|
||||
/// Named by the places that start work — a thread, a UI handler — and by
|
||||
/// nothing below them. Returning `&'static dyn Decoder` rather than `Rawler`
|
||||
/// is the point: a caller that only has this cannot reach past the trait.
|
||||
pub fn default() -> &'static dyn Decoder {
|
||||
static RAWLER: Rawler = Rawler;
|
||||
&RAWLER
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The default is the shipped decoder, reached through the trait: same
|
||||
/// header budget, and the same answer to bytes neither can read.
|
||||
#[test]
|
||||
fn the_default_is_rawler_behind_the_trait() {
|
||||
let d = default();
|
||||
assert_eq!(d.header_bytes(), crate::HEADER_BYTES);
|
||||
let junk = [0u8; 64];
|
||||
assert_eq!(d.metadata(&junk).is_err(), crate::metadata(&junk).is_err());
|
||||
assert!(d.decode(&junk).is_err());
|
||||
assert_eq!(d.orientation(&junk), crate::orientation(&junk));
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,20 @@
|
||||
//!
|
||||
//! Fusing them would force a full decode where a header read suffices, which
|
||||
//! is exactly why Lightroom stalls ~2 s per image during culling.
|
||||
//!
|
||||
//! Callers reach these through the [`Decoder`] trait rather than by name, so a
|
||||
//! second decoder can be put behind them without changing any of them
|
||||
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
|
||||
|
||||
pub mod base_curve;
|
||||
mod decoder;
|
||||
mod error;
|
||||
mod locate;
|
||||
mod preview;
|
||||
pub mod profile;
|
||||
|
||||
pub use base_curve::BaseCurve;
|
||||
pub use decoder::{default, Decoder, Rawler};
|
||||
pub use error::DecodeError;
|
||||
pub use locate::{
|
||||
defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel,
|
||||
|
||||
@@ -11,7 +11,7 @@ log.workspace = true
|
||||
|
||||
# Inference. `ort` is the API; **what runs it is `dr-inference-engine`'s
|
||||
# business** — tract, or an ONNX Runtime the app found on disk, on whichever
|
||||
# provider the device has (docs/inference.md). This crate never names either.
|
||||
# provider the device has (docs/dev/inference.md). This crate never names either.
|
||||
ort = { workspace = true, optional = true }
|
||||
dr-inference-engine = { workspace = true, optional = true }
|
||||
ndarray = { workspace = true, optional = true }
|
||||
@@ -37,7 +37,7 @@ 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
|
||||
# are not a build input and never become one (docs/dev/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 = []
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Detect the faces in a JPEG and read each one's eyes (docs/faces.md §17).
|
||||
//! Detect the faces in a JPEG and read each one's eyes (docs/dev/faces.md §17).
|
||||
//!
|
||||
//! The thing worth looking at is whether the eye boxes land on eyes and
|
||||
//! whether soft ones are refused — so with `--dump DIR` the crops the
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! 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.
|
||||
//! `tools/fix-face-model-shapes.sh` and docs/dev/faces.md §12 M1.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! M1 (docs/faces.md §12) — will tract load these graphs at all?
|
||||
//! M1 (docs/dev/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
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//!
|
||||
//! # What it is for
|
||||
//!
|
||||
//! docs/faces.md §9 has the desktop numbers and the question they leave open:
|
||||
//! docs/dev/faces.md §9 has the desktop numbers and the question they leave open:
|
||||
//! a GPU GEMM is worth roughly 1.5× of a regroup on a twenty-core desktop,
|
||||
//! because the scan is under a third of the pass there. On a tablet the CPU is
|
||||
//! several times slower and the GPU is not, so the same optimisation is worth
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Five-point face alignment (docs/faces.md §5).
|
||||
//! Five-point face alignment (docs/dev/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
|
||||
@@ -208,7 +208,7 @@ impl Similarity {
|
||||
///
|
||||
/// # Why least squares and not RANSAC
|
||||
///
|
||||
/// The reference C++ implementation (docs/faces.md §1.1) fits this with
|
||||
/// The reference C++ implementation (docs/dev/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
|
||||
@@ -427,7 +427,7 @@ fn sample_window(
|
||||
// ── eyes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Width of an eye crop as the classifier reads it, in pixels. Fixed by the
|
||||
/// OCEC input (`docs/faces.md` §17): 40 wide, 24 high.
|
||||
/// OCEC input (`docs/dev/faces.md` §17): 40 wide, 24 high.
|
||||
pub const EYE_PATCH_WIDTH: usize = 40;
|
||||
/// Height of an eye crop as the classifier reads it, in pixels.
|
||||
pub const EYE_PATCH_HEIGHT: usize = 24;
|
||||
@@ -438,7 +438,7 @@ pub const EYE_PATCH_HEIGHT: usize = 24;
|
||||
/// The classifier was trained on a whole-body detector's *eye* boxes — tight
|
||||
/// round the palpebral fissure — and measured on 25 open-eyed faces from the
|
||||
/// reference library, a tight box is what it wants: 22 of 25 read open at
|
||||
/// 0 and 0.1, 18 at 0.4, 14 at 0.6 (docs/faces.md §17.2). A tenth, so a
|
||||
/// 0 and 0.1, 18 at 0.4, 14 at 0.6 (docs/dev/faces.md §17.2). A tenth, so a
|
||||
/// contour landing a pixel short of the lashes still holds them.
|
||||
pub const EYE_BOX_MARGIN: f32 = 0.1;
|
||||
|
||||
@@ -571,7 +571,7 @@ pub const SUNGLASSES_EDGE: usize = 48;
|
||||
/// clear glasses, at 0.68. Erring towards "sunglasses" is the safe direction
|
||||
/// for what this feeds: a face called sunglasses is left alone by the
|
||||
/// eyes-open filter, where a pair of sunglasses missed hands the eye
|
||||
/// classifier a lens to guess at (docs/faces.md §17).
|
||||
/// classifier a lens to guess at (docs/dev/faces.md §17).
|
||||
pub const SUNGLASSES_WINDOWS: [(f32, f32, f32, f32); 2] =
|
||||
[(0.0, 0.0, 112.0, 112.0), (-5.0, -14.0, 122.0, 122.0)];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Cosine to probability (docs/faces.md §8, FR-CULL-9).
|
||||
//! Cosine to probability (docs/dev/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
|
||||
@@ -28,7 +28,7 @@
|
||||
//! calibration to the belief it was supposed to test — and that is the whole
|
||||
//! of the alternative.
|
||||
//!
|
||||
//! docs/faces.md §8.1 names one more that would cost no labelling at all: two
|
||||
//! docs/dev/faces.md §8.1 names one more that would cost no labelling at all: two
|
||||
//! faces in adjacent frames of one burst are near-certainly the same person,
|
||||
//! and FR-CULL-5's grouping is sitting there. Nothing draws on it. This crate
|
||||
//! cannot see a catalog, let alone the bursts in one — it is handed cosines by
|
||||
@@ -83,7 +83,7 @@ pub struct Calibration {
|
||||
}
|
||||
|
||||
impl Default for Calibration {
|
||||
/// The reference implementation's fitted MBF curve (docs/faces.md §1):
|
||||
/// The reference implementation's fitted MBF curve (docs/dev/faces.md §1):
|
||||
/// steepness 16.2, P=0.5 at cosine 0.267.
|
||||
///
|
||||
/// **`valid` is false**, and that is the point. It is a documented
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! TRACES: FR-CULL-8a
|
||||
//! The two small classifiers behind a face's eye state (docs/faces.md §17).
|
||||
//! The two small classifiers behind a face's eye state (docs/dev/faces.md §17).
|
||||
//!
|
||||
//! **OCEC** — *open closed eyes classification*, Hyodo 2025 — reads one
|
||||
//! 40×24 eye and answers P(open). **SGC** — *sunglasses classification*,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Grouping faces into people (docs/faces.md §9, FR-CULL-10).
|
||||
//! Grouping faces into people (docs/dev/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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! SCRFD face detection (docs/faces.md §4).
|
||||
//! SCRFD face detection (docs/dev/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
|
||||
@@ -138,7 +138,7 @@ impl Detection {
|
||||
pub struct Detector {
|
||||
session: Model,
|
||||
/// f32 or int8 — the int8 form finds a different set of faces and is a
|
||||
/// different detector in `model_id` (docs/inference.md §7).
|
||||
/// different detector in `model_id` (docs/dev/inference.md §7).
|
||||
form: Form,
|
||||
/// Feature-map count: 3 for strides {8,16,32}, 4 for {8,16,32,64}.
|
||||
///
|
||||
@@ -346,7 +346,7 @@ fn iou(a: &(f32, f32, f32, f32), b: &(f32, f32, f32, f32)) -> f32 {
|
||||
/// 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
|
||||
/// docs/dev/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.
|
||||
@@ -372,7 +372,7 @@ impl Letterbox {
|
||||
///
|
||||
/// `(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.
|
||||
/// every measured number in docs/dev/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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! ArcFace / MobileFaceNet inference (docs/faces.md §6).
|
||||
//! ArcFace / MobileFaceNet inference (docs/dev/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
|
||||
@@ -66,7 +66,7 @@ impl Embedder {
|
||||
|
||||
pub fn from_bytes(bytes: &[u8], model: ModelId) -> Result<Self, FaceError> {
|
||||
// Always the f32 form: an embedding must compare across devices
|
||||
// (docs/inference.md §7), and the engine pins this role to it.
|
||||
// (docs/dev/inference.md §7), and the engine pins this role to it.
|
||||
let loaded = dr_inference_engine::open(Role::Embedder, Form::F32, bytes)?;
|
||||
let acquired = loaded.acquire()?;
|
||||
let session = acquired.lock();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! What an embedder produces, and how it is stored (docs/faces.md §6).
|
||||
//! What an embedder produces, and how it is stored (docs/dev/faces.md §6).
|
||||
//!
|
||||
//! Deliberately **model-free**: the vector, its identity, its comparison and
|
||||
//! its storage encoding are arithmetic, and `calibrate` and `cluster` are built
|
||||
@@ -273,7 +273,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The claim docs/faces.md §6 makes about the storage format: the f16
|
||||
/// The claim docs/dev/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]
|
||||
|
||||
@@ -67,14 +67,14 @@ pub const SUNGLASSES_THRESHOLD: f32 = 0.5;
|
||||
/// The classifier was trained on eyes down to about a dozen pixels wide
|
||||
/// (its reference footage averaged 15–21); below that the 40-pixel patch is
|
||||
/// an interpolation of nothing, and the answer is noise that reads as
|
||||
/// "closed". docs/faces.md §17.3 has the measurement behind the number.
|
||||
/// "closed". docs/dev/faces.md §17.3 has the measurement behind the number.
|
||||
pub const MIN_EYE_PX: f32 = 12.0;
|
||||
|
||||
/// Least [`Eye::sharpness`] for the eye to be read.
|
||||
///
|
||||
/// The same measure as the face's `min_sharpness`, over the eye patch, and
|
||||
/// chosen the same way: the value under which the open-eyed faces of the
|
||||
/// reference sample were being called closed. docs/faces.md §17.3.
|
||||
/// reference sample were being called closed. docs/dev/faces.md §17.3.
|
||||
pub const MIN_EYE_SHARPNESS: f32 = 0.02;
|
||||
|
||||
/// An eye narrower than this fraction of its partner is the far eye of a
|
||||
@@ -82,7 +82,7 @@ pub const MIN_EYE_SHARPNESS: f32 = 0.02;
|
||||
///
|
||||
/// A landmark model's contour for a hidden eye collapses towards the nose.
|
||||
/// Measured on twenty native renders of the reference library
|
||||
/// (docs/faces.md §17.4): profiles put the far eye at 0.02–0.43 of the near
|
||||
/// (docs/dev/faces.md §17.4): profiles put the far eye at 0.02–0.43 of the near
|
||||
/// one, two three-quarter faces whose far eye read closed sat at 0.54, and
|
||||
/// every face looking at the camera — winks included, since a shut eye's
|
||||
/// box keeps its width — sat at 0.78 or more. 0.6 splits the gap.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! TRACES: FR-CULL-8a
|
||||
//! Dense facial landmarks — InsightFace's `2d106det` (docs/faces.md §17.2).
|
||||
//! Dense facial landmarks — InsightFace's `2d106det` (docs/dev/faces.md §17.2).
|
||||
//!
|
||||
//! SCRFD's five points place a face; they do not place an eye. Its eye
|
||||
//! point is loose enough that a window centred on it left the eye in a
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Faces and identity (S14, docs/faces.md).
|
||||
//! Faces and identity (S14, docs/dev/faces.md).
|
||||
//!
|
||||
//! Two models, run over the native render, producing per face a box, five
|
||||
//! landmarks, a confidence and a 512-d embedding (FR-CULL-8) — and then the
|
||||
@@ -21,9 +21,9 @@
|
||||
//! 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
|
||||
//! docs/dev/faces.md §2 is the full reading, including what would have to change
|
||||
//! for that to stop being true. The eye-state models are the exception: MIT,
|
||||
//! weights and all, and shipped in `models/face/` (docs/faces.md §17).
|
||||
//! weights and all, and shipped in `models/face/` (docs/dev/faces.md §17).
|
||||
//!
|
||||
//! # Why the runtime is split behind a feature
|
||||
//!
|
||||
@@ -55,7 +55,7 @@ pub mod references;
|
||||
/// Smallest long edge a face crop may be sampled from.
|
||||
///
|
||||
/// **A floor on the crop source, not on the detector input.** The distinction
|
||||
/// is the whole of FR-CULL-8 and `docs/faces.md` §7: detection letterboxes
|
||||
/// is the whole of FR-CULL-8 and `docs/dev/faces.md` §7: detection letterboxes
|
||||
/// every buffer into 640×640, so its input resolution decides nothing, while
|
||||
/// [`warp`] samples the 112×112 the embedder sees and so converts source
|
||||
/// resolution directly into embedding quality. FR-CULL-8 requires that crop to
|
||||
|
||||
@@ -115,7 +115,7 @@ pub struct Faces<'a> {
|
||||
/// 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).
|
||||
/// the same person, so those pairs are never returned (docs/dev/faces.md §9).
|
||||
pub images: &'a [u64],
|
||||
/// Which faces may be compared *against* — the gallery
|
||||
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! What a frame actually costs — the measurement FR-DSP-2 is waiting on.
|
||||
//!
|
||||
//! `docs/display-and-extension.md` §2 argues that tiled computation predates
|
||||
//! `docs/dev/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
|
||||
@@ -28,7 +28,7 @@
|
||||
//! 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
|
||||
//! The committed numbers live in `docs/dev/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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Segment an image and write the granularity ladder as false-coloured PPMs.
|
||||
//!
|
||||
//! The whole point of S15 step 2 (docs/segmentation.md §11): look at the
|
||||
//! The whole point of S15 step 2 (docs/dev/segmentation.md §11): look at the
|
||||
//! ladder and decide whether clicking through it would land on the things a
|
||||
//! person means. No amount of design settles that — the pictures do.
|
||||
//!
|
||||
|
||||
@@ -1898,6 +1898,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-20
|
||||
#[test]
|
||||
fn a_keystone_reshapes_the_frame_without_exposing_a_corner() {
|
||||
// The shader half of perspective correction, end to end. A top-bright
|
||||
// frame with a full vertical keystone spreads its top across the
|
||||
// output, so the bright half reaches further down than the middle;
|
||||
// and since the frame is mapped onto a trapezoid *inside* the source,
|
||||
// no corner is left without a pixel behind it.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let img = split_image(&ctx, true);
|
||||
|
||||
let plain = EditGraph::default_chain().compose();
|
||||
let tex = pass.render(&img, &plain, 32, 32).expect("render");
|
||||
let below_middle = read_pixel(&ctx, tex, 16, 19)[0];
|
||||
assert!(
|
||||
below_middle < 90,
|
||||
"unkeyed, row 19 is the dark half: {below_middle}"
|
||||
);
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(
|
||||
dr_pipeline::framing::ID,
|
||||
dr_pipeline::framing::KEYSTONE_V,
|
||||
100.0,
|
||||
);
|
||||
g.set_param(
|
||||
dr_pipeline::framing::ID,
|
||||
dr_pipeline::framing::KEYSTONE_H,
|
||||
100.0,
|
||||
);
|
||||
let shader = g.compose();
|
||||
let tex = pass.render(&img, &shader, 32, 32).expect("render");
|
||||
|
||||
for (x, y) in [(0, 0), (31, 0), (0, 31), (31, 31)] {
|
||||
assert_ne!(
|
||||
read_pixel(&ctx, tex, x, y),
|
||||
[0, 0, 0, 255],
|
||||
"corner ({x},{y}) has no source pixel behind it"
|
||||
);
|
||||
}
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(
|
||||
dr_pipeline::framing::ID,
|
||||
dr_pipeline::framing::KEYSTONE_V,
|
||||
100.0,
|
||||
);
|
||||
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
|
||||
let keyed = read_pixel(&ctx, tex, 16, 19)[0];
|
||||
assert!(
|
||||
keyed > 128,
|
||||
"the spread top half must reach row 19: {keyed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_crop_does_not_recompile() {
|
||||
// The cache contract for framing, which is what makes an interactive
|
||||
|
||||
@@ -470,7 +470,7 @@ impl FocusPeakPass {
|
||||
// TEXTURE_BINDING to be sampled by the compositor.
|
||||
// RENDER_ATTACHMENT is not used by anything here and is required
|
||||
// anyway: Slint rejects an imported texture without it. COPY_SRC
|
||||
// is for `read_overlay` and its two callers.
|
||||
// is for `read_overlay` and the tests that call it.
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
@@ -490,18 +490,11 @@ impl FocusPeakPass {
|
||||
/// TRACES: AC-8
|
||||
/// Copy the overlay to the CPU, as RGBA8 rows with no padding.
|
||||
///
|
||||
/// **Two callers, and neither is the desktop display path.** The tests
|
||||
/// below are one: an overlay is a claim about which pixels are sharp, and
|
||||
/// there is no way to check that claim without looking at the pixels. The
|
||||
/// other is the Android develop view, which reads the *frame* back for the
|
||||
/// reasons `technical-debt.md` TD-1 records — wgpu's Android swapchain
|
||||
/// tears a portrait window, so Slint is not drawing with wgpu there and no
|
||||
/// texture can be handed over. An overlay that stayed on the device on a
|
||||
/// platform where the picture underneath it does not would simply never be
|
||||
/// seen.
|
||||
///
|
||||
/// On desktop nothing calls this, and ARCH §6.1 holds on the path that
|
||||
/// matters: the overlay reaches the compositor as a texture.
|
||||
/// **The tests below are the only caller, and never the display path.** An
|
||||
/// overlay is a claim about which pixels are sharp, and there is no way to
|
||||
/// check that claim without looking at the pixels. On screen, on desktop
|
||||
/// and Android alike, the overlay reaches the compositor as a texture and
|
||||
/// ARCH §6.1 holds. (Android read it back here until TD-1 was paid off.)
|
||||
pub fn read_overlay(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
||||
let Some(layer) = self.layers[self.current].as_ref() else {
|
||||
return Err(GpuError::Readback("no overlay has been rendered".into()));
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! module doc said for eight releases that it held no pipeline and no masks.
|
||||
//! It holds both now, plus demosaic, detail, segmentation masks, two
|
||||
//! histograms and focus peaking. The zero-copy claim is still the one that
|
||||
//! matters, and TD-1 records the one platform where it does not hold.
|
||||
//! matters, and since TD-1 was paid off it holds on Android too.
|
||||
//!
|
||||
//! Deliberately free of UI dependencies (ARCH §6.5a). The texture is handed
|
||||
//! out as a `wgpu::Texture`; who composites it is not this crate's concern.
|
||||
|
||||
@@ -395,6 +395,7 @@ pub struct MaskPass {
|
||||
combine_layout: wgpu::BindGroupLayout,
|
||||
combine_union: wgpu::RenderPipeline,
|
||||
combine_subtract: wgpu::RenderPipeline,
|
||||
combine_intersect: wgpu::RenderPipeline,
|
||||
/// Where a part is drawn before it is joined.
|
||||
///
|
||||
/// One texture for the whole stack rather than one per layer, because
|
||||
@@ -651,6 +652,16 @@ impl MaskPass {
|
||||
"mask-combine-subtract",
|
||||
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
|
||||
);
|
||||
// TRACES: FR-DEV-19a
|
||||
// `dst * src`: what the mask had, kept only in proportion to how much
|
||||
// of it this part also covers. The same three vertices and the same
|
||||
// scratch, so a third set operation is a third blend state and
|
||||
// nothing more — which is what `Join::apply` states on the CPU and
|
||||
// `the_joins_match_their_definition` holds this to.
|
||||
let combine_intersect = combine(
|
||||
"mask-combine-intersect",
|
||||
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::Src),
|
||||
);
|
||||
|
||||
// The same, with the deposit thrown away: coverage is only ever taken
|
||||
// off what earlier strokes on this layer put down. There is no negative
|
||||
@@ -681,6 +692,7 @@ impl MaskPass {
|
||||
combine_layout,
|
||||
combine_union,
|
||||
combine_subtract,
|
||||
combine_intersect,
|
||||
scratch: None,
|
||||
array: None,
|
||||
allocations: 0,
|
||||
@@ -1376,6 +1388,7 @@ impl MaskPass {
|
||||
pass.set_pipeline(match join {
|
||||
Join::Union => &self.combine_union,
|
||||
Join::Subtract => &self.combine_subtract,
|
||||
Join::Intersect => &self.combine_intersect,
|
||||
});
|
||||
pass.set_bind_group(0, &bind_group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Watershed segmentation — arm A's GPU half (S15, docs/segmentation.md).
|
||||
//! Watershed segmentation — arm A's GPU half (S15, docs/dev/segmentation.md).
|
||||
//!
|
||||
//! Runs the five passes in `shaders/watershed.wgsl` over a demosaiced image
|
||||
//! and leaves a basin label per pixel on the GPU. The hierarchy built from
|
||||
@@ -20,7 +20,7 @@
|
||||
//! one AC-8 forbids is per frame in the render loop, and sharing a switch
|
||||
//! would force a build wanting local masking to unlock the other.
|
||||
//!
|
||||
//! It is still a real cost and still unfinished. F3 in docs/segmentation.md
|
||||
//! It is still a real cost and still unfinished. F3 in docs/dev/segmentation.md
|
||||
//! §12 stands: the adjacency accumulation belongs GPU-side with atomics, and
|
||||
//! until it moves there every segmentation pays a full-resolution transfer.
|
||||
//! Read the feature name as a description of a known gap rather than as
|
||||
@@ -36,7 +36,7 @@ pub struct SegmentOptions {
|
||||
/// Longest proxy edge. The segmentation runs here, not at sensor
|
||||
/// resolution: a 24 MP watershed costs 12× the memory to place boundaries
|
||||
/// a person cannot see, and the boundary refinement that matters at 1:1
|
||||
/// is a separate stage (docs/segmentation.md §4).
|
||||
/// is a separate stage (docs/dev/segmentation.md §4).
|
||||
pub max_edge: u32,
|
||||
/// Pre-smoothing radius in proxy pixels. The caller's to raise with ISO —
|
||||
/// this is the single knob that decides whether a noisy file segments
|
||||
@@ -69,7 +69,7 @@ impl Default for SegmentOptions {
|
||||
w_chroma: 0.5,
|
||||
// **Zero: the pass is off.** It is implemented, dispatched
|
||||
// correctly and measurably changes nothing — see the ignored test
|
||||
// below and §12 of docs/segmentation.md. Until that is understood,
|
||||
// below and §12 of docs/dev/segmentation.md. Until that is understood,
|
||||
// running it would buy 64 dispatches per segmentation and no
|
||||
// improvement, so the default declines to pay.
|
||||
plateau_iterations: 0,
|
||||
@@ -486,7 +486,7 @@ impl Segmentation {
|
||||
/// a region graph of a few thousand nodes that every later interaction
|
||||
/// reads from the CPU anyway.
|
||||
///
|
||||
/// What it is *not* is finished. F3 in docs/segmentation.md §12 stands:
|
||||
/// What it is *not* is finished. F3 in docs/dev/segmentation.md §12 stands:
|
||||
/// the adjacency accumulation belongs on the GPU with atomics, and until
|
||||
/// it moves there a segmentation costs one full-resolution transfer of the
|
||||
/// label and gradient buffers. That is a real cost on a phone and the
|
||||
@@ -724,7 +724,7 @@ mod tests {
|
||||
px
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "the plateau pass is a measured no-op; see docs/segmentation.md §12"]
|
||||
#[ignore = "the plateau pass is a measured no-op; see docs/dev/segmentation.md §12"]
|
||||
fn lower_completion_drains_a_plateau_instead_of_shattering_it() {
|
||||
// F1, asserted rather than eyeballed, and asserted at the level where
|
||||
// it matters.
|
||||
@@ -741,7 +741,7 @@ mod tests {
|
||||
// with no exit anywhere — cannot be drained by a distance that has
|
||||
// nowhere to descend to, and collapsing it fully would need connected
|
||||
// component labelling rather than a local rule. It is not worth it:
|
||||
// see docs/segmentation.md §12.
|
||||
// see docs/dev/segmentation.md §12.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let (w, h) = (96u32, 96u32);
|
||||
let src = DemosaicedImage::from_rgba8(&ctx, &ramp(w, h), w, h).expect("source");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Watershed segmentation — the passes behind arm A of S15 (docs/segmentation.md).
|
||||
// Watershed segmentation — the passes behind arm A of S15 (docs/dev/segmentation.md).
|
||||
//
|
||||
// Seven entry points forming one chain:
|
||||
//
|
||||
@@ -197,7 +197,7 @@ fn gradient(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
// lowest-indexed neighbour, which is up and to the left. Each pixel therefore
|
||||
// walks diagonally until it falls off the plateau, and one flat region becomes
|
||||
// a fan of diagonal chains rather than one basin — visible as hatching across
|
||||
// what should be a single area (docs/segmentation.md §12, F1).
|
||||
// what should be a single area (docs/dev/segmentation.md §12, F1).
|
||||
//
|
||||
// The fix is the standard lower-completion: give each plateau pixel its
|
||||
// geodesic distance to the nearest pixel that *does* have a lower neighbour,
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
//!
|
||||
//! 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
|
||||
//! a wish — `docs/dev/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
|
||||
//! The measurements this guards are in [`docs/dev/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
|
||||
@@ -17,7 +17,7 @@
|
||||
//! **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
|
||||
//! `docs/dev/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
|
||||
@@ -81,7 +81,7 @@ 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
|
||||
/// with room to spare (4.5 ms of GPU; see `docs/dev/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);
|
||||
@@ -166,7 +166,7 @@ impl Run {
|
||||
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 \
|
||||
FR-DSP-3 is what this violates; docs/dev/frame-budget.md holds the \
|
||||
numbers it used to be.",
|
||||
viewport.0,
|
||||
viewport.1,
|
||||
|
||||
@@ -790,6 +790,119 @@ fn the_order_parts_are_joined_in_is_the_mask() {
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// The truth table `docs/dev/mask-editing.md` §13 asks for: one base, one
|
||||
/// part that half-covers it, joined each of the three ways. The base is the
|
||||
/// left half of the frame and the part a dab in the middle, so the four
|
||||
/// quarters of the table are four pixels.
|
||||
#[test]
|
||||
fn a_part_unioned_subtracted_and_intersected_gives_the_three_fields() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let field = split_field(&ctx);
|
||||
let joined = |join: Join| {
|
||||
let mut layer = brighten(MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0],
|
||||
});
|
||||
assert!(layer.push_part(MaskPart::painted("p2", join)));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
render(&ctx, &stack, Some(&field))
|
||||
};
|
||||
|
||||
// (base, part): left outside the dab, left inside, right inside, right
|
||||
// outside.
|
||||
let cells = [(4, 16), (14, 16), (18, 16), (27, 16)];
|
||||
let lit = |pixels: &[u8]| cells.map(|(x, y)| luma_at(pixels, x, y) > 200);
|
||||
|
||||
assert_eq!(
|
||||
lit(&joined(Join::Union)),
|
||||
[true, true, true, false],
|
||||
"union: either"
|
||||
);
|
||||
assert_eq!(
|
||||
lit(&joined(Join::Subtract)),
|
||||
[true, false, false, false],
|
||||
"subtract: the base without the dab"
|
||||
);
|
||||
assert_eq!(
|
||||
lit(&joined(Join::Intersect)),
|
||||
[false, true, false, false],
|
||||
"intersect: only where both are"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// Intersection on soft coverage is the product `Join::apply` defines, on
|
||||
/// either side of the join: a gradient intersected with a region it fills is
|
||||
/// the gradient there and nothing elsewhere, and a region intersected with a
|
||||
/// gradient is the gradient wherever the region is.
|
||||
#[test]
|
||||
fn the_joins_match_their_definition() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let field = split_field(&ctx);
|
||||
let ramp = || MaskSource::Linear {
|
||||
centre: (0.5, 0.5),
|
||||
angle: 0.0,
|
||||
width: 1.0,
|
||||
};
|
||||
let right_half = || MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![1],
|
||||
};
|
||||
let draw = |layer: MaskLayer| {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
render(&ctx, &stack, Some(&field))
|
||||
};
|
||||
|
||||
let alone = draw(brighten(ramp()));
|
||||
|
||||
let mut ramp_then_region = brighten(ramp());
|
||||
assert!(ramp_then_region.push_part(MaskPart::new("p2", Join::Intersect, right_half())));
|
||||
let ramp_then_region = draw(ramp_then_region);
|
||||
|
||||
let mut region_then_ramp = brighten(whole_frame());
|
||||
assert!(region_then_ramp.push_part(MaskPart::new("p2", Join::Intersect, ramp())));
|
||||
let region_then_ramp = draw(region_then_ramp);
|
||||
|
||||
for x in 0..SIZE {
|
||||
let y = SIZE / 2;
|
||||
let want = luma_at(&alone, x, y);
|
||||
// dst · 1 = dst on the right; dst · 0 = 0 on the left. Pixels
|
||||
// within two of the seam are left out: the region's own edge
|
||||
// is soft there, so neither side of the table is 0 or 1.
|
||||
let got = luma_at(&ramp_then_region, x, y);
|
||||
if x.abs_diff(SIZE / 2) <= 2 {
|
||||
// The seam.
|
||||
} else if x > SIZE / 2 {
|
||||
assert!(
|
||||
got.abs_diff(want) <= 1,
|
||||
"x={x}: the ramp survives where the region is ({got} vs {want})"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(got, 128, "x={x}: and nothing survives where it is not");
|
||||
}
|
||||
// 1 · src = src everywhere.
|
||||
let got = luma_at(®ion_then_ramp, x, y);
|
||||
assert!(
|
||||
got.abs_diff(want) <= 1,
|
||||
"x={x}: a full base intersected with the ramp is the ramp ({got} vs {want})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- seeing the mask (FR-DEV-19c) ------------------------------------------
|
||||
|
||||
/// A radial that covers the middle of the frame and nothing near the corners.
|
||||
|
||||
@@ -326,7 +326,7 @@ fn a_proxy_and_an_export_agree_about_the_effect() {
|
||||
|
||||
#[test]
|
||||
fn crossing_the_reduction_threshold_does_not_change_the_picture() {
|
||||
// TRACES: FR-DSP-3 — `docs/technical-debt.md` TD-4, held in pixels.
|
||||
// TRACES: FR-DSP-3 — `docs/dev/technical-debt.md` TD-4, held in pixels.
|
||||
//
|
||||
// Clarity's base is computed on a reduced grid, and how reduced depends on
|
||||
// the viewport: `LocalContrast::reduction` steps 4 -> 2 -> 1 as sigma
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! 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
|
||||
//! `docs/dev/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.
|
||||
|
||||
@@ -6,7 +6,7 @@ rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
# The one crate that names a runtime, a provider, a vendor library or a
|
||||
# device (docs/inference.md §8). `dr-face` and `dr-segment` ask it for a
|
||||
# device (docs/dev/inference.md §8). `dr-face` and `dr-segment` ask it for a
|
||||
# session by role and never see which of these answered.
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The API table `ort` runs on, chosen once (docs/inference.md §3).
|
||||
//! The API table `ort` runs on, chosen once (docs/dev/inference.md §3).
|
||||
//!
|
||||
//! `ort` with `alternative-backend` links no runtime and asks, on first use,
|
||||
//! for an `OrtApi` — a struct of function pointers. Two things can fill it:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Compiled engines: what a rung builds once per device, and the thread that
|
||||
//! builds them before anyone asks (docs/inference.md §5, §6).
|
||||
//! builds them before anyone asks (docs/dev/inference.md §5, §6).
|
||||
//!
|
||||
//! TensorRT keeps its own engine cache keyed by graph hash; QNN writes a
|
||||
//! context model. Both are opaque to this crate, which tracks only *that* a
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Which runtime, which provider and which model form — decided once per
|
||||
//! device, and the only crate that knows the answer (docs/inference.md).
|
||||
//! device, and the only crate that knows the answer (docs/dev/inference.md).
|
||||
//!
|
||||
//! Consumers ask for a session by [`Role`] and get `ort`'s `Session` back;
|
||||
//! what built it — tract on one core, ONNX Runtime's CPU pool, a TensorRT
|
||||
@@ -35,13 +35,13 @@ pub enum Role {
|
||||
Embedder,
|
||||
Segmenter,
|
||||
Scene,
|
||||
/// The dense landmark model behind the eye reading (docs/faces.md §7c).
|
||||
/// The dense landmark model behind the eye reading (docs/dev/faces.md §7c).
|
||||
Landmarks,
|
||||
/// The eye-state and sunglasses classifiers, a few hundred kilobytes.
|
||||
EyeClassifier,
|
||||
/// XFeat, the panorama keypoint detector (docs/panorama.md).
|
||||
/// XFeat, the panorama keypoint detector (docs/dev/panorama.md).
|
||||
Keypoints,
|
||||
/// MI-GAN, the panorama border filler (docs/panorama.md §12). Plain
|
||||
/// MI-GAN, the panorama border filler (docs/dev/panorama.md §12). Plain
|
||||
/// convolutions, so any rung serves it; fp16 on TensorRT and int8 on
|
||||
/// the Hexagon are the point of it.
|
||||
Inpainter,
|
||||
@@ -516,7 +516,7 @@ mod tests {
|
||||
|
||||
/// The smallest shipped graph, if this checkout has the weights; a test
|
||||
/// suite that needs a research-licensed download is one that does not
|
||||
/// run in CI (docs/faces.md §3), so absence is a skip.
|
||||
/// run in CI (docs/dev/faces.md §3), so absence is a skip.
|
||||
fn probe_bytes() -> Option<Vec<u8>> {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Walk the ladder, once, by building real sessions (docs/inference.md §4).
|
||||
//! Walk the ladder, once, by building real sessions (docs/dev/inference.md §4).
|
||||
//!
|
||||
//! A rung is taken when a session builds on it, runs, and is faster than
|
||||
//! the floor. Both halves matter: a provider can register and then fail at
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! One session builder per rung (docs/inference.md §2, §7, §9).
|
||||
//! One session builder per rung (docs/dev/inference.md §2, §7, §9).
|
||||
|
||||
use ort::session::Session;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ log.workspace = true
|
||||
|
||||
# Inference for the learned keypoint detector, on the same footing as
|
||||
# `dr-segment`: `ort` is the API, `dr-inference-engine` decides what runs
|
||||
# it (docs/inference.md), and both are optional so that the geometry —
|
||||
# it (docs/dev/inference.md), and both are optional so that the geometry —
|
||||
# matching, the rotation solve, the projections — is a dependency-free crate
|
||||
# that tests without a model.
|
||||
ort = { workspace = true, optional = true }
|
||||
|
||||
@@ -95,7 +95,7 @@ pub struct Params {
|
||||
/// and, beyond the band being filled, still unknown. That is what the
|
||||
/// shipped model was trained on (a fine-tune of MI-GAN on voids cut
|
||||
/// from photographs the way a cylindrical merge cuts them, see
|
||||
/// `docs/panorama.md` §14); a ring would give it a fold to continue.
|
||||
/// `docs/dev/panorama.md` §14); a ring would give it a fold to continue.
|
||||
///
|
||||
/// Non-zero is the stock model's crutch: a plain reflection of a deep
|
||||
/// hole pulls in whatever is that far from the edge — a ridge, a peak —
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! on every rung, and what they cost is the whole story of whether a fill
|
||||
//! is interactive: 7.4 s a tile under tract, 0.4 s under ONNX Runtime's
|
||||
//! CPU pool, 23 ms in fp16 and 13 ms in int8 on a laptop's TensorRT
|
||||
//! (2026-09-19, docs/panorama.md §12).
|
||||
//! (2026-09-19, docs/dev/panorama.md §12).
|
||||
//!
|
||||
//! The model's contract, from the reference `export_inference_model.py`:
|
||||
//! input `1×4×512×512` float — channel 0 is `mask − 0.5` with 1 where the
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Apache-2.0 weights (`models/LICENCE.md`), exported at a fixed shape by
|
||||
//! `tools/export-xfeat.sh` and loaded through the same `dr-inference-engine`
|
||||
//! `dr-segment` and `dr-face` use, so this adds no runtime and no C to the
|
||||
//! tree; what runs it is the device's business (docs/inference.md). ~300 ms
|
||||
//! tree; what runs it is the device's business (docs/dev/inference.md). ~300 ms
|
||||
//! per frame on tract on the reference desktop, ~400 ms on the tablet
|
||||
//! (S15.2, S15.4).
|
||||
|
||||
@@ -37,7 +37,7 @@ pub struct XFeat {
|
||||
}
|
||||
|
||||
/// The bytes of both exports compiled into the binary, for whoever compiles
|
||||
/// engines ahead of the first request (docs/inference.md §6).
|
||||
/// engines ahead of the first request (docs/dev/inference.md §6).
|
||||
#[cfg(feature = "embedded-model")]
|
||||
pub fn embedded_model_bytes() -> [&'static [u8]; 2] {
|
||||
[EMBEDDED_LANDSCAPE, EMBEDDED_PORTRAIT]
|
||||
|
||||
@@ -315,7 +315,7 @@ pub struct DetailPass {
|
||||
/// 52 render pixels at 4K — holds no spatial frequency a quarter-scale
|
||||
/// grid cannot represent. Computing it at the render size therefore buys
|
||||
/// nothing and costs everything: 105 taps over 8.3 M pixels, twice, which
|
||||
/// measured at 34 ms and is where `docs/technical-debt.md` TD-4 came from.
|
||||
/// measured at 34 ms and is where `docs/dev/technical-debt.md` TD-4 came from.
|
||||
/// At a quarter it is a sixteenth of the pixels at a quarter of the
|
||||
/// radius, and the result is not an approximation of the full-resolution
|
||||
/// base — it is the same band-limited function, sampled where it is still
|
||||
@@ -568,7 +568,7 @@ pub fn compose_detail(
|
||||
/// 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`
|
||||
/// texture and clarity, is not what this does — see `docs/dev/spot-removal.md`
|
||||
/// §5.1, which is where the disagreement is written down.
|
||||
pub fn compose_detail_with(
|
||||
ops: &[Box<dyn Operation>],
|
||||
|
||||
@@ -27,6 +27,33 @@
|
||||
//! chain exactly the space it documents: normalised, centred, `r == 1` at the
|
||||
//! corner. Neither stage needs to know the other exists.
|
||||
//!
|
||||
//! # Perspective sits inside framing (FR-DEV-20)
|
||||
//!
|
||||
//! A keystone correction is composition too — straightening converging
|
||||
//! verticals reframes the photograph — so it is a step *of* framing rather
|
||||
//! than a stage beside it, and inherits framing's `Compose` attribute, its
|
||||
//! place in the sidecar and its exclusion from a default paste. Expanded, the
|
||||
//! chain reads:
|
||||
//!
|
||||
//! ```text
|
||||
//! output pixel → crop → straighten → perspective → orientation → warp (lens) → sample
|
||||
//! ```
|
||||
//!
|
||||
//! After the straightening, because the angle is a nudge applied to the
|
||||
//! corrected picture: the verticals are made parallel and *then* the whole is
|
||||
//! levelled. Before the stored orientation, because "vertical" means vertical
|
||||
//! in the photograph as it is shown — a portrait frame the camera stored on
|
||||
//! its side must converge along its displayed height, not along the sensor's
|
||||
//! rows. And before the lens warp, which still sees the whole frame it
|
||||
//! corrects, for the reason given above.
|
||||
//!
|
||||
//! The correction maps the output frame onto a trapezoid **inside** the
|
||||
//! source rather than pulling the source edges in. So a keystone on its own
|
||||
//! never exposes an empty corner, and the crop the user drew is still valid
|
||||
//! after it; only in combination with a straightening angle does the
|
||||
//! inscribed crop have anything to account for — see
|
||||
//! [`Framing::max_inscribed_crop`].
|
||||
//!
|
||||
//! # Why sampling changes with the angle
|
||||
//!
|
||||
//! At 90° steps and flips, output pixels land exactly on source pixels, so
|
||||
@@ -58,6 +85,13 @@ pub const CROP_X: ParamId = ParamId("crop_x");
|
||||
pub const CROP_Y: ParamId = ParamId("crop_y");
|
||||
pub const CROP_W: ParamId = ParamId("crop_w");
|
||||
pub const CROP_H: ParamId = ParamId("crop_h");
|
||||
/// TRACES: FR-DEV-20
|
||||
/// Vertical keystone. Positive spreads the top of the frame — the correction
|
||||
/// for a building photographed looking up, whose verticals lean together.
|
||||
pub const KEYSTONE_V: ParamId = ParamId("keystone_v");
|
||||
/// TRACES: FR-DEV-20
|
||||
/// Horizontal keystone. Positive spreads the right-hand side of the frame.
|
||||
pub const KEYSTONE_H: ParamId = ParamId("keystone_h");
|
||||
|
||||
/// Widest straightening the control offers, in degrees either way.
|
||||
///
|
||||
@@ -66,12 +100,28 @@ pub const CROP_H: ParamId = ParamId("crop_h");
|
||||
/// the edits actually are.
|
||||
pub const MAX_STRAIGHTEN: f32 = 45.0;
|
||||
|
||||
/// The keystone sliders' travel either way.
|
||||
///
|
||||
/// A plain amount rather than degrees of tilt: the angle a camera was tilted
|
||||
/// by depends on a focal length the correction does not know, and a number
|
||||
/// that claimed to be one would be wrong for every lens but one.
|
||||
pub const MAX_KEYSTONE: f32 = 100.0;
|
||||
|
||||
/// How far a full keystone narrows the far edge of the frame, as a fraction
|
||||
/// of its width: at `MAX_KEYSTONE` the source trapezoid's short side is half
|
||||
/// its long one.
|
||||
///
|
||||
/// Enough for a tall building from its own pavement, and short of the point
|
||||
/// where the stretched edge is so magnified that the correction reads as a
|
||||
/// fault of its own.
|
||||
const KEYSTONE_REACH: f64 = 0.5;
|
||||
|
||||
/// The parameters the framing widget owns — every one of them.
|
||||
///
|
||||
/// In the order the widget expects: the rect first, then the angle it is
|
||||
/// straightened by, then the exact reorientations.
|
||||
static FRAMING_PARAMS: [ParamId; 8] = [
|
||||
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, ROTATION, FLIP_H, FLIP_V,
|
||||
static FRAMING_PARAMS: [ParamId; 10] = [
|
||||
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, KEYSTONE_V, KEYSTONE_H, ROTATION, FLIP_H, FLIP_V,
|
||||
];
|
||||
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
@@ -117,6 +167,30 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
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),
|
||||
// TRACES: FR-DEV-20
|
||||
// Perspective. Last so every sidecar written before these existed
|
||||
// reads exactly as it did: a missing parameter is its default, and
|
||||
// the default is no correction.
|
||||
ParamDescriptor::scalar(
|
||||
"keystone_v",
|
||||
"param.keystone_v",
|
||||
-MAX_KEYSTONE,
|
||||
MAX_KEYSTONE,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::scalar(
|
||||
"keystone_h",
|
||||
"param.keystone_h",
|
||||
-MAX_KEYSTONE,
|
||||
MAX_KEYSTONE,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
],
|
||||
})
|
||||
});
|
||||
@@ -311,6 +385,86 @@ fn finite(v: f32, fallback: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-20
|
||||
/// A plane projective map, row-major, acting on `(x, y, 1)`.
|
||||
///
|
||||
/// Held in `f64` because it is built by solving for four corners and then
|
||||
/// inverted for [`Framing::output_at`]; the shader gets `f32` copies of the
|
||||
/// forward map only.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct Homography([[f64; 3]; 3]);
|
||||
|
||||
impl Homography {
|
||||
/// The map taking the square `[-0.5, 0.5]²` onto the quadrilateral whose
|
||||
/// corners are `q`, listed top-left, top-right, bottom-right, bottom-left.
|
||||
///
|
||||
/// Heckbert's closed form for the unit square, composed with the shift
|
||||
/// from the centred square onto it.
|
||||
fn square_to_quad(q: [(f64, f64); 4]) -> Self {
|
||||
let [(x0, y0), (x1, y1), (x2, y2), (x3, y3)] = q;
|
||||
let (dx1, dx2, dx3) = (x1 - x2, x3 - x2, x0 - x1 + x2 - x3);
|
||||
let (dy1, dy2, dy3) = (y1 - y2, y3 - y2, y0 - y1 + y2 - y3);
|
||||
let den = dx1 * dy2 - dx2 * dy1;
|
||||
let (g, h) = if den.abs() < 1e-12 {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
((dx3 * dy2 - dx2 * dy3) / den, (dx1 * dy3 - dx3 * dy1) / den)
|
||||
};
|
||||
// Unit square (u, v) -> quad.
|
||||
let unit = [
|
||||
[x1 - x0 + g * x1, x3 - x0 + h * x3, x0],
|
||||
[y1 - y0 + g * y1, y3 - y0 + h * y3, y0],
|
||||
[g, h, 1.0],
|
||||
];
|
||||
// Centred square -> unit square is `u = x + 0.5`, so fold the shift
|
||||
// into the constant column.
|
||||
let mut m = unit;
|
||||
for row in &mut m {
|
||||
row[2] += 0.5 * (row[0] + row[1]);
|
||||
}
|
||||
Self(m)
|
||||
}
|
||||
|
||||
/// Where `(x, y)` lands, or `None` past the line the map sends to
|
||||
/// infinity — a point with no image, which the caller treats as outside
|
||||
/// the source.
|
||||
fn apply(&self, (x, y): (f64, f64)) -> Option<(f64, f64)> {
|
||||
let m = &self.0;
|
||||
let w = m[2][0] * x + m[2][1] * y + m[2][2];
|
||||
if w <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
(m[0][0] * x + m[0][1] * y + m[0][2]) / w,
|
||||
(m[1][0] * x + m[1][1] * y + m[1][2]) / w,
|
||||
))
|
||||
}
|
||||
|
||||
/// The inverse map, by the adjugate. Scale is irrelevant to a projective
|
||||
/// map, so the determinant is only divided out to keep `w` positive and
|
||||
/// near one — which is what [`Self::apply`]'s horizon test relies on.
|
||||
fn inverse(&self) -> Self {
|
||||
let m = &self.0;
|
||||
let c = |r0: usize, c0: usize, r1: usize, c1: usize| {
|
||||
m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]
|
||||
};
|
||||
let adj = [
|
||||
[c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)],
|
||||
[-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)],
|
||||
[c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)],
|
||||
];
|
||||
let det = m[0][0] * adj[0][0] + m[0][1] * adj[1][0] + m[0][2] * adj[2][0];
|
||||
let det = if det.abs() < 1e-12 { 1.0 } else { det };
|
||||
let mut inv = adj;
|
||||
for row in &mut inv {
|
||||
for v in row.iter_mut() {
|
||||
*v /= det;
|
||||
}
|
||||
}
|
||||
Self(inv)
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-DEV-3d
|
||||
/// Crop, straighten, rotation and flips for one image.
|
||||
///
|
||||
@@ -325,6 +479,10 @@ pub struct Framing {
|
||||
quarter_turns: u8,
|
||||
flip_h: bool,
|
||||
flip_v: bool,
|
||||
/// TRACES: FR-DEV-20
|
||||
/// Vertical and horizontal keystone, each `-MAX_KEYSTONE..=MAX_KEYSTONE`.
|
||||
keystone_v: f32,
|
||||
keystone_h: f32,
|
||||
/// TRACES: FR-DEV-3h
|
||||
/// How the file's pixels were stored, from its EXIF orientation.
|
||||
///
|
||||
@@ -376,6 +534,8 @@ impl Default for Framing {
|
||||
quarter_turns: 0,
|
||||
flip_h: false,
|
||||
flip_v: false,
|
||||
keystone_v: 0.0,
|
||||
keystone_h: 0.0,
|
||||
baseline: dr_types::Orientation::NORMAL,
|
||||
crop: CropRect::default(),
|
||||
view: CropRect::default(),
|
||||
@@ -396,7 +556,7 @@ impl Framing {
|
||||
/// How framing would like to be presented.
|
||||
///
|
||||
/// **This is what stops a frontend having to name this stage.** Rendered
|
||||
/// generically these eight parameters are eight bad controls: four crop
|
||||
/// generically these ten parameters are ten bad controls: four crop
|
||||
/// edges the photographer would have to type coordinates into, a "rotate"
|
||||
/// slider running 0..3, and two switches. Every one of them is a worse
|
||||
/// control than the gesture it stands for — a crop is dragged on the
|
||||
@@ -407,10 +567,10 @@ impl Framing {
|
||||
/// a second frontend would have had to learn the same special case, and
|
||||
/// nothing in the capability output said why. Now the preference is
|
||||
/// declared, the demand says what the widget needs, and a frontend that
|
||||
/// cannot meet it falls back to the eight sliders — tedious, but complete,
|
||||
/// cannot meet it falls back to the ten sliders — tedious, but complete,
|
||||
/// which is the guarantee the whole hint mechanism rests on.
|
||||
///
|
||||
/// The widget owns **all eight** parameters rather than only the rect: a
|
||||
/// The widget owns **all ten** parameters rather than only the rect: a
|
||||
/// frontend that takes this on is taking on the whole framing control
|
||||
/// surface, and leaving rotation and the flips behind would scatter them
|
||||
/// into the generated panel underneath a crop control that already exists.
|
||||
@@ -476,6 +636,52 @@ impl Framing {
|
||||
(self.flip_h, self.flip_v)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-20
|
||||
/// The vertical and horizontal keystone, as the sliders show them.
|
||||
pub fn keystone(&self) -> (f32, f32) {
|
||||
(self.keystone_v, self.keystone_h)
|
||||
}
|
||||
|
||||
/// Whether a perspective correction is applied at all.
|
||||
pub fn has_keystone(&self) -> bool {
|
||||
self.keystone_v != 0.0 || self.keystone_h != 0.0
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-20
|
||||
/// The perspective map, from the straightened output frame to the upright
|
||||
/// source frame, both measured as the centred square `[-0.5, 0.5]²`.
|
||||
///
|
||||
/// **Measured in fractions of the frame, not in the aspect-scaled space
|
||||
/// the rest of the prologue works in**, so the map is the same for every
|
||||
/// frame shape and the uniforms need no image size. The prologue divides
|
||||
/// `p.x` by the frame's aspect on the way in and multiplies it back on
|
||||
/// the way out.
|
||||
///
|
||||
/// The output frame's corners go to a trapezoid inside the source: a
|
||||
/// positive vertical keystone brings the top corners in, so the top of
|
||||
/// the source is spread across the full width of the output and lines
|
||||
/// that converged upward come out parallel. Nothing is ever mapped from
|
||||
/// outside the source, which is why a keystone alone needs no crop.
|
||||
fn keystone_map(&self) -> Option<Homography> {
|
||||
if !self.has_keystone() {
|
||||
return None;
|
||||
}
|
||||
let amount = |v: f32| f64::from(v / MAX_KEYSTONE).clamp(-1.0, 1.0) * KEYSTONE_REACH;
|
||||
let (tv, th) = (amount(self.keystone_v), amount(self.keystone_h));
|
||||
// How much of each edge survives: the top and bottom rows' widths,
|
||||
// the left and right columns' heights.
|
||||
let top = 1.0 - tv.max(0.0);
|
||||
let bottom = 1.0 + tv.min(0.0);
|
||||
let right = 1.0 - th.max(0.0);
|
||||
let left = 1.0 + th.min(0.0);
|
||||
Some(Homography::square_to_quad([
|
||||
(-0.5 * top, -0.5 * left),
|
||||
(0.5 * top, -0.5 * right),
|
||||
(0.5 * bottom, 0.5 * right),
|
||||
(-0.5 * bottom, 0.5 * left),
|
||||
]))
|
||||
}
|
||||
|
||||
/// Add quarter turns, wrapping. The rotate-left/right buttons.
|
||||
pub fn rotate_quarters(&mut self, turns: i32) {
|
||||
self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8;
|
||||
@@ -544,6 +750,7 @@ impl Framing {
|
||||
pub fn is_active(&self) -> bool {
|
||||
let (turns, flip_h, flip_v) = self.effective();
|
||||
self.angle != 0.0
|
||||
|| self.has_keystone()
|
||||
// Effective, not the user's: a file stored sideways needs the
|
||||
// prologue emitted even on an untouched image, or it renders
|
||||
// through the identity map and lies on its side.
|
||||
@@ -572,6 +779,7 @@ impl Framing {
|
||||
/// edited, the file was merely read correctly.
|
||||
pub fn edits_image(&self) -> bool {
|
||||
self.angle != 0.0
|
||||
|| self.has_keystone()
|
||||
|| self.quarter_turns != 0
|
||||
|| self.flip_h
|
||||
|| self.flip_v
|
||||
@@ -591,7 +799,9 @@ impl Framing {
|
||||
/// warp being active forces interpolation regardless, which is the
|
||||
/// composer's call to make rather than this stage's.
|
||||
pub fn needs_interpolation(&self) -> bool {
|
||||
self.angle != 0.0
|
||||
// A keystone stretches the frame by a different amount at every row,
|
||||
// so it lands between pixels everywhere but on its centre line.
|
||||
self.angle != 0.0 || self.has_keystone()
|
||||
}
|
||||
|
||||
pub fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
@@ -629,6 +839,8 @@ impl Framing {
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
KEYSTONE_V => self.keystone_v = finite(value, 0.0).clamp(-MAX_KEYSTONE, MAX_KEYSTONE),
|
||||
KEYSTONE_H => self.keystone_h = finite(value, 0.0).clamp(-MAX_KEYSTONE, MAX_KEYSTONE),
|
||||
_ => log::warn!("framing: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
@@ -643,6 +855,8 @@ impl Framing {
|
||||
CROP_Y => self.crop.y,
|
||||
CROP_W => self.crop.width,
|
||||
CROP_H => self.crop.height,
|
||||
KEYSTONE_V => self.keystone_v,
|
||||
KEYSTONE_H => self.keystone_h,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
@@ -707,8 +921,22 @@ impl Framing {
|
||||
///
|
||||
/// The standard largest-inscribed-rectangle result for a rotated
|
||||
/// rectangle of the same aspect ratio.
|
||||
///
|
||||
/// TRACES: FR-DEV-20
|
||||
/// **With a keystone the closed form no longer applies**: the area with a
|
||||
/// source pixel behind it is the source rectangle pulled back through the
|
||||
/// perspective map and then turned, a quadrilateral no textbook result
|
||||
/// describes. That case is searched instead — see
|
||||
/// [`Self::inscribed_by_search`]. A keystone alone never needs a crop, so
|
||||
/// the search returns the whole frame for it, exactly.
|
||||
pub fn max_inscribed_crop(&self, width: u32, height: u32) -> CropRect {
|
||||
if self.angle == 0.0 || width == 0 || height == 0 {
|
||||
if width == 0 || height == 0 {
|
||||
return CropRect::default();
|
||||
}
|
||||
if self.has_keystone() {
|
||||
return self.inscribed_by_search(width, height);
|
||||
}
|
||||
if self.angle == 0.0 {
|
||||
return CropRect::default();
|
||||
}
|
||||
|
||||
@@ -750,6 +978,123 @@ impl Framing {
|
||||
.normalised()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-20
|
||||
/// The largest centred crop with a source pixel behind every point, found
|
||||
/// by search rather than by formula.
|
||||
///
|
||||
/// The area that has a source pixel behind it is convex — the source
|
||||
/// rectangle pulled back through a projective map whose horizon lies
|
||||
/// outside it, then turned — and a rectangle lies inside a convex region
|
||||
/// exactly when its four corners do. For a given width the tallest
|
||||
/// rectangle that fits is therefore found by bisection, and the area
|
||||
/// `width × tallest(width)` is unimodal in the width (a positive concave
|
||||
/// function times a line), so a golden-section search finds the best
|
||||
/// width. Every rectangle returned has been tested corner by corner, so
|
||||
/// the answer errs inside, never outside.
|
||||
///
|
||||
/// A few hundred corner tests, on a gesture's release, is nothing next to
|
||||
/// the render that follows it.
|
||||
fn inscribed_by_search(&self, width: u32, height: u32) -> CropRect {
|
||||
let (w, h) = if self.swaps_axes() {
|
||||
(f64::from(height), f64::from(width))
|
||||
} else {
|
||||
(f64::from(width), f64::from(height))
|
||||
};
|
||||
let fa = w / h;
|
||||
let rad = f64::from(self.angle).to_radians();
|
||||
let (sn, cs) = (rad.sin(), rad.cos());
|
||||
let map = self.keystone_map();
|
||||
|
||||
// Whether the output point `(fx, fy)`, in fractions of the frame from
|
||||
// its centre, has a source pixel behind it. The prologue's steps, in
|
||||
// its order, stopping short of the turns: those are a permutation of
|
||||
// the frame and cannot move a point across its edge.
|
||||
let defined = |fx: f64, fy: f64| {
|
||||
let p = (fx * fa, fy);
|
||||
let q = (p.0 * cs - p.1 * sn, p.0 * sn + p.1 * cs);
|
||||
let n = (q.0 / fa, q.1);
|
||||
let src = match &map {
|
||||
Some(m) => m.apply(n),
|
||||
None => Some(n),
|
||||
};
|
||||
const EDGE: f64 = 0.5 + 1e-9;
|
||||
src.is_some_and(|(x, y)| x.abs() <= EDGE && y.abs() <= EDGE)
|
||||
};
|
||||
let fits = |hw: f64, hh: f64| {
|
||||
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
|
||||
.iter()
|
||||
.all(|(sx, sy)| defined(sx * hw, sy * hh))
|
||||
};
|
||||
|
||||
if fits(0.5, 0.5) {
|
||||
return CropRect::default();
|
||||
}
|
||||
|
||||
// Half the tallest height that fits at half-width `hw`.
|
||||
let tallest = |hw: f64| {
|
||||
if fits(hw, 0.5) {
|
||||
return 0.5;
|
||||
}
|
||||
if !fits(hw, 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0, 0.5);
|
||||
for _ in 0..40 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if fits(hw, mid) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
lo
|
||||
};
|
||||
let area = |hw: f64| hw * tallest(hw);
|
||||
|
||||
let ratio = (5.0_f64.sqrt() - 1.0) * 0.5;
|
||||
let (mut a, mut b) = (0.0, 0.5);
|
||||
let mut c = b - ratio * (b - a);
|
||||
let mut d = a + ratio * (b - a);
|
||||
let (mut fc, mut fd) = (area(c), area(d));
|
||||
for _ in 0..48 {
|
||||
if fc < fd {
|
||||
a = c;
|
||||
c = d;
|
||||
fc = fd;
|
||||
d = a + ratio * (b - a);
|
||||
fd = area(d);
|
||||
} else {
|
||||
b = d;
|
||||
d = c;
|
||||
fd = fc;
|
||||
c = b - ratio * (b - a);
|
||||
fc = area(c);
|
||||
}
|
||||
}
|
||||
let hw = 0.5 * (a + b);
|
||||
let hh = tallest(hw);
|
||||
// Tested at `hw` as returned, so a width that the search's last step
|
||||
// nudged past the boundary cannot come back with a height that no
|
||||
// longer fits it.
|
||||
let (hw, hh) = if hh > 0.0 && fits(hw, hh) {
|
||||
(hw, hh)
|
||||
} else {
|
||||
(c.min(d), tallest(c.min(d)))
|
||||
};
|
||||
|
||||
let fw = (2.0 * hw) as f32;
|
||||
let fh = (2.0 * hh) as f32;
|
||||
let fw = fw.clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
let fh = fh.clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
CropRect {
|
||||
x: (1.0 - fw) * 0.5,
|
||||
y: (1.0 - fh) * 0.5,
|
||||
width: fw,
|
||||
height: fh,
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Where an output point comes from in the source, both in normalised
|
||||
/// `0..1` coordinates.
|
||||
@@ -782,6 +1127,15 @@ impl Framing {
|
||||
p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c);
|
||||
}
|
||||
|
||||
if let Some(m) = self.keystone_map() {
|
||||
p = match m.apply((f64::from(p.0 / fx), f64::from(p.1))) {
|
||||
Some((x, y)) => (x as f32 * fx, y as f32),
|
||||
// Beyond the map's horizon: no source point at all, reported
|
||||
// as one far outside the frame rather than as a NaN.
|
||||
None => (1e6, 1e6),
|
||||
};
|
||||
}
|
||||
|
||||
let (turns, flip_h, flip_v) = self.effective();
|
||||
p = match turns {
|
||||
1 => (p.1 * ax, -p.0 / fx),
|
||||
@@ -824,6 +1178,13 @@ impl Framing {
|
||||
_ => p,
|
||||
};
|
||||
|
||||
if let Some(m) = self.keystone_map() {
|
||||
p = match m.inverse().apply((f64::from(p.0 / fx), f64::from(p.1))) {
|
||||
Some((x, y)) => (x as f32 * fx, y as f32),
|
||||
None => (1e6, 1e6),
|
||||
};
|
||||
}
|
||||
|
||||
if self.angle != 0.0 {
|
||||
let rad = -self.angle * PI / 180.0;
|
||||
let (s, c) = (rad.sin(), rad.cos());
|
||||
@@ -865,6 +1226,19 @@ impl Framing {
|
||||
// identical whether or not the user is zoomed in, and costs no extra
|
||||
// uniform slot.
|
||||
let rect = self.visible_rect();
|
||||
// The perspective map by columns, so the prologue can apply it as
|
||||
// three multiply-adds. The identity when there is none: the slots
|
||||
// exist either way and the prologue does not read them.
|
||||
let m = self
|
||||
.keystone_map()
|
||||
.unwrap_or(Homography([
|
||||
[1.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
]))
|
||||
.0;
|
||||
let col = |c: usize| [m[0][c] as f32, m[1][c] as f32, m[2][c] as f32, 0.0];
|
||||
let [c0, c1, c2] = [col(0), col(1), col(2)];
|
||||
[
|
||||
rect.x,
|
||||
rect.y,
|
||||
@@ -874,6 +1248,18 @@ impl Framing {
|
||||
rad.cos(),
|
||||
0.0,
|
||||
0.0,
|
||||
c0[0],
|
||||
c0[1],
|
||||
c0[2],
|
||||
c0[3],
|
||||
c1[0],
|
||||
c1[1],
|
||||
c1[2],
|
||||
c1[3],
|
||||
c2[0],
|
||||
c2[1],
|
||||
c2[2],
|
||||
c2[3],
|
||||
]
|
||||
}
|
||||
|
||||
@@ -972,6 +1358,28 @@ impl Framing {
|
||||
);
|
||||
}
|
||||
|
||||
if self.has_keystone() {
|
||||
// TRACES: FR-DEV-20
|
||||
// After the straightening and before the turns, so the keystone
|
||||
// acts on the photograph as it is shown. The map is measured in
|
||||
// fractions of the frame (see `Framing::keystone_map`), hence the
|
||||
// aspect divided out and put back. A point past the map's horizon
|
||||
// has no source at all and is sent far outside it, where the
|
||||
// sampler's bounds test renders it void.
|
||||
s.push_str(
|
||||
"
|
||||
// Perspective: the straightened frame onto a trapezoid of the source.
|
||||
let key_n = vec2<f32>(p.x / frame_aspect.x, p.y);
|
||||
let key_h = u.keystone_c0.xyz * key_n.x + u.keystone_c1.xyz * key_n.y + u.keystone_c2.xyz;
|
||||
p = select(
|
||||
vec2<f32>(1.0e6),
|
||||
vec2<f32>(key_h.x / key_h.z * frame_aspect.x, key_h.y / key_h.z),
|
||||
key_h.z > 1.0e-6,
|
||||
);
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
// The user's turns and mirrors composed with the file's stored
|
||||
// orientation. One permutation covers both, so honouring the EXIF tag
|
||||
// adds no per-pixel work over an untagged file.
|
||||
@@ -1040,13 +1448,15 @@ impl Framing {
|
||||
| u64::from(flip_v) << 3
|
||||
| u64::from(turns) << 4
|
||||
| u64::from(self.is_active()) << 6
|
||||
| u64::from(self.has_keystone()) << 7
|
||||
}
|
||||
}
|
||||
|
||||
/// Floats the framing block occupies in the generated uniform struct.
|
||||
///
|
||||
/// Two `vec4`s: the crop rect, and the angle's sin/cos with padding.
|
||||
pub const FRAMING_UNIFORM_FIELDS: usize = 8;
|
||||
/// Five `vec4`s: the crop rect, the angle's sin/cos with padding, and the
|
||||
/// perspective map's three columns, each padded.
|
||||
pub const FRAMING_UNIFORM_FIELDS: usize = 20;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -2025,6 +2435,8 @@ mod tests {
|
||||
(CROP_Y, 0.2),
|
||||
(CROP_W, 0.5),
|
||||
(CROP_H, 0.4),
|
||||
(KEYSTONE_V, 35.0),
|
||||
(KEYSTONE_H, -20.0),
|
||||
] {
|
||||
f.set_param(id, v);
|
||||
assert_eq!(f.param(id), v, "{id} did not round-trip");
|
||||
@@ -2241,6 +2653,8 @@ mod tests {
|
||||
f.rotate_quarters(turns);
|
||||
f.set_param(FLIP_H, 1.0);
|
||||
f.set_param(FLIP_V, 1.0);
|
||||
f.set_param(KEYSTONE_V, 60.0);
|
||||
f.set_param(KEYSTONE_H, -25.0);
|
||||
|
||||
for out in [(0.0, 0.0), (0.5, 0.5), (0.2, 0.9), (0.95, 0.05)] {
|
||||
let src = f.source_at(out, SRC.0, SRC.1);
|
||||
@@ -2316,6 +2730,27 @@ mod tests {
|
||||
"the three-turn permutation moved; `source_at` must move with it"
|
||||
);
|
||||
|
||||
// The perspective step: the map applied in fractions of the frame,
|
||||
// which is what `source_at` divides the aspect out for.
|
||||
let mut f = Framing::new();
|
||||
f.set_param(KEYSTONE_V, 40.0);
|
||||
let prologue = f.wgsl_prologue();
|
||||
assert!(
|
||||
prologue.contains("let key_n = vec2<f32>(p.x / frame_aspect.x, p.y);")
|
||||
&& prologue.contains("key_h.x / key_h.z * frame_aspect.x"),
|
||||
"the perspective step moved; `source_at` must move with it"
|
||||
);
|
||||
// After the straightening and before the turns, as `source_at` has it.
|
||||
let mut f = Framing::new();
|
||||
f.set_param(KEYSTONE_V, 40.0);
|
||||
f.set_param(ANGLE, 3.0);
|
||||
f.rotate_quarters(1);
|
||||
let prologue = f.wgsl_prologue();
|
||||
let straighten = prologue.find("// Straighten").unwrap();
|
||||
let keystone = prologue.find("// Perspective").unwrap();
|
||||
let turn = prologue.find("90° clockwise").unwrap();
|
||||
assert!(straighten < keystone && keystone < turn, "{prologue}");
|
||||
|
||||
// And the sampler's last step, which lives in `operation.rs` and is
|
||||
// the half of the map this file does not emit.
|
||||
assert!(
|
||||
@@ -2323,4 +2758,241 @@ mod tests {
|
||||
"the sampler's return to texture coordinates moved"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- perspective (FR-DEV-20) -----------------------------------------
|
||||
|
||||
/// A grid over the whole output frame, edges included.
|
||||
fn grid() -> impl Iterator<Item = (f32, f32)> {
|
||||
(0..=10).flat_map(|j| (0..=10).map(move |i| (i as f32 / 10.0, j as f32 / 10.0)))
|
||||
}
|
||||
|
||||
fn inside(p: (f32, f32)) -> bool {
|
||||
(-1e-4..=1.0 + 1e-4).contains(&p.0) && (-1e-4..=1.0 + 1e-4).contains(&p.1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keystone_is_an_edit_and_a_resample() {
|
||||
let mut f = Framing::new();
|
||||
let neutral = f.structure_key();
|
||||
f.set_param(KEYSTONE_V, 30.0);
|
||||
assert!(f.is_active());
|
||||
assert!(f.edits_image(), "a keystone must light the modified dot");
|
||||
assert!(f.needs_interpolation());
|
||||
assert_ne!(f.structure_key(), neutral);
|
||||
assert!(f.wgsl_prologue().contains("u.keystone_c0"));
|
||||
// Neither the output size nor the crop moves: the frame is reshaped
|
||||
// inside itself, so what the user cropped stays cropped.
|
||||
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
|
||||
assert!(f.crop().is_full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_keystone_magnitude_does_not_reach_the_structure_key() {
|
||||
// Dragging the slider is a uniform upload, never a shader build.
|
||||
let mut f = Framing::new();
|
||||
f.set_param(KEYSTONE_V, 10.0);
|
||||
let key = f.structure_key();
|
||||
for (v, h) in [(80.0, 0.0), (-45.0, 30.0), (1.0, -100.0)] {
|
||||
f.set_param(KEYSTONE_V, v);
|
||||
f.set_param(KEYSTONE_H, h);
|
||||
assert_eq!(f.structure_key(), key, "{v}/{h} forced a recompile");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_keystone_is_clamped_to_its_travel() {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(KEYSTONE_V, 1e9);
|
||||
f.set_param(KEYSTONE_H, f32::NAN);
|
||||
assert_eq!(f.keystone(), (MAX_KEYSTONE, 0.0));
|
||||
assert!(f.uniforms().iter().all(|v| v.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keystone_alone_never_reaches_outside_the_source() {
|
||||
// The design decision the crop relies on: the output frame is mapped
|
||||
// onto a trapezoid *inside* the source, so no corner goes empty and
|
||||
// a crop drawn before the keystone is still a crop of the picture.
|
||||
for (v, h) in [
|
||||
(100.0, 0.0),
|
||||
(-100.0, 0.0),
|
||||
(0.0, 100.0),
|
||||
(0.0, -100.0),
|
||||
(100.0, 100.0),
|
||||
(-100.0, 100.0),
|
||||
(37.0, -64.0),
|
||||
] {
|
||||
for turns in 0..4 {
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(turns);
|
||||
f.set_param(KEYSTONE_V, v);
|
||||
f.set_param(KEYSTONE_H, h);
|
||||
for out in grid() {
|
||||
let src = f.source_at(out, SRC.0, SRC.1);
|
||||
assert!(inside(src), "{v}/{h}, {turns} turn(s): {out:?} -> {src:?}");
|
||||
}
|
||||
assert!(f.max_inscribed_crop(SRC.0, SRC.1).is_full());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vertical_keystone_makes_upward_converging_lines_parallel() {
|
||||
// What the control is for. Output columns are straight verticals;
|
||||
// with a positive keystone each must come from a straight source line
|
||||
// that leans in toward the centre as it rises — the shape a building
|
||||
// has when photographed looking up.
|
||||
let mut f = Framing::new();
|
||||
f.set_param(KEYSTONE_V, 60.0);
|
||||
|
||||
for x in [0.1f32, 0.3, 0.7, 0.9] {
|
||||
let bottom = f.source_at((x, 1.0), SRC.0, SRC.1);
|
||||
let middle = f.source_at((x, 0.5), SRC.0, SRC.1);
|
||||
let top = f.source_at((x, 0.0), SRC.0, SRC.1);
|
||||
|
||||
// Straight: the middle sits on the line through the two ends.
|
||||
let cross = (top.0 - bottom.0) * (middle.1 - bottom.1)
|
||||
- (top.1 - bottom.1) * (middle.0 - bottom.0);
|
||||
assert!(cross.abs() < 1e-4, "column {x} is not a straight line");
|
||||
// Leaning in: the top is nearer the centre than the bottom.
|
||||
assert!(
|
||||
(top.0 - 0.5).abs() < (bottom.0 - 0.5).abs(),
|
||||
"column {x}: top {top:?} is not inside bottom {bottom:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The bottom row is left where it was; the top row is the one spread.
|
||||
close(
|
||||
f.source_at((0.0, 1.0), SRC.0, SRC.1),
|
||||
(0.0, 1.0),
|
||||
"bottom-left",
|
||||
);
|
||||
close(
|
||||
f.source_at((0.0, 0.0), SRC.0, SRC.1),
|
||||
(0.15, 0.0),
|
||||
"top-left",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_horizontal_keystone_spreads_the_right_hand_side() {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(KEYSTONE_H, 100.0);
|
||||
// The right-hand column comes from half the source's height.
|
||||
close(
|
||||
f.source_at((1.0, 0.0), SRC.0, SRC.1),
|
||||
(1.0, 0.25),
|
||||
"top-right",
|
||||
);
|
||||
close(
|
||||
f.source_at((1.0, 1.0), SRC.0, SRC.1),
|
||||
(1.0, 0.75),
|
||||
"bottom-right",
|
||||
);
|
||||
close(
|
||||
f.source_at((0.0, 0.0), SRC.0, SRC.1),
|
||||
(0.0, 0.0),
|
||||
"top-left",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_keystone_acts_on_the_frame_as_shown() {
|
||||
// A portrait frame the camera stored on its side: "vertical" is the
|
||||
// frame's displayed height, so the same keystone must move the same
|
||||
// *displayed* points whatever the file's stored orientation.
|
||||
let mut upright = Framing::new();
|
||||
upright.set_param(KEYSTONE_V, 50.0);
|
||||
let mut sideways = Framing::new();
|
||||
sideways.set_baseline(dr_types::Orientation::from_exif(6));
|
||||
sideways.set_param(KEYSTONE_V, 50.0);
|
||||
|
||||
// Compare in the displayed frame: map the sideways result back
|
||||
// through the orientation alone.
|
||||
let mut turn_only = Framing::new();
|
||||
turn_only.set_baseline(dr_types::Orientation::from_exif(6));
|
||||
for out in grid() {
|
||||
let a = upright.source_at(out, SRC.1, SRC.0);
|
||||
let b = turn_only.output_at(sideways.source_at(out, SRC.0, SRC.1), SRC.0, SRC.1);
|
||||
close(a, b, "the keystone turned with the file");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inscribed_crop_accounts_for_the_keystone() {
|
||||
// Straightening a keystoned frame: the empty area is no longer the
|
||||
// rotated rectangle's, and the crop must avoid the area that is.
|
||||
for (angle, v, h) in [
|
||||
(5.0f32, 50.0f32, 0.0f32),
|
||||
(-8.0, -70.0, 20.0),
|
||||
(12.0, 100.0, 100.0),
|
||||
(2.0, 0.0, -40.0),
|
||||
] {
|
||||
for turns in [0, 1] {
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(turns);
|
||||
f.set_param(ANGLE, angle);
|
||||
f.set_param(KEYSTONE_V, v);
|
||||
f.set_param(KEYSTONE_H, h);
|
||||
let c = f.max_inscribed_crop(SRC.0, SRC.1);
|
||||
assert!(
|
||||
c.width > 0.3 && c.height > 0.3 && !c.is_full(),
|
||||
"{angle}°/{v}/{h}: {c:?}"
|
||||
);
|
||||
assert!(
|
||||
((c.x + c.width * 0.5) - 0.5).abs() < 1e-4
|
||||
&& ((c.y + c.height * 0.5) - 0.5).abs() < 1e-4,
|
||||
"{c:?} is not centred"
|
||||
);
|
||||
|
||||
// Every point of it, edges included, has a source pixel.
|
||||
f.set_crop(c);
|
||||
for out in grid() {
|
||||
let src = f.source_at(out, SRC.0, SRC.1);
|
||||
assert!(inside(src), "{angle}°/{v}/{h}: {out:?} -> {src:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inscribed_crop_with_a_keystone_is_not_needlessly_small() {
|
||||
// The search must find the best rectangle, not merely a safe one. A
|
||||
// tenth larger in either direction has to reach outside the source.
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, 6.0);
|
||||
f.set_param(KEYSTONE_V, 60.0);
|
||||
let c = f.max_inscribed_crop(SRC.0, SRC.1);
|
||||
for (gw, gh) in [(1.1, 1.0), (1.0, 1.1)] {
|
||||
let mut g = f;
|
||||
let (w, h) = (c.width * gw, c.height * gh);
|
||||
g.set_crop(CropRect {
|
||||
x: 0.5 - w * 0.5,
|
||||
y: 0.5 - h * 0.5,
|
||||
width: w,
|
||||
height: h,
|
||||
});
|
||||
let spills = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
|
||||
.into_iter()
|
||||
.any(|out| !inside(g.source_at(out, SRC.0, SRC.1)));
|
||||
// Either the grown rect spills, or it could not grow at all
|
||||
// because the crop was already at the frame's edge on that axis.
|
||||
assert!(
|
||||
spills
|
||||
|| (gw > 1.0 && c.width >= 1.0 - 1e-4)
|
||||
|| (gh > 1.0 && c.height >= 1.0 - 1e-4),
|
||||
"{c:?} grown by {gw}x{gh} still fits"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_the_keystone() {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(KEYSTONE_V, 30.0);
|
||||
f.set_param(KEYSTONE_H, -30.0);
|
||||
f.reset();
|
||||
assert_eq!(f.keystone(), (0.0, 0.0));
|
||||
assert!(!f.is_active());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ pub struct EditGraph {
|
||||
/// 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`).
|
||||
/// The repairs (`docs/dev/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
|
||||
@@ -122,7 +122,7 @@ pub struct EditGraph {
|
||||
/// photograph comes from.
|
||||
spots: SpotSet,
|
||||
/// The lens corrections that rewrite coordinates: distortion and lateral
|
||||
/// chromatic aberration (`docs/architecture.md` §5.2).
|
||||
/// chromatic aberration (`docs/dev/architecture.md` §5.2).
|
||||
///
|
||||
/// Apart from `ops` for the fourth time, and this one is not about shape
|
||||
/// but about direction. Every [`Operation`] is a function from colour to
|
||||
|
||||
@@ -44,6 +44,7 @@ pub mod mask;
|
||||
pub mod neutral;
|
||||
pub mod operation;
|
||||
pub mod ops;
|
||||
pub mod orphan;
|
||||
pub mod preset;
|
||||
pub mod sidecar;
|
||||
pub mod spot;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
//! [`MaskSource::Regions`] stores integers naming regions in the segmentation
|
||||
//! hierarchy (`dr-segment`). That choice is what makes a mask diffable, cheap
|
||||
//! in a sidecar, and mergeable per-field under FR-NC-9 — three properties a
|
||||
//! stored raster has none of (docs/segmentation.md §1). Two devices that
|
||||
//! stored raster has none of (docs/dev/segmentation.md §1). Two devices that
|
||||
//! select the same subject produce the same small sorted list, and a sync
|
||||
//! conflict between them is resolvable rather than a binary blob fight.
|
||||
//!
|
||||
@@ -564,7 +564,7 @@ pub enum MaskSource {
|
||||
/// This is what the watershed and the semantic model exist to produce.
|
||||
/// Selecting a subject means "the regions the model's instance covers",
|
||||
/// and the resulting edge is the watershed's, which is to say the image's
|
||||
/// own (docs/segmentation.md §5).
|
||||
/// own (docs/dev/segmentation.md §5).
|
||||
Regions {
|
||||
/// Which segmentation these ids index into.
|
||||
///
|
||||
@@ -590,7 +590,7 @@ pub enum MaskSource {
|
||||
/// **The primary way a local adjustment is made.** The watershed hierarchy
|
||||
/// this crate was first built around does not survive a photograph: its
|
||||
/// saddles are near zero almost everywhere, so a global cut collapses the
|
||||
/// frame into one region plus noise (docs/segmentation.md §15). A model
|
||||
/// frame into one region plus noise (docs/dev/segmentation.md §15). A model
|
||||
/// instance is a whole object, found as one thing, and needs no ladder.
|
||||
///
|
||||
/// The trade is that the boundary is the model's — a quarter-resolution
|
||||
@@ -625,7 +625,7 @@ pub enum MaskSource {
|
||||
/// reason both exist. A subject is *one* instance — this dog, not that one
|
||||
/// — found by a COCO-trained instance model. A category is *all* the sky,
|
||||
/// or all the foliage, from an ADE20K-trained semantic model that has no
|
||||
/// notion of instances at all (docs/segmentation.md §16).
|
||||
/// notion of instances at all (docs/dev/segmentation.md §16).
|
||||
///
|
||||
/// So this is what a global grade attaches to: lift the sky, desaturate
|
||||
/// the vegetation, warm the architecture. Asking it for "that person
|
||||
@@ -926,6 +926,21 @@ pub enum Join {
|
||||
/// erase stroke is a hole in the part it was painted into and reads as
|
||||
/// nothing at all.
|
||||
Subtract,
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// Only where both agree. "Keep the part of this mask that is also that."
|
||||
///
|
||||
/// The join that makes cheap criteria precise: a sky is a category *and*
|
||||
/// a luminance band, skin is a subject *and* a hue. Neither alone is the
|
||||
/// selection, and no feather on either makes it one.
|
||||
///
|
||||
/// The product of the two coverages rather than their minimum, because
|
||||
/// that is what one fixed-function blend gives on the device
|
||||
/// (`dst · src`, `docs/dev/mask-editing.md` §5.2) and it agrees with the
|
||||
/// minimum wherever either side is fully in or fully out. Between two soft
|
||||
/// edges it is the softer of the two readings, which is the right way to
|
||||
/// be wrong: an overlap of two partial selections is less certainly
|
||||
/// selected than either.
|
||||
Intersect,
|
||||
}
|
||||
|
||||
impl Join {
|
||||
@@ -933,6 +948,7 @@ impl Join {
|
||||
match self {
|
||||
Self::Union => "union",
|
||||
Self::Subtract => "subtract",
|
||||
Self::Intersect => "intersect",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,12 +956,30 @@ impl Join {
|
||||
Some(match name {
|
||||
"union" => Self::Union,
|
||||
"subtract" => Self::Subtract,
|
||||
"intersect" => Self::Intersect,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every variant, for a UI building a choice control.
|
||||
pub const ALL: [Join; 2] = [Join::Union, Join::Subtract];
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// The coverage this join leaves at one point, given what the mask had
|
||||
/// there (`dst`) and what the part covers (`src`), both in `0..=1`.
|
||||
///
|
||||
/// The definition the device's blend states implement, spelled out once
|
||||
/// on the CPU so a test can hold the GPU to it and a reader can see the
|
||||
/// three set operations side by side without reading `wgpu` enums.
|
||||
pub fn apply(self, dst: f32, src: f32) -> f32 {
|
||||
match self {
|
||||
Self::Union => dst.max(src),
|
||||
Self::Subtract => dst * (1.0 - src),
|
||||
Self::Intersect => dst * src,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every variant, for a UI building a choice control. The order is the
|
||||
/// panel's: a chip cycles through it and a stored index names a place in
|
||||
/// it, so a new join goes on the end.
|
||||
pub const ALL: [Join; 3] = [Join::Union, Join::Subtract, Join::Intersect];
|
||||
}
|
||||
|
||||
/// One selection inside a layer's mask.
|
||||
@@ -1580,9 +1614,20 @@ impl MaskLayer {
|
||||
// A hidden part is not in the build, whichever way it joins — and a
|
||||
// hidden base hands its role to the first part that is shown, which
|
||||
// is why "adds" is asked of the shown parts rather than of index 0.
|
||||
//
|
||||
// Folded rather than asked with `any`, because an intersection can
|
||||
// take away everything the parts before it added: a subject
|
||||
// intersected with an unpainted brush covers nothing. An inverted
|
||||
// part is taken to cover, whatever its source — an inverted empty
|
||||
// brush is the whole frame, and saying "covers nothing" of a layer
|
||||
// that does would hide an adjustment the photographer made.
|
||||
self.shown_parts()
|
||||
.enumerate()
|
||||
.any(|(i, p)| (i == 0 || p.join == Join::Union) && p.covers())
|
||||
.fold(false, |acc, (i, p)| match (i, p.join) {
|
||||
(0, _) | (_, Join::Union) => acc || p.covers(),
|
||||
(_, Join::Subtract) => acc,
|
||||
(_, Join::Intersect) => acc && (p.covers() || p.invert),
|
||||
})
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
@@ -2231,7 +2276,7 @@ fn reveal_block(slot: usize, layer: &MaskLayer, style: RevealStyle, colour: [f32
|
||||
/// **not** a hash of the label field: that would be a readback on a path that
|
||||
/// must not have one (ARCH §6.1), and would also make the signature depend on
|
||||
/// float arithmetic whose cross-vendor determinism is exactly the open
|
||||
/// question (docs/segmentation.md §6, M5).
|
||||
/// question (docs/dev/segmentation.md §6, M5).
|
||||
pub fn segmentation_signature(width: u32, height: u32, regions: u32, tuning: u64) -> u64 {
|
||||
// FNV-1a over the four fields. Small, dependency-free, and adequate: this
|
||||
// guards against accidental mismatch, not against a forged sidecar.
|
||||
@@ -3037,6 +3082,69 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// The three joins, pointwise, on every pair of coverages a part and a
|
||||
/// mask can meet at: fully in, fully out, and each soft edge. Intersection
|
||||
/// is the product, which agrees with the minimum wherever either side is
|
||||
/// decided and is the softer reading where both are not.
|
||||
#[test]
|
||||
fn the_joins_are_max_cut_and_product() {
|
||||
let levels = [0.0f32, 0.25, 0.5, 0.75, 1.0];
|
||||
for &dst in &levels {
|
||||
for &src in &levels {
|
||||
assert_eq!(Join::Union.apply(dst, src), dst.max(src));
|
||||
assert_eq!(Join::Subtract.apply(dst, src), dst * (1.0 - src));
|
||||
let meet = Join::Intersect.apply(dst, src);
|
||||
assert_eq!(meet, dst * src);
|
||||
assert!(meet <= dst.min(src), "never more than either side");
|
||||
if dst == 0.0 || dst == 1.0 || src == 0.0 || src == 1.0 {
|
||||
assert_eq!(meet, dst.min(src), "the minimum where either is decided");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored index names a place in [`Join::ALL`], and a sidecar names a
|
||||
/// join by word: both have to survive a third join arriving, which means
|
||||
/// the first two keep their places and every name reads back as itself.
|
||||
#[test]
|
||||
fn every_join_reads_back_by_name_and_keeps_its_place() {
|
||||
assert_eq!(Join::ALL[0], Join::Union);
|
||||
assert_eq!(Join::ALL[1], Join::Subtract);
|
||||
for join in Join::ALL {
|
||||
assert_eq!(Join::from_name(join.name()), Some(join));
|
||||
}
|
||||
assert_eq!(Join::from_name("intersect"), Some(Join::Intersect));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// An intersection can empty a mask the parts before it filled: a range
|
||||
/// meeting an unpainted brush selects nothing, and rasterising it would
|
||||
/// spend a slice to draw an empty field. Painting the brush, or inverting
|
||||
/// it, gives the intersection something to keep.
|
||||
#[test]
|
||||
fn an_intersection_with_nothing_covers_nothing() {
|
||||
let mut layer = MaskLayer::new("m1", MaskSource::highlights());
|
||||
layer.set_param("exposure", ParamId("exposure"), 1.0);
|
||||
layer.push_part(MaskPart::painted("p2", Join::Intersect));
|
||||
assert!(
|
||||
!layer.is_active(),
|
||||
"a range intersected with an unpainted brush selects nothing"
|
||||
);
|
||||
|
||||
layer.part_mut(1).expect("p2").invert = true;
|
||||
assert!(layer.is_active(), "inverted, the empty brush is everywhere");
|
||||
layer.part_mut(1).expect("p2").invert = false;
|
||||
|
||||
layer.begin_stroke(1, false, 0.1, 0.5, 1.0);
|
||||
layer.extend_stroke(1, 0.5, 0.5);
|
||||
layer.end_stroke(1);
|
||||
assert!(layer.is_active(), "and painted, it keeps what it covers");
|
||||
|
||||
layer.part_mut(1).expect("p2").hidden = true;
|
||||
assert!(layer.is_active(), "hidden, it is out of the build");
|
||||
}
|
||||
|
||||
/// One stale part is a stale layer: the mask is the fold over all of them,
|
||||
/// so a part that would draw a confidently wrong shape makes the result
|
||||
/// wrong whichever way it joins.
|
||||
|
||||
@@ -54,7 +54,7 @@ pub enum Affects {
|
||||
/// A pixel's *neighbourhood* — sharpening, noise reduction, clarity,
|
||||
/// texture, dehaze, spot removal.
|
||||
///
|
||||
/// The seam `docs/requirements.md` §3.3 designed and nothing cut until
|
||||
/// The seam `docs/dev/requirements.md` §3.3 designed and nothing cut until
|
||||
/// [`crate::detail`] existed. It is a separate variant rather than a flavour
|
||||
/// of `Colour` because it is a separate *dispatch*: a fragment in the fused
|
||||
/// pass is handed a colour and has no way back to a coordinate, so a
|
||||
@@ -801,7 +801,11 @@ fn compose_inner(
|
||||
\x20 // angle as sin/cos — a trig call per pixel would recompute a\n\
|
||||
\x20 // value that is constant across the dispatch.\n\
|
||||
\x20 crop_rect: vec4<f32>,\n\
|
||||
\x20 framing_angle: vec4<f32>,\n",
|
||||
\x20 framing_angle: vec4<f32>,\n\
|
||||
\x20 // The perspective map (FR-DEV-20) by columns, `.w` unused.\n\
|
||||
\x20 keystone_c0: vec4<f32>,\n\
|
||||
\x20 keystone_c1: vec4<f32>,\n\
|
||||
\x20 keystone_c2: vec4<f32>,\n",
|
||||
);
|
||||
uniform_values.extend_from_slice(&framing.uniforms());
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
//! A minimum over a patch is separable, as a Gaussian is: minimum along x,
|
||||
//! then along y. That alone is not enough. The patch is 1% of the shorter edge
|
||||
//! — 61 taps across at 4K — and two passes of 61 taps is the arithmetic that
|
||||
//! measured 34 ms for clarity and became `docs/technical-debt.md` TD-4.
|
||||
//! measured 34 ms for clarity and became `docs/dev/technical-debt.md` TD-4.
|
||||
//!
|
||||
//! A minimum has a property a Gaussian does not: **erosions compose by adding
|
||||
//! their structuring elements**. The minimum over a contiguous run of `d`
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
//! the artefact this control must not have.
|
||||
//!
|
||||
//! Run at the render size, that measured **34 ms at 4K** — seven times the
|
||||
//! entire fused point chain, for one slider — which is `docs/technical-debt.md`
|
||||
//! entire fused point chain, for one slider — which is `docs/dev/technical-debt.md`
|
||||
//! TD-4 and is what [`Recipe::base_scale`] now answers. The base is computed on
|
||||
//! a grid a quarter the size on each axis: a sixteenth of the pixels at a
|
||||
//! quarter of the radius.
|
||||
@@ -271,7 +271,7 @@ impl Band for Coarse {
|
||||
threshold: 0.35,
|
||||
gain: 1.0,
|
||||
midtone_taper: true,
|
||||
// A quarter, which is what `docs/technical-debt.md` TD-4 bought back.
|
||||
// A quarter, which is what `docs/dev/technical-debt.md` TD-4 bought back.
|
||||
//
|
||||
// σ is 1.2% of the shorter edge — 26 px at 4K — so the base holds no
|
||||
// spatial frequency anywhere near the quarter-scale Nyquist of one
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
//! TRACES: FR-DEV-17
|
||||
//! Whether a crop leaves a mask layer's work outside the frame.
|
||||
//!
|
||||
//! # Why this is a question worth asking
|
||||
//!
|
||||
//! Mask geometry is stored in normalised *source* coordinates (see
|
||||
//! [`MaskSource::Linear`] and [`Stroke::points`]), so a tighter crop never
|
||||
//! destroys a layer. It makes it invisible — which is worse, because nothing
|
||||
//! announces it. The layer is still in the panel, still in the sidecar, still
|
||||
//! costing a rasterisation, and its adjustment lands on pixels nobody will
|
||||
//! ever see. The crop that did that is exactly the kind of edit made early
|
||||
//! and quickly, and the loss is found, if at all, much later.
|
||||
//!
|
||||
//! This module answers one question on the CPU, cheaply enough to ask once
|
||||
//! per committed crop: *which layers did this change of crop take out of the
|
||||
//! picture?* The interface turns the answer into a notice. It never refuses
|
||||
//! the crop — the photographer may well mean it.
|
||||
//!
|
||||
//! # How it is measured
|
||||
//!
|
||||
//! Each layer's mask is sampled on a [`GRID`]×[`GRID`] lattice over the
|
||||
//! source, with the same geometry the mask shader uses (`mask.wgsl`), folded
|
||||
//! part by part with the layer's joins and inversions. The sample points are
|
||||
//! then mapped through the framing — crop, straighten, turns and flips — into
|
||||
//! the frame, and the layer's *share inside* is the coverage that lands in
|
||||
//! the crop over the coverage there is. A layer is hidden by a crop when that
|
||||
//! share falls below [`HIDDEN_SHARE`] and was not already below it.
|
||||
//!
|
||||
//! The lattice is coarse on purpose. The question is "is this layer mostly
|
||||
//! gone", not "which pixels are": the edge treatment — feather, morphology —
|
||||
//! moves a boundary by a few hundredths of the frame and cannot turn a layer
|
||||
//! that is mostly inside into one that is mostly outside, so it is left out.
|
||||
//!
|
||||
//! # What cannot be orphaned
|
||||
//!
|
||||
//! A range ([`MaskSource::Luminance`], [`MaskSource::Colour`]) selects by a
|
||||
//! property of the picture, so wherever the crop falls it selects whatever
|
||||
//! part of the picture remains — there is nothing for a crop to strand. A
|
||||
//! region selection needs the segmentation's label map, which this crate does
|
||||
//! not hold, and a model's selection with no raster to hand has nothing to
|
||||
//! measure. A layer that *adds* any of these is therefore never reported: a
|
||||
//! false alarm on the common path would teach the photographer to dismiss the
|
||||
//! notice unread, which is the failure it exists to prevent. Subtracting one
|
||||
//! is ignored, which can only make the layer look larger — the safe side.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::framing::{CropRect, Framing};
|
||||
use crate::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack, Stroke};
|
||||
|
||||
/// Samples per axis over the source.
|
||||
///
|
||||
/// 4096 points: enough that a brush dab a twentieth of the frame across is
|
||||
/// several samples wide, and few enough that the whole stack is measured in
|
||||
/// well under a frame when a crop is let go.
|
||||
pub const GRID: usize = 64;
|
||||
|
||||
/// The share of a layer's coverage below which it counts as cropped away.
|
||||
///
|
||||
/// "Mostly outside" rather than "entirely": a gradient reduced to a sliver
|
||||
/// along one edge, or a subject of which one elbow survives, has lost the
|
||||
/// work as surely as one that is wholly gone. Low enough that an ordinary
|
||||
/// recomposition which trims part of a subject is not reported.
|
||||
pub const HIDDEN_SHARE: f32 = 0.1;
|
||||
|
||||
/// A model's selection, as bytes over the source at some proxy size.
|
||||
///
|
||||
/// Supplied by the caller for a part whose own [`MaskPart::coverage`] is not
|
||||
/// set — the live session holds its model output outside the graph and folds
|
||||
/// it into the parts only when saving.
|
||||
pub struct Raster<'a> {
|
||||
pub values: Cow<'a, [u8]>,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
/// One layer's coverage, sampled over the source.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Footprint {
|
||||
/// Row-major over the lattice, `0.0..=1.0`, one per cell centre.
|
||||
weights: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Footprint {
|
||||
/// The layer's coverage on the lattice, or `None` when it has none to
|
||||
/// strand — see the module header for which layers those are.
|
||||
///
|
||||
/// `model` is asked for the raster behind a subject or category part that
|
||||
/// carries none of its own.
|
||||
pub fn of<'r>(
|
||||
layer: &MaskLayer,
|
||||
source: (u32, u32),
|
||||
model: &dyn Fn(&MaskPart) -> Option<Raster<'r>>,
|
||||
) -> Option<Self> {
|
||||
let mut acc: Option<Vec<f32>> = None;
|
||||
for (i, part) in layer.shown_parts().enumerate() {
|
||||
let adds = i == 0 || part.join == Join::Union;
|
||||
let Some(values) = part_values(part, source, model) else {
|
||||
if adds {
|
||||
// It follows the picture, or cannot be measured: either
|
||||
// way it is not a shape a crop can leave behind.
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
acc = Some(match acc {
|
||||
None => values,
|
||||
Some(mut a) => {
|
||||
for (d, s) in a.iter_mut().zip(values) {
|
||||
*d = part.join.apply(*d, s);
|
||||
}
|
||||
a
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut weights = acc?;
|
||||
if layer.invert {
|
||||
weights.iter_mut().for_each(|w| *w = 1.0 - *w);
|
||||
}
|
||||
Some(Self { weights })
|
||||
}
|
||||
|
||||
/// Of this layer's coverage, the share `inside` marks as in frame.
|
||||
/// `None` when there is no coverage at all.
|
||||
fn share(&self, inside: &[bool]) -> Option<f32> {
|
||||
let (mut total, mut kept) = (0.0f32, 0.0f32);
|
||||
for (w, &i) in self.weights.iter().zip(inside) {
|
||||
total += w;
|
||||
if i {
|
||||
kept += w;
|
||||
}
|
||||
}
|
||||
(total > 1e-3).then(|| kept / total)
|
||||
}
|
||||
|
||||
/// Of this layer's coverage, the share `framing`'s crop keeps.
|
||||
pub fn share_inside(&self, framing: &Framing, source: (u32, u32)) -> Option<f32> {
|
||||
self.share(&in_frame(framing, source))
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-17
|
||||
/// The layers that changing the framing from `before` to `after` took out of
|
||||
/// the picture, in stack order.
|
||||
///
|
||||
/// Only those it *newly* hid: a layer already cropped away by `before` is not
|
||||
/// reported again, or every later adjustment of the crop would repeat a notice
|
||||
/// the photographer has already answered.
|
||||
///
|
||||
/// The view (zoom and pan) of either framing is ignored. It is a way of
|
||||
/// looking, not the frame.
|
||||
pub fn hidden_by_crop<'m, 'r>(
|
||||
masks: &'m MaskStack,
|
||||
before: &Framing,
|
||||
after: &Framing,
|
||||
source: (u32, u32),
|
||||
model: &dyn Fn(&MaskPart) -> Option<Raster<'r>>,
|
||||
) -> Vec<&'m MaskLayer> {
|
||||
if masks.is_empty() || framed_alike(before, after) {
|
||||
return Vec::new();
|
||||
}
|
||||
let was = in_frame(before, source);
|
||||
let now = in_frame(after, source);
|
||||
masks
|
||||
.layers()
|
||||
.iter()
|
||||
.filter(|layer| {
|
||||
let Some(print) = Footprint::of(layer, source, model) else {
|
||||
return false;
|
||||
};
|
||||
let (Some(was), Some(now)) = (print.share(&was), print.share(&now)) else {
|
||||
return false;
|
||||
};
|
||||
was >= HIDDEN_SHARE && now < HIDDEN_SHARE
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the two frame the same part of the source, zoom aside.
|
||||
fn framed_alike(a: &Framing, b: &Framing) -> bool {
|
||||
let mut a = *a;
|
||||
let mut b = *b;
|
||||
a.set_view(CropRect::default());
|
||||
b.set_view(CropRect::default());
|
||||
a == b
|
||||
}
|
||||
|
||||
/// Which lattice cells the framing's crop keeps.
|
||||
fn in_frame(framing: &Framing, source: (u32, u32)) -> Vec<bool> {
|
||||
let mut f = *framing;
|
||||
f.set_view(CropRect::default());
|
||||
lattice()
|
||||
.map(|uv| {
|
||||
let (x, y) = f.output_at(uv, source.0, source.1);
|
||||
(0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Cell centres, row-major, in normalised source coordinates.
|
||||
fn lattice() -> impl Iterator<Item = (f32, f32)> {
|
||||
let step = 1.0 / GRID as f32;
|
||||
(0..GRID).flat_map(move |y| {
|
||||
(0..GRID).map(move |x| ((x as f32 + 0.5) * step, (y as f32 + 0.5) * step))
|
||||
})
|
||||
}
|
||||
|
||||
/// One part's coverage on the lattice, its own inversion applied, or `None`
|
||||
/// where it cannot be measured as a shape.
|
||||
fn part_values<'r>(
|
||||
part: &MaskPart,
|
||||
source: (u32, u32),
|
||||
model: &dyn Fn(&MaskPart) -> Option<Raster<'r>>,
|
||||
) -> Option<Vec<f32>> {
|
||||
let aspect = source.0.max(1) as f32 / source.1.max(1) as f32;
|
||||
let mut values: Vec<f32> = match &part.source {
|
||||
MaskSource::Linear {
|
||||
centre,
|
||||
angle,
|
||||
width,
|
||||
} => {
|
||||
let axis = (angle.cos(), angle.sin());
|
||||
lattice()
|
||||
.map(|(u, v)| {
|
||||
let d = (u - centre.0) * aspect * axis.0 + (v - centre.1) * axis.1;
|
||||
if *width <= 0.0 {
|
||||
if d >= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
smoothstep(-width * 0.5, width * 0.5, d)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
MaskSource::Radial {
|
||||
centre,
|
||||
radii,
|
||||
angle,
|
||||
feather,
|
||||
} => {
|
||||
let (sa, ca) = (-angle).sin_cos();
|
||||
let radii = (radii.0.max(1e-6), radii.1.max(1e-6));
|
||||
let edge = feather.clamp(0.0, 1.0);
|
||||
lattice()
|
||||
.map(|(u, v)| {
|
||||
let d = ((u - centre.0) * aspect, v - centre.1);
|
||||
let local = (d.0 * ca - d.1 * sa, d.0 * sa + d.1 * ca);
|
||||
let r = (local.0 / radii.0).hypot(local.1 / radii.1);
|
||||
if edge <= 0.0 {
|
||||
if r <= 1.0 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
1.0 - smoothstep(1.0 - edge, 1.0, r)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
MaskSource::Brush { strokes } => brush_values(strokes, source),
|
||||
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
|
||||
if let Some(coverage) = &part.coverage {
|
||||
// The lattice is a regular grid over the source, which is
|
||||
// exactly the resample `decode_at` does.
|
||||
coverage
|
||||
.decode_at(GRID, GRID)
|
||||
.into_iter()
|
||||
.map(|b| f32::from(b) / 255.0)
|
||||
.collect()
|
||||
} else {
|
||||
let raster = model(part)?;
|
||||
if raster.width == 0
|
||||
|| raster.height == 0
|
||||
|| raster.values.len() < raster.width * raster.height
|
||||
{
|
||||
return None;
|
||||
}
|
||||
lattice()
|
||||
.map(|(u, v)| {
|
||||
let x = ((u * raster.width as f32) as usize).min(raster.width - 1);
|
||||
let y = ((v * raster.height as f32) as usize).min(raster.height - 1);
|
||||
f32::from(raster.values[y * raster.width + x]) / 255.0
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
MaskSource::Regions { .. } | MaskSource::Luminance { .. } | MaskSource::Colour { .. } => {
|
||||
return None
|
||||
}
|
||||
};
|
||||
if part.invert {
|
||||
values.iter_mut().for_each(|w| *w = 1.0 - *w);
|
||||
}
|
||||
Some(values)
|
||||
}
|
||||
|
||||
/// Strokes composited in order, the way `fs_brush` and its blend states do.
|
||||
fn brush_values(strokes: &[Stroke], source: (u32, u32)) -> Vec<f32> {
|
||||
// Into units of the shorter edge, as `to_square` does, so a dab is round.
|
||||
let short = source.0.min(source.1).max(1) as f32;
|
||||
let scale = (
|
||||
source.0.max(1) as f32 / short,
|
||||
source.1.max(1) as f32 / short,
|
||||
);
|
||||
let square = |p: (f32, f32)| (p.0 * scale.0, p.1 * scale.1);
|
||||
|
||||
let cells: Vec<(f32, f32)> = lattice().map(square).collect();
|
||||
let mut out = vec![0.0f32; cells.len()];
|
||||
for stroke in strokes.iter().filter(|s| !s.is_empty()) {
|
||||
let points: Vec<(f32, f32)> = stroke.points.iter().copied().map(square).collect();
|
||||
let r = stroke.radius;
|
||||
let inner = r * stroke.hardness.clamp(0.0, 1.0);
|
||||
// Only cells inside the stroke's bounding box can be reached.
|
||||
let (lo, hi) = points.iter().fold(
|
||||
((f32::MAX, f32::MAX), (f32::MIN, f32::MIN)),
|
||||
|(lo, hi), p| {
|
||||
(
|
||||
(lo.0.min(p.0), lo.1.min(p.1)),
|
||||
(hi.0.max(p.0), hi.1.max(p.1)),
|
||||
)
|
||||
},
|
||||
);
|
||||
for (cell, dst) in cells.iter().zip(out.iter_mut()) {
|
||||
if cell.0 < lo.0 - r || cell.0 > hi.0 + r || cell.1 < lo.1 - r || cell.1 > hi.1 + r {
|
||||
continue;
|
||||
}
|
||||
let d = if points.len() == 1 {
|
||||
dist(*cell, points[0])
|
||||
} else {
|
||||
points
|
||||
.windows(2)
|
||||
.map(|s| segment_distance(*cell, s[0], s[1]))
|
||||
.fold(f32::MAX, f32::min)
|
||||
};
|
||||
let c = ((1.0 - smoothstep(inner, r, d)) * stroke.flow).clamp(0.0, 1.0);
|
||||
*dst = if stroke.erase {
|
||||
*dst * (1.0 - c)
|
||||
} else {
|
||||
*dst + c - *dst * c
|
||||
};
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn dist(a: (f32, f32), b: (f32, f32)) -> f32 {
|
||||
(a.0 - b.0).hypot(a.1 - b.1)
|
||||
}
|
||||
|
||||
fn segment_distance(q: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
|
||||
let ab = (b.0 - a.0, b.1 - a.1);
|
||||
let len2 = ab.0 * ab.0 + ab.1 * ab.1;
|
||||
if len2 <= 1e-12 {
|
||||
return dist(q, a);
|
||||
}
|
||||
let t = (((q.0 - a.0) * ab.0 + (q.1 - a.1) * ab.1) / len2).clamp(0.0, 1.0);
|
||||
dist(q, (a.0 + ab.0 * t, a.1 + ab.1 * t))
|
||||
}
|
||||
|
||||
/// WGSL's `smoothstep`, including its behaviour when the edges meet.
|
||||
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
if e1 <= e0 {
|
||||
return if x < e0 { 0.0 } else { 1.0 };
|
||||
}
|
||||
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::coverage::Coverage;
|
||||
|
||||
const SOURCE: (u32, u32) = (3000, 2000);
|
||||
|
||||
fn no_model(_: &MaskPart) -> Option<Raster<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn cropped(x: f32, y: f32, w: f32, h: f32) -> Framing {
|
||||
let mut f = Framing::new();
|
||||
f.set_crop(CropRect {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
});
|
||||
f
|
||||
}
|
||||
|
||||
/// A small circle near the source's top-left corner.
|
||||
fn top_left_circle() -> MaskLayer {
|
||||
MaskLayer::new(
|
||||
"m1",
|
||||
MaskSource::Radial {
|
||||
centre: (0.15, 0.15),
|
||||
radii: (0.08, 0.08),
|
||||
angle: 0.0,
|
||||
feather: 0.2,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn stack(layers: Vec<MaskLayer>) -> MaskStack {
|
||||
let mut s = MaskStack::new();
|
||||
for l in layers {
|
||||
assert!(s.push(l));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn hidden(masks: &MaskStack, before: &Framing, after: &Framing) -> Vec<String> {
|
||||
hidden_by_crop(masks, before, after, SOURCE, &no_model)
|
||||
.into_iter()
|
||||
.map(|l| l.id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_away_from_a_shape_hides_it() {
|
||||
let masks = stack(vec![top_left_circle()]);
|
||||
let after = cropped(0.5, 0.5, 0.5, 0.5);
|
||||
assert_eq!(hidden(&masks, &Framing::new(), &after), vec!["m1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_that_keeps_the_shape_is_silent() {
|
||||
let masks = stack(vec![top_left_circle()]);
|
||||
let after = cropped(0.0, 0.0, 0.6, 0.6);
|
||||
assert!(hidden(&masks, &Framing::new(), &after).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_that_trims_part_of_a_shape_is_silent() {
|
||||
// Half the circle survives, which is a recomposition, not a loss.
|
||||
let masks = stack(vec![top_left_circle()]);
|
||||
let after = cropped(0.15, 0.0, 0.85, 1.0);
|
||||
assert!(hidden(&masks, &Framing::new(), &after).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_already_cropped_away_is_not_reported_again() {
|
||||
let masks = stack(vec![top_left_circle()]);
|
||||
let before = cropped(0.5, 0.5, 0.5, 0.5);
|
||||
let after = cropped(0.6, 0.6, 0.4, 0.4);
|
||||
assert!(hidden(&masks, &before, &after).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncropping_brings_a_layer_back_and_says_nothing() {
|
||||
let masks = stack(vec![top_left_circle()]);
|
||||
let before = cropped(0.5, 0.5, 0.5, 0.5);
|
||||
assert!(hidden(&masks, &before, &Framing::new()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_frame_is_silent_whatever_the_zoom() {
|
||||
let masks = stack(vec![top_left_circle()]);
|
||||
let before = cropped(0.5, 0.5, 0.5, 0.5);
|
||||
let mut after = before;
|
||||
after.set_view(CropRect {
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
assert!(hidden(&masks, &Framing::new(), &after).len() == 1);
|
||||
assert!(hidden(&masks, &before, &after).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_follows_the_picture_and_is_never_stranded() {
|
||||
let masks = stack(vec![
|
||||
MaskLayer::new("lum", MaskSource::highlights()),
|
||||
MaskLayer::new("skin", MaskSource::skin_tones()),
|
||||
]);
|
||||
let after = cropped(0.9, 0.9, 0.1, 0.1);
|
||||
assert!(hidden(&masks, &Framing::new(), &after).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linear_gradient_over_the_bottom_is_hidden_by_keeping_the_top() {
|
||||
let masks = stack(vec![MaskLayer::new(
|
||||
"grad",
|
||||
MaskSource::Linear {
|
||||
centre: (0.5, 0.8),
|
||||
angle: std::f32::consts::FRAC_PI_2,
|
||||
width: 0.05,
|
||||
},
|
||||
)]);
|
||||
assert_eq!(
|
||||
hidden(&masks, &Framing::new(), &cropped(0.0, 0.0, 1.0, 0.5)),
|
||||
vec!["grad"]
|
||||
);
|
||||
assert!(hidden(&masks, &Framing::new(), &cropped(0.0, 0.5, 1.0, 0.5)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_painted_stroke_is_measured_where_it_was_painted() {
|
||||
let mut layer = MaskLayer::new("paint", MaskSource::brush());
|
||||
layer.begin_stroke(0, false, 0.05, 0.8, 1.0);
|
||||
for i in 0..10 {
|
||||
layer.extend_stroke(0, 0.8 + i as f32 * 0.01, 0.8);
|
||||
}
|
||||
layer.end_stroke(0);
|
||||
let masks = stack(vec![layer]);
|
||||
assert_eq!(
|
||||
hidden(&masks, &Framing::new(), &cropped(0.0, 0.0, 0.5, 0.5)),
|
||||
vec!["paint"]
|
||||
);
|
||||
assert!(hidden(&masks, &Framing::new(), &cropped(0.5, 0.5, 0.5, 0.5)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unpainted_brush_has_nothing_to_lose() {
|
||||
let masks = stack(vec![MaskLayer::new("empty", MaskSource::brush())]);
|
||||
assert!(hidden(&masks, &Framing::new(), &cropped(0.0, 0.0, 0.3, 0.3)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inverted_layer_is_measured_as_what_it_selects() {
|
||||
// Everything *but* a corner circle: a crop into the other corner
|
||||
// keeps most of it.
|
||||
let mut layer = top_left_circle();
|
||||
layer.invert = true;
|
||||
let masks = stack(vec![layer]);
|
||||
assert!(hidden(&masks, &Framing::new(), &cropped(0.5, 0.5, 0.5, 0.5)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subject_is_measured_from_its_stored_coverage_or_the_model() {
|
||||
// A subject occupying the right-hand quarter of a 40x20 proxy.
|
||||
let (w, h) = (40usize, 20usize);
|
||||
let bytes: Vec<u8> = (0..w * h)
|
||||
.map(|i| if i % w >= 30 { 255 } else { 0 })
|
||||
.collect();
|
||||
let subject = MaskSource::Subject {
|
||||
signature: 1,
|
||||
index: 0,
|
||||
class: "dog".into(),
|
||||
score: 0.9,
|
||||
};
|
||||
let left = cropped(0.0, 0.0, 0.5, 1.0);
|
||||
|
||||
let mut stored = MaskLayer::new("stored", subject.clone());
|
||||
stored.base_mut().coverage = Coverage::encode(&bytes, w, h, 2).map(Arc::new);
|
||||
let live = MaskLayer::new("live", subject);
|
||||
let masks = stack(vec![stored, live]);
|
||||
|
||||
// No model to hand: only the layer carrying its raster is measured.
|
||||
assert_eq!(hidden(&masks, &Framing::new(), &left), vec!["stored"]);
|
||||
|
||||
let model = |_: &MaskPart| {
|
||||
Some(Raster {
|
||||
values: Cow::Borrowed(bytes.as_slice()),
|
||||
width: w,
|
||||
height: h,
|
||||
})
|
||||
};
|
||||
let ids: Vec<_> = hidden_by_crop(&masks, &Framing::new(), &left, SOURCE, &model)
|
||||
.into_iter()
|
||||
.map(|l| l.id.as_str())
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["stored", "live"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_is_read_in_the_turned_frame() {
|
||||
// Turned a quarter clockwise, the source's top-left lands at the
|
||||
// frame's top-right, so keeping the right half of the frame keeps it.
|
||||
let masks = stack(vec![top_left_circle()]);
|
||||
let mut before = Framing::new();
|
||||
before.rotate_quarters(1);
|
||||
let mut right = before;
|
||||
right.set_crop(CropRect {
|
||||
x: 0.5,
|
||||
y: 0.0,
|
||||
width: 0.5,
|
||||
height: 1.0,
|
||||
});
|
||||
let mut left = before;
|
||||
left.set_crop(CropRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 0.5,
|
||||
height: 1.0,
|
||||
});
|
||||
assert!(hidden(&masks, &before, &right).is_empty());
|
||||
assert_eq!(hidden(&masks, &before, &left), vec!["m1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtracted_range_does_not_hide_the_shape_it_cuts() {
|
||||
let mut layer = top_left_circle();
|
||||
assert!(layer.push_part(MaskPart::new(
|
||||
"p2",
|
||||
Join::Subtract,
|
||||
MaskSource::highlights()
|
||||
)));
|
||||
let masks = stack(vec![layer.clone()]);
|
||||
assert_eq!(
|
||||
hidden(&masks, &Framing::new(), &cropped(0.5, 0.5, 0.5, 0.5)),
|
||||
vec!["m1"]
|
||||
);
|
||||
// Added instead, the range reaches everywhere and nothing is lost.
|
||||
let mut layer = top_left_circle();
|
||||
assert!(layer.push_part(MaskPart::new("p2", Join::Union, MaskSource::highlights())));
|
||||
let masks = stack(vec![layer]);
|
||||
assert!(hidden(&masks, &Framing::new(), &cropped(0.5, 0.5, 0.5, 0.5)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_intersection_keeps_only_what_both_parts_cover() {
|
||||
let circle = |id: &str, join, centre, r| {
|
||||
MaskPart::new(
|
||||
id,
|
||||
join,
|
||||
MaskSource::Radial {
|
||||
centre,
|
||||
radii: (r, r),
|
||||
angle: 0.0,
|
||||
feather: 0.2,
|
||||
},
|
||||
)
|
||||
};
|
||||
// Two circles in opposite corners: keeping the bottom-right quarter
|
||||
// keeps one of them, and nothing is lost.
|
||||
let mut layer = top_left_circle();
|
||||
assert!(layer.push_part(circle("p2", Join::Union, (0.85, 0.85), 0.08)));
|
||||
let bottom_right = cropped(0.5, 0.5, 0.5, 0.5);
|
||||
let masks = stack(vec![layer.clone()]);
|
||||
assert!(hidden(&masks, &Framing::new(), &bottom_right).is_empty());
|
||||
// Intersected with a disc around the top-left, only that corner's
|
||||
// circle survives, and the same crop takes it out of the frame.
|
||||
assert!(layer.push_part(circle("p3", Join::Intersect, (0.15, 0.15), 0.3)));
|
||||
let masks = stack(vec![layer]);
|
||||
assert_eq!(hidden(&masks, &Framing::new(), &bottom_right), vec!["m1"]);
|
||||
}
|
||||
}
|
||||
@@ -725,6 +725,7 @@ mod tests {
|
||||
height: 0.5,
|
||||
});
|
||||
g.set_param(framing::ID, framing::ANGLE, -2.0);
|
||||
g.set_param(framing::ID, framing::KEYSTONE_V, 40.0);
|
||||
g
|
||||
}
|
||||
|
||||
@@ -820,6 +821,14 @@ mod tests {
|
||||
Some(0.0),
|
||||
"the source's straightening must not travel on this scope"
|
||||
);
|
||||
// TRACES: FR-DEV-20
|
||||
// Perspective is composition on the same terms as the crop: it is
|
||||
// withheld by the default scope, not pasted over the target's frame.
|
||||
assert_eq!(
|
||||
target.param(framing::ID, framing::KEYSTONE_V),
|
||||
Some(0.0),
|
||||
"the source's keystone must not travel on this scope"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -829,6 +838,7 @@ mod tests {
|
||||
preset.apply(&mut target, Scope::everything());
|
||||
|
||||
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
|
||||
assert_eq!(target.param(framing::ID, framing::KEYSTONE_V), Some(40.0));
|
||||
assert!(
|
||||
(target.crop().width - 0.5).abs() < 1e-5,
|
||||
"{:?}",
|
||||
|
||||
@@ -93,6 +93,9 @@ pub const MAX_RATING: u8 = 5;
|
||||
/// Highest flag code: 0 unflagged, 1 pick, 2 reject.
|
||||
pub const MAX_FLAG: u8 = 2;
|
||||
|
||||
/// Highest colour-label code: purple. Mirrors `dr_catalog::rating::label_code`.
|
||||
pub const MAX_LABEL: u8 = 5;
|
||||
|
||||
/// TRACES: FR-CAT-8 | FR-NC-8
|
||||
/// One image's sidecar: a keyed set of versions.
|
||||
///
|
||||
@@ -187,6 +190,17 @@ pub struct Version {
|
||||
/// so a value moving between the two stores needs no translation table
|
||||
/// that could drift.
|
||||
pub flag: u8,
|
||||
/// TRACES: FR-CAT-5
|
||||
/// The colour label, `0` for none and `1..=5` red, yellow, green, blue,
|
||||
/// purple — the catalog's `versions.label` codes, for the reason
|
||||
/// [`Self::flag`] shares its encoding.
|
||||
///
|
||||
/// Here for the reason the rating is: a label that lived only in the
|
||||
/// catalog would go with the catalog, and would never reach the
|
||||
/// photographer's other devices, which learn judgements from this file.
|
||||
/// A build that predates the key keeps it as an unknown line and writes
|
||||
/// it back, so an older device passes it on rather than erasing it.
|
||||
pub label: u8,
|
||||
/// The edit itself: `(op, param) -> value`, non-default values only.
|
||||
pub params: BTreeMap<(String, String), f32>,
|
||||
/// TRACES: FR-DEV-3 | FR-NC-9
|
||||
@@ -217,7 +231,7 @@ pub struct Version {
|
||||
/// 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`).
|
||||
/// The repairs (`docs/dev/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
|
||||
@@ -264,6 +278,7 @@ impl Version {
|
||||
// it in the "not yet looked at" state a cull resumes from.
|
||||
rating: 0,
|
||||
flag: 0,
|
||||
label: 0,
|
||||
params,
|
||||
masks,
|
||||
film,
|
||||
@@ -572,6 +587,10 @@ impl Version {
|
||||
// that never had it.
|
||||
self.rating = merge_judgement(self.rating, remote.rating, remote_wins);
|
||||
self.flag = merge_judgement(self.flag, remote.flag, remote_wins);
|
||||
// TRACES: FR-CAT-5
|
||||
// A label under the same rule: 0 is "none given", so a device that
|
||||
// never labelled a frame cannot clear another's label.
|
||||
self.label = merge_judgement(self.label, remote.label, remote_wins);
|
||||
|
||||
// TRACES: FR-DEV-3f
|
||||
// The film resolves wholesale to the higher revision, like a mask
|
||||
@@ -873,6 +892,9 @@ impl Sidecar {
|
||||
if v.flag > 0 {
|
||||
let _ = writeln!(out, "flag = {}", v.flag);
|
||||
}
|
||||
if v.label > 0 {
|
||||
let _ = writeln!(out, "label = {}", v.label);
|
||||
}
|
||||
// TRACES: FR-DEV-3f
|
||||
// Before the parameters, because it decides what they mean: the
|
||||
// film's exposure slider is a slider on *that stock's* curve.
|
||||
@@ -1068,6 +1090,17 @@ impl Sidecar {
|
||||
Some(value.to_string());
|
||||
}
|
||||
"flag" => version.flag = value.parse::<u8>().unwrap_or(0).min(MAX_FLAG),
|
||||
// TRACES: FR-CAT-5
|
||||
// A code this build does not know reads as none rather than
|
||||
// being clamped onto purple: a wrong colour is a claim, and
|
||||
// no colour is only a gap.
|
||||
"label" => {
|
||||
version.label = value
|
||||
.parse::<u8>()
|
||||
.ok()
|
||||
.filter(|l| *l <= MAX_LABEL)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
// 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
|
||||
@@ -2141,6 +2174,8 @@ mod tests {
|
||||
height: 0.6,
|
||||
});
|
||||
g.set_param(framing::ID, framing::ANGLE, -1.5);
|
||||
g.set_param(framing::ID, framing::KEYSTONE_V, 42.0);
|
||||
g.set_param(framing::ID, framing::KEYSTONE_H, -17.0);
|
||||
g.rotate_quarters(1);
|
||||
|
||||
let mut sidecar = Sidecar::new();
|
||||
@@ -2157,6 +2192,12 @@ mod tests {
|
||||
assert_eq!(restored.crop(), g.crop());
|
||||
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
|
||||
assert_eq!(restored.param(framing::ID, framing::ROTATION), Some(1.0));
|
||||
// TRACES: FR-DEV-20
|
||||
assert_eq!(restored.param(framing::ID, framing::KEYSTONE_V), Some(42.0));
|
||||
assert_eq!(
|
||||
restored.param(framing::ID, framing::KEYSTONE_H),
|
||||
Some(-17.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2828,6 +2869,37 @@ mod tests {
|
||||
assert_eq!(back.flag, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_survives_the_round_trip_and_an_unknown_one_reads_as_none() {
|
||||
// TRACES: FR-CAT-5
|
||||
let mut v = version_of(&EditGraph::default_chain());
|
||||
v.label = 3;
|
||||
let mut sidecar = Sidecar::new();
|
||||
sidecar.put(v);
|
||||
let text = sidecar.to_text();
|
||||
assert!(text.contains("label = 3"), "{text}");
|
||||
let parsed = Sidecar::parse(&text).expect("valid");
|
||||
assert_eq!(parsed.default_version().expect("a version").label, 3);
|
||||
|
||||
let odd = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
||||
label = 9\n";
|
||||
let parsed = Sidecar::parse(odd).expect("valid");
|
||||
assert_eq!(parsed.default_version().expect("a version").label, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlabelled_device_cannot_clear_another_devices_label() {
|
||||
// TRACES: FR-CAT-5
|
||||
let mut local = version_of(&EditGraph::default_chain());
|
||||
local.label = 0;
|
||||
local.revision = 9;
|
||||
let mut remote = version_of(&EditGraph::default_chain());
|
||||
remote.label = 1;
|
||||
remote.revision = 1;
|
||||
local.merge(&remote, None);
|
||||
assert_eq!(local.label, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrated_image_writes_no_judgement_lines() {
|
||||
// The non-default rule applied to judgement: a library that has never
|
||||
@@ -2838,6 +2910,7 @@ mod tests {
|
||||
let text = sidecar.to_text();
|
||||
assert!(!text.contains("rating"), "{text}");
|
||||
assert!(!text.contains("flag"), "{text}");
|
||||
assert!(!text.contains("label"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! 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`).
|
||||
//! (`docs/dev/spot-removal.md`).
|
||||
//!
|
||||
//! # Why this is not an operation
|
||||
//!
|
||||
@@ -61,7 +61,7 @@ pub const MAX_SPOTS: usize = 64;
|
||||
/// 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).
|
||||
/// tile scheduler cannot plan (ARCH §5.3, `docs/dev/spot-removal.md` §5.3).
|
||||
pub const MAX_SOURCE_DISTANCE: f32 = 0.5;
|
||||
|
||||
/// The radius a new spot starts at, in frame units.
|
||||
@@ -210,7 +210,7 @@ impl Spot {
|
||||
/// **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`
|
||||
/// one small readback when the spot is created (`docs/dev/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
|
||||
|
||||
@@ -1344,6 +1344,28 @@ fn a_correction_painted_onto_a_subject_survives_a_round_trip() {
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// An intersecting part is written under its own word and reads back as an
|
||||
/// intersection. A build from before intersection read that word as a union
|
||||
/// (see `an_unknown_join_adds_the_part`), which keeps the part visible and
|
||||
/// fixable rather than silently cutting the mask down.
|
||||
#[test]
|
||||
fn an_intersecting_part_survives_a_round_trip() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.masks_mut().push(corrected("m1", Join::Intersect));
|
||||
|
||||
let mut sidecar = Sidecar::new();
|
||||
sidecar.put(Version::from_graph("default", "Default", &graph));
|
||||
let text = sidecar.to_text();
|
||||
assert!(text.contains("join = intersect"), "{text}");
|
||||
|
||||
let restored = round_trip(&graph);
|
||||
let layer = &restored.masks().layers()[0];
|
||||
assert_eq!(layer.parts().len(), 2);
|
||||
assert_eq!(layer.parts()[1].join, Join::Intersect);
|
||||
assert_eq!(layer.parts()[0].join, Join::Union, "the base is untouched");
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// A part left out of the build comes back left out, and the file says so
|
||||
/// under a word that cannot be confused with the layer's own switch.
|
||||
|
||||
@@ -13,7 +13,7 @@ log.workspace = true
|
||||
|
||||
# Inference. `ort` is the API; **what runs it is `dr-inference-engine`'s
|
||||
# business** — tract, or an ONNX Runtime the app found on disk, on whichever
|
||||
# provider the device has (docs/inference.md). This crate never names either.
|
||||
# provider the device has (docs/dev/inference.md). This crate never names either.
|
||||
ort = { workspace = true, optional = true }
|
||||
dr-inference-engine = { workspace = true, optional = true }
|
||||
ndarray = { workspace = true, optional = true }
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
//! And doing it here buys two things a shader could not. It is **exactly
|
||||
//! deterministic**, which matters because masks reach the sidecar as indices
|
||||
//! and a field that varied by vendor would mean a mask meaning one thing on
|
||||
//! the desktop and another on the phone (docs/segmentation.md §6, M5). And it
|
||||
//! the desktop and another on the phone (docs/dev/segmentation.md §6, M5). And it
|
||||
//! is testable against hand-computed distances with no adapter present.
|
||||
//!
|
||||
//! # The transform
|
||||
|
||||
@@ -40,7 +40,7 @@ pub struct Edge {
|
||||
|
||||
/// A partition of the image into labelled regions, plus how they adjoin.
|
||||
///
|
||||
/// The shared interface from docs/segmentation.md §2: arm A produces this
|
||||
/// The shared interface from docs/dev/segmentation.md §2: arm A produces this
|
||||
/// from a watershed, arm B would produce it from a class map, and the
|
||||
/// consumers above cannot tell which.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! TRACES: FR-DEV-3i
|
||||
//! Region segmentation for local masking (S15, docs/segmentation.md).
|
||||
//! Region segmentation for local masking (S15, docs/dev/segmentation.md).
|
||||
//!
|
||||
//! Local adjustments need to know where the image's regions are before they
|
||||
//! can snap a mask to one. This crate is that map, and it is deliberately
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Arm C — semantic instances as a prior over the watershed merge order.
|
||||
//!
|
||||
//! docs/segmentation.md §5. The spec calls this the expected winner and it is
|
||||
//! docs/dev/segmentation.md §5. The spec calls this the expected winner and it is
|
||||
//! what ships, for a reason that survives the model turning out to be narrower
|
||||
//! than §4 assumed: the two arms fail in *opposite* directions, so each one
|
||||
//! covers the other's failure.
|
||||
@@ -233,7 +233,7 @@ pub fn apply_semantic_prior(
|
||||
/// This is the interaction the whole spike exists to enable, and the reason it
|
||||
/// returns *region ids* rather than a raster: a mask that is a set of integers
|
||||
/// is diffable, mergeable at node level under FR-NC-9, and cheap in a sidecar
|
||||
/// (docs/segmentation.md §1). A raster is none of those.
|
||||
/// (docs/dev/segmentation.md §1). A raster is none of those.
|
||||
///
|
||||
/// The returned ids are sorted, so the same click always produces the same
|
||||
/// mask — which is what lets it be a cache key.
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
//! So the last step is a **marker-based watershed**. The mask is eroded to
|
||||
//! give two markers — confidently inside, confidently outside — and the flood
|
||||
//! runs in the ribbon left between them, meeting along the most expensive line
|
||||
//! it can find. The cost is a sum of terms, as docs/segmentation.md §2 says it
|
||||
//! it can find. The cost is a sum of terms, as docs/dev/segmentation.md §2 says it
|
||||
//! should be: the photograph's own edges, and the colour model's disagreement.
|
||||
//!
|
||||
//! Markers are what make this the right shape rather than the watershed §15
|
||||
@@ -244,7 +244,7 @@ pub struct RefineOptions {
|
||||
/// How much the photograph's own edges count against the colour model in
|
||||
/// the flood's cost, `0.0..=1.0`.
|
||||
///
|
||||
/// docs/segmentation.md §2 specifies the cost as *a sum of terms* — image
|
||||
/// docs/dev/segmentation.md §2 specifies the cost as *a sum of terms* — image
|
||||
/// gradient always available, semantic evidence added when a model is
|
||||
/// present — and this is the mix. At one the boundary lands purely on the
|
||||
/// strongest edge in the band; at zero purely where the colour verdict
|
||||
@@ -378,7 +378,7 @@ const MAX_SAMPLES: usize = 20_000;
|
||||
/// floating-point comparison is a stopping rule that can differ between
|
||||
/// machines, and a mask that differs between machines reaches the sidecar as
|
||||
/// indices meaning one thing on the desktop and another on the phone
|
||||
/// (docs/segmentation.md §6).
|
||||
/// (docs/dev/segmentation.md §6).
|
||||
const ITERATIONS: usize = 12;
|
||||
|
||||
/// Half-width of the verdict scale, in nats.
|
||||
@@ -676,7 +676,7 @@ impl Refinement {
|
||||
/// fronts meet along the most expensive line in the ribbon — which is the
|
||||
/// watershed, and which is where the boundary belongs.
|
||||
///
|
||||
/// The cost is a sum of terms, as docs/segmentation.md §2 says it should
|
||||
/// The cost is a sum of terms, as docs/dev/segmentation.md §2 says it should
|
||||
/// be: the photograph's own edges, and the colour model's disagreement.
|
||||
/// Neither alone is right. An edge with no colour meaning is a texture,
|
||||
/// and a colour change with no edge is a gradient.
|
||||
@@ -889,7 +889,7 @@ fn neighbours(p: usize, w: usize, h: usize) -> impl Iterator<Item = usize> {
|
||||
/// Edge strength over the opponent features, as one byte per pixel.
|
||||
///
|
||||
/// Sobel over the same three numbers the colour model is fitted on, rather
|
||||
/// than over plain luma — docs/segmentation.md §3 is explicit that a
|
||||
/// than over plain luma — docs/dev/segmentation.md §3 is explicit that a
|
||||
/// channel-weighted RGB gradient reads a saturated red edge as weaker than it
|
||||
/// looks, and a flag against sky is exactly that edge.
|
||||
///
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Semantic segmentation — arm B (S15, docs/segmentation.md §4).
|
||||
//! Semantic segmentation — arm B (S15, docs/dev/segmentation.md §4).
|
||||
//!
|
||||
//! Runs a YOLO instance-segmentation graph over a proxy-resolution image and
|
||||
//! returns the instances it found: a class, a score, a box, and a soft mask
|
||||
@@ -209,7 +209,7 @@ const EMBEDDED_MODEL: &[u8] = include_bytes!("../../../models/segment/yolo26n-se
|
||||
const EMBEDDED_CLASSES: &str = include_str!("../../../models/segment/yolo26n-seg.classes.json");
|
||||
|
||||
/// The bytes of the model that ships with this crate, for whoever compiles
|
||||
/// engines ahead of the first request (docs/inference.md §6).
|
||||
/// engines ahead of the first request (docs/dev/inference.md §6).
|
||||
#[cfg(feature = "embedded-model")]
|
||||
pub fn embedded_model_bytes() -> &'static [u8] {
|
||||
EMBEDDED_MODEL
|
||||
@@ -237,7 +237,7 @@ impl SemanticModel {
|
||||
|
||||
pub fn from_bytes(bytes: &[u8], classes: Vec<Arc<str>>) -> Result<Self, SegmentError> {
|
||||
// The f32 graph on whatever the device's backend is. An int8 form
|
||||
// for the Hexagon waits on docs/inference.md §10 M7 — the mask
|
||||
// for the Hexagon waits on docs/dev/inference.md §10 M7 — the mask
|
||||
// boundary has to be measured before it moves.
|
||||
let session = dr_inference_engine::open(
|
||||
dr_inference_engine::Role::Segmenter,
|
||||
|
||||
@@ -22,6 +22,11 @@ pub struct LoginFlow {
|
||||
pub login_url: String,
|
||||
#[serde(rename = "poll")]
|
||||
pub poll: PollInfo,
|
||||
/// The server the flow was started against — the address the user typed,
|
||||
/// already normalised. Not part of the response: [`begin`] fills it in so
|
||||
/// [`poll`] can put it in the credentials instead of the server's answer.
|
||||
#[serde(skip)]
|
||||
pub server: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -66,9 +71,57 @@ pub async fn begin(
|
||||
});
|
||||
}
|
||||
|
||||
resp.json::<LoginFlow>()
|
||||
let mut flow = resp
|
||||
.json::<LoginFlow>()
|
||||
.await
|
||||
.map_err(|e| RemoteError::Protocol(e.to_string()))
|
||||
.map_err(|e| RemoteError::Protocol(e.to_string()))?;
|
||||
|
||||
// Both URLs are the server's to choose, and neither may be trusted as
|
||||
// sent. The login URL is handed to the operating system to open, where a
|
||||
// `file:` or UNC path is a program launch rather than a web page; the poll
|
||||
// endpoint is where the app password comes back from.
|
||||
flow.login_url = upgraded("login URL", &flow.login_url)?;
|
||||
flow.poll.endpoint = upgraded("poll endpoint", &flow.poll.endpoint)?;
|
||||
flow.server = server.trim_end_matches('/').to_string();
|
||||
Ok(flow)
|
||||
}
|
||||
|
||||
/// TRACES: NFR-SEC-3
|
||||
/// A URL the server sent, upgraded to HTTPS, or refused.
|
||||
///
|
||||
/// `http` is upgraded rather than refused: a Nextcloud behind a TLS-terminating
|
||||
/// proxy without `overwriteprotocol` builds every absolute URL it returns with
|
||||
/// `http`, and the same path over `https` is the one that works. Any other
|
||||
/// scheme is refused, because the only thing it could be for is reaching
|
||||
/// something that is not this server.
|
||||
///
|
||||
/// The host is not checked. A server reached by its LAN address may answer with
|
||||
/// its public name, and nothing here is safer for refusing that: the account
|
||||
/// is stored under the address the user typed (see [`poll`]), not under
|
||||
/// anything the server said.
|
||||
pub(crate) fn upgraded(what: &str, url: &str) -> Result<String, RemoteError> {
|
||||
let mut parsed = url::Url::parse(url).map_err(|e| {
|
||||
RemoteError::Protocol(format!(
|
||||
"the server sent a {what} that is not a URL ({e}): {url}"
|
||||
))
|
||||
})?;
|
||||
match parsed.scheme() {
|
||||
"https" => {}
|
||||
"http" => parsed.set_scheme("https").map_err(|()| {
|
||||
RemoteError::Protocol(format!("{what} cannot be upgraded to https: {url}"))
|
||||
})?,
|
||||
other => {
|
||||
return Err(RemoteError::Protocol(format!(
|
||||
"the server sent a {what} using {other}:, and only https is accepted: {url}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
if parsed.host_str().is_none_or(str::is_empty) {
|
||||
return Err(RemoteError::Protocol(format!(
|
||||
"the server sent a {what} with no host: {url}"
|
||||
)));
|
||||
}
|
||||
Ok(parsed.into())
|
||||
}
|
||||
|
||||
/// Poll until the user finishes authenticating in the browser.
|
||||
@@ -98,10 +151,11 @@ pub async fn poll(
|
||||
|
||||
match resp.status().as_u16() {
|
||||
200 => {
|
||||
return resp
|
||||
let creds = resp
|
||||
.json::<AppCredentials>()
|
||||
.await
|
||||
.map_err(|e| RemoteError::Protocol(e.to_string()))
|
||||
.map_err(|e| RemoteError::Protocol(e.to_string()))?;
|
||||
return Ok(under_typed_server(creds, &flow.server));
|
||||
}
|
||||
// Still waiting for the user.
|
||||
404 => tokio::time::sleep(POLL_INTERVAL).await,
|
||||
@@ -117,6 +171,26 @@ pub async fn poll(
|
||||
Err(RemoteError::AuthFailed)
|
||||
}
|
||||
|
||||
/// TRACES: NFR-SEC-3
|
||||
/// Credentials filed under the address the user typed, not the one the server
|
||||
/// reports.
|
||||
///
|
||||
/// That report is the server's idea of its own URL, and behind a proxy
|
||||
/// without `overwriteprotocol` it says `http://` — which, stored, would send
|
||||
/// the app password in the clear on every request from then on. The typed
|
||||
/// address has just carried the whole flow, so it is known to reach the
|
||||
/// server.
|
||||
fn under_typed_server(mut creds: AppCredentials, server: &str) -> AppCredentials {
|
||||
if creds.server.trim_end_matches('/') != server {
|
||||
log::info!(
|
||||
"server reports itself as {}; keeping {server}",
|
||||
creds.server
|
||||
);
|
||||
}
|
||||
creds.server = server.to_string();
|
||||
creds
|
||||
}
|
||||
|
||||
/// Percent-encode a form value.
|
||||
fn urlencode(s: &str) -> String {
|
||||
s.bytes()
|
||||
@@ -167,6 +241,49 @@ mod tests {
|
||||
assert!(flow.login_url.contains("/login/v2/flow/"));
|
||||
}
|
||||
|
||||
/// TRACES: NFR-SEC-3
|
||||
#[test]
|
||||
fn server_urls_are_upgraded_to_https_or_refused() {
|
||||
assert_eq!(
|
||||
upgraded("login URL", "http://cloud.example/login/v2/flow/xyz").unwrap(),
|
||||
"https://cloud.example/login/v2/flow/xyz"
|
||||
);
|
||||
// A non-default port stays, and only the scheme changes.
|
||||
assert_eq!(
|
||||
upgraded("poll endpoint", "http://cloud.example:8443/login/v2/poll").unwrap(),
|
||||
"https://cloud.example:8443/login/v2/poll"
|
||||
);
|
||||
assert_eq!(
|
||||
upgraded("login URL", "https://cloud.example/x").unwrap(),
|
||||
"https://cloud.example/x"
|
||||
);
|
||||
// What `rundll32 url.dll,FileProtocolHandler` would run, and what
|
||||
// `xdg-open` would hand to whatever claims it.
|
||||
for hostile in [
|
||||
"file:///C:/Windows/System32/calc.exe",
|
||||
"\\\\evil\\share\\x.exe",
|
||||
"C:\\x.exe",
|
||||
"javascript:alert(1)",
|
||||
"-v",
|
||||
"",
|
||||
] {
|
||||
assert!(upgraded("login URL", hostile).is_err(), "{hostile:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: NFR-SEC-3
|
||||
#[test]
|
||||
fn credentials_keep_the_typed_server_not_the_reported_one() {
|
||||
let reported = AppCredentials {
|
||||
server: "http://cloud.example".into(),
|
||||
login_name: "duncan".into(),
|
||||
app_password: "secret-token".into(),
|
||||
};
|
||||
let c = under_typed_server(reported, "https://cloud.example");
|
||||
assert_eq!(c.server, "https://cloud.example");
|
||||
assert_eq!(c.app_password, "secret-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_values_are_encoded() {
|
||||
assert_eq!(urlencode("abc123"), "abc123");
|
||||
|
||||
@@ -723,6 +723,15 @@ pub fn http_client(user_agent: &str) -> Result<reqwest::Client, RemoteError> {
|
||||
// for months. [`EXTRA_ROOTS`] carries those, and is additive — the
|
||||
// webpki-roots set is still installed alongside.
|
||||
.tls_certs_only(extra_roots())
|
||||
// TRACES: NFR-SEC-3
|
||||
// Refuse `http` here, below every URL this crate builds, rather than
|
||||
// trusting each place a URL comes from. `normalise_endpoint` upgrades
|
||||
// what the user types, but the login flow's poll endpoint, the account
|
||||
// an older build saved and a redirect all arrive from somewhere else,
|
||||
// and any of them naming `http://` would send the app password in
|
||||
// Basic auth in the clear. reqwest checks this before connecting and
|
||||
// again on every redirect, so a refused request never opens a socket.
|
||||
.https_only(true)
|
||||
// A request that hangs forever is indistinguishable from a worker that
|
||||
// died, and cost a long time to tell apart once. These turn that into
|
||||
// an error the UI can show.
|
||||
@@ -780,8 +789,16 @@ fn map_send_error(e: reqwest::Error) -> RemoteError {
|
||||
let mut detail = e.to_string();
|
||||
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&e);
|
||||
let mut tls = false;
|
||||
// A URL the client refused to send — `http` under `https_only`, on the
|
||||
// first request or a redirect. Nothing left the process, so this is the
|
||||
// account's configuration, not the network: reported as `Network` it
|
||||
// would put the app into offline mode over a connection that is fine.
|
||||
let mut refused = e.is_builder();
|
||||
while let Some(s) = src {
|
||||
let text = s.to_string();
|
||||
if text.contains("URL scheme is not allowed") {
|
||||
refused = true;
|
||||
}
|
||||
// rustls surfaces every verification failure through this wording:
|
||||
// UnknownIssuer, Expired, NotValidForName, BadSignature.
|
||||
if text.contains("invalid peer certificate")
|
||||
@@ -795,7 +812,9 @@ fn map_send_error(e: reqwest::Error) -> RemoteError {
|
||||
src = std::error::Error::source(s);
|
||||
}
|
||||
|
||||
if tls {
|
||||
if refused {
|
||||
RemoteError::Configuration(detail)
|
||||
} else if tls {
|
||||
RemoteError::Tls(detail)
|
||||
} else {
|
||||
RemoteError::Network(detail)
|
||||
@@ -1013,6 +1032,35 @@ mod tests {
|
||||
assert!(c.is_ok(), "client must build without a backend");
|
||||
}
|
||||
|
||||
/// TRACES: NFR-SEC-3
|
||||
#[tokio::test]
|
||||
async fn plain_http_is_refused_before_a_connection_opens() {
|
||||
// A listener that would take the connection if one were made. The
|
||||
// app password travels in a header, so a request that reached the
|
||||
// socket has already leaked it; failing on the response is too late.
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let url = format!("http://{}/remote.php/dav/", listener.local_addr().unwrap());
|
||||
|
||||
let err = http_client("test")
|
||||
.unwrap()
|
||||
.get(&url)
|
||||
.basic_auth("duncan", Some("app-password"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("http must be refused");
|
||||
|
||||
assert!(
|
||||
matches!(map_send_error(err), RemoteError::Configuration(_)),
|
||||
"a refused scheme is configuration, not the network"
|
||||
);
|
||||
assert_eq!(
|
||||
listener.accept().err().map(|e| e.kind()),
|
||||
Some(std::io::ErrorKind::WouldBlock),
|
||||
"nothing may have connected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delta_is_unsupported_and_says_why() {
|
||||
// Verified absent in the server; the engine must fall back rather
|
||||
|
||||
@@ -96,6 +96,18 @@ impl BackendProvider for NextcloudProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: NFR-SEC-3
|
||||
/// An `http://` account written before sign-in kept the address the user
|
||||
/// typed: it was stored as the server reported itself, and behind a proxy
|
||||
/// without `overwriteprotocol` that is `http`. The client refuses to send
|
||||
/// to it now, so it is upgraded here rather than left to fail. The
|
||||
/// namespace ignores the scheme, so the catalog stays where it is.
|
||||
fn upgrade_endpoint(&self, stored: &str) -> Option<String> {
|
||||
stored
|
||||
.strip_prefix("http://")
|
||||
.map(|rest| format!("https://{rest}"))
|
||||
}
|
||||
|
||||
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
||||
let creds = Self::credentials(conn)?;
|
||||
Ok(Box::new(NextcloudBackend::new(
|
||||
@@ -132,6 +144,18 @@ mod tests {
|
||||
assert!(p.normalise_endpoint(" ").is_err());
|
||||
}
|
||||
|
||||
/// TRACES: NFR-SEC-3
|
||||
#[test]
|
||||
fn a_stored_http_endpoint_is_upgraded_and_nothing_else_is_touched() {
|
||||
let p = NextcloudProvider;
|
||||
assert_eq!(
|
||||
p.upgrade_endpoint("http://cloud.example/nextcloud")
|
||||
.as_deref(),
|
||||
Some("https://cloud.example/nextcloud")
|
||||
);
|
||||
assert_eq!(p.upgrade_endpoint("https://cloud.example"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_account_keeps_the_dav_user_id_apart_from_the_login() {
|
||||
// A login can be an email address while the user id is something
|
||||
|
||||
+223
-1
@@ -29,7 +29,7 @@ use dr_plat::{SecretError, SecretRef, SecretStore};
|
||||
use dr_types::{Format, FormatFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::RemoteError;
|
||||
use crate::{BackendRegistry, RemoteError};
|
||||
|
||||
/// The connector every account had before there was a choice.
|
||||
///
|
||||
@@ -510,6 +510,95 @@ impl AccountStore {
|
||||
written
|
||||
}
|
||||
|
||||
/// TRACES: NFR-SEC-3
|
||||
/// Rewrite the endpoints an older build stored in a form this one would
|
||||
/// not, asking each account's connector
|
||||
/// ([`upgrade_endpoint`](crate::BackendProvider::upgrade_endpoint)).
|
||||
///
|
||||
/// Per account and best effort: one that cannot be moved — its keyring
|
||||
/// locked, say — is logged and left as it was, and is tried again on the
|
||||
/// next launch, rather than stopping the others or the launch. Returns
|
||||
/// the accounts it rewrote.
|
||||
pub fn upgrade_endpoints(&self, registry: &BackendRegistry) -> Vec<Account> {
|
||||
let mut upgraded = Vec::new();
|
||||
for account in self.list() {
|
||||
let Ok(provider) = registry.for_account(&account) else {
|
||||
continue;
|
||||
};
|
||||
let Some(endpoint) = provider.upgrade_endpoint(&account.endpoint) else {
|
||||
continue;
|
||||
};
|
||||
if endpoint == account.endpoint {
|
||||
continue;
|
||||
}
|
||||
match self.move_endpoint(&account, &endpoint) {
|
||||
Ok(moved) => {
|
||||
log::info!("account {} moved to {endpoint}", account.describe());
|
||||
upgraded.push(moved);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"account {} could not be moved to {endpoint}: {e}",
|
||||
account.describe()
|
||||
),
|
||||
}
|
||||
}
|
||||
upgraded
|
||||
}
|
||||
|
||||
/// Move an account to a new endpoint, taking its credential with it.
|
||||
///
|
||||
/// The endpoint is half of two keys, and both have to be dealt with. The
|
||||
/// credential is filed under it ([`Account::secret_ref`]), so rewriting
|
||||
/// the record alone would strand the app password under the old key and
|
||||
/// sign the user out. And it feeds [`Account::namespace`], so a rewrite
|
||||
/// that changed the namespace would abandon the catalog and everything
|
||||
/// beside it; that is refused outright rather than left to the caller.
|
||||
///
|
||||
/// Ordered so an interruption at any step leaves something that works:
|
||||
/// the credential is copied before the record names the new key, and the
|
||||
/// old copy is deleted only once nothing names the old one.
|
||||
fn move_endpoint(&self, account: &Account, endpoint: &str) -> Result<Account, AccountError> {
|
||||
let mut moved = account.clone();
|
||||
moved.endpoint = endpoint.to_string();
|
||||
if moved.namespace() != account.namespace() {
|
||||
return Err(AccountError::WouldMoveData {
|
||||
from: account.namespace(),
|
||||
to: moved.namespace(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut config = self.read_config();
|
||||
// Already there — the user signed in again at the new address. That
|
||||
// record and its credential are the newer, so the old one just goes.
|
||||
let duplicate = config.sessions.iter().any(|a| a.is_same_as(&moved));
|
||||
|
||||
let old_ref = account.secret_ref();
|
||||
let secret = match self.secrets.retrieve(&old_ref) {
|
||||
Ok(s) => Some(s),
|
||||
Err(SecretError::NotFound) => None,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if let (Some(s), false) = (&secret, duplicate) {
|
||||
self.secrets.store(&moved.secret_ref(), s)?;
|
||||
}
|
||||
|
||||
if duplicate {
|
||||
config.sessions.retain(|a| !a.is_same_as(account));
|
||||
} else {
|
||||
// In place, not removed and pushed: the last record is the one
|
||||
// the next launch resumes.
|
||||
for a in config.sessions.iter_mut().filter(|a| a.is_same_as(account)) {
|
||||
*a = moved.clone();
|
||||
}
|
||||
}
|
||||
self.write_config(&config)?;
|
||||
|
||||
if secret.is_some() {
|
||||
self.secrets.delete(&old_ref)?;
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
fn read_config(&self) -> ConfigFile {
|
||||
std::fs::read_to_string(&self.config_path)
|
||||
.ok()
|
||||
@@ -545,6 +634,11 @@ pub enum AccountError {
|
||||
|
||||
#[error(transparent)]
|
||||
Remote(#[from] RemoteError),
|
||||
|
||||
/// A change that would give an account a different local data directory,
|
||||
/// leaving its catalog and caches behind under the old one.
|
||||
#[error("moving the account would leave its local data behind ({from} → {to})")]
|
||||
WouldMoveData { from: String, to: String },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -574,6 +668,134 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
/// A connector that upgrades `http://` endpoints the way Nextcloud's does,
|
||||
/// and every other one not at all.
|
||||
struct Upgrading(&'static str);
|
||||
impl crate::BackendProvider for Upgrading {
|
||||
fn id(&self) -> &'static str {
|
||||
self.0
|
||||
}
|
||||
fn display_name(&self) -> &'static str {
|
||||
"U"
|
||||
}
|
||||
fn endpoint_label(&self) -> &'static str {
|
||||
"Server"
|
||||
}
|
||||
fn endpoint_placeholder(&self) -> &'static str {
|
||||
""
|
||||
}
|
||||
fn sign_in(&self) -> crate::SignIn {
|
||||
crate::SignIn::Browser
|
||||
}
|
||||
fn normalise_endpoint(&self, i: &str) -> Result<String, String> {
|
||||
Ok(i.into())
|
||||
}
|
||||
fn upgrade_endpoint(&self, stored: &str) -> Option<String> {
|
||||
stored
|
||||
.strip_prefix("http://")
|
||||
.map(|rest| format!("https://{rest}"))
|
||||
}
|
||||
fn connect(&self, _: &Connection) -> Result<Box<dyn crate::RemoteBackend>, RemoteError> {
|
||||
Err(RemoteError::Unsupported("stub"))
|
||||
}
|
||||
}
|
||||
|
||||
fn upgrading(id: &'static str) -> BackendRegistry {
|
||||
let mut r = BackendRegistry::new();
|
||||
r.register(std::sync::Arc::new(Upgrading(id)));
|
||||
r
|
||||
}
|
||||
|
||||
/// TRACES: NFR-SEC-3
|
||||
#[test]
|
||||
fn an_http_account_is_upgraded_and_keeps_its_credential_and_data() {
|
||||
let dir = tmpdir("upgrade");
|
||||
let store = store_in(&dir);
|
||||
let old =
|
||||
Account::new(LEGACY_BACKEND, "http://cloud.example").with_login("duncan", "duncan");
|
||||
store.save(&folder(), None).unwrap();
|
||||
store
|
||||
.save(&old, Some(&Secret::new("secret-token")))
|
||||
.unwrap();
|
||||
|
||||
let moved = store.upgrade_endpoints(&upgrading(LEGACY_BACKEND));
|
||||
assert_eq!(moved.len(), 1);
|
||||
|
||||
let now = store.current().expect("still the account resumed");
|
||||
assert_eq!(now.endpoint, "https://cloud.example");
|
||||
assert_eq!(
|
||||
now.namespace(),
|
||||
old.namespace(),
|
||||
"the catalog directory must not move"
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.connection(&now, true)
|
||||
.unwrap()
|
||||
.require_secret()
|
||||
.unwrap()
|
||||
.expose(),
|
||||
"secret-token",
|
||||
"the credential moves with the account"
|
||||
);
|
||||
assert!(
|
||||
store.connection(&old, true).is_err(),
|
||||
"nothing is left under the old key"
|
||||
);
|
||||
assert_eq!(store.list().len(), 2, "the folder library is untouched");
|
||||
|
||||
// And a second launch has nothing to do.
|
||||
assert!(store
|
||||
.upgrade_endpoints(&upgrading(LEGACY_BACKEND))
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
/// TRACES: NFR-SEC-3
|
||||
#[test]
|
||||
fn a_move_that_would_change_the_data_directory_is_refused() {
|
||||
// A scheme change keeps the namespace, which is what makes the upgrade
|
||||
// safe; a host change does not, and would give the library a new,
|
||||
// empty data directory. The account is left exactly as it was.
|
||||
let dir = tmpdir("upgrade-refused");
|
||||
let store = store_in(&dir);
|
||||
let old = nextcloud();
|
||||
store
|
||||
.save(&old, Some(&Secret::new("secret-token")))
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
store.move_endpoint(&old, "https://elsewhere.example"),
|
||||
Err(AccountError::WouldMoveData { .. })
|
||||
));
|
||||
assert_eq!(store.current().unwrap().endpoint, old.endpoint);
|
||||
assert!(store.connection(&old, true).is_ok());
|
||||
}
|
||||
|
||||
/// TRACES: NFR-SEC-3
|
||||
#[test]
|
||||
fn an_upgrade_onto_an_existing_sign_in_keeps_the_newer_one() {
|
||||
let dir = tmpdir("upgrade-duplicate");
|
||||
let store = store_in(&dir);
|
||||
let old =
|
||||
Account::new(LEGACY_BACKEND, "http://cloud.example").with_login("duncan", "duncan");
|
||||
let new =
|
||||
Account::new(LEGACY_BACKEND, "https://cloud.example").with_login("duncan", "duncan");
|
||||
store.save(&old, Some(&Secret::new("stale"))).unwrap();
|
||||
store.save(&new, Some(&Secret::new("fresh"))).unwrap();
|
||||
|
||||
store.upgrade_endpoints(&upgrading(LEGACY_BACKEND));
|
||||
assert_eq!(store.list().len(), 1);
|
||||
assert_eq!(
|
||||
store
|
||||
.connection(&new, true)
|
||||
.unwrap()
|
||||
.require_secret()
|
||||
.unwrap()
|
||||
.expose(),
|
||||
"fresh"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_account_survives_reopening() {
|
||||
let dir = tmpdir("survives");
|
||||
|
||||
@@ -83,6 +83,20 @@ pub trait BackendProvider: Send + Sync {
|
||||
/// wrong rather than naming a type.
|
||||
fn normalise_endpoint(&self, input: &str) -> Result<String, String>;
|
||||
|
||||
/// The form a *stored* endpoint should take now, where an older build
|
||||
/// wrote one this build would not.
|
||||
///
|
||||
/// Not [`normalise_endpoint`](Self::normalise_endpoint) run again: that
|
||||
/// judges what a person typed, and may touch the world to do it — a folder
|
||||
/// is canonicalised and must exist — so rerunning it on every launch would
|
||||
/// fail a library whose disk is unplugged, or rename one whose path now
|
||||
/// resolves differently. This is a pure rewrite of the string, and `None`
|
||||
/// means leave it alone, which is the answer for almost every connector.
|
||||
fn upgrade_endpoint(&self, stored: &str) -> Option<String> {
|
||||
let _ = stored;
|
||||
None
|
||||
}
|
||||
|
||||
/// Build an account from a normalised endpoint alone.
|
||||
///
|
||||
/// Only meaningful for [`SignIn::EndpointOnly`]; a browser flow produces
|
||||
|
||||
@@ -115,6 +115,10 @@ pub enum PlaceScope {
|
||||
#[serde(default)]
|
||||
pub struct StoredFilter {
|
||||
pub min_rating: u8,
|
||||
/// TRACES: FR-UI-5
|
||||
/// The top of a star range. A record from before ranges existed has none,
|
||||
/// which reads as no ceiling — what it meant when it was written.
|
||||
pub max_rating: Option<u8>,
|
||||
pub unjudged: bool,
|
||||
pub flag: Option<FlagState>,
|
||||
pub local_only: bool,
|
||||
@@ -131,6 +135,10 @@ pub struct StoredFilter {
|
||||
/// Travels: it is a narrowing like `local_only`, and a record without it
|
||||
/// — from a build before it existed — reads as off.
|
||||
pub eyes_open: bool,
|
||||
/// TRACES: FR-CAT-6
|
||||
/// The colour label the grid was narrowed to. A record without it reads
|
||||
/// as none, as every term added after the first does.
|
||||
pub label: Option<crate::ColourLabel>,
|
||||
}
|
||||
|
||||
/// Where the photographer was, at the moment they were there.
|
||||
|
||||
@@ -177,7 +177,7 @@ pub struct FaceSettings {
|
||||
/// Which SCRFD graph the indexing pass detects with.
|
||||
///
|
||||
/// Three exports of one architecture, differing only in how much computation
|
||||
/// they spend, and docs/faces.md §12.3 is the measurement that made this a
|
||||
/// they spend, and docs/dev/faces.md §12.3 is the measurement that made this a
|
||||
/// choice rather than a constant: over the same photographs the cheapest one
|
||||
/// misses the small faces in a group and reports a dog a dozen times, the
|
||||
/// middle one finds 14% more faces for 12% more time, and the largest a
|
||||
@@ -261,7 +261,7 @@ impl FaceDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/// The id when the detector runs in its int8 form (docs/inference.md §7).
|
||||
/// The id when the detector runs in its int8 form (docs/dev/inference.md §7).
|
||||
///
|
||||
/// A different detector: it finds a different set of faces, so it is a
|
||||
/// different population of detections. The embedder half is unchanged,
|
||||
|
||||
@@ -48,7 +48,7 @@ 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
|
||||
fetch. docs/dev/faces.md §2.2a is the decision and its limits — these files come back out before anything
|
||||
is published.
|
||||
|
||||
## Java in the APK
|
||||
|
||||
@@ -258,7 +258,7 @@ fi
|
||||
cp "${SO}" "${OUT}/staging/lib/${ABI}/libdarkroom.so"
|
||||
cp "${DEX}" "${OUT}/staging/classes.dex"
|
||||
|
||||
# The inference runtime (docs/inference.md §3): ONNX Runtime and Qualcomm's
|
||||
# The inference runtime (docs/dev/inference.md §3): ONNX Runtime and Qualcomm's
|
||||
# Hexagon backend, beside libdarkroom.so so the app finds them in its own
|
||||
# native library directory. The build links none of it — the app dlopens
|
||||
# `libonnxruntime.so` at launch and runs on tract if it is not there — so an
|
||||
@@ -278,7 +278,7 @@ else
|
||||
fi
|
||||
|
||||
# The 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
|
||||
# user-reachable and the in-app fetch is unbuilt (docs/dev/faces.md §2.2a) — so
|
||||
# they go in the APK and `android_main` unpacks them on first launch. The
|
||||
# sources are `models/face/` and `models/scene/`, shared with the Arch package
|
||||
# rather than living under this one platform's directory.
|
||||
@@ -332,6 +332,29 @@ else
|
||||
echo " assets: no models found (face indexing and the scene tab will be off on the device)"
|
||||
fi
|
||||
|
||||
# The manual: the rendered page and its pictures, read in place by
|
||||
# ManualActivity's WebView as file:///android_asset/manual/index.html. Not
|
||||
# unpacked like the models: a WebView reads an asset straight out of the APK,
|
||||
# and relative links to media/ resolve inside the same asset tree, so the page
|
||||
# costs no first-launch copy and no second copy on /data.
|
||||
#
|
||||
# About 27 MB, stored below like the models — a GIF or PNG is already
|
||||
# compressed, and deflating it again buys nothing. The pictures are LFS
|
||||
# objects, and unlike a missing model a pointer would not fail loudly: it
|
||||
# ships as a manual full of broken images. So it stops the build here.
|
||||
rm -rf "${OUT}/staging/assets/manual"
|
||||
mkdir -p "${OUT}/staging/assets/manual/media"
|
||||
cp "${REPO}/docs/manual/index.html" "${OUT}/staging/assets/manual/"
|
||||
for f in "${REPO}/docs/manual/media"/*; do
|
||||
if head -c 40 "${f}" | grep -q '^version https://git-lfs'; then
|
||||
echo "error: $(basename "${f}") is an LFS pointer, not a picture." >&2
|
||||
echo " run: git lfs pull --include='docs/manual/media/**'" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "${f}" "${OUT}/staging/assets/manual/media/"
|
||||
done
|
||||
echo " manual: index.html and $(ls "${OUT}/staging/assets/manual/media" | wc -l) picture(s), $(du -sh "${OUT}/staging/assets/manual" | cut -f1)"
|
||||
|
||||
# -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.
|
||||
|
||||
@@ -73,7 +73,7 @@ SO="${CACHE}/target/jniLibs/${ABI}/libdarkroom.so"
|
||||
# of KEYSTORE_PASS (see its header), and the keystore has to be reachable from
|
||||
# inside the container, so a host path in KEYSTORE is copied under the mounted
|
||||
# target directory for the duration of the build and removed after. The
|
||||
# passwords travel as environment, never as arguments -- docs/android-signing.md
|
||||
# passwords travel as environment, never as arguments -- docs/dev/android-signing.md
|
||||
# has the incantation.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "==> packaging APK"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DarkRoom — reproducible Windows cross-build environment
|
||||
#
|
||||
# Everything docs/windows.md §2 names: Rust with the GNU Windows target, the
|
||||
# Everything docs/dev/windows.md §2 names: Rust with the GNU Windows target, the
|
||||
# MinGW-w64 cross compiler it links with, NSIS to build the installer, and Wine
|
||||
# to smoke-test the result. Both CI and local builds use this image, so "works
|
||||
# on my machine" and "works in CI" are the same machine — the same argument
|
||||
@@ -77,7 +77,7 @@ RUN curl -fsSL https://sh.rustup.rs | sh -s -- \
|
||||
# libwinpthread the Rust target's own MinGW pieces were built against, and
|
||||
# picking the other produces link errors that read as if std were missing.
|
||||
#
|
||||
# The runtime is linked statically (docs/windows.md §2) so the installer
|
||||
# The runtime is linked statically (docs/dev/windows.md §2) so the installer
|
||||
# carries one file. `-static-libgcc` is all it takes: rustc's windows-gnu
|
||||
# target links its own copy of winpthread in self-contained mode, so nothing
|
||||
# imports libwinpthread-1.dll — the smoke test's objdump step is what checks
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Windows cross-build environment
|
||||
|
||||
Reproducible container for building the Windows executable and its installer from Linux. The
|
||||
specification is [docs/windows.md](../../docs/windows.md); this directory is what it turned into,
|
||||
specification is [docs/dev/windows.md](../../docs/dev/windows.md); this directory is what it turned into,
|
||||
and every departure from the spec's first draft is recorded in the Dockerfile's comments.
|
||||
|
||||
## Use
|
||||
@@ -16,7 +16,7 @@ and every departure from the spec's first draft is recorded in the Dockerfile's
|
||||
# Build the installer from that binary
|
||||
./docker/windows/build.sh docker/windows/package.sh
|
||||
|
||||
# Smoke-test under Wine (docs/windows.md §6)
|
||||
# Smoke-test under Wine (docs/dev/windows.md §6)
|
||||
./docker/windows/build.sh wine target-windows/x86_64-pc-windows-gnu/release/darkroom-desktop.exe --version
|
||||
./docker/windows/build.sh wine target-windows/installer/DarkRoom-0.12.0-x86_64-setup.exe /S
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# to have run in the same target directory. Produces
|
||||
# DarkRoom-<version>-x86_64-setup.exe in $OUT (default: target-windows/installer).
|
||||
#
|
||||
# Runs inside the container, where makensis is; docs/windows.md §5 is the
|
||||
# Runs inside the container, where makensis is; docs/dev/windows.md §5 is the
|
||||
# specification this implements.
|
||||
set -euo pipefail
|
||||
|
||||
@@ -48,7 +48,13 @@ sed 's/$/\r/' "${REPO}/LICENSE" > "${STAGE}/LICENSE"
|
||||
# tract on the user's machine with a message about a broken graph rather than
|
||||
# a checkout that needed `git lfs pull`. Only the weights are checked; the
|
||||
# scene model's vocabulary and category descriptor are legitimately small.
|
||||
for dir in face scene; do
|
||||
#
|
||||
# The directories are the ones the APK stages (assemble-apk.sh) and the Arch
|
||||
# package installs: the face pair and its eye-state models, the scene model
|
||||
# with its two descriptors, and the panorama border filler. The installer
|
||||
# smoke test counts the same directories, so a model added here is expected
|
||||
# there without a number to update.
|
||||
for dir in face scene inpaint; do
|
||||
for f in "${REPO}/models/${dir}"/*; do
|
||||
case "$(basename "${f}")" in
|
||||
README.md) continue ;;
|
||||
@@ -63,6 +69,23 @@ for dir in face scene; do
|
||||
done
|
||||
echo "==> staged $(ls "${STAGE}/models" | wc -l) model file(s)"
|
||||
|
||||
# The manual: the rendered page and its pictures, beside the executable where
|
||||
# dr_ui::manual looks on Windows (dr_plat::system_data_dirs is the exe's own
|
||||
# directory there). The pictures are LFS objects; a pointer is ~130 bytes of
|
||||
# text that every browser draws as a broken image, so refuse it here rather
|
||||
# than ship a manual with no pictures in it.
|
||||
mkdir -p "${STAGE}/manual/media"
|
||||
cp "${REPO}/docs/manual/index.html" "${STAGE}/manual/"
|
||||
for f in "${REPO}/docs/manual/media"/*; do
|
||||
if head -c 40 "${f}" | grep -q '^version https://git-lfs'; then
|
||||
echo "error: $(basename "${f}") is an LFS pointer, not a picture." >&2
|
||||
echo " run: git lfs pull --include='docs/manual/media/**'" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "${f}" "${STAGE}/manual/media/"
|
||||
done
|
||||
echo "==> staged the manual and $(ls "${STAGE}/manual/media" | wc -l) picture(s)"
|
||||
|
||||
# One installer in the output directory, the one just built. The directory
|
||||
# is cached between CI runs, so after a version bump a glob over it would find
|
||||
# two and the smoke test would hand Wine both names as one path.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Documentation
|
||||
|
||||
Two audiences, two folders. Most people want the first table and never the
|
||||
second.
|
||||
|
||||
## Using DarkRoom
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [The manual](manual/README.md) | Every feature, pictured from the application itself — opening a library, rating and filing, developing, local masks, repair, film, panoramas, export |
|
||||
| [How it is driven](gestures.md) | Every gesture and shortcut, by screen. Generated from the code, so it cannot describe one the application does not have |
|
||||
|
||||
The [top-level README](../README.md) says what DarkRoom is, how to get it on
|
||||
each platform, and what is still missing.
|
||||
|
||||
## Changing DarkRoom
|
||||
|
||||
Everything under [`dev/`](dev/) is for someone working on the code. Start with
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md), which says how to land a first change
|
||||
without reading the rest.
|
||||
|
||||
**The register and the record.** What must be built, how it is built, and how
|
||||
far along it is.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [requirements.md](dev/requirements.md) | What the software must do — the numbered register, the decisions (D-numbers) and the spikes (S-numbers) |
|
||||
| [architecture.md](dev/architecture.md) | How it is built — crates, the GPU pipeline, the data model, sync |
|
||||
| [traceability.md](dev/traceability.md) | Generated: which requirement is claimed by which file. Never edited by hand |
|
||||
| [outstanding.md](dev/outstanding.md) | What is specified and not built, and whether that is a decision or a gap |
|
||||
| [technical-debt.md](dev/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
|
||||
| [code-health.md](dev/code-health.md) | What a contribution costs, per seam, measured |
|
||||
|
||||
**Designs, one per subsystem.** Each is the specification the code was built
|
||||
to, kept current as the code moved.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [catalog.md](dev/catalog.md) | The index, the library view, incremental scan, the job queue |
|
||||
| [storage.md](dev/storage.md) | Storage backends: the seam a folder, a sync client and a Nextcloud account share |
|
||||
| [faces.md](dev/faces.md) | Face detection, identity, clustering and the eye-state models |
|
||||
| [segmentation.md](dev/segmentation.md) | How the application finds the regions a local mask snaps to |
|
||||
| [mask-editing.md](dev/mask-editing.md) | Painting, erasing and combining masks |
|
||||
| [spot-removal.md](dev/spot-removal.md) | Clone and heal as parameters in the edit graph |
|
||||
| [panorama.md](dev/panorama.md) | Alignment, projection, the chunked composite and the border fill |
|
||||
| [inference.md](dev/inference.md) | The neural runtime and model chosen per device, with the measurements |
|
||||
| [display-and-extension.md](dev/display-and-extension.md) | The display contract, and why the fused pipeline is already most of a plugin format |
|
||||
| [view-composition.md](dev/view-composition.md) | A controller for the display layer |
|
||||
| [ui-navigation.md](dev/ui-navigation.md) | Finding things in the interface once there are many |
|
||||
|
||||
**Measurements.** Numbers committed so a regression is a diff rather than a
|
||||
recollection.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [benchmarks.md](dev/benchmarks.md) | The per-commit suite: what it covers, what it does not, how to read a failure |
|
||||
| [bench-baseline.json](dev/bench-baseline.json) | The committed numbers the suite checks against |
|
||||
| [frame-budget.md](dev/frame-budget.md) | What a frame costs on each device, and the decision those figures settled |
|
||||
|
||||
**Platforms and distribution.**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [distribution.md](dev/distribution.md) | Which channels v1 targets and what each one constrains |
|
||||
| [windows.md](dev/windows.md) | The Windows installer, cross-built from the Linux CI |
|
||||
| [android-signing.md](dev/android-signing.md) | Which key signs the APK, and keeping it |
|
||||
|
||||
**Archive.** Kept as the record of what was asked for, not as plans.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [milestone-v0.1.md](dev/archive/milestone-v0.1.md) | The first milestone, delivered 2026-08-30 and superseded |
|
||||
| [ui-refinement.md](dev/archive/ui-refinement.md) | How the interface should look; succeeded by [ui-navigation.md](dev/ui-navigation.md) |
|
||||
|
||||
## Conventions
|
||||
|
||||
Two files here are generated and must not be edited by hand:
|
||||
`gestures.md` and `dev/traceability.md`. Both come from
|
||||
`cargo run -p traceability` and the pre-commit hook keeps them in step with
|
||||
the tree. The manual's pictures are recorded by
|
||||
[`tools/manual`](../tools/manual/README.md) and live in LFS.
|
||||
|
||||
A design document links to the requirements it satisfies and to the code
|
||||
that satisfies them. When the code moves, the link moves with it; a document
|
||||
that has stopped being true goes to `dev/archive/` with a note saying what
|
||||
replaced it, rather than being deleted.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user