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 | ||
|
|
195388b2e3 | ||
|
|
e166b64ee9 | ||
|
|
a773ad5c27 | ||
|
|
1e472fd251 | ||
|
|
6bf67cefc4 | ||
|
|
00e2fe6aaf | ||
|
|
402dcdc24c | ||
|
|
94b39410bc | ||
|
|
2014c80e62 | ||
|
|
6fd342680b | ||
|
|
78acc73dad | ||
|
|
954246b969 | ||
|
|
baed1c4782 | ||
|
|
fbfa891296 | ||
|
|
aee62dc7f2 | ||
|
|
84fade99ec | ||
|
|
3bfa73d1e1 | ||
|
|
4616cb0a23 | ||
|
|
cc73ea3153 | ||
|
|
f6ff5eabd9 | ||
|
|
34ac2f14d3 | ||
|
|
f71d7bacc6 | ||
|
|
14ed1dc410 | ||
|
|
38819da222 | ||
|
|
d6e9c7dc94 | ||
|
|
9c8f21b754 | ||
|
|
bd7d75522d | ||
|
|
4ef1b74f2f | ||
|
|
681486196e | ||
|
|
f5d0d57574 | ||
|
|
c96e670356 | ||
|
|
9c556364fa | ||
|
|
8d72cabff5 | ||
|
|
8a90d888d5 | ||
|
|
e86edef47c | ||
|
|
0aff5e6c8c | ||
|
|
5458314083 | ||
|
|
39a22875b1 | ||
|
|
cb7b9d71c4 | ||
|
|
beb7a5eac0 | ||
|
|
262ed2553c | ||
|
|
8d08ffd7b7 | ||
|
|
8540518022 | ||
|
|
b952f5976a | ||
|
|
a1d511fd4b | ||
|
|
050c2c9d16 | ||
|
|
bd3b993b90 | ||
|
|
59605f9fbb | ||
|
|
04949741c1 | ||
|
|
5b4ad11853 | ||
|
|
6b1aac477d | ||
|
|
2afc2a7890 | ||
|
|
6507593715 | ||
|
|
08727cff5a | ||
|
|
f4c3f425dd | ||
|
|
12f8990e09 | ||
|
|
8a897bbc01 | ||
|
|
a03e082fe2 | ||
|
|
4576499c3b | ||
|
|
2f47087223 | ||
|
|
764ad55ead | ||
|
|
301e6f3828 | ||
|
|
a437363bd6 | ||
|
|
065872bec5 | ||
|
|
0ed38ada28 | ||
|
|
695d5ec304 |
+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
|
# 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,
|
# 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
|
# and every re-recording would otherwise stay in every clone for good. The
|
||||||
# pulls exclude the directory; nothing built or tested reads it.
|
# 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
|
docs/manual/media/** filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: Benchmarks
|
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
|
# "an automated benchmark suite against a synthetic 50k catalog, run per-commit
|
||||||
# … A regression beyond stated tolerance fails the build."
|
# … 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
|
# 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*
|
# 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
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -183,7 +183,7 @@ jobs:
|
|||||||
- name: Frame budget (FR-DSP-3)
|
- name: Frame budget (FR-DSP-3)
|
||||||
run: cargo test --release -p dr-gpu --test frame_budget -- --nocapture
|
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
|
# 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
|
# 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
|
# in the log for whoever asked for this run; the committed numbers are
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ name: Build and test
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, master, develop]
|
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:
|
pull_request:
|
||||||
branches: [main, master, develop]
|
branches: [main, master, develop]
|
||||||
|
|
||||||
@@ -96,7 +100,9 @@ jobs:
|
|||||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"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/
|
ls -lR models/
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
@@ -154,6 +160,16 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --workspace --release
|
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
|
- name: Disk after
|
||||||
if: always()
|
if: always()
|
||||||
run: df -h /workspace 2>/dev/null || df -h .
|
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
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"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/
|
ls -lR models/
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
@@ -322,7 +340,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CARGO_TARGET_DIR: target-android
|
CARGO_TARGET_DIR: target-android
|
||||||
# Absent secrets mean a debug signature, which is what a fork or a
|
# 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.
|
# and the same job produces a release-signed APK instead.
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
@@ -370,7 +388,7 @@ jobs:
|
|||||||
|
|
||||||
# TRACES: FR-PLAT-WIN-3
|
# TRACES: FR-PLAT-WIN-3
|
||||||
# The Windows executable and its installer, cross-built from Linux
|
# 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
|
# can prove is that the binary links, is a Windows executable with no
|
||||||
# MinGW runtime imports, starts under Wine, and that the installer installs
|
# MinGW runtime imports, starts under Wine, and that the installer installs
|
||||||
# and uninstalls under Wine. What it cannot prove — a Vulkan device, a
|
# 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
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"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
|
ls -l models/face models/scene
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
@@ -454,7 +474,17 @@ jobs:
|
|||||||
wine "$SETUP" /S 2>/dev/null
|
wine "$SETUP" /S 2>/dev/null
|
||||||
INST=$(echo "$HOME"/.wine/drive_c/users/*/AppData/Local/Programs/DarkRoom)
|
INST=$(echo "$HOME"/.wine/drive_c/users/*/AppData/Local/Programs/DarkRoom)
|
||||||
ls "$INST"
|
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 \
|
wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \
|
||||||
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
|
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
|
||||||
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
|
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
|
||||||
@@ -514,3 +544,47 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
exit $FAILED
|
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
|
# fail its own threshold. Two rules follow, and the extractor's own tests
|
||||||
# enforce both:
|
# 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.
|
# 2. Coverage is |traced ∩ defined| / |defined|, never a raw traced count.
|
||||||
#
|
#
|
||||||
# This job is static analysis of source comments plus markdown parsing, so it
|
# 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
|
# threshold: zero requirements parsed, zero files scanned, a ratio above
|
||||||
# 100%, or any orphan tag all fail the build. A misconfigured run must not
|
# 100%, or any orphan tag all fail the build. A misconfigured run must not
|
||||||
# report a plausible-looking 0%.
|
# 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
|
- name: Traceability gate
|
||||||
run: cargo run -q -p traceability -- check
|
run: cargo run -q -p traceability -- check
|
||||||
|
|
||||||
@@ -74,11 +80,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
cargo run -q -p traceability -- report
|
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 ""
|
||||||
echo "docs/traceability.md is out of date."
|
echo "docs/dev/traceability.md is out of date."
|
||||||
echo "Run: cargo run -p traceability -- report"
|
echo "Run: cargo run -p traceability -- report"
|
||||||
git diff --stat docs/traceability.md
|
git diff --stat docs/dev/traceability.md
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -91,10 +97,19 @@ jobs:
|
|||||||
# they will conclude the application is broken rather than the page.
|
# 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
|
# 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
|
- name: Regenerate the gesture vocabulary and check it is committed
|
||||||
run: cargo run -q -p traceability -- gestures-check
|
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
|
# 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
|
# tag on every function is noise that rots faster than it helps. Tag the
|
||||||
# unit that decides.
|
# unit that decides.
|
||||||
@@ -127,4 +142,4 @@ jobs:
|
|||||||
|
|
||||||
- name: Summary
|
- name: Summary
|
||||||
if: always()
|
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
|
# The artefacts are generated from the tree, so regenerating them because one
|
||||||
# was itself edited would be circular.
|
# was itself edited would be circular.
|
||||||
case "$(tr -d '[:space:]' <<< "${staged}")" in
|
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
|
exit 0
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -41,9 +41,9 @@ if ! cargo run -q -p traceability -- report >/dev/null 2>&1; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! git diff --quiet -- docs/traceability.md; then
|
if ! git diff --quiet -- docs/dev/traceability.md; then
|
||||||
git add docs/traceability.md
|
git add docs/dev/traceability.md
|
||||||
echo "pre-commit: regenerated docs/traceability.md and staged it"
|
echo "pre-commit: regenerated docs/dev/traceability.md and staged it"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The gesture vocabulary, same discipline.
|
# 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"
|
echo "pre-commit: regenerated ${f} and staged it"
|
||||||
fi
|
fi
|
||||||
done
|
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
|
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
|
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.
|
about habits, not features.
|
||||||
|
|
||||||
## Catalog reads: work is proportional to what changed, never to library size
|
## 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
|
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.
|
24k-image library (2026-09-19), and what each fix looked like.
|
||||||
|
|
||||||
@@ -89,6 +89,30 @@ three `405`s before each `MOVE`. The backend now remembers the collections
|
|||||||
it has confirmed (`known_dirs`) for its lifetime, which is one job. When a
|
it has confirmed (`known_dirs`) for its lifetime, which is one job. When a
|
||||||
per-file operation has a per-batch precondition, satisfy it once.
|
per-file operation has a per-batch precondition, satisfy it once.
|
||||||
|
|
||||||
|
## Providers: read the runtime's source for the version on disk, not the binding
|
||||||
|
|
||||||
|
Two things the MIGraphX rung (2026-09-20) got wrong before it was measured
|
||||||
|
right, both because `ort`'s builder was trusted to mean what its method
|
||||||
|
names say.
|
||||||
|
|
||||||
|
**A binding's option builder may fill a struct the runtime no longer
|
||||||
|
reads.** `ep::MIGraphX::with_save_model` sets fields of the legacy
|
||||||
|
`OrtMIGraphXProviderOptions`; ONNX Runtime 1.29 reads that struct for the
|
||||||
|
precision flags and ignores the rest, so every session compiled for 40 s
|
||||||
|
and the cache directory went nowhere. The option that works
|
||||||
|
(`migraphx_model_cache_dir`) exists only in the generic key/value
|
||||||
|
registration, which `session::migraphx` calls on the API table directly.
|
||||||
|
Before wiring a provider option, fetch the provider's source at the
|
||||||
|
runtime's exact version and find where the option is *read*.
|
||||||
|
|
||||||
|
**A provider's cache key may leave out what you are varying.** MIGraphX
|
||||||
|
keys a compiled program on graph, GPU and its own version — not precision.
|
||||||
|
The first fp16 measurement built in 0.3 s and matched f32 to the tenth of a
|
||||||
|
millisecond, because it had loaded the f32 program. A "from cache" build
|
||||||
|
that is suspiciously fast on the first run of a new configuration is a key
|
||||||
|
collision, not a fast provider; give each precision its own directory (the
|
||||||
|
engine does) and check the cache directory gained a file.
|
||||||
|
|
||||||
## Measuring
|
## Measuring
|
||||||
|
|
||||||
`cargo run --release -p dr-ui --example identity_bench -- CATALOG THUMBS`
|
`cargo run --release -p dr-ui --example identity_bench -- CATALOG THUMBS`
|
||||||
|
|||||||
+11
-11
@@ -90,18 +90,18 @@ break it by accident:
|
|||||||
cargo run --release -p dr-bench -- check
|
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
|
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
|
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
|
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
|
touched the catalog, the decoder, the thumbnail store or the exporter, run it
|
||||||
before you send.
|
before you send.
|
||||||
|
|
||||||
## Requirements and traceability
|
## Requirements and traceability
|
||||||
|
|
||||||
[`requirements.md`](docs/requirements.md) is the register of record.
|
[`requirements.md`](docs/dev/requirements.md) is the register of record.
|
||||||
[`traceability.md`](docs/traceability.md) is generated from `TRACES:` tags in
|
[`traceability.md`](docs/dev/traceability.md) is generated from `TRACES:` tags in
|
||||||
the source and must never be hand-edited:
|
the source and must never be hand-edited:
|
||||||
|
|
||||||
```rust
|
```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.
|
the matrix. Never regenerate it with a stale prebuilt binary.
|
||||||
|
|
||||||
**One convention that the tooling cannot enforce.** A tag proves that a tag
|
**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
|
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
|
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
|
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 |
|
| 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 |
|
| [`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/dev/architecture.md`](docs/dev/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/dev/code-health.md`](docs/dev/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/dev/benchmarks.md`](docs/dev/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/dev/technical-debt.md`](docs/dev/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/dev/distribution.md`](docs/dev/distribution.md) | Packaging a build, or adding a permission to one |
|
||||||
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading |
|
| [`docs/dev/requirements.md`](docs/dev/requirements.md) | Reference, not reading |
|
||||||
|
|
||||||
`technical-debt.md` is the one to check before "fixing" anything surprising.
|
`technical-debt.md` is the one to check before "fixing" anything surprising.
|
||||||
It records compromises that were deliberate, each with the reasoning and a
|
It records compromises that were deliberate, each with the reasoning and a
|
||||||
|
|||||||
Generated
+42
-29
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-android"
|
name = "darkroom-android"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1234,7 +1234,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-desktop"
|
name = "darkroom-desktop"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1408,7 +1408,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-bench"
|
name = "dr-bench"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-catalog",
|
"dr-catalog",
|
||||||
@@ -1425,7 +1425,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-catalog"
|
name = "dr-catalog"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-face",
|
"dr-face",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1440,7 +1440,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-decode"
|
name = "dr-decode"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1454,7 +1454,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-export"
|
name = "dr-export"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
@@ -1473,7 +1473,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-face"
|
name = "dr-face"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-film"
|
name = "dr-film"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1495,7 +1495,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-gpu"
|
name = "dr-gpu"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1513,8 +1513,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-inference-engine"
|
name = "dr-inference-engine"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"env_logger",
|
||||||
"libloading",
|
"libloading",
|
||||||
"log",
|
"log",
|
||||||
"ort",
|
"ort",
|
||||||
@@ -1527,7 +1528,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ingest"
|
name = "dr-ingest"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1539,7 +1540,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-lens"
|
name = "dr-lens"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lensfun",
|
"lensfun",
|
||||||
"log",
|
"log",
|
||||||
@@ -1547,7 +1548,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pano"
|
name = "dr-pano"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
@@ -1561,7 +1562,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pipeline"
|
name = "dr-pipeline"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -1570,7 +1571,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-plat"
|
name = "dr-plat"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-native-keyring-store",
|
"android-native-keyring-store",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1586,7 +1587,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-preset-xmp"
|
name = "dr-preset-xmp"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-pipeline",
|
"dr-pipeline",
|
||||||
"log",
|
"log",
|
||||||
@@ -1596,7 +1597,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-segment"
|
name = "dr-segment"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1609,7 +1610,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync"
|
name = "dr-sync"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1623,7 +1624,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-folder"
|
name = "dr-sync-folder"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-sync",
|
"dr-sync",
|
||||||
@@ -1635,7 +1636,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-nextcloud"
|
name = "dr-sync-nextcloud"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1657,7 +1658,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-thumbs"
|
name = "dr-thumbs"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
@@ -1669,7 +1670,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-types"
|
name = "dr-types"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1678,7 +1679,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ui"
|
name = "dr-ui"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1703,9 +1704,11 @@ dependencies = [
|
|||||||
"dr-types",
|
"dr-types",
|
||||||
"dr-xmp",
|
"dr-xmp",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"i-slint-backend-testing",
|
||||||
"jni 0.22.4",
|
"jni 0.22.4",
|
||||||
"log",
|
"log",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
|
"png",
|
||||||
"pollster",
|
"pollster",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -1715,12 +1718,13 @@ dependencies = [
|
|||||||
"slint-build",
|
"slint-build",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"url",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-xmp"
|
name = "dr-xmp"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -2778,6 +2782,18 @@ dependencies = [
|
|||||||
"i-slint-renderer-skia",
|
"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]]
|
[[package]]
|
||||||
name = "i-slint-backend-winit"
|
name = "i-slint-backend-winit"
|
||||||
version = "1.17.1"
|
version = "1.17.1"
|
||||||
@@ -2958,8 +2974,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "i-slint-renderer-skia"
|
name = "i-slint-renderer-skia"
|
||||||
version = "1.17.1"
|
version = "1.17.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7b6eed7f3f0a9a3d3ca6e8b9d4ca233371d989351fdb2a7ab88ec368b99e7b57"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ash",
|
"ash",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
@@ -7021,9 +7035,10 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traceability"
|
name = "traceability"
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"pulldown-cmark",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
@@ -7921,8 +7936,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-hal"
|
name = "wgpu-hal"
|
||||||
version = "29.0.4"
|
version = "29.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
|
|||||||
+18
-2
@@ -27,9 +27,12 @@ members = [
|
|||||||
"tools/bench",
|
"tools/bench",
|
||||||
"tools/traceability",
|
"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]
|
[workspace.package]
|
||||||
version = "0.13.2"
|
version = "0.15.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "GPL-3.0-or-later"
|
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-face = { path = "core/dr-face", default-features = false }
|
||||||
dr-film = { path = "core/dr-film" }
|
dr-film = { path = "core/dr-film" }
|
||||||
# `tract` on by default so a test binary can open a session with nothing
|
# `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-inference-engine = { path = "core/dr-inference-engine" }
|
||||||
dr-ingest = { path = "core/dr-ingest" }
|
dr-ingest = { path = "core/dr-ingest" }
|
||||||
dr-gpu = { path = "core/dr-gpu" }
|
dr-gpu = { path = "core/dr-gpu" }
|
||||||
@@ -127,6 +130,10 @@ url = "2.5"
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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"
|
base64 = "0.23"
|
||||||
|
|
||||||
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
|
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
|
||||||
@@ -262,3 +269,12 @@ opt-level = 0
|
|||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
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" }
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ is still missing.
|
|||||||
or one a Nextcloud client keeps in virtual-files mode, where a placeholder
|
or one a Nextcloud client keeps in virtual-files mode, where a placeholder
|
||||||
is treated as the photograph rather than as a one-byte file — or at a
|
is treated as the photograph rather than as a one-byte file — or at a
|
||||||
Nextcloud account directly. The grid is virtualised, ordered by capture
|
Nextcloud account directly. The grid is virtualised, ordered by capture
|
||||||
time with a timeline beside it, and filtered by rating, flag, keyword,
|
time with a timeline beside it, and filtered by rating, flag, person and
|
||||||
person and whether the file is here. Ratings, keywords, collections and a
|
whether the file is here. Ratings, keywords, collections and a
|
||||||
trash that survives a crash mid-operation. Card ingest. Bursts fold. Face
|
trash that survives a crash mid-operation. Card ingest. Bursts fold. Face
|
||||||
detection and identity, with the index syncing between devices.
|
detection and identity, with the index syncing between devices.
|
||||||
|
|
||||||
**Developing.** Fifteen declared operations fused into one compute
|
**Developing.** Eighteen declared operations fused into one compute
|
||||||
dispatch, plus the neighbourhood work that cannot be: clarity, texture,
|
dispatch, plus the neighbourhood work that cannot be: clarity, texture,
|
||||||
capture sharpening, noise reduction, lens correction, spectral film
|
capture sharpening, noise reduction, lens correction, spectral film
|
||||||
simulation. Crop and straighten, spot repair, and local adjustments over
|
simulation. Crop and straighten, spot repair, and local adjustments over
|
||||||
@@ -33,11 +33,11 @@ judging what is recoverable. Named presets; XMP sidecars other editors read.
|
|||||||
|
|
||||||
**Panoramas.** Select the frames, align, choose a projection, fill the
|
**Panoramas.** Select the frames, align, choose a projection, fill the
|
||||||
ragged border rather than crop it, and the composite lands beside its
|
ragged border rather than crop it, and the composite lands beside its
|
||||||
sources as a DNG with the merge as the first step in its history.
|
sources as a DNG, with a sidecar recording what it was merged from.
|
||||||
|
|
||||||
[](docs/manual/README.md#merging-a-panorama)
|
[](docs/manual/README.md#merging-a-panorama)
|
||||||
|
|
||||||
**Export.** JPEG, PNG, AVIF, 8- and 16-bit TIFF, with resize, output
|
**Export.** JPEG, PNG, AVIF, JPEG XL, 8- and 16-bit TIFF, with resize, output
|
||||||
sharpening, a naming template and a colour space — to a folder here or back
|
sharpening, a naming template and a colour space — to a folder here or back
|
||||||
into the library.
|
into the library.
|
||||||
|
|
||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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
|
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
|
## Where it stands
|
||||||
|
|
||||||
**0.13.2**, sixteen tagged releases in. 184 numbered requirements in
|
**0.15.0**, twenty-three tagged releases in. 190 numbered requirements in
|
||||||
scope, 84% of them claimed by code and [traced to it](docs/traceability.md);
|
scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
||||||
the rest are written down rather than merely absent.
|
the rest are written down rather than merely absent.
|
||||||
|
|
||||||
**Not built:** plugins (post-v1, [D12](docs/requirements.md)), compare and
|
**Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
|
||||||
survey culling, AI denoise, tiled and progressive rendering, HDR merge and
|
survey culling, AI denoise, tiled rendering, HDR merge and
|
||||||
focus stacking, most of the Android platform integration beyond running,
|
focus stacking, most of the Android platform integration beyond running,
|
||||||
and the Flatpak's library chooser. The performance targets are half
|
and the Flatpak's library chooser. The performance targets are half
|
||||||
verified: the per-commit benchmark suite §8 requires exists for everything
|
verified: the per-commit benchmark suite §8 requires exists for everything
|
||||||
that does not need a frame — the catalog, the scan, the thumbnails — and
|
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.
|
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.
|
each.
|
||||||
|
|
||||||
**The one deliberate compromise worth knowing about before reading
|
**The one deliberate compromise worth knowing about before reading
|
||||||
anything else:** the Android develop view reads its frame back through the
|
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
|
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,
|
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.
|
has the measurements and the three things any one of which would remove it.
|
||||||
|
|
||||||
## Documentation
|
## 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 |
|
| [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 |
|
| [requirements.md](docs/dev/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 |
|
| [architecture.md](docs/dev/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 |
|
| [technical-debt.md](docs/dev/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 |
|
| [outstanding.md](docs/dev/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 |
|
| [code-health.md](docs/dev/code-health.md) | What a contribution costs, per seam, measured |
|
||||||
| [traceability.md](docs/traceability.md) | Generated: which requirement is claimed by which file |
|
| [traceability.md](docs/dev/traceability.md) | Generated: which requirement is claimed by which file |
|
||||||
|
|
||||||
Designs, one per subsystem:
|
Designs, one per subsystem:
|
||||||
[segmentation](docs/segmentation.md) and [mask editing](docs/mask-editing.md) ·
|
[segmentation](docs/dev/segmentation.md) and [mask editing](docs/dev/mask-editing.md) ·
|
||||||
[spot removal](docs/spot-removal.md) · [panorama](docs/panorama.md) ·
|
[spot removal](docs/dev/spot-removal.md) · [panorama](docs/dev/panorama.md) ·
|
||||||
[faces](docs/faces.md) · [inference](docs/inference.md) ·
|
[faces](docs/dev/faces.md) · [inference](docs/dev/inference.md) ·
|
||||||
[storage and sync](docs/storage.md) · [catalog](docs/catalog.md) ·
|
[storage and sync](docs/dev/storage.md) · [catalog](docs/dev/catalog.md) ·
|
||||||
[display and extension](docs/display-and-extension.md) ·
|
[display and extension](docs/dev/display-and-extension.md) ·
|
||||||
[navigation](docs/ui-navigation.md) · [distribution](docs/distribution.md) ·
|
[navigation](docs/dev/ui-navigation.md) · [distribution](docs/dev/distribution.md) ·
|
||||||
[windows](docs/windows.md) · [benchmarks](docs/benchmarks.md).
|
[windows](docs/dev/windows.md) · [benchmarks](docs/dev/benchmarks.md).
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,23 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
|
||||||
between apps since API 24 — handing one out raises
|
between apps since API 24 — handing one out raises
|
||||||
FileUriExposedException in *this* process — so an exported JPEG
|
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
|
/// **Face weights are absent from the repository by design.** The InsightFace
|
||||||
/// grant is research-only and incompatible with this project's licence
|
/// 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
|
/// `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.
|
/// 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.
|
// before it reports the tab available.
|
||||||
//
|
//
|
||||||
// Three detectors, because which one runs is a setting
|
// 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
|
// obtain the one it was not shipped with. Twenty megabytes of APK for
|
||||||
// the choice; the embedder is the same for all three.
|
// 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
|
// 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
|
// eyes-open filter has something to read, and a tablet has no other way
|
||||||
// to get them either.
|
// to get them either.
|
||||||
//
|
//
|
||||||
// The int8 forms beside the three detectors are what the Hexagon runs
|
// 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.
|
// chose that rung and ignores it otherwise.
|
||||||
const BUNDLED: [(&std::ffi::CStr, &str); 14] = [
|
const BUNDLED: [(&std::ffi::CStr, &str); 14] = [
|
||||||
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
|
(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
|
// 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`,
|
// 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
|
// 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());
|
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]
|
#[test]
|
||||||
fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() {
|
fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() {
|
||||||
let manifest = manifest();
|
let manifest = manifest();
|
||||||
|
|||||||
@@ -25,3 +25,6 @@ winresource = "0.1"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
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
|
// build made on a machine that cannot run the application — the Linux CI
|
||||||
// producing the Windows binary, checked under Wine — has an exit that
|
// producing the Windows binary, checked under Wine — has an exit that
|
||||||
// proves the executable starts without opening a window or touching the
|
// 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") {
|
if std::env::args().nth(1).as_deref() == Some("--version") {
|
||||||
println!("darkroom-desktop {}", env!("CARGO_PKG_VERSION"));
|
println!("darkroom-desktop {}", env!("CARGO_PKG_VERSION"));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -63,7 +63,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Before the window: the probe runs on its own thread and the first
|
// 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
|
// 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::inference::init(runtime_dirs());
|
||||||
|
|
||||||
dr_ui::run(paths)?;
|
dr_ui::run(paths)?;
|
||||||
@@ -78,7 +78,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
/// Where a desktop package may have put `libonnxruntime`, most specific
|
/// Where a desktop package may have put `libonnxruntime`, most specific
|
||||||
/// first. None of these existing is the tract build, which is a complete
|
/// 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
|
/// `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
|
/// 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
|
// The three phases, separately, because "a regroup takes n seconds" does
|
||||||
// not tell anyone which half to optimise — and the answer differs between
|
// 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 dim = candidates.first().map(|c| c.embedding.len()).unwrap_or(0);
|
||||||
let flat: Vec<f32> = candidates
|
let flat: Vec<f32> = candidates
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
//! Grouping has no natural `subject_id`: it is a property of a *run* of frames,
|
//! 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
|
//! so a per-image job would rebuild the world once per photograph. It is
|
||||||
//! therefore a debounced library-level pass, for exactly the reasons
|
//! 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.
|
//! of it — one ordered walk, no per-pair comparison beyond adjacent frames.
|
||||||
//!
|
//!
|
||||||
//! # Grouping is not hiding
|
//! # Grouping is not hiding
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! Face data as sealed shards, so a second device does not re-index the library.
|
//! 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
|
//! 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
|
//! 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
|
//! 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.
|
//! module, and it is the same bargain the thumbnail store already makes.
|
||||||
@@ -178,6 +178,67 @@ impl FaceShardStore {
|
|||||||
.flatten()
|
.flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The other pipelines this file is held under that share `model_id`'s
|
||||||
|
/// embedder — the generations a put of `model_id` may supersede.
|
||||||
|
fn siblings(&self, file_id: u64, model_id: &str) -> Vec<String> {
|
||||||
|
let mut stmt = match self.index.prepare(&format!(
|
||||||
|
"SELECT model_id FROM entries
|
||||||
|
WHERE file_id = ?1 AND model_id != ?2 AND {} = ?3",
|
||||||
|
crate::faces::embedder_sql("model_id")
|
||||||
|
)) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
stmt.query_map(
|
||||||
|
rusqlite::params![
|
||||||
|
file_id as i64,
|
||||||
|
model_id,
|
||||||
|
crate::faces::embedder_of(model_id)
|
||||||
|
],
|
||||||
|
|r| r.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.map(|rows| rows.filter_map(|r| r.ok()).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a pass this file is already held under outranks `model_id`,
|
||||||
|
/// so a put of `model_id` would add a generation nobody would adopt.
|
||||||
|
pub fn outranked(&self, file_id: u64, model_id: &str) -> bool {
|
||||||
|
use dr_types::FaceDetector;
|
||||||
|
let Some(incoming) = FaceDetector::for_model_id(model_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.siblings(file_id, model_id)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| FaceDetector::for_model_id(m))
|
||||||
|
.any(|held| held.outranks(incoming))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the index entries for generations of this file that `model_id`
|
||||||
|
/// outranks. The bytes stay where they are — a sealed shard is
|
||||||
|
/// immutable — but the store stops offering them, and a later export or
|
||||||
|
/// merge writes nothing for them again.
|
||||||
|
fn supersede(&self, file_id: u64, model_id: &str) -> Result<(), CatalogError> {
|
||||||
|
use dr_types::FaceDetector;
|
||||||
|
let Some(incoming) = FaceDetector::for_model_id(model_id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
for held in self.siblings(file_id, model_id) {
|
||||||
|
let weaker = FaceDetector::for_model_id(&held).is_some_and(|h| incoming.outranks(h));
|
||||||
|
if weaker {
|
||||||
|
self.index.execute(
|
||||||
|
"DELETE FROM entries WHERE file_id = ?1 AND model_id = ?2",
|
||||||
|
rusqlite::params![file_id as i64, held],
|
||||||
|
)?;
|
||||||
|
self.index.execute(
|
||||||
|
"DELETE FROM faces_meta WHERE file_id = ?1 AND model_id = ?2",
|
||||||
|
rusqlite::params![file_id as i64, held],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn contains(&self, file_id: u64, model_id: &str) -> bool {
|
pub fn contains(&self, file_id: u64, model_id: &str) -> bool {
|
||||||
self.index
|
self.index
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -233,6 +294,15 @@ impl FaceShardStore {
|
|||||||
faces: &[SharedFace],
|
faces: &[SharedFace],
|
||||||
indexed_at: Option<i64>,
|
indexed_at: Option<i64>,
|
||||||
) -> Result<u32, CatalogError> {
|
) -> Result<u32, CatalogError> {
|
||||||
|
// One generation per image per embedder. A store carried every pass
|
||||||
|
// — 24,123 entries for 19,089 images on the reference library, a
|
||||||
|
// third of its 293 MB — and only the strongest was ever adopted.
|
||||||
|
// A weaker pass arriving after a stronger one is not written; a
|
||||||
|
// stronger one arriving retires the weaker from the index.
|
||||||
|
if self.outranked(file_id, model_id) {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
self.supersede(file_id, model_id)?;
|
||||||
let incoming = faces
|
let incoming = faces
|
||||||
.iter()
|
.iter()
|
||||||
.map(|f| BYTES_PER_FACE + if f.crop.is_empty() { 0 } else { BYTES_PER_CROP })
|
.map(|f| BYTES_PER_FACE + if f.crop.is_empty() { 0 } else { BYTES_PER_CROP })
|
||||||
@@ -474,15 +544,28 @@ impl FaceShardStore {
|
|||||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut q =
|
// The peer's marker travels with the image: it is what lets
|
||||||
src.prepare("SELECT file_id, model_id, faces_found, source_edge FROM indexed")?;
|
// `import_from_shards` record the adoption under the time the peer
|
||||||
let images: Vec<(i64, String, i64, i64)> = q
|
// indexed it, and so what keeps `export_to_shards` from reading the
|
||||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?
|
// adoption as a re-index and sending the peer's faces back out under
|
||||||
|
// this device's name. A shard from before the column has none.
|
||||||
|
let mut q = src.prepare(&format!(
|
||||||
|
"SELECT file_id, model_id, faces_found, source_edge, {} FROM indexed",
|
||||||
|
match has_column(&src, "indexed", "indexed_at") {
|
||||||
|
Ok(true) => "indexed_at",
|
||||||
|
_ => "NULL",
|
||||||
|
}
|
||||||
|
))?;
|
||||||
|
let images: Vec<(i64, String, i64, i64, Option<i64>)> = q
|
||||||
|
.query_map([], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
|
||||||
|
})?
|
||||||
.collect::<Result<_, _>>()?;
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
let mut adopted = 0;
|
let mut adopted = 0;
|
||||||
for (file_id, model_id, _found, edge) in images {
|
for (file_id, model_id, _found, edge, indexed_at) in images {
|
||||||
if self.contains(file_id as u64, &model_id) {
|
if self.contains(file_id as u64, &model_id) || self.outranked(file_id as u64, &model_id)
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut fq = src.prepare(&format!(
|
let mut fq = src.prepare(&format!(
|
||||||
@@ -501,12 +584,27 @@ impl FaceShardStore {
|
|||||||
let faces: Vec<SharedFace> = fq
|
let faces: Vec<SharedFace> = fq
|
||||||
.query_map(rusqlite::params![file_id, &model_id], read_shared_face)?
|
.query_map(rusqlite::params![file_id, &model_id], read_shared_face)?
|
||||||
.collect::<Result<_, _>>()?;
|
.collect::<Result<_, _>>()?;
|
||||||
self.put_image(file_id as u64, &model_id, edge as u32, &faces)?;
|
self.put_image_at(file_id as u64, &model_id, edge as u32, &faces, indexed_at)?;
|
||||||
adopted += 1;
|
adopted += 1;
|
||||||
}
|
}
|
||||||
Ok(adopted)
|
Ok(adopted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record when the catalog indexed a held image, for an entry that
|
||||||
|
/// arrived without a marker — a peer's shard from before the column.
|
||||||
|
pub fn set_indexed_at(
|
||||||
|
&self,
|
||||||
|
file_id: u64,
|
||||||
|
model_id: &str,
|
||||||
|
at: i64,
|
||||||
|
) -> Result<(), CatalogError> {
|
||||||
|
self.index.execute(
|
||||||
|
"UPDATE entries SET indexed_at = ?3 WHERE file_id = ?1 AND model_id = ?2",
|
||||||
|
rusqlite::params![file_id as i64, model_id, at],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Read back everything held for one image.
|
/// Read back everything held for one image.
|
||||||
pub fn get_image(
|
pub fn get_image(
|
||||||
&self,
|
&self,
|
||||||
@@ -798,8 +896,21 @@ pub fn import_from_shards(
|
|||||||
})?
|
})?
|
||||||
.collect::<Result<_, _>>()?;
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
|
/// Images per write transaction. Large enough that fourteen thousand
|
||||||
|
/// adoptions are a hundred and forty commits rather than fourteen
|
||||||
|
/// thousand; small enough that a read on the UI thread, queued behind
|
||||||
|
/// the lock, waits a fraction of a second and not the whole import.
|
||||||
|
const CHUNK: usize = 100;
|
||||||
|
|
||||||
let mut adopted = 0;
|
let mut adopted = 0;
|
||||||
|
let mut tx = conn.unchecked_transaction()?;
|
||||||
|
let mut in_chunk = 0;
|
||||||
for (file_id, image_id, local) in candidates {
|
for (file_id, image_id, local) in candidates {
|
||||||
|
if in_chunk == CHUNK {
|
||||||
|
tx.commit()?;
|
||||||
|
tx = conn.unchecked_transaction()?;
|
||||||
|
in_chunk = 0;
|
||||||
|
}
|
||||||
let Some(held) = store.held_model(file_id as u64, model_id) else {
|
let Some(held) = store.held_model(file_id as u64, model_id) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -820,20 +931,22 @@ pub fn import_from_shards(
|
|||||||
let Some((faces, edge)) = store.get_image(file_id as u64, &held)? else {
|
let Some((faces, edge)) = store.get_image(file_id as u64, &held)? else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// A peer that embedded before the quality was kept has done work this
|
// A face the peer embedded before its quality was kept (schema V14)
|
||||||
// device cannot finish: the number exists only at embedding time, and
|
// is adopted with the reading missing, exactly as one without an eye
|
||||||
// adopting the faces would write the run marker that keeps them from
|
// reading is. The measuring passes find their work by the NULL
|
||||||
// ever being measured (schema V14). Left for this device's own pass —
|
// column, not by the run marker (`dr_ui::repairs`, `faces_needing`),
|
||||||
// or for the peer's, whose re-export replaces these.
|
// so adopting costs the reading nothing and this device's own pass
|
||||||
|
// fills it.
|
||||||
//
|
//
|
||||||
// A missing *eye* reading is not the same case and is adopted. The
|
// This used to refuse such faces, on the reasoning that the marker
|
||||||
// measuring pass finds those by the NULL, not by the marker, so
|
// would stop them ever being measured — true before the quality
|
||||||
// adopting the faces costs the reading nothing (schema V16) — and a
|
// repair existed, and wrong after. What it cost: V14 had dropped the
|
||||||
// peer that has no eye models may be the only one that has done the
|
// markers of every image holding such faces, so the peer never
|
||||||
// detection at all.
|
// re-exported them, and the only copies in the shards were the
|
||||||
if faces.iter().any(|f| f.quality.is_none()) {
|
// unmeasured ones. A tablet holding shards with 4,310 of the
|
||||||
continue;
|
// desktop's images and 3,170 of its confirmations declined every one
|
||||||
}
|
// of them, showed a fraction of each person, and queued the whole
|
||||||
|
// library for a re-detection of its own instead.
|
||||||
let local: Vec<crate::faces::DetectedFace> = faces
|
let local: Vec<crate::faces::DetectedFace> = faces
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| crate::faces::DetectedFace {
|
.map(|f| crate::faces::DetectedFace {
|
||||||
@@ -856,15 +969,42 @@ pub fn import_from_shards(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
crate::faces::record_detections(
|
crate::faces::record_detections_within(
|
||||||
conn,
|
&tx,
|
||||||
dr_types::ImageId(image_id as u64),
|
dr_types::ImageId(image_id as u64),
|
||||||
&held,
|
&held,
|
||||||
edge,
|
edge,
|
||||||
&local,
|
&local,
|
||||||
)?;
|
)?;
|
||||||
|
// The peer's marker, not this moment. `record_detections` stamps the
|
||||||
|
// run as now, and `export_to_shards` reads a marker newer than the
|
||||||
|
// shard's as a re-index — so every adopted image went straight back
|
||||||
|
// out as this device's own work: 14,100 adopted, 15,457 "newly
|
||||||
|
// indexed" on the next pass, and twenty-two shards of a peer's faces
|
||||||
|
// uploaded again under a second name. Where the peer's shard carried
|
||||||
|
// no marker, the store takes the catalog's, so the two agree either
|
||||||
|
// way and the export sees nothing to send.
|
||||||
|
match store.indexed_at(file_id as u64, &held) {
|
||||||
|
Some(theirs) => {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE face_index SET indexed_at = ?3
|
||||||
|
WHERE image_id = ?1 AND model_id = ?2",
|
||||||
|
rusqlite::params![image_id, held, theirs],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let ours: i64 = tx.query_row(
|
||||||
|
"SELECT indexed_at FROM face_index WHERE image_id = ?1 AND model_id = ?2",
|
||||||
|
rusqlite::params![image_id, held],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
store.set_indexed_at(file_id as u64, &held, ours)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
adopted += 1;
|
adopted += 1;
|
||||||
|
in_chunk += 1;
|
||||||
}
|
}
|
||||||
|
tx.commit()?;
|
||||||
Ok(adopted)
|
Ok(adopted)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1100,6 +1240,32 @@ mod tests {
|
|||||||
assert!(!s.contains(1, "lvface"));
|
assert!(!s.contains(1, "lvface"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One generation per image per embedder: a stronger detector's pass
|
||||||
|
/// retires a weaker one from the index, and a weaker pass arriving after
|
||||||
|
/// a stronger is not written at all.
|
||||||
|
#[test]
|
||||||
|
fn a_stronger_pass_retires_a_weaker_one_and_a_weaker_is_not_added() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let mut s = FaceShardStore::open(&dir).unwrap();
|
||||||
|
s.put_image(1, "w600k_mbf", 1024, &[face(1, 1)]).unwrap();
|
||||||
|
s.put_image(1, "scrfd_10g+w600k_mbf", 1024, &[face(1, 2)])
|
||||||
|
.unwrap();
|
||||||
|
assert!(s.contains(1, "scrfd_10g+w600k_mbf"));
|
||||||
|
assert!(!s.contains(1, "w600k_mbf"), "the fast pass was not retired");
|
||||||
|
assert_eq!(s.len(), 1, "faces_meta still counts the retired pass");
|
||||||
|
|
||||||
|
s.put_image(1, "scrfd_2.5g+w600k_mbf", 1024, &[face(1, 3)])
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!s.contains(1, "scrfd_2.5g+w600k_mbf"),
|
||||||
|
"a weaker pass was added"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.held_model(1, "w600k_mbf").as_deref(),
|
||||||
|
Some("scrfd_10g+w600k_mbf")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn re_storing_an_image_replaces_rather_than_doubling_it() {
|
fn re_storing_an_image_replaces_rather_than_doubling_it() {
|
||||||
let dir = tempdir();
|
let dir = tempdir();
|
||||||
@@ -1320,6 +1486,11 @@ mod catalog_round_trip {
|
|||||||
assert!((got[0].landmarks[2].0 - 0.15).abs() < 1e-5);
|
assert!((got[0].landmarks[2].0 - 0.15).abs() < 1e-5);
|
||||||
let emb = faces::embeddings(&b, "w600k_mbf").unwrap();
|
let emb = faces::embeddings(&b, "w600k_mbf").unwrap();
|
||||||
assert!(emb.iter().any(|e| e.embedding[0] == 1));
|
assert!(emb.iter().any(|e| e.embedding[0] == 1));
|
||||||
|
|
||||||
|
// And what B adopted is not B's work: its next export sends nothing.
|
||||||
|
// Adopting used to stamp the run as now, so every adopted image went
|
||||||
|
// back out under B's name as a re-index.
|
||||||
|
assert_eq!(export_to_shards(&b, &mut store_b, "w600k_mbf").unwrap(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The desktop switched to a stronger detector part-way through the
|
/// The desktop switched to a stronger detector part-way through the
|
||||||
@@ -1365,11 +1536,12 @@ mod catalog_round_trip {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A face a peer embedded without measuring it is work this device
|
/// A face a peer embedded without measuring it is adopted all the same,
|
||||||
/// cannot finish, and adopting it would write the marker that stops it
|
/// and left on this device's quality pass by its missing reading. Refusing
|
||||||
/// ever being measured. The image stays outstanding instead.
|
/// it was what stranded every confirmation the desktop had made on faces
|
||||||
|
/// from before V14: the tablet held the shards and would not use them.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_peers_unmeasured_faces_are_left_for_this_device_to_index() {
|
fn a_peers_unmeasured_faces_are_adopted_and_left_for_the_quality_pass() {
|
||||||
let b = device(&[(90, 5001), (91, 5002)]);
|
let b = device(&[(90, 5001), (91, 5002)]);
|
||||||
let mut store = FaceShardStore::open(&tempdir("unmeasured")).unwrap();
|
let mut store = FaceShardStore::open(&tempdir("unmeasured")).unwrap();
|
||||||
let shared = |file_id: u64, quality: Option<f32>| SharedFace {
|
let shared = |file_id: u64, quality: Option<f32>| SharedFace {
|
||||||
@@ -1395,13 +1567,18 @@ mod catalog_round_trip {
|
|||||||
.put_image(5002, "w600k_mbf", 2560, &[shared(5002, Some(19.0))])
|
.put_image(5002, "w600k_mbf", 2560, &[shared(5002, Some(19.0))])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(import_from_shards(&b, &store, "w600k_mbf").unwrap(), 1);
|
assert_eq!(import_from_shards(&b, &store, "w600k_mbf").unwrap(), 2);
|
||||||
let cov = faces::coverage(&b, "w600k_mbf").unwrap();
|
let cov = faces::coverage(&b, "w600k_mbf").unwrap();
|
||||||
assert_eq!(cov.indexed, 1);
|
assert_eq!(cov.indexed, 2);
|
||||||
assert_eq!(cov.outstanding(), 1, "the unmeasured image was adopted");
|
assert_eq!(cov.outstanding(), 0, "the unmeasured image was refused");
|
||||||
assert!(faces::for_image(&b, dr_types::ImageId(90))
|
let got = faces::for_image(&b, dr_types::ImageId(90)).unwrap();
|
||||||
.unwrap()
|
assert_eq!(got.len(), 1);
|
||||||
.is_empty());
|
assert_eq!(got[0].quality, None, "a reading was invented");
|
||||||
|
// Still owed to the measuring pass, which lists by the column.
|
||||||
|
assert_eq!(
|
||||||
|
faces::count_needing(&b, "w600k_mbf", "f.quality IS NULL").unwrap(),
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+133
-11
@@ -1,7 +1,7 @@
|
|||||||
//! TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
|
//! 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.
|
//! 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
|
//! 512 numbers; this module is where those numbers acquire an identity, and
|
||||||
//! where the user's corrections outrank the model's guesses.
|
//! 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
|
/// Raw rather than unit length, so the length ([`Self::quality`]) is in
|
||||||
/// the blob and not only beside it. Readers re-normalise on load.
|
/// the blob and not only beside it. Readers re-normalise on load.
|
||||||
pub embedding: Vec<u8>,
|
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,
|
pub crop_px: f32,
|
||||||
/// Length of the raw embedding before normalisation — the model's own
|
/// Length of the raw embedding before normalisation — the model's own
|
||||||
/// reading of how recognisable the crop was, and the gate on whether
|
/// 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
|
/// the same face in the same photograph, for carrying an identity across a
|
||||||
/// re-detection.
|
/// 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
|
/// 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
|
/// 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*
|
/// does. The number is only ever asked about *overlapping* boxes on *one*
|
||||||
@@ -279,7 +279,25 @@ pub fn record_detections(
|
|||||||
faces: &[DetectedFace],
|
faces: &[DetectedFace],
|
||||||
) -> Result<Vec<FaceId>, CatalogError> {
|
) -> Result<Vec<FaceId>, CatalogError> {
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
let ids = record_detections_within(&tx, image_id, model_id, source_edge, faces)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`record_detections`] inside a transaction the caller owns.
|
||||||
|
///
|
||||||
|
/// For a caller recording many images at once — the shard import adopts
|
||||||
|
/// fourteen thousand in one pass — where a commit per image is fourteen
|
||||||
|
/// thousand fsyncs and fourteen thousand turns at the write lock that every
|
||||||
|
/// read on the UI thread queues behind. `unchecked_transaction` cannot nest,
|
||||||
|
/// so the batching has to be offered here rather than wrapped from above.
|
||||||
|
pub fn record_detections_within(
|
||||||
|
tx: &Connection,
|
||||||
|
image_id: ImageId,
|
||||||
|
model_id: &str,
|
||||||
|
source_edge: u32,
|
||||||
|
faces: &[DetectedFace],
|
||||||
|
) -> Result<Vec<FaceId>, CatalogError> {
|
||||||
// Everything the old faces knew, so it can be carried across the
|
// Everything the old faces knew, so it can be carried across the
|
||||||
// replacement. Read only when there is something to carry it onto: a
|
// replacement. Read only when there is something to carry it onto: a
|
||||||
// pass that found nothing has nothing to match, and decoding a vector
|
// pass that found nothing has nothing to match, and decoding a vector
|
||||||
@@ -287,7 +305,7 @@ pub fn record_detections(
|
|||||||
let prior = if faces.is_empty() {
|
let prior = if faces.is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
read_priors(&tx, image_id)?
|
read_priors(tx, image_id)?
|
||||||
};
|
};
|
||||||
|
|
||||||
tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?;
|
tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?;
|
||||||
@@ -389,7 +407,6 @@ pub fn record_detections(
|
|||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
tx.commit()?;
|
|
||||||
Ok(ids)
|
Ok(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,6 +573,19 @@ impl FaceUpdate {
|
|||||||
/// bookkeeping: `face_shard::export_to_shards` re-exports an image whose
|
/// bookkeeping: `face_shard::export_to_shards` re-exports an image whose
|
||||||
/// marker is newer than the store's copy, which is how what was written
|
/// marker is newer than the store's copy, which is how what was written
|
||||||
/// here reaches the other devices.
|
/// here reaches the other devices.
|
||||||
|
///
|
||||||
|
/// It is re-written under the pipeline id the **faces carry**, not the one
|
||||||
|
/// this pass ran as. `model_id` names the pass only through its embedder;
|
||||||
|
/// the detector half of a marker is a statement about who drew the boxes,
|
||||||
|
/// and this pass drew none. Every reader takes the two to agree: the export
|
||||||
|
/// selects an image's faces by the marker's id, `marker_under` takes a
|
||||||
|
/// marker as proof the detector has been over the image, and the shard
|
||||||
|
/// store keys each face by it. When the marker was written as
|
||||||
|
/// `scrfd_10g+w600k_mbf` over faces still spelled `w600k_mbf`, the export
|
||||||
|
/// found no faces under it and sent the other devices an entry saying the
|
||||||
|
/// thorough detector had looked and found nothing — over photographs with
|
||||||
|
/// named faces on them. With no faces left, the pass's own id is the only
|
||||||
|
/// one there is, and the marker says so.
|
||||||
pub fn record_updates(
|
pub fn record_updates(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
image_id: ImageId,
|
image_id: ImageId,
|
||||||
@@ -602,13 +632,25 @@ pub fn record_updates(
|
|||||||
for f in dropped {
|
for f in dropped {
|
||||||
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
|
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
|
||||||
}
|
}
|
||||||
let remaining: i64 = tx.query_row(
|
let (remaining, found_by): (i64, Option<String>) = tx.query_row(
|
||||||
&format!(
|
&format!(
|
||||||
"SELECT COUNT(*) FROM faces WHERE image_id = ?1 AND {} = ?2",
|
"SELECT COUNT(*), MIN(model_id) FROM faces WHERE image_id = ?1 AND {} = ?2",
|
||||||
embedder_sql("model_id")
|
embedder_sql("model_id")
|
||||||
),
|
),
|
||||||
rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|
rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|
||||||
|r| r.get(0),
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)?;
|
||||||
|
let marker = found_by.as_deref().unwrap_or(model_id);
|
||||||
|
// One marker per embedder: a stale one under another spelling would
|
||||||
|
// keep saying that detector had been here, which is the claim the
|
||||||
|
// faces' own id is now making in its place.
|
||||||
|
tx.execute(
|
||||||
|
&format!(
|
||||||
|
"DELETE FROM face_index
|
||||||
|
WHERE image_id = ?1 AND model_id != ?2 AND {} = ?3",
|
||||||
|
embedder_sql("model_id")
|
||||||
|
),
|
||||||
|
rusqlite::params![image_id.0 as i64, marker, embedder_of(model_id)],
|
||||||
)?;
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO face_index (image_id, model_id, indexed_at, faces_found, source_edge)
|
"INSERT INTO face_index (image_id, model_id, indexed_at, faces_found, source_edge)
|
||||||
@@ -619,7 +661,7 @@ pub fn record_updates(
|
|||||||
source_edge = excluded.source_edge",
|
source_edge = excluded.source_edge",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
image_id.0 as i64,
|
image_id.0 as i64,
|
||||||
model_id,
|
marker,
|
||||||
now_secs(),
|
now_secs(),
|
||||||
remaining,
|
remaining,
|
||||||
source_edge as i64,
|
source_edge as i64,
|
||||||
@@ -1557,7 +1599,7 @@ fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now_secs() -> i64 {
|
pub(crate) fn now_secs() -> i64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs() as i64)
|
.map(|d| d.as_secs() as i64)
|
||||||
@@ -1890,6 +1932,86 @@ mod tests {
|
|||||||
assert!(at >= marked_at, "the marker was not refreshed");
|
assert!(at >= marked_at, "the marker was not refreshed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The marker a per-face pass leaves names the detector that drew the
|
||||||
|
/// boxes, whatever pipeline the pass itself ran as. A marker under the
|
||||||
|
/// pass's id over faces spelled another way is one the export finds no
|
||||||
|
/// faces under — and it sent every other device "nothing here".
|
||||||
|
#[test]
|
||||||
|
fn an_update_keeps_the_marker_under_the_detector_that_found_the_faces() {
|
||||||
|
let c = db();
|
||||||
|
let img = image(&c, 1);
|
||||||
|
let ids = record_detections(
|
||||||
|
&c,
|
||||||
|
img,
|
||||||
|
"w600k_mbf",
|
||||||
|
1024,
|
||||||
|
&[DetectedFace {
|
||||||
|
quality: None,
|
||||||
|
..face(1)
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// The state V14 leaves: the faces, and no marker at all.
|
||||||
|
c.execute("DELETE FROM face_index", []).unwrap();
|
||||||
|
|
||||||
|
record_updates(
|
||||||
|
&c,
|
||||||
|
img,
|
||||||
|
"scrfd_10g+w600k_mbf",
|
||||||
|
6000,
|
||||||
|
&[FaceUpdate {
|
||||||
|
embedding: Some((vec![9; 1024], 21.5)),
|
||||||
|
..FaceUpdate::for_face(ids[0])
|
||||||
|
}],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let markers: Vec<(String, i64)> = c
|
||||||
|
.prepare("SELECT model_id, faces_found FROM face_index")
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||||
|
.unwrap()
|
||||||
|
.map(Result::unwrap)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(markers, vec![("w600k_mbf".to_string(), 1)]);
|
||||||
|
|
||||||
|
// A marker already there under the pass's own id is replaced, not
|
||||||
|
// kept beside the right one.
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
|
||||||
|
VALUES (1, 'scrfd_10g+w600k_mbf', 0, 0, 6000)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
record_updates(
|
||||||
|
&c,
|
||||||
|
img,
|
||||||
|
"scrfd_10g+w600k_mbf",
|
||||||
|
6000,
|
||||||
|
&[FaceUpdate {
|
||||||
|
crop: Some(vec![1, 2, 3]),
|
||||||
|
..FaceUpdate::for_face(ids[0])
|
||||||
|
}],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let n: i64 = c
|
||||||
|
.query_row("SELECT COUNT(*) FROM face_index", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 1, "a second marker survived");
|
||||||
|
|
||||||
|
// With every face dropped there is no detector left to name, and
|
||||||
|
// the pass's own id records that it looked.
|
||||||
|
record_updates(&c, img, "scrfd_10g+w600k_mbf", 6000, &[], &ids).unwrap();
|
||||||
|
let marker: (String, i64) = c
|
||||||
|
.query_row("SELECT model_id, faces_found FROM face_index", [], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(marker, ("scrfd_10g+w600k_mbf".to_string(), 0));
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-detection is coalesced per image, so it must replace rather than
|
/// Re-detection is coalesced per image, so it must replace rather than
|
||||||
/// append — otherwise every re-index doubles the library's face count.
|
/// append — otherwise every re-index doubles the library's face count.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2364,7 +2486,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The reference implementation's fitted MBF curve puts the P=0.5 boundary
|
/// 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
|
/// 0.596 between distinct photographs of one person and 0.05 between
|
||||||
/// different people, so those two must land either side.
|
/// different people, so those two must land either side.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ pub struct MergeReport {
|
|||||||
pub keywords_fused: usize,
|
pub keywords_fused: usize,
|
||||||
/// Keyword assignments taken from the remote.
|
/// Keyword assignments taken from the remote.
|
||||||
pub keywords_assigned: usize,
|
pub keywords_assigned: usize,
|
||||||
|
/// Images whose capture metadata was taken from the remote.
|
||||||
|
pub metadata_adopted: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MergeReport {
|
impl MergeReport {
|
||||||
@@ -133,6 +135,7 @@ impl MergeReport {
|
|||||||
|| self.keywords_deleted > 0
|
|| self.keywords_deleted > 0
|
||||||
|| self.keywords_fused > 0
|
|| self.keywords_fused > 0
|
||||||
|| self.keywords_assigned > 0
|
|| self.keywords_assigned > 0
|
||||||
|
|| self.metadata_adopted > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the local catalog holds anything the remote did not, and so
|
/// Whether the local catalog holds anything the remote did not, and so
|
||||||
@@ -193,10 +196,67 @@ pub fn merge_all(conn: &Connection) -> Result<MergeReport, CatalogError> {
|
|||||||
merge_collections_within(&tx, &mut report)?;
|
merge_collections_within(&tx, &mut report)?;
|
||||||
merge_keywords_within(&tx, &mut report)?;
|
merge_keywords_within(&tx, &mut report)?;
|
||||||
merge_people_within(&tx, &mut report)?;
|
merge_people_within(&tx, &mut report)?;
|
||||||
|
merge_metadata_within(&tx, &mut report)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adopt capture metadata from an attached catalog, on its own.
|
||||||
|
pub fn merge_metadata(conn: &Connection) -> Result<MergeReport, CatalogError> {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
let mut report = MergeReport::default();
|
||||||
|
merge_metadata_within(&tx, &mut report)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture metadata a peer's sweep already read, for images this device has
|
||||||
|
/// not dated yet.
|
||||||
|
///
|
||||||
|
/// The `images` table is local state and the merge leaves it alone — except
|
||||||
|
/// for these columns, which are not: a capture time, an offset, a camera, a
|
||||||
|
/// lens and an ISO are facts about the file's bytes, identical on every
|
||||||
|
/// device, and read by fetching a header per image across the whole library
|
||||||
|
/// (`dr_ui::library::spawn_sweep`). A fresh device inherits its peers'
|
||||||
|
/// thumbnails and faces from the shards and then spent hours re-reading
|
||||||
|
/// every header for the timeline; the snapshot it had just merged held
|
||||||
|
/// every one of those dates.
|
||||||
|
///
|
||||||
|
/// Matched by `oc:fileid`, as collection membership is. Only rows still at
|
||||||
|
/// `metadata_state < 2` take anything, and only from a remote row at 2: a
|
||||||
|
/// date this device read for itself is never overwritten, and a peer that
|
||||||
|
/// has not read one has nothing to give. The sweep's own query
|
||||||
|
/// (`metadata_state < 2`) then finds nothing left to do for them.
|
||||||
|
const METADATA_BY_FILE_ID: &str = "
|
||||||
|
UPDATE main.images
|
||||||
|
SET captured_at = r.captured_at,
|
||||||
|
captured_offset = coalesce(main.images.captured_offset, r.captured_offset),
|
||||||
|
camera = coalesce(main.images.camera, r.camera),
|
||||||
|
lens = coalesce(main.images.lens, r.lens),
|
||||||
|
iso = coalesce(main.images.iso, r.iso),
|
||||||
|
metadata_state = 2
|
||||||
|
FROM (SELECT lr.image_id, ri.captured_at, ri.captured_offset,
|
||||||
|
ri.camera, ri.lens, ri.iso
|
||||||
|
FROM remote_cat.images ri
|
||||||
|
JOIN remote_cat.remote rr ON rr.image_id = ri.id
|
||||||
|
JOIN main.remote lr ON lr.file_id = rr.file_id
|
||||||
|
WHERE ri.metadata_state >= 2 AND ri.captured_at IS NOT NULL) AS r
|
||||||
|
WHERE main.images.id = r.image_id
|
||||||
|
AND main.images.metadata_state < 2";
|
||||||
|
|
||||||
|
fn merge_metadata_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> {
|
||||||
|
// A snapshot from before these columns, or from a library with no server
|
||||||
|
// behind it, has nothing to join on.
|
||||||
|
if !remote_has(tx, "remote")?
|
||||||
|
|| !remote_has_column(tx, "images", "metadata_state")?
|
||||||
|
|| !remote_has_column(tx, "images", "captured_offset")?
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
report.metadata_adopted = tx.execute(METADATA_BY_FILE_ID, [])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge people and identity judgements from an attached catalog.
|
/// Merge people and identity judgements from an attached catalog.
|
||||||
///
|
///
|
||||||
/// The people half of [`merge_all`], on its own, for the same reason the other
|
/// The people half of [`merge_all`], on its own, for the same reason the other
|
||||||
@@ -729,7 +789,7 @@ fn attached_has_table(conn: &Connection, schema: &str, table: &str) -> Result<bo
|
|||||||
/// # What travels, and what is recomputed
|
/// # What travels, and what is recomputed
|
||||||
///
|
///
|
||||||
/// The rule this module already follows for the rest of the catalog: user
|
/// 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):
|
/// asymmetry `crate::faces` opens with):
|
||||||
///
|
///
|
||||||
/// - **People** — uuid, name, and whether the user set them aside. Merged by
|
/// - **People** — uuid, name, and whether the user set them aside. Merged by
|
||||||
@@ -1168,6 +1228,57 @@ mod tests {
|
|||||||
|
|
||||||
// ---- integration over two real catalogs ------------------------------
|
// ---- integration over two real catalogs ------------------------------
|
||||||
|
|
||||||
|
/// A fresh device takes the capture dates a peer's sweep read, matched by
|
||||||
|
/// `oc:fileid`, and never overwrites a date it read for itself.
|
||||||
|
#[test]
|
||||||
|
fn capture_metadata_arrives_for_undated_images_only() {
|
||||||
|
let c = two_catalogs();
|
||||||
|
// Three photographs on both devices: 1 undated here and dated there;
|
||||||
|
// 2 dated on both, differently; 3 undated on both.
|
||||||
|
for id in 1..=3 {
|
||||||
|
add_image_without_hash(&c, "main", id);
|
||||||
|
add_image_without_hash(&c, "remote_cat", id + 10);
|
||||||
|
add_remote_id(&c, "main", id, 100 + id);
|
||||||
|
add_remote_id(&c, "remote_cat", id + 10, 100 + id);
|
||||||
|
}
|
||||||
|
c.execute(
|
||||||
|
"UPDATE remote_cat.images
|
||||||
|
SET captured_at = 1000, captured_offset = 60, camera = 'X', metadata_state = 2
|
||||||
|
WHERE id = 11",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.execute(
|
||||||
|
"UPDATE remote_cat.images SET captured_at = 2000, metadata_state = 2 WHERE id = 12",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.execute(
|
||||||
|
"UPDATE main.images SET captured_at = 2222, metadata_state = 2 WHERE id = 2",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let report = merge_metadata(&c).unwrap();
|
||||||
|
assert_eq!(report.metadata_adopted, 1);
|
||||||
|
|
||||||
|
let row = |id: i64| -> (Option<i64>, Option<i64>, Option<String>, i64) {
|
||||||
|
c.query_row(
|
||||||
|
"SELECT captured_at, captured_offset, camera, metadata_state
|
||||||
|
FROM main.images WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(row(1), (Some(1000), Some(60), Some("X".into()), 2));
|
||||||
|
assert_eq!(row(2), (Some(2222), None, None, 2));
|
||||||
|
assert_eq!(row(3), (None, None, None, 0));
|
||||||
|
|
||||||
|
// Idempotent: a second pass finds nothing left to take.
|
||||||
|
assert_eq!(merge_metadata(&c).unwrap().metadata_adopted, 0);
|
||||||
|
}
|
||||||
|
|
||||||
fn two_catalogs() -> Connection {
|
fn two_catalogs() -> Connection {
|
||||||
attached_remote(schema::for_attached("remote_cat"))
|
attached_remote(schema::for_attached("remote_cat"))
|
||||||
}
|
}
|
||||||
|
|||||||
+171
-14
@@ -49,6 +49,12 @@ pub struct Judgement {
|
|||||||
/// 0..=5. Zero means *unrated*, which is a state in its own right.
|
/// 0..=5. Zero means *unrated*, which is a state in its own right.
|
||||||
pub rating: u8,
|
pub rating: u8,
|
||||||
pub flag: FlagState,
|
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 {
|
impl Judgement {
|
||||||
@@ -267,14 +273,6 @@ pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogEr
|
|||||||
Ok(moved)
|
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
|
/// TRACES: FR-CAT-13
|
||||||
/// How `versions.label` encodes a colour label, and back.
|
/// 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> {
|
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
|
||||||
let existing: Option<i64> = conn
|
let existing: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -387,6 +393,88 @@ pub fn set_flag_many(
|
|||||||
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
|
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
|
/// Shared bulk wrapper, so the two axes cannot drift in their commit
|
||||||
/// behaviour — a partially-committed rating and a fully-committed flag from
|
/// behaviour — a partially-committed rating and a fully-committed flag from
|
||||||
/// the same keystroke would be hard to explain and harder to notice.
|
/// 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
|
/// An image with no version reads as unrated and unflagged rather than as an
|
||||||
/// error: that is exactly what it is.
|
/// error: that is exactly what it is.
|
||||||
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
|
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(
|
.query_row(
|
||||||
"SELECT rating, flag FROM versions
|
"SELECT rating, flag, label FROM versions
|
||||||
WHERE image_id = ?1
|
WHERE image_id = ?1
|
||||||
ORDER BY is_default DESC, id ASC
|
ORDER BY is_default DESC, id ASC
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
[image.0 as i64],
|
[image.0 as i64],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
|
|
||||||
Ok(match row {
|
Ok(match row {
|
||||||
Some((rating, flag)) => Judgement {
|
Some((rating, flag, label)) => Judgement {
|
||||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||||
flag: flag_from_code(flag),
|
flag: flag_from_code(flag),
|
||||||
|
label: label_from_code(label),
|
||||||
},
|
},
|
||||||
None => Judgement::default(),
|
None => Judgement::default(),
|
||||||
})
|
})
|
||||||
@@ -452,7 +541,7 @@ pub fn judgements(
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
let sql = format!(
|
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"
|
WHERE image_id IN ({placeholders}) AND is_default = 1"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -467,15 +556,17 @@ pub fn judgements(
|
|||||||
r.get::<_, i64>(0)?,
|
r.get::<_, i64>(0)?,
|
||||||
r.get::<_, i64>(1)?,
|
r.get::<_, i64>(1)?,
|
||||||
r.get::<_, i64>(2)?,
|
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(
|
out.insert(
|
||||||
ImageId(image as u64),
|
ImageId(image as u64),
|
||||||
Judgement {
|
Judgement {
|
||||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||||
flag: flag_from_code(flag),
|
flag: flag_from_code(flag),
|
||||||
|
label: label_from_code(label),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -686,6 +777,72 @@ mod tests {
|
|||||||
assert_eq!(distinct, 200);
|
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]
|
#[test]
|
||||||
fn a_rating_round_trips() {
|
fn a_rating_round_trips() {
|
||||||
let cat = with_images(1);
|
let cat = with_images(1);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
//! # The one thing a rebuild does not recover
|
//! # The one thing a rebuild does not recover
|
||||||
//!
|
//!
|
||||||
//! **Collections.** A manual collection is a set of images the user assembled
|
//! **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
|
//! 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
|
//! 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.
|
//! 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
|
// The first NFR-R6 branch, asserted on the thing that distinguishes it
|
||||||
// from the second: a collection exists nowhere but the catalog, so 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
|
// 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 dir = tempdir("restore");
|
||||||
let path = dir.join("catalog.sqlite");
|
let path = dir.join("catalog.sqlite");
|
||||||
fixture(&path, 500);
|
fixture(&path, 500);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
|||||||
use crate::error::CatalogError;
|
use crate::error::CatalogError;
|
||||||
|
|
||||||
/// Schema version this build writes and understands.
|
/// Schema version this build writes and understands.
|
||||||
pub const SCHEMA_VERSION: i64 = 19;
|
pub const SCHEMA_VERSION: i64 = 20;
|
||||||
|
|
||||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||||
///
|
///
|
||||||
@@ -188,9 +188,98 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
|||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if from < 20 {
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
v20_markers_name_the_detector_that_found_the_faces(&tx)?;
|
||||||
|
tx.pragma_update(None, "user_version", 20)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(from)
|
Ok(from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V20 -- TRACES: FR-CAT-7
|
||||||
|
//
|
||||||
|
// Run markers that named the wrong detector, put right.
|
||||||
|
//
|
||||||
|
// `faces::record_updates` -- the write behind the quality, eye and crop
|
||||||
|
// passes -- re-marked an image under the pipeline the pass ran as, while
|
||||||
|
// the faces it had updated kept the id of the detector that found them.
|
||||||
|
// A marker of `scrfd_10g+w600k_mbf` over faces spelled `w600k_mbf` reads,
|
||||||
|
// to every consumer, as the thorough detector having examined the image:
|
||||||
|
// the upgrade repair skips it, and `face_shard::export_to_shards` selects
|
||||||
|
// its faces by the marker's id, finds none, and tells every other device
|
||||||
|
// that the thorough detector found nothing there. The desktop's shard index
|
||||||
|
// held 54 such entries over photographs with named faces, and the tablet's
|
||||||
|
// eye pass over faces it had adopted from the desktop had made 430 more.
|
||||||
|
//
|
||||||
|
// The write is fixed to keep the marker under the faces' own id. This puts
|
||||||
|
// the markers already written right, with a fresh time so the export sends
|
||||||
|
// each image again under an entry newer than the empty one -- which is what
|
||||||
|
// `held_model` orders by. Where the right marker is still there beside the
|
||||||
|
// wrong one (the old write inserted rather than replaced), the wrong one
|
||||||
|
// goes and the right one is refreshed for the same reason: its entry in
|
||||||
|
// the shards is older than the empty one, and a device that has neither
|
||||||
|
// would take the empty one. An image V14 left with faces and no marker at
|
||||||
|
// all is not touched: that state is the quality pass's cue, and the fixed
|
||||||
|
// write marks it correctly when the pass reaches it.
|
||||||
|
//
|
||||||
|
// Restated in Rust rather than SQL because the embedder half of a pipeline
|
||||||
|
// id is `faces::embedder_sql`, which this must agree with.
|
||||||
|
fn v20_markers_name_the_detector_that_found_the_faces(tx: &Connection) -> Result<(), CatalogError> {
|
||||||
|
let fi = crate::faces::embedder_sql("face_index.model_id");
|
||||||
|
let f = crate::faces::embedder_sql("f.model_id");
|
||||||
|
// A marker is wrong when the image holds faces of its embedder under
|
||||||
|
// another id. First the wrong ones that sit beside a right one -- the
|
||||||
|
// update below would collide with it -- then the rest are renamed.
|
||||||
|
let wrong = format!(
|
||||||
|
"EXISTS (SELECT 1 FROM faces f
|
||||||
|
WHERE f.image_id = face_index.image_id
|
||||||
|
AND {f} = {fi}
|
||||||
|
AND f.model_id != face_index.model_id)"
|
||||||
|
);
|
||||||
|
let found_by = format!(
|
||||||
|
"(SELECT MIN(f.model_id) FROM faces f
|
||||||
|
WHERE f.image_id = face_index.image_id AND {f} = {fi})"
|
||||||
|
);
|
||||||
|
let now = crate::faces::now_secs();
|
||||||
|
tx.execute(
|
||||||
|
&format!(
|
||||||
|
"UPDATE face_index
|
||||||
|
SET indexed_at = ?1
|
||||||
|
WHERE model_id = {found_by}
|
||||||
|
AND EXISTS (SELECT 1 FROM face_index w
|
||||||
|
WHERE w.image_id = face_index.image_id
|
||||||
|
AND w.model_id != face_index.model_id
|
||||||
|
AND {} = {fi})",
|
||||||
|
crate::faces::embedder_sql("w.model_id")
|
||||||
|
),
|
||||||
|
[now],
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
&format!(
|
||||||
|
"DELETE FROM face_index
|
||||||
|
WHERE {wrong}
|
||||||
|
AND EXISTS (SELECT 1 FROM face_index o
|
||||||
|
WHERE o.image_id = face_index.image_id
|
||||||
|
AND o.model_id = {found_by})"
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
&format!(
|
||||||
|
"UPDATE face_index
|
||||||
|
SET model_id = {found_by},
|
||||||
|
faces_found = (SELECT COUNT(*) FROM faces f
|
||||||
|
WHERE f.image_id = face_index.image_id AND {f} = {fi}),
|
||||||
|
indexed_at = ?1
|
||||||
|
WHERE {wrong}"
|
||||||
|
),
|
||||||
|
[now],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// The seven columns V16 adds to `faces`, in the order the readers name them.
|
/// The seven columns V16 adds to `faces`, in the order the readers name them.
|
||||||
///
|
///
|
||||||
/// Named once because three places have to agree on them: this migration,
|
/// Named once because three places have to agree on them: this migration,
|
||||||
@@ -920,7 +1009,7 @@ CREATE INDEX face_index_model ON face_index(model_id);
|
|||||||
|
|
||||||
const V8: &str = r#"
|
const V8: &str = r#"
|
||||||
-- TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
|
-- 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,
|
-- Everything here is **derived data** except one column. Faces, landmarks,
|
||||||
-- embeddings, cluster assignments and suggestions are all reproducible by
|
-- embeddings, cluster assignments and suggestions are all reproducible by
|
||||||
@@ -957,7 +1046,7 @@ CREATE TABLE faces (
|
|||||||
landmarks BLOB NOT NULL, -- 5 x (x, y) f32, normalised likewise
|
landmarks BLOB NOT NULL, -- 5 x (x, y) f32, normalised likewise
|
||||||
detector_confidence REAL NOT NULL,
|
detector_confidence REAL NOT NULL,
|
||||||
embedding BLOB NOT NULL, -- 512 x f16; unit length until V14, raw since
|
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
|
-- 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
|
-- the §8 calibration -- FR-CULL-9 names face size as an axis along which an
|
||||||
@@ -1854,6 +1943,90 @@ mod tests {
|
|||||||
assert_eq!(faces, 2);
|
assert_eq!(faces, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v20_renames_markers_to_the_detector_that_found_the_faces() {
|
||||||
|
let c = mem();
|
||||||
|
c.pragma_update(None, "user_version", 0).unwrap();
|
||||||
|
migrate(&c).unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'test')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||||
|
VALUES (1,1,'a',0),(2,1,'b',0),(3,1,'c',0),(4,1,'d',0),(5,1,'e',0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// 1: the desktop's case -- old faces, re-marked as thorough.
|
||||||
|
// 2: the tablet's case -- adopted thorough faces, re-marked int8,
|
||||||
|
// and the right marker still beside it (refreshed, so it is
|
||||||
|
// exported again over the empty entry).
|
||||||
|
// 3: right already. 4: examined and empty. 5: V14's state, faces
|
||||||
|
// and no marker.
|
||||||
|
for (image, model) in [
|
||||||
|
(1, "scrfd_10g+w600k_mbf"),
|
||||||
|
(2, "scrfd_10g_i8+w600k_mbf"),
|
||||||
|
(2, "scrfd_10g+w600k_mbf"),
|
||||||
|
(3, "scrfd_10g+w600k_mbf"),
|
||||||
|
(4, "scrfd_10g+w600k_mbf"),
|
||||||
|
] {
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
|
||||||
|
VALUES (?1, ?2, 100, 0, 6000)",
|
||||||
|
rusqlite::params![image, model],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
for (image, model) in [
|
||||||
|
(1, "w600k_mbf"),
|
||||||
|
(1, "w600k_mbf"),
|
||||||
|
(2, "scrfd_10g+w600k_mbf"),
|
||||||
|
(3, "scrfd_10g+w600k_mbf"),
|
||||||
|
(5, "w600k_mbf"),
|
||||||
|
] {
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO faces
|
||||||
|
(image_id, x, y, w, h, landmarks, detector_confidence, embedding,
|
||||||
|
crop_px, model_id, detected_at)
|
||||||
|
VALUES (?1, 0.1, 0.1, 0.2, 0.2, X'00', 0.9, X'00', 180.0, ?2, 0)",
|
||||||
|
rusqlite::params![image, model],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
c.pragma_update(None, "user_version", 19).unwrap();
|
||||||
|
|
||||||
|
migrate(&c).unwrap();
|
||||||
|
|
||||||
|
let markers: Vec<(i64, String, i64, bool)> = c
|
||||||
|
.prepare(
|
||||||
|
"SELECT image_id, model_id, faces_found, indexed_at > 100
|
||||||
|
FROM face_index ORDER BY image_id, model_id",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||||
|
.unwrap()
|
||||||
|
.map(Result::unwrap)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
markers,
|
||||||
|
vec![
|
||||||
|
(1, "w600k_mbf".to_string(), 2, true),
|
||||||
|
(2, "scrfd_10g+w600k_mbf".to_string(), 0, true),
|
||||||
|
(3, "scrfd_10g+w600k_mbf".to_string(), 0, false),
|
||||||
|
(4, "scrfd_10g+w600k_mbf".to_string(), 0, false),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// Re-enterable: nothing left to rename.
|
||||||
|
c.pragma_update(None, "user_version", 19).unwrap();
|
||||||
|
migrate(&c).unwrap();
|
||||||
|
let n: i64 = c
|
||||||
|
.query_row("SELECT count(*) FROM face_index", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 4);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn job_uniqueness_coalesces_rather_than_duplicating() {
|
fn job_uniqueness_coalesces_rather_than_duplicating() {
|
||||||
let c = mem();
|
let c = mem();
|
||||||
|
|||||||
@@ -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
|
//! Fusing them would force a full decode where a header read suffices, which
|
||||||
//! is exactly why Lightroom stalls ~2 s per image during culling.
|
//! 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;
|
pub mod base_curve;
|
||||||
|
mod decoder;
|
||||||
mod error;
|
mod error;
|
||||||
mod locate;
|
mod locate;
|
||||||
mod preview;
|
mod preview;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
|
||||||
pub use base_curve::BaseCurve;
|
pub use base_curve::BaseCurve;
|
||||||
|
pub use decoder::{default, Decoder, Rawler};
|
||||||
pub use error::DecodeError;
|
pub use error::DecodeError;
|
||||||
pub use locate::{
|
pub use locate::{
|
||||||
defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel,
|
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
|
# 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
|
# 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 }
|
ort = { workspace = true, optional = true }
|
||||||
dr-inference-engine = { workspace = true, optional = true }
|
dr-inference-engine = { workspace = true, optional = true }
|
||||||
ndarray = { workspace = true, optional = true }
|
ndarray = { workspace = true, optional = true }
|
||||||
@@ -37,7 +37,7 @@ required-features = ["inference"]
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Nothing on by default, and in particular **no `embedded-model`**: the weights
|
# 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
|
# flag that *could* embed them is a flag someone eventually sets in a packaging
|
||||||
# script, and the InsightFace grant does not survive that.
|
# script, and the InsightFace grant does not survive that.
|
||||||
default = []
|
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
|
//! 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
|
//! whether soft ones are refused — so with `--dump DIR` the crops the
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! DET.onnx EMB.onnx photo.jpg [photo.jpg ...]
|
//! DET.onnx EMB.onnx photo.jpg [photo.jpg ...]
|
||||||
//!
|
//!
|
||||||
//! The models must have had their input dims frozen first; see
|
//! 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;
|
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
|
//! 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
|
//! on. `det_500m.onnx` has a dynamic H/W input, which is exactly what tract
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//!
|
//!
|
||||||
//! # What it is for
|
//! # 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,
|
//! 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
|
//! 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
|
//! 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
|
//! ArcFace embeddings are trained on faces warped to a canonical 112×112
|
||||||
//! arrangement. Feeding the model a plain bounding-box crop *works* — it
|
//! arrangement. Feeding the model a plain bounding-box crop *works* — it
|
||||||
@@ -208,7 +208,7 @@ impl Similarity {
|
|||||||
///
|
///
|
||||||
/// # Why least squares and not RANSAC
|
/// # 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
|
/// 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
|
/// 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
|
/// landmarks it judges outliers and solve from a subset — and on a profile face
|
||||||
@@ -427,7 +427,7 @@ fn sample_window(
|
|||||||
// ── eyes ──────────────────────────────────────────────────────────────────
|
// ── eyes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Width of an eye crop as the classifier reads it, in pixels. Fixed by the
|
/// 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;
|
pub const EYE_PATCH_WIDTH: usize = 40;
|
||||||
/// Height of an eye crop as the classifier reads it, in pixels.
|
/// Height of an eye crop as the classifier reads it, in pixels.
|
||||||
pub const EYE_PATCH_HEIGHT: usize = 24;
|
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
|
/// 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
|
/// 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
|
/// 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.
|
/// contour landing a pixel short of the lashes still holds them.
|
||||||
pub const EYE_BOX_MARGIN: f32 = 0.1;
|
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
|
/// 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
|
/// 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
|
/// 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] =
|
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)];
|
[(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
|
//! 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
|
//! 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
|
//! calibration to the belief it was supposed to test — and that is the whole
|
||||||
//! of the alternative.
|
//! 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,
|
//! 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
|
//! 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
|
//! 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 {
|
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.
|
/// steepness 16.2, P=0.5 at cosine 0.267.
|
||||||
///
|
///
|
||||||
/// **`valid` is false**, and that is the point. It is a documented
|
/// **`valid` is false**, and that is the point. It is a documented
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! TRACES: FR-CULL-8a
|
//! 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
|
//! **OCEC** — *open closed eyes classification*, Hyodo 2025 — reads one
|
||||||
//! 40×24 eye and answers P(open). **SGC** — *sunglasses classification*,
|
//! 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
|
//! 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
|
//! 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
|
//! One forward pass produces a box, a confidence and **five landmarks** per
|
||||||
//! face — the landmarks being the reason for this detector rather than a
|
//! face — the landmarks being the reason for this detector rather than a
|
||||||
@@ -138,7 +138,7 @@ impl Detection {
|
|||||||
pub struct Detector {
|
pub struct Detector {
|
||||||
session: Model,
|
session: Model,
|
||||||
/// f32 or int8 — the int8 form finds a different set of faces and is a
|
/// 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,
|
form: Form,
|
||||||
/// Feature-map count: 3 for strides {8,16,32}, 4 for {8,16,32,64}.
|
/// 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.
|
/// How the image is fitted into the graph's fixed square input.
|
||||||
///
|
///
|
||||||
/// The forward and inverse mappings live in one struct on purpose:
|
/// 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
|
/// but that the two agree. A mismatch offsets every box and landmark by the
|
||||||
/// padding, producing detections that look plausible and embeddings that
|
/// padding, producing detections that look plausible and embeddings that
|
||||||
/// quietly cluster badly three stages later.
|
/// quietly cluster badly three stages later.
|
||||||
@@ -372,7 +372,7 @@ impl Letterbox {
|
|||||||
///
|
///
|
||||||
/// `(x·255 − 127.5) / 128` — note `/128`, not `/127.5`. The reference
|
/// `(x·255 − 127.5) / 128` — note `/128`, not `/127.5`. The reference
|
||||||
/// implementation this is ported from uses `/128` for both models, and
|
/// 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
|
/// 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
|
/// 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
|
//! 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
|
//! 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> {
|
pub fn from_bytes(bytes: &[u8], model: ModelId) -> Result<Self, FaceError> {
|
||||||
// Always the f32 form: an embedding must compare across devices
|
// 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 loaded = dr_inference_engine::open(Role::Embedder, Form::F32, bytes)?;
|
||||||
let acquired = loaded.acquire()?;
|
let acquired = loaded.acquire()?;
|
||||||
let session = acquired.lock();
|
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
|
//! Deliberately **model-free**: the vector, its identity, its comparison and
|
||||||
//! its storage encoding are arithmetic, and `calibrate` and `cluster` are built
|
//! 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
|
/// round-trip costs ~1e-3 of cosine, three orders below the separation
|
||||||
/// between a match and a non-match.
|
/// between a match and a non-match.
|
||||||
#[test]
|
#[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
|
/// 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
|
/// (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
|
/// 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;
|
pub const MIN_EYE_PX: f32 = 12.0;
|
||||||
|
|
||||||
/// Least [`Eye::sharpness`] for the eye to be read.
|
/// Least [`Eye::sharpness`] for the eye to be read.
|
||||||
///
|
///
|
||||||
/// The same measure as the face's `min_sharpness`, over the eye patch, and
|
/// 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
|
/// 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;
|
pub const MIN_EYE_SHARPNESS: f32 = 0.02;
|
||||||
|
|
||||||
/// An eye narrower than this fraction of its partner is the far eye of a
|
/// 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.
|
/// A landmark model's contour for a hidden eye collapses towards the nose.
|
||||||
/// Measured on twenty native renders of the reference library
|
/// 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
|
/// 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
|
/// 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.
|
/// box keeps its width — sat at 0.78 or more. 0.6 splits the gap.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! TRACES: FR-CULL-8a
|
//! 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
|
//! 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
|
//! 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
|
//! 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
|
//! 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
|
//! for a packaging script to switch on. The application obtains a model at
|
||||||
//! runtime; this crate takes bytes and never fetches anything.
|
//! 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,
|
//! 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
|
//! # Why the runtime is split behind a feature
|
||||||
//!
|
//!
|
||||||
@@ -50,11 +50,12 @@ pub mod eyes;
|
|||||||
pub mod landmarks;
|
pub mod landmarks;
|
||||||
pub mod naming;
|
pub mod naming;
|
||||||
pub mod neighbours;
|
pub mod neighbours;
|
||||||
|
pub mod references;
|
||||||
|
|
||||||
/// Smallest long edge a face crop may be sampled from.
|
/// Smallest long edge a face crop may be sampled from.
|
||||||
///
|
///
|
||||||
/// **A floor on the crop source, not on the detector input.** The distinction
|
/// **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
|
/// every buffer into 640×640, so its input resolution decides nothing, while
|
||||||
/// [`warp`] samples the 112×112 the embedder sees and so converts source
|
/// [`warp`] samples the 112×112 the embedder sees and so converts source
|
||||||
/// resolution directly into embedding quality. FR-CULL-8 requires that crop to
|
/// 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.
|
/// Source pixels across the aligned crop, for the calibration's size term.
|
||||||
pub crop_px: &'a [f32],
|
pub crop_px: &'a [f32],
|
||||||
/// Which photograph each face came from. Two faces in one frame are not
|
/// 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],
|
pub images: &'a [u64],
|
||||||
/// Which faces may be compared *against* — the gallery
|
/// Which faces may be compared *against* — the gallery
|
||||||
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]).
|
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]).
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
//! TRACES: FR-CULL-10 | NFR-P9
|
||||||
|
//! Which of a person's faces stand for them in a grouping pass.
|
||||||
|
//!
|
||||||
|
//! # Why not all of them
|
||||||
|
//!
|
||||||
|
//! Every face the user has ruled on enters [`crate::cluster`] as an anchor,
|
||||||
|
//! and the pass compares every face against every other
|
||||||
|
//! ([`crate::neighbours`] is exhaustive by design). So a person with 750
|
||||||
|
//! confirmed faces costs 750 comparisons against each of the library's other
|
||||||
|
//! faces, and the cost of naming a library well grows with how well it is
|
||||||
|
//! named: a fully confirmed library of 25,000 faces spends almost the whole
|
||||||
|
//! scan re-comparing faces whose identity is already settled against each
|
||||||
|
//! other.
|
||||||
|
//!
|
||||||
|
//! Most of those comparisons say nothing new. A person's confirmed faces are
|
||||||
|
//! heavily redundant — thirty frames from one afternoon are one point of
|
||||||
|
//! view, not thirty — and a new face that matches one of them matches the
|
||||||
|
//! others too. What a new face needs to be measured against is the person's
|
||||||
|
//! *range*: the angles, ages and lights they have been photographed in, each
|
||||||
|
//! represented once.
|
||||||
|
//!
|
||||||
|
//! # The choice: the most diverse of the good ones
|
||||||
|
//!
|
||||||
|
//! Two rules, in order.
|
||||||
|
//!
|
||||||
|
//! **Good enough to vouch.** Only faces whose raw embedding was at least
|
||||||
|
//! [`MIN_REFERENCE_QUALITY`] long are eligible — a stricter floor than the
|
||||||
|
//! gallery's ([`crate::embedding::MIN_GALLERY_QUALITY`]), because a reference
|
||||||
|
//! is asked to speak *for* a person rather than merely be admitted to the
|
||||||
|
//! comparison. A face whose length was never recorded is admitted, as it is
|
||||||
|
//! everywhere else: a rule that cannot be checked admits rather than excludes.
|
||||||
|
//!
|
||||||
|
//! **As far apart as possible.** From the eligible pool, up to
|
||||||
|
//! [`MAX_REFERENCES`] faces are chosen to maximise the volume they span —
|
||||||
|
//! the determinant of their Gram matrix — greedily: start from the longest
|
||||||
|
//! vector, and at each step add the face with the largest component
|
||||||
|
//! orthogonal to everything chosen so far. That is Gram–Schmidt with a
|
||||||
|
//! pivot, and the product of the squared residuals it picks *is* the
|
||||||
|
//! determinant, so the greedy step is the exact greedy on the objective.
|
||||||
|
//! The effect is that a near-duplicate of a chosen face has almost no
|
||||||
|
//! residual and is passed over, while the one profile shot among two
|
||||||
|
//! hundred frontal frames is taken early.
|
||||||
|
//!
|
||||||
|
//! What is not chosen still belongs to the person. Those faces keep their
|
||||||
|
//! confirmations and are not touched by the pass; they are simply not
|
||||||
|
//! compared, which is the whole saving.
|
||||||
|
|
||||||
|
/// The most faces that stand for one person.
|
||||||
|
///
|
||||||
|
/// A hundred is far more points of view than a person has. What it bounds
|
||||||
|
/// is the cost: with every person at the cap, a scan against the named part
|
||||||
|
/// of a library is `people × 100` comparisons per face rather than
|
||||||
|
/// `confirmations`, and the two part company as soon as a library is used.
|
||||||
|
pub const MAX_REFERENCES: usize = 100;
|
||||||
|
|
||||||
|
/// The shortest raw embedding that may stand for a person.
|
||||||
|
///
|
||||||
|
/// One above the gallery floor: a reference vouches for someone, and the
|
||||||
|
/// margin keeps the faces that only just cleared the gallery — the ones
|
||||||
|
/// nearest the middle of the sphere — out of the set that speaks for a
|
||||||
|
/// person.
|
||||||
|
pub const MIN_REFERENCE_QUALITY: f32 = 15.0;
|
||||||
|
|
||||||
|
/// Whether a face of this quality may stand for a person.
|
||||||
|
///
|
||||||
|
/// `None` is "never measured" and is admitted, as in
|
||||||
|
/// [`crate::embedding::in_gallery`].
|
||||||
|
pub fn eligible(quality: Option<f32>) -> bool {
|
||||||
|
quality.is_none_or(|q| q >= MIN_REFERENCE_QUALITY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Choose which of one person's faces stand for them.
|
||||||
|
///
|
||||||
|
/// `embeddings` and `quality` are one entry per face, the embeddings unit
|
||||||
|
/// length and all of one dimension. Returns the indices chosen, in the order
|
||||||
|
/// chosen — the first is the longest eligible vector, and each after it is
|
||||||
|
/// the one furthest from the span of those before. Every eligible face is
|
||||||
|
/// returned when there are `max` or fewer of them, so a person under the
|
||||||
|
/// cap loses nothing.
|
||||||
|
///
|
||||||
|
/// Deterministic: equal residuals break on the longer vector, then the lower
|
||||||
|
/// index, so two devices holding the same faces choose the same references
|
||||||
|
/// and group the same way (`cluster::clustering_is_deterministic`).
|
||||||
|
pub fn select(embeddings: &[&[f32]], quality: &[Option<f32>], max: usize) -> Vec<usize> {
|
||||||
|
debug_assert_eq!(embeddings.len(), quality.len());
|
||||||
|
let mut pool: Vec<usize> = (0..embeddings.len())
|
||||||
|
.filter(|&i| eligible(quality[i]))
|
||||||
|
.collect();
|
||||||
|
if pool.len() <= max {
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
// Longest first, so the seed is the pool's front and a tie on residual
|
||||||
|
// resolves to the earlier position. A missing reading ranks below any
|
||||||
|
// measured one for this purpose only: it is admitted, but a face that
|
||||||
|
// was measured and found long is the better seed.
|
||||||
|
pool.sort_by(|&a, &b| {
|
||||||
|
let qa = quality[a].unwrap_or(0.0);
|
||||||
|
let qb = quality[b].unwrap_or(0.0);
|
||||||
|
qb.total_cmp(&qa).then(a.cmp(&b))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Residuals: what remains of each pool vector outside the span of the
|
||||||
|
// chosen ones. Copied, since they are rewritten in place.
|
||||||
|
let mut residual: Vec<Vec<f32>> = pool.iter().map(|&i| embeddings[i].to_vec()).collect();
|
||||||
|
let mut taken = vec![false; pool.len()];
|
||||||
|
let mut chosen = Vec::with_capacity(max);
|
||||||
|
|
||||||
|
while chosen.len() < max {
|
||||||
|
// The face with the most left outside the span. The seed is the
|
||||||
|
// pool's front by construction: every unit vector has the same
|
||||||
|
// residual before anything is chosen, up to rounding, and rounding
|
||||||
|
// is not a reason to prefer one. After that `> best` and not `>=`,
|
||||||
|
// so a genuine tie keeps the earlier (longer) candidate.
|
||||||
|
let mut pick = None;
|
||||||
|
let mut best = 0.0_f32;
|
||||||
|
if chosen.is_empty() {
|
||||||
|
pick = Some(0);
|
||||||
|
best = residual[0].iter().map(|x| x * x).sum();
|
||||||
|
} else {
|
||||||
|
for (k, r) in residual.iter().enumerate() {
|
||||||
|
if taken[k] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let n2: f32 = r.iter().map(|x| x * x).sum();
|
||||||
|
if n2 > best {
|
||||||
|
best = n2;
|
||||||
|
pick = Some(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nothing left outside the span: every remaining face is a
|
||||||
|
// combination of the chosen ones and adds no volume.
|
||||||
|
let Some(k) = pick.filter(|_| best > 1e-6) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
taken[k] = true;
|
||||||
|
chosen.push(pool[k]);
|
||||||
|
|
||||||
|
// Project the chosen direction out of every remaining residual.
|
||||||
|
let inv = best.sqrt().recip();
|
||||||
|
let q: Vec<f32> = residual[k].iter().map(|x| x * inv).collect();
|
||||||
|
for (j, r) in residual.iter_mut().enumerate() {
|
||||||
|
if taken[j] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let d: f32 = r.iter().zip(&q).map(|(a, b)| a * b).sum();
|
||||||
|
for (x, y) in r.iter_mut().zip(&q) {
|
||||||
|
*x -= d * y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chosen
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn unit(v: &[f32]) -> Vec<f32> {
|
||||||
|
let n = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
v.iter().map(|x| x / n).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_person_under_the_cap_keeps_every_eligible_face() {
|
||||||
|
let e = [unit(&[1.0, 0.0]), unit(&[0.0, 1.0]), unit(&[1.0, 1.0])];
|
||||||
|
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
|
||||||
|
let q = [Some(20.0), None, Some(16.0)];
|
||||||
|
assert_eq!(select(&refs, &q, 100), vec![0, 1, 2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_short_vector_never_stands_for_a_person() {
|
||||||
|
let e = [unit(&[1.0, 0.0]), unit(&[0.0, 1.0])];
|
||||||
|
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
|
||||||
|
let q = [Some(20.0), Some(MIN_REFERENCE_QUALITY - 0.01)];
|
||||||
|
assert_eq!(select(&refs, &q, 100), vec![0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two hundred frames from one afternoon and one profile shot: the
|
||||||
|
/// profile is the second choice, not the two-hundred-and-first.
|
||||||
|
#[test]
|
||||||
|
fn the_odd_one_out_is_chosen_before_any_duplicate() {
|
||||||
|
let mut e: Vec<Vec<f32>> = Vec::new();
|
||||||
|
let mut q = Vec::new();
|
||||||
|
for i in 0..200 {
|
||||||
|
// Near-duplicates of one direction, with a little noise.
|
||||||
|
let t = (i as f32) * 1e-3;
|
||||||
|
e.push(unit(&[1.0, t, t * 0.5]));
|
||||||
|
q.push(Some(20.0 + (i % 7) as f32));
|
||||||
|
}
|
||||||
|
e.push(unit(&[0.0, 0.0, 1.0]));
|
||||||
|
q.push(Some(16.0));
|
||||||
|
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
|
||||||
|
let chosen = select(&refs, &q, 3);
|
||||||
|
assert_eq!(chosen.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
chosen[1], 200,
|
||||||
|
"the profile shot was not second: {chosen:?}"
|
||||||
|
);
|
||||||
|
// Seeded on the longest vector.
|
||||||
|
assert_eq!(q[chosen[0]], Some(26.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Faces inside the span of the chosen ones add no volume and are not
|
||||||
|
/// taken to fill the cap.
|
||||||
|
#[test]
|
||||||
|
fn the_cap_is_not_filled_from_inside_the_span() {
|
||||||
|
let e = [
|
||||||
|
unit(&[1.0, 0.0]),
|
||||||
|
unit(&[0.0, 1.0]),
|
||||||
|
unit(&[1.0, 1.0]),
|
||||||
|
unit(&[2.0, -1.0]),
|
||||||
|
];
|
||||||
|
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
|
||||||
|
let q = [Some(20.0); 4];
|
||||||
|
assert_eq!(select(&refs, &q, 3).len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_choice_is_deterministic() {
|
||||||
|
let e: Vec<Vec<f32>> = (0..50)
|
||||||
|
.map(|i| {
|
||||||
|
let a = (i as f32) * 0.37;
|
||||||
|
unit(&[a.cos(), a.sin(), (a * 3.0).sin(), 0.2])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let refs: Vec<&[f32]> = e.iter().map(Vec::as_slice).collect();
|
||||||
|
let q = vec![Some(18.0); 50];
|
||||||
|
assert_eq!(select(&refs, &q, 5), select(&refs, &q, 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! What a frame actually costs — the measurement FR-DSP-2 is waiting on.
|
//! 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
|
//! 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
|
//! active operation into **one dispatch over a viewport-sized target**, so the
|
||||||
//! problem tiles were invented to solve may already be solved. That argument
|
//! 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
|
//! the per-frame CPU half is dominated by shader-source assembly, which is
|
||||||
//! string formatting and is several times slower unoptimised.
|
//! 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.
|
//! that file; a regression should be a diff rather than somebody's memory.
|
||||||
//!
|
//!
|
||||||
//! # Why the 99th percentile and not the mean
|
//! # Why the 99th percentile and not the mean
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Segment an image and write the granularity ladder as false-coloured PPMs.
|
//! 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
|
//! ladder and decide whether clicking through it would land on the things a
|
||||||
//! person means. No amount of design settles that — the pictures do.
|
//! 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]
|
#[test]
|
||||||
fn dragging_the_crop_does_not_recompile() {
|
fn dragging_the_crop_does_not_recompile() {
|
||||||
// The cache contract for framing, which is what makes an interactive
|
// 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.
|
// TEXTURE_BINDING to be sampled by the compositor.
|
||||||
// RENDER_ATTACHMENT is not used by anything here and is required
|
// RENDER_ATTACHMENT is not used by anything here and is required
|
||||||
// anyway: Slint rejects an imported texture without it. COPY_SRC
|
// 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
|
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||||
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||||
@@ -490,18 +490,11 @@ impl FocusPeakPass {
|
|||||||
/// TRACES: AC-8
|
/// TRACES: AC-8
|
||||||
/// Copy the overlay to the CPU, as RGBA8 rows with no padding.
|
/// Copy the overlay to the CPU, as RGBA8 rows with no padding.
|
||||||
///
|
///
|
||||||
/// **Two callers, and neither is the desktop display path.** The tests
|
/// **The tests below are the only caller, and never the display path.** An
|
||||||
/// below are one: an overlay is a claim about which pixels are sharp, and
|
/// overlay is a claim about which pixels are sharp, and there is no way to
|
||||||
/// there is no way to check that claim without looking at the pixels. The
|
/// check that claim without looking at the pixels. On screen, on desktop
|
||||||
/// other is the Android develop view, which reads the *frame* back for the
|
/// and Android alike, the overlay reaches the compositor as a texture and
|
||||||
/// reasons `technical-debt.md` TD-1 records — wgpu's Android swapchain
|
/// ARCH §6.1 holds. (Android read it back here until TD-1 was paid off.)
|
||||||
/// 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.
|
|
||||||
pub fn read_overlay(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
pub fn read_overlay(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
||||||
let Some(layer) = self.layers[self.current].as_ref() else {
|
let Some(layer) = self.layers[self.current].as_ref() else {
|
||||||
return Err(GpuError::Readback("no overlay has been rendered".into()));
|
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.
|
//! module doc said for eight releases that it held no pipeline and no masks.
|
||||||
//! It holds both now, plus demosaic, detail, segmentation masks, two
|
//! It holds both now, plus demosaic, detail, segmentation masks, two
|
||||||
//! histograms and focus peaking. The zero-copy claim is still the one that
|
//! 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
|
//! 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.
|
//! 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_layout: wgpu::BindGroupLayout,
|
||||||
combine_union: wgpu::RenderPipeline,
|
combine_union: wgpu::RenderPipeline,
|
||||||
combine_subtract: wgpu::RenderPipeline,
|
combine_subtract: wgpu::RenderPipeline,
|
||||||
|
combine_intersect: wgpu::RenderPipeline,
|
||||||
/// Where a part is drawn before it is joined.
|
/// Where a part is drawn before it is joined.
|
||||||
///
|
///
|
||||||
/// One texture for the whole stack rather than one per layer, because
|
/// One texture for the whole stack rather than one per layer, because
|
||||||
@@ -651,6 +652,16 @@ impl MaskPass {
|
|||||||
"mask-combine-subtract",
|
"mask-combine-subtract",
|
||||||
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
|
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
|
// 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
|
// off what earlier strokes on this layer put down. There is no negative
|
||||||
@@ -681,6 +692,7 @@ impl MaskPass {
|
|||||||
combine_layout,
|
combine_layout,
|
||||||
combine_union,
|
combine_union,
|
||||||
combine_subtract,
|
combine_subtract,
|
||||||
|
combine_intersect,
|
||||||
scratch: None,
|
scratch: None,
|
||||||
array: None,
|
array: None,
|
||||||
allocations: 0,
|
allocations: 0,
|
||||||
@@ -1376,6 +1388,7 @@ impl MaskPass {
|
|||||||
pass.set_pipeline(match join {
|
pass.set_pipeline(match join {
|
||||||
Join::Union => &self.combine_union,
|
Join::Union => &self.combine_union,
|
||||||
Join::Subtract => &self.combine_subtract,
|
Join::Subtract => &self.combine_subtract,
|
||||||
|
Join::Intersect => &self.combine_intersect,
|
||||||
});
|
});
|
||||||
pass.set_bind_group(0, &bind_group, &[]);
|
pass.set_bind_group(0, &bind_group, &[]);
|
||||||
pass.draw(0..3, 0..1);
|
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
|
//! 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
|
//! 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
|
//! 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.
|
//! 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
|
//! §12 stands: the adjacency accumulation belongs GPU-side with atomics, and
|
||||||
//! until it moves there every segmentation pays a full-resolution transfer.
|
//! 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
|
//! 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
|
/// Longest proxy edge. The segmentation runs here, not at sensor
|
||||||
/// resolution: a 24 MP watershed costs 12× the memory to place boundaries
|
/// 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
|
/// 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,
|
pub max_edge: u32,
|
||||||
/// Pre-smoothing radius in proxy pixels. The caller's to raise with ISO —
|
/// 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
|
/// this is the single knob that decides whether a noisy file segments
|
||||||
@@ -69,7 +69,7 @@ impl Default for SegmentOptions {
|
|||||||
w_chroma: 0.5,
|
w_chroma: 0.5,
|
||||||
// **Zero: the pass is off.** It is implemented, dispatched
|
// **Zero: the pass is off.** It is implemented, dispatched
|
||||||
// correctly and measurably changes nothing — see the ignored test
|
// 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
|
// running it would buy 64 dispatches per segmentation and no
|
||||||
// improvement, so the default declines to pay.
|
// improvement, so the default declines to pay.
|
||||||
plateau_iterations: 0,
|
plateau_iterations: 0,
|
||||||
@@ -486,7 +486,7 @@ impl Segmentation {
|
|||||||
/// a region graph of a few thousand nodes that every later interaction
|
/// a region graph of a few thousand nodes that every later interaction
|
||||||
/// reads from the CPU anyway.
|
/// 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
|
/// the adjacency accumulation belongs on the GPU with atomics, and until
|
||||||
/// it moves there a segmentation costs one full-resolution transfer of the
|
/// 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
|
/// label and gradient buffers. That is a real cost on a phone and the
|
||||||
@@ -724,7 +724,7 @@ mod tests {
|
|||||||
px
|
px
|
||||||
}
|
}
|
||||||
#[test]
|
#[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() {
|
fn lower_completion_drains_a_plateau_instead_of_shattering_it() {
|
||||||
// F1, asserted rather than eyeballed, and asserted at the level where
|
// F1, asserted rather than eyeballed, and asserted at the level where
|
||||||
// it matters.
|
// it matters.
|
||||||
@@ -741,7 +741,7 @@ mod tests {
|
|||||||
// with no exit anywhere — cannot be drained by a distance that has
|
// with no exit anywhere — cannot be drained by a distance that has
|
||||||
// nowhere to descend to, and collapsing it fully would need connected
|
// nowhere to descend to, and collapsing it fully would need connected
|
||||||
// component labelling rather than a local rule. It is not worth it:
|
// 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 Some(ctx) = ctx() else { return };
|
||||||
let (w, h) = (96u32, 96u32);
|
let (w, h) = (96u32, 96u32);
|
||||||
let src = DemosaicedImage::from_rgba8(&ctx, &ramp(w, h), w, h).expect("source");
|
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:
|
// 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
|
// 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
|
// 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
|
// 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
|
// The fix is the standard lower-completion: give each plateau pixel its
|
||||||
// geodesic distance to the nearest pixel that *does* have a lower neighbour,
|
// 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
|
//! 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
|
//! 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
|
//! blunt about what tagging an unchecked requirement does to the coverage
|
||||||
//! figure.
|
//! 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
|
//! `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
|
//! being true: it renders the **whole point-operation chain** through the real
|
||||||
//! `render_detailed` for a hundred frames, moving a slider between each, and
|
//! `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
|
//! **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
|
//! 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
|
//! 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
|
//! 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;
|
//! 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
|
//! 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.
|
/// 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
|
/// 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
|
/// 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.
|
/// suite time to re-establish a conclusion 4.1 M pixels already establishes.
|
||||||
const VIEWPORT: (u32, u32) = (2560, 1600);
|
const VIEWPORT: (u32, u32) = (2560, 1600);
|
||||||
@@ -166,7 +166,7 @@ impl Run {
|
|||||||
judged <= BUDGET_MS,
|
judged <= BUDGET_MS,
|
||||||
"{case} at {}x{}: p99 of {FRAMES} frames was {judged:.2} ms, over the \
|
"{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). \
|
{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.",
|
numbers it used to be.",
|
||||||
viewport.0,
|
viewport.0,
|
||||||
viewport.1,
|
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) ------------------------------------------
|
// --- seeing the mask (FR-DEV-19c) ------------------------------------------
|
||||||
|
|
||||||
/// A radial that covers the middle of the frame and nothing near the corners.
|
/// 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]
|
#[test]
|
||||||
fn crossing_the_reduction_threshold_does_not_change_the_picture() {
|
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
|
// Clarity's base is computed on a reduced grid, and how reduced depends on
|
||||||
// the viewport: `LocalContrast::reduction` steps 4 -> 2 -> 1 as sigma
|
// 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
|
//! code path — the zoom is the full-resolution path — which is why the
|
||||||
//! requirement has been satisfied for some time without anyone tagging it.
|
//! 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`
|
//! 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
|
//! 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.
|
//! thing. `FR-DEV-8` is tagged against plumbing a future operation would use.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ rust-version.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
# The one crate that names a runtime, a provider, a vendor library or a
|
# 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.
|
# session by role and never see which of these answered.
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -28,7 +28,10 @@ ort-sys = { version = "2.0.0-rc.13", default-features = false, features = ["disa
|
|||||||
# The NVIDIA rungs exist on the desktop only. These features add `ort`'s
|
# The NVIDIA rungs exist on the desktop only. These features add `ort`'s
|
||||||
# option builders and nothing else — no linking under `alternative-backend` —
|
# option builders and nothing else — no linking under `alternative-backend` —
|
||||||
# but an Android binary has no business carrying even the option names, and
|
# but an Android binary has no business carrying even the option names, and
|
||||||
# the packaging must never be tempted to (§2, §3.1).
|
# the packaging must never be tempted to (§2, §3.1). The AMD rung needs no
|
||||||
|
# feature: MIGraphX is registered through the runtime's generic key/value
|
||||||
|
# entry point (`session::migraphx`), because `ort`'s own builder cannot
|
||||||
|
# name the compiled-program cache.
|
||||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
ort = { workspace = true, features = ["cuda", "tensorrt"] }
|
ort = { workspace = true, features = ["cuda", "tensorrt"] }
|
||||||
|
|
||||||
@@ -42,3 +45,8 @@ default = ["tract"]
|
|||||||
tract = ["dep:ort-tract"]
|
tract = ["dep:ort-tract"]
|
||||||
# Look for `libonnxruntime` on disk and hand its table to `ort`.
|
# Look for `libonnxruntime` on disk and hand its table to `ort`.
|
||||||
native = ["dep:libloading", "dep:ort-sys"]
|
native = ["dep:libloading", "dep:ort-sys"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# The `ep_probe` example prints the provider's own diagnostics, which is most
|
||||||
|
# of what a failed rung tells you.
|
||||||
|
env_logger.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
//! Time each execution provider a runtime offers, on the models this
|
||||||
|
//! repository ships — the measurement docs/inference.md §1 requires before a
|
||||||
|
//! rung is added to §2's ladder.
|
||||||
|
//!
|
||||||
|
//! DARKROOM_ORT_DIR=/usr/lib \
|
||||||
|
//! cargo run --release -p dr-inference-engine --features native,tract \
|
||||||
|
//! --example ep_probe -- models/face/scrfd_500m_640.onnx ...
|
||||||
|
//!
|
||||||
|
//! Prints one row per (model, provider): the median of timed runs after
|
||||||
|
//! warm-ups, and the build time, which for a compiling provider is the
|
||||||
|
//! number that decides whether it needs an engine cache. MIGraphX is built
|
||||||
|
//! twice per precision — cold, then again from the cache it just wrote —
|
||||||
|
//! so both numbers are on the page.
|
||||||
|
//!
|
||||||
|
//! The ROCm provider is not in the list: ONNX Runtime removed it in 1.23,
|
||||||
|
//! and 1.29's `onnxruntime-rocm` ships `libonnxruntime_providers_migraphx.so`
|
||||||
|
//! and nothing else for AMD.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum Ep {
|
||||||
|
Cpu,
|
||||||
|
MiGraphX,
|
||||||
|
MiGraphXFp16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ep {
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Ep::Cpu => "CPU",
|
||||||
|
Ep::MiGraphX => "MIGraphX f32",
|
||||||
|
Ep::MiGraphXFp16 => "MIGraphX fp16",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(ep: Ep, bytes: &[u8], threads: usize, cache: &Path) -> ort::Result<ort::session::Session> {
|
||||||
|
let mut b = ort::session::Session::builder()?.with_intra_threads(threads)?;
|
||||||
|
match ep {
|
||||||
|
Ep::Cpu => {}
|
||||||
|
Ep::MiGraphX => migraphx(&mut b, false, &cache.join("f32"))?,
|
||||||
|
Ep::MiGraphXFp16 => migraphx(&mut b, true, &cache.join("fp16"))?,
|
||||||
|
}
|
||||||
|
b.commit_from_memory(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register MIGraphX through the generic key/value API. `ort`'s own
|
||||||
|
/// builder fills the legacy `OrtMIGraphXProviderOptions`, which 1.29 reads
|
||||||
|
/// for its precision flags and nothing else: the model cache directory —
|
||||||
|
/// the difference between a 40 s load and a 0.3 s one — only travels this
|
||||||
|
/// way. The cache key is the graph, the GPU and the MIGraphX version, not
|
||||||
|
/// the precision, so each precision gets its own directory.
|
||||||
|
fn migraphx(
|
||||||
|
b: &mut ort::session::builder::SessionBuilder,
|
||||||
|
fp16: bool,
|
||||||
|
cache: &Path,
|
||||||
|
) -> ort::Result<()> {
|
||||||
|
use ort::AsPointer;
|
||||||
|
use std::ffi::CString;
|
||||||
|
std::fs::create_dir_all(cache).map_err(|e| ort::Error::new(e.to_string()))?;
|
||||||
|
let keys = [c"migraphx_fp16_enable", c"migraphx_model_cache_dir"];
|
||||||
|
let values = [
|
||||||
|
CString::new(if fp16 { "1" } else { "0" }).unwrap(),
|
||||||
|
CString::new(cache.to_string_lossy().as_bytes()).unwrap(),
|
||||||
|
];
|
||||||
|
let key_ptrs: Vec<_> = keys.iter().map(|k| k.as_ptr()).collect();
|
||||||
|
let value_ptrs: Vec<_> = values.iter().map(|v| v.as_ptr()).collect();
|
||||||
|
// SAFETY: the documented C call, over arrays that outlive it; the
|
||||||
|
// runtime copies the strings into its own options map.
|
||||||
|
unsafe {
|
||||||
|
let status = (ort::api().SessionOptionsAppendExecutionProvider)(
|
||||||
|
b.ptr_mut(),
|
||||||
|
c"MIGraphX".as_ptr(),
|
||||||
|
key_ptrs.as_ptr(),
|
||||||
|
value_ptrs.as_ptr(),
|
||||||
|
keys.len(),
|
||||||
|
);
|
||||||
|
ort::Error::result_from_status(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Median of `runs` timed runs over zeros, in milliseconds, after warm-ups.
|
||||||
|
fn time(session: &mut ort::session::Session, warmups: usize, runs: usize) -> Result<f64, String> {
|
||||||
|
let shape: Vec<usize> = session.inputs()[0]
|
||||||
|
.dtype()
|
||||||
|
.tensor_shape()
|
||||||
|
.ok_or("input is not a tensor")?
|
||||||
|
.iter()
|
||||||
|
.map(|&d| if d > 0 { d as usize } else { 1 })
|
||||||
|
.collect();
|
||||||
|
let zeros = vec![0f32; shape.iter().product()];
|
||||||
|
let once = |s: &mut ort::session::Session| -> Result<f64, String> {
|
||||||
|
let input = ort::value::Tensor::from_array((shape.clone(), zeros.clone()))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let t = Instant::now();
|
||||||
|
let out = s.run(ort::inputs![input]).map_err(|e| e.to_string())?;
|
||||||
|
let _ = out[0]
|
||||||
|
.try_extract_tensor::<f32>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(t.elapsed().as_secs_f64() * 1e3)
|
||||||
|
};
|
||||||
|
for _ in 0..warmups {
|
||||||
|
once(session)?;
|
||||||
|
}
|
||||||
|
let mut times = Vec::with_capacity(runs);
|
||||||
|
for _ in 0..runs {
|
||||||
|
times.push(once(session)?);
|
||||||
|
}
|
||||||
|
times.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
Ok(times[times.len() / 2])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_line(s: &str) -> String {
|
||||||
|
s.lines().next().unwrap_or("").chars().take(120).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
|
let models: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
|
||||||
|
if models.is_empty() {
|
||||||
|
eprintln!("usage: ep_probe MODEL.onnx [MODEL.onnx ...]");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
dr_inference_engine::ensure_runtime();
|
||||||
|
let runtime = dr_inference_engine::status().runtime;
|
||||||
|
println!("runtime: {}", runtime.label());
|
||||||
|
if !runtime.is_native() {
|
||||||
|
println!("(tract: no provider to compare; set DARKROOM_ORT_DIR)");
|
||||||
|
}
|
||||||
|
|
||||||
|
let threads = std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get().saturating_sub(2).max(1))
|
||||||
|
.unwrap_or(1);
|
||||||
|
println!("intra-op threads: {threads}");
|
||||||
|
let cache = std::env::temp_dir().join("darkroom-ep-probe");
|
||||||
|
let _ = std::fs::remove_dir_all(&cache);
|
||||||
|
println!("compiled-program cache: {}\n", cache.display());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{:<28} {:<15} {:>10} {:>10}",
|
||||||
|
"model", "provider", "build s", "median ms"
|
||||||
|
);
|
||||||
|
for model in &models {
|
||||||
|
let bytes = match std::fs::read(model) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
println!("{:<28} read failed: {e}", name(model));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// A compiling provider is built twice: the second build reads the
|
||||||
|
// program the first wrote, and its time is what a launch after the
|
||||||
|
// first costs.
|
||||||
|
let plan = [
|
||||||
|
(Ep::Cpu, false),
|
||||||
|
(Ep::MiGraphX, false),
|
||||||
|
(Ep::MiGraphX, true),
|
||||||
|
(Ep::MiGraphXFp16, false),
|
||||||
|
(Ep::MiGraphXFp16, true),
|
||||||
|
];
|
||||||
|
for (ep, cached) in plan {
|
||||||
|
let started = Instant::now();
|
||||||
|
match build(ep, &bytes, threads, &cache) {
|
||||||
|
Ok(mut session) => {
|
||||||
|
let built = started.elapsed().as_secs_f64();
|
||||||
|
match time(&mut session, 3, 15) {
|
||||||
|
Ok(ms) => println!(
|
||||||
|
"{:<28} {:<15} {:>10.1} {:>10.1}{}",
|
||||||
|
name(model),
|
||||||
|
ep.label(),
|
||||||
|
built,
|
||||||
|
ms,
|
||||||
|
if cached { " (from cache)" } else { "" }
|
||||||
|
),
|
||||||
|
Err(e) => println!(
|
||||||
|
"{:<28} {:<15} {:>10.1} {:>10} {}",
|
||||||
|
name(model),
|
||||||
|
ep.label(),
|
||||||
|
built,
|
||||||
|
"ran ✗",
|
||||||
|
first_line(&e)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => println!(
|
||||||
|
"{:<28} {:<15} {:>21} {}",
|
||||||
|
name(model),
|
||||||
|
ep.label(),
|
||||||
|
"build ✗",
|
||||||
|
first_line(&e.to_string())
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(p: &Path) -> String {
|
||||||
|
p.file_name()
|
||||||
|
.unwrap_or(p.as_os_str())
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//! Walk the ladder as the app does — probe, engines, then a session — and
|
||||||
|
//! say what each step chose. The M5 check of docs/inference.md §6 without
|
||||||
|
//! the app around it.
|
||||||
|
//!
|
||||||
|
//! DARKROOM_ORT_DIR=/usr/lib \
|
||||||
|
//! cargo run --release -p dr-inference-engine --features native,tract \
|
||||||
|
//! --example ladder -- CACHE_DIR models/face/scrfd_500m_640.onnx [MODEL.onnx ...]
|
||||||
|
//!
|
||||||
|
//! Every model named is a `Detector` for the config's purposes, which is
|
||||||
|
//! enough to see the rung taken, the engines compiled and a session land
|
||||||
|
//! on it. Delete `CACHE_DIR` to see the first run again; keep it to see the
|
||||||
|
//! second.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
let mut args = std::env::args_os().skip(1).map(PathBuf::from);
|
||||||
|
let (Some(cache_dir), models) = (args.next(), args.collect::<Vec<_>>()) else {
|
||||||
|
eprintln!("usage: ladder CACHE_DIR MODEL.onnx [MODEL.onnx ...]");
|
||||||
|
std::process::exit(2);
|
||||||
|
};
|
||||||
|
if models.is_empty() {
|
||||||
|
eprintln!("usage: ladder CACHE_DIR MODEL.onnx [MODEL.onnx ...]");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtime_dirs: Vec<PathBuf> = std::env::var_os("DARKROOM_ORT_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let started = Instant::now();
|
||||||
|
dr_inference_engine::init(dr_inference_engine::Config {
|
||||||
|
runtime_dirs,
|
||||||
|
cache_dir: cache_dir.clone(),
|
||||||
|
models: models
|
||||||
|
.iter()
|
||||||
|
.map(|p| (dr_inference_engine::Role::Detector, p.clone()))
|
||||||
|
.collect(),
|
||||||
|
embedded: Vec::new(),
|
||||||
|
ceiling: None,
|
||||||
|
threads: 0,
|
||||||
|
decay: Duration::ZERO,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut last = String::new();
|
||||||
|
loop {
|
||||||
|
let s = dr_inference_engine::status();
|
||||||
|
let line = format!(
|
||||||
|
"{} · {} · engines {}/{}{}",
|
||||||
|
s.line(),
|
||||||
|
if s.probing {
|
||||||
|
"probing"
|
||||||
|
} else {
|
||||||
|
s.reason.as_str()
|
||||||
|
},
|
||||||
|
s.engines.0,
|
||||||
|
s.engines.1,
|
||||||
|
if s.failed.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
" · tried {}",
|
||||||
|
s.failed
|
||||||
|
.iter()
|
||||||
|
.map(|(r, why)| format!("{}: {why}", r.label()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" · ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if line != last {
|
||||||
|
println!("{:>6.1} s {line}", started.elapsed().as_secs_f64());
|
||||||
|
last = line;
|
||||||
|
}
|
||||||
|
if !s.probing && s.engines.0 >= s.engines.1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in &models {
|
||||||
|
let bytes = std::fs::read(path).expect("read model");
|
||||||
|
let t = Instant::now();
|
||||||
|
let model = dr_inference_engine::open(
|
||||||
|
dr_inference_engine::Role::Detector,
|
||||||
|
dr_inference_engine::Form::F32,
|
||||||
|
&bytes,
|
||||||
|
)
|
||||||
|
.expect("open model");
|
||||||
|
let acquired = model.acquire().expect("acquire session");
|
||||||
|
println!(
|
||||||
|
"{} on {} in {:.2} s",
|
||||||
|
path.file_name().unwrap().to_string_lossy(),
|
||||||
|
acquired.rung().label(),
|
||||||
|
t.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
//! `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:
|
//! 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
|
//! 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
|
//! 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
|
//! context model. Both are opaque to this crate, which tracks only *that* a
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! Which runtime, which provider and which model form — decided once per
|
//! 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;
|
//! 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
|
//! what built it — tract on one core, ONNX Runtime's CPU pool, a TensorRT
|
||||||
//! engine, the Hexagon — is this crate's business and shows up in
|
//! engine, a MIGraphX program, the Hexagon — is this crate's business and
|
||||||
//! [`status`] for the settings row and nowhere else.
|
//! shows up in [`status`] for the settings row and nowhere else.
|
||||||
//!
|
//!
|
||||||
//! The shape follows §3 of the spec: `ort` links nothing (`alternative-backend`),
|
//! The shape follows §3 of the spec: `ort` links nothing (`alternative-backend`),
|
||||||
//! and the first call hands it an API table from either a `libonnxruntime`
|
//! and the first call hands it an API table from either a `libonnxruntime`
|
||||||
@@ -35,13 +35,13 @@ pub enum Role {
|
|||||||
Embedder,
|
Embedder,
|
||||||
Segmenter,
|
Segmenter,
|
||||||
Scene,
|
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,
|
Landmarks,
|
||||||
/// The eye-state and sunglasses classifiers, a few hundred kilobytes.
|
/// The eye-state and sunglasses classifiers, a few hundred kilobytes.
|
||||||
EyeClassifier,
|
EyeClassifier,
|
||||||
/// XFeat, the panorama keypoint detector (docs/panorama.md).
|
/// XFeat, the panorama keypoint detector (docs/dev/panorama.md).
|
||||||
Keypoints,
|
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
|
/// convolutions, so any rung serves it; fp16 on TensorRT and int8 on
|
||||||
/// the Hexagon are the point of it.
|
/// the Hexagon are the point of it.
|
||||||
Inpainter,
|
Inpainter,
|
||||||
@@ -60,7 +60,9 @@ pub enum Form {
|
|||||||
|
|
||||||
/// A rung of the ladder (§2). Ordered: a user override names the highest rung
|
/// A rung of the ladder (§2). Ordered: a user override names the highest rung
|
||||||
/// the probe may take, and a compiling rung falls back to the one below it
|
/// the probe may take, and a compiling rung falls back to the one below it
|
||||||
/// until its engine exists.
|
/// until its engine exists. The order is within a vendor's ladder — a
|
||||||
|
/// machine has NVIDIA rungs or an AMD rung, never both — so a ceiling is
|
||||||
|
/// read as "no higher than this on whichever ladder the device has".
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
pub enum Rung {
|
pub enum Rung {
|
||||||
/// ONNX Runtime's CPU provider, or tract when no runtime file was found.
|
/// ONNX Runtime's CPU provider, or tract when no runtime file was found.
|
||||||
@@ -69,6 +71,11 @@ pub enum Rung {
|
|||||||
Cuda,
|
Cuda,
|
||||||
/// NVIDIA, through a TensorRT engine compiled on this device. Desktop only.
|
/// NVIDIA, through a TensorRT engine compiled on this device. Desktop only.
|
||||||
TensorRt,
|
TensorRt,
|
||||||
|
/// AMD, through a MIGraphX program compiled on this device. Desktop
|
||||||
|
/// only. ONNX Runtime's ROCm provider, the CUDA provider's twin, was
|
||||||
|
/// removed in ONNX Runtime 1.23, so there is no non-compiling AMD rung
|
||||||
|
/// to fall back to: this one falls back to the CPU.
|
||||||
|
MiGraphX,
|
||||||
/// Qualcomm's Hexagon NPU through QNN, int8 models only. Android only.
|
/// Qualcomm's Hexagon NPU through QNN, int8 models only. Android only.
|
||||||
Hexagon,
|
Hexagon,
|
||||||
}
|
}
|
||||||
@@ -79,6 +86,7 @@ impl Rung {
|
|||||||
Rung::Cpu => "CPU",
|
Rung::Cpu => "CPU",
|
||||||
Rung::Cuda => "CUDA",
|
Rung::Cuda => "CUDA",
|
||||||
Rung::TensorRt => "TensorRT",
|
Rung::TensorRt => "TensorRT",
|
||||||
|
Rung::MiGraphX => "MIGraphX",
|
||||||
Rung::Hexagon => "Hexagon NPU",
|
Rung::Hexagon => "Hexagon NPU",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,13 +96,13 @@ impl Rung {
|
|||||||
fn fallback(self) -> Rung {
|
fn fallback(self) -> Rung {
|
||||||
match self {
|
match self {
|
||||||
Rung::TensorRt => Rung::Cuda,
|
Rung::TensorRt => Rung::Cuda,
|
||||||
Rung::Hexagon | Rung::Cuda | Rung::Cpu => Rung::Cpu,
|
Rung::MiGraphX | Rung::Hexagon | Rung::Cuda | Rung::Cpu => Rung::Cpu,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a session on this rung needs an engine built first.
|
/// Whether a session on this rung needs an engine built first.
|
||||||
fn compiles(self) -> bool {
|
fn compiles(self) -> bool {
|
||||||
matches!(self, Rung::TensorRt | Rung::Hexagon)
|
matches!(self, Rung::TensorRt | Rung::MiGraphX | Rung::Hexagon)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The model form this rung wants for a role.
|
/// The model form this rung wants for a role.
|
||||||
@@ -164,7 +172,7 @@ impl Status {
|
|||||||
pub fn line(&self) -> String {
|
pub fn line(&self) -> String {
|
||||||
let form = match self.rung {
|
let form = match self.rung {
|
||||||
Rung::Hexagon => " · int8",
|
Rung::Hexagon => " · int8",
|
||||||
Rung::TensorRt => " · fp16",
|
Rung::TensorRt | Rung::MiGraphX => " · fp16",
|
||||||
_ => "",
|
_ => "",
|
||||||
};
|
};
|
||||||
format!("{}{} · {}", self.rung.label(), form, self.runtime.label())
|
format!("{}{} · {}", self.rung.label(), form, self.runtime.label())
|
||||||
@@ -269,8 +277,8 @@ fn acquire(role: Role, form: Form, bytes: &Arc<[u8]>, hash: u64) -> Result<Acqui
|
|||||||
return Ok(Acquired { entry });
|
return Ok(Acquired { entry });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Built outside the registry lock: a TensorRT engine load is long enough
|
// Built outside the registry lock: a TensorRT or MIGraphX engine load
|
||||||
// that another role's acquire should not wait on it.
|
// is long enough that another role's acquire should not wait on it.
|
||||||
let session = session::build(rung, role, bytes, &cfg)?;
|
let session = session::build(rung, role, bytes, &cfg)?;
|
||||||
log::debug!("inference: {role:?} loaded on {}", rung.label());
|
log::debug!("inference: {role:?} loaded on {}", rung.label());
|
||||||
let entry = Arc::new(Loaded {
|
let entry = Arc::new(Loaded {
|
||||||
@@ -401,7 +409,16 @@ pub fn status() -> Status {
|
|||||||
runtime: api::runtime(),
|
runtime: api::runtime(),
|
||||||
rung,
|
rung,
|
||||||
reason: s.cache.reason.clone(),
|
reason: s.cache.reason.clone(),
|
||||||
failed: s.cache.failed.clone(),
|
// Only what explains the selection: on an AMD machine the NVIDIA
|
||||||
|
// rungs "not enabled in this build" say nothing about why MIGraphX
|
||||||
|
// was taken. With the floor selected, everything tried is above it.
|
||||||
|
failed: s
|
||||||
|
.cache
|
||||||
|
.failed
|
||||||
|
.iter()
|
||||||
|
.filter(|(r, _)| *r > rung)
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
probing: s.probing,
|
probing: s.probing,
|
||||||
engines: if rung.compiles() {
|
engines: if rung.compiles() {
|
||||||
(s.cache.compiled.len(), s.wanted)
|
(s.cache.compiled.len(), s.wanted)
|
||||||
@@ -499,7 +516,7 @@ mod tests {
|
|||||||
|
|
||||||
/// The smallest shipped graph, if this checkout has the weights; a test
|
/// The smallest shipped graph, if this checkout has the weights; a test
|
||||||
/// suite that needs a research-licensed download is one that does not
|
/// 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>> {
|
fn probe_bytes() -> Option<Vec<u8>> {
|
||||||
let path = concat!(
|
let path = concat!(
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
@@ -614,8 +631,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_status_reports_only_the_rungs_above_the_selection() {
|
||||||
|
let _serial = serial();
|
||||||
|
let failed = vec![
|
||||||
|
(Rung::TensorRt, "not enabled".to_string()),
|
||||||
|
(Rung::Cuda, "not enabled".to_string()),
|
||||||
|
];
|
||||||
|
let before = state().lock().unwrap().cache.clone();
|
||||||
|
state().lock().unwrap().cache = Cache {
|
||||||
|
rung: Some(Rung::MiGraphX),
|
||||||
|
failed: failed.clone(),
|
||||||
|
..Cache::default()
|
||||||
|
};
|
||||||
|
// An AMD desktop: the NVIDIA rungs below MIGraphX are not the story.
|
||||||
|
assert!(status().failed.is_empty());
|
||||||
|
// An NVIDIA desktop on the CUDA provider: TensorRT's failure is.
|
||||||
|
state().lock().unwrap().cache.rung = Some(Rung::Cuda);
|
||||||
|
assert_eq!(status().failed, vec![failed[0].clone()]);
|
||||||
|
// The floor: everything tried explains it.
|
||||||
|
state().lock().unwrap().cache.rung = Some(Rung::Cpu);
|
||||||
|
assert_eq!(status().failed.len(), 2);
|
||||||
|
state().lock().unwrap().cache = before;
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_status_line_reads_as_the_floor_before_init() {
|
fn the_status_line_reads_as_the_floor_before_init() {
|
||||||
|
let _serial = serial();
|
||||||
let s = status();
|
let s = status();
|
||||||
assert_eq!(s.rung, Rung::Cpu);
|
assert_eq!(s.rung, Rung::Cpu);
|
||||||
assert!(s.line().starts_with("CPU"), "{}", s.line());
|
assert!(s.line().starts_with("CPU"), "{}", s.line());
|
||||||
|
|||||||
@@ -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
|
//! 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
|
//! the floor. Both halves matter: a provider can register and then fail at
|
||||||
@@ -16,8 +16,11 @@ use crate::{api::Runtime, state, Cache, Config, Form, Role, Rung};
|
|||||||
fn ladder(ceiling: Option<Rung>) -> Vec<Rung> {
|
fn ladder(ceiling: Option<Rung>) -> Vec<Rung> {
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
let all = [Rung::Hexagon];
|
let all = [Rung::Hexagon];
|
||||||
|
// A desktop has one vendor's GPU; the other vendor's providers are
|
||||||
|
// "not enabled in this build" or a library that fails to load, and
|
||||||
|
// either answer arrives in milliseconds.
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
let all = [Rung::TensorRt, Rung::Cuda];
|
let all = [Rung::TensorRt, Rung::Cuda, Rung::MiGraphX];
|
||||||
all.into_iter()
|
all.into_iter()
|
||||||
.filter(|r| ceiling.is_none_or(|c| *r <= c))
|
.filter(|r| ceiling.is_none_or(|c| *r <= c))
|
||||||
.collect()
|
.collect()
|
||||||
@@ -206,15 +209,22 @@ fn first_line(s: &str) -> String {
|
|||||||
line[start..].chars().take(200).collect()
|
line[start..].chars().take(200).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything a change of which should re-probe: the runtime and where it
|
/// Everything a change of which should re-probe: the runtime, where it
|
||||||
/// came from, this crate, the platform, the driver or SoC, and the models.
|
/// came from and which providers sit beside it, this crate, the platform,
|
||||||
|
/// the driver or SoC, and the models.
|
||||||
fn fingerprint(runtime: &Runtime, cfg: &Config) -> String {
|
fn fingerprint(runtime: &Runtime, cfg: &Config) -> String {
|
||||||
let mut parts = vec![
|
let mut parts = vec![
|
||||||
format!("engine {}", env!("CARGO_PKG_VERSION")),
|
format!("engine {}", env!("CARGO_PKG_VERSION")),
|
||||||
format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
|
format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||||
match runtime {
|
match runtime {
|
||||||
Runtime::Tract => "tract".to_string(),
|
Runtime::Tract => "tract".to_string(),
|
||||||
Runtime::OnnxRuntime { path, version } => format!("ort {version} {}", path.display()),
|
Runtime::OnnxRuntime { path, version } => {
|
||||||
|
format!(
|
||||||
|
"ort {version} {} [{}]",
|
||||||
|
path.display(),
|
||||||
|
providers_beside(path)
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
device_identity(),
|
device_identity(),
|
||||||
];
|
];
|
||||||
@@ -237,13 +247,41 @@ fn fingerprint(runtime: &Runtime, cfg: &Config) -> String {
|
|||||||
parts.join("\n")
|
parts.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `libonnxruntime_providers_*.so` files in the runtime's directory.
|
||||||
|
/// A distribution's CPU-only and ROCm builds are the same version at the
|
||||||
|
/// same path; the provider libraries beside them are what differs.
|
||||||
|
fn providers_beside(runtime: &Path) -> String {
|
||||||
|
let Some(dir) = runtime.parent() else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let mut names: Vec<String> = std::fs::read_dir(dir)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter_map(|e| e.file_name().into_string().ok())
|
||||||
|
.filter(|n| {
|
||||||
|
n.starts_with("libonnxruntime_providers_") || n.starts_with("onnxruntime_providers_")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
names.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn device_identity() -> String {
|
fn device_identity() -> String {
|
||||||
// The NVIDIA driver's version line; absent means no NVIDIA driver.
|
// The NVIDIA driver's version line, or the ROCm release the AMD stack
|
||||||
std::fs::read_to_string("/proc/driver/nvidia/version")
|
// came from (`rocm-core` writes it; the kernel driver has no version
|
||||||
|
// of its own). Absent means neither.
|
||||||
|
if let Some(line) = std::fs::read_to_string("/proc/driver/nvidia/version")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.lines().next().map(str::to_string))
|
.and_then(|s| s.lines().next().map(str::to_string))
|
||||||
.unwrap_or_else(|| "no nvidia driver".into())
|
{
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
if let Ok(rocm) = std::fs::read_to_string("/opt/rocm/.info/version") {
|
||||||
|
return format!("rocm {}", rocm.trim());
|
||||||
|
}
|
||||||
|
"no nvidia driver, no rocm".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
|
|||||||
@@ -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;
|
use ort::session::Session;
|
||||||
|
|
||||||
@@ -83,10 +83,65 @@ fn providers(
|
|||||||
ep::CUDA::default().build(),
|
ep::CUDA::default().build(),
|
||||||
])?)
|
])?)
|
||||||
}
|
}
|
||||||
|
Rung::MiGraphX => {
|
||||||
|
// fp16 on the same terms as TensorRT (§7). MIGraphX compiles a
|
||||||
|
// program per graph — 20–60 s here — and keeps it in the cache
|
||||||
|
// directory, keyed on the graph, the GPU and its own version
|
||||||
|
// but not the precision: hence one directory per precision.
|
||||||
|
// The CPU takes any node it declines.
|
||||||
|
let fp16 = role != Role::Embedder;
|
||||||
|
let cache = cfg
|
||||||
|
.cache_dir
|
||||||
|
.join("migraphx")
|
||||||
|
.join(if fp16 { "fp16" } else { "f32" });
|
||||||
|
let _ = std::fs::create_dir_all(&cache);
|
||||||
|
let mut b = b;
|
||||||
|
migraphx(&mut b, fp16, &cache)?;
|
||||||
|
Ok(b)
|
||||||
|
}
|
||||||
Rung::Hexagon => unreachable!("the Hexagon rung is not on a desktop ladder"),
|
Rung::Hexagon => unreachable!("the Hexagon rung is not on a desktop ladder"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register MIGraphX through ONNX Runtime's generic key/value entry point.
|
||||||
|
///
|
||||||
|
/// `ort`'s own builder (`ep::MIGraphX`) fills the legacy
|
||||||
|
/// `OrtMIGraphXProviderOptions`, and 1.29 reads that struct for its
|
||||||
|
/// precision flags and nothing else — the compiled-program cache directory
|
||||||
|
/// is only a key in the generic map (`migraphx_model_cache_dir`), and
|
||||||
|
/// without it every session is a full compile. Registration through the
|
||||||
|
/// generic entry point needs no `ort` feature: it is one call on the API
|
||||||
|
/// table, which is why the crate's `ort` dependency names no AMD feature.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
fn migraphx(
|
||||||
|
b: &mut ort::session::builder::SessionBuilder,
|
||||||
|
fp16: bool,
|
||||||
|
cache: &std::path::Path,
|
||||||
|
) -> ort::Result<()> {
|
||||||
|
use ort::AsPointer;
|
||||||
|
use std::ffi::CString;
|
||||||
|
let keys = [c"migraphx_fp16_enable", c"migraphx_model_cache_dir"];
|
||||||
|
let values = [
|
||||||
|
CString::new(if fp16 { "1" } else { "0" }).unwrap(),
|
||||||
|
CString::new(cache.to_string_lossy().as_bytes())
|
||||||
|
.map_err(|e| ort::Error::new(e.to_string()))?,
|
||||||
|
];
|
||||||
|
let key_ptrs: Vec<_> = keys.iter().map(|k| k.as_ptr()).collect();
|
||||||
|
let value_ptrs: Vec<_> = values.iter().map(|v| v.as_ptr()).collect();
|
||||||
|
// SAFETY: the documented C call over arrays that outlive it; the
|
||||||
|
// runtime copies the strings into its own options map before returning.
|
||||||
|
unsafe {
|
||||||
|
let status = (ort::api().SessionOptionsAppendExecutionProvider)(
|
||||||
|
b.ptr_mut(),
|
||||||
|
c"MIGraphX".as_ptr(),
|
||||||
|
key_ptrs.as_ptr(),
|
||||||
|
value_ptrs.as_ptr(),
|
||||||
|
keys.len(),
|
||||||
|
);
|
||||||
|
ort::Error::result_from_status(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
fn providers(
|
fn providers(
|
||||||
b: ort::session::builder::SessionBuilder,
|
b: ort::session::builder::SessionBuilder,
|
||||||
@@ -120,6 +175,8 @@ fn providers(
|
|||||||
.build()
|
.build()
|
||||||
.error_on_failure()])?)
|
.error_on_failure()])?)
|
||||||
}
|
}
|
||||||
Rung::Cuda | Rung::TensorRt => unreachable!("no NVIDIA rung on Android"),
|
Rung::Cuda | Rung::TensorRt | Rung::MiGraphX => {
|
||||||
|
unreachable!("no desktop GPU rung on Android")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ log.workspace = true
|
|||||||
|
|
||||||
# Inference for the learned keypoint detector, on the same footing as
|
# Inference for the learned keypoint detector, on the same footing as
|
||||||
# `dr-segment`: `ort` is the API, `dr-inference-engine` decides what runs
|
# `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
|
# matching, the rotation solve, the projections — is a dependency-free crate
|
||||||
# that tests without a model.
|
# that tests without a model.
|
||||||
ort = { workspace = true, optional = true }
|
ort = { workspace = true, optional = true }
|
||||||
|
|||||||
+62
-10
@@ -95,7 +95,7 @@ pub struct Params {
|
|||||||
/// and, beyond the band being filled, still unknown. That is what the
|
/// 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
|
/// shipped model was trained on (a fine-tune of MI-GAN on voids cut
|
||||||
/// from photographs the way a cylindrical merge cuts them, see
|
/// 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
|
/// 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 —
|
/// hole pulls in whatever is that far from the edge — a ridge, a peak —
|
||||||
@@ -303,7 +303,7 @@ fn fill_once(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The padded canvas with mirrored context, and the hole within it.
|
// The padded canvas with mirrored context, and the hole within it.
|
||||||
let ctx = MirroredContext::build(rgb, width, height, known, mirror_depth);
|
let ctx = MirroredContext::build(rgb, width, height, known, mirror_depth, t);
|
||||||
let (pw, ph) = (ctx.width, ctx.height);
|
let (pw, ph) = (ctx.width, ctx.height);
|
||||||
|
|
||||||
// Tiles that touch the hole, on a grid that reaches both far edges.
|
// Tiles that touch the hole, on a grid that reaches both far edges.
|
||||||
@@ -499,16 +499,36 @@ struct MirroredContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MirroredContext {
|
impl MirroredContext {
|
||||||
fn build(rgb: &[f32], width: usize, height: usize, known: &[bool], depth: usize) -> Self {
|
fn build(
|
||||||
|
rgb: &[f32],
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
known: &[bool],
|
||||||
|
depth: usize,
|
||||||
|
tile: usize,
|
||||||
|
) -> Self {
|
||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
// Open: the picture as it is, the hole as it is. What the hole
|
// Open: the picture as it is, the hole as it is. What the hole
|
||||||
// holds does not matter — the model masks it out.
|
// holds does not matter — the model masks it out. A picture
|
||||||
|
// smaller than a tile (the merge page's preview) sits at the
|
||||||
|
// origin of a tile-sized canvas whose rest is hole: still the
|
||||||
|
// void as it is, and the only way a tile fits at all.
|
||||||
|
let (pw, ph) = (width.max(tile), height.max(tile));
|
||||||
|
let mut canvas = vec![0.0f32; pw * ph * 3];
|
||||||
|
let mut hole = vec![true; pw * ph];
|
||||||
|
for y in 0..height {
|
||||||
|
canvas[y * pw * 3..(y * pw + width) * 3]
|
||||||
|
.copy_from_slice(&rgb[y * width * 3..(y + 1) * width * 3]);
|
||||||
|
for x in 0..width {
|
||||||
|
hole[y * pw + x] = !known[y * width + x];
|
||||||
|
}
|
||||||
|
}
|
||||||
return MirroredContext {
|
return MirroredContext {
|
||||||
width,
|
width: pw,
|
||||||
height,
|
height: ph,
|
||||||
ring: 0,
|
ring: 0,
|
||||||
rgb: rgb.to_vec(),
|
rgb: canvas,
|
||||||
hole: known.iter().map(|&k| !k).collect(),
|
hole,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let fold = |d: usize| fold(d, depth);
|
let fold = |d: usize| fold(d, depth);
|
||||||
@@ -715,6 +735,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_picture_smaller_than_the_tile_is_still_filled_when_the_void_is_open() {
|
||||||
|
// The merge page's preview is 1600 wide and a few hundred tall —
|
||||||
|
// shorter than a 512 tile. With no ring the canvas is padded to a
|
||||||
|
// tile, the padding hole, and the border is still filled.
|
||||||
|
let (mut rgb, known) = picture(300, 40, 8);
|
||||||
|
let mut model = Flat {
|
||||||
|
tile: 64,
|
||||||
|
seen: Vec::new(),
|
||||||
|
};
|
||||||
|
let tiles = fill_border(
|
||||||
|
&mut rgb,
|
||||||
|
300,
|
||||||
|
40,
|
||||||
|
&known,
|
||||||
|
&mut model,
|
||||||
|
test_params(0),
|
||||||
|
&mut |_, _| {},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(tiles > 0, "no tile fitted a picture shorter than the tile");
|
||||||
|
for i in 0..300 * 40 {
|
||||||
|
if !known[i] {
|
||||||
|
assert!((rgb[i * 3] - 0.5).abs() < 1e-4, "pixel {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And the model saw the padding as hole, never as black content.
|
||||||
|
for (_, k) in &model.seen {
|
||||||
|
assert_eq!(k.len(), 64 * 64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_fine_passes_run_in_bands_after_the_coarse_one() {
|
fn the_fine_passes_run_in_bands_after_the_coarse_one() {
|
||||||
// A 150-tall hole above and below a picture: the coarse pass sees
|
// A 150-tall hole above and below a picture: the coarse pass sees
|
||||||
@@ -824,7 +876,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn the_context_mirrors_the_top_rows_upward() {
|
fn the_context_mirrors_the_top_rows_upward() {
|
||||||
let (rgb, known) = picture(40, 30, 5);
|
let (rgb, known) = picture(40, 30, 5);
|
||||||
let ctx = MirroredContext::build(&rgb, 40, 30, &known, 48);
|
let ctx = MirroredContext::build(&rgb, 40, 30, &known, 48, 64);
|
||||||
let x = RING + 10;
|
let x = RING + 10;
|
||||||
let first = RING + 5;
|
let first = RING + 5;
|
||||||
for k in 1..=4 {
|
for k in 1..=4 {
|
||||||
@@ -846,7 +898,7 @@ mod tests {
|
|||||||
for x in 0..40 {
|
for x in 0..40 {
|
||||||
rgb[(ridge * 40 + x) * 3..(ridge * 40 + x) * 3 + 3].copy_from_slice(&[0.9, 0.1, 0.1]);
|
rgb[(ridge * 40 + x) * 3..(ridge * 40 + x) * 3 + 3].copy_from_slice(&[0.9, 0.1, 0.1]);
|
||||||
}
|
}
|
||||||
let ctx = MirroredContext::build(&rgb, 40, 400, &known, 48);
|
let ctx = MirroredContext::build(&rgb, 40, 400, &known, 48, 64);
|
||||||
let x = RING + 10;
|
let x = RING + 10;
|
||||||
for y in 0..RING + 5 {
|
for y in 0..RING + 5 {
|
||||||
let p = (y * ctx.width + x) * 3;
|
let p = (y * ctx.width + x) * 3;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//! on every rung, and what they cost is the whole story of whether a fill
|
//! 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
|
//! 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
|
//! 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`:
|
//! 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
|
//! 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
|
//! 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`
|
//! `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
|
//! `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
|
//! per frame on tract on the reference desktop, ~400 ms on the tablet
|
||||||
//! (S15.2, S15.4).
|
//! (S15.2, S15.4).
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ pub struct XFeat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The bytes of both exports compiled into the binary, for whoever compiles
|
/// 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")]
|
#[cfg(feature = "embedded-model")]
|
||||||
pub fn embedded_model_bytes() -> [&'static [u8]; 2] {
|
pub fn embedded_model_bytes() -> [&'static [u8]; 2] {
|
||||||
[EMBEDDED_LANDSCAPE, EMBEDDED_PORTRAIT]
|
[EMBEDDED_LANDSCAPE, EMBEDDED_PORTRAIT]
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ pub struct DetailPass {
|
|||||||
/// 52 render pixels at 4K — holds no spatial frequency a quarter-scale
|
/// 52 render pixels at 4K — holds no spatial frequency a quarter-scale
|
||||||
/// grid cannot represent. Computing it at the render size therefore buys
|
/// grid cannot represent. Computing it at the render size therefore buys
|
||||||
/// nothing and costs everything: 105 taps over 8.3 M pixels, twice, which
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// photograph the photographer thinks they are sharpening.
|
||||||
///
|
///
|
||||||
/// It also means ARCH §5.2's stage list, which draws spot removal after
|
/// 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.
|
/// §5.1, which is where the disagreement is written down.
|
||||||
pub fn compose_detail_with(
|
pub fn compose_detail_with(
|
||||||
ops: &[Box<dyn Operation>],
|
ops: &[Box<dyn Operation>],
|
||||||
|
|||||||
@@ -27,6 +27,33 @@
|
|||||||
//! chain exactly the space it documents: normalised, centred, `r == 1` at the
|
//! chain exactly the space it documents: normalised, centred, `r == 1` at the
|
||||||
//! corner. Neither stage needs to know the other exists.
|
//! 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
|
//! # Why sampling changes with the angle
|
||||||
//!
|
//!
|
||||||
//! At 90° steps and flips, output pixels land exactly on source pixels, so
|
//! 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_Y: ParamId = ParamId("crop_y");
|
||||||
pub const CROP_W: ParamId = ParamId("crop_w");
|
pub const CROP_W: ParamId = ParamId("crop_w");
|
||||||
pub const CROP_H: ParamId = ParamId("crop_h");
|
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.
|
/// 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.
|
/// the edits actually are.
|
||||||
pub const MAX_STRAIGHTEN: f32 = 45.0;
|
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.
|
/// The parameters the framing widget owns — every one of them.
|
||||||
///
|
///
|
||||||
/// In the order the widget expects: the rect first, then the angle it is
|
/// In the order the widget expects: the rect first, then the angle it is
|
||||||
/// straightened by, then the exact reorientations.
|
/// straightened by, then the exact reorientations.
|
||||||
static FRAMING_PARAMS: [ParamId; 8] = [
|
static FRAMING_PARAMS: [ParamId; 10] = [
|
||||||
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, ROTATION, FLIP_H, FLIP_V,
|
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(|| {
|
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_y", "param.crop_y", 0.0),
|
||||||
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
|
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
|
||||||
ParamDescriptor::fraction("crop_h", "param.crop_h", 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
|
/// TRACES: FR-DEV-3 | FR-DEV-3d
|
||||||
/// Crop, straighten, rotation and flips for one image.
|
/// Crop, straighten, rotation and flips for one image.
|
||||||
///
|
///
|
||||||
@@ -325,6 +479,10 @@ pub struct Framing {
|
|||||||
quarter_turns: u8,
|
quarter_turns: u8,
|
||||||
flip_h: bool,
|
flip_h: bool,
|
||||||
flip_v: 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
|
/// TRACES: FR-DEV-3h
|
||||||
/// How the file's pixels were stored, from its EXIF orientation.
|
/// How the file's pixels were stored, from its EXIF orientation.
|
||||||
///
|
///
|
||||||
@@ -376,6 +534,8 @@ impl Default for Framing {
|
|||||||
quarter_turns: 0,
|
quarter_turns: 0,
|
||||||
flip_h: false,
|
flip_h: false,
|
||||||
flip_v: false,
|
flip_v: false,
|
||||||
|
keystone_v: 0.0,
|
||||||
|
keystone_h: 0.0,
|
||||||
baseline: dr_types::Orientation::NORMAL,
|
baseline: dr_types::Orientation::NORMAL,
|
||||||
crop: CropRect::default(),
|
crop: CropRect::default(),
|
||||||
view: CropRect::default(),
|
view: CropRect::default(),
|
||||||
@@ -396,7 +556,7 @@ impl Framing {
|
|||||||
/// How framing would like to be presented.
|
/// How framing would like to be presented.
|
||||||
///
|
///
|
||||||
/// **This is what stops a frontend having to name this stage.** Rendered
|
/// **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"
|
/// 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
|
/// 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
|
/// 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
|
/// a second frontend would have had to learn the same special case, and
|
||||||
/// nothing in the capability output said why. Now the preference is
|
/// nothing in the capability output said why. Now the preference is
|
||||||
/// declared, the demand says what the widget needs, and a frontend that
|
/// 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.
|
/// 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
|
/// frontend that takes this on is taking on the whole framing control
|
||||||
/// surface, and leaving rotation and the flips behind would scatter them
|
/// surface, and leaving rotation and the flips behind would scatter them
|
||||||
/// into the generated panel underneath a crop control that already exists.
|
/// into the generated panel underneath a crop control that already exists.
|
||||||
@@ -476,6 +636,52 @@ impl Framing {
|
|||||||
(self.flip_h, self.flip_v)
|
(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.
|
/// Add quarter turns, wrapping. The rotate-left/right buttons.
|
||||||
pub fn rotate_quarters(&mut self, turns: i32) {
|
pub fn rotate_quarters(&mut self, turns: i32) {
|
||||||
self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8;
|
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 {
|
pub fn is_active(&self) -> bool {
|
||||||
let (turns, flip_h, flip_v) = self.effective();
|
let (turns, flip_h, flip_v) = self.effective();
|
||||||
self.angle != 0.0
|
self.angle != 0.0
|
||||||
|
|| self.has_keystone()
|
||||||
// Effective, not the user's: a file stored sideways needs the
|
// Effective, not the user's: a file stored sideways needs the
|
||||||
// prologue emitted even on an untouched image, or it renders
|
// prologue emitted even on an untouched image, or it renders
|
||||||
// through the identity map and lies on its side.
|
// through the identity map and lies on its side.
|
||||||
@@ -572,6 +779,7 @@ impl Framing {
|
|||||||
/// edited, the file was merely read correctly.
|
/// edited, the file was merely read correctly.
|
||||||
pub fn edits_image(&self) -> bool {
|
pub fn edits_image(&self) -> bool {
|
||||||
self.angle != 0.0
|
self.angle != 0.0
|
||||||
|
|| self.has_keystone()
|
||||||
|| self.quarter_turns != 0
|
|| self.quarter_turns != 0
|
||||||
|| self.flip_h
|
|| self.flip_h
|
||||||
|| self.flip_v
|
|| self.flip_v
|
||||||
@@ -591,7 +799,9 @@ impl Framing {
|
|||||||
/// warp being active forces interpolation regardless, which is the
|
/// warp being active forces interpolation regardless, which is the
|
||||||
/// composer's call to make rather than this stage's.
|
/// composer's call to make rather than this stage's.
|
||||||
pub fn needs_interpolation(&self) -> bool {
|
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) {
|
pub fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
@@ -629,6 +839,8 @@ impl Framing {
|
|||||||
}
|
}
|
||||||
.normalised()
|
.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}"),
|
_ => log::warn!("framing: unknown parameter {id}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -643,6 +855,8 @@ impl Framing {
|
|||||||
CROP_Y => self.crop.y,
|
CROP_Y => self.crop.y,
|
||||||
CROP_W => self.crop.width,
|
CROP_W => self.crop.width,
|
||||||
CROP_H => self.crop.height,
|
CROP_H => self.crop.height,
|
||||||
|
KEYSTONE_V => self.keystone_v,
|
||||||
|
KEYSTONE_H => self.keystone_h,
|
||||||
_ => 0.0,
|
_ => 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -707,8 +921,22 @@ impl Framing {
|
|||||||
///
|
///
|
||||||
/// The standard largest-inscribed-rectangle result for a rotated
|
/// The standard largest-inscribed-rectangle result for a rotated
|
||||||
/// rectangle of the same aspect ratio.
|
/// 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 {
|
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();
|
return CropRect::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,6 +978,123 @@ impl Framing {
|
|||||||
.normalised()
|
.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
|
/// TRACES: FR-DEV-3
|
||||||
/// Where an output point comes from in the source, both in normalised
|
/// Where an output point comes from in the source, both in normalised
|
||||||
/// `0..1` coordinates.
|
/// `0..1` coordinates.
|
||||||
@@ -782,6 +1127,15 @@ impl Framing {
|
|||||||
p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c);
|
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();
|
let (turns, flip_h, flip_v) = self.effective();
|
||||||
p = match turns {
|
p = match turns {
|
||||||
1 => (p.1 * ax, -p.0 / fx),
|
1 => (p.1 * ax, -p.0 / fx),
|
||||||
@@ -824,6 +1178,13 @@ impl Framing {
|
|||||||
_ => p,
|
_ => 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 {
|
if self.angle != 0.0 {
|
||||||
let rad = -self.angle * PI / 180.0;
|
let rad = -self.angle * PI / 180.0;
|
||||||
let (s, c) = (rad.sin(), rad.cos());
|
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
|
// identical whether or not the user is zoomed in, and costs no extra
|
||||||
// uniform slot.
|
// uniform slot.
|
||||||
let rect = self.visible_rect();
|
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.x,
|
||||||
rect.y,
|
rect.y,
|
||||||
@@ -874,6 +1248,18 @@ impl Framing {
|
|||||||
rad.cos(),
|
rad.cos(),
|
||||||
0.0,
|
0.0,
|
||||||
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
|
// The user's turns and mirrors composed with the file's stored
|
||||||
// orientation. One permutation covers both, so honouring the EXIF tag
|
// orientation. One permutation covers both, so honouring the EXIF tag
|
||||||
// adds no per-pixel work over an untagged file.
|
// adds no per-pixel work over an untagged file.
|
||||||
@@ -1040,13 +1448,15 @@ impl Framing {
|
|||||||
| u64::from(flip_v) << 3
|
| u64::from(flip_v) << 3
|
||||||
| u64::from(turns) << 4
|
| u64::from(turns) << 4
|
||||||
| u64::from(self.is_active()) << 6
|
| u64::from(self.is_active()) << 6
|
||||||
|
| u64::from(self.has_keystone()) << 7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Floats the framing block occupies in the generated uniform struct.
|
/// Floats the framing block occupies in the generated uniform struct.
|
||||||
///
|
///
|
||||||
/// Two `vec4`s: the crop rect, and the angle's sin/cos with padding.
|
/// Five `vec4`s: the crop rect, the angle's sin/cos with padding, and the
|
||||||
pub const FRAMING_UNIFORM_FIELDS: usize = 8;
|
/// perspective map's three columns, each padded.
|
||||||
|
pub const FRAMING_UNIFORM_FIELDS: usize = 20;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -2025,6 +2435,8 @@ mod tests {
|
|||||||
(CROP_Y, 0.2),
|
(CROP_Y, 0.2),
|
||||||
(CROP_W, 0.5),
|
(CROP_W, 0.5),
|
||||||
(CROP_H, 0.4),
|
(CROP_H, 0.4),
|
||||||
|
(KEYSTONE_V, 35.0),
|
||||||
|
(KEYSTONE_H, -20.0),
|
||||||
] {
|
] {
|
||||||
f.set_param(id, v);
|
f.set_param(id, v);
|
||||||
assert_eq!(f.param(id), v, "{id} did not round-trip");
|
assert_eq!(f.param(id), v, "{id} did not round-trip");
|
||||||
@@ -2241,6 +2653,8 @@ mod tests {
|
|||||||
f.rotate_quarters(turns);
|
f.rotate_quarters(turns);
|
||||||
f.set_param(FLIP_H, 1.0);
|
f.set_param(FLIP_H, 1.0);
|
||||||
f.set_param(FLIP_V, 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)] {
|
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);
|
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 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
|
// And the sampler's last step, which lives in `operation.rs` and is
|
||||||
// the half of the map this file does not emit.
|
// the half of the map this file does not emit.
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2323,4 +2758,241 @@ mod tests {
|
|||||||
"the sampler's return to texture coordinates moved"
|
"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.
|
/// a sidecar comes to name one stock while the shader draws another.
|
||||||
film: Option<Film>,
|
film: Option<Film>,
|
||||||
/// TRACES: FR-DEV-8
|
/// 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
|
/// 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
|
/// 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.
|
/// photograph comes from.
|
||||||
spots: SpotSet,
|
spots: SpotSet,
|
||||||
/// The lens corrections that rewrite coordinates: distortion and lateral
|
/// 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
|
/// 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
|
/// but about direction. Every [`Operation`] is a function from colour to
|
||||||
@@ -857,6 +857,39 @@ impl EditGraph {
|
|||||||
crate::operation::compose_camera_linear(&self.warps, self.framing.baseline(), view)
|
crate::operation::compose_camera_linear(&self.warps, self.framing.baseline(), view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// The camera-space tap over one patch of what is on the canvas.
|
||||||
|
///
|
||||||
|
/// `patch` is in fractions of the visible region — the coordinates a
|
||||||
|
/// click on the canvas arrives in — and is laid over this edit's own
|
||||||
|
/// framing: crop, view, rotation and all, so a fraction of the canvas is
|
||||||
|
/// a fraction of the probe. Nothing else of the edit: no operation, no
|
||||||
|
/// mask, no repair. Rendering only the patch is what lets a small target
|
||||||
|
/// cover every sensor pixel under it rather than sampling one in fifty;
|
||||||
|
/// see [`crate::operation::compose_camera_probe`] for why the white
|
||||||
|
/// balance picker reads from here and not from the display.
|
||||||
|
///
|
||||||
|
/// The patch is centred where asked and held to the view's own minimum
|
||||||
|
/// extent: at a deep zoom a patch a fraction of the view would be
|
||||||
|
/// smaller than a view may be, and letting `set_view` widen it from one
|
||||||
|
/// corner would move the sample off the point that was clicked.
|
||||||
|
pub fn compose_camera_probe(&self, patch: crate::framing::CropRect) -> ComposedShader {
|
||||||
|
use crate::framing::CropRect;
|
||||||
|
let mut framing = self.framing;
|
||||||
|
let view = framing.view();
|
||||||
|
let width = (patch.width * view.width).max(CropRect::MIN_EXTENT);
|
||||||
|
let height = (patch.height * view.height).max(CropRect::MIN_EXTENT);
|
||||||
|
let cx = view.x + (patch.x + patch.width * 0.5) * view.width;
|
||||||
|
let cy = view.y + (patch.y + patch.height * 0.5) * view.height;
|
||||||
|
framing.set_view(CropRect {
|
||||||
|
x: (cx - width * 0.5).clamp(0.0, 1.0 - width),
|
||||||
|
y: (cy - height * 0.5).clamp(0.0, 1.0 - height),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
crate::operation::compose_camera_probe(&self.warps, &framing)
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-19c
|
/// TRACES: FR-DEV-19c
|
||||||
/// [`Self::compose_for`], with one layer's mask drawn over the picture.
|
/// [`Self::compose_for`], with one layer's mask drawn over the picture.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ pub mod mask;
|
|||||||
pub mod neutral;
|
pub mod neutral;
|
||||||
pub mod operation;
|
pub mod operation;
|
||||||
pub mod ops;
|
pub mod ops;
|
||||||
|
pub mod orphan;
|
||||||
pub mod preset;
|
pub mod preset;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
pub mod spot;
|
pub mod spot;
|
||||||
@@ -65,7 +66,8 @@ pub use history::{Edit, Entry as HistoryEntry, History, Step};
|
|||||||
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
|
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
|
||||||
pub use operation::{
|
pub use operation::{
|
||||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
||||||
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS,
|
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET,
|
||||||
|
RESERVED_UNIFORM_FIELDS,
|
||||||
};
|
};
|
||||||
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
|
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope};
|
||||||
pub use sidecar::{Sidecar, Version};
|
pub use sidecar::{Sidecar, Version};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
//! [`MaskSource::Regions`] stores integers naming regions in the segmentation
|
//! [`MaskSource::Regions`] stores integers naming regions in the segmentation
|
||||||
//! hierarchy (`dr-segment`). That choice is what makes a mask diffable, cheap
|
//! 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
|
//! 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
|
//! select the same subject produce the same small sorted list, and a sync
|
||||||
//! conflict between them is resolvable rather than a binary blob fight.
|
//! 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.
|
/// This is what the watershed and the semantic model exist to produce.
|
||||||
/// Selecting a subject means "the regions the model's instance covers",
|
/// 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
|
/// 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 {
|
Regions {
|
||||||
/// Which segmentation these ids index into.
|
/// Which segmentation these ids index into.
|
||||||
///
|
///
|
||||||
@@ -590,7 +590,7 @@ pub enum MaskSource {
|
|||||||
/// **The primary way a local adjustment is made.** The watershed hierarchy
|
/// **The primary way a local adjustment is made.** The watershed hierarchy
|
||||||
/// this crate was first built around does not survive a photograph: its
|
/// this crate was first built around does not survive a photograph: its
|
||||||
/// saddles are near zero almost everywhere, so a global cut collapses the
|
/// 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.
|
/// 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
|
/// 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
|
/// 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,
|
/// — 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
|
/// 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
|
/// So this is what a global grade attaches to: lift the sky, desaturate
|
||||||
/// the vegetation, warm the architecture. Asking it for "that person
|
/// 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
|
/// erase stroke is a hole in the part it was painted into and reads as
|
||||||
/// nothing at all.
|
/// nothing at all.
|
||||||
Subtract,
|
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 {
|
impl Join {
|
||||||
@@ -933,6 +948,7 @@ impl Join {
|
|||||||
match self {
|
match self {
|
||||||
Self::Union => "union",
|
Self::Union => "union",
|
||||||
Self::Subtract => "subtract",
|
Self::Subtract => "subtract",
|
||||||
|
Self::Intersect => "intersect",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,12 +956,30 @@ impl Join {
|
|||||||
Some(match name {
|
Some(match name {
|
||||||
"union" => Self::Union,
|
"union" => Self::Union,
|
||||||
"subtract" => Self::Subtract,
|
"subtract" => Self::Subtract,
|
||||||
|
"intersect" => Self::Intersect,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every variant, for a UI building a choice control.
|
/// TRACES: FR-DEV-19a
|
||||||
pub const ALL: [Join; 2] = [Join::Union, Join::Subtract];
|
/// 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.
|
/// 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
|
// 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
|
// 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.
|
// 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()
|
self.shown_parts()
|
||||||
.enumerate()
|
.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
|
/// 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
|
/// **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
|
/// 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
|
/// 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 {
|
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
|
// FNV-1a over the four fields. Small, dependency-free, and adequate: this
|
||||||
// guards against accidental mismatch, not against a forged sidecar.
|
// 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,
|
/// 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
|
/// so a part that would draw a confidently wrong shape makes the result
|
||||||
/// wrong whichever way it joins.
|
/// wrong whichever way it joins.
|
||||||
|
|||||||
@@ -69,20 +69,26 @@ const ROUNDS: u32 = 3;
|
|||||||
|
|
||||||
/// Below this a channel carries no ratio worth balancing.
|
/// Below this a channel carries no ratio worth balancing.
|
||||||
///
|
///
|
||||||
/// A sample in the deep shadows, or one taken on a blown highlight where a
|
/// A sample in the deep shadows has no white balance in it: the logarithms
|
||||||
/// channel has already clipped to nothing, has no white balance in it: the
|
/// below would run away and the picker would slam a slider to its stop.
|
||||||
/// logarithms below would run away and the picker would slam a slider to its
|
/// Refusing is the honest answer, and the caller reports that the point was
|
||||||
/// stop. Refusing is the honest answer, and the caller reports that the point
|
/// not usable rather than moving the photograph. The other end — a blown
|
||||||
/// was not usable rather than moving the photograph.
|
/// highlight, where every channel has stopped counting — is refused by the
|
||||||
|
/// caller before the sample is taken, because only the caller can see the
|
||||||
|
/// sensor value; see [`crate::operation::CLIP_ONSET`].
|
||||||
const FLOOR: f32 = 1e-4;
|
const FLOOR: f32 = 1e-4;
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3
|
/// TRACES: FR-DEV-3
|
||||||
/// Move the graph so that `sample` renders neutral.
|
/// Move the graph so that `sample` renders neutral.
|
||||||
///
|
///
|
||||||
/// `sample` is linear RGB, as the operation's own gains multiply it — that is,
|
/// `sample` is the linear triple the operation's own gains multiply — camera
|
||||||
/// measured with the sampling operation at its defaults. Returns whether the
|
/// RGB with the camera's as-shot balance on, *before* the body's base curve
|
||||||
/// graph was moved: `false` where the chain offers no white point widget, or
|
/// and matrix, and with the sampling operation at its defaults. Not the
|
||||||
/// where the colour has no balance in it to correct.
|
/// pixel on the screen: the matrix mixes the channels on the way there, so
|
||||||
|
/// a colour read after it does not answer to these gains, and a solve over
|
||||||
|
/// one lands somewhere no sample asked for. Returns whether the graph was
|
||||||
|
/// moved: `false` where the chain offers no white point widget, or where the
|
||||||
|
/// colour has no balance in it to correct.
|
||||||
///
|
///
|
||||||
/// **Absolute, not relative.** The values written depend on the colour and not
|
/// **Absolute, not relative.** The values written depend on the colour and not
|
||||||
/// on where the sliders happened to be, so sampling the same wall twice lands
|
/// on where the sliders happened to be, so sampling the same wall twice lands
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ pub enum Affects {
|
|||||||
/// A pixel's *neighbourhood* — sharpening, noise reduction, clarity,
|
/// A pixel's *neighbourhood* — sharpening, noise reduction, clarity,
|
||||||
/// texture, dehaze, spot removal.
|
/// 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
|
/// [`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
|
/// 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
|
/// pass is handed a colour and has no way back to a coordinate, so a
|
||||||
@@ -431,9 +431,11 @@ pub enum OutputMode {
|
|||||||
/// no operations, and the caller fills the reserved uniforms neutral —
|
/// no operations, and the caller fills the reserved uniforms neutral —
|
||||||
/// unit white balance, identity matrix, base curve off — so what is
|
/// unit white balance, identity matrix, base curve off — so what is
|
||||||
/// stored is the sensor's own numbers, demosaiced and undistorted. Only
|
/// stored is the sensor's own numbers, demosaiced and undistorted. Only
|
||||||
/// [`compose_camera_linear`] produces it, and only
|
/// [`compose_camera_probe`] produces it — for a merge through
|
||||||
/// `AdjustPass::render_camera_linear` accepts it, so the neutral
|
/// [`compose_camera_linear`], and for the white balance picker under the
|
||||||
/// uniforms cannot be forgotten by a caller that composed it by mistake.
|
/// edit's own framing — and only `AdjustPass::render_camera_linear`
|
||||||
|
/// accepts it, so the neutral uniforms cannot be forgotten by a caller
|
||||||
|
/// that composed it by mistake.
|
||||||
///
|
///
|
||||||
/// Thirty-two bits rather than sixteen because the composite is written
|
/// Thirty-two bits rather than sixteen because the composite is written
|
||||||
/// back as a RAW at the sensor's scale (FR-MRG-3): a 14-bit sensor has
|
/// back as a RAW at the sensor's scale (FR-MRG-3): a 14-bit sensor has
|
||||||
@@ -490,6 +492,19 @@ pub const BASE_CURVE_UNIFORM_OFFSET: usize = 16;
|
|||||||
/// selection in [`compose_full`].
|
/// selection in [`compose_full`].
|
||||||
pub const BASE_CURVE_POINTS: usize = 5;
|
pub const BASE_CURVE_POINTS: usize = 5;
|
||||||
|
|
||||||
|
/// Where the highlight desaturation begins: the fraction of the white level
|
||||||
|
/// above which a photosite is treated as clipped.
|
||||||
|
///
|
||||||
|
/// A photosite this close to saturation has stopped counting, so its ratio
|
||||||
|
/// to its neighbours is not a colour. The generated prologue fades a pixel
|
||||||
|
/// above this toward a neutral of the same brightness before any operation
|
||||||
|
/// runs, and the white balance probe refuses to sample one: a blown sky is
|
||||||
|
/// sensor white, which the as-shot multipliers make magenta, and a solve
|
||||||
|
/// over that slams tint to its stop. One number, so the two cannot drift
|
||||||
|
/// apart — a probe that accepted what the shader had already desaturated
|
||||||
|
/// would be balancing against a pixel the photographer cannot see.
|
||||||
|
pub const CLIP_ONSET: f32 = 0.985;
|
||||||
|
|
||||||
/// Where an operation's own uniforms begin in the generated block.
|
/// Where an operation's own uniforms begin in the generated block.
|
||||||
///
|
///
|
||||||
/// The base fields, then framing's. Exported because `dr-gpu` writes the
|
/// The base fields, then framing's. Exported because `dr-gpu` writes the
|
||||||
@@ -589,7 +604,9 @@ pub fn compose_full_revealing(
|
|||||||
warps: &[Box<dyn crate::lens::Warp>],
|
warps: &[Box<dyn crate::lens::Warp>],
|
||||||
reveal: Option<&crate::mask::Reveal>,
|
reveal: Option<&crate::mask::Reveal>,
|
||||||
) -> ComposedShader {
|
) -> ComposedShader {
|
||||||
compose_inner(ops, framing, output, masks, spots, warps, reveal, None)
|
compose_inner(
|
||||||
|
ops, framing, output, masks, spots, warps, reveal, None, false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-MRG-2
|
/// TRACES: FR-MRG-2
|
||||||
@@ -614,15 +631,50 @@ pub fn compose_camera_linear(
|
|||||||
// upright too, and `view` is a fraction of the upright frame.
|
// upright too, and `view` is a fraction of the upright frame.
|
||||||
framing.set_baseline(baseline);
|
framing.set_baseline(baseline);
|
||||||
framing.set_view(view);
|
framing.set_view(view);
|
||||||
|
compose_camera_tap(warps, &framing, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// The same tap under a framing the caller chose: the white balance probe.
|
||||||
|
///
|
||||||
|
/// A neutral picked off the canvas has to be measured in the space the
|
||||||
|
/// white balance gains multiply, and that is camera RGB — the operation
|
||||||
|
/// runs before the body's matrix, and a probe read after the matrix would
|
||||||
|
/// be solving the wrong equation on any body whose matrix mixes the
|
||||||
|
/// channels, which is every body. It also has to be measured at the pixel
|
||||||
|
/// the canvas is showing, which is why this takes the edit's own framing
|
||||||
|
/// where a merge passes the file's orientation and a tile.
|
||||||
|
///
|
||||||
|
/// **Interpolated whatever the framing says.** The point of rendering a
|
||||||
|
/// patch is to average what is under it, and the nearest sampling an
|
||||||
|
/// unrotated frame otherwise gets is a comb: at two source pixels per
|
||||||
|
/// probe pixel it lands on the same column of any pattern every time, and
|
||||||
|
/// the average of a thousand samples is then the average of nothing.
|
||||||
|
pub fn compose_camera_probe(
|
||||||
|
warps: &[Box<dyn crate::lens::Warp>],
|
||||||
|
framing: &Framing,
|
||||||
|
) -> ComposedShader {
|
||||||
|
compose_camera_tap(warps, framing, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The camera-space tap proper: no operations, `rgba32float`, and the
|
||||||
|
/// profile uniforms left for the GPU side to fill neutral. `smooth` forces
|
||||||
|
/// the interpolating sampler; see the two callers for who wants it and why.
|
||||||
|
fn compose_camera_tap(
|
||||||
|
warps: &[Box<dyn crate::lens::Warp>],
|
||||||
|
framing: &Framing,
|
||||||
|
smooth: bool,
|
||||||
|
) -> ComposedShader {
|
||||||
compose_inner(
|
compose_inner(
|
||||||
&[],
|
&[],
|
||||||
&framing,
|
framing,
|
||||||
ColourSpace::Srgb,
|
ColourSpace::Srgb,
|
||||||
&MaskStack::new(),
|
&MaskStack::new(),
|
||||||
&crate::spot::SpotSet::new(),
|
&crate::spot::SpotSet::new(),
|
||||||
warps,
|
warps,
|
||||||
None,
|
None,
|
||||||
Some(OutputMode::CameraLinear),
|
Some(OutputMode::CameraLinear),
|
||||||
|
smooth,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,6 +688,7 @@ fn compose_inner(
|
|||||||
warps: &[Box<dyn crate::lens::Warp>],
|
warps: &[Box<dyn crate::lens::Warp>],
|
||||||
reveal: Option<&crate::mask::Reveal>,
|
reveal: Option<&crate::mask::Reveal>,
|
||||||
forced: Option<OutputMode>,
|
forced: Option<OutputMode>,
|
||||||
|
smooth: bool,
|
||||||
) -> ComposedShader {
|
) -> ComposedShader {
|
||||||
// The lens corrections, composed into one coordinate transform. Beside
|
// The lens corrections, composed into one coordinate transform. Beside
|
||||||
// `framing` because they are the other half of the same stage: framing
|
// `framing` because they are the other half of the same stage: framing
|
||||||
@@ -668,7 +721,7 @@ fn compose_inner(
|
|||||||
// twice and be bound to a texture of the wrong format.
|
// twice and be bound to a texture of the wrong format.
|
||||||
//
|
//
|
||||||
// `forced` is the one exception, and it is not a caller flag in the
|
// `forced` is the one exception, and it is not a caller flag in the
|
||||||
// sense above: `compose_camera_linear` is the only function that passes
|
// sense above: `compose_camera_probe` is the only function that passes
|
||||||
// it, with an empty operation list, and the mode it forces has its own
|
// it, with an empty operation list, and the mode it forces has its own
|
||||||
// storage format and its own render entry on the GPU side.
|
// storage format and its own render entry on the GPU side.
|
||||||
let output_mode = forced.unwrap_or(
|
let output_mode = forced.unwrap_or(
|
||||||
@@ -748,7 +801,11 @@ fn compose_inner(
|
|||||||
\x20 // angle as sin/cos — a trig call per pixel would recompute a\n\
|
\x20 // angle as sin/cos — a trig call per pixel would recompute a\n\
|
||||||
\x20 // value that is constant across the dispatch.\n\
|
\x20 // value that is constant across the dispatch.\n\
|
||||||
\x20 crop_rect: vec4<f32>,\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());
|
uniform_values.extend_from_slice(&framing.uniforms());
|
||||||
|
|
||||||
@@ -842,7 +899,10 @@ fn compose_inner(
|
|||||||
// framing alone — which is what this did before the warps existed — would
|
// framing alone — which is what this did before the warps existed — would
|
||||||
// have nearest-neighboured a distortion correction on an unstraightened
|
// have nearest-neighboured a distortion correction on an unstraightened
|
||||||
// frame, and the aliasing would have looked like a bad profile.
|
// frame, and the aliasing would have looked like a bad profile.
|
||||||
let interpolate = framing.needs_interpolation() || warp.is_active();
|
// `smooth` is the third reason, and the only one a caller states: the
|
||||||
|
// white balance probe averages a patch and cannot do that through a
|
||||||
|
// nearest-neighbour comb (see `compose_camera_probe`).
|
||||||
|
let interpolate = smooth || framing.needs_interpolation() || warp.is_active();
|
||||||
|
|
||||||
// Declared ahead of the warp block, which assigns to them. They enter
|
// Declared ahead of the warp block, which assigns to them. They enter
|
||||||
// equal to `p` so that a chain mixing a splitting warp with a
|
// equal to `p` so that a chain mixing a splitting warp with a
|
||||||
@@ -997,6 +1057,9 @@ fn compose_inner(
|
|||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Formatted with Rust's `Display` so the shader reads the same threshold
|
||||||
|
// the probe checks against; see `CLIP_ONSET`.
|
||||||
|
let clip_onset = CLIP_ONSET;
|
||||||
let source = format!(
|
let source = format!(
|
||||||
"// GENERATED — do not edit.
|
"// GENERATED — do not edit.
|
||||||
//
|
//
|
||||||
@@ -1067,7 +1130,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
|||||||
// A photosite at its white level carries no colour information — every
|
// A photosite at its white level carries no colour information — every
|
||||||
// channel simply stopped counting — so the balance below must not be
|
// channel simply stopped counting — so the balance below must not be
|
||||||
// allowed to tint it.
|
// allowed to tint it.
|
||||||
let clipped = smoothstep(0.985, 1.0, max(c.r, max(c.g, c.b)));
|
let clipped = smoothstep({clip_onset}, 1.0, max(c.r, max(c.g, c.b)));
|
||||||
|
|
||||||
c = c * u.as_shot_wb.rgb;
|
c = c * u.as_shot_wb.rgb;
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
//! A minimum over a patch is separable, as a Gaussian is: minimum along x,
|
//! 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
|
//! 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
|
//! — 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
|
//! A minimum has a property a Gaussian does not: **erosions compose by adding
|
||||||
//! their structuring elements**. The minimum over a contiguous run of `d`
|
//! their structuring elements**. The minimum over a contiguous run of `d`
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
//! the artefact this control must not have.
|
//! the artefact this control must not have.
|
||||||
//!
|
//!
|
||||||
//! Run at the render size, that measured **34 ms at 4K** — seven times the
|
//! 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
|
//! 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
|
//! a grid a quarter the size on each axis: a sixteenth of the pixels at a
|
||||||
//! quarter of the radius.
|
//! quarter of the radius.
|
||||||
@@ -271,7 +271,7 @@ impl Band for Coarse {
|
|||||||
threshold: 0.35,
|
threshold: 0.35,
|
||||||
gain: 1.0,
|
gain: 1.0,
|
||||||
midtone_taper: true,
|
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
|
// σ 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
|
// 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,
|
height: 0.5,
|
||||||
});
|
});
|
||||||
g.set_param(framing::ID, framing::ANGLE, -2.0);
|
g.set_param(framing::ID, framing::ANGLE, -2.0);
|
||||||
|
g.set_param(framing::ID, framing::KEYSTONE_V, 40.0);
|
||||||
g
|
g
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,6 +821,14 @@ mod tests {
|
|||||||
Some(0.0),
|
Some(0.0),
|
||||||
"the source's straightening must not travel on this scope"
|
"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]
|
#[test]
|
||||||
@@ -829,6 +838,7 @@ mod tests {
|
|||||||
preset.apply(&mut target, Scope::everything());
|
preset.apply(&mut target, Scope::everything());
|
||||||
|
|
||||||
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
|
assert_eq!(target.param(framing::ID, framing::ANGLE), Some(-2.0));
|
||||||
|
assert_eq!(target.param(framing::ID, framing::KEYSTONE_V), Some(40.0));
|
||||||
assert!(
|
assert!(
|
||||||
(target.crop().width - 0.5).abs() < 1e-5,
|
(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.
|
/// Highest flag code: 0 unflagged, 1 pick, 2 reject.
|
||||||
pub const MAX_FLAG: u8 = 2;
|
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
|
/// TRACES: FR-CAT-8 | FR-NC-8
|
||||||
/// One image's sidecar: a keyed set of versions.
|
/// 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
|
/// so a value moving between the two stores needs no translation table
|
||||||
/// that could drift.
|
/// that could drift.
|
||||||
pub flag: u8,
|
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.
|
/// The edit itself: `(op, param) -> value`, non-default values only.
|
||||||
pub params: BTreeMap<(String, String), f32>,
|
pub params: BTreeMap<(String, String), f32>,
|
||||||
/// TRACES: FR-DEV-3 | FR-NC-9
|
/// 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`.
|
/// graph's film cleared and the caller re-bakes — see `EditGraph::set_film`.
|
||||||
pub film: Option<FilmRef>,
|
pub film: Option<FilmRef>,
|
||||||
/// TRACES: FR-DEV-8 | FR-NC-9
|
/// 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
|
/// 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
|
/// 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.
|
// it in the "not yet looked at" state a cull resumes from.
|
||||||
rating: 0,
|
rating: 0,
|
||||||
flag: 0,
|
flag: 0,
|
||||||
|
label: 0,
|
||||||
params,
|
params,
|
||||||
masks,
|
masks,
|
||||||
film,
|
film,
|
||||||
@@ -572,6 +587,10 @@ impl Version {
|
|||||||
// that never had it.
|
// that never had it.
|
||||||
self.rating = merge_judgement(self.rating, remote.rating, remote_wins);
|
self.rating = merge_judgement(self.rating, remote.rating, remote_wins);
|
||||||
self.flag = merge_judgement(self.flag, remote.flag, 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
|
// TRACES: FR-DEV-3f
|
||||||
// The film resolves wholesale to the higher revision, like a mask
|
// The film resolves wholesale to the higher revision, like a mask
|
||||||
@@ -873,6 +892,9 @@ impl Sidecar {
|
|||||||
if v.flag > 0 {
|
if v.flag > 0 {
|
||||||
let _ = writeln!(out, "flag = {}", v.flag);
|
let _ = writeln!(out, "flag = {}", v.flag);
|
||||||
}
|
}
|
||||||
|
if v.label > 0 {
|
||||||
|
let _ = writeln!(out, "label = {}", v.label);
|
||||||
|
}
|
||||||
// TRACES: FR-DEV-3f
|
// TRACES: FR-DEV-3f
|
||||||
// Before the parameters, because it decides what they mean: the
|
// Before the parameters, because it decides what they mean: the
|
||||||
// film's exposure slider is a slider on *that stock's* curve.
|
// film's exposure slider is a slider on *that stock's* curve.
|
||||||
@@ -1068,6 +1090,17 @@ impl Sidecar {
|
|||||||
Some(value.to_string());
|
Some(value.to_string());
|
||||||
}
|
}
|
||||||
"flag" => version.flag = value.parse::<u8>().unwrap_or(0).min(MAX_FLAG),
|
"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
|
// TRACES: FR-DEV-8
|
||||||
// Ahead of the `op.param` arm below, which would otherwise try
|
// Ahead of the `op.param` arm below, which would otherwise try
|
||||||
// to read eight numbers as one float and drop the repair with a
|
// to read eight numbers as one float and drop the repair with a
|
||||||
@@ -2141,6 +2174,8 @@ mod tests {
|
|||||||
height: 0.6,
|
height: 0.6,
|
||||||
});
|
});
|
||||||
g.set_param(framing::ID, framing::ANGLE, -1.5);
|
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);
|
g.rotate_quarters(1);
|
||||||
|
|
||||||
let mut sidecar = Sidecar::new();
|
let mut sidecar = Sidecar::new();
|
||||||
@@ -2157,6 +2192,12 @@ mod tests {
|
|||||||
assert_eq!(restored.crop(), g.crop());
|
assert_eq!(restored.crop(), g.crop());
|
||||||
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
|
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
|
||||||
assert_eq!(restored.param(framing::ID, framing::ROTATION), Some(1.0));
|
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]
|
#[test]
|
||||||
@@ -2828,6 +2869,37 @@ mod tests {
|
|||||||
assert_eq!(back.flag, 1);
|
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]
|
#[test]
|
||||||
fn an_unrated_image_writes_no_judgement_lines() {
|
fn an_unrated_image_writes_no_judgement_lines() {
|
||||||
// The non-default rule applied to judgement: a library that has never
|
// The non-default rule applied to judgement: a library that has never
|
||||||
@@ -2838,6 +2910,7 @@ mod tests {
|
|||||||
let text = sidecar.to_text();
|
let text = sidecar.to_text();
|
||||||
assert!(!text.contains("rating"), "{text}");
|
assert!(!text.contains("rating"), "{text}");
|
||||||
assert!(!text.contains("flag"), "{text}");
|
assert!(!text.contains("flag"), "{text}");
|
||||||
|
assert!(!text.contains("label"), "{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! are blended. No pixels are stored, here or anywhere: the shader draws the
|
//! 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
|
//! repair from these numbers every time the photograph is rendered, which is
|
||||||
//! what makes it non-destructive, cheap to sync, and undoable
|
//! 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
|
//! # 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
|
/// 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
|
/// 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
|
/// 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;
|
pub const MAX_SOURCE_DISTANCE: f32 = 0.5;
|
||||||
|
|
||||||
/// The radius a new spot starts at, in frame units.
|
/// 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
|
/// **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
|
/// half of it.** The good half searches the photograph for a patch whose
|
||||||
/// surroundings match — a compute dispatch scoring candidate offsets, and
|
/// 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
|
/// §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
|
/// 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
|
/// 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
|
/// TRACES: FR-DEV-19a
|
||||||
/// A part left out of the build comes back left out, and the file says so
|
/// 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.
|
/// 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
|
# 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
|
# 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 }
|
ort = { workspace = true, optional = true }
|
||||||
dr-inference-engine = { workspace = true, optional = true }
|
dr-inference-engine = { workspace = true, optional = true }
|
||||||
ndarray = { 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
|
//! And doing it here buys two things a shader could not. It is **exactly
|
||||||
//! deterministic**, which matters because masks reach the sidecar as indices
|
//! 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
|
//! 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.
|
//! is testable against hand-computed distances with no adapter present.
|
||||||
//!
|
//!
|
||||||
//! # The transform
|
//! # The transform
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ pub struct Edge {
|
|||||||
|
|
||||||
/// A partition of the image into labelled regions, plus how they adjoin.
|
/// 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
|
/// from a watershed, arm B would produce it from a class map, and the
|
||||||
/// consumers above cannot tell which.
|
/// consumers above cannot tell which.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! TRACES: FR-DEV-3i
|
//! 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
|
//! 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
|
//! 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.
|
//! 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
|
//! 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
|
//! than §4 assumed: the two arms fail in *opposite* directions, so each one
|
||||||
//! covers the other's failure.
|
//! 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
|
/// 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
|
/// 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
|
/// 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
|
/// The returned ids are sorted, so the same click always produces the same
|
||||||
/// mask — which is what lets it be a cache key.
|
/// 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
|
//! So the last step is a **marker-based watershed**. The mask is eroded to
|
||||||
//! give two markers — confidently inside, confidently outside — and the flood
|
//! give two markers — confidently inside, confidently outside — and the flood
|
||||||
//! runs in the ribbon left between them, meeting along the most expensive line
|
//! 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.
|
//! 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
|
//! 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
|
/// How much the photograph's own edges count against the colour model in
|
||||||
/// the flood's cost, `0.0..=1.0`.
|
/// 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
|
/// gradient always available, semantic evidence added when a model is
|
||||||
/// present — and this is the mix. At one the boundary lands purely on the
|
/// 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
|
/// 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
|
/// floating-point comparison is a stopping rule that can differ between
|
||||||
/// machines, and a mask that differs between machines reaches the sidecar as
|
/// machines, and a mask that differs between machines reaches the sidecar as
|
||||||
/// indices meaning one thing on the desktop and another on the phone
|
/// 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;
|
const ITERATIONS: usize = 12;
|
||||||
|
|
||||||
/// Half-width of the verdict scale, in nats.
|
/// 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
|
/// fronts meet along the most expensive line in the ribbon — which is the
|
||||||
/// watershed, and which is where the boundary belongs.
|
/// 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.
|
/// 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,
|
/// Neither alone is right. An edge with no colour meaning is a texture,
|
||||||
/// and a colour change with no edge is a gradient.
|
/// 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.
|
/// Edge strength over the opponent features, as one byte per pixel.
|
||||||
///
|
///
|
||||||
/// Sobel over the same three numbers the colour model is fitted on, rather
|
/// 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
|
/// channel-weighted RGB gradient reads a saturated red edge as weaker than it
|
||||||
/// looks, and a flag against sky is exactly that edge.
|
/// 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
|
//! 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
|
//! 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");
|
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
|
/// 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")]
|
#[cfg(feature = "embedded-model")]
|
||||||
pub fn embedded_model_bytes() -> &'static [u8] {
|
pub fn embedded_model_bytes() -> &'static [u8] {
|
||||||
EMBEDDED_MODEL
|
EMBEDDED_MODEL
|
||||||
@@ -237,7 +237,7 @@ impl SemanticModel {
|
|||||||
|
|
||||||
pub fn from_bytes(bytes: &[u8], classes: Vec<Arc<str>>) -> Result<Self, SegmentError> {
|
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
|
// 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.
|
// boundary has to be measured before it moves.
|
||||||
let session = dr_inference_engine::open(
|
let session = dr_inference_engine::open(
|
||||||
dr_inference_engine::Role::Segmenter,
|
dr_inference_engine::Role::Segmenter,
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ pub struct LoginFlow {
|
|||||||
pub login_url: String,
|
pub login_url: String,
|
||||||
#[serde(rename = "poll")]
|
#[serde(rename = "poll")]
|
||||||
pub poll: PollInfo,
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -66,9 +71,57 @@ pub async fn begin(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.json::<LoginFlow>()
|
let mut flow = resp
|
||||||
|
.json::<LoginFlow>()
|
||||||
.await
|
.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.
|
/// Poll until the user finishes authenticating in the browser.
|
||||||
@@ -98,10 +151,11 @@ pub async fn poll(
|
|||||||
|
|
||||||
match resp.status().as_u16() {
|
match resp.status().as_u16() {
|
||||||
200 => {
|
200 => {
|
||||||
return resp
|
let creds = resp
|
||||||
.json::<AppCredentials>()
|
.json::<AppCredentials>()
|
||||||
.await
|
.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.
|
// Still waiting for the user.
|
||||||
404 => tokio::time::sleep(POLL_INTERVAL).await,
|
404 => tokio::time::sleep(POLL_INTERVAL).await,
|
||||||
@@ -117,6 +171,26 @@ pub async fn poll(
|
|||||||
Err(RemoteError::AuthFailed)
|
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.
|
/// Percent-encode a form value.
|
||||||
fn urlencode(s: &str) -> String {
|
fn urlencode(s: &str) -> String {
|
||||||
s.bytes()
|
s.bytes()
|
||||||
@@ -167,6 +241,49 @@ mod tests {
|
|||||||
assert!(flow.login_url.contains("/login/v2/flow/"));
|
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]
|
#[test]
|
||||||
fn form_values_are_encoded() {
|
fn form_values_are_encoded() {
|
||||||
assert_eq!(urlencode("abc123"), "abc123");
|
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
|
// for months. [`EXTRA_ROOTS`] carries those, and is additive — the
|
||||||
// webpki-roots set is still installed alongside.
|
// webpki-roots set is still installed alongside.
|
||||||
.tls_certs_only(extra_roots())
|
.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
|
// 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
|
// died, and cost a long time to tell apart once. These turn that into
|
||||||
// an error the UI can show.
|
// 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 detail = e.to_string();
|
||||||
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&e);
|
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&e);
|
||||||
let mut tls = false;
|
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 {
|
while let Some(s) = src {
|
||||||
let text = s.to_string();
|
let text = s.to_string();
|
||||||
|
if text.contains("URL scheme is not allowed") {
|
||||||
|
refused = true;
|
||||||
|
}
|
||||||
// rustls surfaces every verification failure through this wording:
|
// rustls surfaces every verification failure through this wording:
|
||||||
// UnknownIssuer, Expired, NotValidForName, BadSignature.
|
// UnknownIssuer, Expired, NotValidForName, BadSignature.
|
||||||
if text.contains("invalid peer certificate")
|
if text.contains("invalid peer certificate")
|
||||||
@@ -795,7 +812,9 @@ fn map_send_error(e: reqwest::Error) -> RemoteError {
|
|||||||
src = std::error::Error::source(s);
|
src = std::error::Error::source(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
if tls {
|
if refused {
|
||||||
|
RemoteError::Configuration(detail)
|
||||||
|
} else if tls {
|
||||||
RemoteError::Tls(detail)
|
RemoteError::Tls(detail)
|
||||||
} else {
|
} else {
|
||||||
RemoteError::Network(detail)
|
RemoteError::Network(detail)
|
||||||
@@ -1013,6 +1032,35 @@ mod tests {
|
|||||||
assert!(c.is_ok(), "client must build without a backend");
|
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]
|
#[tokio::test]
|
||||||
async fn delta_is_unsupported_and_says_why() {
|
async fn delta_is_unsupported_and_says_why() {
|
||||||
// Verified absent in the server; the engine must fall back rather
|
// 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> {
|
fn connect(&self, conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
||||||
let creds = Self::credentials(conn)?;
|
let creds = Self::credentials(conn)?;
|
||||||
Ok(Box::new(NextcloudBackend::new(
|
Ok(Box::new(NextcloudBackend::new(
|
||||||
@@ -132,6 +144,18 @@ mod tests {
|
|||||||
assert!(p.normalise_endpoint(" ").is_err());
|
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]
|
#[test]
|
||||||
fn the_account_keeps_the_dav_user_id_apart_from_the_login() {
|
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
|
// 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 dr_types::{Format, FormatFilter};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::RemoteError;
|
use crate::{BackendRegistry, RemoteError};
|
||||||
|
|
||||||
/// The connector every account had before there was a choice.
|
/// The connector every account had before there was a choice.
|
||||||
///
|
///
|
||||||
@@ -510,6 +510,95 @@ impl AccountStore {
|
|||||||
written
|
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 {
|
fn read_config(&self) -> ConfigFile {
|
||||||
std::fs::read_to_string(&self.config_path)
|
std::fs::read_to_string(&self.config_path)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -545,6 +634,11 @@ pub enum AccountError {
|
|||||||
|
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Remote(#[from] RemoteError),
|
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)]
|
#[cfg(test)]
|
||||||
@@ -574,6 +668,134 @@ mod tests {
|
|||||||
d
|
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]
|
#[test]
|
||||||
fn a_saved_account_survives_reopening() {
|
fn a_saved_account_survives_reopening() {
|
||||||
let dir = tmpdir("survives");
|
let dir = tmpdir("survives");
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ pub trait BackendProvider: Send + Sync {
|
|||||||
/// wrong rather than naming a type.
|
/// wrong rather than naming a type.
|
||||||
fn normalise_endpoint(&self, input: &str) -> Result<String, String>;
|
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.
|
/// Build an account from a normalised endpoint alone.
|
||||||
///
|
///
|
||||||
/// Only meaningful for [`SignIn::EndpointOnly`]; a browser flow produces
|
/// Only meaningful for [`SignIn::EndpointOnly`]; a browser flow produces
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ pub enum PlaceScope {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct StoredFilter {
|
pub struct StoredFilter {
|
||||||
pub min_rating: u8,
|
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 unjudged: bool,
|
||||||
pub flag: Option<FlagState>,
|
pub flag: Option<FlagState>,
|
||||||
pub local_only: bool,
|
pub local_only: bool,
|
||||||
@@ -131,6 +135,10 @@ pub struct StoredFilter {
|
|||||||
/// Travels: it is a narrowing like `local_only`, and a record without it
|
/// Travels: it is a narrowing like `local_only`, and a record without it
|
||||||
/// — from a build before it existed — reads as off.
|
/// — from a build before it existed — reads as off.
|
||||||
pub eyes_open: bool,
|
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.
|
/// 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.
|
/// Which SCRFD graph the indexing pass detects with.
|
||||||
///
|
///
|
||||||
/// Three exports of one architecture, differing only in how much computation
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// A different detector: it finds a different set of faces, so it is a
|
||||||
/// different population of detections. The embedder half is unchanged,
|
/// 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
|
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
|
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.
|
is published.
|
||||||
|
|
||||||
## Java in the APK
|
## Java in the APK
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ fi
|
|||||||
cp "${SO}" "${OUT}/staging/lib/${ABI}/libdarkroom.so"
|
cp "${SO}" "${OUT}/staging/lib/${ABI}/libdarkroom.so"
|
||||||
cp "${DEX}" "${OUT}/staging/classes.dex"
|
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
|
# 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
|
# 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
|
# `libonnxruntime.so` at launch and runs on tract if it is not there — so an
|
||||||
@@ -278,7 +278,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# The models. Android has no other route to one — app-private storage is not
|
# 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
|
# 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
|
# sources are `models/face/` and `models/scene/`, shared with the Arch package
|
||||||
# rather than living under this one platform's directory.
|
# 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)"
|
echo " assets: no models found (face indexing and the scene tab will be off on the device)"
|
||||||
fi
|
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
|
# -0 "" stores the .so without compression so Android can mmap it directly
|
||||||
# (extractNativeLibs=false territory); for a 37 MB library that also keeps
|
# (extractNativeLibs=false territory); for a 37 MB library that also keeps
|
||||||
# install times sane.
|
# 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
|
# 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
|
# 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
|
# 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.
|
# has the incantation.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "==> packaging APK"
|
echo "==> packaging APK"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# DarkRoom — reproducible Windows cross-build environment
|
# 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
|
# 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
|
# 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
|
# 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
|
# 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.
|
# 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
|
# 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
|
# 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
|
# imports libwinpthread-1.dll — the smoke test's objdump step is what checks
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user