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 | ||
|
|
ef1afc254d | ||
|
|
103c6e7fc0 | ||
|
|
e9398de9c1 | ||
|
|
b1c99b5796 | ||
|
|
3de109fbd1 | ||
|
|
388bda6af3 | ||
|
|
73845d8a77 | ||
|
|
b4821ee1ab | ||
|
|
9d1aa5735b | ||
|
|
97a854833d | ||
|
|
e0e193efb4 | ||
|
|
d790961b28 | ||
|
|
14ac41bee0 | ||
|
|
86410def88 | ||
|
|
df8be10c7d | ||
|
|
f176043632 | ||
|
|
9c2cd73337 | ||
|
|
e3acbdf4a3 | ||
|
|
eddaa44cd3 | ||
|
|
7fba28f7d8 | ||
|
|
0fa9003e54 | ||
|
|
e750bcdb8c | ||
|
|
48c4b403d2 | ||
|
|
9d04ff2154 | ||
|
|
c6cfb2a02a | ||
|
|
b83f192847 | ||
|
|
ecb648818b | ||
|
|
5fbf8944d7 | ||
|
|
b502a8ef90 | ||
|
|
6fbdb1d06f | ||
|
|
d04b8f6044 | ||
|
|
8baa46ff49 | ||
|
|
e43ae10439 | ||
|
|
104e3a106f | ||
|
|
031ba7b77d | ||
|
|
54a80e688c | ||
|
|
2fd7690b6f | ||
|
|
c5f07f9ced | ||
|
|
5c00942b84 | ||
|
|
2a4ac0ed3d | ||
|
|
46af2a0a46 | ||
|
|
95c9cffc0d | ||
|
|
cbbe67fbd7 | ||
|
|
691af96e3e | ||
|
|
7a436e2549 | ||
|
|
76bc5652d7 | ||
|
|
4ed29b9d81 | ||
|
|
05508741af | ||
|
|
d15c41e699 | ||
|
|
caf21bea64 | ||
|
|
6739fdf908 | ||
|
|
42d11d919b | ||
|
|
67f225beba | ||
|
|
39adfd4b75 | ||
|
|
57ed51c1c5 | ||
|
|
30bd276d0b | ||
|
|
75d2ceb23c | ||
|
|
2e9a1eb0f0 | ||
|
|
44ea763c61 | ||
|
|
acab0d7abb | ||
|
|
9b6b4942cf | ||
|
|
54290b9540 | ||
|
|
231b4a54ab | ||
|
|
2bf0ec8dba | ||
|
|
5bf06c5030 | ||
|
|
44fdcbc6f7 | ||
|
|
f9510405c3 | ||
|
|
7e6b25b21b | ||
|
|
e4b6b6c935 | ||
|
|
1ded5afbaa | ||
|
|
c901fc1a0a | ||
|
|
f79a76f2d5 | ||
|
|
facb44cb55 | ||
|
|
85cc2b1dcc | ||
|
|
d706c12d77 | ||
|
|
cd0ca6785f | ||
|
|
83f4253b6a | ||
|
|
6aae4c3eb0 | ||
|
|
54b543fb77 | ||
|
|
f5956707e7 | ||
|
|
b908d861e0 | ||
|
|
6b51726322 | ||
|
|
2481904016 | ||
|
|
a92ae4576f | ||
|
|
ed4460cb9c | ||
|
|
7596cf9bcc | ||
|
|
696bafa9d5 | ||
|
|
d259c0d4bb | ||
|
|
95458356da | ||
|
|
dc9db11033 | ||
|
|
3692306fd3 | ||
|
|
c826fed605 | ||
|
|
c921852d89 | ||
|
|
e6ac31d39d | ||
|
|
7db999c1f6 | ||
|
|
30b89ad70a | ||
|
|
327decfab1 | ||
|
|
f8addbee53 | ||
|
|
c0b1e78f7c | ||
|
|
78cb00634e | ||
|
|
2917b7427d | ||
|
|
33e2e277a2 | ||
|
|
9b627e7713 | ||
|
|
8c3b62745a | ||
|
|
8012979a1e | ||
|
|
5e67f026ec | ||
|
|
eeee3d920a | ||
|
|
f100db89ca | ||
|
|
2ce0fcc74a | ||
|
|
6f62ac09f8 | ||
|
|
c1e0f09be7 | ||
|
|
38a87c0bca | ||
|
|
693195fa96 | ||
|
|
43f70c4765 | ||
|
|
6609aa9acf | ||
|
|
b396096787 | ||
|
|
beb822dced | ||
|
|
ef1154af94 | ||
|
|
896188a489 | ||
|
|
d3b6127db6 | ||
|
|
369eb8fbf0 | ||
|
|
4574c35236 | ||
|
|
9cc52fd72b | ||
|
|
2836ec2881 | ||
|
|
fa4dca327f | ||
|
|
0a2c49dd10 | ||
|
|
b718c70b11 |
@@ -10,3 +10,22 @@
|
||||
# detects exactly that and fails with an instruction rather than embedding the
|
||||
# pointer and failing at inference time.
|
||||
*.onnx filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Test photographs live in LFS too, and are fetched only by the tests that
|
||||
# need them.
|
||||
#
|
||||
# `fixtures/**` holds real camera files — a twelve-frame panorama set is
|
||||
# 325 MB — and CI's `git lfs pull` excludes the directory, so a checkout
|
||||
# carries pointers there until a merge test asks for the frames. Same
|
||||
# reasoning as the models, with the opposite default: the model is not
|
||||
# optional and the fixtures are.
|
||||
fixtures/** filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# The manual's pictures live in LFS for the same reason the models do: a
|
||||
# screenshot or a GIF changes wholesale when the interface it shows changes,
|
||||
# and every re-recording would otherwise stay in every clone for good. The
|
||||
# desktop and benchmark legs exclude the directory, since nothing they build
|
||||
# or test reads it; the Android and Windows legs fetch it, because the APK
|
||||
# and the installer carry the manual (docs/manual/index.html) with its
|
||||
# pictures, and their packagers refuse a pointer.
|
||||
docs/manual/media/** filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Benchmarks
|
||||
|
||||
# The suite docs/requirements.md §8 has been promising since it was written:
|
||||
# The suite docs/dev/requirements.md §8 has been promising since it was written:
|
||||
# "an automated benchmark suite against a synthetic 50k catalog, run per-commit
|
||||
# … A regression beyond stated tolerance fails the build."
|
||||
#
|
||||
@@ -29,7 +29,7 @@ name: Benchmarks
|
||||
# every commit to establish, every time, that this runner has no GPU. It
|
||||
# runs on demand (Actions → Run workflow) so that a runner that *does*
|
||||
# have one can be pointed at it, and the numbers it produces belong in
|
||||
# docs/frame-budget.md by hand, as they already are.
|
||||
# docs/dev/frame-budget.md by hand, as they already are.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull
|
||||
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
- name: Frame budget (FR-DSP-3)
|
||||
run: cargo test --release -p dr-gpu --test frame_budget -- --nocapture
|
||||
|
||||
# The instrument behind docs/frame-budget.md. It exits non-zero with no
|
||||
# The instrument behind docs/dev/frame-budget.md. It exits non-zero with no
|
||||
# adapter, which is right for a tool a person runs deliberately and wrong
|
||||
# for a job that usually has none — hence continue-on-error. Its table is
|
||||
# in the log for whoever asked for this run; the committed numbers are
|
||||
|
||||
@@ -7,6 +7,10 @@ name: Build and test
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop]
|
||||
# A release tag builds again and publishes what it built (the `release`
|
||||
# job at the end). The master push of the same commit has usually filled
|
||||
# the caches, so the second run is the warm one.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main, master, develop]
|
||||
|
||||
@@ -96,7 +100,9 @@ jobs:
|
||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull
|
||||
# The manual's pictures too: the APK carries the manual, and
|
||||
# assemble-apk.sh refuses a pointer where a picture should be.
|
||||
git lfs pull --exclude="fixtures/**"
|
||||
ls -lR models/
|
||||
|
||||
- name: Cache cargo
|
||||
@@ -154,6 +160,16 @@ jobs:
|
||||
- name: Build
|
||||
run: cargo build --workspace --release
|
||||
|
||||
# Only on a release tag: the binary is 150 MB and nothing but the
|
||||
# release job wants it.
|
||||
- name: Upload the desktop binary
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: darkroom-desktop-x86_64-linux
|
||||
path: target/release/darkroom-desktop
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Disk after
|
||||
if: always()
|
||||
run: df -h /workspace 2>/dev/null || df -h .
|
||||
@@ -213,7 +229,9 @@ jobs:
|
||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull
|
||||
# The manual's pictures too: the APK carries the manual, and
|
||||
# assemble-apk.sh refuses a pointer where a picture should be.
|
||||
git lfs pull --exclude="fixtures/**"
|
||||
ls -lR models/
|
||||
|
||||
- name: Cache cargo
|
||||
@@ -322,7 +340,7 @@ jobs:
|
||||
env:
|
||||
CARGO_TARGET_DIR: target-android
|
||||
# Absent secrets mean a debug signature, which is what a fork or a
|
||||
# branch build should get. Set all three (see docs/android-signing.md)
|
||||
# branch build should get. Set all three (see docs/dev/android-signing.md)
|
||||
# and the same job produces a release-signed APK instead.
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
@@ -365,6 +383,124 @@ jobs:
|
||||
path: target-android/apk/darkroom.apk
|
||||
if-no-files-found: error
|
||||
|
||||
windows-image:
|
||||
uses: ./.gitea/workflows/windows-image.yml
|
||||
|
||||
# TRACES: FR-PLAT-WIN-3
|
||||
# The Windows executable and its installer, cross-built from Linux
|
||||
# (docs/dev/windows.md §7). No Windows machine anywhere in this job: what it
|
||||
# can prove is that the binary links, is a Windows executable with no
|
||||
# MinGW runtime imports, starts under Wine, and that the installer installs
|
||||
# and uninstalls under Wine. What it cannot prove — a Vulkan device, a
|
||||
# render, the secret store — is a release step on a real machine (§6).
|
||||
windows:
|
||||
runs-on: linux/amd64
|
||||
name: Windows (x86_64, cross)
|
||||
needs: windows-image
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/darkroom-windows:latest
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_PROFILE_DEV_DEBUG: 0
|
||||
CARGO_TARGET_DIR: target-windows
|
||||
# Wine keeps its prefix under $HOME, which the image points at a
|
||||
# directory that does not exist in a fresh container.
|
||||
HOME: /tmp/home
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Same step as the desktop leg: the models are LFS objects and the
|
||||
# packager refuses pointers.
|
||||
- name: Fetch the models
|
||||
env:
|
||||
LFS_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
run: |
|
||||
set -e
|
||||
git lfs install --local
|
||||
git config --local --get-regexp '^http\..*extraheader$' \
|
||||
| cut -d' ' -f1 | sort -u \
|
||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
# The manual's pictures too: the installer carries the manual, and
|
||||
# package.sh refuses a pointer where a picture should be.
|
||||
git lfs pull --exclude="fixtures/**"
|
||||
ls -l models/face models/scene
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/opt/cargo/registry
|
||||
target-windows
|
||||
key: windows-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
# The cfg(windows) branches are linted here and nowhere else: the
|
||||
# desktop leg's clippy never compiles them.
|
||||
- name: Clippy for the target
|
||||
run: cargo clippy --release --target x86_64-pc-windows-gnu -p darkroom-desktop -- -D warnings
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
|
||||
|
||||
- name: Smoke-test the executable
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p "$HOME"
|
||||
EXE=target-windows/x86_64-pc-windows-gnu/release/darkroom-desktop.exe
|
||||
file "$EXE"
|
||||
file "$EXE" | grep -q 'PE32+' || { echo "FAIL: not a PE32+ executable"; exit 1; }
|
||||
file "$EXE" | grep -q '(GUI)' || { echo "FAIL: not a GUI-subsystem executable"; exit 1; }
|
||||
if x86_64-w64-mingw32-objdump -p "$EXE" | grep -iE 'libwinpthread|libgcc|libstdc'; then
|
||||
echo "FAIL: the executable imports a MinGW runtime DLL"
|
||||
exit 1
|
||||
fi
|
||||
x86_64-w64-mingw32-objdump -p "$EXE" | grep 'DLL Name' | sort -u
|
||||
wineboot --init >/dev/null 2>&1 || true
|
||||
OUT=$(wine "$EXE" --version 2>/dev/null)
|
||||
echo "wine: $OUT"
|
||||
echo "$OUT" | grep -q '^darkroom-desktop ' || { echo "FAIL: --version did not answer under Wine"; exit 1; }
|
||||
|
||||
- name: Package the installer
|
||||
run: bash docker/windows/package.sh
|
||||
|
||||
- name: Smoke-test the installer
|
||||
run: |
|
||||
set -e
|
||||
SETUP=$(ls target-windows/installer/DarkRoom-*-x86_64-setup.exe)
|
||||
file "$SETUP" | grep -q 'PE32+' || { echo "FAIL: the installer is not 64-bit"; exit 1; }
|
||||
wine "$SETUP" /S 2>/dev/null
|
||||
INST=$(echo "$HOME"/.wine/drive_c/users/*/AppData/Local/Programs/DarkRoom)
|
||||
ls "$INST"
|
||||
# As many files as package.sh stages: everything but the READMEs in
|
||||
# the directories it copies. A literal here went stale the first
|
||||
# time a model was added.
|
||||
WANT=$(find models/face models/scene models/inpaint -maxdepth 1 -type f ! -name README.md | wc -l)
|
||||
GOT=$(ls "$INST/models" | wc -l)
|
||||
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT model files, installed $GOT"; exit 1; }
|
||||
# The manual, and every picture it shows, counted the same way.
|
||||
[ -f "$INST/manual/index.html" ] || { echo "FAIL: no manual installed"; exit 1; }
|
||||
WANT=$(ls docs/manual/media | wc -l)
|
||||
GOT=$(ls "$INST/manual/media" | wc -l)
|
||||
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT manual pictures, installed $GOT"; exit 1; }
|
||||
wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \
|
||||
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
|
||||
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
|
||||
|| { echo "FAIL: the installed executable does not run"; exit 1; }
|
||||
wine "$INST/uninstall.exe" /S 2>/dev/null
|
||||
sleep 3
|
||||
[ ! -e "$INST" ] || { echo "FAIL: uninstall left $INST behind"; ls -R "$INST"; exit 1; }
|
||||
echo "OK: installed and uninstalled under Wine"
|
||||
|
||||
- name: Upload the installer
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: darkroom-windows-x86_64-setup
|
||||
path: target-windows/installer/DarkRoom-*-x86_64-setup.exe
|
||||
if-no-files-found: error
|
||||
|
||||
layering:
|
||||
runs-on: linux/amd64
|
||||
name: Layer separation
|
||||
@@ -408,3 +544,47 @@ jobs:
|
||||
fi
|
||||
done
|
||||
exit $FAILED
|
||||
|
||||
# A v* tag becomes a Gitea Release carrying the three builds and their
|
||||
# SHA256SUMS, titled and described by the tag's message. Until this job
|
||||
# existed every release was made by hand, and most tags never got one.
|
||||
#
|
||||
# It needs all three platform jobs, so a tag whose tests fail publishes
|
||||
# nothing; re-run the failed job and this one follows. The work is
|
||||
# tools/publish-release.sh, which is also how a release is finished by hand.
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [desktop, android, windows]
|
||||
runs-on: linux/amd64
|
||||
name: Publish the release
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch the builds
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: dist
|
||||
|
||||
# Named for the download page, with the version in each name the way
|
||||
# the hand-made releases had them. The installer already carries its
|
||||
# version from package.sh.
|
||||
- name: Publish
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -e
|
||||
V="${TAG#v}"
|
||||
ls -lR dist
|
||||
mkdir -p out
|
||||
cp dist/darkroom-arm64-v8a-apk/darkroom.apk "out/darkroom-${V}-arm64-v8a.apk"
|
||||
cp dist/darkroom-desktop-x86_64-linux/darkroom-desktop "out/darkroom-desktop-${V}-x86_64-linux"
|
||||
chmod +x "out/darkroom-desktop-${V}-x86_64-linux"
|
||||
cp dist/darkroom-windows-x86_64-setup/DarkRoom-${V}-x86_64-setup.exe out/
|
||||
bash tools/publish-release.sh "$TAG" out/*
|
||||
|
||||
@@ -7,7 +7,7 @@ name: Traceability
|
||||
# fail its own threshold. Two rules follow, and the extractor's own tests
|
||||
# enforce both:
|
||||
#
|
||||
# 1. Denominators are parsed from docs/requirements.md at run time.
|
||||
# 1. Denominators are parsed from docs/dev/requirements.md at run time.
|
||||
# 2. Coverage is |traced ∩ defined| / |defined|, never a raw traced count.
|
||||
#
|
||||
# This job is static analysis of source comments plus markdown parsing, so it
|
||||
@@ -67,6 +67,12 @@ jobs:
|
||||
# threshold: zero requirements parsed, zero files scanned, a ratio above
|
||||
# 100%, or any orphan tag all fail the build. A misconfigured run must not
|
||||
# report a plausible-looking 0%.
|
||||
# Every picture the manual shows is made by a scene in
|
||||
# tools/manual/scenes.py, and every picture a scene makes is shown.
|
||||
# Two files read; no app, no display.
|
||||
- name: Manual pictures have scenes
|
||||
run: tools/manual/record.sh --check
|
||||
|
||||
- name: Traceability gate
|
||||
run: cargo run -q -p traceability -- check
|
||||
|
||||
@@ -74,11 +80,11 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
cargo run -q -p traceability -- report
|
||||
if ! git diff --quiet docs/traceability.md; then
|
||||
if ! git diff --quiet docs/dev/traceability.md; then
|
||||
echo ""
|
||||
echo "docs/traceability.md is out of date."
|
||||
echo "docs/dev/traceability.md is out of date."
|
||||
echo "Run: cargo run -p traceability -- report"
|
||||
git diff --stat docs/traceability.md
|
||||
git diff --stat docs/dev/traceability.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -91,10 +97,19 @@ jobs:
|
||||
# they will conclude the application is broken rather than the page.
|
||||
#
|
||||
# This also fails on a malformed tag, so a typo costs a gesture its
|
||||
# desktop half loudly rather than silently.
|
||||
# desktop half loudly rather than silently — and on a key a Slint
|
||||
# handler binds that no tag names, or a key a tag names that no handler
|
||||
# binds (tools/traceability/src/keymap.rs).
|
||||
- name: Regenerate the gesture vocabulary and check it is committed
|
||||
run: cargo run -q -p traceability -- gestures-check
|
||||
|
||||
# The manual's page, which the packages carry and the help sheet links
|
||||
# into. Blocking for the gesture book's reason: it is shown to the user,
|
||||
# and a page that disagrees with the README is a manual describing an
|
||||
# application that no longer exists.
|
||||
- name: Regenerate the manual page and check it is committed
|
||||
run: cargo run -q -p traceability -- manual-check
|
||||
|
||||
# Advisory, not blocking: not every file implements a requirement, and a
|
||||
# tag on every function is noise that rots faster than it helps. Tag the
|
||||
# unit that decides.
|
||||
@@ -127,4 +142,4 @@ jobs:
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: head -30 docs/traceability.md || true
|
||||
run: head -30 docs/dev/traceability.md || true
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
name: '🐳 Windows image'
|
||||
|
||||
# Builds and pushes gitea.tourolle.paris/dtourolle/darkroom-windows, the job
|
||||
# container for the Windows leg of build-and-test.yml.
|
||||
#
|
||||
# The same shape as android-image.yml, for the same reason that one exists:
|
||||
# an image that lives only on a developer's laptop is a job that dies at
|
||||
# `docker pull`. Built from docker/windows, tagged by that directory's tree
|
||||
# id, skipped when the registry already has it.
|
||||
#
|
||||
# Called by build-and-test.yml on every push, and runnable by hand via
|
||||
# workflow_dispatch. It is cheap when nothing changed — see the guard below.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Rebuild even if the registry already has this image ("true"/"false")'
|
||||
type: string
|
||||
default: 'false'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Rebuild even if the registry already has this image ("true"/"false")'
|
||||
type: string
|
||||
default: 'false'
|
||||
|
||||
# Gitea's act_runner mangles boolean workflow inputs passed through an
|
||||
# expression — they arrive as false regardless of what was sent. Every input
|
||||
# here is a string compared with == 'true', as in KPN's docker.yaml.
|
||||
|
||||
env:
|
||||
IMAGE: gitea.tourolle.paris/dtourolle/darkroom-windows
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux/amd64
|
||||
name: Build and push
|
||||
# Deliberately NOT in a container: this job needs the host Docker daemon to
|
||||
# build an image, and the host's cached ~/.docker/config.json to push it.
|
||||
# That is also why there is no `docker login` step — the runner host was
|
||||
# authenticated to the registry during setup.
|
||||
|
||||
steps:
|
||||
# The host has no Node, so the JS-based actions/checkout cannot run here.
|
||||
# A minimal shallow fetch with plain git gets the same tree.
|
||||
- name: Checkout
|
||||
run: |
|
||||
set -e
|
||||
git init -q .
|
||||
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||
git -c http.extraheader="AUTHORIZATION: basic $(printf '%s' '${{ github.actor }}:${{ github.token }}' | base64 -w0)" \
|
||||
fetch --depth 1 origin "${{ github.sha }}"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
# The image is tagged by the content of docker/windows, not by the commit
|
||||
# that happened to touch it. `git rev-parse HEAD:<dir>` is the tree object
|
||||
# id — it changes when and only when a file in that directory changes, so
|
||||
# an unrelated push reuses the existing image and a Dockerfile edit can
|
||||
# never silently keep serving a stale `latest`.
|
||||
#
|
||||
# Using the commit sha instead would rebuild 2.5 GB on every push; using a
|
||||
# paths-filter action would need a container that has Node, and the only
|
||||
# one this repo would reach for is the very image being built.
|
||||
- name: Resolve image tag
|
||||
id: tag
|
||||
run: |
|
||||
set -e
|
||||
TREE=$(git rev-parse HEAD:docker/windows)
|
||||
echo "tree=$TREE" >> "$GITHUB_OUTPUT"
|
||||
echo "docker/windows tree: $TREE"
|
||||
|
||||
# Skip the build when the registry already holds this exact content. This
|
||||
# is what keeps the job a few seconds long on a normal push, and what
|
||||
# makes it self-healing: if the tag is missing for any reason, including
|
||||
# the image having never been pushed at all, it gets built here.
|
||||
#
|
||||
# The probe is curl against the registry API, NOT `docker manifest
|
||||
# inspect`. The latter exits 1 on this registry even for tags that are
|
||||
# demonstrably present — jellytau-builder:latest answers HTTP 200 to the
|
||||
# API while `docker manifest inspect` reports "manifest unknown" for it.
|
||||
# Trusting that would have rebuilt 7 GB on every single push.
|
||||
#
|
||||
# A HEAD request also gives the digest for free, which is how the repoint
|
||||
# decision below is made without pulling any layers.
|
||||
- name: Query registry
|
||||
id: check
|
||||
env:
|
||||
# The runner's own credentials, so this does not depend on how the
|
||||
# host's ~/.docker/config.json happens to be set up.
|
||||
REG_USER: ${{ github.actor }}
|
||||
REG_PASS: ${{ github.token }}
|
||||
TREE: ${{ steps.tag.outputs.tree }}
|
||||
run: |
|
||||
set -eu
|
||||
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
API="https://gitea.tourolle.paris/v2/dtourolle/darkroom-windows/manifests"
|
||||
|
||||
# Prints "<http-status> <digest-or-empty>" for a tag.
|
||||
probe() {
|
||||
curl -sI -u "$REG_USER:$REG_PASS" -H "Accept: $ACCEPT" "$API/$1" \
|
||||
| tr -d '\r' \
|
||||
| awk 'BEGIN{s="000";d=""} /^HTTP/{s=$2} tolower($1)=="docker-content-digest:"{d=$2} END{print s, d}'
|
||||
}
|
||||
|
||||
read -r TREE_STATUS TREE_DIGEST <<EOF
|
||||
$(probe "$TREE")
|
||||
EOF
|
||||
read -r LATEST_STATUS LATEST_DIGEST <<EOF
|
||||
$(probe latest)
|
||||
EOF
|
||||
|
||||
echo "tag $TREE -> HTTP $TREE_STATUS ${TREE_DIGEST:-(no digest)}"
|
||||
echo "tag latest -> HTTP $LATEST_STATUS ${LATEST_DIGEST:-(no digest)}"
|
||||
|
||||
# Build unless the registry definitively confirms this content is
|
||||
# already there. An auth failure or an unreachable registry lands
|
||||
# here too, and rebuilding needlessly is the safe direction to fail —
|
||||
# skipping a build that was needed is what breaks the Windows job.
|
||||
if [ "${{ inputs.force }}" = "true" ]; then
|
||||
echo "forced rebuild requested"
|
||||
echo "build=true" >> "$GITHUB_OUTPUT"
|
||||
echo "repoint=false" >> "$GITHUB_OUTPUT"
|
||||
elif [ "$TREE_STATUS" != "200" ]; then
|
||||
echo "registry does not have this content — building"
|
||||
echo "build=true" >> "$GITHUB_OUTPUT"
|
||||
echo "repoint=false" >> "$GITHUB_OUTPUT"
|
||||
elif [ -n "$TREE_DIGEST" ] && [ "$TREE_DIGEST" = "$LATEST_DIGEST" ]; then
|
||||
echo "registry is already correct — nothing to do"
|
||||
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||
echo "repoint=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "content is present but latest points elsewhere — repointing"
|
||||
echo "build=false" >> "$GITHUB_OUTPUT"
|
||||
echo "repoint=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Context is docker/windows, matching the README's build command. The
|
||||
# Dockerfile COPYs nothing from the repo, so it needs no wider context —
|
||||
# and a narrow context keeps the daemon from tarring up the whole tree,
|
||||
# target/ included.
|
||||
- name: Build
|
||||
if: ${{ steps.check.outputs.build == 'true' }}
|
||||
run: |
|
||||
set -e
|
||||
docker build \
|
||||
-t "$IMAGE:${{ steps.tag.outputs.tree }}" \
|
||||
-t "$IMAGE:latest" \
|
||||
docker/windows
|
||||
|
||||
# Both tags are pushed: the tree tag is what the guard above looks for on
|
||||
# the next run, and `latest` is what build-and-test.yml pulls.
|
||||
- name: Push
|
||||
if: ${{ steps.check.outputs.build == 'true' }}
|
||||
run: |
|
||||
set -e
|
||||
docker push "$IMAGE:${{ steps.tag.outputs.tree }}"
|
||||
docker push "$IMAGE:latest"
|
||||
|
||||
# A cache hit on the tree tag says nothing about where `latest` points — a
|
||||
# reverted Dockerfile or a build from another branch can leave it on
|
||||
# different content. This runs only when the digests above actually
|
||||
# disagree, so the common case costs nothing; the layers are already in
|
||||
# the registry, so the push that follows uploads a manifest, not 2.5 GB.
|
||||
- name: Repoint latest
|
||||
if: ${{ steps.check.outputs.repoint == 'true' }}
|
||||
run: |
|
||||
set -e
|
||||
docker pull "$IMAGE:${{ steps.tag.outputs.tree }}"
|
||||
docker tag "$IMAGE:${{ steps.tag.outputs.tree }}" "$IMAGE:latest"
|
||||
docker push "$IMAGE:latest"
|
||||
+18
-4
@@ -26,7 +26,7 @@ fi
|
||||
# The artefacts are generated from the tree, so regenerating them because one
|
||||
# was itself edited would be circular.
|
||||
case "$(tr -d '[:space:]' <<< "${staged}")" in
|
||||
docs/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs)
|
||||
docs/dev/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs | docs/manual/index.html)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
@@ -41,9 +41,9 @@ if ! cargo run -q -p traceability -- report >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! git diff --quiet -- docs/traceability.md; then
|
||||
git add docs/traceability.md
|
||||
echo "pre-commit: regenerated docs/traceability.md and staged it"
|
||||
if ! git diff --quiet -- docs/dev/traceability.md; then
|
||||
git add docs/dev/traceability.md
|
||||
echo "pre-commit: regenerated docs/dev/traceability.md and staged it"
|
||||
fi
|
||||
|
||||
# The gesture vocabulary, same discipline.
|
||||
@@ -65,3 +65,17 @@ for f in docs/gestures.md ui/dr-ui/src/gesture_book.rs; do
|
||||
echo "pre-commit: regenerated ${f} and staged it"
|
||||
fi
|
||||
done
|
||||
|
||||
# The manual's page, when its source is part of the commit. Rendered from
|
||||
# nothing but the README, so there is no reason to pay for it otherwise.
|
||||
if grep -qx 'docs/manual/README.md' <<< "${staged}"; then
|
||||
if ! out="$(cargo run -q -p traceability -- manual 2>&1)"; then
|
||||
echo "pre-commit: the manual would not render" >&2
|
||||
echo "${out}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git diff --quiet -- docs/manual/index.html; then
|
||||
git add docs/manual/index.html
|
||||
echo "pre-commit: regenerated docs/manual/index.html and staged it"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -23,3 +23,4 @@ tools/film-profiles/upstream/
|
||||
# checkout, so it is larger than the repository it sits in.
|
||||
/.flatpak-builder/
|
||||
/build/
|
||||
__pycache__/
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Working in this repository
|
||||
|
||||
Notes for anyone — person or agent — changing this code. They record what
|
||||
went wrong once and what the fix looked like, so the same shape is not
|
||||
written again. Requirements live in `docs/dev/requirements.md`; this file is
|
||||
about habits, not features.
|
||||
|
||||
## Catalog reads: work is proportional to what changed, never to library size
|
||||
|
||||
`docs/dev/catalog.md §1` states the rule. These are the ways it was broken on
|
||||
the Identity screen, found when every confirm click cost half a second on a
|
||||
24k-image library (2026-09-19), and what each fix looked like.
|
||||
|
||||
**A redraw must know what changed.** A click handler that calls "refresh
|
||||
everything" pays for everything. `identity_ui::refresh` takes a `Changed`:
|
||||
a confirm re-reads the rail and the grid and *not* the coverage line,
|
||||
because moving a face between people cannot alter how many images are
|
||||
indexed. Before adding a read to a shared refresh, ask which events can
|
||||
change its answer, and gate it on those.
|
||||
|
||||
**Count with `COUNT(*)`, never with `.len()` on a list you then drop.**
|
||||
`repairs::counts` used to build every repair's work list — a `Target` with
|
||||
its path per row, sorted into visiting order — to report its length. Six
|
||||
repairs, 350 ms, nothing kept. If the caller wants a number, the query
|
||||
returns a number.
|
||||
|
||||
**One query, not one per row.** `ThumbStore::contains` in a filter over
|
||||
5,000 rows is 5,000 prepared statements; `ThumbStore::held(size)` reads the
|
||||
index once into a set. The same applies to any `query_row` inside a loop
|
||||
over a result set — including `deep_count` per sidebar row, which is fine
|
||||
at sidebar scale and would not be at grid scale. Aggregate in one
|
||||
statement and look up in memory.
|
||||
|
||||
**Filter and aggregate in SQL, and aggregate the small side first.**
|
||||
`faces::people` read 19,000 rows, grouped, sorted them by name, and the
|
||||
screen threw 17,000 away (empty unnamed groups). `people_in_use` filters in
|
||||
the `WHERE`, and joins `people` to a pre-aggregated `face_person` (2,000
|
||||
groups) rather than grouping after a `LEFT JOIN` over every person. The
|
||||
sort then sees only the rows that will be drawn.
|
||||
|
||||
**Wide rows make "just check one column" a table scan.** A `faces` row is
|
||||
~8 KB (a 1 KB embedding and a ~5 KB crop, then the columns added later).
|
||||
Any predicate that reads `quality`, `crop` or an eye column for every face
|
||||
reads every row. V17 learned this for the eye filter; V19 applies it to the
|
||||
repair counts with partial indexes (`faces_owed_*`) that hold only the rows
|
||||
still owing, keyed on what the predicate joins on and carrying `model_id`
|
||||
because the predicate reads it. Two things to know about them:
|
||||
|
||||
- **Drive the count from the small side.** SQLite uses a partial index
|
||||
when the query starts from `faces` (`repairs::count`, `Needs::Face`) and
|
||||
ignores it inside a correlated `EXISTS (... WHERE f.image_id = i.id ...)`.
|
||||
That is why `Needs::Face` carries the per-face fragment and spells it two
|
||||
ways.
|
||||
- **Spell the predicate as the index's `WHERE` is spelled.** `NEEDS_EYES`
|
||||
is `(f.eye_right IS NULL OR f.landmarks_dense IS NULL)` because
|
||||
`faces_owed_eyes` is `WHERE eye_right IS NULL OR landmarks_dense IS NULL`.
|
||||
Change one, change both, and `counts_are_the_sizes_of_the_lists` will
|
||||
tell you if they drift.
|
||||
|
||||
Check a query's plan with `EXPLAIN QUERY PLAN` against a copy of a real
|
||||
catalog before trusting an index exists for it: "SEARCH ... USING COVERING
|
||||
INDEX" is the answer you want, "SEARCH f USING INDEX faces_image" on a wide
|
||||
table means every probe opens a row.
|
||||
|
||||
## Catalog writes: one transaction per user action
|
||||
|
||||
`faces::confirm` opens a transaction. Calling it in a loop over a group is
|
||||
a commit per face; `faces::confirm_all` is two statements and one commit,
|
||||
`faces::reassign` one transaction for a whole split. When a UI action
|
||||
touches N rows, give the catalog a function that takes the N, not a loop
|
||||
that calls the one-row function N times — `unchecked_transaction` cannot
|
||||
nest, so this has to be designed in at the catalog layer, not wrapped
|
||||
from above.
|
||||
|
||||
## Screens: keep what is already decoded
|
||||
|
||||
`identity::load_faces` takes the crops the grid is currently showing and
|
||||
hands them back into the new cells. Before that, a click re-read 4 MB of
|
||||
crop blobs and decoded 700 JPEGs to produce the pixels already on screen.
|
||||
When a redraw replaces a model, the expensive parts of the old model — a
|
||||
decoded image, a cut portrait — are the first thing to reuse; only the row
|
||||
that changed needs new work. Drain the old cells rather than cloning them.
|
||||
|
||||
## Remote calls: one round trip per file, not one per ancestor
|
||||
|
||||
`NextcloudBackend::move_to` guaranteed its destination's parent with a
|
||||
`MKCOL` per ancestor from the account root, on every file of a batch —
|
||||
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
|
||||
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
|
||||
|
||||
`cargo run --release -p dr-ui --example identity_bench -- CATALOG THUMBS`
|
||||
times what one click on the Identity screen reads and what the batch
|
||||
operations write. Run it against a **copy** of a real catalog (it writes),
|
||||
never the library's own file; `sqlite3 catalog.sqlite ".backup copy.sqlite"`
|
||||
takes a consistent one while the app runs. Compare the `cpu` column when
|
||||
other builds are running on the machine — the wall clock doubles under
|
||||
load, the CPU figure does not. Keep the binary from before the change and
|
||||
run both back to back rather than trusting numbers taken an hour apart.
|
||||
|
||||
Reference figures from the 2026-09-19 fixes, largest person (754 faces),
|
||||
24k images, 19k faces, before → after. What one click read: `load_people`
|
||||
22 ms → 12 ms, `load_faces` 316 ms → 2.4 ms, `audit` 190 ms → not run
|
||||
(66 ms when it is, on open and at the end of a sweep). What one click
|
||||
wrote: `confirm_all` 16 ms → 2 ms, `split_off` 23 ms → 4.5 ms. A click on
|
||||
the face grid went from ~530 ms of catalog work to ~15 ms.
|
||||
+11
-11
@@ -90,18 +90,18 @@ break it by accident:
|
||||
cargo run --release -p dr-bench -- check
|
||||
```
|
||||
|
||||
That is the benchmark suite (`docs/requirements.md` §8), which builds a
|
||||
That is the benchmark suite (`docs/dev/requirements.md` §8), which builds a
|
||||
synthetic 50,000-image catalog and fails the build if a performance target is
|
||||
missed or a measurement has drifted past its tolerance. It runs on every push in
|
||||
its own workflow. [`docs/benchmarks.md`](docs/benchmarks.md) says what it
|
||||
its own workflow. [`docs/dev/benchmarks.md`](docs/dev/benchmarks.md) says what it
|
||||
measures, what it deliberately does not, and how to read a failure. If you have
|
||||
touched the catalog, the decoder, the thumbnail store or the exporter, run it
|
||||
before you send.
|
||||
|
||||
## Requirements and traceability
|
||||
|
||||
[`requirements.md`](docs/requirements.md) is the register of record.
|
||||
[`traceability.md`](docs/traceability.md) is generated from `TRACES:` tags in
|
||||
[`requirements.md`](docs/dev/requirements.md) is the register of record.
|
||||
[`traceability.md`](docs/dev/traceability.md) is generated from `TRACES:` tags in
|
||||
the source and must never be hand-edited:
|
||||
|
||||
```rust
|
||||
@@ -124,7 +124,7 @@ Note that it tracks line numbers, so a change that only moves code still moves
|
||||
the matrix. Never regenerate it with a stale prebuilt binary.
|
||||
|
||||
**One convention that the tooling cannot enforce.** A tag proves that a tag
|
||||
exists, not that the code under it does the thing — `docs/code-health.md`
|
||||
exists, not that the code under it does the thing — `docs/dev/code-health.md`
|
||||
CH-4 has the details, and two requirements currently read as covered on the
|
||||
strength of plumbing a future feature would use. So: **close a requirement
|
||||
with a test that would fail if the behaviour were removed.** Coverage that
|
||||
@@ -163,12 +163,12 @@ One commit per change. If you fixed two things, that is two commits.
|
||||
| Document | Read it when |
|
||||
|---|---|
|
||||
| [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) | Adding or changing a develop operation — start here regardless |
|
||||
| [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync |
|
||||
| [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs |
|
||||
| [`docs/benchmarks.md`](docs/benchmarks.md) | A change that could plausibly cost time or memory |
|
||||
| [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen |
|
||||
| [`docs/distribution.md`](docs/distribution.md) | Packaging a build, or adding a permission to one |
|
||||
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading |
|
||||
| [`docs/dev/architecture.md`](docs/dev/architecture.md) | Anything touching the render path, catalog or sync |
|
||||
| [`docs/dev/code-health.md`](docs/dev/code-health.md) | Deciding what to work on; grades each seam by what it costs |
|
||||
| [`docs/dev/benchmarks.md`](docs/dev/benchmarks.md) | A change that could plausibly cost time or memory |
|
||||
| [`docs/dev/technical-debt.md`](docs/dev/technical-debt.md) | Something looks wrong — check it was not chosen |
|
||||
| [`docs/dev/distribution.md`](docs/dev/distribution.md) | Packaging a build, or adding a permission to one |
|
||||
| [`docs/dev/requirements.md`](docs/dev/requirements.md) | Reference, not reading |
|
||||
|
||||
`technical-debt.md` is the one to check before "fixing" anything surprising.
|
||||
It records compromises that were deliberate, each with the reasoning and a
|
||||
|
||||
Generated
+86
-29
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-android"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-plat",
|
||||
@@ -1234,13 +1234,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-desktop"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-plat",
|
||||
"dr-ui",
|
||||
"env_logger",
|
||||
"log",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1407,7 +1408,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dr-bench"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-catalog",
|
||||
@@ -1424,7 +1425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-catalog"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-face",
|
||||
"dr-plat",
|
||||
@@ -1439,7 +1440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-decode"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
@@ -1453,7 +1454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-export"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-gpu",
|
||||
@@ -1464,6 +1465,7 @@ dependencies = [
|
||||
"log",
|
||||
"png",
|
||||
"pollster",
|
||||
"rawler",
|
||||
"thiserror 2.0.20",
|
||||
"tiff",
|
||||
"zune-jpeg 0.4.21",
|
||||
@@ -1471,20 +1473,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-face"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
"log",
|
||||
"ndarray",
|
||||
"ort",
|
||||
"ort-tract",
|
||||
"thiserror 2.0.20",
|
||||
"zune-jpeg 0.4.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-film"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1493,11 +1495,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-gpu"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dr-decode",
|
||||
"dr-film",
|
||||
"dr-pano",
|
||||
"dr-pipeline",
|
||||
"dr-segment",
|
||||
"dr-types",
|
||||
@@ -1508,9 +1511,24 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-inference-engine"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"libloading",
|
||||
"log",
|
||||
"ort",
|
||||
"ort-sys",
|
||||
"ort-tract",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-ingest"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
@@ -1522,15 +1540,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-lens"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"lensfun",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-pano"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-inference-engine",
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
"log",
|
||||
"ndarray",
|
||||
"ort",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-pipeline"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -1539,7 +1571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-plat"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"android-native-keyring-store",
|
||||
"dr-types",
|
||||
@@ -1555,7 +1587,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-preset-xmp"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-pipeline",
|
||||
"log",
|
||||
@@ -1565,20 +1597,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
"log",
|
||||
"ndarray",
|
||||
"ort",
|
||||
"ort-tract",
|
||||
"thiserror 2.0.20",
|
||||
"zune-jpeg 0.4.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-plat",
|
||||
@@ -1592,7 +1624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-folder"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-sync",
|
||||
@@ -1604,7 +1636,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-nextcloud"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-decode",
|
||||
@@ -1626,7 +1658,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-thumbs"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"jpeg-encoder",
|
||||
@@ -1638,7 +1670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-types"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1647,7 +1679,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ui"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1657,8 +1689,10 @@ dependencies = [
|
||||
"dr-face",
|
||||
"dr-film",
|
||||
"dr-gpu",
|
||||
"dr-inference-engine",
|
||||
"dr-ingest",
|
||||
"dr-lens",
|
||||
"dr-pano",
|
||||
"dr-pipeline",
|
||||
"dr-plat",
|
||||
"dr-preset-xmp",
|
||||
@@ -1668,10 +1702,13 @@ dependencies = [
|
||||
"dr-sync-nextcloud",
|
||||
"dr-thumbs",
|
||||
"dr-types",
|
||||
"dr-xmp",
|
||||
"env_logger",
|
||||
"i-slint-backend-testing",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"png",
|
||||
"pollster",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
@@ -1681,12 +1718,13 @@ dependencies = [
|
||||
"slint-build",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"url",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-xmp"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -2744,6 +2782,18 @@ dependencies = [
|
||||
"i-slint-renderer-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "i-slint-backend-testing"
|
||||
version = "1.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521e901e3d47ab829c0ef500c63155776208707cd93259e6a7803ed627fa2786"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"i-slint-common",
|
||||
"i-slint-core",
|
||||
"vtable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "i-slint-backend-winit"
|
||||
version = "1.17.1"
|
||||
@@ -2924,8 +2974,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "i-slint-renderer-skia"
|
||||
version = "1.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b6eed7f3f0a9a3d3ca6e8b9d4ca233371d989351fdb2a7ab88ec368b99e7b57"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bytemuck",
|
||||
@@ -6987,9 +7035,10 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "traceability"
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pulldown-cmark",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -7887,8 +7936,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "wgpu-hal"
|
||||
version = "29.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
@@ -8387,6 +8434,16 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winresource"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
|
||||
dependencies = [
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
|
||||
+24
-1
@@ -8,9 +8,11 @@ members = [
|
||||
"core/dr-export",
|
||||
"core/dr-face",
|
||||
"core/dr-film",
|
||||
"core/dr-inference-engine",
|
||||
"core/dr-ingest",
|
||||
"core/dr-gpu",
|
||||
"core/dr-lens",
|
||||
"core/dr-pano",
|
||||
"core/dr-pipeline",
|
||||
"core/dr-preset-xmp",
|
||||
"core/dr-segment",
|
||||
@@ -25,9 +27,12 @@ members = [
|
||||
"tools/bench",
|
||||
"tools/traceability",
|
||||
]
|
||||
# Patched copies of upstream crates, not our code: see third_party/README.md.
|
||||
# Excluded so `--workspace` does not test, lint or format them as ours.
|
||||
exclude = ["third_party"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.15.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -45,9 +50,14 @@ dr-export = { path = "core/dr-export" }
|
||||
# `features = ["inference"]`.
|
||||
dr-face = { path = "core/dr-face", default-features = false }
|
||||
dr-film = { path = "core/dr-film" }
|
||||
# `tract` on by default so a test binary can open a session with nothing
|
||||
# installed; the apps add `native` to look for a runtime file (docs/dev/inference.md §3).
|
||||
dr-inference-engine = { path = "core/dr-inference-engine" }
|
||||
dr-ingest = { path = "core/dr-ingest" }
|
||||
dr-gpu = { path = "core/dr-gpu" }
|
||||
dr-lens = { path = "core/dr-lens" }
|
||||
# Optional runtime, like `dr-segment`: the geometry never needs a model.
|
||||
dr-pano = { path = "core/dr-pano", default-features = false }
|
||||
dr-pipeline = { path = "core/dr-pipeline" }
|
||||
dr-preset-xmp = { path = "core/dr-preset-xmp" }
|
||||
# `default-features = false` belongs *here*, not on each dependant: a member
|
||||
@@ -120,6 +130,10 @@ url = "2.5"
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The manual's HTML rendering (tools/traceability). Already in the tree as
|
||||
# Slint's Markdown parser, so this adds a dependency edge and no crate; only
|
||||
# the HTML writer is needed, not the command-line front end.
|
||||
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
|
||||
base64 = "0.23"
|
||||
|
||||
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
|
||||
@@ -255,3 +269,12 @@ opt-level = 0
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# Two upstream crates carry a local patch so that the Android build can draw
|
||||
# with wgpu on a rotated display (technical-debt.md TD-1). Both are exact
|
||||
# copies of the version the lockfile already resolves, plus that patch;
|
||||
# third_party/README.md says what was changed and how to carry it forward
|
||||
# when Slint or wgpu moves.
|
||||
[patch.crates-io]
|
||||
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
|
||||
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
“This License” refers to version 3 of the GNU General Public License.
|
||||
|
||||
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||
|
||||
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||
|
||||
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||
|
||||
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||
|
||||
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||
|
||||
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||
|
||||
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||
|
||||
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||
|
||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||
|
||||
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||
|
||||
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||
|
||||
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||
|
||||
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||
|
||||
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
@@ -1,84 +1,134 @@
|
||||
# DarkRoom
|
||||
|
||||
A cross-platform, non-destructive RAW photo editor for Linux and Android.
|
||||
A non-destructive RAW photo editor and library for Linux and Android, with a
|
||||
GPU develop pipeline, a catalog that syncs between devices, and no account,
|
||||
no telemetry and no cloud of its own.
|
||||
|
||||
**Status:** 0.9.0, and no longer a spike. A library opens, culls, develops and
|
||||
exports on both platforms, across eight tagged releases. What is *not*
|
||||
built is written down rather than merely absent — see
|
||||
[docs/outstanding.md](docs/outstanding.md) for the requirements that have no
|
||||
implementation and why, and [docs/technical-debt.md](docs/technical-debt.md)
|
||||
for the compromises that were chosen.
|
||||
[](docs/manual/README.md)
|
||||
|
||||
## Documentation
|
||||
**[The manual](docs/manual/README.md)** shows every feature, pictured from
|
||||
the application itself. This page says what it is, how to get it, and what
|
||||
is still missing.
|
||||
|
||||
| Document | Contents |
|
||||
|---|---|
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
|
||||
| [requirements.md](docs/requirements.md) | What the software must do — 179 numbered requirements |
|
||||
| [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync |
|
||||
| [technical-debt.md](docs/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
|
||||
| [outstanding.md](docs/outstanding.md) | What is not built, and whether that is a decision or a gap |
|
||||
| [code-health.md](docs/code-health.md) | What a contribution costs, per seam, measured |
|
||||
| [traceability.md](docs/traceability.md) | Generated: which requirement is claimed by which file |
|
||||
| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measured |
|
||||
## What it does
|
||||
|
||||
## Building
|
||||
**A library.** Point it at a folder — on this machine, on a network mount,
|
||||
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
|
||||
Nextcloud account directly. The grid is virtualised, ordered by capture
|
||||
time with a timeline beside it, and filtered by rating, flag, person and
|
||||
whether the file is here. Ratings, keywords, collections and a
|
||||
trash that survives a crash mid-operation. Card ingest. Bursts fold. Face
|
||||
detection and identity, with the index syncing between devices.
|
||||
|
||||
Desktop:
|
||||
**Developing.** Eighteen declared operations fused into one compute
|
||||
dispatch, plus the neighbourhood work that cannot be: clarity, texture,
|
||||
capture sharpening, noise reduction, lens correction, spectral film
|
||||
simulation. Crop and straighten, spot repair, and local adjustments over
|
||||
masks the model draws — click a subject or a category, then paint, subtract
|
||||
a gradient, grow or shrink the edge. Focus peaking and a raw histogram for
|
||||
judging what is recoverable. Named presets; XMP sidecars other editors read.
|
||||
|
||||
[](docs/manual/README.md#local-adjustments)
|
||||
|
||||
**Panoramas.** Select the frames, align, choose a projection, fill the
|
||||
ragged border rather than crop it, and the composite lands beside its
|
||||
sources as a DNG, with a sidecar recording what it was merged from.
|
||||
|
||||
[](docs/manual/README.md#merging-a-panorama)
|
||||
|
||||
**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
|
||||
into the library.
|
||||
|
||||
**On both platforms.** The same core runs on a desktop and a 12-inch
|
||||
tablet; the interface is one layout, tuned for a wide viewport with touch
|
||||
targets throughout. On desktop the develop view draws the compute pass's
|
||||
texture directly — no readback between the GPU and the screen.
|
||||
|
||||
## Getting it
|
||||
|
||||
| Platform | How | State |
|
||||
|---|---|---|
|
||||
| Arch Linux | [`packaging/PKGBUILD`](packaging/PKGBUILD) — `makepkg -si` | Built from every release |
|
||||
| Android | The APK from each CI run, or `./docker/android/package.sh --install` | Runs on a tablet; F-Droid not yet submitted |
|
||||
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned |
|
||||
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; choosing a library does not yet work in the sandbox |
|
||||
|
||||
Or build it. Git LFS is required for the model weights, and the toolchain
|
||||
pins itself to 1.92.0:
|
||||
|
||||
```bash
|
||||
cargo run -p darkroom-desktop
|
||||
git lfs install && git lfs pull
|
||||
cargo run --release -p darkroom-desktop
|
||||
```
|
||||
|
||||
Android (containerised toolchain, see [docker/android](docker/android/README.md)):
|
||||
Android, through the containerised toolchain ([docker/android](docker/android/README.md)):
|
||||
|
||||
```bash
|
||||
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
|
||||
```
|
||||
|
||||
Git LFS is required for the model weights, and the toolchain pins itself.
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) has the details and the four commands CI
|
||||
will run against what you send.
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) has the system packages, the four
|
||||
commands CI runs against what you send, and the shortest useful
|
||||
contribution — a develop operation is one YAML file, and it arrives with its
|
||||
controls, its place in the chain and its tests.
|
||||
|
||||
## Current state
|
||||
## Where it stands
|
||||
|
||||
**Working.** A catalog over a local folder, a Nextcloud account, or a folder a
|
||||
sync client keeps in virtual-files mode — where a placeholder is treated as the
|
||||
photograph rather than as a one-byte file. A virtualised library grid with a
|
||||
capture-time timeline, ratings, labels, keywords, collections and a trash that
|
||||
survives a crash mid-operation. Card ingest. Face detection and identity, with
|
||||
the index syncing between devices. A develop pipeline of fifteen declared
|
||||
operations fused into a single compute dispatch, plus the neighbourhood
|
||||
operations that cannot be — clarity, texture, capture sharpening, noise
|
||||
reduction, lens correction, spectral film simulation. Crop, straighten, spot
|
||||
removal, gradient and subject-segmentation masks, named presets, and a
|
||||
generated panel that no operation in `ui/` is allowed to name. Export to JPEG,
|
||||
PNG and 8- or 16-bit TIFF with resize and output sharpening.
|
||||
**0.15.0**, twenty-three tagged releases in. 190 numbered requirements in
|
||||
scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
||||
the rest are written down rather than merely absent.
|
||||
|
||||
**The zero-copy display path works on desktop.** The compute pass writes a
|
||||
texture that Slint composites directly, which is what
|
||||
[ARCH §6.1](docs/architecture.md) requires; the readback it forbids costs 96%
|
||||
of frame time at 4K, and
|
||||
**Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
|
||||
survey culling, AI denoise, tiled rendering, HDR merge and
|
||||
focus stacking, most of the Android platform integration beyond running,
|
||||
and the Flatpak's library chooser. The performance targets are half
|
||||
verified: the per-commit benchmark suite §8 requires exists for everything
|
||||
that does not need a frame — the catalog, the scan, the thumbnails — and
|
||||
not yet for the render path, so a regression there fails nothing.
|
||||
[outstanding.md](docs/dev/outstanding.md) is the list, with the reasoning for
|
||||
each.
|
||||
|
||||
```bash
|
||||
cargo run -p dr-gpu --example bench --features readback
|
||||
```
|
||||
**The one deliberate compromise worth knowing about before reading
|
||||
anything else:** the Android develop view reads its frame back through the
|
||||
CPU, because zero-copy there needs wgpu's Vulkan swapchain and that tears a
|
||||
portrait window on a tablet whose panel is mounted landscape. It is debt,
|
||||
not a revision of the rule — [technical-debt.md TD-1](docs/dev/technical-debt.md)
|
||||
has the measurements and the three things any one of which would remove it.
|
||||
|
||||
still reproduces that measurement. **The one exception is the Android develop
|
||||
view**, which reads the frame back through the CPU because zero-copy there
|
||||
needs wgpu's Vulkan swapchain, and that tears a portrait window on a tablet
|
||||
whose panel is mounted landscape. It is debt, not a revision of the rule: the
|
||||
reasoning, the on-device measurements that forced it, and the three separate
|
||||
things any one of which would remove it are in
|
||||
[technical-debt.md TD-1](docs/technical-debt.md).
|
||||
## Documentation
|
||||
|
||||
**Not built.** Plugins, compare and survey culling, focus peaking, burst
|
||||
grouping, AI denoise, tiled and progressive rendering, and most of the Android
|
||||
platform integration beyond running. The performance targets in §4.1 are
|
||||
unverified rather than unmet — the per-commit benchmark suite §8 requires does
|
||||
not exist, so nothing fails a build on a regression.
|
||||
[docs/outstanding.md](docs/outstanding.md) is the list, with the reasoning.
|
||||
[docs/README.md](docs/README.md) is the index. The short version, for someone using it:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [manual](docs/manual/README.md) | Every feature, pictured |
|
||||
| [gestures.md](docs/gestures.md) | How it is driven — generated from the code, so it cannot describe a gesture that does not exist |
|
||||
|
||||
For someone changing it:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
|
||||
| [requirements.md](docs/dev/requirements.md) | What the software must do — the numbered register, and the decisions |
|
||||
| [architecture.md](docs/dev/architecture.md) | How it is built — crates, the GPU pipeline, the data model, sync |
|
||||
| [technical-debt.md](docs/dev/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
|
||||
| [outstanding.md](docs/dev/outstanding.md) | What is not built, and whether that is a decision or a gap |
|
||||
| [code-health.md](docs/dev/code-health.md) | What a contribution costs, per seam, measured |
|
||||
| [traceability.md](docs/dev/traceability.md) | Generated: which requirement is claimed by which file |
|
||||
|
||||
Designs, one per subsystem:
|
||||
[segmentation](docs/dev/segmentation.md) and [mask editing](docs/dev/mask-editing.md) ·
|
||||
[spot removal](docs/dev/spot-removal.md) · [panorama](docs/dev/panorama.md) ·
|
||||
[faces](docs/dev/faces.md) · [inference](docs/dev/inference.md) ·
|
||||
[storage and sync](docs/dev/storage.md) · [catalog](docs/dev/catalog.md) ·
|
||||
[display and extension](docs/dev/display-and-extension.md) ·
|
||||
[navigation](docs/dev/ui-navigation.md) · [distribution](docs/dev/distribution.md) ·
|
||||
[windows](docs/dev/windows.md) · [benchmarks](docs/dev/benchmarks.md).
|
||||
|
||||
## Licence
|
||||
|
||||
GPL-3.0-or-later.
|
||||
GPL-3.0-or-later. The photographs in the manual and the test fixtures are
|
||||
the author's and are there to show and test this project, nothing else.
|
||||
The model weights carry their own licences — [models/LICENCE.md](models/LICENCE.md).
|
||||
|
||||
@@ -141,6 +141,23 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- The manual (dr_ui::manual): a WebView over the copy the APK
|
||||
carries in assets/manual. See ManualActivity.java for why it is
|
||||
not the browser.
|
||||
|
||||
Not exported: nothing outside this app has a reason to start it,
|
||||
and dr_ui starts it by class name, which needs no intent filter.
|
||||
Its own task entry is not wanted either — it is a page over the
|
||||
app, and Back returns to the photograph it was opened from.
|
||||
configChanges so a rotation reflows the page rather than
|
||||
reloading it at the top. -->
|
||||
<activity
|
||||
android:name="paris.tourolle.darkroom.ManualActivity"
|
||||
android:exported="false"
|
||||
android:label="DarkRoom manual"
|
||||
android:theme="@style/ManualTheme"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|uiMode" />
|
||||
|
||||
<!-- FR-PLAT-AND-6, outbound. Android has refused file:// URIs
|
||||
between apps since API 24 — handing one out raises
|
||||
FileUriExposedException in *this* process — so an exported JPEG
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package paris.tourolle.darkroom;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
|
||||
/**
|
||||
* The manual that ships in the APK, shown in a WebView.
|
||||
*
|
||||
* <h2>Why an activity of our own rather than the browser</h2>
|
||||
*
|
||||
* <p>The desktop hands the manual to the system browser. Android leaves no
|
||||
* way to do the same: the page is an asset inside the APK, which is not a
|
||||
* file; an unpacked copy in app-private storage is a file no browser may
|
||||
* read; a {@code file:} URI handed to another app is refused since API 24;
|
||||
* and a {@code content:} URI serves the page but leaves the browser to fetch
|
||||
* every picture by a relative URL against the provider, which browsers do not
|
||||
* reliably do. A WebView reads {@code file:///android_asset/} straight from
|
||||
* the APK, pictures and section anchor included, and nothing is unpacked.
|
||||
*
|
||||
* <h2>What it is not</h2>
|
||||
*
|
||||
* <p>A browser. JavaScript stays off (the page has none), and a link that
|
||||
* leaves the manual — the design documents are on the forge — goes to the
|
||||
* user's browser rather than opening inside this view, so the only thing ever
|
||||
* shown here is the page the APK carries.
|
||||
*
|
||||
* <p>Started by {@code dr_ui::manual} with {@code Intent.setClassName}, so the
|
||||
* name here and there must agree; a test in lib.rs checks the manifest
|
||||
* declares it.
|
||||
*/
|
||||
public final class ManualActivity extends Activity {
|
||||
/** The section to open at, a heading's anchor. Absent opens the top. */
|
||||
public static final String EXTRA_ANCHOR = "anchor";
|
||||
|
||||
private static final String PAGE = "file:///android_asset/manual/index.html";
|
||||
|
||||
private WebView web;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle saved) {
|
||||
super.onCreate(saved);
|
||||
setTitle("DarkRoom manual");
|
||||
|
||||
web = new WebView(this);
|
||||
WebSettings settings = web.getSettings();
|
||||
settings.setJavaScriptEnabled(false);
|
||||
// Pinch to zoom into a screenshot, which is 1600 pixels wide and drawn
|
||||
// at the width of a phone.
|
||||
settings.setBuiltInZoomControls(true);
|
||||
settings.setDisplayZoomControls(false);
|
||||
web.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
||||
Uri uri = request.getUrl();
|
||||
if ("file".equals(uri.getScheme())) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
startActivity(new Intent(Intent.ACTION_VIEW, uri));
|
||||
} catch (ActivityNotFoundException e) {
|
||||
// No browser on the device: the link does nothing, which
|
||||
// is all it could do.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
setContentView(web);
|
||||
|
||||
if (saved != null) {
|
||||
web.restoreState(saved);
|
||||
} else {
|
||||
String anchor = getIntent().getStringExtra(EXTRA_ANCHOR);
|
||||
web.loadUrl(anchor == null || anchor.isEmpty() ? PAGE : PAGE + "#" + anchor);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(Bundle out) {
|
||||
super.onSaveInstanceState(out);
|
||||
web.saveState(out);
|
||||
}
|
||||
|
||||
/** Back walks back through the sections visited, then leaves. */
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (web.canGoBack()) {
|
||||
web.goBack();
|
||||
} else {
|
||||
super.onBackPressed();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
web.destroy();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Day or night as the system is; see values/themes.xml. -->
|
||||
<resources>
|
||||
<style name="ManualTheme" parent="@android:style/Theme.DeviceDefault.DayNight" />
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The manual's theme (ManualActivity). Light below API 29, which has no
|
||||
day-night theme in the platform; values-v29 follows the system from there.
|
||||
The WebView takes prefers-color-scheme from whether this theme is light, and
|
||||
the manual's stylesheet takes its colours from that.
|
||||
-->
|
||||
<resources>
|
||||
<style name="ManualTheme" parent="@android:style/Theme.DeviceDefault.Light" />
|
||||
</resources>
|
||||
@@ -240,7 +240,7 @@ fn android_main(app: slint::android::AndroidApp) {
|
||||
///
|
||||
/// **Face weights are absent from the repository by design.** The InsightFace
|
||||
/// grant is research-only and incompatible with this project's licence
|
||||
/// (docs/faces.md §2), so a desktop user fetches them, runs
|
||||
/// (docs/dev/faces.md §2), so a desktop user fetches them, runs
|
||||
/// `tools/fix-face-model-shapes.sh` over them, and drops the result in. A build
|
||||
/// that carries none is the ordinary case and face indexing simply stays off.
|
||||
///
|
||||
@@ -321,20 +321,43 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
// before it reports the tab available.
|
||||
//
|
||||
// Three detectors, because which one runs is a setting
|
||||
// (`FaceDetector`, docs/faces.md §12.3) and a tablet has no other way to
|
||||
// (`FaceDetector`, docs/dev/faces.md §12.3) and a tablet has no other way to
|
||||
// obtain the one it was not shipped with. Twenty megabytes of APK for
|
||||
// the choice; the embedder is the same for all three.
|
||||
const BUNDLED: [(&std::ffi::CStr, &str); 7] = [
|
||||
//
|
||||
// Then the three eye-state models (docs/dev/faces.md §17): landmarks, open
|
||||
// or closed, sunglasses. The app indexes without them; with them the
|
||||
// eyes-open filter has something to read, and a tablet has no other way
|
||||
// to get them either.
|
||||
//
|
||||
// The int8 forms beside the three detectors are what the Hexagon runs
|
||||
// (docs/dev/inference.md §5); the engine loads the sibling when the probe
|
||||
// chose that rung and ignores it otherwise.
|
||||
const BUNDLED: [(&std::ffi::CStr, &str); 14] = [
|
||||
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
|
||||
(
|
||||
c"models/scrfd_500m_640.int8.onnx",
|
||||
"scrfd_500m_640.int8.onnx",
|
||||
),
|
||||
(c"models/scrfd_2.5g_640.onnx", "scrfd_2.5g_640.onnx"),
|
||||
(
|
||||
c"models/scrfd_2.5g_640.int8.onnx",
|
||||
"scrfd_2.5g_640.int8.onnx",
|
||||
),
|
||||
(c"models/scrfd_10g_640.onnx", "scrfd_10g_640.onnx"),
|
||||
(c"models/scrfd_10g_640.int8.onnx", "scrfd_10g_640.int8.onnx"),
|
||||
(c"models/arcface_mbf_b1.onnx", "arcface_mbf_b1.onnx"),
|
||||
(c"models/2d106det_b1.onnx", "2d106det_b1.onnx"),
|
||||
(c"models/ocec_s_b1.onnx", "ocec_s_b1.onnx"),
|
||||
(c"models/sgc_l_48_b1.onnx", "sgc_l_48_b1.onnx"),
|
||||
(c"models/yolo26s-sem-ade20k.onnx", "yolo26s-sem-ade20k.onnx"),
|
||||
(
|
||||
c"models/yolo26s-sem-ade20k.classes.json",
|
||||
"yolo26s-sem-ade20k.classes.json",
|
||||
),
|
||||
(c"models/categories.txt", "categories.txt"),
|
||||
// The panorama border filler (FR-MRG-4); MIT, 28 MB.
|
||||
(c"models/migan-512.onnx", "migan-512.onnx"),
|
||||
];
|
||||
|
||||
let dir = dr_ui::shared_face_models_dir();
|
||||
@@ -343,8 +366,8 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
|
||||
for (asset_path, name) in BUNDLED {
|
||||
let dest = dir.join(name);
|
||||
// Already unpacked. Not re-read on every launch: this is 61 MB of
|
||||
// copying across the seven entries, and the file does not change without
|
||||
// Already unpacked. Not re-read on every launch: this is 73 MB of
|
||||
// copying across the ten entries, and the file does not change without
|
||||
// the APK changing, at which point the install wiped it anyway. It
|
||||
// matters more now than it did — a launch that skips every entry here
|
||||
// costs nothing at all, which is what makes the second launch after an
|
||||
@@ -392,6 +415,27 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
"bundled models ready: {copied} bytes copied in {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
|
||||
// Now, and not at launch: the probe fingerprints the model files, and
|
||||
// on a first launch they were not on disk until this line. The runtime
|
||||
// is in the APK's native library directory beside `libdarkroom.so`,
|
||||
// which is also where Qualcomm's DSP loader has to be pointed for the
|
||||
// Hexagon skel (docs/dev/inference.md §3, §8).
|
||||
dr_ui::inference::init(native_library_dir().into_iter().collect());
|
||||
}
|
||||
|
||||
/// The directory the system unpacked this APK's native libraries into.
|
||||
///
|
||||
/// Read from where the loader put *this* library rather than asked of the
|
||||
/// activity: `android-activity` does not expose `nativeLibraryDir`, and the
|
||||
/// answer is in `/proc/self/maps` for free.
|
||||
#[cfg(target_os = "android")]
|
||||
fn native_library_dir() -> Option<std::path::PathBuf> {
|
||||
let maps = std::fs::read_to_string("/proc/self/maps").ok()?;
|
||||
maps.lines()
|
||||
.filter_map(|l| l.split_whitespace().nth(5))
|
||||
.find(|p| p.ends_with("/libdarkroom.so"))
|
||||
.and_then(|p| std::path::Path::new(p).parent().map(Into::into))
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLAT-AND-6
|
||||
@@ -537,6 +581,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `dr_ui::manual` starts the manual by class name. A name the manifest
|
||||
/// does not declare is an `ActivityNotFoundException` on the device and a
|
||||
/// Manual button that does nothing, so the three spellings — dr_ui's, the
|
||||
/// manifest's and the Java file's — are checked to be one.
|
||||
#[test]
|
||||
fn the_manual_activity_dr_ui_starts_is_declared() {
|
||||
let manifest = manifest();
|
||||
let wanted = dr_ui::manual::ANDROID_ACTIVITY;
|
||||
let element = manifest
|
||||
.split("<activity")
|
||||
.skip(1)
|
||||
.find(|a| attribute(a, "android:name").as_deref() == Some(wanted))
|
||||
.unwrap_or_else(|| panic!("the manifest declares no activity {wanted}"));
|
||||
assert_eq!(
|
||||
attribute(element, "android:exported").as_deref(),
|
||||
Some("false"),
|
||||
"the manual activity has no reason to be startable by another app"
|
||||
);
|
||||
let java = include_str!("../android/java/paris/tourolle/darkroom/ManualActivity.java");
|
||||
let (package, class) = wanted.rsplit_once('.').expect("unqualified class name");
|
||||
assert!(java.contains(&format!("package {package};")));
|
||||
assert!(java.contains(&format!("class {class} ")));
|
||||
assert!(
|
||||
java.contains(&format!(
|
||||
"EXTRA_ANCHOR = \"{}\"",
|
||||
dr_ui::manual::ANDROID_EXTRA_ANCHOR
|
||||
)),
|
||||
"ManualActivity reads the section from a different extra than dr_ui writes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_provider_hands_out_one_file_at_a_time_and_nothing_by_itself() {
|
||||
let manifest = manifest();
|
||||
|
||||
@@ -15,5 +15,16 @@ anyhow.workspace = true
|
||||
env_logger.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
# The Windows resource block — icon and version — compiled in by build.rs.
|
||||
# Unconditional rather than under `[target.'cfg(windows)']`, because a cfg on
|
||||
# a build-dependency is evaluated against the *host* — the machine running
|
||||
# the build script — and this is built for Windows from Linux. The script
|
||||
# itself returns before touching the crate on every other target.
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# The manual's recording hook (dr-ui's `automation`); tools/manual/record.sh
|
||||
# builds with it, nothing else does.
|
||||
automation = ["dr-ui/automation"]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! TRACES: FR-PLAT-WIN-2
|
||||
//! The Windows resource block: icon and version, compiled into the executable.
|
||||
//!
|
||||
//! Windows takes an application's icon and its "Details" tab from a resource
|
||||
//! inside the `.exe`, not from a `.desktop` file, so without this the installed
|
||||
//! program shows the generic executable icon in Explorer, the Start Menu and
|
||||
//! the taskbar, and reports no version. Nothing here runs for any other
|
||||
//! target: the whole body is behind the target-OS check, and the crate that
|
||||
//! does the work is a build-dependency only.
|
||||
//!
|
||||
//! The icon is the same PNG every other platform uses, wrapped into an `.ico`
|
||||
//! in `OUT_DIR` rather than committed: an ICO entry may *be* a PNG (Vista and
|
||||
//! later read them directly), so the wrapper is a 22-byte header and the
|
||||
//! file's bytes, and a generated binary stays out of the tree.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
|
||||
let png = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../ui/dr-ui/ui/app-icon.png");
|
||||
println!("cargo:rerun-if-changed={}", png.display());
|
||||
let bytes = std::fs::read(&png).expect("read app-icon.png");
|
||||
let ico = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("darkroom.ico");
|
||||
write_png_ico(&ico, &bytes, 256).expect("write darkroom.ico");
|
||||
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
res.set_icon(ico.to_str().unwrap());
|
||||
res.set("ProductName", "DarkRoom");
|
||||
res.set("FileDescription", "DarkRoom");
|
||||
res.set("LegalCopyright", "GPL-3.0-or-later");
|
||||
// Cross-compiling: `winresource` looks for a `windres` for the target and
|
||||
// the Windows image names it explicitly, for the same reason the Android
|
||||
// image names its linkers.
|
||||
if let Ok(windres) = std::env::var("WINDRES") {
|
||||
res.set_windres_path(&windres);
|
||||
}
|
||||
res.compile().expect("compile the Windows resource block");
|
||||
}
|
||||
|
||||
/// One PNG image as an `.ico`. `edge` is the PNG's width and height; 256 is
|
||||
/// written as 0 per the format.
|
||||
fn write_png_ico(path: &std::path::Path, png: &[u8], edge: u32) -> std::io::Result<()> {
|
||||
let mut f = std::fs::File::create(path)?;
|
||||
let dim = if edge >= 256 { 0u8 } else { edge as u8 };
|
||||
// ICONDIR: reserved, type 1 (icon), one image.
|
||||
f.write_all(&[0, 0, 1, 0, 1, 0])?;
|
||||
// ICONDIRENTRY: width, height, palette 0, reserved, planes 1, bpp 32,
|
||||
// byte length, offset (6 + 16).
|
||||
f.write_all(&[dim, dim, 0, 0, 1, 0, 32, 0])?;
|
||||
f.write_all(&(png.len() as u32).to_le_bytes())?;
|
||||
f.write_all(&22u32.to_le_bytes())?;
|
||||
f.write_all(png)
|
||||
}
|
||||
@@ -1,12 +1,32 @@
|
||||
//! DarkRoom desktop entry point.
|
||||
//!
|
||||
//! darkroom-desktop <file-or-directory>...
|
||||
//! darkroom-desktop --version
|
||||
|
||||
// TRACES: FR-PLAT-WIN-2
|
||||
// A GUI-subsystem executable, or Windows opens a console window behind the
|
||||
// application for the life of the process. Release only: the console is where
|
||||
// the log goes when there is no file, and a debug build is run from one.
|
||||
// `--version` still prints under this — stdout is simply not attached when
|
||||
// launched from Explorer, which is not where anyone asks for a version.
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dr_plat::diagnostics::Installed;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// TRACES: FR-PLAT-WIN-3
|
||||
// Before the logger, the crash hook and everything else: this exists so a
|
||||
// build made on a machine that cannot run the application — the Linux CI
|
||||
// producing the Windows binary, checked under Wine — has an exit that
|
||||
// proves the executable starts without opening a window or touching the
|
||||
// user's directories (docs/dev/windows.md §6).
|
||||
if std::env::args().nth(1).as_deref() == Some("--version") {
|
||||
println!("darkroom-desktop {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Built rather than `init`ed, so the same logger can be handed to the
|
||||
// diagnostics tee: `env_logger` keeps writing to stderr exactly as before,
|
||||
// and every record it accepts is also appended to the on-disk log
|
||||
@@ -41,6 +61,11 @@ fn main() -> anyhow::Result<()> {
|
||||
eprintln!("usage: darkroom-desktop <file-or-directory>...");
|
||||
}
|
||||
|
||||
// Before the window: the probe runs on its own thread and the first
|
||||
// frame does not wait for it, but the models a background job asks for
|
||||
// should already know where the runtime is (docs/dev/inference.md §4).
|
||||
dr_ui::inference::init(runtime_dirs());
|
||||
|
||||
dr_ui::run(paths)?;
|
||||
|
||||
// Skip Rust's normal static/thread-local teardown on the way out: a
|
||||
@@ -50,3 +75,39 @@ fn main() -> anyhow::Result<()> {
|
||||
// destruction" when the window is closed.
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
/// Where a desktop package may have put `libonnxruntime`, most specific
|
||||
/// first. None of these existing is the tract build, which is a complete
|
||||
/// application and not an error (docs/dev/inference.md §3).
|
||||
///
|
||||
/// `DARKROOM_ORT_DIR` is for a developer pointing at a runtime that is not
|
||||
/// installed — the wheel's `capi` directory, say. Then beside the executable
|
||||
/// and in the package's private library directory, for a package that
|
||||
/// bundles its own; then the user's own `runtime/` beside the models, where
|
||||
/// `tools/fetch-desktop-runtime.sh` puts one; then the Flatpak prefix; then
|
||||
/// the system library directory, for a distribution that ships ONNX Runtime
|
||||
/// as a package of its own. The user's copy outranks the system's because
|
||||
/// the system's is the one most likely to be built without the GPU
|
||||
/// providers, or against the wrong cuDNN — and a system copy whose providers
|
||||
/// do not load is not a problem, only a slower app: the probe builds a real
|
||||
/// session before believing a provider.
|
||||
fn runtime_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(dir) = std::env::var_os("DARKROOM_ORT_DIR") {
|
||||
dirs.push(PathBuf::from(dir));
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(bin) = exe.parent() {
|
||||
dirs.push(bin.to_path_buf());
|
||||
dirs.push(bin.join("../lib/darkroom"));
|
||||
}
|
||||
}
|
||||
dirs.push(dr_ui::inference::user_runtime_dir());
|
||||
#[cfg(target_os = "linux")]
|
||||
dirs.extend([
|
||||
PathBuf::from("/app/lib/darkroom"),
|
||||
PathBuf::from("/usr/lib/darkroom"),
|
||||
PathBuf::from("/usr/lib"),
|
||||
]);
|
||||
dirs
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ fn full_library(
|
||||
|
||||
// The three phases, separately, because "a regroup takes n seconds" does
|
||||
// not tell anyone which half to optimise — and the answer differs between
|
||||
// a desktop and a tablet (docs/faces.md §9).
|
||||
// a desktop and a tablet (docs/dev/faces.md §9).
|
||||
{
|
||||
let dim = candidates.first().map(|c| c.embedding.len()).unwrap_or(0);
|
||||
let flat: Vec<f32> = candidates
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
//! Grouping has no natural `subject_id`: it is a property of a *run* of frames,
|
||||
//! so a per-image job would rebuild the world once per photograph. It is
|
||||
//! therefore a debounced library-level pass, for exactly the reasons
|
||||
//! docs/catalog.md §10.2 gives for face clustering, and [`regroup`] is the whole
|
||||
//! docs/dev/catalog.md §10.2 gives for face clustering, and [`regroup`] is the whole
|
||||
//! of it — one ordered walk, no per-pair comparison beyond adjacent frames.
|
||||
//!
|
||||
//! # Grouping is not hiding
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Face data as sealed shards, so a second device does not re-index the library.
|
||||
//!
|
||||
//! Indexing a 23,500-image library is on the order of two hours of CPU
|
||||
//! (docs/faces.md §12.2). It is also **byte-identical on every device**: the
|
||||
//! (docs/dev/faces.md §12.2). It is also **byte-identical on every device**: the
|
||||
//! same model over the same proxy produces the same embedding. Paying for it
|
||||
//! once per account rather than once per device is the whole point of this
|
||||
//! module, and it is the same bargain the thumbnail store already makes.
|
||||
@@ -48,9 +48,10 @@ pub const SHARD_MAX_BYTES: u64 = dr_thumbs::SHARD_MAX_BYTES;
|
||||
/// Bytes one stored face occupies, near enough to bound a shard by.
|
||||
///
|
||||
/// Counted rather than measured: the embedding is fixed at 512 × f16, the
|
||||
/// landmarks at 5 × 2 × f32, and the rest is a handful of numbers. Measuring
|
||||
/// landmarks at 5 × 2 × f32 and the dense ones at 106 × 2 × u16, and the
|
||||
/// rest is a handful of numbers. Measuring
|
||||
/// the file after each insert would mean a `VACUUM` to get an honest answer.
|
||||
const BYTES_PER_FACE: u64 = 1024 + 40 + 64;
|
||||
const BYTES_PER_FACE: u64 = 1024 + 40 + 424 + 64;
|
||||
|
||||
/// Bytes a stored crop occupies, near enough to bound a shard by.
|
||||
///
|
||||
@@ -79,6 +80,11 @@ pub struct SharedFace {
|
||||
/// See `faces::DetectedFace::quality`. `None` from a shard written before
|
||||
/// the number was kept.
|
||||
pub quality: Option<f32>,
|
||||
/// See `faces::DetectedFace::eyes`. `None` from a peer without the eye
|
||||
/// models, or a shard written before they existed.
|
||||
pub eyes: Option<dr_face::EyeReading>,
|
||||
/// See `faces::DetectedFace::landmarks_dense`; empty where none.
|
||||
pub landmarks_dense: Vec<u8>,
|
||||
/// The face cut out and encoded, or empty where none was kept.
|
||||
///
|
||||
/// Travels with the face rather than in the catalog snapshot, which is the
|
||||
@@ -149,6 +155,90 @@ impl FaceShardStore {
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// The pipeline this store holds an image under, among those sharing
|
||||
/// `model_id`'s embedder — the most recently indexed where a peer has
|
||||
/// sent more than one.
|
||||
///
|
||||
/// What the import asks: not "has anyone run *this* detector over it" but
|
||||
/// "does anyone hold comparable faces for it". See `faces::embedder_of`.
|
||||
pub fn held_model(&self, file_id: u64, model_id: &str) -> Option<String> {
|
||||
self.index
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT model_id FROM entries
|
||||
WHERE file_id = ?1 AND {} = ?2
|
||||
ORDER BY indexed_at DESC NULLS LAST, model_id",
|
||||
crate::faces::embedder_sql("model_id")
|
||||
),
|
||||
rusqlite::params![file_id as i64, crate::faces::embedder_of(model_id)],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
.ok()
|
||||
.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 {
|
||||
self.index
|
||||
.query_row(
|
||||
@@ -204,6 +294,15 @@ impl FaceShardStore {
|
||||
faces: &[SharedFace],
|
||||
indexed_at: Option<i64>,
|
||||
) -> 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
|
||||
.iter()
|
||||
.map(|f| BYTES_PER_FACE + if f.crop.is_empty() { 0 } else { BYTES_PER_CROP })
|
||||
@@ -227,8 +326,12 @@ impl FaceShardStore {
|
||||
tx.execute(
|
||||
"INSERT INTO faces
|
||||
(file_id, model_id, x, y, w, h, landmarks, confidence,
|
||||
embedding, crop_px, crop, quality)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
embedding, crop_px, crop, quality,
|
||||
eye_right, eye_right_px, eye_right_sharp,
|
||||
eye_left, eye_left_px, eye_left_sharp, sunglasses,
|
||||
landmarks_dense)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
|
||||
?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
||||
rusqlite::params![
|
||||
f.file_id as i64,
|
||||
f.model_id,
|
||||
@@ -242,6 +345,14 @@ impl FaceShardStore {
|
||||
f.crop_px as f64,
|
||||
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
|
||||
f.quality.map(f64::from),
|
||||
f.eyes.map(|e| f64::from(e.right.open)),
|
||||
f.eyes.map(|e| f64::from(e.right.px)),
|
||||
f.eyes.map(|e| f64::from(e.right.sharpness)),
|
||||
f.eyes.map(|e| f64::from(e.left.open)),
|
||||
f.eyes.map(|e| f64::from(e.left.px)),
|
||||
f.eyes.map(|e| f64::from(e.left.sharpness)),
|
||||
f.eyes.map(|e| f64::from(e.sunglasses)),
|
||||
(!f.landmarks_dense.is_empty()).then_some(f.landmarks_dense.as_slice()),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -433,33 +544,67 @@ impl FaceShardStore {
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)?;
|
||||
|
||||
let mut q =
|
||||
src.prepare("SELECT file_id, model_id, faces_found, source_edge FROM indexed")?;
|
||||
let images: Vec<(i64, String, i64, i64)> = q
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?
|
||||
// The peer's marker travels with the image: it is what lets
|
||||
// `import_from_shards` record the adoption under the time the peer
|
||||
// indexed it, and so what keeps `export_to_shards` from reading the
|
||||
// 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<_, _>>()?;
|
||||
|
||||
let mut adopted = 0;
|
||||
for (file_id, model_id, _found, edge) in images {
|
||||
if self.contains(file_id as u64, &model_id) {
|
||||
for (file_id, model_id, _found, edge, indexed_at) in images {
|
||||
if self.contains(file_id as u64, &model_id) || self.outranked(file_id as u64, &model_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut fq = src.prepare(&format!(
|
||||
"SELECT f.file_id, f.model_id, f.x, f.y, f.w, f.h, f.landmarks,
|
||||
f.confidence, f.embedding, f.crop_px, {}, {}
|
||||
f.confidence, f.embedding, f.crop_px, {}, {}, {}, {}
|
||||
FROM faces f WHERE f.file_id = ?1 AND f.model_id = ?2",
|
||||
column_or_null(&src, "crop"),
|
||||
column_or_null(&src, "quality"),
|
||||
crate::schema::EYE_COLUMNS
|
||||
.iter()
|
||||
.map(|c| column_or_null(&src, c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
column_or_null(&src, "landmarks_dense"),
|
||||
))?;
|
||||
let faces: Vec<SharedFace> = fq
|
||||
.query_map(rusqlite::params![file_id, &model_id], read_shared_face)?
|
||||
.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;
|
||||
}
|
||||
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.
|
||||
pub fn get_image(
|
||||
&self,
|
||||
@@ -490,7 +635,8 @@ impl FaceShardStore {
|
||||
|
||||
let mut q = conn.prepare(
|
||||
"SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px,
|
||||
crop, quality
|
||||
crop, quality, eye_right, eye_right_px, eye_right_sharp,
|
||||
eye_left, eye_left_px, eye_left_sharp, sunglasses, landmarks_dense
|
||||
FROM faces WHERE file_id = ?1 AND model_id = ?2",
|
||||
)?;
|
||||
let faces: Vec<SharedFace> = q
|
||||
@@ -548,6 +694,14 @@ fn upgrade_shard(conn: &Connection) -> Result<(), CatalogError> {
|
||||
("faces", "crop", "BLOB"),
|
||||
("indexed", "indexed_at", "INTEGER"),
|
||||
("faces", "quality", "REAL"),
|
||||
("faces", "eye_right", "REAL"),
|
||||
("faces", "eye_right_px", "REAL"),
|
||||
("faces", "eye_right_sharp", "REAL"),
|
||||
("faces", "eye_left", "REAL"),
|
||||
("faces", "eye_left_px", "REAL"),
|
||||
("faces", "eye_left_sharp", "REAL"),
|
||||
("faces", "sunglasses", "REAL"),
|
||||
("faces", "landmarks_dense", "BLOB"),
|
||||
] {
|
||||
if !has_column(conn, table, column)? {
|
||||
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {decl}"))?;
|
||||
@@ -571,7 +725,7 @@ fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool, Cata
|
||||
///
|
||||
/// `column` is one of this module's own names, never anything read from
|
||||
/// outside, which is what makes formatting it into SQL acceptable.
|
||||
fn column_or_null(conn: &Connection, column: &'static str) -> String {
|
||||
fn column_or_null(conn: &Connection, column: &str) -> String {
|
||||
match has_column(conn, "faces", column) {
|
||||
Ok(true) => format!("f.{column}"),
|
||||
_ => "NULL".to_string(),
|
||||
@@ -608,16 +762,21 @@ pub fn export_to_shards_reporting(
|
||||
model_id: &str,
|
||||
progress: &mut dyn FnMut(usize, usize),
|
||||
) -> Result<usize, CatalogError> {
|
||||
let mut q = conn.prepare(
|
||||
"SELECT r.file_id, fi.image_id, fi.source_edge, fi.indexed_at
|
||||
// Every pipeline sharing this one's embedder, each image under the id
|
||||
// that actually indexed it. A device that switched detectors still holds
|
||||
// most of its library under the previous id, and those faces are exactly
|
||||
// as comparable — and as wanted by a peer — as the new ones.
|
||||
let mut q = conn.prepare(&format!(
|
||||
"SELECT r.file_id, fi.image_id, fi.source_edge, fi.indexed_at, fi.model_id
|
||||
FROM face_index fi
|
||||
JOIN remote r ON r.image_id = fi.image_id
|
||||
WHERE fi.model_id = ?1
|
||||
WHERE {} = ?1
|
||||
ORDER BY fi.image_id",
|
||||
)?;
|
||||
let rows: Vec<(i64, i64, i64, i64)> = q
|
||||
.query_map([model_id], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
||||
crate::faces::embedder_sql("fi.model_id")
|
||||
))?;
|
||||
let rows: Vec<(i64, i64, i64, i64, String)> = q
|
||||
.query_map([crate::faces::embedder_of(model_id)], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
@@ -627,7 +786,8 @@ pub fn export_to_shards_reporting(
|
||||
|
||||
let total = rows.len();
|
||||
let mut exported = 0;
|
||||
for (seen, (file_id, image_id, edge, indexed_at)) in rows.into_iter().enumerate() {
|
||||
for (seen, (file_id, image_id, edge, indexed_at, model_id)) in rows.into_iter().enumerate() {
|
||||
let model_id = model_id.as_str();
|
||||
if seen.is_multiple_of(REPORT_EVERY) {
|
||||
progress(seen, total);
|
||||
}
|
||||
@@ -648,7 +808,8 @@ pub fn export_to_shards_reporting(
|
||||
}
|
||||
let mut fq = conn.prepare(
|
||||
"SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px, crop,
|
||||
quality
|
||||
quality, eye_right, eye_right_px, eye_right_sharp,
|
||||
eye_left, eye_left_px, eye_left_sharp, sunglasses, landmarks_dense
|
||||
FROM faces WHERE image_id = ?1 AND model_id = ?2",
|
||||
)?;
|
||||
let faces: Vec<SharedFace> = fq
|
||||
@@ -666,6 +827,8 @@ pub fn export_to_shards_reporting(
|
||||
crop_px: r.get::<_, f64>(7)? as f32,
|
||||
crop: r.get::<_, Option<Vec<u8>>>(8)?.unwrap_or_default(),
|
||||
quality: r.get::<_, Option<f64>>(9)?.map(|q| q as f32),
|
||||
eyes: crate::faces::read_eyes(r, 10)?,
|
||||
landmarks_dense: r.get::<_, Option<Vec<u8>>>(17)?.unwrap_or_default(),
|
||||
})
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
@@ -689,10 +852,20 @@ pub fn export_to_shards_reporting(
|
||||
/// adopted rather than re-detected, which is the difference between a new
|
||||
/// device being useful in a minute and in two hours.
|
||||
///
|
||||
/// Skips any image this device has already indexed itself. Local work is not
|
||||
/// second-guessed by a peer's — the two should agree, since the same model over
|
||||
/// the same proxy is deterministic, but where they do not, the copy this device
|
||||
/// computed is the one it can vouch for.
|
||||
/// Skips any image this device has already indexed itself under this
|
||||
/// pipeline or any sharing its embedder — unless the peer ran a detector that
|
||||
/// outranks the one that indexed it here. Local work is not second-guessed
|
||||
/// by a peer's equal: the two should agree, since the same model over the
|
||||
/// same proxy is deterministic, and where they do not, the copy this device
|
||||
/// computed is the one it can vouch for. A peer's *stronger* pass is another
|
||||
/// matter: it is the re-detection this device's own sweep would queue
|
||||
/// (`FaceDetector::supersedes`), already done, and taking it is what spares
|
||||
/// a tablet the fetch. Names survive the replacement by box overlap and
|
||||
/// embedding, as they do a local re-detection (`faces::record_detections`).
|
||||
///
|
||||
/// A peer's faces are taken under whichever compatible detector found them:
|
||||
/// a tablet set to the fast detector adopts the desktop's thorough pass
|
||||
/// rather than re-detecting it worse.
|
||||
///
|
||||
/// Returns how many images were adopted.
|
||||
pub fn import_from_shards(
|
||||
@@ -700,36 +873,80 @@ pub fn import_from_shards(
|
||||
store: &FaceShardStore,
|
||||
model_id: &str,
|
||||
) -> Result<usize, CatalogError> {
|
||||
use dr_types::FaceDetector;
|
||||
|
||||
// Only images this device actually has. A shard covers the whole account,
|
||||
// and a device holding a subset of the library should take only its own
|
||||
// part rather than accumulating faces for photographs it cannot show.
|
||||
let mut q = conn.prepare(
|
||||
"SELECT r.file_id, r.image_id
|
||||
//
|
||||
// With the pipeline that indexed each one here, or NULL: the marker is
|
||||
// what decides whether a peer's copy is a gap filled or an upgrade.
|
||||
let mut q = conn.prepare(&format!(
|
||||
"SELECT r.file_id, r.image_id,
|
||||
(SELECT fi.model_id FROM face_index fi
|
||||
WHERE fi.image_id = r.image_id AND {} = ?1)
|
||||
FROM remote r
|
||||
JOIN images i ON i.id = r.image_id
|
||||
WHERE i.trashed_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM face_index fi
|
||||
WHERE fi.image_id = r.image_id AND fi.model_id = ?1
|
||||
)",
|
||||
)?;
|
||||
let candidates: Vec<(i64, i64)> = q
|
||||
.query_map([model_id], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
WHERE i.trashed_at IS NULL",
|
||||
crate::faces::embedder_sql("fi.model_id")
|
||||
))?;
|
||||
let candidates: Vec<(i64, i64, Option<String>)> = q
|
||||
.query_map([crate::faces::embedder_of(model_id)], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||
})?
|
||||
.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;
|
||||
for (file_id, image_id) in candidates {
|
||||
let Some((faces, edge)) = store.get_image(file_id as u64, model_id)? else {
|
||||
let mut tx = conn.unchecked_transaction()?;
|
||||
let mut in_chunk = 0;
|
||||
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 {
|
||||
continue;
|
||||
};
|
||||
// A peer that embedded before the quality was kept has done work this
|
||||
// device cannot finish: the number exists only at embedding time, and
|
||||
// adopting the faces would write the run marker that keeps them from
|
||||
// ever being measured (schema V14). Left for this device's own pass —
|
||||
// or for the peer's, whose re-export replaces these.
|
||||
if faces.iter().any(|f| f.quality.is_none()) {
|
||||
continue;
|
||||
if let Some(local) = local {
|
||||
// An unknown detector on either side cannot be ranked, and an
|
||||
// unranked peer is treated as an equal: kept out.
|
||||
let upgrade = match (
|
||||
FaceDetector::for_model_id(&held),
|
||||
FaceDetector::for_model_id(&local),
|
||||
) {
|
||||
(Some(theirs), Some(ours)) => theirs.outranks(ours),
|
||||
_ => false,
|
||||
};
|
||||
if !upgrade {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some((faces, edge)) = store.get_image(file_id as u64, &held)? else {
|
||||
continue;
|
||||
};
|
||||
// A face the peer embedded before its quality was kept (schema V14)
|
||||
// is adopted with the reading missing, exactly as one without an eye
|
||||
// reading is. The measuring passes find their work by the NULL
|
||||
// column, not by the run marker (`dr_ui::repairs`, `faces_needing`),
|
||||
// so adopting costs the reading nothing and this device's own pass
|
||||
// fills it.
|
||||
//
|
||||
// This used to refuse such faces, on the reasoning that the marker
|
||||
// would stop them ever being measured — true before the quality
|
||||
// repair existed, and wrong after. What it cost: V14 had dropped the
|
||||
// markers of every image holding such faces, so the peer never
|
||||
// re-exported them, and the only copies in the shards were the
|
||||
// unmeasured ones. A tablet holding shards with 4,310 of the
|
||||
// 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
|
||||
.into_iter()
|
||||
.map(|f| crate::faces::DetectedFace {
|
||||
@@ -742,6 +959,8 @@ pub fn import_from_shards(
|
||||
embedding: f.embedding,
|
||||
crop_px: f.crop_px,
|
||||
quality: f.quality,
|
||||
eyes: f.eyes,
|
||||
landmarks_dense: f.landmarks_dense,
|
||||
model_id: f.model_id,
|
||||
// A peer that indexed before crops existed sends none, and the
|
||||
// reader falls back to the proxy exactly as it does for a face
|
||||
@@ -750,15 +969,42 @@ pub fn import_from_shards(
|
||||
})
|
||||
.collect();
|
||||
|
||||
crate::faces::record_detections(
|
||||
conn,
|
||||
crate::faces::record_detections_within(
|
||||
&tx,
|
||||
dr_types::ImageId(image_id as u64),
|
||||
model_id,
|
||||
&held,
|
||||
edge,
|
||||
&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;
|
||||
in_chunk += 1;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(adopted)
|
||||
}
|
||||
|
||||
@@ -788,6 +1034,8 @@ fn read_shared_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<SharedFace> {
|
||||
crop_px: r.get::<_, f64>(9)? as f32,
|
||||
crop: r.get::<_, Option<Vec<u8>>>(10)?.unwrap_or_default(),
|
||||
quality: r.get::<_, Option<f64>>(11)?.map(|q| q as f32),
|
||||
eyes: crate::faces::read_eyes(r, 12)?,
|
||||
landmarks_dense: r.get::<_, Option<Vec<u8>>>(19)?.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -875,7 +1123,20 @@ CREATE TABLE IF NOT EXISTS faces (
|
||||
-- Length of the raw embedding (`faces::DetectedFace::quality`). NULL from
|
||||
-- a build that did not keep it, and a face the receiving device will not
|
||||
-- adopt -- see `import_from_shards`.
|
||||
quality REAL
|
||||
quality REAL,
|
||||
-- The eye reading (`faces::DetectedFace::eyes`), all seven or none. NULL
|
||||
-- from a peer without the eye models; adopted anyway, and read by the
|
||||
-- receiving device's own measuring pass if it has them.
|
||||
eye_right REAL,
|
||||
eye_right_px REAL,
|
||||
eye_right_sharp REAL,
|
||||
eye_left REAL,
|
||||
eye_left_px REAL,
|
||||
eye_left_sharp REAL,
|
||||
sunglasses REAL,
|
||||
-- The dense landmarks behind the reading (`faces::DetectedFace::
|
||||
-- landmarks_dense`), 424 bytes packed; NULL where none.
|
||||
landmarks_dense BLOB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS faces_file ON faces(file_id, model_id);
|
||||
|
||||
@@ -935,6 +1196,8 @@ mod tests {
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(17.5),
|
||||
eyes: None,
|
||||
landmarks_dense: Vec::new(),
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
}
|
||||
@@ -977,6 +1240,32 @@ mod tests {
|
||||
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]
|
||||
fn re_storing_an_image_replaces_rather_than_doubling_it() {
|
||||
let dir = tempdir();
|
||||
@@ -1144,6 +1433,8 @@ mod catalog_round_trip {
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(20.0),
|
||||
eyes: None,
|
||||
landmarks_dense: Vec::new(),
|
||||
model_id: "w600k_mbf".into(),
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
@@ -1195,13 +1486,62 @@ mod catalog_round_trip {
|
||||
assert!((got[0].landmarks[2].0 - 0.15).abs() < 1e-5);
|
||||
let emb = faces::embeddings(&b, "w600k_mbf").unwrap();
|
||||
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);
|
||||
}
|
||||
|
||||
/// A face a peer embedded without measuring it is work this device
|
||||
/// cannot finish, and adopting it would write the marker that stops it
|
||||
/// ever being measured. The image stays outstanding instead.
|
||||
/// The desktop switched to a stronger detector part-way through the
|
||||
/// library, so its faces sit under two pipeline ids. A tablet on the
|
||||
/// original detector must receive *all* of them — each under the id that
|
||||
/// found it — and not re-detect the thorough half worse.
|
||||
#[test]
|
||||
fn a_peers_unmeasured_faces_are_left_for_this_device_to_index() {
|
||||
fn every_generation_sharing_an_embedder_travels_and_is_adopted() {
|
||||
let a = device(&[(1, 5001), (2, 5002)]);
|
||||
let b = device(&[(90, 5001), (91, 5002)]);
|
||||
|
||||
faces::record_detections(&a, dr_types::ImageId(1), "w600k_mbf", 1024, &[detected(1)])
|
||||
.unwrap();
|
||||
let mut thorough = detected(2);
|
||||
thorough.model_id = "scrfd_10g+w600k_mbf".into();
|
||||
faces::record_detections(
|
||||
&a,
|
||||
dr_types::ImageId(2),
|
||||
"scrfd_10g+w600k_mbf",
|
||||
1024,
|
||||
&[thorough],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut store_a = FaceShardStore::open(&tempdir("a")).unwrap();
|
||||
assert_eq!(
|
||||
export_to_shards(&a, &mut store_a, "scrfd_10g+w600k_mbf").unwrap(),
|
||||
2,
|
||||
"the export left the earlier detector's images behind"
|
||||
);
|
||||
|
||||
let mut store_b = FaceShardStore::open(&tempdir("b")).unwrap();
|
||||
store_b.merge_shard(&store_a.shard_path(0)).unwrap();
|
||||
assert_eq!(import_from_shards(&b, &store_b, "w600k_mbf").unwrap(), 2);
|
||||
|
||||
assert_eq!(faces::coverage(&b, "w600k_mbf").unwrap().outstanding(), 0);
|
||||
let old = faces::for_image(&b, dr_types::ImageId(90)).unwrap();
|
||||
let new = faces::for_image(&b, dr_types::ImageId(91)).unwrap();
|
||||
assert_eq!(old[0].model_id, "w600k_mbf");
|
||||
assert_eq!(
|
||||
new[0].model_id, "scrfd_10g+w600k_mbf",
|
||||
"adopted under the wrong id"
|
||||
);
|
||||
}
|
||||
|
||||
/// A face a peer embedded without measuring it is adopted all the same,
|
||||
/// and left on this device's quality pass by its missing reading. Refusing
|
||||
/// 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]
|
||||
fn a_peers_unmeasured_faces_are_adopted_and_left_for_the_quality_pass() {
|
||||
let b = device(&[(90, 5001), (91, 5002)]);
|
||||
let mut store = FaceShardStore::open(&tempdir("unmeasured")).unwrap();
|
||||
let shared = |file_id: u64, quality: Option<f32>| SharedFace {
|
||||
@@ -1216,6 +1556,8 @@ mod catalog_round_trip {
|
||||
embedding: vec![1; 1024],
|
||||
crop_px: 180.0,
|
||||
quality,
|
||||
eyes: None,
|
||||
landmarks_dense: Vec::new(),
|
||||
crop: Vec::new(),
|
||||
};
|
||||
store
|
||||
@@ -1225,13 +1567,18 @@ mod catalog_round_trip {
|
||||
.put_image(5002, "w600k_mbf", 2560, &[shared(5002, Some(19.0))])
|
||||
.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();
|
||||
assert_eq!(cov.indexed, 1);
|
||||
assert_eq!(cov.outstanding(), 1, "the unmeasured image was adopted");
|
||||
assert!(faces::for_image(&b, dr_types::ImageId(90))
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
assert_eq!(cov.indexed, 2);
|
||||
assert_eq!(cov.outstanding(), 0, "the unmeasured image was refused");
|
||||
let got = faces::for_image(&b, dr_types::ImageId(90)).unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
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]
|
||||
@@ -1257,6 +1604,59 @@ mod catalog_round_trip {
|
||||
assert_eq!(emb[0].embedding[0], 9, "B's own embedding was overwritten");
|
||||
}
|
||||
|
||||
/// A peer's stronger detector is the re-detection this device would
|
||||
/// otherwise queue for itself. Taking it saves the fetch; the name the
|
||||
/// user confirmed here rides across on the box, as it would locally.
|
||||
#[test]
|
||||
fn a_peers_stronger_pass_replaces_a_weaker_local_one_and_keeps_the_name() {
|
||||
let a = device(&[(1, 5001)]);
|
||||
let b = device(&[(50, 5001)]);
|
||||
|
||||
let ids =
|
||||
faces::record_detections(&b, dr_types::ImageId(50), "w600k_mbf", 1024, &[detected(9)])
|
||||
.unwrap();
|
||||
let anna = faces::create_person(&b, "Anna").unwrap();
|
||||
faces::confirm(&b, ids[0], anna).unwrap();
|
||||
|
||||
let mut thorough = detected(7);
|
||||
thorough.model_id = "scrfd_10g+w600k_mbf".into();
|
||||
let mut second = detected(8);
|
||||
second.model_id = "scrfd_10g+w600k_mbf".into();
|
||||
second.x = 0.6;
|
||||
faces::record_detections(
|
||||
&a,
|
||||
dr_types::ImageId(1),
|
||||
"scrfd_10g+w600k_mbf",
|
||||
1024,
|
||||
&[thorough, second],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut store = FaceShardStore::open(&tempdir("upgrade")).unwrap();
|
||||
export_to_shards(&a, &mut store, "scrfd_10g+w600k_mbf").unwrap();
|
||||
assert_eq!(import_from_shards(&b, &store, "w600k_mbf").unwrap(), 1);
|
||||
|
||||
let got = faces::for_image(&b, dr_types::ImageId(50)).unwrap();
|
||||
assert_eq!(got.len(), 2, "the stronger pass was not adopted");
|
||||
let named = got
|
||||
.iter()
|
||||
.find(|f| f.person == Some(anna))
|
||||
.expect("the name was lost");
|
||||
assert!(named.confirmed);
|
||||
assert_eq!(named.model_id, "scrfd_10g+w600k_mbf");
|
||||
|
||||
// And never downwards: A on the fast detector keeps B's thorough faces.
|
||||
let mut store_b = FaceShardStore::open(&tempdir("downgrade")).unwrap();
|
||||
faces::record_detections(&b, dr_types::ImageId(50), "w600k_mbf", 1024, &[detected(9)])
|
||||
.unwrap();
|
||||
export_to_shards(&b, &mut store_b, "w600k_mbf").unwrap();
|
||||
assert_eq!(
|
||||
import_from_shards(&a, &store_b, "scrfd_10g+w600k_mbf").unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(faces::for_image(&a, dr_types::ImageId(1)).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// A device holding a subset of the library takes only its own part.
|
||||
#[test]
|
||||
fn a_device_ignores_faces_for_photographs_it_does_not_have() {
|
||||
@@ -1349,6 +1749,8 @@ mod catalog_round_trip {
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: None,
|
||||
eyes: None,
|
||||
landmarks_dense: Vec::new(),
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
}
|
||||
|
||||
+1162
-153
File diff suppressed because it is too large
Load Diff
@@ -61,7 +61,7 @@ pub use collections::{Collection, CollectionKind, TreeRow};
|
||||
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
|
||||
pub use error::CatalogError;
|
||||
pub use face_shard::{FaceShardStore, SharedFace};
|
||||
pub use faces::{Calibration, DetectedFace, Face, FaceId, Measurement, Person, PersonId};
|
||||
pub use faces::{Calibration, DetectedFace, Face, FaceId, FaceUpdate, Person, PersonId};
|
||||
pub use jobs::{Job, JobKind, Priority};
|
||||
pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword};
|
||||
pub use merge::MergeReport;
|
||||
|
||||
@@ -119,6 +119,8 @@ pub struct MergeReport {
|
||||
pub keywords_fused: usize,
|
||||
/// Keyword assignments taken from the remote.
|
||||
pub keywords_assigned: usize,
|
||||
/// Images whose capture metadata was taken from the remote.
|
||||
pub metadata_adopted: usize,
|
||||
}
|
||||
|
||||
impl MergeReport {
|
||||
@@ -133,6 +135,7 @@ impl MergeReport {
|
||||
|| self.keywords_deleted > 0
|
||||
|| self.keywords_fused > 0
|
||||
|| self.keywords_assigned > 0
|
||||
|| self.metadata_adopted > 0
|
||||
}
|
||||
|
||||
/// 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_keywords_within(&tx, &mut report)?;
|
||||
merge_people_within(&tx, &mut report)?;
|
||||
merge_metadata_within(&tx, &mut report)?;
|
||||
tx.commit()?;
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
///
|
||||
/// The rule this module already follows for the rest of the catalog: user
|
||||
/// judgements travel, inference is rebuilt. Concretely (docs/faces.md, and the
|
||||
/// judgements travel, inference is rebuilt. Concretely (docs/dev/faces.md, and the
|
||||
/// asymmetry `crate::faces` opens with):
|
||||
///
|
||||
/// - **People** — uuid, name, and whether the user set them aside. Merged by
|
||||
@@ -991,9 +1051,13 @@ fn remote_has_column(tx: &Connection, table: &str, column: &str) -> Result<bool,
|
||||
/// Remote face row id to local face row id, by photograph and box overlap.
|
||||
///
|
||||
/// See [`merge_people_within`] for why a face has no shared identity and this
|
||||
/// has to be derived. Only faces from the same model are compared: boxes from
|
||||
/// two different detectors are not the same measurement, and matching across
|
||||
/// them would attach a judgement to a face nobody looked at.
|
||||
/// has to be derived. Faces are compared within an *embedder*
|
||||
/// (`faces::embedder_of`), not within an exact pipeline id: two detectors in
|
||||
/// front of the same embedder draw boxes around the same faces, and a
|
||||
/// confirmation made on one device's box is about the face, not the
|
||||
/// rectangle — the same judgement `faces::record_detections` makes when it
|
||||
/// carries a confirmation across a re-detection. Keying on the exact id was
|
||||
/// what let a detector change strand every name on the device that made it.
|
||||
fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, CatalogError> {
|
||||
/// Loose on purpose — "the same face in the frame", not "the same
|
||||
/// rectangle". The figure `record_detections` uses for the same job.
|
||||
@@ -1026,7 +1090,8 @@ fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, C
|
||||
})?;
|
||||
for row in rows {
|
||||
let (file_id, model, boxed) = row?;
|
||||
local.entry((file_id, model)).or_default().push(boxed);
|
||||
let embedder = crate::faces::embedder_of(&model).to_string();
|
||||
local.entry((file_id, embedder)).or_default().push(boxed);
|
||||
}
|
||||
}
|
||||
if local.is_empty() {
|
||||
@@ -1056,7 +1121,8 @@ fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, C
|
||||
|
||||
for row in rows {
|
||||
let (remote_id, file_id, model, rbox) = row?;
|
||||
let Some(candidates) = local.get(&(file_id, model)) else {
|
||||
let embedder = crate::faces::embedder_of(&model).to_string();
|
||||
let Some(candidates) = local.get(&(file_id, embedder)) else {
|
||||
continue;
|
||||
};
|
||||
let best = candidates
|
||||
@@ -1162,6 +1228,57 @@ mod tests {
|
||||
|
||||
// ---- 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 {
|
||||
attached_remote(schema::for_attached("remote_cat"))
|
||||
}
|
||||
@@ -1977,6 +2094,54 @@ mod tests {
|
||||
assert_eq!(person_of(&c, local), Some(("Anna".to_string(), true)));
|
||||
}
|
||||
|
||||
/// The bug this rule exists for: the desktop switched to a stronger
|
||||
/// detector and confirmed 3,500 faces under the old pipeline id; the
|
||||
/// tablet held the same faces under the new one, and not one name
|
||||
/// crossed, because the match demanded the exact id. Same photograph,
|
||||
/// same box, same embedder — that is the same face.
|
||||
#[test]
|
||||
fn a_confirmation_crosses_a_detector_change() {
|
||||
let c = two_catalogs();
|
||||
for db in ["main", "remote_cat"] {
|
||||
add_synced_image(&c, db, 1, 5000);
|
||||
}
|
||||
let local = add_face(&c, "main", 7, 1, 0.30);
|
||||
c.execute(
|
||||
"UPDATE main.faces SET model_id = 'scrfd_10g+w600k_mbf' WHERE id = ?1",
|
||||
[local],
|
||||
)
|
||||
.unwrap();
|
||||
let remote = add_face(&c, "remote_cat", 42, 1, 0.31);
|
||||
add_person(&c, "remote_cat", 3, "u-anna", "Anna", false);
|
||||
assign(&c, "remote_cat", remote, 3, true);
|
||||
|
||||
let report = merge_all(&c).unwrap();
|
||||
assert_eq!(report.faces_assigned, 1);
|
||||
assert_eq!(person_of(&c, local), Some(("Anna".to_string(), true)));
|
||||
}
|
||||
|
||||
/// A different embedder is a different space, and a box there is a face
|
||||
/// nobody here has a vector for.
|
||||
#[test]
|
||||
fn a_confirmation_does_not_cross_an_embedder_change() {
|
||||
let c = two_catalogs();
|
||||
for db in ["main", "remote_cat"] {
|
||||
add_synced_image(&c, db, 1, 5000);
|
||||
}
|
||||
let local = add_face(&c, "main", 7, 1, 0.30);
|
||||
c.execute(
|
||||
"UPDATE main.faces SET model_id = 'scrfd_10g+other_embedder' WHERE id = ?1",
|
||||
[local],
|
||||
)
|
||||
.unwrap();
|
||||
let remote = add_face(&c, "remote_cat", 42, 1, 0.31);
|
||||
add_person(&c, "remote_cat", 3, "u-anna", "Anna", false);
|
||||
assign(&c, "remote_cat", remote, 3, true);
|
||||
|
||||
merge_all(&c).unwrap();
|
||||
assert_eq!(person_of(&c, local), None, "matched across embedders");
|
||||
}
|
||||
|
||||
/// Boxes from two devices are close but not identical. Matching has to be
|
||||
/// by overlap, not equality, or nothing ever lines up.
|
||||
#[test]
|
||||
|
||||
@@ -301,13 +301,7 @@ fn like_prefix(path: &str) -> String {
|
||||
}
|
||||
|
||||
fn label_code(l: ColourLabel) -> i64 {
|
||||
match l {
|
||||
ColourLabel::Red => 1,
|
||||
ColourLabel::Yellow => 2,
|
||||
ColourLabel::Green => 3,
|
||||
ColourLabel::Blue => 4,
|
||||
ColourLabel::Purple => 5,
|
||||
}
|
||||
crate::rating::label_code(l)
|
||||
}
|
||||
|
||||
fn flag_code(f: FlagState) -> i64 {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
|
||||
use dr_types::{FlagState, ImageId};
|
||||
use dr_types::{ColourLabel, FlagState, ImageId};
|
||||
|
||||
use crate::error::CatalogError;
|
||||
|
||||
@@ -49,6 +49,12 @@ pub struct Judgement {
|
||||
/// 0..=5. Zero means *unrated*, which is a state in its own right.
|
||||
pub rating: u8,
|
||||
pub flag: FlagState,
|
||||
/// TRACES: FR-CAT-5
|
||||
/// The colour label, or `None`. Not part of [`Judgement::is_judged`]:
|
||||
/// a label sorts photographs into piles of the photographer's own
|
||||
/// meaning — "to print", "send to Anna" — and says nothing about whether
|
||||
/// a frame has been culled, which is the question "unjudged" asks.
|
||||
pub label: Option<ColourLabel>,
|
||||
}
|
||||
|
||||
impl Judgement {
|
||||
@@ -267,6 +273,35 @@ pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogEr
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-13
|
||||
/// How `versions.label` encodes a colour label, and back.
|
||||
///
|
||||
/// One place for both directions, so a label written by the XMP pull and a
|
||||
/// label queried by the selector cannot drift apart: the query used to hold
|
||||
/// its own copy of the forward mapping and nothing held the reverse.
|
||||
pub fn label_code(l: ColourLabel) -> i64 {
|
||||
match l {
|
||||
ColourLabel::Red => 1,
|
||||
ColourLabel::Yellow => 2,
|
||||
ColourLabel::Green => 3,
|
||||
ColourLabel::Blue => 4,
|
||||
ColourLabel::Purple => 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour a `versions.label` value names, or `None` for NULL and for a
|
||||
/// code this build does not know.
|
||||
pub fn label_from_code(code: Option<i64>) -> Option<ColourLabel> {
|
||||
Some(match code? {
|
||||
1 => ColourLabel::Red,
|
||||
2 => ColourLabel::Yellow,
|
||||
3 => ColourLabel::Green,
|
||||
4 => ColourLabel::Blue,
|
||||
5 => ColourLabel::Purple,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -358,6 +393,88 @@ pub fn set_flag_many(
|
||||
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
/// Set or clear the colour label for one image.
|
||||
pub fn set_label(
|
||||
conn: &Connection,
|
||||
image: ImageId,
|
||||
label: Option<ColourLabel>,
|
||||
) -> Result<(), CatalogError> {
|
||||
let version = default_version_id(conn, image)?;
|
||||
conn.execute(
|
||||
"UPDATE versions SET label = ?2 WHERE id = ?1",
|
||||
rusqlite::params![version, label.map(label_code)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
/// Set or clear a label on many images in one transaction — one keystroke
|
||||
/// over a selection is one commit, as for [`set_rating_many`].
|
||||
pub fn set_label_many(
|
||||
conn: &Connection,
|
||||
images: &[ImageId],
|
||||
label: Option<ColourLabel>,
|
||||
) -> Result<usize, CatalogError> {
|
||||
apply_many(conn, images, |conn, id| set_label(conn, id, label))
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
/// What a label key does to a set of images: Lightroom's toggle.
|
||||
///
|
||||
/// Pressing the key for the label every one of them already carries takes it
|
||||
/// off; otherwise every one of them gets it. Decided over the whole set
|
||||
/// rather than per image, so a selection that was half red comes out all red
|
||||
/// rather than inverted — the photographer pressed "red", and a key that
|
||||
/// turned half of them red and the other half plain would be two answers to
|
||||
/// one question.
|
||||
pub fn toggled_label(
|
||||
current: impl IntoIterator<Item = Option<ColourLabel>>,
|
||||
pressed: ColourLabel,
|
||||
) -> Option<ColourLabel> {
|
||||
let mut any = false;
|
||||
for label in current {
|
||||
any = true;
|
||||
if label != Some(pressed) {
|
||||
return Some(pressed);
|
||||
}
|
||||
}
|
||||
if any {
|
||||
None
|
||||
} else {
|
||||
Some(pressed)
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||||
/// How the library divides by colour label, for the filter chips' counts.
|
||||
///
|
||||
/// Index 0 is unlabelled and index `n` the label whose code is `n`. One
|
||||
/// grouped statement — the same shape as [`rating_histogram`], and for the
|
||||
/// same reason it LEFT JOINs: an image without a version row is unlabelled,
|
||||
/// not missing.
|
||||
pub fn label_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
|
||||
let mut out = [0usize; 6];
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT coalesce(v.label, 0) AS l, count(*)
|
||||
FROM images i
|
||||
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||||
GROUP BY l",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
for (code, count) in rows.flatten() {
|
||||
// A code this build does not know counts as unlabelled, which is how
|
||||
// `label_from_code` reads it everywhere else.
|
||||
let slot = if label_from_code(Some(code)).is_some() {
|
||||
code as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
out[slot] += count as usize;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Shared bulk wrapper, so the two axes cannot drift in their commit
|
||||
/// behaviour — a partially-committed rating and a fully-committed flag from
|
||||
/// the same keystroke would be hard to explain and harder to notice.
|
||||
@@ -382,21 +499,22 @@ fn apply_many(
|
||||
/// An image with no version reads as unrated and unflagged rather than as an
|
||||
/// error: that is exactly what it is.
|
||||
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
|
||||
let row: Option<(i64, i64)> = conn
|
||||
let row: Option<(i64, i64, Option<i64>)> = conn
|
||||
.query_row(
|
||||
"SELECT rating, flag FROM versions
|
||||
"SELECT rating, flag, label FROM versions
|
||||
WHERE image_id = ?1
|
||||
ORDER BY is_default DESC, id ASC
|
||||
LIMIT 1",
|
||||
[image.0 as i64],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
Ok(match row {
|
||||
Some((rating, flag)) => Judgement {
|
||||
Some((rating, flag, label)) => Judgement {
|
||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||
flag: flag_from_code(flag),
|
||||
label: label_from_code(label),
|
||||
},
|
||||
None => Judgement::default(),
|
||||
})
|
||||
@@ -423,7 +541,7 @@ pub fn judgements(
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!(
|
||||
"SELECT image_id, rating, flag FROM versions
|
||||
"SELECT image_id, rating, flag, label FROM versions
|
||||
WHERE image_id IN ({placeholders}) AND is_default = 1"
|
||||
);
|
||||
|
||||
@@ -438,15 +556,17 @@ pub fn judgements(
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
r.get::<_, Option<i64>>(3)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
for (image, rating, flag) in rows.flatten() {
|
||||
for (image, rating, flag, label) in rows.flatten() {
|
||||
out.insert(
|
||||
ImageId(image as u64),
|
||||
Judgement {
|
||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||
flag: flag_from_code(flag),
|
||||
label: label_from_code(label),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -657,6 +777,72 @@ mod tests {
|
||||
assert_eq!(distinct, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_round_trips_and_clears() {
|
||||
// TRACES: FR-CAT-5
|
||||
let cat = with_images(1);
|
||||
let id = ids(&cat)[0];
|
||||
set_label(cat.connection(), id, Some(ColourLabel::Green)).unwrap();
|
||||
assert_eq!(
|
||||
judgement(cat.connection(), id).unwrap().label,
|
||||
Some(ColourLabel::Green)
|
||||
);
|
||||
set_label(cat.connection(), id, None).unwrap();
|
||||
assert_eq!(judgement(cat.connection(), id).unwrap().label, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_is_not_a_judgement() {
|
||||
// "Unjudged" is the cull's resume point; a label is a pile of the
|
||||
// photographer's own, and labelling a frame must not hide it there.
|
||||
let cat = with_images(1);
|
||||
let id = ids(&cat)[0];
|
||||
set_label(cat.connection(), id, Some(ColourLabel::Red)).unwrap();
|
||||
assert!(!judgement(cat.connection(), id).unwrap().is_judged());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labelling_a_selection_is_one_commit_and_reaches_every_image() {
|
||||
// TRACES: FR-CAT-5
|
||||
let cat = with_images(4);
|
||||
let all = ids(&cat);
|
||||
assert_eq!(
|
||||
set_label_many(cat.connection(), &all, Some(ColourLabel::Blue)).unwrap(),
|
||||
4
|
||||
);
|
||||
let found = judgements(cat.connection(), &all).unwrap();
|
||||
assert!(all
|
||||
.iter()
|
||||
.all(|id| found[id].label == Some(ColourLabel::Blue)));
|
||||
assert_eq!(
|
||||
label_histogram(cat.connection()).unwrap(),
|
||||
[0, 0, 0, 0, 4, 0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_key_toggles_only_when_every_image_already_has_it() {
|
||||
// TRACES: FR-CAT-5
|
||||
use ColourLabel::*;
|
||||
assert_eq!(toggled_label([Some(Red), Some(Red)], Red), None);
|
||||
assert_eq!(toggled_label([Some(Red), None], Red), Some(Red));
|
||||
assert_eq!(toggled_label([Some(Blue)], Red), Some(Red));
|
||||
assert_eq!(toggled_label([], Red), Some(Red));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_label_histogram_sums_to_the_library() {
|
||||
// TRACES: FR-CAT-6
|
||||
// Images without a version row count as unlabelled rather than
|
||||
// vanishing, as the rating histogram's do.
|
||||
let cat = with_images(3);
|
||||
let first = ids(&cat)[0];
|
||||
set_label(cat.connection(), first, Some(ColourLabel::Purple)).unwrap();
|
||||
let h = label_histogram(cat.connection()).unwrap();
|
||||
assert_eq!(h, [2, 0, 0, 0, 0, 1]);
|
||||
assert_eq!(h.iter().sum::<usize>(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rating_round_trips() {
|
||||
let cat = with_images(1);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! # The one thing a rebuild does not recover
|
||||
//!
|
||||
//! **Collections.** A manual collection is a set of images the user assembled
|
||||
//! by hand and nothing in the filesystem records it (`docs/catalog.md` §8.1) —
|
||||
//! by hand and nothing in the filesystem records it (`docs/dev/catalog.md` §8.1) —
|
||||
//! which is the whole reason the catalog file itself syncs. So the two offers
|
||||
//! are not interchangeable, and the interface must not present them as if they
|
||||
//! were: a restore keeps the user's collections, a rebuild does not.
|
||||
@@ -198,6 +198,54 @@ pub fn backup_before_migration(conn: &Connection, catalog: &Path) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long a catalog may go without a backup before the next opportunity
|
||||
/// takes one.
|
||||
///
|
||||
/// A day. The catalog is an index, so what a backup protects is the day's
|
||||
/// worth of collection and people edits the sidecars do not hold — and a
|
||||
/// second copy of a 130 MB file per launch would be a cost with nothing to
|
||||
/// show for it when the user launches four times in an afternoon.
|
||||
pub const BACKUP_EVERY: i64 = 24 * 60 * 60;
|
||||
|
||||
/// Whether [`BACKUP_EVERY`] has passed since the newest backup, or there is
|
||||
/// none.
|
||||
///
|
||||
/// Read from the filenames, like [`backups`], so a restored or copied backup
|
||||
/// directory answers the same way it did on the machine it came from.
|
||||
pub fn backup_due(catalog: &Path) -> bool {
|
||||
match backups(catalog).first() {
|
||||
Some(newest) => now() - newest.taken_at >= BACKUP_EVERY,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: NFR-R2
|
||||
/// Take the scheduled backup, if one is due. Returns the file written, or
|
||||
/// `None` when the newest is recent enough.
|
||||
///
|
||||
/// The scheduled half of NFR-R2 — the migration half is
|
||||
/// [`backup_before_migration`]. "On a schedule" for an application that runs
|
||||
/// when the user opens it means "at the next chance after a day has passed",
|
||||
/// and the chance the caller picks is the end of a library sweep: the
|
||||
/// catalog is quiet, the work is already off the UI thread, and it is the
|
||||
/// moment a day's edits have just been consolidated.
|
||||
///
|
||||
/// A brand-new catalog with no images is not backed up: there is nothing in
|
||||
/// it yet that a rescan would not rebuild, and the first backup would only be
|
||||
/// a copy of an empty schema.
|
||||
pub fn backup_if_due(conn: &Connection, catalog: &Path) -> Result<Option<PathBuf>, CatalogError> {
|
||||
if !backup_due(catalog) {
|
||||
return Ok(None);
|
||||
}
|
||||
let images: i64 = conn.query_row("SELECT count(*) FROM images", [], |r| r.get(0))?;
|
||||
if images == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = backup(conn, catalog)?;
|
||||
log::info!("scheduled backup of the catalog to {}", path.display());
|
||||
Ok(Some(path))
|
||||
}
|
||||
|
||||
/// The backups available for `catalog`, newest first.
|
||||
///
|
||||
/// Never fails: an unreadable or absent backup directory means there are no
|
||||
@@ -386,6 +434,49 @@ mod tests {
|
||||
base
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scheduled_backup_is_taken_once_a_day_and_not_more() {
|
||||
let dir = tempdir("scheduled");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
fixture(&path, 3);
|
||||
let cat = Catalog::open(&path).unwrap();
|
||||
|
||||
// Nothing yet: due.
|
||||
assert!(backup_due(&path));
|
||||
let first = backup_if_due(cat.connection(), &path).unwrap();
|
||||
assert!(first.is_some(), "the first opportunity takes one");
|
||||
|
||||
// Taken just now: not due, and a second call does nothing.
|
||||
assert!(!backup_due(&path));
|
||||
assert_eq!(backup_if_due(cat.connection(), &path).unwrap(), None);
|
||||
assert_eq!(backups(&path).len(), 1);
|
||||
|
||||
// Age the one backup past the interval by renaming it, since the
|
||||
// timestamp is read from the name. Now it is due again.
|
||||
let old = first.unwrap();
|
||||
let aged = old
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join(format!("catalog-{}.sqlite", now() - BACKUP_EVERY - 1));
|
||||
std::fs::rename(&old, &aged).unwrap();
|
||||
assert!(backup_due(&path));
|
||||
assert!(backup_if_due(cat.connection(), &path).unwrap().is_some());
|
||||
assert_eq!(backups(&path).len(), 2);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_catalog_is_not_worth_backing_up() {
|
||||
let dir = tempdir("empty");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
let cat = Catalog::open(&path).unwrap();
|
||||
assert!(backup_due(&path), "due in principle");
|
||||
assert_eq!(backup_if_due(cat.connection(), &path).unwrap(), None);
|
||||
assert!(backups(&path).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A catalog on disk with enough rows to span several pages, closed.
|
||||
///
|
||||
/// Closed matters: WAL means the rows are in `catalog.sqlite-wal` until
|
||||
@@ -479,7 +570,7 @@ mod tests {
|
||||
// The first NFR-R6 branch, asserted on the thing that distinguishes it
|
||||
// from the second: a collection exists nowhere but the catalog, so it
|
||||
// is the evidence that the *contents* came back and not merely a
|
||||
// readable file (docs/catalog.md §8.1).
|
||||
// readable file (docs/dev/catalog.md §8.1).
|
||||
let dir = tempdir("restore");
|
||||
let path = dir.join("catalog.sqlite");
|
||||
fixture(&path, 500);
|
||||
|
||||
+505
-14
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// Schema version this build writes and understands.
|
||||
pub const SCHEMA_VERSION: i64 = 14;
|
||||
pub const SCHEMA_VERSION: i64 = 20;
|
||||
|
||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
@@ -136,9 +136,164 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 15 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V15)?;
|
||||
tx.pragma_update(None, "user_version", 15)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 16 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
// Guarded like V14's column, and for the same reason: `ALTER TABLE
|
||||
// ... ADD COLUMN` has no `IF NOT EXISTS`, and this step must be
|
||||
// re-enterable (NFR-R5).
|
||||
for column in EYE_COLUMNS {
|
||||
let present: bool = tx
|
||||
.prepare("SELECT 1 FROM pragma_table_info('faces') WHERE name = ?1")?
|
||||
.exists([column])?;
|
||||
if !present {
|
||||
tx.execute_batch(&format!("ALTER TABLE faces ADD COLUMN {column} REAL;"))?;
|
||||
}
|
||||
}
|
||||
tx.pragma_update(None, "user_version", 16)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 17 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V17)?;
|
||||
tx.pragma_update(None, "user_version", 17)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 18 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
// Guarded like V14's and V16's columns: ALTER has no IF NOT EXISTS
|
||||
// and the step must be re-enterable (NFR-R5).
|
||||
let present: bool = tx
|
||||
.prepare("SELECT 1 FROM pragma_table_info('faces') WHERE name = 'landmarks_dense'")?
|
||||
.exists([])?;
|
||||
if !present {
|
||||
tx.execute_batch("ALTER TABLE faces ADD COLUMN landmarks_dense BLOB;")?;
|
||||
}
|
||||
tx.pragma_update(None, "user_version", 18)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 19 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V19)?;
|
||||
tx.pragma_update(None, "user_version", 19)?;
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
///
|
||||
/// Named once because three places have to agree on them: this migration,
|
||||
/// [`for_attached`], and the face shard's own catch-up (`face_shard`).
|
||||
pub const EYE_COLUMNS: [&str; 7] = [
|
||||
"eye_right",
|
||||
"eye_right_px",
|
||||
"eye_right_sharp",
|
||||
"eye_left",
|
||||
"eye_left_px",
|
||||
"eye_left_sharp",
|
||||
"sunglasses",
|
||||
];
|
||||
|
||||
/// Recompute columns a migration added, for rows that predate it.
|
||||
///
|
||||
/// A migration adds a column with a default; it cannot know what the value
|
||||
@@ -201,11 +356,38 @@ pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, Catalog
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How long a connection waits for a writer to finish before giving up.
|
||||
///
|
||||
/// TRACES: NFR-R1
|
||||
/// SQLite's default is **zero** — the loser of a race gets `SQLITE_BUSY` at
|
||||
/// once rather than a turn — and WAL does not change that for two writers. One
|
||||
/// writer and many readers is the case WAL makes free; this is the other one,
|
||||
/// and this application has it constantly: the face sweep commits a batch while
|
||||
/// reclustering reads, the derived sync imports shards while the sweep writes.
|
||||
///
|
||||
/// Without a timeout that contention was *lost work*, not a retry. A face
|
||||
/// sweep that had already paid for the detection and the embedding — the
|
||||
/// expensive part, seconds per image — threw the result away on
|
||||
/// `storing faces for 214: database is locked` and moved on, and both the
|
||||
/// desktop and the tablet logged runs of those on consecutive images.
|
||||
///
|
||||
/// Ten seconds, matching the figure the job runner's tests already use for the
|
||||
/// same reason. It is far longer than any transaction here (a sweep batch is
|
||||
/// sub-second; the slowest is a WAL checkpoint of a 130 MB catalog), so in
|
||||
/// practice it is a bound on pathology rather than a wait anyone sits through.
|
||||
/// The tension with NFR-P9 is real but one-sided: a query on the UI thread
|
||||
/// would rather wait for its turn than fail, because the failure is what the
|
||||
/// user sees as "cannot open catalog".
|
||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Connection setup applied on every open, migration or not.
|
||||
///
|
||||
/// WAL is required by NFR-R1: it survives power loss without corruption, and
|
||||
/// it lets a background job write while the grid reads.
|
||||
pub fn configure(conn: &Connection) -> Result<(), CatalogError> {
|
||||
// Before the pragmas, so that a connection racing a migration waits for it
|
||||
// rather than failing on the first statement it tries.
|
||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
// NORMAL rather than FULL: with WAL this is durable across process death
|
||||
// (which is what FR-PLAT-AND-3 cares about) and only risks the last
|
||||
@@ -269,7 +451,15 @@ pub fn for_attached(schema_name: &str) -> String {
|
||||
"{}\n{}\n{}\n\
|
||||
ALTER TABLE {schema_name}.people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN crop BLOB;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN quality REAL;",
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN quality REAL;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN eye_right REAL;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN eye_right_px REAL;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN eye_right_sharp REAL;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN eye_left REAL;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN eye_left_px REAL;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN eye_left_sharp REAL;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN sunglasses REAL;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN landmarks_dense BLOB;",
|
||||
rewrite_for_attached(V1, schema_name),
|
||||
rewrite_for_attached(V6, schema_name),
|
||||
rewrite_for_attached(V8, schema_name),
|
||||
@@ -607,20 +797,20 @@ const V14: &str = r#"
|
||||
-- face this rule is not yet protecting anyone from, and the only way to
|
||||
-- measure it is to embed it again.
|
||||
--
|
||||
-- The sweep's measuring pass is what does that: `dr_ui::library::
|
||||
-- faces_unmeasured` lists every image holding a face with no reading, and
|
||||
-- each face is embedded again from the native render with the landmarks it
|
||||
-- already has, the raw vector written over the old one (`record_measurements`)
|
||||
-- and nothing else touched -- not the id, not the box, not who the user said
|
||||
-- it was. The faces keep drawing the People screen throughout.
|
||||
-- The `face-quality` repair is what does that (`dr_ui::repairs`, once the
|
||||
-- sweep's measuring pass): it lists every face with no reading, and each is
|
||||
-- embedded again from the native render with the landmarks it already has,
|
||||
-- the raw vector written over the old one (`record_updates`) and nothing
|
||||
-- else touched -- not the id, not the box, not who the user said it was.
|
||||
-- The faces keep drawing the People screen throughout.
|
||||
--
|
||||
-- The run markers of those images are forgotten too, exactly as V12 forgot
|
||||
-- the runs made against too small a proxy. The build this shipped in had no
|
||||
-- measuring pass yet, and a marker is the one thing that stops a face ever
|
||||
-- being looked at again; with the pass in place `faces_unindexed` leaves
|
||||
-- these images to it rather than detecting them from scratch, so the
|
||||
-- deletion costs nothing -- and an image that was examined and found empty
|
||||
-- keeps its marker, since there is nothing on it to measure.
|
||||
-- being looked at again; with the repair in place, detection leaves an
|
||||
-- image holding this embedder's faces to it rather than detecting from
|
||||
-- scratch, so the deletion costs nothing -- and an image that was examined
|
||||
-- and found empty keeps its marker, since there is nothing on it to measure.
|
||||
--
|
||||
-- The cost is a re-fetch of every image with a face on it, on the next pass
|
||||
-- the user starts. That is a whole-library transfer (FR-NC-6), and it starts
|
||||
@@ -642,6 +832,140 @@ DELETE FROM face_index
|
||||
AND f.model_id = face_index.model_id);
|
||||
"#;
|
||||
|
||||
const V15: &str = r#"
|
||||
-- TRACES: FR-CAT-13
|
||||
-- Where a standard XMP sidecar and the catalog disagree.
|
||||
--
|
||||
-- An `.xmp` beside a photograph is read on the same pull as DarkRoom's own
|
||||
-- sidecar, and reconciled field by field (`dr_xmp::reconcile`): keywords
|
||||
-- union, and a rating, label or caption is taken only where the catalog holds
|
||||
-- none. That rule is the safe one and it is not always the right one -- a
|
||||
-- rating changed in Lightroom after it was changed here is a genuine
|
||||
-- disagreement, and a standard XMP carries no revision to settle it by. So
|
||||
-- the disagreement is written here instead of being resolved, and the
|
||||
-- requirement's "a metadata reload offered" is a row in this table with a
|
||||
-- button in front of it: the reload re-reads the file with the sidecar
|
||||
-- winning, and deletes the row.
|
||||
--
|
||||
-- Keyed on the sidecar's path like `sidecars` is, and for the same reason: a
|
||||
-- path is what the scan reports, what a fetch addresses, and what the ETag
|
||||
-- that noticed the change belongs to. `fields` is the disagreeing fields as
|
||||
-- `dr_xmp` names them, space-separated, for the line the settings page shows.
|
||||
--
|
||||
-- Rebuildable: the next pull that sees a changed ETag writes the row again.
|
||||
CREATE TABLE IF NOT EXISTS xmp_conflicts (
|
||||
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
fields TEXT NOT NULL,
|
||||
seen_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(root_id, path)
|
||||
);
|
||||
"#;
|
||||
|
||||
// V19 -- TRACES: NFR-P9
|
||||
//
|
||||
// The indexes the repair counts are served from, and V17's lesson applied
|
||||
// to the rest of the face columns.
|
||||
//
|
||||
// "How many images still owe a quality reading" was answered per image: a
|
||||
// correlated EXISTS over `faces` that had to open each face's row to look
|
||||
// at one nullable column -- the row being eight kilobytes of embedding and
|
||||
// crop. Six such counts run every time the Identity screen opens and every
|
||||
// time a sweep ends, 160 ms of them on the reference library. Three
|
||||
// partial indexes hold only the faces still owing each pass, keyed by the
|
||||
// image and carrying the model id the predicate also reads, so the count
|
||||
// walks a few thousand index entries and touches no row at all -- and each
|
||||
// index shrinks to nothing as its pass completes. The planner takes them
|
||||
// when the count is driven from `faces` (`repairs::count`) and ignores
|
||||
// them inside the per-image EXISTS, which is why that function has two
|
||||
// spellings of the same predicate.
|
||||
//
|
||||
// `faces_image_model` replaces `faces_image`: the same key with the model
|
||||
// id beside it, so "does this image hold this embedder's faces" -- asked in
|
||||
// the audit, the proxy repair and the outstanding-detection count -- is an
|
||||
// index-only probe where it used to read the row for the model id. Every
|
||||
// lookup that used `faces_image` is served by its prefix.
|
||||
//
|
||||
// Not applied to attached catalogs, like V7 and V17: an index is a local
|
||||
// concern, and a merge never runs these queries across an attachment.
|
||||
|
||||
const V19: &str = r#"
|
||||
CREATE INDEX IF NOT EXISTS faces_image_model ON faces(image_id, model_id);
|
||||
DROP INDEX IF EXISTS faces_image;
|
||||
CREATE INDEX IF NOT EXISTS faces_owed_quality ON faces(image_id, model_id)
|
||||
WHERE quality IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS faces_owed_crop ON faces(image_id, model_id)
|
||||
WHERE crop IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS faces_owed_eyes ON faces(image_id, model_id)
|
||||
WHERE eye_right IS NULL OR landmarks_dense IS NULL;
|
||||
"#;
|
||||
|
||||
// V18 -- TRACES: FR-CULL-8a | FR-CULL-12
|
||||
//
|
||||
// The 106 dense landmarks the eye pass reads its eye boxes from, kept beside
|
||||
// the reading as `dr_face::Landmarks::to_packed_bytes`: 106 x (x, y) as
|
||||
// 16-bit fixed point over the frame, 424 bytes a face, a seventh of a
|
||||
// pixel on a 6000-pixel frame. Derived data under FR-CULL-12 -- rebuilt by
|
||||
// re-reading, never in a sidecar -- and stored for the same reason the
|
||||
// embedding is: it cost a fetch of the original and a model run, and the
|
||||
// next per-face pass (head pose, expression) should not have to pay either
|
||||
// again. NULL where the face was never read.
|
||||
//
|
||||
// Added in `migrate`, guarded, like every ALTER here (NFR-R5).
|
||||
|
||||
const V17: &str = r#"
|
||||
-- TRACES: FR-CULL-8a | FR-CULL-13 | NFR-P9
|
||||
-- The eyes-open filter's index, and a lesson about where a column lands.
|
||||
--
|
||||
-- The people filter is a correlated EXISTS over `faces` per image, and it
|
||||
-- was fast because `faces_image` *covers* it: the subquery never touched a
|
||||
-- row. Reading V16's seven eye columns in the same subquery did touch the
|
||||
-- row -- and `ALTER TABLE ADD COLUMN` puts a column at the end of the
|
||||
-- record, after the 1 KB embedding and the ~5 KB crop, so every check
|
||||
-- dragged six kilobytes off disk to reach seven floats. Measured on the
|
||||
-- reference library: 24 seconds for one count, thirteen of them system
|
||||
-- time. With this index the same count takes five milliseconds, because
|
||||
-- the subquery is served from the index again and never reads a row.
|
||||
--
|
||||
-- The columns are listed in EYE_COLUMNS' order behind `image_id`, which is
|
||||
-- the key the subquery searches on. Nothing else changed in V17; a catalog
|
||||
-- already at V16 needs only this.
|
||||
CREATE INDEX IF NOT EXISTS faces_eyes ON faces(
|
||||
image_id, eye_right, eye_right_px, eye_right_sharp,
|
||||
eye_left, eye_left_px, eye_left_sharp, sunglasses
|
||||
);
|
||||
"#;
|
||||
|
||||
// V16 -- TRACES: FR-CULL-8a
|
||||
//
|
||||
// What each face's eyes are doing: for each eye P(open), the source pixels
|
||||
// across its box and the sharpness of the patch the classifier saw; and
|
||||
// P(sunglasses) for the head. Seven numbers rather than a verdict, because
|
||||
// the verdict is a rule with thresholds in it (dr_face::eyes::EyeReading::
|
||||
// state) and a rule belongs in code that can be changed, not in rows that
|
||||
// would have to be re-measured.
|
||||
//
|
||||
// The pixels and the sharpness are what stop a smear reading as a blink: an
|
||||
// eye too small or too soft to read is not asked, and a face with no
|
||||
// readable eye is "unclear", which no filter drops. Sunglasses are a column
|
||||
// of their own for the same kind of reason — the eye classifier answers
|
||||
// confidently over dark glass, and its answer means nothing there. A filter
|
||||
// for "eyes open" reads all seven.
|
||||
//
|
||||
// NULL means "never measured" -- a face indexed before this version, or on a
|
||||
// device without the eye models -- and a NULL is left alone by every filter
|
||||
// that reads these, so an old library does not empty its grid the moment the
|
||||
// chip is pressed. The sweep's measuring pass fills them in, from the native
|
||||
// render, with the landmarks already stored: the same pass V14 built for the
|
||||
// embedding's length, extended to ask the eye models too. No run marker is
|
||||
// forgotten here, for the reason V14's note gives -- the measuring pass
|
||||
// finds its own work by the NULL, and deleting markers would only put the
|
||||
// detector back over images it has finished with.
|
||||
//
|
||||
// The columns are added in `migrate`, guarded, because ALTER has no IF NOT
|
||||
// EXISTS and the step has to be re-enterable (NFR-R5). Their names are
|
||||
// `EYE_COLUMNS`.
|
||||
|
||||
const V9: &str = r#"
|
||||
-- TRACES: FR-CULL-8
|
||||
-- A record that face detection has *run* on an image, distinct from what it
|
||||
@@ -685,7 +1009,7 @@ CREATE INDEX face_index_model ON face_index(model_id);
|
||||
|
||||
const V8: &str = r#"
|
||||
-- TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
|
||||
-- People and faces (docs/faces.md, docs/catalog.md §10).
|
||||
-- People and faces (docs/dev/faces.md, docs/dev/catalog.md §10).
|
||||
--
|
||||
-- Everything here is **derived data** except one column. Faces, landmarks,
|
||||
-- embeddings, cluster assignments and suggestions are all reproducible by
|
||||
@@ -722,7 +1046,7 @@ CREATE TABLE faces (
|
||||
landmarks BLOB NOT NULL, -- 5 x (x, y) f32, normalised likewise
|
||||
detector_confidence REAL NOT NULL,
|
||||
embedding BLOB NOT NULL, -- 512 x f16; unit length until V14, raw since
|
||||
-- Source pixels across the aligned 112x112 crop (docs/faces.md §7).
|
||||
-- Source pixels across the aligned 112x112 crop (docs/dev/faces.md §7).
|
||||
--
|
||||
-- Not cosmetic: it is the honest quality signal for the UI, a feature in
|
||||
-- the §8 calibration -- FR-CULL-9 names face size as an axis along which an
|
||||
@@ -1111,6 +1435,60 @@ CREATE INDEX jobs_ready ON jobs(state, priority DESC, not_before);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_writer_waits_for_its_turn_rather_than_losing_its_work() {
|
||||
// The failure this exists for: a face sweep that had already paid for
|
||||
// the detection and the embedding threw the result away on
|
||||
// "database is locked" and moved on. WAL does not help here — it makes
|
||||
// one writer and many readers free, and this is two writers.
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"dr-busy-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("catalog.sqlite");
|
||||
|
||||
let held = rusqlite::Connection::open(&path).unwrap();
|
||||
configure(&held).unwrap();
|
||||
migrate(&held).unwrap();
|
||||
|
||||
let other = rusqlite::Connection::open(&path).unwrap();
|
||||
configure(&other).unwrap();
|
||||
|
||||
// Every connection carries the timeout, which is what makes the wait
|
||||
// below a wait rather than an immediate error.
|
||||
let timeout: i64 = other
|
||||
.query_row("PRAGMA busy_timeout", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(timeout, BUSY_TIMEOUT.as_millis() as i64);
|
||||
|
||||
// A writer holds the database; the other one must still get its turn
|
||||
// once the first commits, rather than failing at the moment it asks.
|
||||
let writing = held.unchecked_transaction().unwrap();
|
||||
held.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
other.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (2, 'local', 'two')",
|
||||
[],
|
||||
)
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
writing.commit().unwrap();
|
||||
|
||||
assert!(
|
||||
handle.join().unwrap().is_ok(),
|
||||
"the second writer waited and then wrote, rather than erroring"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
use super::*;
|
||||
|
||||
fn mem() -> Connection {
|
||||
@@ -1398,6 +1776,35 @@ mod tests {
|
||||
assert_eq!(migrate(&c).unwrap(), SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
/// V16 adds its columns guarded, so a catalog whose version was rewound
|
||||
/// after the columns landed — the rollback NFR-R5 contemplates — migrates
|
||||
/// again rather than failing on "duplicate column".
|
||||
#[test]
|
||||
fn the_eye_columns_survive_a_rewound_version() {
|
||||
let c = mem();
|
||||
migrate(&c).unwrap();
|
||||
for column in EYE_COLUMNS {
|
||||
let present: bool = c
|
||||
.prepare("SELECT 1 FROM pragma_table_info('faces') WHERE name = ?1")
|
||||
.unwrap()
|
||||
.exists([column])
|
||||
.unwrap();
|
||||
assert!(present, "{column} missing after migration");
|
||||
}
|
||||
c.pragma_update(None, "user_version", 15).unwrap();
|
||||
assert_eq!(migrate(&c).unwrap(), 15);
|
||||
let indexed: bool = c
|
||||
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'faces_eyes'")
|
||||
.unwrap()
|
||||
.exists([])
|
||||
.unwrap();
|
||||
assert!(indexed, "V17's covering index is there");
|
||||
let v: i64 = c
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_catalog_from_a_newer_build() {
|
||||
let c = mem();
|
||||
@@ -1536,6 +1943,90 @@ mod tests {
|
||||
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]
|
||||
fn job_uniqueness_coalesces_rather_than_duplicating() {
|
||||
let c = mem();
|
||||
|
||||
@@ -54,9 +54,31 @@ pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> {
|
||||
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
|
||||
let out = copy_to(conn, dest)?;
|
||||
strip_face_crops(&out)?;
|
||||
verify_snapshot(&out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: NFR-R2
|
||||
/// Refuse to hand over a snapshot that will not pass `quick_check`.
|
||||
///
|
||||
/// The upload is the copy every other device merges from, and a damaged one
|
||||
/// costs far more than the check: each device downloads it, fails, and — for
|
||||
/// a week, once — declines to push over it. `quick_check` reads every page
|
||||
/// but skips index verification, which is the affordable version of "is this
|
||||
/// a database" on a 40 MB file that has just been written and is still in the
|
||||
/// page cache. A failure here is [`CatalogError::Corrupt`], the same thing a
|
||||
/// receiving device would have said, so the sync reports it the same way.
|
||||
fn verify_snapshot(snapshot: &Connection) -> Result<(), CatalogError> {
|
||||
let verdict: String = snapshot.query_row("PRAGMA quick_check", [], |r| r.get(0))?;
|
||||
if verdict == "ok" {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CatalogError::Corrupt {
|
||||
detail: format!("the snapshot for upload failed quick_check: {verdict}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Checkpoint, then copy the whole database to `dest`, and hand back the
|
||||
/// connection to the copy.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! TRACES: S15 | FR-MRG-3
|
||||
//! Spike S15.1 — does rawler read back a linear DNG this application writes?
|
||||
//!
|
||||
//! cargo run -p dr-decode --example linear_dng [-- <out.dng>]
|
||||
//!
|
||||
//! Decides FR-MRG-3's container. A panorama composite is three linear samples
|
||||
//! per pixel with a camera matrix attached, which is exactly what a
|
||||
//! `LinearRaw` DNG is; if rawler parses one, the composite re-enters the
|
||||
//! library as `Format::Dng` and the only new decode work is a `cpp == 3`
|
||||
//! branch. If it does not, the container is a float TIFF with a decode path
|
||||
//! of its own.
|
||||
//!
|
||||
//! The file is hand-rolled rather than written with the `tiff` crate, whose
|
||||
//! encoder fixes `PhotometricInterpretation` to RGB and cannot say
|
||||
//! `LinearRaw`. Eighty lines of IFD is the cheaper thing to own than a fork.
|
||||
|
||||
use rawler::rawsource::RawSource;
|
||||
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 48;
|
||||
|
||||
fn main() {
|
||||
let bytes = write_linear_dng(W, H);
|
||||
if let Some(path) = std::env::args().nth(1) {
|
||||
std::fs::write(&path, &bytes).expect("write");
|
||||
println!("wrote {path} ({} bytes)", bytes.len());
|
||||
}
|
||||
|
||||
let source = RawSource::new_from_slice(&bytes);
|
||||
let decoder = match rawler::get_decoder(&source) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
println!("FAIL get_decoder: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
println!("ok decoder found");
|
||||
|
||||
let image = match decoder.raw_image(&source, &Default::default(), false) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
println!("FAIL raw_image: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"ok raw_image: {}×{}, cpp {}, bps {}, {} samples, make {:?} model {:?}",
|
||||
image.width,
|
||||
image.height,
|
||||
image.cpp,
|
||||
image.bps,
|
||||
match &image.data {
|
||||
rawler::RawImageData::Integer(v) => v.len(),
|
||||
rawler::RawImageData::Float(v) => v.len(),
|
||||
},
|
||||
image.make,
|
||||
image.model
|
||||
);
|
||||
println!(
|
||||
" white {:?} black {:?} wb {:?}",
|
||||
image.whitelevel.0,
|
||||
image
|
||||
.blacklevel
|
||||
.levels
|
||||
.iter()
|
||||
.map(|r| r.n as f32 / r.d.max(1) as f32)
|
||||
.collect::<Vec<_>>(),
|
||||
image.wb_coeffs
|
||||
);
|
||||
|
||||
// The pixel at (1, 0) was written as (1000, 2000, 3000): if the samples
|
||||
// come back interleaved in that order, cpp == 3 means what it says.
|
||||
if let rawler::RawImageData::Integer(v) = &image.data {
|
||||
let i = image.cpp;
|
||||
println!(" pixel (1,0) = {:?}", &v[i..i + image.cpp.min(3)]);
|
||||
}
|
||||
|
||||
// What dr-decode itself makes of it: the colour matrix rawler parsed into
|
||||
// the camera definition, and the profile the decoder would build from it.
|
||||
println!(" rawler color_matrix: {:?}", image.camera.color_matrix);
|
||||
let dng = dr_decode::profile::read_dng_matrices(decoder.as_ref());
|
||||
let profile = dr_decode::CameraProfile::extract(&image, &dng);
|
||||
println!(
|
||||
" CameraProfile: {}",
|
||||
profile
|
||||
.as_ref()
|
||||
.map(|p| format!("xyz_to_cam {:?}", p.xyz_to_cam()))
|
||||
.unwrap_or_else(|| "none".into())
|
||||
);
|
||||
|
||||
match dr_decode::decode(&bytes) {
|
||||
Ok(r) => println!(
|
||||
"note dr_decode::decode accepted it as CFA: {}×{}, {} samples — the cpp==3 branch is the work",
|
||||
r.width,
|
||||
r.height,
|
||||
r.data.len()
|
||||
),
|
||||
Err(e) => println!("note dr_decode::decode refused it: {e} — the cpp==3 branch is the work"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal `LinearRaw` DNG: one IFD, uncompressed 16-bit RGB, the tags a
|
||||
/// decoder needs to treat it as a DNG and the matrix a develop chain needs
|
||||
/// to treat it as a camera. Little-endian, one strip.
|
||||
fn write_linear_dng(w: u32, h: u32) -> Vec<u8> {
|
||||
// Pixels first, so their offset is known: a ramp with one marker pixel.
|
||||
let mut pixels: Vec<u16> = Vec::with_capacity((w * h * 3) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
if (x, y) == (1, 0) {
|
||||
pixels.extend([1000, 2000, 3000]);
|
||||
} else {
|
||||
let v = ((x + y) * 512).min(65535) as u16;
|
||||
pixels.extend([v, v / 2, v / 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let pixel_bytes: Vec<u8> = pixels.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
|
||||
// Layout: header (8) | pixels | extra data | IFD.
|
||||
let pixels_off = 8u32;
|
||||
let extra_off = pixels_off + pixel_bytes.len() as u32;
|
||||
|
||||
// Values that do not fit in four bytes go in `extra`, and the entry
|
||||
// points at them.
|
||||
let mut extra: Vec<u8> = Vec::new();
|
||||
let mut entries: Vec<(u16, u16, u32, [u8; 4])> = Vec::new();
|
||||
|
||||
fn short(tag: u16, v: u16) -> (u16, u16, u32, [u8; 4]) {
|
||||
let mut b = [0u8; 4];
|
||||
b[..2].copy_from_slice(&v.to_le_bytes());
|
||||
(tag, 3, 1, b)
|
||||
}
|
||||
fn long(tag: u16, v: u32) -> (u16, u16, u32, [u8; 4]) {
|
||||
(tag, 4, 1, v.to_le_bytes())
|
||||
}
|
||||
fn ascii(extra: &mut Vec<u8>, extra_off: u32, tag: u16, s: &str) -> (u16, u16, u32, [u8; 4]) {
|
||||
let mut bytes = s.as_bytes().to_vec();
|
||||
bytes.push(0);
|
||||
let off = extra_off + extra.len() as u32;
|
||||
extra.extend(&bytes);
|
||||
(tag, 2, bytes.len() as u32, off.to_le_bytes())
|
||||
}
|
||||
|
||||
entries.push(long(254, 0)); // NewSubfileType: main image
|
||||
entries.push(long(256, w));
|
||||
entries.push(long(257, h));
|
||||
// BitsPerSample ×3 — three shorts, six bytes, so out of line.
|
||||
{
|
||||
let off = extra_off + extra.len() as u32;
|
||||
for _ in 0..3 {
|
||||
extra.extend(16u16.to_le_bytes());
|
||||
}
|
||||
entries.push((258, 3, 3, off.to_le_bytes()));
|
||||
}
|
||||
entries.push(short(259, 1)); // Compression: none
|
||||
entries.push(short(262, 34892)); // PhotometricInterpretation: LinearRaw
|
||||
entries.push(ascii(&mut extra, extra_off, 271, "DarkRoom"));
|
||||
entries.push(ascii(&mut extra, extra_off, 272, "Panorama"));
|
||||
entries.push(long(273, pixels_off)); // StripOffsets
|
||||
entries.push(short(274, 1)); // Orientation
|
||||
entries.push(short(277, 3)); // SamplesPerPixel
|
||||
entries.push(long(278, h)); // RowsPerStrip
|
||||
entries.push(long(279, pixel_bytes.len() as u32)); // StripByteCounts
|
||||
entries.push(short(284, 1)); // PlanarConfiguration: chunky
|
||||
entries.push((50706, 1, 4, [1, 4, 0, 0])); // DNGVersion
|
||||
entries.push((50707, 1, 4, [1, 4, 0, 0])); // DNGBackwardVersion
|
||||
entries.push(ascii(&mut extra, extra_off, 50708, "DarkRoom Panorama")); // UniqueCameraModel
|
||||
entries.push(long(50717, 65535)); // WhiteLevel
|
||||
|
||||
// ColorMatrix1: XYZ → camera, 9 SRATIONALs. A plausible sRGB-ish matrix
|
||||
// (the inverse of the sRGB D65 primaries), scaled to integers.
|
||||
{
|
||||
let m: [(i32, i32); 9] = [
|
||||
(32406, 10000),
|
||||
(-15372, 10000),
|
||||
(-4986, 10000),
|
||||
(-9689, 10000),
|
||||
(18758, 10000),
|
||||
(415, 10000),
|
||||
(557, 10000),
|
||||
(-2040, 10000),
|
||||
(10570, 10000),
|
||||
];
|
||||
let off = extra_off + extra.len() as u32;
|
||||
for (n, d) in m {
|
||||
extra.extend(n.to_le_bytes());
|
||||
extra.extend(d.to_le_bytes());
|
||||
}
|
||||
entries.push((50721, 10, 9, off.to_le_bytes()));
|
||||
}
|
||||
// AsShotNeutral: 3 RATIONALs, neutral.
|
||||
{
|
||||
let off = extra_off + extra.len() as u32;
|
||||
for _ in 0..3 {
|
||||
extra.extend(1u32.to_le_bytes());
|
||||
extra.extend(1u32.to_le_bytes());
|
||||
}
|
||||
entries.push((50728, 5, 3, off.to_le_bytes()));
|
||||
}
|
||||
entries.push(short(50778, 21)); // CalibrationIlluminant1: D65
|
||||
|
||||
entries.sort_by_key(|e| e.0);
|
||||
|
||||
let ifd_off = extra_off + extra.len() as u32;
|
||||
let mut out = Vec::new();
|
||||
out.extend(b"II");
|
||||
out.extend(42u16.to_le_bytes());
|
||||
out.extend(ifd_off.to_le_bytes());
|
||||
out.extend(&pixel_bytes);
|
||||
out.extend(&extra);
|
||||
out.extend((entries.len() as u16).to_le_bytes());
|
||||
for (tag, ty, count, value) in &entries {
|
||||
out.extend(tag.to_le_bytes());
|
||||
out.extend(ty.to_le_bytes());
|
||||
out.extend(count.to_le_bytes());
|
||||
out.extend(value);
|
||||
}
|
||||
out.extend(0u32.to_le_bytes()); // no next IFD
|
||||
out
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,42 @@ pub enum DecodeError {
|
||||
CorruptPreview(String),
|
||||
}
|
||||
|
||||
/// Run a decoder call, and return a panic inside it as an error.
|
||||
///
|
||||
/// TRACES: FR-RAW-4 | NFR-SEC-1 | NFR-R3
|
||||
/// rawler `panic!`s on some malformed input rather than returning `Err` — a
|
||||
/// DNG whose IFD claims a >50000 px image, for one, which is in the reference
|
||||
/// library. A panic on a worker thread ends the thread: the face sweep that
|
||||
/// met that file stopped 13 seconds in, three sweeps running, with "17301
|
||||
/// image(s) to index" as the last word and nothing to say why. FR-RAW-4's
|
||||
/// rule — a malformed file must not abort a batch — is this crate's to keep
|
||||
/// whatever the library beneath it does, so every entry point that calls into
|
||||
/// rawler runs through here, and a file that panics the decoder is one failed
|
||||
/// file like any other.
|
||||
///
|
||||
/// The crash hook still records the panic, because it runs before unwinding
|
||||
/// reaches this frame; that is right — it is a real defect in a dependency
|
||||
/// and the record is how it gets reported upstream — and a repeat is the same
|
||||
/// file being met again rather than a new fault.
|
||||
pub(crate) fn guarded<T>(
|
||||
what: &'static str,
|
||||
f: impl FnOnce() -> Result<T, DecodeError>,
|
||||
) -> Result<T, DecodeError> {
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
|
||||
Ok(result) => result,
|
||||
Err(payload) => {
|
||||
let msg = payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "no message".to_string());
|
||||
Err(DecodeError::Decode(format!(
|
||||
"{what}: the decoder panicked on this file: {msg}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DecodeError {
|
||||
/// Whether a fallback path might still produce an image.
|
||||
///
|
||||
@@ -49,4 +85,28 @@ mod tests {
|
||||
// A genuinely unsupported file has nowhere to fall through to.
|
||||
assert!(!DecodeError::Unsupported("unknown".into()).has_fallback());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_in_the_decoder_is_an_error_and_the_thread_survives() {
|
||||
// The property the face sweep relies on: one file that panics rawler
|
||||
// is one failed file, not the end of the pass. The message travels,
|
||||
// because "decode failed" alone sends the reader to the crash log.
|
||||
let err = guarded("decode", || -> Result<(), DecodeError> {
|
||||
panic!("rawler: surely there's no such thing as a {}MP image!", 600)
|
||||
})
|
||||
.unwrap_err();
|
||||
let text = err.to_string();
|
||||
assert!(text.contains("panicked"), "{text}");
|
||||
assert!(text.contains("600MP"), "{text}");
|
||||
assert!(!err.has_fallback(), "a panic is not a missing preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_passes_through_untouched() {
|
||||
assert_eq!(guarded("decode", || Ok::<_, DecodeError>(7)).unwrap(), 7);
|
||||
assert!(matches!(
|
||||
guarded("decode", || Err::<(), _>(DecodeError::NoPreview)),
|
||||
Err(DecodeError::NoPreview)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,20 @@
|
||||
//!
|
||||
//! Fusing them would force a full decode where a header read suffices, which
|
||||
//! is exactly why Lightroom stalls ~2 s per image during culling.
|
||||
//!
|
||||
//! Callers reach these through the [`Decoder`] trait rather than by name, so a
|
||||
//! second decoder can be put behind them without changing any of them
|
||||
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
|
||||
|
||||
pub mod base_curve;
|
||||
mod decoder;
|
||||
mod error;
|
||||
mod locate;
|
||||
mod preview;
|
||||
pub mod profile;
|
||||
|
||||
pub use base_curve::BaseCurve;
|
||||
pub use decoder::{default, Decoder, Rawler};
|
||||
pub use error::DecodeError;
|
||||
pub use locate::{
|
||||
defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel,
|
||||
@@ -134,6 +140,22 @@ pub struct RawImage {
|
||||
pub base_curve: BaseCurve,
|
||||
/// The usable region of `data`, excluding masked and border photosites.
|
||||
pub crop: CropRect,
|
||||
/// TRACES: FR-MRG-3
|
||||
/// Samples per photosite in `data`: 1 for a colour-filter-array capture,
|
||||
/// 3 for a *linear* DNG — demosaiced RGB, still camera-space, which is
|
||||
/// what a merge writes. With 3, `cfa_pattern` means nothing, `data` is
|
||||
/// `width × height × 3` interleaved, and the GPU uploads it as it is
|
||||
/// rather than demosaicing.
|
||||
pub samples_per_pixel: u8,
|
||||
/// TRACES: FR-MRG-3
|
||||
/// The body's colour profile as the file carried it, for a composite to
|
||||
/// carry on: calibrations and the as-shot neutral. `None` for a body the
|
||||
/// decoder has no matrix for.
|
||||
pub profile: Option<profile::CameraProfile>,
|
||||
/// The body, as rawler cleans the names: what `Make`/`Model` say and what
|
||||
/// the base-curve database matches on.
|
||||
pub make: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// TRACES: FR-RAW-3
|
||||
@@ -285,6 +307,30 @@ pub fn probe(header: &[u8]) -> Option<Format> {
|
||||
/// TRACES: FR-CAT-5 | M-12
|
||||
/// Read capture metadata without decoding sensor data.
|
||||
pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||||
error::guarded("metadata", || metadata_unguarded(bytes))
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
/// Where a TIFF-shaped file keeps its first IFD, when the head handed to
|
||||
/// [`metadata`] does not reach it — the linear DNG a merge writes puts its
|
||||
/// IFDs after the pixels, and rawler, given the head alone, finds no
|
||||
/// decoder in it. The caller fetches from this offset to the end and
|
||||
/// reads the two ranges with [`metadata_split`].
|
||||
pub fn trailing_ifd(head: &[u8]) -> Option<u64> {
|
||||
locate::trailing_ifd(head)
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
/// [`metadata`] for a file read in two ranges: `head` from offset 0 and
|
||||
/// `tail` from `tail_at`. The EXIF sub-IFD such a file wrote before its
|
||||
/// pixels is in the head; the first IFD and its values are in the tail.
|
||||
pub fn metadata_split(head: &[u8], tail: &[u8], tail_at: u64) -> Result<Metadata, DecodeError> {
|
||||
error::guarded("metadata", || {
|
||||
locate::tiff_metadata_split(head, tail, tail_at)
|
||||
})
|
||||
}
|
||||
|
||||
fn metadata_unguarded(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||||
use rawler::rawsource::RawSource;
|
||||
|
||||
// rawler has no decoder for a plain JPEG, so without this every JPEG in a
|
||||
@@ -510,6 +556,10 @@ pub(crate) fn parse_exif_offset(s: &str) -> Option<i32> {
|
||||
/// Only develop and export should call it; culling and the grid must not
|
||||
/// (FR-CULL-1).
|
||||
pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||
error::guarded("decode", || decode_unguarded(bytes))
|
||||
}
|
||||
|
||||
fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||
use rawler::rawsource::RawSource;
|
||||
|
||||
let source = RawSource::new_from_slice(bytes);
|
||||
@@ -551,6 +601,21 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||
image.camera.clean_model.as_str(),
|
||||
);
|
||||
|
||||
// TRACES: FR-MRG-3
|
||||
// A linear DNG — three samples per pixel, no colour filter array — is a
|
||||
// composite this application wrote (or any other demosaiced DNG). It
|
||||
// carries the same scale, matrices and neutral as a CFA file and goes
|
||||
// through the same profile; only the demosaic is skipped.
|
||||
let samples_per_pixel = match image.cpp {
|
||||
1 => 1u8,
|
||||
3 => 3,
|
||||
other => {
|
||||
return Err(DecodeError::Unsupported(format!(
|
||||
"{other} samples per pixel; only CFA (1) and linear RGB (3) are handled"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let data = match image.data {
|
||||
rawler::RawImageData::Integer(v) => v,
|
||||
rawler::RawImageData::Float(v) => {
|
||||
@@ -619,6 +684,10 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||
wb_coeffs,
|
||||
color_matrix,
|
||||
base_curve,
|
||||
samples_per_pixel,
|
||||
profile,
|
||||
make: image.camera.clean_make.clone(),
|
||||
model: image.camera.clean_model.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -183,22 +183,65 @@ struct Entry {
|
||||
value: u32,
|
||||
}
|
||||
|
||||
/// The bytes a [`TiffReader`] reads: the head of a file, and optionally a
|
||||
/// second range from further in, at a known offset.
|
||||
///
|
||||
/// A camera writes its IFDs at the front, so the first 256 KB of a file
|
||||
/// is the whole structure. A file written strip by strip — the linear
|
||||
/// DNG a merge produces — has its first IFD at the *end*, after the
|
||||
/// pixels, and a reader that only has the head sees a pointer into
|
||||
/// nothing. Rather than fetch 800 MB to read a date, the caller fetches
|
||||
/// the head, asks [`crate::trailing_ifd`] where the IFD is, fetches that
|
||||
/// tail, and reads through both. Offsets are the file's own throughout;
|
||||
/// a read that falls in neither range is simply absent.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Src<'a> {
|
||||
head: &'a [u8],
|
||||
tail: &'a [u8],
|
||||
/// Where `tail` starts in the file.
|
||||
tail_at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Src<'a> {
|
||||
fn whole(data: &'a [u8]) -> Self {
|
||||
Src {
|
||||
head: data,
|
||||
tail: &[],
|
||||
tail_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, start: usize, len: usize) -> Option<&'a [u8]> {
|
||||
let end = start.checked_add(len)?;
|
||||
if let Some(b) = self.head.get(start..end) {
|
||||
return Some(b);
|
||||
}
|
||||
let s = start.checked_sub(self.tail_at)?;
|
||||
self.tail.get(s..s.checked_add(len)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal TIFF structure reader.
|
||||
///
|
||||
/// Deliberately not a general TIFF parser: it reads the IFD chain and entry
|
||||
/// values and nothing else, because that is all locating a preview needs.
|
||||
struct TiffReader<'a> {
|
||||
data: &'a [u8],
|
||||
data: Src<'a>,
|
||||
little_endian: bool,
|
||||
first_ifd: u32,
|
||||
}
|
||||
|
||||
impl<'a> TiffReader<'a> {
|
||||
fn new(data: &'a [u8]) -> Option<Self> {
|
||||
if data.len() < 8 {
|
||||
Self::over(Src::whole(data))
|
||||
}
|
||||
|
||||
fn over(data: Src<'a>) -> Option<Self> {
|
||||
let head = data.head;
|
||||
if head.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let little_endian = match &data[0..2] {
|
||||
let little_endian = match &head[0..2] {
|
||||
b"II" => true,
|
||||
b"MM" => false,
|
||||
_ => return None,
|
||||
@@ -362,9 +405,7 @@ impl<'a> TiffReader<'a> {
|
||||
};
|
||||
raw[..len.min(4)].to_vec()
|
||||
} else {
|
||||
self.data
|
||||
.get(e.value as usize..e.value as usize + len)?
|
||||
.to_vec()
|
||||
self.data.get(e.value as usize, len)?.to_vec()
|
||||
};
|
||||
|
||||
let s = String::from_utf8_lossy(&bytes);
|
||||
@@ -396,8 +437,7 @@ impl<'a> TiffReader<'a> {
|
||||
// same way to recover the original byte order.
|
||||
return None;
|
||||
}
|
||||
let start = e.value as usize;
|
||||
self.data.get(start..start.checked_add(len)?)
|
||||
self.data.get(e.value as usize, len)
|
||||
}
|
||||
|
||||
fn offsets(&self, e: &Entry) -> Vec<u32> {
|
||||
@@ -420,8 +460,8 @@ impl<'a> TiffReader<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_u16(data: &[u8], at: usize, le: bool) -> Option<u16> {
|
||||
let b = data.get(at..at + 2)?;
|
||||
fn read_u16(data: Src<'_>, at: usize, le: bool) -> Option<u16> {
|
||||
let b = data.get(at, 2)?;
|
||||
Some(if le {
|
||||
u16::from_le_bytes([b[0], b[1]])
|
||||
} else {
|
||||
@@ -429,8 +469,8 @@ fn read_u16(data: &[u8], at: usize, le: bool) -> Option<u16> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_u32(data: &[u8], at: usize, le: bool) -> Option<u32> {
|
||||
let b = data.get(at..at + 4)?;
|
||||
fn read_u32(data: Src<'_>, at: usize, le: bool) -> Option<u32> {
|
||||
let b = data.get(at, 4)?;
|
||||
Some(if le {
|
||||
u32::from_le_bytes([b[0], b[1], b[2], b[3]])
|
||||
} else {
|
||||
@@ -460,7 +500,36 @@ pub fn jpeg_metadata(bytes: &[u8]) -> Result<crate::Metadata, crate::DecodeError
|
||||
/// no `DateTimeOriginal` for some DNGs whose tag sits plainly at byte 826 —
|
||||
/// and without this fallback those images are silently undated.
|
||||
pub fn tiff_metadata(tiff_data: &[u8]) -> Result<crate::Metadata, crate::DecodeError> {
|
||||
let reader = TiffReader::new(tiff_data)
|
||||
tiff_metadata_over(Src::whole(tiff_data))
|
||||
}
|
||||
|
||||
/// Where a TIFF-shaped file's first IFD is, when the head does not reach
|
||||
/// it: the offset to fetch from, so [`tiff_metadata_split`] can read it.
|
||||
/// `None` for a file that is not TIFF, or whose IFD the head already holds.
|
||||
pub fn trailing_ifd(head: &[u8]) -> Option<u64> {
|
||||
let r = TiffReader::new(head)?;
|
||||
let at = r.first_ifd as u64;
|
||||
(at >= head.len() as u64).then_some(at)
|
||||
}
|
||||
|
||||
/// [`tiff_metadata`] over a head and a tail fetched separately: the head
|
||||
/// from offset 0, the tail from `tail_at`. For the file whose IFDs follow
|
||||
/// its pixels.
|
||||
pub fn tiff_metadata_split(
|
||||
head: &[u8],
|
||||
tail: &[u8],
|
||||
tail_at: u64,
|
||||
) -> Result<crate::Metadata, crate::DecodeError> {
|
||||
tiff_metadata_over(Src {
|
||||
head,
|
||||
tail,
|
||||
tail_at: usize::try_from(tail_at)
|
||||
.map_err(|_| crate::DecodeError::Metadata("tail offset out of range".into()))?,
|
||||
})
|
||||
}
|
||||
|
||||
fn tiff_metadata_over(src: Src<'_>) -> Result<crate::Metadata, crate::DecodeError> {
|
||||
let reader = TiffReader::over(src)
|
||||
.ok_or_else(|| crate::DecodeError::Metadata("malformed EXIF header".into()))?;
|
||||
|
||||
let mut md = crate::Metadata::default();
|
||||
|
||||
@@ -158,6 +158,10 @@ pub enum PreviewSize {
|
||||
/// Returns [`DecodeError::NoPreview`] where there is none at all: a
|
||||
/// fall-through signal, not a failure (see [`DecodeError::has_fallback`]).
|
||||
pub fn extract_preview(bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError> {
|
||||
crate::error::guarded("preview", || extract_preview_unguarded(bytes, size))
|
||||
}
|
||||
|
||||
fn extract_preview_unguarded(bytes: &[u8], size: PreviewSize) -> Result<Preview, DecodeError> {
|
||||
use rawler::rawsource::RawSource;
|
||||
|
||||
// A plain JPEG *is* its own preview — rawler has no decoder for one, and
|
||||
|
||||
@@ -392,6 +392,21 @@ impl CameraProfile {
|
||||
}
|
||||
|
||||
/// The calibrations this profile was built from, coolest first.
|
||||
/// TRACES: FR-MRG-3
|
||||
/// The calibrations as a DNG carries them: `(CalibrationIlluminant,
|
||||
/// ColorMatrix)` with the EXIF light-source code, for a composite to
|
||||
/// write the profile of the body that took its sources.
|
||||
///
|
||||
/// The code is recovered from the temperature, which is lossy only for
|
||||
/// illuminants this profile never kept: `extract` drops calibrations
|
||||
/// whose illuminant has no temperature, so every one here maps back.
|
||||
pub fn dng_calibrations(&self) -> Vec<(u16, [[f32; 3]; 3])> {
|
||||
self.calibrations
|
||||
.iter()
|
||||
.map(|c| (illuminant_code(c.temperature), c.xyz_to_cam))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn calibrations(&self) -> &[Calibration] {
|
||||
&self.calibrations
|
||||
}
|
||||
@@ -528,6 +543,39 @@ fn illuminant_temperature(illuminant: Illuminant) -> Option<f32> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The EXIF `LightSource` code for a calibration temperature — the inverse
|
||||
/// of [`illuminant_temperature`], on the temperatures it produces.
|
||||
fn illuminant_code(temperature: f32) -> u16 {
|
||||
// Nearest of the table, so a temperature that came through a float
|
||||
// round-trip still lands on its illuminant. Where two illuminants share
|
||||
// a temperature (D55 and Daylight, D65 and Cloudy, D75 and Shade) the
|
||||
// CIE standard one is written: it is what every profile database means.
|
||||
const TABLE: &[(f32, u16)] = &[
|
||||
(2856.0, 17), // A
|
||||
(3200.0, 24), // ISO studio tungsten
|
||||
(3500.0, 15), // white fluorescent
|
||||
(4150.0, 14), // cool white fluorescent
|
||||
(4230.0, 2), // fluorescent
|
||||
(4874.0, 18), // B
|
||||
(5000.0, 13), // daylight white fluorescent
|
||||
(5003.0, 23), // D50
|
||||
(5503.0, 20), // D55
|
||||
(6430.0, 12), // daylight fluorescent
|
||||
(6504.0, 21), // D65
|
||||
(6774.0, 19), // C
|
||||
(7504.0, 22), // D75
|
||||
];
|
||||
TABLE
|
||||
.iter()
|
||||
.min_by(|a, b| {
|
||||
(a.0 - temperature)
|
||||
.abs()
|
||||
.total_cmp(&(b.0 - temperature).abs())
|
||||
})
|
||||
.map(|(_, code)| *code)
|
||||
.unwrap_or(255)
|
||||
}
|
||||
|
||||
/// Compose a forward matrix into camera RGB → linear sRGB.
|
||||
///
|
||||
/// `forward` takes white-balanced camera RGB to XYZ under D50, which is the
|
||||
|
||||
@@ -38,4 +38,7 @@ dr-gpu.workspace = true
|
||||
dr-pipeline.workspace = true
|
||||
env_logger.workspace = true
|
||||
pollster.workspace = true
|
||||
# The DNG writer's test reads its output back through the decoder the
|
||||
# library uses, which is the whole claim the writer makes (S15.1).
|
||||
rawler.workspace = true
|
||||
zune-jpeg.workspace = true
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! TRACES: FR-MRG-3
|
||||
//! A linear DNG: the container a merge writes its composite into.
|
||||
//!
|
||||
//! Decided by S15.1 (2026-09-19): rawler reads back a `LinearRaw` DNG the
|
||||
//! application writes, so a composite re-enters the library as
|
||||
//! `Format::Dng` through the decoder every camera DNG uses. What is written
|
||||
//! is a RAW in every sense a warp can preserve — camera-linear `u16`
|
||||
//! samples at the first source's own scale, its matrices, illuminants,
|
||||
//! as-shot neutral and body name — so the panorama is developed afterwards
|
||||
//! as one photograph, from the sensor's numbers.
|
||||
//!
|
||||
//! # Streamed, not buffered
|
||||
//!
|
||||
//! The composite is larger than any single photograph the pipeline renders
|
||||
//! and larger than the tablet's memory (FR-MRG-11), so the writer never
|
||||
//! holds it. Strips are pulled from the caller one at a time through a
|
||||
//! closure, in order, and written as they arrive; the caller renders a band
|
||||
//! of chunks, hands over its rows, and moves on.
|
||||
//!
|
||||
//! # Why the `tiff` crate after all
|
||||
//!
|
||||
//! S15.1's spike hand-rolled its IFD because the crate's encoder fixes
|
||||
//! `PhotometricInterpretation` to RGB when the image is opened. It does — but
|
||||
//! a directory is a map and a later `write_tag` on the same tag replaces the
|
||||
//! earlier, so `LinearRaw` goes in over the top and everything else the
|
||||
//! crate does (strips, offsets, sub-IFDs, the EXIF block `encode.rs` already
|
||||
//! knows how to write) is kept.
|
||||
|
||||
use std::io::{Seek, Write};
|
||||
|
||||
use tiff::encoder::{colortype, DirectoryEncoder, SRational, TiffEncoder, TiffKind, TiffValue};
|
||||
use tiff::tags::Tag;
|
||||
|
||||
use crate::encode::{sub_directories, tag_metadata, Ascii, Rationals};
|
||||
use crate::{ExportError, SourceMetadata};
|
||||
|
||||
/// What the DNG says about the camera that "took" the composite: the first
|
||||
/// source's profile, carried across so the composite develops through it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DngProfile {
|
||||
/// `UniqueCameraModel`, the name the profile database matches on.
|
||||
pub unique_model: String,
|
||||
/// `(CalibrationIlluminant, ColorMatrix)`: the EXIF light-source code and
|
||||
/// the XYZ → camera matrix measured under it. One or two.
|
||||
pub calibrations: Vec<(u16, [[f32; 3]; 3])>,
|
||||
/// `AsShotNeutral`, camera RGB of the scene's white.
|
||||
pub as_shot_neutral: [f32; 3],
|
||||
/// `WhiteLevel`: the sample value that is clipping. The first source's
|
||||
/// white minus its black, since the samples are black-subtracted.
|
||||
pub white_level: u32,
|
||||
}
|
||||
|
||||
/// Write a linear DNG, pulling `rows_per_strip`-row strips from `strips`.
|
||||
///
|
||||
/// Each call to `strips` receives the strip index and a buffer to fill with
|
||||
/// `width × rows × 3` interleaved RGB `u16` samples (the last strip may be
|
||||
/// shorter). `source` supplies the `Make`, `Model`, dates and EXIF block
|
||||
/// exactly as an export does (FR-EXP-8 sanitising already applied by the
|
||||
/// caller).
|
||||
///
|
||||
/// `PhotometricInterpretation = LinearRaw`, `DNGVersion 1.4`, uncompressed,
|
||||
/// `Orientation = 1` — the composite is written upright (panorama.md §8).
|
||||
///
|
||||
/// `crop` is asked once every strip is in, and its answer — the largest
|
||||
/// rectangle the frames covered, found while the strips went by
|
||||
/// (`Inscribed`) — becomes `DefaultCropOrigin`/`DefaultCropSize`
|
||||
/// (FR-MRG-4): the file opens on the picture, and the border is still in it.
|
||||
// Eight arguments, and each is a different thing: the sink, three
|
||||
// dimensions, the profile, the header, the strip source and the crop. A
|
||||
// struct for them would be a struct with one caller.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn write_linear_dng<W, F, C>(
|
||||
out: W,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rows_per_strip: u32,
|
||||
profile: &DngProfile,
|
||||
source: Option<&SourceMetadata>,
|
||||
mut strips: F,
|
||||
crop: C,
|
||||
) -> Result<(), ExportError>
|
||||
where
|
||||
W: Write + Seek,
|
||||
F: FnMut(usize, &mut Vec<u16>) -> Result<(), ExportError>,
|
||||
C: FnOnce() -> Option<crate::Rect>,
|
||||
{
|
||||
let enc = |e: tiff::TiffError| ExportError::Encode(e.to_string());
|
||||
let mut encoder = TiffEncoder::new(out).map_err(enc)?;
|
||||
let sub = sub_directories(&mut encoder, source, width, height)?;
|
||||
let mut image = encoder
|
||||
.new_image::<colortype::RGB16>(width, height)
|
||||
.map_err(enc)?;
|
||||
image.rows_per_strip(rows_per_strip.max(1)).map_err(enc)?;
|
||||
tag_metadata(image.encoder(), source, &sub)?;
|
||||
tag_dng(image.encoder(), profile).map_err(enc)?;
|
||||
|
||||
let rows = rows_per_strip.max(1);
|
||||
let strip_count = height.div_ceil(rows) as usize;
|
||||
let mut buf: Vec<u16> = Vec::with_capacity((width * rows * 3) as usize);
|
||||
for k in 0..strip_count {
|
||||
buf.clear();
|
||||
strips(k, &mut buf)?;
|
||||
let expected_rows = rows.min(height - k as u32 * rows);
|
||||
let expected = (width * expected_rows * 3) as usize;
|
||||
if buf.len() != expected {
|
||||
return Err(ExportError::Encode(format!(
|
||||
"strip {k} has {} samples, expected {expected}",
|
||||
buf.len()
|
||||
)));
|
||||
}
|
||||
image.write_strip(&buf).map_err(enc)?;
|
||||
}
|
||||
if let Some(r) = crop().filter(|r| r.width > 0 && r.height > 0) {
|
||||
let r = crate::Rect {
|
||||
x: r.x.min(width - 1),
|
||||
y: r.y.min(height - 1),
|
||||
width: r.width.min(width - r.x.min(width - 1)),
|
||||
height: r.height.min(height - r.y.min(height - 1)),
|
||||
};
|
||||
image
|
||||
.encoder()
|
||||
.write_tag(Tag::Unknown(tag::DEFAULT_CROP_ORIGIN), &[r.x, r.y][..])
|
||||
.map_err(enc)?;
|
||||
image
|
||||
.encoder()
|
||||
.write_tag(
|
||||
Tag::Unknown(tag::DEFAULT_CROP_SIZE),
|
||||
&[r.width, r.height][..],
|
||||
)
|
||||
.map_err(enc)?;
|
||||
}
|
||||
image.finish().map_err(enc)
|
||||
}
|
||||
|
||||
/// The tags that make a TIFF a DNG, and a linear one.
|
||||
fn tag_dng<W, K>(dir: &mut DirectoryEncoder<'_, W, K>, profile: &DngProfile) -> tiff::TiffResult<()>
|
||||
where
|
||||
W: Write + Seek,
|
||||
K: TiffKind,
|
||||
{
|
||||
// Over the top of what `new_image` wrote: this is the whole trick.
|
||||
dir.write_tag(Tag::PhotometricInterpretation, LINEAR_RAW)?;
|
||||
dir.write_tag(Tag::Orientation, 1u16)?;
|
||||
dir.write_tag(Tag::Unknown(tag::DNG_VERSION), &[1u8, 4, 0, 0][..])?;
|
||||
dir.write_tag(Tag::Unknown(tag::DNG_BACKWARD_VERSION), &[1u8, 4, 0, 0][..])?;
|
||||
dir.write_tag(
|
||||
Tag::Unknown(tag::UNIQUE_CAMERA_MODEL),
|
||||
Ascii(&profile.unique_model),
|
||||
)?;
|
||||
dir.write_tag(
|
||||
Tag::Unknown(tag::WHITE_LEVEL),
|
||||
&[profile.white_level; 3][..],
|
||||
)?;
|
||||
dir.write_tag(Tag::Unknown(tag::BLACK_LEVEL), &[0u32; 3][..])?;
|
||||
|
||||
for (slot, (illuminant, matrix)) in profile.calibrations.iter().take(2).enumerate() {
|
||||
let (ill_tag, mat_tag) = if slot == 0 {
|
||||
(tag::CALIBRATION_ILLUMINANT_1, tag::COLOR_MATRIX_1)
|
||||
} else {
|
||||
(tag::CALIBRATION_ILLUMINANT_2, tag::COLOR_MATRIX_2)
|
||||
};
|
||||
dir.write_tag(Tag::Unknown(ill_tag), *illuminant)?;
|
||||
let flat: Vec<SRational> = matrix
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|&v| SRational {
|
||||
n: (v * 10_000.0).round() as i32,
|
||||
d: 10_000,
|
||||
})
|
||||
.collect();
|
||||
dir.write_tag(Tag::Unknown(mat_tag), SRationals(&flat))?;
|
||||
}
|
||||
|
||||
let neutral: Vec<(u32, u32)> = profile
|
||||
.as_shot_neutral
|
||||
.iter()
|
||||
.map(|&v| ((v.max(0.0) * 1_000_000.0).round() as u32, 1_000_000))
|
||||
.collect();
|
||||
dir.write_tag(Tag::Unknown(tag::AS_SHOT_NEUTRAL), Rationals(&neutral))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `PhotometricInterpretation` for demosaiced, un-rendered sensor data.
|
||||
const LINEAR_RAW: u16 = 34892;
|
||||
|
||||
/// DNG tag numbers the `tiff` crate has no names for.
|
||||
mod tag {
|
||||
pub const DNG_VERSION: u16 = 50706;
|
||||
pub const DNG_BACKWARD_VERSION: u16 = 50707;
|
||||
pub const UNIQUE_CAMERA_MODEL: u16 = 50708;
|
||||
pub const BLACK_LEVEL: u16 = 50714;
|
||||
pub const WHITE_LEVEL: u16 = 50717;
|
||||
pub const DEFAULT_CROP_ORIGIN: u16 = 50719;
|
||||
pub const DEFAULT_CROP_SIZE: u16 = 50720;
|
||||
pub const COLOR_MATRIX_1: u16 = 50721;
|
||||
pub const COLOR_MATRIX_2: u16 = 50722;
|
||||
pub const AS_SHOT_NEUTRAL: u16 = 50728;
|
||||
pub const CALIBRATION_ILLUMINANT_1: u16 = 50778;
|
||||
pub const CALIBRATION_ILLUMINANT_2: u16 = 50779;
|
||||
}
|
||||
|
||||
/// A run of `SRATIONAL`s, as `encode::Rationals` is for `RATIONAL`.
|
||||
struct SRationals<'a>(&'a [SRational]);
|
||||
|
||||
impl TiffValue for SRationals<'_> {
|
||||
const BYTE_LEN: u8 = 8;
|
||||
const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::SRATIONAL;
|
||||
|
||||
fn count(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
fn data(&self) -> std::borrow::Cow<'_, [u8]> {
|
||||
let mut out = Vec::with_capacity(self.0.len() * 8);
|
||||
for r in self.0 {
|
||||
out.extend_from_slice(&r.n.to_ne_bytes());
|
||||
out.extend_from_slice(&r.d.to_ne_bytes());
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile() -> DngProfile {
|
||||
DngProfile {
|
||||
unique_model: "Canon EOS 6D".into(),
|
||||
calibrations: vec![
|
||||
(17, [[0.8, -0.2, 0.1], [-0.3, 1.1, 0.2], [0.0, -0.1, 0.9]]),
|
||||
(21, [[0.7, -0.1, 0.0], [-0.2, 1.0, 0.1], [0.0, -0.2, 0.8]]),
|
||||
],
|
||||
as_shot_neutral: [0.5, 1.0, 0.6],
|
||||
white_level: 13_023,
|
||||
}
|
||||
}
|
||||
|
||||
fn write(width: u32, height: u32, rows: u32) -> Vec<u8> {
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
let source = SourceMetadata {
|
||||
make: Some("Canon".into()),
|
||||
model: Some("Canon EOS 6D".into()),
|
||||
captured_at: Some(1_754_398_664),
|
||||
captured_offset: Some(120),
|
||||
..Default::default()
|
||||
};
|
||||
write_linear_dng(
|
||||
&mut bytes,
|
||||
width,
|
||||
height,
|
||||
rows,
|
||||
&profile(),
|
||||
Some(&source),
|
||||
|k, buf| {
|
||||
let first = k as u32 * rows;
|
||||
let n = rows.min(height - first);
|
||||
for y in first..first + n {
|
||||
for x in 0..width {
|
||||
buf.extend([(x + y * width) as u16, 1000, 2000]);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
|| {
|
||||
Some(crate::Rect {
|
||||
x: 2,
|
||||
y: 1,
|
||||
width: 15,
|
||||
height: 10,
|
||||
})
|
||||
},
|
||||
)
|
||||
.expect("written");
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rawler_reads_it_back_as_linear_raw() {
|
||||
let bytes = write(20, 13, 4);
|
||||
let source = rawler::rawsource::RawSource::new_from_slice(&bytes);
|
||||
let decoder = rawler::get_decoder(&source).expect("a DNG");
|
||||
let image = decoder
|
||||
.raw_image(&source, &Default::default(), false)
|
||||
.expect("decodes");
|
||||
assert_eq!((image.width, image.height, image.cpp), (20, 13, 3));
|
||||
assert_eq!(image.whitelevel.0[0], 13_023);
|
||||
// Pixel (3, 2) is (3 + 2·20, 1000, 2000) — samples in order, strips
|
||||
// joined without a seam.
|
||||
let rawler::RawImageData::Integer(data) = &image.data else {
|
||||
panic!("integer samples")
|
||||
};
|
||||
let i = (2 * 20 + 3) * 3;
|
||||
assert_eq!(&data[i..i + 3], &[43, 1000, 2000]);
|
||||
// Last row, from the short final strip.
|
||||
let i = (12 * 20 + 19) * 3;
|
||||
assert_eq!(data[i], (19 + 12 * 20) as u16);
|
||||
// The profile came through as the camera's.
|
||||
assert!(!image.camera.color_matrix.is_empty());
|
||||
assert_eq!(image.model, "Canon EOS 6D");
|
||||
// The default crop is what the decoder reports as the picture.
|
||||
let crop = image.crop_area.expect("a crop");
|
||||
assert_eq!((crop.p.x, crop.p.y, crop.d.w, crop.d.h), (2, 1, 15, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_catalog_reads_the_date_from_a_head_and_a_tail() {
|
||||
// TRACES: FR-CAT-5
|
||||
// The IFDs follow the pixels, so a scan that has the first bytes of
|
||||
// the file has a pointer into nothing; rawler finds no decoder in
|
||||
// that, and the composite would sit undated at the end of the grid.
|
||||
// The scan's second range — from the first IFD to the end — with
|
||||
// the head is enough to date it, and to name the camera.
|
||||
let bytes = write(640, 400, 64);
|
||||
let head = &bytes[..4096];
|
||||
assert!(
|
||||
dr_decode::metadata(head).is_err(),
|
||||
"the head alone must not read"
|
||||
);
|
||||
let at = dr_decode::trailing_ifd(head).expect("the IFD is beyond the head");
|
||||
assert!(at as usize > head.len());
|
||||
let tail = &bytes[at as usize..];
|
||||
assert!(tail.len() < 4096, "the tail is the IFD, not the pixels");
|
||||
let md = dr_decode::metadata_split(head, tail, at).expect("read from two ranges");
|
||||
assert_eq!(md.captured_at, Some(1_754_398_664));
|
||||
assert_eq!(md.captured_offset, Some(120));
|
||||
assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"));
|
||||
// A head that holds everything is not a trailing-IFD file.
|
||||
assert_eq!(dr_decode::trailing_ifd(&bytes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_strip_of_the_wrong_length_is_refused() {
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
let err = write_linear_dng(
|
||||
&mut bytes,
|
||||
8,
|
||||
8,
|
||||
8,
|
||||
&profile(),
|
||||
None,
|
||||
|_, buf| {
|
||||
buf.extend([0u16; 10]);
|
||||
Ok(())
|
||||
},
|
||||
|| None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ExportError::Encode(_)));
|
||||
}
|
||||
}
|
||||
@@ -213,7 +213,7 @@ impl tiff::encoder::TiffValue for Undefined<'_> {
|
||||
/// specification says, `dr-decode` reads them back with `from_utf8_lossy`, and
|
||||
/// a mangled accent is a far better outcome than a refusal. So the bytes go
|
||||
/// through verbatim with the terminating NUL the type requires.
|
||||
struct Ascii<'a>(&'a str);
|
||||
pub(crate) struct Ascii<'a>(pub(crate) &'a str);
|
||||
|
||||
impl tiff::encoder::TiffValue for Ascii<'_> {
|
||||
const BYTE_LEN: u8 = 1;
|
||||
@@ -241,7 +241,7 @@ impl tiff::encoder::TiffValue for Ascii<'_> {
|
||||
/// a value that forced little-endian would be read back byte-swapped on a
|
||||
/// big-endian machine. `exif.rs` builds its own header and so chooses its own
|
||||
/// order; here the container has already chosen.
|
||||
struct Rationals<'a>(&'a [(u32, u32)]);
|
||||
pub(crate) struct Rationals<'a>(pub(crate) &'a [(u32, u32)]);
|
||||
|
||||
impl tiff::encoder::TiffValue for Rationals<'_> {
|
||||
const BYTE_LEN: u8 = 8;
|
||||
@@ -317,7 +317,7 @@ where
|
||||
/// and then no pointer is written either, so the file has no trace of the
|
||||
/// directory rather than a pointer to an empty one.
|
||||
#[derive(Default)]
|
||||
struct SubDirectories {
|
||||
pub(crate) struct SubDirectories {
|
||||
exif: Option<u32>,
|
||||
gps: Option<u32>,
|
||||
}
|
||||
@@ -335,7 +335,7 @@ struct SubDirectories {
|
||||
/// A TIFF gets no separate EXIF *block* — no APP1, no `eXIf` chunk. Its own
|
||||
/// directory is the EXIF structure, and adding a second copy inside it would
|
||||
/// give a reader two answers to every question.
|
||||
fn sub_directories<W>(
|
||||
pub(crate) fn sub_directories<W>(
|
||||
encoder: &mut tiff::encoder::TiffEncoder<W>,
|
||||
source: Option<&SourceMetadata>,
|
||||
width: u32,
|
||||
@@ -465,7 +465,7 @@ where
|
||||
///
|
||||
/// No `Orientation`, for the reason `exif.rs` gives at length: the pixels
|
||||
/// arriving here are already upright.
|
||||
fn tag_metadata<W, K>(
|
||||
pub(crate) fn tag_metadata<W, K>(
|
||||
dir: &mut tiff::encoder::DirectoryEncoder<'_, W, K>,
|
||||
source: Option<&SourceMetadata>,
|
||||
sub: &SubDirectories,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! TRACES: FR-MRG-4
|
||||
//! The largest rectangle inside a coverage mask, found a row at a time.
|
||||
//!
|
||||
//! A merged panorama has ragged edges: the frames' footprints under a
|
||||
//! cylinder or a sphere are not rectangles, and the composite carries a
|
||||
//! black border where none of them reached. FR-MRG-4 asks for an auto-crop
|
||||
//! to the largest inscribed rectangle. This finds it as the bands are
|
||||
//! produced, so the composite is never held to be measured (FR-MRG-11):
|
||||
//! each row extends a running histogram of consecutive covered rows above
|
||||
//! it, and the largest rectangle ending on that row is the largest
|
||||
//! rectangle under the histogram — a stack pass, linear in the width.
|
||||
//!
|
||||
//! The crop is written as the DNG's `DefaultCropOrigin`/`DefaultCropSize`,
|
||||
//! which every reader honours and which discards nothing: the pixels
|
||||
//! outside it are still in the file for a photographer who wants them.
|
||||
|
||||
/// The rectangle so far, in pixels from the top left.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Rect {
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub fn area(&self) -> u64 {
|
||||
u64::from(self.width) * u64::from(self.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed rows top to bottom; ask for the best at any point.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Inscribed {
|
||||
width: usize,
|
||||
/// How many consecutive covered rows end at the last row fed, per column.
|
||||
heights: Vec<u32>,
|
||||
rows: u32,
|
||||
best: Rect,
|
||||
}
|
||||
|
||||
impl Inscribed {
|
||||
pub fn new(width: u32) -> Self {
|
||||
Inscribed {
|
||||
width: width as usize,
|
||||
heights: vec![0; width as usize],
|
||||
rows: 0,
|
||||
best: Rect::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One more row of coverage, `width` long.
|
||||
pub fn push_row(&mut self, covered: &[bool]) {
|
||||
debug_assert_eq!(covered.len(), self.width);
|
||||
for (h, &c) in self.heights.iter_mut().zip(covered) {
|
||||
*h = if c { *h + 1 } else { 0 };
|
||||
}
|
||||
self.rows += 1;
|
||||
// Largest rectangle under the histogram, with a sentinel column of
|
||||
// height 0 at the end so every bar is popped.
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
for i in 0..=self.width {
|
||||
let h = if i < self.width { self.heights[i] } else { 0 };
|
||||
while let Some(&top) = stack.last() {
|
||||
if self.heights[top] <= h {
|
||||
break;
|
||||
}
|
||||
stack.pop();
|
||||
let height = self.heights[top];
|
||||
let left = stack.last().map_or(0, |&l| l + 1);
|
||||
let width = (i - left) as u32;
|
||||
let area = u64::from(width) * u64::from(height);
|
||||
if area > self.best.area() {
|
||||
self.best = Rect {
|
||||
x: left as u32,
|
||||
y: self.rows - height,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
}
|
||||
stack.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Several rows at once, as a band hands them over.
|
||||
pub fn push_rows(&mut self, covered: &[bool], rows: u32) {
|
||||
for r in 0..rows as usize {
|
||||
self.push_row(&covered[r * self.width..(r + 1) * self.width]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn best(&self) -> Rect {
|
||||
self.best
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn from_art(art: &[&str]) -> Rect {
|
||||
let mut ins = Inscribed::new(art[0].len() as u32);
|
||||
for row in art {
|
||||
let covered: Vec<bool> = row.chars().map(|c| c == '#').collect();
|
||||
ins.push_row(&covered);
|
||||
}
|
||||
ins.best()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_mask_is_its_own_rectangle() {
|
||||
let r = from_art(&["####", "####", "####"]);
|
||||
assert_eq!(
|
||||
r,
|
||||
Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 4,
|
||||
height: 3
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ragged_edges_are_cut_off() {
|
||||
// A cylinder's footprint: narrower at top and bottom.
|
||||
let r = from_art(&[
|
||||
"..####..", ".######.", "########", "########", ".######.", "..####..",
|
||||
]);
|
||||
// 6 wide × 4 tall = 24 beats 8 × 2 = 16 and 4 × 6 = 24 ties; the
|
||||
// first found wins a tie, which is the wider one here.
|
||||
assert_eq!(r.area(), 24);
|
||||
assert!(r.width == 6 && r.height == 4 || r.width == 4 && r.height == 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_is_avoided() {
|
||||
let r = from_art(&["#####", "##.##", "#####", "#####"]);
|
||||
// Left of the hole: 2 × 4 = 8; right: 2 × 4 = 8; below: 5 × 2 = 10.
|
||||
assert_eq!(
|
||||
r,
|
||||
Rect {
|
||||
x: 0,
|
||||
y: 2,
|
||||
width: 5,
|
||||
height: 2
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_covered_is_nothing() {
|
||||
assert_eq!(from_art(&["....", "...."]).area(), 0);
|
||||
}
|
||||
}
|
||||
@@ -24,16 +24,20 @@
|
||||
|
||||
use dr_types::{ColourSpace, ExportFormat, ExportSettings};
|
||||
|
||||
mod dng;
|
||||
mod encode;
|
||||
mod error;
|
||||
mod exif;
|
||||
pub mod icc;
|
||||
mod inscribed;
|
||||
mod metadata;
|
||||
mod name;
|
||||
mod sharpen;
|
||||
mod size;
|
||||
|
||||
pub use dng::{write_linear_dng, DngProfile};
|
||||
pub use error::ExportError;
|
||||
pub use inscribed::{Inscribed, Rect};
|
||||
pub use metadata::SourceMetadata;
|
||||
pub use name::{resolve_name, NameContext};
|
||||
pub use size::target_size;
|
||||
|
||||
+11
-6
@@ -9,10 +9,11 @@ license.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
# Inference. `ort` is the API; **tract is the engine** — see the workspace
|
||||
# manifest, and docs/faces.md §3, for why the C++ ONNX Runtime is not linked.
|
||||
# Inference. `ort` is the API; **what runs it is `dr-inference-engine`'s
|
||||
# business** — tract, or an ONNX Runtime the app found on disk, on whichever
|
||||
# provider the device has (docs/dev/inference.md). This crate never names either.
|
||||
ort = { workspace = true, optional = true }
|
||||
ort-tract = { workspace = true, optional = true }
|
||||
dr-inference-engine = { workspace = true, optional = true }
|
||||
ndarray = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -20,7 +21,7 @@ zune-jpeg.workspace = true
|
||||
env_logger.workspace = true
|
||||
# The M1 probe drives `ort` directly so it can print the raw load error.
|
||||
ort = { workspace = true }
|
||||
ort-tract = { workspace = true }
|
||||
dr-inference-engine = { workspace = true }
|
||||
|
||||
[[example]]
|
||||
name = "probe"
|
||||
@@ -30,9 +31,13 @@ required-features = ["inference"]
|
||||
name = "faces"
|
||||
required-features = ["inference"]
|
||||
|
||||
[[example]]
|
||||
name = "eyes"
|
||||
required-features = ["inference"]
|
||||
|
||||
[features]
|
||||
# Nothing on by default, and in particular **no `embedded-model`**: the weights
|
||||
# are not a build input and never become one (docs/faces.md §2.2). A feature
|
||||
# are not a build input and never become one (docs/dev/faces.md §2.2). A feature
|
||||
# flag that *could* embed them is a flag someone eventually sets in a packaging
|
||||
# script, and the InsightFace grant does not survive that.
|
||||
default = []
|
||||
@@ -44,4 +49,4 @@ default = []
|
||||
# must be testable against synthetic embeddings on a machine with no weights on
|
||||
# it — a test suite that needs a research-licensed download is a test suite
|
||||
# that does not run in CI.
|
||||
inference = ["dep:ort", "dep:ort-tract", "dep:ndarray"]
|
||||
inference = ["dep:ort", "dep:dr-inference-engine", "dep:ndarray"]
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Detect the faces in a JPEG and read each one's eyes (docs/dev/faces.md §17).
|
||||
//!
|
||||
//! The thing worth looking at is whether the eye boxes land on eyes and
|
||||
//! whether soft ones are refused — so with `--dump DIR` the crops the
|
||||
//! classifiers were shown are written out as PPMs, one per eye and one per
|
||||
//! head framing, named by image and face, and every line carries the
|
||||
//! numbers the readability floors are set from.
|
||||
//!
|
||||
//! cargo run -p dr-face --features inference --example eyes -- \
|
||||
//! DET.onnx 2D106DET.onnx OCEC.onnx SGC.onnx [--dump DIR] photo.jpg [photo.jpg ...]
|
||||
//!
|
||||
//! All four models must have had their dynamic dims pinned first; see
|
||||
//! `tools/fix-face-model-shapes.sh`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use dr_face::{align, DetectOptions, Detector, EyeModels, Pixels};
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let dump = args.iter().position(|a| a == "--dump").map(|i| {
|
||||
args.remove(i);
|
||||
PathBuf::from(args.remove(i))
|
||||
});
|
||||
if args.len() < 5 {
|
||||
eprintln!(
|
||||
"usage: eyes DET.onnx 2D106DET.onnx OCEC.onnx SGC.onnx [--dump DIR] IMAGE.jpg [IMAGE.jpg ...]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
if let Some(d) = &dump {
|
||||
std::fs::create_dir_all(d).expect("dump dir");
|
||||
}
|
||||
|
||||
let t = Instant::now();
|
||||
let mut detector = Detector::from_path(&args[0]).expect("load detector");
|
||||
let mut models = EyeModels::from_paths(&args[1], &args[2], &args[3]).expect("load eye models");
|
||||
println!("loaded the models in {:?}", t.elapsed());
|
||||
|
||||
let opts = DetectOptions::default();
|
||||
for path in &args[4..] {
|
||||
let (rgb, w, h) = match load_jpeg(path) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
println!("{path}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let dets = detector.detect(&rgb, w, h, &opts).expect("detect");
|
||||
println!("\n{path} ({w}×{h}) {} face(s)", dets.len());
|
||||
|
||||
let stem = Path::new(path)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
for (i, d) in dets.iter().enumerate() {
|
||||
let px = Pixels::RgbF32(&rgb);
|
||||
let t = Instant::now();
|
||||
let reading = models
|
||||
.read(px, w, h, d.bbox, &d.landmarks)
|
||||
.expect("read eyes");
|
||||
let ms = t.elapsed().as_secs_f64() * 1e3;
|
||||
let Some((r, lm)) = reading else {
|
||||
println!(" [{i}] nothing to cut, skipped");
|
||||
continue;
|
||||
};
|
||||
println!(
|
||||
" [{i}] conf {:.2} box {:.0}×{:.0} right {:.3} ({:.0}px, sharp {:.3}) left {:.3} ({:.0}px, sharp {:.3}) sunglasses {:.3} → {:?} ({ms:.1} ms)",
|
||||
d.confidence,
|
||||
d.width(),
|
||||
d.height(),
|
||||
r.right.open,
|
||||
r.right.px,
|
||||
r.right.sharpness,
|
||||
r.left.open,
|
||||
r.left.px,
|
||||
r.left.sharpness,
|
||||
r.sunglasses,
|
||||
r.state(),
|
||||
);
|
||||
if let Some(dir) = &dump {
|
||||
// The same crops `EyeModels::read` cut, cut again for the
|
||||
// sheet from the landmarks it handed back: the reading itself
|
||||
// carries numbers, not pixels.
|
||||
for (name, contour) in [("right", lm.right_eye()), ("left", lm.left_eye())] {
|
||||
if let Some(patch) =
|
||||
align::eye_box(&contour).and_then(|b| align::eye_patch(px, w, h, b))
|
||||
{
|
||||
write_ppm(
|
||||
&dir.join(format!("{stem}-{i}-{name}.ppm")),
|
||||
patch.pixels(),
|
||||
align::EYE_PATCH_WIDTH,
|
||||
align::EYE_PATCH_HEIGHT,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(head) = align::head_views(px, w, h, &d.landmarks) {
|
||||
for (n, view) in head.views().enumerate() {
|
||||
write_ppm(
|
||||
&dir.join(format!("{stem}-{i}-head{n}.ppm")),
|
||||
view,
|
||||
align::SUNGLASSES_EDGE,
|
||||
align::SUNGLASSES_EDGE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_ppm(path: &Path, rgb: &[f32], w: usize, h: usize) {
|
||||
let mut out = format!("P6\n{w} {h}\n255\n").into_bytes();
|
||||
out.extend(
|
||||
rgb.iter()
|
||||
.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8),
|
||||
);
|
||||
std::fs::write(path, out).expect("write ppm");
|
||||
}
|
||||
|
||||
/// Decode to the tightly packed `f32` RGB `0.0..=1.0` the crate expects.
|
||||
fn load_jpeg(path: &str) -> Result<(Vec<f32>, usize, usize), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let mut dec = zune_jpeg::JpegDecoder::new(&bytes);
|
||||
let px = dec.decode().map_err(|e| e.to_string())?;
|
||||
let info = dec.info().ok_or("no jpeg header")?;
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
|
||||
let rgb: Vec<f32> = match px.len() / (w * h) {
|
||||
3 => px.iter().map(|&v| v as f32 / 255.0).collect(),
|
||||
1 => px
|
||||
.iter()
|
||||
.flat_map(|&v| {
|
||||
let g = v as f32 / 255.0;
|
||||
[g, g, g]
|
||||
})
|
||||
.collect(),
|
||||
n => return Err(format!("{n} components per pixel, expected 1 or 3")),
|
||||
};
|
||||
Ok((rgb, w, h))
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
//! DET.onnx EMB.onnx photo.jpg [photo.jpg ...]
|
||||
//!
|
||||
//! The models must have had their input dims frozen first; see
|
||||
//! `tools/fix-face-model-shapes.sh` and docs/faces.md §12 M1.
|
||||
//! `tools/fix-face-model-shapes.sh` and docs/dev/faces.md §12 M1.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! M1 (docs/faces.md §12) — will tract load these graphs at all?
|
||||
//! M1 (docs/dev/faces.md §12) — will tract load these graphs at all?
|
||||
//!
|
||||
//! The one measurement everything else in the face subsystem is conditional
|
||||
//! on. `det_500m.onnx` has a dynamic H/W input, which is exactly what tract
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//!
|
||||
//! # What it is for
|
||||
//!
|
||||
//! docs/faces.md §9 has the desktop numbers and the question they leave open:
|
||||
//! docs/dev/faces.md §9 has the desktop numbers and the question they leave open:
|
||||
//! a GPU GEMM is worth roughly 1.5× of a regroup on a twenty-core desktop,
|
||||
//! because the scan is under a third of the pass there. On a tablet the CPU is
|
||||
//! several times slower and the GPU is not, so the same optimisation is worth
|
||||
|
||||
+469
-58
@@ -1,4 +1,4 @@
|
||||
//! Five-point face alignment (docs/faces.md §5).
|
||||
//! Five-point face alignment (docs/dev/faces.md §5).
|
||||
//!
|
||||
//! ArcFace embeddings are trained on faces warped to a canonical 112×112
|
||||
//! arrangement. Feeding the model a plain bounding-box crop *works* — it
|
||||
@@ -117,51 +117,56 @@ impl Aligned112 {
|
||||
/// `face_index --quality` prints the joint distribution so the two are
|
||||
/// chosen together rather than each in ignorance of the other.
|
||||
pub fn sharpness(&self) -> f32 {
|
||||
let e = ALIGNED_EDGE;
|
||||
let luma: Vec<f32> = self
|
||||
.pixels
|
||||
.chunks_exact(3)
|
||||
.map(|p| 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2])
|
||||
.collect();
|
||||
|
||||
let (mut lap_sum, mut lap_sq) = (0.0_f64, 0.0_f64);
|
||||
let (mut lum_sum, mut lum_sq) = (0.0_f64, 0.0_f64);
|
||||
let mut n = 0.0_f64;
|
||||
|
||||
for y in 1..e - 1 {
|
||||
for x in 1..e - 1 {
|
||||
let i = y * e + x;
|
||||
// Four-neighbour Laplacian. The 8-neighbour form is more
|
||||
// sensitive to diagonal detail and also to noise, which on a
|
||||
// high-ISO frame is exactly the thing that must not read as
|
||||
// sharpness.
|
||||
let lap = 4.0 * luma[i] - luma[i - 1] - luma[i + 1] - luma[i - e] - luma[i + e];
|
||||
let lap = lap as f64;
|
||||
lap_sum += lap;
|
||||
lap_sq += lap * lap;
|
||||
|
||||
let l = luma[i] as f64;
|
||||
lum_sum += l;
|
||||
lum_sq += l * l;
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
if n == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let lap_var = (lap_sq / n - (lap_sum / n).powi(2)).max(0.0);
|
||||
let lum_var = (lum_sq / n - (lum_sum / n).powi(2)).max(0.0);
|
||||
|
||||
// A crop with no luma variation has no edges to find either, so the
|
||||
// ratio is 0/0. Zero is the right answer: nothing there is a face.
|
||||
if lum_var <= 1e-9 {
|
||||
return 0.0;
|
||||
}
|
||||
(lap_var / lum_var) as f32
|
||||
laplacian_ratio(&self.pixels, ALIGNED_EDGE, ALIGNED_EDGE)
|
||||
}
|
||||
}
|
||||
|
||||
/// Variance of the four-neighbour Laplacian over the variance of the luma,
|
||||
/// for a `w × h` RGB crop — the measure [`Aligned112::sharpness`] describes,
|
||||
/// shared with [`EyePatch::sharpness`].
|
||||
fn laplacian_ratio(pixels: &[f32], w: usize, h: usize) -> f32 {
|
||||
let luma: Vec<f32> = pixels
|
||||
.chunks_exact(3)
|
||||
.map(|p| 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2])
|
||||
.collect();
|
||||
|
||||
let (mut lap_sum, mut lap_sq) = (0.0_f64, 0.0_f64);
|
||||
let (mut lum_sum, mut lum_sq) = (0.0_f64, 0.0_f64);
|
||||
let mut n = 0.0_f64;
|
||||
|
||||
for y in 1..h.saturating_sub(1) {
|
||||
for x in 1..w.saturating_sub(1) {
|
||||
let i = y * w + x;
|
||||
// Four-neighbour Laplacian. The 8-neighbour form is more
|
||||
// sensitive to diagonal detail and also to noise, which on a
|
||||
// high-ISO frame is exactly the thing that must not read as
|
||||
// sharpness.
|
||||
let lap = 4.0 * luma[i] - luma[i - 1] - luma[i + 1] - luma[i - w] - luma[i + w];
|
||||
let lap = lap as f64;
|
||||
lap_sum += lap;
|
||||
lap_sq += lap * lap;
|
||||
|
||||
let l = luma[i] as f64;
|
||||
lum_sum += l;
|
||||
lum_sq += l * l;
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
if n == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let lap_var = (lap_sq / n - (lap_sum / n).powi(2)).max(0.0);
|
||||
let lum_var = (lum_sq / n - (lum_sum / n).powi(2)).max(0.0);
|
||||
|
||||
// A crop with no luma variation has no edges to find either, so the
|
||||
// ratio is 0/0. Zero is the right answer: nothing there is a face.
|
||||
if lum_var <= 1e-9 {
|
||||
return 0.0;
|
||||
}
|
||||
(lap_var / lum_var) as f32
|
||||
}
|
||||
|
||||
/// A similarity transform: rotation, uniform scale, translation.
|
||||
///
|
||||
/// Stored as the four independent parameters rather than a 2×3 matrix so that
|
||||
@@ -203,7 +208,7 @@ impl Similarity {
|
||||
///
|
||||
/// # Why least squares and not RANSAC
|
||||
///
|
||||
/// The reference C++ implementation (docs/faces.md §1.1) fits this with
|
||||
/// The reference C++ implementation (docs/dev/faces.md §1.1) fits this with
|
||||
/// OpenCV's `estimateAffinePartial2D` under RANSAC. RANSAC over five points is
|
||||
/// a strange fit: the minimal sample for a similarity is two, so it can discard
|
||||
/// landmarks it judges outliers and solve from a subset — and on a profile face
|
||||
@@ -351,27 +356,314 @@ pub fn warp_pixels(
|
||||
let m = fit_similarity(landmarks, &ARCFACE_TEMPLATE)?;
|
||||
|
||||
let e = ALIGNED_EDGE;
|
||||
let mut pixels = vec![0.0_f32; e * e * 3];
|
||||
for v in 0..e {
|
||||
for u in 0..e {
|
||||
// Pixel centres, so the transform is not off by half a pixel —
|
||||
// which is small enough to survive review and large enough to
|
||||
// matter on a 40-pixel face.
|
||||
let (x, y) = m.invert(u as f32 + 0.5, v as f32 + 0.5);
|
||||
let (x, y) = (x - 0.5, y - 0.5);
|
||||
let out = (v * e + u) * 3;
|
||||
sample_bilinear(px, width, height, x, y, &mut pixels[out..out + 3]);
|
||||
}
|
||||
}
|
||||
|
||||
let window = TemplateWindow {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: e as f32,
|
||||
h: e as f32,
|
||||
};
|
||||
Some(Aligned112 {
|
||||
pixels,
|
||||
pixels: sample_window(px, width, height, &m, &window, e, e),
|
||||
// The warp maps `scale` source pixels to one destination pixel, so the
|
||||
// crop spans 112/scale of the source.
|
||||
source_px: ALIGNED_EDGE as f32 / m.scale(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A rectangle in **template** coordinates — the 112-unit frame
|
||||
/// [`ARCFACE_TEMPLATE`] is written in — that a crop is sampled from.
|
||||
///
|
||||
/// Every crop this module makes is one of these resampled through the same
|
||||
/// fitted similarity: the aligned face is the window `(0, 0, 112, 112)`, an
|
||||
/// eye is a small window around its template point, a head is a window larger
|
||||
/// than the face. Stating them all in one frame is what lets a second crop be
|
||||
/// added as a constant rather than a second warp, and what keeps them
|
||||
/// consistent with each other — the eye window sits where the eye landmark
|
||||
/// lands *after* alignment, so a tilted face gets an upright eye.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct TemplateWindow {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
}
|
||||
|
||||
/// Resample `window` of the template frame into an `out_w × out_h` RGB buffer.
|
||||
///
|
||||
/// Bilinear, from the source, in one step — the property [`warp`] insists on,
|
||||
/// and every crop through here inherits it. The output pixel `(u, v)` is placed
|
||||
/// at its centre in the window, taken back through `m` to source coordinates,
|
||||
/// and sampled there; the window's aspect is **not** preserved when it differs
|
||||
/// from the output's, which is deliberate for the eye classifier (it was
|
||||
/// trained on detector boxes resized the same way) and moot for the others.
|
||||
fn sample_window(
|
||||
px: Pixels<'_>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
m: &Similarity,
|
||||
window: &TemplateWindow,
|
||||
out_w: usize,
|
||||
out_h: usize,
|
||||
) -> Vec<f32> {
|
||||
let mut pixels = vec![0.0_f32; out_w * out_h * 3];
|
||||
let sx = window.w / out_w as f32;
|
||||
let sy = window.h / out_h as f32;
|
||||
for v in 0..out_h {
|
||||
for u in 0..out_w {
|
||||
// Pixel centres, so the transform is not off by half a pixel —
|
||||
// which is small enough to survive review and large enough to
|
||||
// matter on a 40-pixel face.
|
||||
let tx = window.x + (u as f32 + 0.5) * sx;
|
||||
let ty = window.y + (v as f32 + 0.5) * sy;
|
||||
let (x, y) = m.invert(tx, ty);
|
||||
let (x, y) = (x - 0.5, y - 0.5);
|
||||
let out = (v * out_w + u) * 3;
|
||||
sample_bilinear(px, width, height, x, y, &mut pixels[out..out + 3]);
|
||||
}
|
||||
}
|
||||
pixels
|
||||
}
|
||||
|
||||
// ── eyes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Width of an eye crop as the classifier reads it, in pixels. Fixed by the
|
||||
/// OCEC input (`docs/dev/faces.md` §17): 40 wide, 24 high.
|
||||
pub const EYE_PATCH_WIDTH: usize = 40;
|
||||
/// Height of an eye crop as the classifier reads it, in pixels.
|
||||
pub const EYE_PATCH_HEIGHT: usize = 24;
|
||||
|
||||
/// How much an eye's box is grown beyond its lid contour, as a fraction of
|
||||
/// its width and height on each side.
|
||||
///
|
||||
/// The classifier was trained on a whole-body detector's *eye* boxes — tight
|
||||
/// round the palpebral fissure — and measured on 25 open-eyed faces from the
|
||||
/// reference library, a tight box is what it wants: 22 of 25 read open at
|
||||
/// 0 and 0.1, 18 at 0.4, 14 at 0.6 (docs/dev/faces.md §17.2). A tenth, so a
|
||||
/// contour landing a pixel short of the lashes still holds them.
|
||||
pub const EYE_BOX_MARGIN: f32 = 0.1;
|
||||
|
||||
/// Height a shut eye's box is given, as a fraction of its width.
|
||||
///
|
||||
/// A closed eye's contour has no height. The box is given the height an
|
||||
/// open eye of the same width would have, so the classifier sees the same
|
||||
/// framing either way — which is what it was trained on.
|
||||
pub const EYE_BOX_MIN_ASPECT: f32 = 0.4;
|
||||
|
||||
/// The box round an eye's lid contour, in the contour's own coordinates:
|
||||
/// `(x, y, w, h)`.
|
||||
///
|
||||
/// Model-free: the contour is whatever the landmark model gave for the ten
|
||||
/// (or so) points on the lids, in source pixels. `None` for an empty
|
||||
/// contour or one with no width, which is what a hidden eye's collapsed
|
||||
/// contour can come to.
|
||||
pub fn eye_box(contour: &[(f32, f32)]) -> Option<(f32, f32, f32, f32)> {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||
for &(x, y) in contour {
|
||||
x0 = x0.min(x);
|
||||
y0 = y0.min(y);
|
||||
x1 = x1.max(x);
|
||||
y1 = y1.max(y);
|
||||
}
|
||||
let w = x1 - x0;
|
||||
if contour.is_empty() || w <= 0.0 || w.is_nan() {
|
||||
return None;
|
||||
}
|
||||
let h = (y1 - y0).max(w * EYE_BOX_MIN_ASPECT);
|
||||
let cy = (y0 + y1) / 2.0;
|
||||
let (mx, my) = (w * EYE_BOX_MARGIN, h * EYE_BOX_MARGIN);
|
||||
Some((x0 - mx, cy - h / 2.0 - my, w + 2.0 * mx, h + 2.0 * my))
|
||||
}
|
||||
|
||||
/// One eye, resampled to the classifier's input.
|
||||
///
|
||||
/// Constructible only by [`eye_patch`], for the reason [`Aligned112`] is
|
||||
/// only constructible by [`warp`]: the classifier accepting a plain buffer
|
||||
/// would accept any 40×24 of anything, and its answer would still be a
|
||||
/// plausible probability.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct EyePatch {
|
||||
/// `24 × 40 × 3`, row-major RGB in `0.0..=1.0`.
|
||||
pixels: Vec<f32>,
|
||||
/// Source pixels across the box the patch was cut from.
|
||||
source_px: f32,
|
||||
}
|
||||
|
||||
impl EyePatch {
|
||||
pub fn pixels(&self) -> &[f32] {
|
||||
&self.pixels
|
||||
}
|
||||
|
||||
/// Source pixels across the eye box — how much eye there was to read.
|
||||
///
|
||||
/// The classifier was trained down to eyes a dozen pixels wide, and
|
||||
/// below that a crop is an interpolation of nothing; `crate::eyes` draws
|
||||
/// the line. Zero when the box had no width, which is a hidden eye.
|
||||
pub fn source_px(&self) -> f32 {
|
||||
self.source_px
|
||||
}
|
||||
|
||||
/// How sharp the eye the classifier is about to see actually is —
|
||||
/// [`Aligned112::sharpness`]'s measure, over the patch.
|
||||
///
|
||||
/// The reason it exists is the reason the face's does: a soft eye is
|
||||
/// not a closed one, but a classifier shown a smear says "closed" with
|
||||
/// the same confidence it says anything, and the only defence is to
|
||||
/// not ask. A face sharp enough to embed can still hold an eye too soft
|
||||
/// to read — it is a fortieth of the face — so the measure is taken
|
||||
/// here and not inherited from the crop.
|
||||
pub fn sharpness(&self) -> f32 {
|
||||
laplacian_ratio(&self.pixels, EYE_PATCH_WIDTH, EYE_PATCH_HEIGHT)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut an eye out of the source at the classifier's size, from an
|
||||
/// axis-aligned box in source pixels — [`eye_box`]'s, as a rule.
|
||||
///
|
||||
/// Upright and from the frame, not through the face's alignment: the
|
||||
/// classifier's training crops were detector boxes, and a landmark model's
|
||||
/// contour already says where the eye is on a tilted head. Bilinear in one
|
||||
/// step from the native buffer, so a large face gives real pixels; the
|
||||
/// box's aspect is not preserved, which is what the training resize did.
|
||||
pub fn eye_patch(
|
||||
px: Pixels<'_>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
bbox: (f32, f32, f32, f32),
|
||||
) -> Option<EyePatch> {
|
||||
let pixels = crop_box(px, width, height, bbox, EYE_PATCH_WIDTH, EYE_PATCH_HEIGHT)?;
|
||||
Some(EyePatch {
|
||||
pixels,
|
||||
source_px: bbox.2,
|
||||
})
|
||||
}
|
||||
|
||||
// ── sunglasses ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Edge of the crop the sunglasses classifier reads. Fixed by the SGC input:
|
||||
/// 48×48.
|
||||
pub const SUNGLASSES_EDGE: usize = 48;
|
||||
|
||||
/// The windows read for the sunglasses classifier, in template units:
|
||||
/// `(x, y, w, h)`.
|
||||
///
|
||||
/// **Two framings, and the classifier's answer is the higher of the two.**
|
||||
/// It was trained on a whole-body detector's *head* boxes, and a head box
|
||||
/// is not reproducible from five landmarks: how much hair and hat it took in
|
||||
/// depended on the person. So it is shown the face twice — once as the
|
||||
/// aligned crop itself, once shifted up and widened to take in hair and
|
||||
/// hat at the cost of the chin, which is roughly where a head box falls —
|
||||
/// and a pair of sunglasses counts if it looks like one in either.
|
||||
///
|
||||
/// Measured over 12 faces in sunglasses and 28 with plainly visible eyes
|
||||
/// from the reference library (`examples/eyes.rs --head`), at the 0.5
|
||||
/// threshold:
|
||||
///
|
||||
/// | window | sunglasses found | clear eyes kept |
|
||||
/// |---|---|---|
|
||||
/// | the aligned face, `(0, 0, 112, 112)` | 9 | 28 |
|
||||
/// | a head, `(-5, -14, 122, 122)` | 6 | 27 |
|
||||
/// | a larger head, `(-30, -55, 172, 190)` | 6 | 25 |
|
||||
/// | **the higher of the first two** | **11** | 27 |
|
||||
///
|
||||
/// The face-tight crop alone was the best single framing, which was not the
|
||||
/// expectation; the head framing found the sunglasses under a cap that the
|
||||
/// face crop missed. The one clear-eyed face the pair loses wears a cap and
|
||||
/// clear glasses, at 0.68. Erring towards "sunglasses" is the safe direction
|
||||
/// for what this feeds: a face called sunglasses is left alone by the
|
||||
/// eyes-open filter, where a pair of sunglasses missed hands the eye
|
||||
/// classifier a lens to guess at (docs/dev/faces.md §17).
|
||||
pub const SUNGLASSES_WINDOWS: [(f32, f32, f32, f32); 2] =
|
||||
[(0.0, 0.0, 112.0, 112.0), (-5.0, -14.0, 122.0, 122.0)];
|
||||
|
||||
/// The framings of one face the sunglasses classifier is shown.
|
||||
///
|
||||
/// A newtype for the reason [`EyePatch`] is one.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct HeadViews {
|
||||
/// Each `48 × 48 × 3`, row-major RGB in `0.0..=1.0`.
|
||||
views: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl HeadViews {
|
||||
pub fn views(&self) -> impl Iterator<Item = &[f32]> {
|
||||
self.views.iter().map(Vec::as_slice)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut the [`SUNGLASSES_WINDOWS`] out of the source, aligned, at the
|
||||
/// classifier's size.
|
||||
pub fn head_views(
|
||||
px: Pixels<'_>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
landmarks: &[(f32, f32); 5],
|
||||
) -> Option<HeadViews> {
|
||||
head_views_in(px, width, height, landmarks, &SUNGLASSES_WINDOWS)
|
||||
}
|
||||
|
||||
/// [`head_views`] over windows other than [`SUNGLASSES_WINDOWS`].
|
||||
///
|
||||
/// For measuring them, which is how the constant was chosen
|
||||
/// (`examples/eyes.rs --head`); production callers use the constant.
|
||||
pub fn head_views_in(
|
||||
px: Pixels<'_>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
landmarks: &[(f32, f32); 5],
|
||||
windows: &[(f32, f32, f32, f32)],
|
||||
) -> Option<HeadViews> {
|
||||
if !px.fits(width, height) || windows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let m = fit_similarity(landmarks, &ARCFACE_TEMPLATE)?;
|
||||
let views = windows
|
||||
.iter()
|
||||
.map(|&(x, y, w, h)| {
|
||||
let window = TemplateWindow { x, y, w, h };
|
||||
sample_window(
|
||||
px,
|
||||
width,
|
||||
height,
|
||||
&m,
|
||||
&window,
|
||||
SUNGLASSES_EDGE,
|
||||
SUNGLASSES_EDGE,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Some(HeadViews { views })
|
||||
}
|
||||
|
||||
/// An axis-aligned crop of the source, resampled to `out_w × out_h` RGB.
|
||||
///
|
||||
/// `(x, y, w, h)` in source pixels; the aspect is not preserved when it
|
||||
/// differs from the output's. Bilinear in one step, like every crop here;
|
||||
/// pixels outside the source read black. What a landmark model trained on
|
||||
/// detector boxes wants — upright, from the frame — as against the aligned
|
||||
/// windows above.
|
||||
pub fn crop_box(
|
||||
px: Pixels<'_>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
(x, y, w, h): (f32, f32, f32, f32),
|
||||
out_w: usize,
|
||||
out_h: usize,
|
||||
) -> Option<Vec<f32>> {
|
||||
if !px.fits(width, height) || w <= 0.0 || h <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let identity = Similarity {
|
||||
a: 1.0,
|
||||
b: 0.0,
|
||||
tx: 0.0,
|
||||
ty: 0.0,
|
||||
};
|
||||
let window = TemplateWindow { x, y, w, h };
|
||||
Some(sample_window(
|
||||
px, width, height, &identity, &window, out_w, out_h,
|
||||
))
|
||||
}
|
||||
|
||||
fn sample_bilinear(px: Pixels<'_>, w: usize, h: usize, x: f32, y: f32, out: &mut [f32]) {
|
||||
let x0 = x.floor();
|
||||
let y0 = y.floor();
|
||||
@@ -489,6 +781,125 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A source whose red channel is its x coordinate and green its y, so a
|
||||
/// crop's mean colour says where in the source it was taken from.
|
||||
fn coordinate_image(w: usize, h: usize) -> Vec<f32> {
|
||||
let mut rgb = vec![0.0_f32; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
rgb[(y * w + x) * 3] = x as f32 / w as f32;
|
||||
rgb[(y * w + x) * 3 + 1] = y as f32 / h as f32;
|
||||
}
|
||||
}
|
||||
rgb
|
||||
}
|
||||
|
||||
fn mean_channel(px: &[f32], c: usize) -> f32 {
|
||||
let n = px.len() / 3;
|
||||
px.chunks_exact(3).map(|p| p[c]).sum::<f32>() / n as f32
|
||||
}
|
||||
|
||||
/// The box is the contour's bounds, grown by the margin, and a shut
|
||||
/// eye's flat contour is given an open eye's height.
|
||||
#[test]
|
||||
fn an_eye_box_holds_its_contour_with_a_margin() {
|
||||
let open = [(100.0, 50.0), (110.0, 46.0), (120.0, 50.0), (110.0, 54.0)];
|
||||
let (x, y, w, h) = eye_box(&open).unwrap();
|
||||
assert!((w - 20.0 * (1.0 + 2.0 * EYE_BOX_MARGIN)).abs() < 1e-4);
|
||||
assert!((h - 8.0 * (1.0 + 2.0 * EYE_BOX_MARGIN)).abs() < 1e-4);
|
||||
assert!((x + w / 2.0 - 110.0).abs() < 1e-4);
|
||||
assert!((y + h / 2.0 - 50.0).abs() < 1e-4);
|
||||
|
||||
let shut = [(100.0, 50.0), (110.0, 50.0), (120.0, 50.0)];
|
||||
let (_, _, w2, h2) = eye_box(&shut).unwrap();
|
||||
assert!((w2 - w).abs() < 1e-4, "same width");
|
||||
assert!((h2 - 20.0 * EYE_BOX_MIN_ASPECT * (1.0 + 2.0 * EYE_BOX_MARGIN)).abs() < 1e-4);
|
||||
|
||||
assert!(eye_box(&[]).is_none());
|
||||
assert!(eye_box(&[(5.0, 5.0), (5.0, 9.0)]).is_none(), "no width");
|
||||
}
|
||||
|
||||
/// The patch is cut from the box it was given, upright, and knows how
|
||||
/// many source pixels it spans.
|
||||
#[test]
|
||||
fn an_eye_patch_is_the_box_resampled() {
|
||||
let (w, h) = (200, 200);
|
||||
let rgb = coordinate_image(w, h);
|
||||
let bbox = (60.0, 90.0, 30.0, 12.0);
|
||||
let eye = eye_patch(Pixels::RgbF32(&rgb), w, h, bbox).unwrap();
|
||||
assert_eq!(eye.pixels().len(), EYE_PATCH_WIDTH * EYE_PATCH_HEIGHT * 3);
|
||||
assert_eq!(eye.source_px(), 30.0);
|
||||
let cx = mean_channel(eye.pixels(), 0) * w as f32;
|
||||
let cy = mean_channel(eye.pixels(), 1) * h as f32;
|
||||
assert!((cx - 75.0).abs() < 0.6, "{cx}");
|
||||
assert!((cy - 96.0).abs() < 0.6, "{cy}");
|
||||
// No width, or a buffer that is not the size it claims: nothing.
|
||||
assert!(eye_patch(Pixels::RgbF32(&rgb), w, h, (60.0, 90.0, 0.0, 12.0)).is_none());
|
||||
assert!(eye_patch(Pixels::RgbF32(&rgb), 190, 200, bbox).is_none());
|
||||
}
|
||||
|
||||
/// A soft eye scores lower than the same eye sharp, on the patch itself.
|
||||
#[test]
|
||||
fn an_eye_patchs_sharpness_falls_with_blur() {
|
||||
let edge = 120;
|
||||
let sharp = image(
|
||||
edge,
|
||||
|x, y| if (x / 5 + y / 5) % 2 == 0 { 0.9 } else { 0.1 },
|
||||
);
|
||||
let soft = blur(&blur(&sharp, edge), edge);
|
||||
let bbox = (20.0, 40.0, 40.0, 24.0);
|
||||
let a = eye_patch(Pixels::RgbF32(&sharp), edge, edge, bbox)
|
||||
.unwrap()
|
||||
.sharpness();
|
||||
let b = eye_patch(Pixels::RgbF32(&soft), edge, edge, bbox)
|
||||
.unwrap()
|
||||
.sharpness();
|
||||
assert!(a > b * 2.0, "sharp {a} should clearly beat blurred {b}");
|
||||
}
|
||||
|
||||
/// The second sunglasses framing takes in more than the face — it starts
|
||||
/// above the template's top edge and ends below its bottom — and the
|
||||
/// first is the aligned face itself.
|
||||
#[test]
|
||||
fn the_head_views_are_the_face_and_a_wider_framing_of_it() {
|
||||
let (w, h) = (300, 300);
|
||||
let rgb = coordinate_image(w, h);
|
||||
let lm = shifted_scaled(1.0, 100.0, 100.0, 0.0);
|
||||
let head = head_views(Pixels::RgbF32(&rgb), w, h, &lm).unwrap();
|
||||
let views: Vec<&[f32]> = head.views().collect();
|
||||
let face = warp(&rgb, w, h, &lm).unwrap();
|
||||
assert_eq!(views.len(), SUNGLASSES_WINDOWS.len());
|
||||
for v in &views {
|
||||
assert_eq!(v.len(), SUNGLASSES_EDGE * SUNGLASSES_EDGE * 3);
|
||||
}
|
||||
|
||||
// The face view samples the same region as the aligned crop.
|
||||
assert!((mean_channel(views[0], 0) - mean_channel(face.pixels(), 0)).abs() < 0.01);
|
||||
assert!((mean_channel(views[0], 1) - mean_channel(face.pixels(), 1)).abs() < 0.01);
|
||||
|
||||
let (x, y, ww, hh) = SUNGLASSES_WINDOWS[1];
|
||||
assert!(
|
||||
x < 0.0 && y < 0.0,
|
||||
"the window starts outside the face crop"
|
||||
);
|
||||
assert!(x + ww > ALIGNED_EDGE as f32, "and is wider than it");
|
||||
assert!(y + hh < ALIGNED_EDGE as f32, "but stops short of the chin");
|
||||
// Centred horizontally on the face, so the two share a mean x.
|
||||
assert!((mean_channel(views[1], 0) - mean_channel(face.pixels(), 0)).abs() < 0.01);
|
||||
// Its first row lies above the face's first row.
|
||||
assert!(views[1][1] < face.pixels()[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_landmarks_yield_no_head_crop() {
|
||||
let rgb = vec![0.5_f32; 64 * 64 * 3];
|
||||
let degenerate = [(50.0, 50.0); 5];
|
||||
assert!(head_views(Pixels::RgbF32(&rgb), 64, 64, °enerate).is_none());
|
||||
// And a buffer that is not the size it claims.
|
||||
let lm = shifted_scaled(1.0, 0.0, 0.0, 0.0);
|
||||
assert!(head_views(Pixels::RgbF32(&rgb), 60, 60, &lm).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_bounds_samples_read_black_rather_than_wrapping() {
|
||||
let rgb = vec![1.0_f32; 32 * 32 * 3];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Cosine to probability (docs/faces.md §8, FR-CULL-9).
|
||||
//! Cosine to probability (docs/dev/faces.md §8, FR-CULL-9).
|
||||
//!
|
||||
//! FR-CULL-9 is a hard requirement rather than an implementation detail: no
|
||||
//! code path may threshold a bare cosine, every threshold in the subsystem is
|
||||
@@ -28,7 +28,7 @@
|
||||
//! calibration to the belief it was supposed to test — and that is the whole
|
||||
//! of the alternative.
|
||||
//!
|
||||
//! docs/faces.md §8.1 names one more that would cost no labelling at all: two
|
||||
//! docs/dev/faces.md §8.1 names one more that would cost no labelling at all: two
|
||||
//! faces in adjacent frames of one burst are near-certainly the same person,
|
||||
//! and FR-CULL-5's grouping is sitting there. Nothing draws on it. This crate
|
||||
//! cannot see a catalog, let alone the bursts in one — it is handed cosines by
|
||||
@@ -83,7 +83,7 @@ pub struct Calibration {
|
||||
}
|
||||
|
||||
impl Default for Calibration {
|
||||
/// The reference implementation's fitted MBF curve (docs/faces.md §1):
|
||||
/// The reference implementation's fitted MBF curve (docs/dev/faces.md §1):
|
||||
/// steepness 16.2, P=0.5 at cosine 0.267.
|
||||
///
|
||||
/// **`valid` is false**, and that is the point. It is a documented
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! TRACES: FR-CULL-8a
|
||||
//! The two small classifiers behind a face's eye state (docs/dev/faces.md §17).
|
||||
//!
|
||||
//! **OCEC** — *open closed eyes classification*, Hyodo 2025 — reads one
|
||||
//! 40×24 eye and answers P(open). **SGC** — *sunglasses classification*,
|
||||
//! Hyodo 2026 — reads a 48×48 head and answers P(sunglasses); it is shown
|
||||
//! two framings of each face and the higher answer stands, for the reason
|
||||
//! [`crate::align::SUNGLASSES_WINDOWS`] gives. Both are
|
||||
//! depthwise-separable CNNs of a few hundred kilobytes, both MIT with their
|
||||
//! weights, and both were exported with BatchNorm already folded, which is
|
||||
//! about the friendliest graph tract can be handed.
|
||||
//!
|
||||
//! Neither takes a plain buffer. [`EyeClassifier::classify`] takes an
|
||||
//! [`EyePatch`] and [`SunglassesClassifier::classify`] a [`HeadViews`], each
|
||||
//! constructible only by the crop in [`crate::align`] that puts the right
|
||||
//! pixels in it — the same defence [`crate::embed::Embedder`] makes with
|
||||
//! [`crate::align::Aligned112`], for the same reason: a classifier handed the
|
||||
//! wrong region returns a confident probability of nothing. Where the eye
|
||||
//! box comes from is [`crate::landmarks`]; [`EyeModels::read`] is the whole
|
||||
//! chain.
|
||||
//!
|
||||
//! # The graphs must have a fixed batch
|
||||
//!
|
||||
//! Both ship with a dynamic batch dimension, which tract will not analyse.
|
||||
//! `tools/fix-face-model-shapes.sh` pins it to 1, exactly as it does for the
|
||||
//! embedder; the shipped files are the pinned ones.
|
||||
//!
|
||||
//! # Pre-processing
|
||||
//!
|
||||
//! Read off the reference demos rather than assumed: RGB, `x / 255`, NCHW,
|
||||
//! the crop resized to the input with bilinear interpolation and **without**
|
||||
//! preserving its aspect. [`crate::align`]'s crops arrive already at the
|
||||
//! input size in `0..=1`, so there is nothing left to do but lay them out.
|
||||
|
||||
use ndarray::Array4;
|
||||
|
||||
use crate::align::{
|
||||
eye_box, eye_patch, head_views, EyePatch, HeadViews, EYE_PATCH_HEIGHT, EYE_PATCH_WIDTH,
|
||||
SUNGLASSES_EDGE,
|
||||
};
|
||||
use crate::eyes::{Eye, EyeReading};
|
||||
use crate::landmarks::{Landmarker, Landmarks};
|
||||
use crate::{FaceError, Pixels};
|
||||
use dr_inference_engine::{Form, Model, Role};
|
||||
|
||||
/// A loaded OCEC graph.
|
||||
pub struct EyeClassifier {
|
||||
session: Model,
|
||||
}
|
||||
|
||||
/// A loaded SGC graph.
|
||||
pub struct SunglassesClassifier {
|
||||
session: Model,
|
||||
}
|
||||
|
||||
/// Open a single-input, single-output classifier and check it is the shape
|
||||
/// the crop feeding it will be.
|
||||
///
|
||||
/// The check is against the *input*, because that is where these two graphs
|
||||
/// differ from each other and from everything else in this crate: an SGC file
|
||||
/// given to the eye classifier would otherwise be resized into by an eye
|
||||
/// patch, and answer. `expected` names the model in the error.
|
||||
fn open_classifier(
|
||||
bytes: &[u8],
|
||||
expected: &'static str,
|
||||
(h, w): (usize, usize),
|
||||
) -> Result<Model, FaceError> {
|
||||
let model = dr_inference_engine::open(Role::EyeClassifier, Form::F32, bytes)?;
|
||||
let acquired = model.acquire()?;
|
||||
let session = acquired.lock();
|
||||
|
||||
let input = session.inputs().first().ok_or(FaceError::WrongModel {
|
||||
expected,
|
||||
detail: "model has no inputs".into(),
|
||||
})?;
|
||||
let shape: Option<Vec<i64>> = input.dtype().tensor_shape().map(|s| s.to_vec());
|
||||
let want = [1, 3, h as i64, w as i64];
|
||||
if shape.as_deref() != Some(&want[..]) {
|
||||
return Err(FaceError::WrongModel {
|
||||
expected,
|
||||
detail: format!(
|
||||
"input '{}' is {:?}, expected {:?} (batch pinned to 1)",
|
||||
input.name(),
|
||||
shape,
|
||||
want
|
||||
),
|
||||
});
|
||||
}
|
||||
if session.outputs().len() != 1 {
|
||||
return Err(FaceError::WrongModel {
|
||||
expected,
|
||||
detail: format!("{} outputs, expected one", session.outputs().len()),
|
||||
});
|
||||
}
|
||||
drop(session);
|
||||
drop(acquired);
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// Lay a `h × w` RGB crop out as the `[1, 3, h, w]` tensor both graphs take.
|
||||
fn to_nchw(pixels: &[f32], h: usize, w: usize) -> Array4<f32> {
|
||||
let mut input = Array4::<f32>::zeros((1, 3, h, w));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
for c in 0..3 {
|
||||
input[[0, c, y, x]] = pixels[(y * w + x) * 3 + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
input
|
||||
}
|
||||
|
||||
/// Run a one-number classifier and read its sigmoid back, clamped.
|
||||
fn run_scalar(model: &Model, input: Array4<f32>, expected: &'static str) -> Result<f32, FaceError> {
|
||||
let acquired = model.acquire()?;
|
||||
let mut session = acquired.lock();
|
||||
let outputs = session
|
||||
.run(ort::inputs![
|
||||
ort::value::Tensor::from_array(input).map_err(FaceError::Inference)?
|
||||
])
|
||||
.map_err(FaceError::Inference)?;
|
||||
let (_, data) = outputs[0]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(FaceError::Inference)?;
|
||||
let Some(&p) = data.first() else {
|
||||
return Err(FaceError::WrongModel {
|
||||
expected,
|
||||
detail: "empty output".into(),
|
||||
});
|
||||
};
|
||||
// The graph ends in a sigmoid, so this is a clamp against rounding and
|
||||
// nothing more — the reference demo does the same.
|
||||
Ok(p.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
impl EyeClassifier {
|
||||
pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self, FaceError> {
|
||||
let bytes = std::fs::read(path).map_err(FaceError::ModelRead)?;
|
||||
Self::from_bytes(&bytes)
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, FaceError> {
|
||||
Ok(Self {
|
||||
session: open_classifier(bytes, "OCEC", (EYE_PATCH_HEIGHT, EYE_PATCH_WIDTH))?,
|
||||
})
|
||||
}
|
||||
|
||||
/// P(open) for one eye.
|
||||
pub fn classify(&mut self, eye: &EyePatch) -> Result<f32, FaceError> {
|
||||
let input = to_nchw(eye.pixels(), EYE_PATCH_HEIGHT, EYE_PATCH_WIDTH);
|
||||
run_scalar(&self.session, input, "OCEC")
|
||||
}
|
||||
}
|
||||
|
||||
impl SunglassesClassifier {
|
||||
pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self, FaceError> {
|
||||
let bytes = std::fs::read(path).map_err(FaceError::ModelRead)?;
|
||||
Self::from_bytes(&bytes)
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, FaceError> {
|
||||
Ok(Self {
|
||||
session: open_classifier(bytes, "SGC", (SUNGLASSES_EDGE, SUNGLASSES_EDGE))?,
|
||||
})
|
||||
}
|
||||
|
||||
/// P(sunglasses) for one head: the highest answer over its framings.
|
||||
pub fn classify(&mut self, head: &HeadViews) -> Result<f32, FaceError> {
|
||||
let mut best = 0.0_f32;
|
||||
for view in head.views() {
|
||||
let input = to_nchw(view, SUNGLASSES_EDGE, SUNGLASSES_EDGE);
|
||||
best = best.max(run_scalar(&self.session, input, "SGC")?);
|
||||
}
|
||||
Ok(best)
|
||||
}
|
||||
}
|
||||
|
||||
/// The three models behind a reading, which is how every caller holds them.
|
||||
///
|
||||
/// One struct rather than three optional parameters, because a partial
|
||||
/// reading is not a reading: an eye state with no sunglasses number behind
|
||||
/// it is exactly the beach-photograph failure [`crate::eyes`] describes, and
|
||||
/// an eye box without the landmarks is the loose one this module replaced.
|
||||
/// The models load together or not at all.
|
||||
pub struct EyeModels {
|
||||
pub landmarks: Landmarker,
|
||||
pub eyes: EyeClassifier,
|
||||
pub sunglasses: SunglassesClassifier,
|
||||
}
|
||||
|
||||
impl EyeModels {
|
||||
pub fn from_paths(
|
||||
landmarks: impl AsRef<std::path::Path>,
|
||||
eyes: impl AsRef<std::path::Path>,
|
||||
sunglasses: impl AsRef<std::path::Path>,
|
||||
) -> Result<Self, FaceError> {
|
||||
Ok(Self {
|
||||
landmarks: Landmarker::from_path(landmarks)?,
|
||||
eyes: EyeClassifier::from_path(eyes)?,
|
||||
sunglasses: SunglassesClassifier::from_path(sunglasses)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read one face's eyes, and hand back the dense landmarks it read them
|
||||
/// from.
|
||||
///
|
||||
/// `bbox` is the detector's `(x0, y0, x1, y1)` and `landmarks5` its five
|
||||
/// points, both in source pixels; the buffer is the one the aligned
|
||||
/// crop was taken from, so an eye is read from the same pixels the
|
||||
/// embedder saw the face in. `None` where nothing could be cut — a
|
||||
/// degenerate box or landmarks — which the caller stores as "not read".
|
||||
///
|
||||
/// The landmarks come back because they cost a model run the caller will
|
||||
/// not want to pay twice: stored beside the reading, a later pass over
|
||||
/// faces — head pose, expression — has them without the original.
|
||||
pub fn read(
|
||||
&mut self,
|
||||
px: Pixels<'_>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
bbox: (f32, f32, f32, f32),
|
||||
landmarks5: &[(f32, f32); 5],
|
||||
) -> Result<Option<(EyeReading, Landmarks)>, FaceError> {
|
||||
let Some(lm) = self.landmarks.landmarks(px, width, height, bbox)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(head) = head_views(px, width, height, landmarks5) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut eye = |contour: &[(f32, f32)]| -> Result<Eye, FaceError> {
|
||||
// A hidden eye's contour can collapse to no width. Its numbers
|
||||
// are then zero — no pixels, no sharpness — which is what the
|
||||
// rule in `crate::eyes` reads as "not readable".
|
||||
let Some(b) = eye_box(contour) else {
|
||||
return Ok(Eye {
|
||||
open: 0.0,
|
||||
px: 0.0,
|
||||
sharpness: 0.0,
|
||||
});
|
||||
};
|
||||
let Some(patch) = eye_patch(px, width, height, b) else {
|
||||
return Ok(Eye {
|
||||
open: 0.0,
|
||||
px: 0.0,
|
||||
sharpness: 0.0,
|
||||
});
|
||||
};
|
||||
Ok(Eye {
|
||||
open: self.eyes.classify(&patch)?,
|
||||
px: patch.source_px(),
|
||||
sharpness: patch.sharpness(),
|
||||
})
|
||||
};
|
||||
let right = eye(&lm.right_eye())?;
|
||||
let left = eye(&lm.left_eye())?;
|
||||
let reading = EyeReading {
|
||||
right,
|
||||
left,
|
||||
sunglasses: self.sunglasses.classify(&head)?,
|
||||
};
|
||||
Ok(Some((reading, lm)))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Grouping faces into people (docs/faces.md §9, FR-CULL-10).
|
||||
//! Grouping faces into people (docs/dev/faces.md §9, FR-CULL-10).
|
||||
//!
|
||||
//! Model-free: this is arithmetic over embeddings, and it is where the
|
||||
//! subsystem's accuracy actually lives, so it is testable with no weights on
|
||||
|
||||
+39
-17
@@ -1,4 +1,4 @@
|
||||
//! SCRFD face detection (docs/faces.md §4).
|
||||
//! SCRFD face detection (docs/dev/faces.md §4).
|
||||
//!
|
||||
//! One forward pass produces a box, a confidence and **five landmarks** per
|
||||
//! face — the landmarks being the reason for this detector rather than a
|
||||
@@ -14,7 +14,8 @@
|
||||
|
||||
use ndarray::Array4;
|
||||
|
||||
use crate::{install_backend, FaceError};
|
||||
use crate::FaceError;
|
||||
use dr_inference_engine::{Form, Model, Role};
|
||||
|
||||
/// The graph's input edge, in pixels. See the module note: not configurable.
|
||||
pub const INPUT_EDGE: usize = 640;
|
||||
@@ -135,7 +136,10 @@ impl Detection {
|
||||
|
||||
/// A loaded SCRFD graph.
|
||||
pub struct Detector {
|
||||
session: ort::session::Session,
|
||||
session: Model,
|
||||
/// f32 or int8 — the int8 form finds a different set of faces and is a
|
||||
/// different detector in `model_id` (docs/dev/inference.md §7).
|
||||
form: Form,
|
||||
/// Feature-map count: 3 for strides {8,16,32}, 4 for {8,16,32,64}.
|
||||
///
|
||||
/// Discovered from the output count rather than assumed, because both
|
||||
@@ -145,18 +149,29 @@ pub struct Detector {
|
||||
}
|
||||
|
||||
impl Detector {
|
||||
pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self, FaceError> {
|
||||
let bytes = std::fs::read(path).map_err(FaceError::ModelRead)?;
|
||||
Self::from_bytes(&bytes)
|
||||
/// Which form this detector was loaded from.
|
||||
pub fn form(&self) -> Form {
|
||||
self.form
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, FaceError> {
|
||||
install_backend();
|
||||
/// Load the canonical f32 file at `path`, or the form the device's
|
||||
/// backend wants instead — the `.int8.onnx` beside it on a Hexagon —
|
||||
/// which [`Detector::form`] then reports.
|
||||
pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self, FaceError> {
|
||||
let (path, form) = dr_inference_engine::resolve_model(Role::Detector, path.as_ref());
|
||||
let bytes = std::fs::read(path).map_err(FaceError::ModelRead)?;
|
||||
Self::from_bytes_in(&bytes, form)
|
||||
}
|
||||
|
||||
let session = ort::session::Session::builder()
|
||||
.map_err(FaceError::Inference)?
|
||||
.commit_from_memory(bytes)
|
||||
.map_err(FaceError::Inference)?;
|
||||
/// An f32 graph from memory.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, FaceError> {
|
||||
Self::from_bytes_in(bytes, Form::F32)
|
||||
}
|
||||
|
||||
fn from_bytes_in(bytes: &[u8], form: Form) -> Result<Self, FaceError> {
|
||||
let model = dr_inference_engine::open(Role::Detector, form, bytes)?;
|
||||
let acquired = model.acquire()?;
|
||||
let session = acquired.lock();
|
||||
|
||||
let n_out = session.outputs().len();
|
||||
if n_out % 3 != 0 || !(9..=12).contains(&n_out) {
|
||||
@@ -191,7 +206,13 @@ impl Detector {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self { session, fmc })
|
||||
drop(session);
|
||||
drop(acquired);
|
||||
Ok(Self {
|
||||
session: model,
|
||||
form,
|
||||
fmc,
|
||||
})
|
||||
}
|
||||
|
||||
/// Stride levels this graph emits.
|
||||
@@ -223,8 +244,9 @@ impl Detector {
|
||||
let lb = Letterbox::fit(width as f32, height as f32);
|
||||
let input = lb.sample(rgb, width, height);
|
||||
|
||||
let outputs = self
|
||||
.session
|
||||
let acquired = self.session.acquire()?;
|
||||
let mut session = acquired.lock();
|
||||
let outputs = session
|
||||
.run(ort::inputs![
|
||||
ort::value::Tensor::from_array(input).map_err(FaceError::Inference)?
|
||||
])
|
||||
@@ -324,7 +346,7 @@ fn iou(a: &(f32, f32, f32, f32), b: &(f32, f32, f32, f32)) -> f32 {
|
||||
/// How the image is fitted into the graph's fixed square input.
|
||||
///
|
||||
/// The forward and inverse mappings live in one struct on purpose:
|
||||
/// docs/faces.md §4.1 notes that what matters is not *where* the padding goes
|
||||
/// docs/dev/faces.md §4.1 notes that what matters is not *where* the padding goes
|
||||
/// but that the two agree. A mismatch offsets every box and landmark by the
|
||||
/// padding, producing detections that look plausible and embeddings that
|
||||
/// quietly cluster badly three stages later.
|
||||
@@ -350,7 +372,7 @@ impl Letterbox {
|
||||
///
|
||||
/// `(x·255 − 127.5) / 128` — note `/128`, not `/127.5`. The reference
|
||||
/// implementation this is ported from uses `/128` for both models, and
|
||||
/// every measured number in docs/faces.md §1 came from it.
|
||||
/// every measured number in docs/dev/faces.md §1 came from it.
|
||||
///
|
||||
/// Padding is grey, matching the reference's `114`: the value the network
|
||||
/// reads least as an edge, where black would draw a hard border across the
|
||||
|
||||
+18
-12
@@ -1,4 +1,4 @@
|
||||
//! ArcFace / MobileFaceNet inference (docs/faces.md §6).
|
||||
//! ArcFace / MobileFaceNet inference (docs/dev/faces.md §6).
|
||||
//!
|
||||
//! Takes an aligned crop and returns 512 L2-normalised floats. The alignment is
|
||||
//! not optional and cannot be skipped by accident: [`Embedder::embed`] takes an
|
||||
@@ -14,7 +14,8 @@ use ndarray::Array4;
|
||||
|
||||
use crate::align::{Aligned112, ALIGNED_EDGE};
|
||||
use crate::embedding::{normalise, Embedding, ModelId, EMBEDDING_DIM};
|
||||
use crate::{install_backend, FaceError};
|
||||
use crate::FaceError;
|
||||
use dr_inference_engine::{Form, Model, Role};
|
||||
|
||||
/// What one pass of the embedder produces: the direction, and the length.
|
||||
///
|
||||
@@ -53,7 +54,7 @@ impl Embedded {
|
||||
|
||||
/// A loaded ArcFace graph.
|
||||
pub struct Embedder {
|
||||
session: ort::session::Session,
|
||||
session: Model,
|
||||
model: ModelId,
|
||||
}
|
||||
|
||||
@@ -64,12 +65,11 @@ impl Embedder {
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8], model: ModelId) -> Result<Self, FaceError> {
|
||||
install_backend();
|
||||
|
||||
let session = ort::session::Session::builder()
|
||||
.map_err(FaceError::Inference)?
|
||||
.commit_from_memory(bytes)
|
||||
.map_err(FaceError::Inference)?;
|
||||
// Always the f32 form: an embedding must compare across devices
|
||||
// (docs/dev/inference.md §7), and the engine pins this role to it.
|
||||
let loaded = dr_inference_engine::open(Role::Embedder, Form::F32, bytes)?;
|
||||
let acquired = loaded.acquire()?;
|
||||
let session = acquired.lock();
|
||||
|
||||
// One output, `[1, 512]`. Checked because an ArcFace variant with a
|
||||
// different embedding width would otherwise be read as a truncated
|
||||
@@ -90,7 +90,12 @@ impl Embedder {
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self { session, model })
|
||||
drop(session);
|
||||
drop(acquired);
|
||||
Ok(Self {
|
||||
session: loaded,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &ModelId {
|
||||
@@ -111,8 +116,9 @@ impl Embedder {
|
||||
}
|
||||
}
|
||||
|
||||
let outputs = self
|
||||
.session
|
||||
let acquired = self.session.acquire()?;
|
||||
let mut session = acquired.lock();
|
||||
let outputs = session
|
||||
.run(ort::inputs![
|
||||
ort::value::Tensor::from_array(input).map_err(FaceError::Inference)?
|
||||
])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! What an embedder produces, and how it is stored (docs/faces.md §6).
|
||||
//! What an embedder produces, and how it is stored (docs/dev/faces.md §6).
|
||||
//!
|
||||
//! Deliberately **model-free**: the vector, its identity, its comparison and
|
||||
//! its storage encoding are arithmetic, and `calibrate` and `cluster` are built
|
||||
@@ -273,7 +273,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The claim docs/faces.md §6 makes about the storage format: the f16
|
||||
/// The claim docs/dev/faces.md §6 makes about the storage format: the f16
|
||||
/// round-trip costs ~1e-3 of cosine, three orders below the separation
|
||||
/// between a match and a non-match.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! TRACES: FR-CULL-8a
|
||||
//! What a face's eyes are doing, and how the numbers behind it are read.
|
||||
//!
|
||||
//! Model-free: the models in [`crate::classify`] produce the numbers, and
|
||||
//! everything that interprets them — the catalog's filter, the People
|
||||
//! screen's label — comes through here, so a threshold lives in exactly one
|
||||
//! place.
|
||||
//!
|
||||
//! # Seven numbers, one answer
|
||||
//!
|
||||
//! An eye classifier answers "open or closed" for whatever it is shown, and
|
||||
//! it is shown three things it cannot answer for. **Dark glass**: over
|
||||
//! sunglasses it answers anyway, confidently, for a state that cannot be
|
||||
//! seen — so the reading carries P(sunglasses) from a classifier that looks
|
||||
//! at the whole head, and that takes precedence. **A smear**: a soft eye is
|
||||
//! not a closed one, but shown a blur the classifier says "closed" with the
|
||||
//! same confidence it says anything, and on the reference library that was
|
||||
//! the commonest wrong answer of all — small faces, motion, a proxy where
|
||||
//! the native render should have been. So each eye carries how many source
|
||||
//! pixels it spanned and how sharp the patch was, and an eye under either
|
||||
//! floor is not asked. **A cheek**: a head turned far enough hides its far
|
||||
//! eye, and the landmark contour of a hidden eye collapses to a sliver; an
|
||||
//! eye much narrower than its partner is not asked either.
|
||||
//!
|
||||
//! The two eyes are kept apart rather than averaged. A wink is one eye
|
||||
//! closed, and averaging it lands at 0.5 — the one value that says the least.
|
||||
//! [`EyeState::Open`] requires every eye that *could be read* to be open;
|
||||
//! a face with no readable eye is [`EyeState::Unreadable`], which is not a
|
||||
//! blink and not open, and a filter for either leaves it alone.
|
||||
|
||||
/// One eye's numbers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Eye {
|
||||
/// P(open), the classifier's sigmoid.
|
||||
pub open: f32,
|
||||
/// Source pixels across the eye box — [`crate::align::EyePatch::source_px`].
|
||||
pub px: f32,
|
||||
/// [`crate::align::EyePatch::sharpness`] of the patch the classifier saw.
|
||||
pub sharpness: f32,
|
||||
}
|
||||
|
||||
/// The numbers the models produced for one face.
|
||||
///
|
||||
/// Stored per face, nullable as a whole: a face indexed before the eye models
|
||||
/// existed, or on a device without them, has no reading rather than a
|
||||
/// reading of zeros.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct EyeReading {
|
||||
/// The subject's **right** eye — image-left.
|
||||
pub right: Eye,
|
||||
/// The subject's **left** eye — image-right.
|
||||
pub left: Eye,
|
||||
/// P(the head wears sunglasses).
|
||||
pub sunglasses: f32,
|
||||
}
|
||||
|
||||
/// Above this an eye is open. The classifier's own decision point; its
|
||||
/// training put the two classes either side of a sigmoid and this is where
|
||||
/// the sigmoid crosses.
|
||||
pub const EYES_OPEN_THRESHOLD: f32 = 0.5;
|
||||
|
||||
/// Above this the head wears sunglasses and the eye readings are moot.
|
||||
pub const SUNGLASSES_THRESHOLD: f32 = 0.5;
|
||||
|
||||
/// Fewest source pixels across an eye box for the eye to be read.
|
||||
///
|
||||
/// The classifier was trained on eyes down to about a dozen pixels wide
|
||||
/// (its reference footage averaged 15–21); below that the 40-pixel patch is
|
||||
/// an interpolation of nothing, and the answer is noise that reads as
|
||||
/// "closed". docs/dev/faces.md §17.3 has the measurement behind the number.
|
||||
pub const MIN_EYE_PX: f32 = 12.0;
|
||||
|
||||
/// Least [`Eye::sharpness`] for the eye to be read.
|
||||
///
|
||||
/// The same measure as the face's `min_sharpness`, over the eye patch, and
|
||||
/// chosen the same way: the value under which the open-eyed faces of the
|
||||
/// reference sample were being called closed. docs/dev/faces.md §17.3.
|
||||
pub const MIN_EYE_SHARPNESS: f32 = 0.02;
|
||||
|
||||
/// An eye narrower than this fraction of its partner is the far eye of a
|
||||
/// turned head, out of view behind the nose, and is not read.
|
||||
///
|
||||
/// A landmark model's contour for a hidden eye collapses towards the nose.
|
||||
/// Measured on twenty native renders of the reference library
|
||||
/// (docs/dev/faces.md §17.4): profiles put the far eye at 0.02–0.43 of the near
|
||||
/// one, two three-quarter faces whose far eye read closed sat at 0.54, and
|
||||
/// every face looking at the camera — winks included, since a shut eye's
|
||||
/// box keeps its width — sat at 0.78 or more. 0.6 splits the gap.
|
||||
pub const HIDDEN_EYE_RATIO: f32 = 0.6;
|
||||
|
||||
/// What the reading says, for a screen or a filter.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EyeState {
|
||||
/// Every eye that could be read is open.
|
||||
Open,
|
||||
/// An eye that could be read is closed — a blink, or a wink.
|
||||
Closed,
|
||||
/// The eyes cannot be seen. Neither open nor closed, and a filter for
|
||||
/// either leaves the face alone.
|
||||
Sunglasses,
|
||||
/// No eye was sharp enough, large enough and in view to read. Neither
|
||||
/// open nor closed, like sunglasses, and left alone by every filter.
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
impl Eye {
|
||||
/// Whether this eye can be read at all: enough pixels, sharp enough,
|
||||
/// and not the collapsed contour of a hidden eye — measured against
|
||||
/// `other`, its partner.
|
||||
pub fn readable(&self, other: &Eye) -> bool {
|
||||
self.px >= MIN_EYE_PX
|
||||
&& self.sharpness >= MIN_EYE_SHARPNESS
|
||||
&& self.px >= other.px * HIDDEN_EYE_RATIO
|
||||
}
|
||||
}
|
||||
|
||||
impl EyeReading {
|
||||
pub fn state(&self) -> EyeState {
|
||||
if self.sunglasses >= SUNGLASSES_THRESHOLD {
|
||||
return EyeState::Sunglasses;
|
||||
}
|
||||
let readable = [
|
||||
self.right.readable(&self.left).then_some(self.right.open),
|
||||
self.left.readable(&self.right).then_some(self.left.open),
|
||||
];
|
||||
let mut any = false;
|
||||
for open in readable.into_iter().flatten() {
|
||||
any = true;
|
||||
if open < EYES_OPEN_THRESHOLD {
|
||||
return EyeState::Closed;
|
||||
}
|
||||
}
|
||||
if any {
|
||||
EyeState::Open
|
||||
} else {
|
||||
EyeState::Unreadable
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a face a "no one blinking" filter should drop.
|
||||
///
|
||||
/// The filter's question, rather than [`EyeState`]'s four-way answer,
|
||||
/// because the two differ on exactly the cases that matter: a face
|
||||
/// behind sunglasses, or one whose eyes could not be read, is not open
|
||||
/// — and it is not a blink either. Only [`EyeState::Closed`] is one.
|
||||
pub fn is_blink(&self) -> bool {
|
||||
self.state() == EyeState::Closed
|
||||
}
|
||||
}
|
||||
|
||||
impl EyeState {
|
||||
/// The word the People screen puts on the face.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
EyeState::Open => "Eyes open",
|
||||
EyeState::Closed => "Eyes closed",
|
||||
EyeState::Sunglasses => "Sunglasses",
|
||||
EyeState::Unreadable => "Eyes unclear",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn eye(open: f32) -> Eye {
|
||||
Eye {
|
||||
open,
|
||||
px: 40.0,
|
||||
sharpness: 0.1,
|
||||
}
|
||||
}
|
||||
|
||||
fn reading(right: f32, left: f32, sunglasses: f32) -> EyeReading {
|
||||
EyeReading {
|
||||
right: eye(right),
|
||||
left: eye(left),
|
||||
sunglasses,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_eyes_open_is_open() {
|
||||
assert_eq!(reading(0.9, 0.8, 0.1).state(), EyeState::Open);
|
||||
assert!(!reading(0.9, 0.8, 0.1).is_blink());
|
||||
}
|
||||
|
||||
/// A wink is not "eyes open": one eye closed lands the same place a
|
||||
/// blink does, and a filter for "nobody blinking" should drop it.
|
||||
#[test]
|
||||
fn one_eye_closed_is_closed() {
|
||||
assert_eq!(reading(0.9, 0.2, 0.1).state(), EyeState::Closed);
|
||||
assert_eq!(reading(0.2, 0.9, 0.1).state(), EyeState::Closed);
|
||||
assert!(reading(0.2, 0.9, 0.1).is_blink());
|
||||
}
|
||||
|
||||
/// The whole reason the sunglasses number exists: whatever the eye
|
||||
/// classifier says over dark glass, it is not a reading of the eyes.
|
||||
#[test]
|
||||
fn sunglasses_override_the_eye_readings_either_way() {
|
||||
assert_eq!(reading(0.9, 0.9, 0.8).state(), EyeState::Sunglasses);
|
||||
assert_eq!(reading(0.1, 0.1, 0.8).state(), EyeState::Sunglasses);
|
||||
assert!(!reading(0.1, 0.1, 0.8).is_blink());
|
||||
}
|
||||
|
||||
/// A soft or tiny eye is not asked; if neither can be, the face is
|
||||
/// unreadable rather than closed.
|
||||
#[test]
|
||||
fn a_soft_or_tiny_eye_is_not_read() {
|
||||
let mut r = reading(0.1, 0.9, 0.0);
|
||||
r.right.sharpness = MIN_EYE_SHARPNESS / 2.0;
|
||||
assert_eq!(r.state(), EyeState::Open, "the soft closed eye is ignored");
|
||||
|
||||
let mut r = reading(0.1, 0.9, 0.0);
|
||||
r.right.px = MIN_EYE_PX - 1.0;
|
||||
assert_eq!(r.state(), EyeState::Open, "the tiny closed eye is ignored");
|
||||
|
||||
let mut r = reading(0.1, 0.1, 0.0);
|
||||
r.right.sharpness = 0.0;
|
||||
r.left.px = 3.0;
|
||||
assert_eq!(r.state(), EyeState::Unreadable);
|
||||
assert!(!r.is_blink());
|
||||
assert_eq!(r.state().label(), "Eyes unclear");
|
||||
}
|
||||
|
||||
/// A profile: the far eye's contour collapses, and the sliver is not
|
||||
/// read. The near eye still decides.
|
||||
#[test]
|
||||
fn a_turned_heads_collapsed_far_eye_is_not_read() {
|
||||
let mut r = reading(0.05, 0.95, 0.0);
|
||||
r.right.px = 40.0 * HIDDEN_EYE_RATIO - 1.0;
|
||||
assert!(!r.right.readable(&r.left));
|
||||
assert_eq!(r.state(), EyeState::Open);
|
||||
|
||||
let mut blink = reading(0.95, 0.05, 0.0);
|
||||
blink.right.px = 40.0 * HIDDEN_EYE_RATIO - 1.0;
|
||||
assert_eq!(blink.state(), EyeState::Closed);
|
||||
|
||||
// Both eyes narrow but alike is not a turned head: both count.
|
||||
let mut small = reading(0.05, 0.95, 0.0);
|
||||
small.right.px = 14.0;
|
||||
small.left.px = 14.0;
|
||||
assert_eq!(small.state(), EyeState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_thresholds_are_inclusive_at_the_decision_point() {
|
||||
assert_eq!(
|
||||
reading(EYES_OPEN_THRESHOLD, EYES_OPEN_THRESHOLD, 0.0).state(),
|
||||
EyeState::Open
|
||||
);
|
||||
assert_eq!(
|
||||
reading(1.0, 1.0, SUNGLASSES_THRESHOLD).state(),
|
||||
EyeState::Sunglasses
|
||||
);
|
||||
let mut r = reading(1.0, 1.0, 0.0);
|
||||
r.right.px = MIN_EYE_PX;
|
||||
r.left.px = MIN_EYE_PX;
|
||||
r.right.sharpness = MIN_EYE_SHARPNESS;
|
||||
assert!(r.right.readable(&r.left));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! TRACES: FR-CULL-8a
|
||||
//! Dense facial landmarks — InsightFace's `2d106det` (docs/dev/faces.md §17.2).
|
||||
//!
|
||||
//! SCRFD's five points place a face; they do not place an eye. Its eye
|
||||
//! point is loose enough that a window centred on it left the eye in a
|
||||
//! corner on turned and smiling heads, and two model-free ways of
|
||||
//! re-centring it made things worse. So a second model draws the eye's lid
|
||||
//! contour, and the eye box is cut from that.
|
||||
//!
|
||||
//! **Why this one.** Three were measured on the same faces — MediaPipe Face
|
||||
//! Mesh V2, PIPNet and this — and tied on what the eye classifier made of
|
||||
//! their boxes (22 of 25 open eyes read open, against 19 from the SCRFD
|
||||
//! point). This is the cheapest of the three by a wide margin (5 MB, 106
|
||||
//! points, ~24 ms in tract), and it is under the grant the detector and
|
||||
//! embedder already carry rather than a new one to read.
|
||||
//!
|
||||
//! # Pre-processing
|
||||
//!
|
||||
//! Ported from InsightFace's `landmark.py`: a square crop centred on the
|
||||
//! detector box, 1.5× its longer edge, resized to 192; **RGB in 0..255**
|
||||
//! (the graph carries its own `bn_data` normalisation, so `input_mean` is
|
||||
//! 0 and `input_std` 1); 106 `(x, y)` in −1..1 mapped back through
|
||||
//! `(p + 1) · 96`. The graph's batch dimension is the literal `None` and
|
||||
//! is pinned to 1 by `tools/fix-face-model-shapes.sh`, like the embedder's.
|
||||
//!
|
||||
//! # The layout
|
||||
//!
|
||||
//! Checked by drawing the points on the reference faces rather than taken
|
||||
//! from a diagram: the subject's right eye (image-left) is points 33–42,
|
||||
//! the left 87–96, ten each round the lids.
|
||||
|
||||
use ndarray::Array4;
|
||||
|
||||
use crate::align::crop_box;
|
||||
use crate::{FaceError, Pixels};
|
||||
use dr_inference_engine::{Form, Model, Role};
|
||||
|
||||
/// The graph's input edge, in pixels.
|
||||
pub const INPUT_EDGE: usize = 192;
|
||||
|
||||
/// How many points the model returns.
|
||||
pub const POINTS: usize = 106;
|
||||
|
||||
/// The crop's edge as a multiple of the detector box's longer edge.
|
||||
const CROP_SCALE: f32 = 1.5;
|
||||
|
||||
/// The span of the frame, in long-edge units, the packed form covers: a
|
||||
/// quarter of the frame outside each edge.
|
||||
pub const PACKED_RANGE: (f32, f32) = (-0.25, 1.25);
|
||||
|
||||
/// Bytes the packed form of one face's landmarks takes.
|
||||
pub const PACKED_BYTES: usize = POINTS * 4;
|
||||
|
||||
/// Point indices of the subject's right eye's lid contour (image-left).
|
||||
pub const RIGHT_EYE: [usize; 10] = [33, 34, 35, 36, 37, 38, 39, 40, 41, 42];
|
||||
/// Point indices of the subject's left eye's lid contour (image-right).
|
||||
pub const LEFT_EYE: [usize; 10] = [87, 88, 89, 90, 91, 92, 93, 94, 95, 96];
|
||||
|
||||
/// The 106 points of one face, in **source pixels**.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Landmarks {
|
||||
pub points: [(f32, f32); POINTS],
|
||||
}
|
||||
|
||||
impl Landmarks {
|
||||
/// Storage form: `106 × (x, y)` as little-endian **`u16` fixed point**
|
||||
/// over the frame, 424 bytes.
|
||||
///
|
||||
/// Each coordinate is normalised by `long_edge` like the five points the
|
||||
/// catalog already keeps, then mapped over [`PACKED_RANGE`] — a quarter
|
||||
/// of the frame either side of it, because a landmark on a face at the
|
||||
/// edge does land outside the image — onto 0..65535. That is 0.14 source
|
||||
/// pixels on a 6000-pixel frame. `f16` would be the same size and worse:
|
||||
/// its three significant figures near 1.0 are six pixels at that scale,
|
||||
/// and the eye contour this is kept for is drawn to the pixel.
|
||||
pub fn to_packed_bytes(&self, long_edge: f32) -> Vec<u8> {
|
||||
let (lo, hi) = PACKED_RANGE;
|
||||
let pack = |v: f32| -> [u8; 2] {
|
||||
let t = ((v / long_edge - lo) / (hi - lo)).clamp(0.0, 1.0);
|
||||
((t * 65535.0).round() as u16).to_le_bytes()
|
||||
};
|
||||
let mut out = Vec::with_capacity(POINTS * 4);
|
||||
for &(x, y) in &self.points {
|
||||
out.extend_from_slice(&pack(x));
|
||||
out.extend_from_slice(&pack(y));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`Self::to_packed_bytes`] read back, into source pixels of a frame
|
||||
/// with this `long_edge`. `None` for a blob of the wrong length.
|
||||
pub fn from_packed_bytes(bytes: &[u8], long_edge: f32) -> Option<Self> {
|
||||
if bytes.len() != POINTS * 4 {
|
||||
return None;
|
||||
}
|
||||
let (lo, hi) = PACKED_RANGE;
|
||||
let unpack = |b: &[u8]| -> f32 {
|
||||
let t = u16::from_le_bytes([b[0], b[1]]) as f32 / 65535.0;
|
||||
(t * (hi - lo) + lo) * long_edge
|
||||
};
|
||||
let mut points = [(0.0_f32, 0.0_f32); POINTS];
|
||||
for (i, p) in points.iter_mut().enumerate() {
|
||||
let at = i * 4;
|
||||
*p = (unpack(&bytes[at..at + 2]), unpack(&bytes[at + 2..at + 4]));
|
||||
}
|
||||
Some(Self { points })
|
||||
}
|
||||
|
||||
/// The lid contour of the subject's right eye.
|
||||
pub fn right_eye(&self) -> [(f32, f32); 10] {
|
||||
RIGHT_EYE.map(|i| self.points[i])
|
||||
}
|
||||
|
||||
/// The lid contour of the subject's left eye.
|
||||
pub fn left_eye(&self) -> [(f32, f32); 10] {
|
||||
LEFT_EYE.map(|i| self.points[i])
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded `2d106det` graph.
|
||||
pub struct Landmarker {
|
||||
session: Model,
|
||||
}
|
||||
|
||||
impl Landmarker {
|
||||
pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self, FaceError> {
|
||||
let bytes = std::fs::read(path).map_err(FaceError::ModelRead)?;
|
||||
Self::from_bytes(&bytes)
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, FaceError> {
|
||||
let model = dr_inference_engine::open(Role::Landmarks, Form::F32, bytes)?;
|
||||
let acquired = model.acquire()?;
|
||||
let session = acquired.lock();
|
||||
|
||||
let input = session.inputs().first().ok_or(FaceError::WrongModel {
|
||||
expected: "2d106det",
|
||||
detail: "model has no inputs".into(),
|
||||
})?;
|
||||
let shape: Option<Vec<i64>> = input.dtype().tensor_shape().map(|s| s.to_vec());
|
||||
let want = [1, 3, INPUT_EDGE as i64, INPUT_EDGE as i64];
|
||||
if shape.as_deref() != Some(&want[..]) {
|
||||
return Err(FaceError::WrongModel {
|
||||
expected: "2d106det",
|
||||
detail: format!(
|
||||
"input '{}' is {:?}, expected {:?} (batch pinned to 1)",
|
||||
input.name(),
|
||||
shape,
|
||||
want
|
||||
),
|
||||
});
|
||||
}
|
||||
let out = session.outputs().first().ok_or(FaceError::WrongModel {
|
||||
expected: "2d106det",
|
||||
detail: "model has no outputs".into(),
|
||||
})?;
|
||||
let last: Option<i64> = out.dtype().tensor_shape().and_then(|d| d.last().copied());
|
||||
if last != Some((POINTS * 2) as i64) {
|
||||
return Err(FaceError::WrongModel {
|
||||
expected: "2d106det",
|
||||
detail: format!(
|
||||
"output '{}' is {:?}-wide, expected {}",
|
||||
out.name(),
|
||||
last,
|
||||
POINTS * 2
|
||||
),
|
||||
});
|
||||
}
|
||||
drop(session);
|
||||
drop(acquired);
|
||||
Ok(Self { session: model })
|
||||
}
|
||||
|
||||
/// The landmarks of the face in `bbox` — `(x0, y0, x1, y1)` in source
|
||||
/// pixels, the detector's box — read from the source.
|
||||
///
|
||||
/// `None` for a box with no area or a buffer that is not the size it
|
||||
/// claims, as every crop here.
|
||||
pub fn landmarks(
|
||||
&mut self,
|
||||
px: Pixels<'_>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
bbox: (f32, f32, f32, f32),
|
||||
) -> Result<Option<Landmarks>, FaceError> {
|
||||
let (w, h) = (bbox.2 - bbox.0, bbox.3 - bbox.1);
|
||||
let side = w.max(h) * CROP_SCALE;
|
||||
let (cx, cy) = ((bbox.0 + bbox.2) / 2.0, (bbox.1 + bbox.3) / 2.0);
|
||||
let (x0, y0) = (cx - side / 2.0, cy - side / 2.0);
|
||||
let Some(crop) = crop_box(
|
||||
px,
|
||||
width,
|
||||
height,
|
||||
(x0, y0, side, side),
|
||||
INPUT_EDGE,
|
||||
INPUT_EDGE,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let e = INPUT_EDGE;
|
||||
let mut input = Array4::<f32>::zeros((1, 3, e, e));
|
||||
for y in 0..e {
|
||||
for x in 0..e {
|
||||
for c in 0..3 {
|
||||
input[[0, c, y, x]] = crop[(y * e + x) * 3 + c] * 255.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let acquired = self.session.acquire()?;
|
||||
let mut session = acquired.lock();
|
||||
let outputs = session
|
||||
.run(ort::inputs![
|
||||
ort::value::Tensor::from_array(input).map_err(FaceError::Inference)?
|
||||
])
|
||||
.map_err(FaceError::Inference)?;
|
||||
let (_, data) = outputs[0]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(FaceError::Inference)?;
|
||||
if data.len() < POINTS * 2 {
|
||||
return Err(FaceError::WrongModel {
|
||||
expected: "2d106det",
|
||||
detail: format!("got {} values, expected {}", data.len(), POINTS * 2),
|
||||
});
|
||||
}
|
||||
|
||||
// −1..1 in the crop → crop pixels → source pixels.
|
||||
let scale = side / e as f32;
|
||||
let half = e as f32 / 2.0;
|
||||
let mut points = [(0.0_f32, 0.0_f32); POINTS];
|
||||
for (i, p) in points.iter_mut().enumerate() {
|
||||
let (u, v) = ((data[2 * i] + 1.0) * half, (data[2 * i + 1] + 1.0) * half);
|
||||
*p = (x0 + u * scale, y0 + v * scale);
|
||||
}
|
||||
Ok(Some(Landmarks { points }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Packed and unpacked, every point comes back within a fifth of a
|
||||
/// source pixel on a 6000-pixel frame — including one outside the
|
||||
/// image, which a face at the edge does produce.
|
||||
#[test]
|
||||
fn dense_landmarks_round_trip_through_their_packed_bytes() {
|
||||
let mut points = [(0.0_f32, 0.0_f32); POINTS];
|
||||
for (i, p) in points.iter_mut().enumerate() {
|
||||
*p = (i as f32 * 37.3 - 200.0, 5900.0 - i as f32 * 11.1);
|
||||
}
|
||||
let lm = Landmarks { points };
|
||||
let bytes = lm.to_packed_bytes(6000.0);
|
||||
assert_eq!(bytes.len(), PACKED_BYTES);
|
||||
assert_eq!(PACKED_BYTES, 424);
|
||||
let back = Landmarks::from_packed_bytes(&bytes, 6000.0).unwrap();
|
||||
for (a, b) in lm.points.iter().zip(back.points.iter()) {
|
||||
assert!((a.0 - b.0).abs() < 0.2, "{} vs {}", a.0, b.0);
|
||||
assert!((a.1 - b.1).abs() < 0.2, "{} vs {}", a.1, b.1);
|
||||
}
|
||||
assert!(Landmarks::from_packed_bytes(&bytes[..100], 6000.0).is_none());
|
||||
}
|
||||
}
|
||||
+36
-22
@@ -1,8 +1,10 @@
|
||||
//! Faces and identity (S14, docs/faces.md).
|
||||
//! Faces and identity (S14, docs/dev/faces.md).
|
||||
//!
|
||||
//! Two models, run over the proxy tier, 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
|
||||
//! arithmetic that turns embeddings into people (FR-CULL-9, FR-CULL-10).
|
||||
//! arithmetic that turns embeddings into people (FR-CULL-9, FR-CULL-10). Two
|
||||
//! more, optional, read each face's eyes and whether sunglasses hide them
|
||||
//! (FR-CULL-8a, [`classify`] and [`eyes`]).
|
||||
//!
|
||||
//! Like `dr-segment`, this crate is **device-free**: no GPU adapter, no
|
||||
//! Slint, nothing that needs a display. Unlike `dr-segment`, it carries **no
|
||||
@@ -19,8 +21,9 @@
|
||||
//! for a packaging script to switch on. The application obtains a model at
|
||||
//! runtime; this crate takes bytes and never fetches anything.
|
||||
//!
|
||||
//! docs/faces.md §2 is the full reading, including what would have to change
|
||||
//! for that to stop being true.
|
||||
//! docs/dev/faces.md §2 is the full reading, including what would have to change
|
||||
//! for that to stop being true. The eye-state models are the exception: MIT,
|
||||
//! weights and all, and shipped in `models/face/` (docs/dev/faces.md §17).
|
||||
//!
|
||||
//! # Why the runtime is split behind a feature
|
||||
//!
|
||||
@@ -34,19 +37,25 @@
|
||||
pub mod align;
|
||||
pub mod assign;
|
||||
pub mod calibrate;
|
||||
#[cfg(feature = "inference")]
|
||||
pub mod classify;
|
||||
pub mod cluster;
|
||||
#[cfg(feature = "inference")]
|
||||
pub mod detect;
|
||||
#[cfg(feature = "inference")]
|
||||
pub mod embed;
|
||||
pub mod embedding;
|
||||
pub mod eyes;
|
||||
#[cfg(feature = "inference")]
|
||||
pub mod landmarks;
|
||||
pub mod naming;
|
||||
pub mod neighbours;
|
||||
pub mod references;
|
||||
|
||||
/// Smallest long edge a face crop may be sampled from.
|
||||
///
|
||||
/// **A floor on the crop source, not on the detector input.** The distinction
|
||||
/// is the whole of FR-CULL-8 and `docs/faces.md` §7: detection letterboxes
|
||||
/// is the whole of FR-CULL-8 and `docs/dev/faces.md` §7: detection letterboxes
|
||||
/// every buffer into 640×640, so its input resolution decides nothing, while
|
||||
/// [`warp`] samples the 112×112 the embedder sees and so converts source
|
||||
/// resolution directly into embedding quality. FR-CULL-8 requires that crop to
|
||||
@@ -65,10 +74,13 @@ pub mod neighbours;
|
||||
pub const MIN_CROP_EDGE: u32 = 1025;
|
||||
|
||||
pub use align::{
|
||||
warp, warp_pixels, Aligned112, Pixels, Similarity, ALIGNED_EDGE, ARCFACE_TEMPLATE,
|
||||
crop_box, eye_box, eye_patch, head_views, warp, warp_pixels, Aligned112, EyePatch, HeadViews,
|
||||
Pixels, Similarity, ALIGNED_EDGE, ARCFACE_TEMPLATE,
|
||||
};
|
||||
pub use assign::{identity_shares, RIVAL_FLOOR, TOP_MATCHES};
|
||||
pub use calibrate::{Calibration, Pairs, ReliabilityBand};
|
||||
#[cfg(feature = "inference")]
|
||||
pub use classify::{EyeClassifier, EyeModels, SunglassesClassifier};
|
||||
pub use cluster::{
|
||||
cluster, cluster_scored, split, Candidate, Cluster, Grouping, DEFAULT_MERGE_PROBABILITY,
|
||||
};
|
||||
@@ -79,6 +91,12 @@ pub use embed::{Embedded, Embedder};
|
||||
pub use embedding::{
|
||||
in_gallery, read_f16_bytes, Embedding, ModelId, EMBEDDING_DIM, MIN_GALLERY_QUALITY,
|
||||
};
|
||||
pub use eyes::{
|
||||
Eye, EyeReading, EyeState, EYES_OPEN_THRESHOLD, HIDDEN_EYE_RATIO, MIN_EYE_PX,
|
||||
MIN_EYE_SHARPNESS, SUNGLASSES_THRESHOLD,
|
||||
};
|
||||
#[cfg(feature = "inference")]
|
||||
pub use landmarks::{Landmarker, Landmarks};
|
||||
pub use naming::{name_for_instance, name_instances, NamedFace};
|
||||
|
||||
/// What can go wrong between an image and a face.
|
||||
@@ -107,25 +125,21 @@ pub enum FaceError {
|
||||
ImageShape { expected: usize, got: usize },
|
||||
}
|
||||
|
||||
/// Install tract as `ort`'s backend.
|
||||
///
|
||||
/// Idempotent, and it must happen before any other `ort` call: with
|
||||
/// `alternative-backend` there is no linked runtime to fall back on, so an
|
||||
/// un-set API is a panic rather than a slow path. Same helper as
|
||||
/// `dr-segment::semantic`, for the same reason.
|
||||
#[cfg(feature = "inference")]
|
||||
pub(crate) fn install_backend() {
|
||||
use std::sync::Once;
|
||||
static ONCE: Once = Once::new();
|
||||
ONCE.call_once(|| {
|
||||
let _ = ort::set_api(ort_tract::api());
|
||||
});
|
||||
impl From<dr_inference_engine::Error> for FaceError {
|
||||
fn from(e: dr_inference_engine::Error) -> Self {
|
||||
match e {
|
||||
dr_inference_engine::Error::Inference(e) => FaceError::Inference(e),
|
||||
dr_inference_engine::Error::Io(e) => FaceError::ModelRead(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`install_backend`] for the M1 probe example, which drives `ort` directly
|
||||
/// rather than through [`detect::Detector`] so it can report the raw error.
|
||||
/// Make sure `ort` has a backend, for the M1 probe example, which drives
|
||||
/// `ort` directly rather than through [`detect::Detector`] so it can report
|
||||
/// the raw error. Every other path goes through `dr-inference-engine`.
|
||||
#[cfg(feature = "inference")]
|
||||
#[doc(hidden)]
|
||||
pub fn install_backend_for_probe() {
|
||||
install_backend();
|
||||
dr_inference_engine::ensure_runtime();
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ pub struct Faces<'a> {
|
||||
/// Source pixels across the aligned crop, for the calibration's size term.
|
||||
pub crop_px: &'a [f32],
|
||||
/// Which photograph each face came from. Two faces in one frame are not
|
||||
/// the same person, so those pairs are never returned (docs/faces.md §9).
|
||||
/// the same person, so those pairs are never returned (docs/dev/faces.md §9).
|
||||
pub images: &'a [u64],
|
||||
/// Which faces may be compared *against* — the gallery
|
||||
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]).
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
# The merge's geometry (FR-MRG-10): rotations, the focal length and the
|
||||
# projections, solved on proxies by dr-pano and consumed here per chunk. The
|
||||
# geometry alone — no keypoint model, no runtime — which is what the
|
||||
# workspace entry turns off.
|
||||
dr-pano.workspace = true
|
||||
dr-decode.workspace = true
|
||||
dr-pipeline.workspace = true
|
||||
# The watershed's pixel passes are here because they are shaders; everything
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! What a frame actually costs — the measurement FR-DSP-2 is waiting on.
|
||||
//!
|
||||
//! `docs/display-and-extension.md` §2 argues that tiled computation predates
|
||||
//! `docs/dev/display-and-extension.md` §2 argues that tiled computation predates
|
||||
//! the fused-shader design and may not need to exist: the composer folds every
|
||||
//! active operation into **one dispatch over a viewport-sized target**, so the
|
||||
//! problem tiles were invented to solve may already be solved. That argument
|
||||
@@ -28,7 +28,7 @@
|
||||
//! the per-frame CPU half is dominated by shader-source assembly, which is
|
||||
//! string formatting and is several times slower unoptimised.
|
||||
//!
|
||||
//! The committed numbers live in `docs/frame-budget.md`. Rerun this and diff
|
||||
//! The committed numbers live in `docs/dev/frame-budget.md`. Rerun this and diff
|
||||
//! that file; a regression should be a diff rather than somebody's memory.
|
||||
//!
|
||||
//! # Why the 99th percentile and not the mean
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Segment an image and write the granularity ladder as false-coloured PPMs.
|
||||
//!
|
||||
//! The whole point of S15 step 2 (docs/segmentation.md §11): look at the
|
||||
//! The whole point of S15 step 2 (docs/dev/segmentation.md §11): look at the
|
||||
//! ladder and decide whether clicking through it would land on the things a
|
||||
//! person means. No amount of design settles that — the pictures do.
|
||||
//!
|
||||
|
||||
+289
-7
@@ -101,6 +101,16 @@ pub struct AdjustPass {
|
||||
/// switched on does not build a pipeline layout mid-frame.
|
||||
linear_bind_group_layout: wgpu::BindGroupLayout,
|
||||
linear_pipeline_layout: wgpu::PipelineLayout,
|
||||
/// TRACES: FR-MRG-2
|
||||
/// A third layout, writing `rgba32float`, for the camera-space tap a
|
||||
/// merge reads (`OutputMode::CameraLinear`). Same reasoning as the
|
||||
/// linear one: the format is in the layout, so a format is a layout.
|
||||
camera_bind_group_layout: wgpu::BindGroupLayout,
|
||||
camera_pipeline_layout: wgpu::PipelineLayout,
|
||||
/// The camera-space texture the last `render_camera_linear` wrote.
|
||||
/// Separate from `targets`: a different format, and a merge reads it
|
||||
/// back or samples it while the display targets go on being swapped.
|
||||
camera_target: Option<Target>,
|
||||
/// TRACES: FR-DEV-3d
|
||||
/// What the linear intermediate currently holds, and at what size.
|
||||
///
|
||||
@@ -303,6 +313,11 @@ impl AdjustPass {
|
||||
|
||||
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
/// TRACES: FR-MRG-2
|
||||
/// The camera-space tap's format: full precision, because what it holds
|
||||
/// is written back as a RAW at the sensor's own scale (FR-MRG-3).
|
||||
pub const CAMERA_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
|
||||
pub fn new(ctx: &GpuContext) -> Self {
|
||||
let bind_group_layout = Self::layout_writing(ctx, Self::FORMAT, "adjust-bgl");
|
||||
|
||||
@@ -330,6 +345,15 @@ impl AdjustPass {
|
||||
bind_group_layouts: &[Some(&linear_bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let camera_bind_group_layout =
|
||||
Self::layout_writing(ctx, Self::CAMERA_FORMAT, "adjust-camera-bgl");
|
||||
let camera_pipeline_layout =
|
||||
ctx.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("adjust-camera-layout"),
|
||||
bind_group_layouts: &[Some(&camera_bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
// A 1x1 single-layer mask, bound when the edit has no local
|
||||
// adjustments. The generated shader never samples it — no layer block
|
||||
@@ -412,6 +436,9 @@ impl AdjustPass {
|
||||
detail: DetailRunner::new(ctx),
|
||||
linear_bind_group_layout,
|
||||
linear_pipeline_layout,
|
||||
camera_bind_group_layout,
|
||||
camera_pipeline_layout,
|
||||
camera_target: None,
|
||||
colour_key: None,
|
||||
colour_dispatches: 0,
|
||||
detail_dispatches: 0,
|
||||
@@ -549,6 +576,7 @@ impl AdjustPass {
|
||||
let layout = match shader.output_mode {
|
||||
OutputMode::Encoded => &self.pipeline_layout,
|
||||
OutputMode::LinearWorking => &self.linear_pipeline_layout,
|
||||
OutputMode::CameraLinear => &self.camera_pipeline_layout,
|
||||
};
|
||||
|
||||
let pipeline =
|
||||
@@ -1126,28 +1154,206 @@ impl AdjustPass {
|
||||
self.copy_output()
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-2
|
||||
/// Render the camera-space tap: the source after its lens warp and
|
||||
/// nothing else, at full precision.
|
||||
///
|
||||
/// `shader` must come from `EditGraph::compose_camera_linear` — it is
|
||||
/// refused otherwise, for the reason `render_masked` refuses a linear
|
||||
/// one: the storage format is in the layout. The profile uniforms are
|
||||
/// filled neutral here rather than from the source, which is the whole
|
||||
/// point of the mode (`OutputMode::CameraLinear`): unit white balance,
|
||||
/// identity matrix, base curve off. The non-linear flag is kept, so a
|
||||
/// JPEG source is still linearised — camera space for a JPEG is the
|
||||
/// decoded values made linear, which is the best that exists.
|
||||
///
|
||||
/// The texture stays on the device for a merge's warp to sample; see
|
||||
/// [`Self::camera_texture`] and [`Self::read_camera_linear`].
|
||||
pub fn render_camera_linear(
|
||||
&mut self,
|
||||
source: &DemosaicedImage,
|
||||
shader: &ComposedShader,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<&wgpu::Texture, GpuError> {
|
||||
if shader.output_mode != OutputMode::CameraLinear {
|
||||
return Err(GpuError::ShaderCompilation(
|
||||
"render_camera_linear takes the shader from EditGraph::compose_camera_linear \
|
||||
and no other; this one writes a different format"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
self.colour_key = None;
|
||||
let (width, height) = (width.max(1), height.max(1));
|
||||
self.ensure_camera_target(width, height);
|
||||
|
||||
let mut uniforms = Self::fused_uniforms(source, shader);
|
||||
// Neutral profile: the numbers the sensor produced, and only those.
|
||||
let non_linear = uniforms[15];
|
||||
uniforms[0..4].copy_from_slice(&[1.0, 0.0, 0.0, 0.0]);
|
||||
uniforms[4..8].copy_from_slice(&[0.0, 1.0, 0.0, 0.0]);
|
||||
uniforms[8..12].copy_from_slice(&[0.0, 0.0, 1.0, 0.0]);
|
||||
uniforms[12..16].copy_from_slice(&[1.0, 1.0, 1.0, non_linear]);
|
||||
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
|
||||
uniforms[b + 10] = 0.0;
|
||||
|
||||
let params_buf = self
|
||||
.ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("adjust-camera-params"),
|
||||
contents: bytemuck::cast_slice(&uniforms),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
|
||||
let _ = self.pipeline(shader)?;
|
||||
let pipeline = self
|
||||
.cache
|
||||
.get(&shader.structure_hash)
|
||||
.expect("compiled above");
|
||||
let target = self.camera_target.as_ref().expect("ensured above");
|
||||
|
||||
let bind_group = self
|
||||
.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("adjust-camera-bg"),
|
||||
layout: &self.camera_bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source.view()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: params_buf.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&target.view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&self.empty_masks),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::TextureView(self.film_curves_view()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::TextureView(self.film_lut_view()),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let mut enc = self
|
||||
.ctx
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("adjust-camera-encoder"),
|
||||
});
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("adjust-camera-pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &bind_group, &[]);
|
||||
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
|
||||
}
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
self.colour_dispatches += 1;
|
||||
|
||||
Ok(&self.camera_target.as_ref().expect("ensured above").texture)
|
||||
}
|
||||
|
||||
/// The camera-space texture, if one has been rendered.
|
||||
pub fn camera_texture(&self) -> Option<&wgpu::Texture> {
|
||||
self.camera_target.as_ref().map(|t| &t.texture)
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-2
|
||||
/// Read the camera-space tap back: tightly packed RGBA `f32`,
|
||||
/// `width * height * 4` values, alpha 1.0 everywhere.
|
||||
pub fn read_camera_linear(&self) -> Result<(Vec<f32>, u32, u32), GpuError> {
|
||||
let Some(target) = self.camera_target.as_ref() else {
|
||||
return Err(GpuError::Readback("no camera-space render yet".into()));
|
||||
};
|
||||
let (bytes, w, h) = Self::copy_texture(&self.ctx, &target.texture, w_h(target), 16)?;
|
||||
let floats: Vec<f32> = bytes
|
||||
.chunks_exact(4)
|
||||
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
.collect();
|
||||
Ok((floats, w, h))
|
||||
}
|
||||
|
||||
fn ensure_camera_target(&mut self, width: u32, height: u32) {
|
||||
if self
|
||||
.camera_target
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.width == width && t.height == height)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("adjust-camera-output"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: Self::CAMERA_FORMAT,
|
||||
// Written by compute, sampled by a merge's warp, copied out for
|
||||
// the CPU. Never handed to the compositor, so no RENDER_ATTACHMENT.
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
self.camera_target = Some(Target {
|
||||
texture,
|
||||
view,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
/// The transfer itself.
|
||||
fn copy_output(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
||||
let Some(target) = self.targets[self.current].as_ref() else {
|
||||
return Err(GpuError::Readback("nothing rendered yet".into()));
|
||||
};
|
||||
let (w, h) = (target.width, target.height);
|
||||
Self::copy_texture(&self.ctx, &target.texture, w_h(target), 4)
|
||||
}
|
||||
|
||||
let unpadded = w * 4;
|
||||
/// Copy a whole texture to the CPU, `bytes_per_pixel` wide, rows
|
||||
/// unpadded. Shared by the display readback and the camera-space one.
|
||||
fn copy_texture(
|
||||
ctx: &GpuContext,
|
||||
texture: &wgpu::Texture,
|
||||
(w, h): (u32, u32),
|
||||
bytes_per_pixel: u32,
|
||||
) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
||||
let unpadded = w * bytes_per_pixel;
|
||||
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let padded = unpadded.div_ceil(align) * align;
|
||||
|
||||
let buf = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("adjust-readback"),
|
||||
size: (padded * h) as u64,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
|
||||
let mut enc = ctx.device.create_command_encoder(&Default::default());
|
||||
enc.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &target.texture,
|
||||
texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
@@ -1166,7 +1372,7 @@ impl AdjustPass {
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
ctx.queue.submit(Some(enc.finish()));
|
||||
|
||||
let slice = buf.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
@@ -1177,7 +1383,7 @@ impl AdjustPass {
|
||||
// Polled rather than parked, and bounded rather than spun forever —
|
||||
// see `readback::await_mapping`, which the histogram's own transfer
|
||||
// shares for exactly the same reasons.
|
||||
await_mapping(&self.ctx, &rx)?;
|
||||
await_mapping(ctx, &rx)?;
|
||||
|
||||
let data = slice.get_mapped_range();
|
||||
let mut out = Vec::with_capacity((unpadded * h) as usize);
|
||||
@@ -1191,6 +1397,10 @@ impl AdjustPass {
|
||||
}
|
||||
}
|
||||
|
||||
fn w_h(t: &Target) -> (u32, u32) {
|
||||
(t.width, t.height)
|
||||
}
|
||||
|
||||
/// Number the lines of generated source, so a compiler error can be located.
|
||||
pub(crate) fn numbered(src: &str) -> String {
|
||||
src.lines()
|
||||
@@ -1242,6 +1452,10 @@ mod tests {
|
||||
// rather than about a camera's colour response.
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -1442,6 +1656,10 @@ mod tests {
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -1680,6 +1898,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-20
|
||||
#[test]
|
||||
fn a_keystone_reshapes_the_frame_without_exposing_a_corner() {
|
||||
// The shader half of perspective correction, end to end. A top-bright
|
||||
// frame with a full vertical keystone spreads its top across the
|
||||
// output, so the bright half reaches further down than the middle;
|
||||
// and since the frame is mapped onto a trapezoid *inside* the source,
|
||||
// no corner is left without a pixel behind it.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let img = split_image(&ctx, true);
|
||||
|
||||
let plain = EditGraph::default_chain().compose();
|
||||
let tex = pass.render(&img, &plain, 32, 32).expect("render");
|
||||
let below_middle = read_pixel(&ctx, tex, 16, 19)[0];
|
||||
assert!(
|
||||
below_middle < 90,
|
||||
"unkeyed, row 19 is the dark half: {below_middle}"
|
||||
);
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(
|
||||
dr_pipeline::framing::ID,
|
||||
dr_pipeline::framing::KEYSTONE_V,
|
||||
100.0,
|
||||
);
|
||||
g.set_param(
|
||||
dr_pipeline::framing::ID,
|
||||
dr_pipeline::framing::KEYSTONE_H,
|
||||
100.0,
|
||||
);
|
||||
let shader = g.compose();
|
||||
let tex = pass.render(&img, &shader, 32, 32).expect("render");
|
||||
|
||||
for (x, y) in [(0, 0), (31, 0), (0, 31), (31, 31)] {
|
||||
assert_ne!(
|
||||
read_pixel(&ctx, tex, x, y),
|
||||
[0, 0, 0, 255],
|
||||
"corner ({x},{y}) has no source pixel behind it"
|
||||
);
|
||||
}
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(
|
||||
dr_pipeline::framing::ID,
|
||||
dr_pipeline::framing::KEYSTONE_V,
|
||||
100.0,
|
||||
);
|
||||
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
|
||||
let keyed = read_pixel(&ctx, tex, 16, 19)[0];
|
||||
assert!(
|
||||
keyed > 128,
|
||||
"the spread top half must reach row 19: {keyed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_crop_does_not_recompile() {
|
||||
// The cache contract for framing, which is what makes an interactive
|
||||
@@ -1969,6 +2243,10 @@ mod tests {
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -2069,6 +2347,10 @@ mod tests {
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
||||
@@ -250,6 +250,135 @@ impl DemosaicedImage {
|
||||
}
|
||||
}
|
||||
|
||||
impl DemosaicedImage {
|
||||
/// TRACES: FR-MRG-3
|
||||
/// A source that is already RGB in camera space: a linear DNG, which is
|
||||
/// what a merge writes. No demosaic; the samples are normalised by the
|
||||
/// file's black and white levels exactly as the demosaic kernel would
|
||||
/// normalise a photosite, and everything else — the matrix, the
|
||||
/// balance, the body's base curve — is carried through as for a CFA
|
||||
/// file, because the composite is developed as one photograph from the
|
||||
/// body that took its sources.
|
||||
pub fn from_linear_rgb16(ctx: &GpuContext, raw: &RawImage) -> Result<Self, GpuError> {
|
||||
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||||
let limits = ctx.device.limits();
|
||||
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
|
||||
return Err(GpuError::TooLarge(format!(
|
||||
"{width}×{height} exceeds the device limit of {}",
|
||||
limits.max_texture_dimension_2d
|
||||
)));
|
||||
}
|
||||
let stride = raw.width as usize * 3;
|
||||
let expected = raw.height as usize * stride;
|
||||
if raw.data.len() < expected {
|
||||
return Err(GpuError::TooLarge(format!(
|
||||
"{} samples is short of the {expected} a {}×{} RGB image needs",
|
||||
raw.data.len(),
|
||||
raw.width,
|
||||
raw.height
|
||||
)));
|
||||
}
|
||||
let black = black_per_cell(raw);
|
||||
let inv = inv_range_per_cell(raw);
|
||||
// Per channel rather than per CFA cell: R, G, B are the first three.
|
||||
let mut half: Vec<u16> = Vec::with_capacity((width * height * 4) as usize);
|
||||
for y in 0..height as usize {
|
||||
let row = (raw.crop.y as usize + y) * stride + raw.crop.x as usize * 3;
|
||||
for x in 0..width as usize {
|
||||
let p = &raw.data[row + x * 3..row + x * 3 + 3];
|
||||
for c in 0..3 {
|
||||
let v = (f32::from(p[c]) - black[c]) * inv[c];
|
||||
half.push(f32_to_f16_bits_unclamped(v));
|
||||
}
|
||||
half.push(f32_to_f16_bits(1.0));
|
||||
}
|
||||
}
|
||||
let texture = ctx.device.create_texture_with_data(
|
||||
&ctx.queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("linear-rgb-source"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: Self::FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
bytemuck::cast_slice(&half),
|
||||
);
|
||||
let view = texture.create_view(&Default::default());
|
||||
Ok(Self {
|
||||
texture,
|
||||
view,
|
||||
width,
|
||||
height,
|
||||
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
||||
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
||||
base_curve: raw.base_curve,
|
||||
non_linear: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an f32 to half-precision bits, the general case: sign,
|
||||
/// subnormals, round-to-nearest-even, saturation at the largest finite.
|
||||
///
|
||||
/// `f32_to_f16_bits` below is the 8-bit special case and says why it can
|
||||
/// be; this one exists because a linear DNG is not that case. A 14-bit
|
||||
/// sensor's least significant step, normalised, is 6.1e-5 — right at f16's
|
||||
/// smallest normal (6.1e-5) — so the deepest shadows of a composite land
|
||||
/// in the subnormal range, and rounding them to zero would crush the
|
||||
/// shadows of exactly the file that was written to keep them. Values below
|
||||
/// zero (black subtraction on a noisy photosite) and above one (a highlight
|
||||
/// past the white level) are legitimate and kept.
|
||||
fn f32_to_f16_bits_unclamped(v: f32) -> u16 {
|
||||
let bits = v.to_bits();
|
||||
let sign = ((bits >> 16) & 0x8000) as u16;
|
||||
let exp = ((bits >> 23) & 0xFF) as i32;
|
||||
let mant = bits & 0x7F_FFFF;
|
||||
if exp == 0xFF {
|
||||
// Infinity or NaN: a NaN sample is a decode fault; store the largest
|
||||
// finite rather than propagate it through a blend.
|
||||
return sign | 0x7BFF;
|
||||
}
|
||||
let e = exp - 127 + 15;
|
||||
if e >= 0x1F {
|
||||
return sign | 0x7BFF;
|
||||
}
|
||||
if e <= 0 {
|
||||
// Subnormal in f16 (or underflow). Shift the full mantissa with its
|
||||
// implicit bit right by the deficit, rounding to nearest even.
|
||||
if e < -10 {
|
||||
return sign;
|
||||
}
|
||||
let m = (mant | 0x80_0000) >> (1 - e);
|
||||
let shift = 13;
|
||||
let rounded = round_shift(m, shift);
|
||||
return sign | rounded as u16;
|
||||
}
|
||||
let rounded = round_shift(mant, 13);
|
||||
// Rounding can carry into the exponent; that is correct.
|
||||
sign | (((e as u32) << 10) + rounded) as u16
|
||||
}
|
||||
|
||||
/// `v >> shift`, rounded to nearest with ties to even.
|
||||
fn round_shift(v: u32, shift: u32) -> u32 {
|
||||
let half = 1u32 << (shift - 1);
|
||||
let mask = (1u32 << shift) - 1;
|
||||
let low = v & mask;
|
||||
let mut out = v >> shift;
|
||||
if low > half || (low == half && (out & 1) == 1) {
|
||||
out += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert an f32 to IEEE 754 half-precision bits.
|
||||
///
|
||||
/// Written out rather than pulled in as a dependency: the inputs here are
|
||||
@@ -389,6 +518,9 @@ impl Demosaicer {
|
||||
/// `RawImage`; which of the two CFA families it came off is this
|
||||
/// function's problem, not theirs.
|
||||
pub fn run(&self, raw: &RawImage) -> Result<DemosaicedImage, GpuError> {
|
||||
if raw.samples_per_pixel == 3 {
|
||||
return DemosaicedImage::from_linear_rgb16(&self.ctx, raw);
|
||||
}
|
||||
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||||
|
||||
let limits = self.ctx.device.limits();
|
||||
@@ -827,6 +959,43 @@ mod tests {
|
||||
use super::*;
|
||||
use dr_decode::CropRect;
|
||||
|
||||
fn f16_to_f32(bits: u16) -> f32 {
|
||||
let sign = if bits & 0x8000 != 0 { -1.0 } else { 1.0 };
|
||||
let e = ((bits >> 10) & 0x1F) as i32;
|
||||
let m = (bits & 0x3FF) as f32;
|
||||
if e == 0 {
|
||||
sign * m * 2f32.powi(-24)
|
||||
} else {
|
||||
sign * (1.0 + m / 1024.0) * 2f32.powi(e - 15)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclamped_half_keeps_shadows_signs_and_highlights() {
|
||||
// A 14-bit LSB, normalised: subnormal in f16, and must not be zero.
|
||||
let lsb = 1.0 / 16383.0;
|
||||
let back = f16_to_f32(f32_to_f16_bits_unclamped(lsb));
|
||||
assert!((back - lsb).abs() / lsb < 0.01, "{back} vs {lsb}");
|
||||
// A quarter of that, still representable.
|
||||
let tiny = lsb / 4.0;
|
||||
let back = f16_to_f32(f32_to_f16_bits_unclamped(tiny));
|
||||
assert!((back - tiny).abs() / tiny < 0.05, "{back} vs {tiny}");
|
||||
// Below zero and above one survive.
|
||||
assert!((f16_to_f32(f32_to_f16_bits_unclamped(-0.01)) + 0.01).abs() < 1e-5);
|
||||
assert!((f16_to_f32(f32_to_f16_bits_unclamped(1.75)) - 1.75).abs() < 1e-3);
|
||||
// Exact values are exact.
|
||||
assert_eq!(f32_to_f16_bits_unclamped(1.0), 0x3C00);
|
||||
assert_eq!(f32_to_f16_bits_unclamped(0.5), 0x3800);
|
||||
assert_eq!(f32_to_f16_bits_unclamped(0.0), 0);
|
||||
// Within one ULP of the clamped one on its domain: that one
|
||||
// truncates the mantissa, this one rounds it.
|
||||
for i in 0..=255 {
|
||||
let v = i as f32 / 255.0;
|
||||
let (a, b) = (f32_to_f16_bits_unclamped(v), f32_to_f16_bits(v));
|
||||
assert!(a.abs_diff(b) <= 1, "{v}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_for(black: [u16; 4], white: u16) -> RawImage {
|
||||
RawImage {
|
||||
width: 4,
|
||||
@@ -838,6 +1007,10 @@ mod tests {
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -950,6 +1123,10 @@ mod tests {
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -1170,6 +1347,53 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grey_step_edge_stays_grey() {
|
||||
// A flat patch cannot tell the Malvar kernels from any other set of
|
||||
// weights that sum to zero. An edge can. A grey vertical step, so
|
||||
// every photosite records the same profile, must come back with the
|
||||
// three channels close together on both sides; any spread is false
|
||||
// colour from interpolating across the edge.
|
||||
//
|
||||
// The bound is set by the paper's kernels, which peak at 0.19 here.
|
||||
// With the ±2 terms of the green-site kernels transposed — the bug
|
||||
// this test was written against — the peak is 0.375.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let d = Demosaicer::new(&ctx).expect("demosaicer");
|
||||
|
||||
let size = 32u32;
|
||||
let white = 16383u16;
|
||||
let mut raw = flat_cfa(CfaPattern::Rggb, size, [0, 0, 0], 0, white);
|
||||
for y in 0..size {
|
||||
for x in size / 2..size {
|
||||
raw.data[(y * size + x) as usize] = white;
|
||||
}
|
||||
}
|
||||
|
||||
let img = d.run(&raw).expect("demosaic");
|
||||
let px = read_rgba(&ctx, &img);
|
||||
let (w, _) = img.size();
|
||||
|
||||
let mut worst = (0.0f32, 0u32, 0u32);
|
||||
for y in 2..size - 2 {
|
||||
for x in 2..size - 2 {
|
||||
let p = px[(y * w + x) as usize];
|
||||
let spread = (p[0] - p[1]).abs().max((p[2] - p[1]).abs());
|
||||
if spread > worst.0 {
|
||||
worst = (spread, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
worst.0 < 0.25,
|
||||
"false colour of {} at ({}, {}) on a grey edge — the green-site \
|
||||
kernels are interpolating across the edge",
|
||||
worst.0,
|
||||
worst.1,
|
||||
worst.2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_free_of_nan_and_negatives() {
|
||||
// f16 NaN propagates silently through every later stage; a negative
|
||||
@@ -1196,6 +1420,10 @@ mod tests {
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -1277,6 +1505,10 @@ mod tests {
|
||||
],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
||||
@@ -470,7 +470,7 @@ impl FocusPeakPass {
|
||||
// TEXTURE_BINDING to be sampled by the compositor.
|
||||
// RENDER_ATTACHMENT is not used by anything here and is required
|
||||
// anyway: Slint rejects an imported texture without it. COPY_SRC
|
||||
// is for `read_overlay` and its two callers.
|
||||
// is for `read_overlay` and the tests that call it.
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
@@ -490,18 +490,11 @@ impl FocusPeakPass {
|
||||
/// TRACES: AC-8
|
||||
/// Copy the overlay to the CPU, as RGBA8 rows with no padding.
|
||||
///
|
||||
/// **Two callers, and neither is the desktop display path.** The tests
|
||||
/// below are one: an overlay is a claim about which pixels are sharp, and
|
||||
/// there is no way to check that claim without looking at the pixels. The
|
||||
/// other is the Android develop view, which reads the *frame* back for the
|
||||
/// reasons `technical-debt.md` TD-1 records — wgpu's Android swapchain
|
||||
/// tears a portrait window, so Slint is not drawing with wgpu there and no
|
||||
/// texture can be handed over. An overlay that stayed on the device on a
|
||||
/// platform where the picture underneath it does not would simply never be
|
||||
/// seen.
|
||||
///
|
||||
/// On desktop nothing calls this, and ARCH §6.1 holds on the path that
|
||||
/// matters: the overlay reaches the compositor as a texture.
|
||||
/// **The tests below are the only caller, and never the display path.** An
|
||||
/// overlay is a claim about which pixels are sharp, and there is no way to
|
||||
/// check that claim without looking at the pixels. On screen, on desktop
|
||||
/// and Android alike, the overlay reaches the compositor as a texture and
|
||||
/// ARCH §6.1 holds. (Android read it back here until TD-1 was paid off.)
|
||||
pub fn read_overlay(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
||||
let Some(layer) = self.layers[self.current].as_ref() else {
|
||||
return Err(GpuError::Readback("no overlay has been rendered".into()));
|
||||
|
||||
+18
-1
@@ -6,7 +6,7 @@
|
||||
//! module doc said for eight releases that it held no pipeline and no masks.
|
||||
//! It holds both now, plus demosaic, detail, segmentation masks, two
|
||||
//! histograms and focus peaking. The zero-copy claim is still the one that
|
||||
//! matters, and TD-1 records the one platform where it does not hold.
|
||||
//! matters, and since TD-1 was paid off it holds on Android too.
|
||||
//!
|
||||
//! Deliberately free of UI dependencies (ARCH §6.5a). The texture is handed
|
||||
//! out as a `wgpu::Texture`; who composites it is not this crate's concern.
|
||||
@@ -28,6 +28,7 @@ mod error;
|
||||
mod focus;
|
||||
mod histogram;
|
||||
mod mask;
|
||||
mod merge;
|
||||
mod raw_histogram;
|
||||
mod readback;
|
||||
mod segment;
|
||||
@@ -40,6 +41,7 @@ pub use demosaic::{DemosaicedImage, Demosaicer};
|
||||
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
|
||||
pub use error::GpuError;
|
||||
pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity};
|
||||
pub use merge::{Band, MergeFrame, MergeOutput, MergePass};
|
||||
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
|
||||
// at all at a crate root shared with demosaic and segmentation.
|
||||
pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS};
|
||||
@@ -281,6 +283,7 @@ impl GpuContext {
|
||||
)))
|
||||
}
|
||||
|
||||
/// TRACES: NFR-COMPAT-1
|
||||
/// Ask one adapter for a device, with the limits the pipeline needs.
|
||||
async fn device_from(adapter: &wgpu::Adapter) -> Result<(wgpu::Device, wgpu::Queue), GpuError> {
|
||||
adapter
|
||||
@@ -332,6 +335,20 @@ impl GpuContext {
|
||||
pub fn backend(&self) -> wgpu::Backend {
|
||||
self.adapter_info.backend
|
||||
}
|
||||
|
||||
/// TRACES: NFR-OPS-1
|
||||
/// The driver, as the adapter reported it, for a diagnostics bundle.
|
||||
/// Name and version in one string because wgpu splits them by backend
|
||||
/// and neither half means much without the other.
|
||||
pub fn driver(&self) -> String {
|
||||
let info = &self.adapter_info;
|
||||
match (info.driver.is_empty(), info.driver_info.is_empty()) {
|
||||
(true, true) => "unknown driver".to_string(),
|
||||
(false, true) => info.driver.clone(),
|
||||
(true, false) => info.driver_info.clone(),
|
||||
(false, false) => format!("{} {}", info.driver, info.driver_info),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
|
||||
+53
-2
@@ -395,6 +395,7 @@ pub struct MaskPass {
|
||||
combine_layout: wgpu::BindGroupLayout,
|
||||
combine_union: wgpu::RenderPipeline,
|
||||
combine_subtract: wgpu::RenderPipeline,
|
||||
combine_intersect: wgpu::RenderPipeline,
|
||||
/// Where a part is drawn before it is joined.
|
||||
///
|
||||
/// One texture for the whole stack rather than one per layer, because
|
||||
@@ -651,6 +652,16 @@ impl MaskPass {
|
||||
"mask-combine-subtract",
|
||||
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
|
||||
);
|
||||
// TRACES: FR-DEV-19a
|
||||
// `dst * src`: what the mask had, kept only in proportion to how much
|
||||
// of it this part also covers. The same three vertices and the same
|
||||
// scratch, so a third set operation is a third blend state and
|
||||
// nothing more — which is what `Join::apply` states on the CPU and
|
||||
// `the_joins_match_their_definition` holds this to.
|
||||
let combine_intersect = combine(
|
||||
"mask-combine-intersect",
|
||||
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::Src),
|
||||
);
|
||||
|
||||
// The same, with the deposit thrown away: coverage is only ever taken
|
||||
// off what earlier strokes on this layer put down. There is no negative
|
||||
@@ -681,6 +692,7 @@ impl MaskPass {
|
||||
combine_layout,
|
||||
combine_union,
|
||||
combine_subtract,
|
||||
combine_intersect,
|
||||
scratch: None,
|
||||
array: None,
|
||||
allocations: 0,
|
||||
@@ -758,12 +770,24 @@ impl MaskPass {
|
||||
// is done where it is read back rather than where it is drawn —
|
||||
// a brush deposits dabs and cannot know what the rest of the
|
||||
// frame is. See `fs_combine`.
|
||||
let direct = layer.parts().len() == 1 && !layer.base().invert;
|
||||
// TRACES: FR-DEV-19a
|
||||
// The shown parts, not the parts: a hidden one is skipped here
|
||||
// and nowhere else, and the first *shown* part is the one that
|
||||
// opens the fold. Which can leave nothing — a revealed layer with
|
||||
// every part hidden — and that clears the slice rather than
|
||||
// leaving whatever the last rasterisation put there to be read
|
||||
// back as this mask.
|
||||
let shown: Vec<&dr_pipeline::mask::MaskPart> = layer.shown_parts().collect();
|
||||
if shown.is_empty() {
|
||||
self.clear_slice(&mut encoder, slot as u32);
|
||||
continue;
|
||||
}
|
||||
let direct = shown.len() == 1 && !shown[0].invert;
|
||||
if !direct {
|
||||
self.ensure_scratch(width, height)?;
|
||||
}
|
||||
|
||||
for (index, part) in layer.parts().iter().enumerate() {
|
||||
for (index, part) in shown.iter().copied().enumerate() {
|
||||
let base = index == 0;
|
||||
let field = match (&part.source, labels) {
|
||||
(MaskSource::Regions { .. }, None) => {
|
||||
@@ -1364,11 +1388,38 @@ impl MaskPass {
|
||||
pass.set_pipeline(match join {
|
||||
Join::Union => &self.combine_union,
|
||||
Join::Subtract => &self.combine_subtract,
|
||||
Join::Intersect => &self.combine_intersect,
|
||||
});
|
||||
pass.set_bind_group(0, &bind_group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// Leave a layer's slice covering nothing.
|
||||
///
|
||||
/// A pass that clears and draws nothing, for the one case where a layer
|
||||
/// reaches the array with no part to draw: every part hidden while the
|
||||
/// layer is being revealed. The slice has to be written, because the
|
||||
/// shader reads it whatever this function did.
|
||||
fn clear_slice(&self, encoder: &mut wgpu::CommandEncoder, slot: u32) {
|
||||
let target = self.slice_view(slot);
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("mask-clear-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// The texture a part is drawn in before it is joined.
|
||||
///
|
||||
/// Allocated on the first mask that has more than one part and kept at the
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
//! TRACES: FR-MRG-10 | FR-MRG-11
|
||||
//! The merge: source frames warped into an output surface, chunk by chunk.
|
||||
//!
|
||||
//! The per-pixel half of a panorama (FR-MRG-10), on the GPU: the warp of a
|
||||
//! source tile into an output chunk, the weighted accumulation across
|
||||
//! frames, and the resolve to sixteen-bit samples. The geometry it is
|
||||
//! given — rotations, focal length, projection — is `dr-pano`'s, solved on
|
||||
//! proxies before any full-resolution pixel exists (panorama.md §5), and
|
||||
//! that is what makes this simple: every output pixel's source coordinates
|
||||
//! are a closed-form function, so a chunk can be produced from the source
|
||||
//! tiles that project into it and nothing else.
|
||||
//!
|
||||
//! # The loop
|
||||
//!
|
||||
//! ```text
|
||||
//! for each band of rows of the output:
|
||||
//! for each chunk across the band:
|
||||
//! zero the accumulator
|
||||
//! for each frame whose footprint meets the chunk:
|
||||
//! the source rectangle the chunk needs, from the geometry
|
||||
//! render it camera-linear through the pipeline (the tile)
|
||||
//! warp the tile into the chunk, accumulate ← GPU
|
||||
//! resolve the chunk to u16 ← GPU
|
||||
//! copy it into the band
|
||||
//! hand the band to the writer (one DNG strip)
|
||||
//! ```
|
||||
//!
|
||||
//! No stage holds the composite (FR-MRG-11): the working set is one
|
||||
//! chunk's accumulator, one tile, one band of u16 rows. The frame textures
|
||||
//! are the caller's to provide and cache — `source` is asked for frame `k`
|
||||
//! as it is needed, and a caller short of memory may demosaic on demand.
|
||||
//!
|
||||
//! # What is not here yet
|
||||
//!
|
||||
//! A feathered blend, not seams and a Laplacian pyramid: the weight is the
|
||||
//! distance to the frame's edge, which hides exposure steps and small
|
||||
//! misalignments and does not hide parallax. Gain is a scalar per frame
|
||||
//! the caller supplies. Both are panorama.md §10's step 5, after the path
|
||||
//! writes a file end to end.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dr_pano::bundle::Cameras;
|
||||
use dr_pano::projection::{Bounds, Projection};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::readback::await_mapping;
|
||||
use crate::{AdjustPass, DemosaicedImage, GpuContext, GpuError};
|
||||
|
||||
/// One frame's part in the merge.
|
||||
pub struct MergeFrame {
|
||||
/// The frame's edit, for its lens corrections — the only part of an
|
||||
/// edit the camera-space tap uses (FR-MRG-2).
|
||||
pub graph: Arc<dr_pipeline::EditGraph>,
|
||||
/// Multiplies the frame's samples, to bring its exposure to the
|
||||
/// reference frame's. 1.0 for no correction.
|
||||
pub gain: f32,
|
||||
}
|
||||
|
||||
/// The output the merge produces.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct MergeOutput {
|
||||
pub projection: Projection,
|
||||
/// The projection's scale in output pixels: the cylinder's radius, the
|
||||
/// plane's distance. The source focal length at full resolution gives
|
||||
/// output pixels the size of source pixels at the centre.
|
||||
pub scale: f64,
|
||||
/// The rectangle of the projection to produce, centred coordinates.
|
||||
pub bounds: Bounds,
|
||||
/// Pixels over which a frame's weight ramps up from its edge.
|
||||
pub feather: f32,
|
||||
/// Chunk size: the unit of GPU work and of memory.
|
||||
pub chunk: (u32, u32),
|
||||
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
|
||||
pub sample_scale: f32,
|
||||
}
|
||||
|
||||
impl MergeOutput {
|
||||
pub fn width(&self) -> u32 {
|
||||
self.bounds.width().ceil().max(1.0) as u32
|
||||
}
|
||||
pub fn height(&self) -> u32 {
|
||||
self.bounds.height().ceil().max(1.0) as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// A band of finished rows: `rows × width × 3` RGB `u16`, plus a coverage
|
||||
/// mask (`true` where any frame reached the pixel).
|
||||
pub struct Band<'a> {
|
||||
pub first_row: u32,
|
||||
pub rows: u32,
|
||||
pub rgb: &'a [u16],
|
||||
pub covered: &'a [bool],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct WarpParams {
|
||||
chunk_origin: [f32; 2],
|
||||
chunk_size: [u32; 2],
|
||||
projection: u32,
|
||||
proj_scale: f32,
|
||||
focal: f32,
|
||||
gain: f32,
|
||||
r0: [f32; 4],
|
||||
r1: [f32; 4],
|
||||
r2: [f32; 4],
|
||||
frame_size: [f32; 2],
|
||||
tile_origin: [f32; 2],
|
||||
tile_size: [u32; 2],
|
||||
feather: f32,
|
||||
_pad: f32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct ResolveParams {
|
||||
chunk_size: [u32; 2],
|
||||
scale: f32,
|
||||
_pad: f32,
|
||||
}
|
||||
|
||||
/// The two pipelines and the chunk buffers.
|
||||
pub struct MergePass {
|
||||
ctx: GpuContext,
|
||||
warp: wgpu::ComputePipeline,
|
||||
warp_layout: wgpu::BindGroupLayout,
|
||||
resolve: wgpu::ComputePipeline,
|
||||
resolve_layout: wgpu::BindGroupLayout,
|
||||
/// Accumulator and packed output for the current chunk size.
|
||||
buffers: Option<(wgpu::Buffer, wgpu::Buffer, wgpu::Buffer, (u32, u32))>,
|
||||
}
|
||||
|
||||
impl MergePass {
|
||||
pub fn new(ctx: &GpuContext) -> Result<Self, GpuError> {
|
||||
let module = ctx
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("merge"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/merge.wgsl").into()),
|
||||
});
|
||||
let uniform = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let storage = |binding, read_only| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let warp_layout = ctx
|
||||
.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("merge-warp-bgl"),
|
||||
entries: &[
|
||||
uniform(0),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
// Unfilterable: rgba32float, loaded by hand.
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
storage(2, false),
|
||||
],
|
||||
});
|
||||
let resolve_layout =
|
||||
ctx.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("merge-resolve-bgl"),
|
||||
entries: &[uniform(0), storage(1, true), storage(2, false)],
|
||||
});
|
||||
let pipeline = |name: &str, layout: &wgpu::BindGroupLayout| {
|
||||
let pl = ctx
|
||||
.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(name),
|
||||
bind_group_layouts: &[Some(layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
ctx.device
|
||||
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(name),
|
||||
layout: Some(&pl),
|
||||
module: &module,
|
||||
entry_point: Some(name),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
Ok(MergePass {
|
||||
ctx: ctx.clone(),
|
||||
warp: pipeline("warp", &warp_layout),
|
||||
warp_layout,
|
||||
resolve: pipeline("resolve", &resolve_layout),
|
||||
resolve_layout,
|
||||
buffers: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Allocate the chunk buffers for this size if the last ones differ.
|
||||
fn ensure_buffers(&mut self, chunk: (u32, u32)) {
|
||||
if self.buffers.as_ref().is_none_or(|b| b.3 != chunk) {
|
||||
let n = u64::from(chunk.0) * u64::from(chunk.1);
|
||||
let acc = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("merge-acc"),
|
||||
size: n * 16,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let out = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("merge-out"),
|
||||
size: n * 8,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let read = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("merge-read"),
|
||||
size: n * 8,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.buffers = Some((acc, out, read, chunk));
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk_buffers(&self) -> (&wgpu::Buffer, &wgpu::Buffer, &wgpu::Buffer) {
|
||||
let b = self.buffers.as_ref().expect("ensured by the caller");
|
||||
(&b.0, &b.1, &b.2)
|
||||
}
|
||||
|
||||
/// Produce the whole output, band by band, handing each finished band
|
||||
/// to `sink`.
|
||||
///
|
||||
/// `cameras` are in **full-resolution source pixels** (`frame_size`),
|
||||
/// with frame `k` corresponding to `frames[k]` and `source(k)`. `source`
|
||||
/// supplies the demosaiced frame on demand and may cache as it sees fit.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn merge<S, F>(
|
||||
&mut self,
|
||||
adjust: &mut AdjustPass,
|
||||
frames: &[MergeFrame],
|
||||
cameras: &Cameras,
|
||||
frame_size: (u32, u32),
|
||||
output: &MergeOutput,
|
||||
mut source: S,
|
||||
mut sink: F,
|
||||
mut cancelled: impl FnMut() -> bool,
|
||||
) -> Result<(), GpuError>
|
||||
where
|
||||
S: FnMut(usize) -> Result<Arc<DemosaicedImage>, GpuError>,
|
||||
F: FnMut(Band<'_>) -> Result<(), GpuError>,
|
||||
{
|
||||
let (out_w, out_h) = (output.width(), output.height());
|
||||
let (cw, ch) = (output.chunk.0.max(8), output.chunk.1.max(8));
|
||||
let (fw, fh) = (frame_size.0 as f64, frame_size.1 as f64);
|
||||
|
||||
let mut band_rgb = vec![0u16; (out_w * ch * 3) as usize];
|
||||
let mut band_cov = vec![false; (out_w * ch) as usize];
|
||||
let mut chunk_px: Vec<u32> = Vec::new();
|
||||
|
||||
let mut y = 0u32;
|
||||
while y < out_h {
|
||||
let rows = ch.min(out_h - y);
|
||||
band_rgb.iter_mut().for_each(|v| *v = 0);
|
||||
band_cov.iter_mut().for_each(|v| *v = false);
|
||||
|
||||
let mut x = 0u32;
|
||||
while x < out_w {
|
||||
if cancelled() {
|
||||
return Err(GpuError::Readback("merge cancelled".into()));
|
||||
}
|
||||
let cols = cw.min(out_w - x);
|
||||
let origin = (
|
||||
output.bounds.min_u + f64::from(x),
|
||||
output.bounds.min_v + f64::from(y),
|
||||
);
|
||||
self.zero_accumulator((cols, rows));
|
||||
|
||||
for (k, frame) in frames.iter().enumerate() {
|
||||
let Some(rect) = source_rect(
|
||||
output.projection,
|
||||
output.scale,
|
||||
cameras,
|
||||
k,
|
||||
origin,
|
||||
(cols, rows),
|
||||
(fw, fh),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let image = source(k)?;
|
||||
// The tile: that rectangle of the frame, camera-linear,
|
||||
// at 1:1.
|
||||
let view = dr_pipeline::CropRect {
|
||||
x: (rect.0 as f32) / fw as f32,
|
||||
y: (rect.1 as f32) / fh as f32,
|
||||
width: (rect.2 as f32) / fw as f32,
|
||||
height: (rect.3 as f32) / fh as f32,
|
||||
};
|
||||
let shader = frame.graph.compose_camera_linear(view);
|
||||
let tile = adjust.render_camera_linear(&image, &shader, rect.2, rect.3)?;
|
||||
let r = cameras.rotations[k].transpose();
|
||||
let params = WarpParams {
|
||||
chunk_origin: [origin.0 as f32, origin.1 as f32],
|
||||
chunk_size: [cols, rows],
|
||||
projection: match output.projection {
|
||||
Projection::Perspective => 0,
|
||||
Projection::Cylindrical => 1,
|
||||
Projection::Spherical => 2,
|
||||
},
|
||||
proj_scale: output.scale as f32,
|
||||
focal: cameras.focal as f32,
|
||||
gain: frame.gain,
|
||||
r0: [r.0[0][0] as f32, r.0[0][1] as f32, r.0[0][2] as f32, 0.0],
|
||||
r1: [r.0[1][0] as f32, r.0[1][1] as f32, r.0[1][2] as f32, 0.0],
|
||||
r2: [r.0[2][0] as f32, r.0[2][1] as f32, r.0[2][2] as f32, 0.0],
|
||||
frame_size: [fw as f32, fh as f32],
|
||||
tile_origin: [rect.0 as f32, rect.1 as f32],
|
||||
tile_size: [rect.2, rect.3],
|
||||
feather: output.feather,
|
||||
_pad: 0.0,
|
||||
};
|
||||
self.accumulate(¶ms, tile);
|
||||
}
|
||||
|
||||
self.resolve_chunk((cols, rows), output.sample_scale, &mut chunk_px)?;
|
||||
// Into the band.
|
||||
for row in 0..rows as usize {
|
||||
for col in 0..cols as usize {
|
||||
let px = chunk_px[(row * cols as usize + col) * 2..][..2].to_vec();
|
||||
let i = row * out_w as usize + (x as usize + col);
|
||||
band_rgb[i * 3] = (px[0] & 0xFFFF) as u16;
|
||||
band_rgb[i * 3 + 1] = (px[0] >> 16) as u16;
|
||||
band_rgb[i * 3 + 2] = (px[1] & 0xFFFF) as u16;
|
||||
band_cov[i] = (px[1] >> 16) != 0;
|
||||
}
|
||||
}
|
||||
x += cols;
|
||||
}
|
||||
|
||||
sink(Band {
|
||||
first_row: y,
|
||||
rows,
|
||||
rgb: &band_rgb[..(out_w * rows * 3) as usize],
|
||||
covered: &band_cov[..(out_w * rows) as usize],
|
||||
})?;
|
||||
y += rows;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn zero_accumulator(&mut self, chunk: (u32, u32)) {
|
||||
self.ensure_buffers(chunk);
|
||||
let (acc, _, _) = self.chunk_buffers();
|
||||
let n = u64::from(chunk.0) * u64::from(chunk.1) * 16;
|
||||
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
|
||||
enc.clear_buffer(acc, 0, Some(n));
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
}
|
||||
|
||||
fn accumulate(&mut self, params: &WarpParams, tile: &wgpu::Texture) {
|
||||
let chunk = (params.chunk_size[0], params.chunk_size[1]);
|
||||
let uniforms = self
|
||||
.ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("merge-warp-params"),
|
||||
contents: bytemuck::bytes_of(params),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let view = tile.create_view(&Default::default());
|
||||
self.ensure_buffers(chunk);
|
||||
let (acc, _, _) = self.chunk_buffers();
|
||||
let bind = self
|
||||
.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("merge-warp-bg"),
|
||||
layout: &self.warp_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniforms.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: acc.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&self.warp);
|
||||
pass.set_bind_group(0, &bind, &[]);
|
||||
pass.dispatch_workgroups(chunk.0.div_ceil(8), chunk.1.div_ceil(8), 1);
|
||||
}
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
}
|
||||
|
||||
fn resolve_chunk(
|
||||
&mut self,
|
||||
chunk: (u32, u32),
|
||||
scale: f32,
|
||||
out: &mut Vec<u32>,
|
||||
) -> Result<(), GpuError> {
|
||||
let params = ResolveParams {
|
||||
chunk_size: [chunk.0, chunk.1],
|
||||
scale,
|
||||
_pad: 0.0,
|
||||
};
|
||||
let uniforms = self
|
||||
.ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("merge-resolve-params"),
|
||||
contents: bytemuck::bytes_of(¶ms),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let n = u64::from(chunk.0) * u64::from(chunk.1);
|
||||
self.ensure_buffers(chunk);
|
||||
let (acc, packed, read) = self.chunk_buffers();
|
||||
let bind = self
|
||||
.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("merge-resolve-bg"),
|
||||
layout: &self.resolve_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniforms.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: acc.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: packed.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&self.resolve);
|
||||
pass.set_bind_group(0, &bind, &[]);
|
||||
pass.dispatch_workgroups(chunk.0.div_ceil(8), chunk.1.div_ceil(8), 1);
|
||||
}
|
||||
enc.copy_buffer_to_buffer(packed, 0, read, 0, n * 8);
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
|
||||
let slice = read.slice(..n * 8);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
await_mapping(&self.ctx, &rx)?;
|
||||
{
|
||||
let data = slice.get_mapped_range();
|
||||
out.clear();
|
||||
out.extend_from_slice(bytemuck::cast_slice::<u8, u32>(&data));
|
||||
}
|
||||
read.unmap();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The rectangle of frame `k` (x, y, w, h in source pixels) a chunk reads,
|
||||
/// or `None` if the chunk sees nothing of the frame.
|
||||
///
|
||||
/// Walks the chunk's border, projects each point into the frame, and takes
|
||||
/// the bounding box with a two-pixel margin for the bilinear fetch. The
|
||||
/// border rather than the corners because under a cylinder or sphere the
|
||||
/// extreme of a footprint is not at a corner.
|
||||
fn source_rect(
|
||||
projection: Projection,
|
||||
scale: f64,
|
||||
cameras: &Cameras,
|
||||
k: usize,
|
||||
origin: (f64, f64),
|
||||
size: (u32, u32),
|
||||
frame: (f64, f64),
|
||||
) -> Option<(u32, u32, u32, u32)> {
|
||||
let (w, h) = (f64::from(size.0), f64::from(size.1));
|
||||
let steps = 16;
|
||||
let mut min = (f64::MAX, f64::MAX);
|
||||
let mut max = (f64::MIN, f64::MIN);
|
||||
let mut any = false;
|
||||
let mut visit = |u: f64, v: f64| {
|
||||
let d = projection.to_direction(scale, u, v);
|
||||
if let Some((x, y)) = cameras.project(k, d) {
|
||||
let (x, y) = (x + frame.0 / 2.0, y + frame.1 / 2.0);
|
||||
min = (min.0.min(x), min.1.min(y));
|
||||
max = (max.0.max(x), max.1.max(y));
|
||||
any = true;
|
||||
}
|
||||
};
|
||||
for s in 0..=steps {
|
||||
let t = f64::from(s) / f64::from(steps);
|
||||
visit(origin.0 + w * t, origin.1);
|
||||
visit(origin.0 + w * t, origin.1 + h);
|
||||
visit(origin.0, origin.1 + h * t);
|
||||
visit(origin.0 + w, origin.1 + h * t);
|
||||
}
|
||||
// The interior too, coarsely: a chunk can contain a frame entirely.
|
||||
for i in 1..4 {
|
||||
for j in 1..4 {
|
||||
visit(
|
||||
origin.0 + w * f64::from(i) / 4.0,
|
||||
origin.1 + h * f64::from(j) / 4.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return None;
|
||||
}
|
||||
let x0 = (min.0.floor() - 2.0).max(0.0);
|
||||
let y0 = (min.1.floor() - 2.0).max(0.0);
|
||||
let x1 = (max.0.ceil() + 2.0).min(frame.0);
|
||||
let y1 = (max.1.ceil() + 2.0).min(frame.1);
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return None;
|
||||
}
|
||||
Some((x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Watershed segmentation — arm A's GPU half (S15, docs/segmentation.md).
|
||||
//! Watershed segmentation — arm A's GPU half (S15, docs/dev/segmentation.md).
|
||||
//!
|
||||
//! Runs the five passes in `shaders/watershed.wgsl` over a demosaiced image
|
||||
//! and leaves a basin label per pixel on the GPU. The hierarchy built from
|
||||
@@ -20,7 +20,7 @@
|
||||
//! one AC-8 forbids is per frame in the render loop, and sharing a switch
|
||||
//! would force a build wanting local masking to unlock the other.
|
||||
//!
|
||||
//! It is still a real cost and still unfinished. F3 in docs/segmentation.md
|
||||
//! It is still a real cost and still unfinished. F3 in docs/dev/segmentation.md
|
||||
//! §12 stands: the adjacency accumulation belongs GPU-side with atomics, and
|
||||
//! until it moves there every segmentation pays a full-resolution transfer.
|
||||
//! Read the feature name as a description of a known gap rather than as
|
||||
@@ -36,7 +36,7 @@ pub struct SegmentOptions {
|
||||
/// Longest proxy edge. The segmentation runs here, not at sensor
|
||||
/// resolution: a 24 MP watershed costs 12× the memory to place boundaries
|
||||
/// a person cannot see, and the boundary refinement that matters at 1:1
|
||||
/// is a separate stage (docs/segmentation.md §4).
|
||||
/// is a separate stage (docs/dev/segmentation.md §4).
|
||||
pub max_edge: u32,
|
||||
/// Pre-smoothing radius in proxy pixels. The caller's to raise with ISO —
|
||||
/// this is the single knob that decides whether a noisy file segments
|
||||
@@ -69,7 +69,7 @@ impl Default for SegmentOptions {
|
||||
w_chroma: 0.5,
|
||||
// **Zero: the pass is off.** It is implemented, dispatched
|
||||
// correctly and measurably changes nothing — see the ignored test
|
||||
// below and §12 of docs/segmentation.md. Until that is understood,
|
||||
// below and §12 of docs/dev/segmentation.md. Until that is understood,
|
||||
// running it would buy 64 dispatches per segmentation and no
|
||||
// improvement, so the default declines to pay.
|
||||
plateau_iterations: 0,
|
||||
@@ -486,7 +486,7 @@ impl Segmentation {
|
||||
/// a region graph of a few thousand nodes that every later interaction
|
||||
/// reads from the CPU anyway.
|
||||
///
|
||||
/// What it is *not* is finished. F3 in docs/segmentation.md §12 stands:
|
||||
/// What it is *not* is finished. F3 in docs/dev/segmentation.md §12 stands:
|
||||
/// the adjacency accumulation belongs on the GPU with atomics, and until
|
||||
/// it moves there a segmentation costs one full-resolution transfer of the
|
||||
/// label and gradient buffers. That is a real cost on a phone and the
|
||||
@@ -724,7 +724,7 @@ mod tests {
|
||||
px
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "the plateau pass is a measured no-op; see docs/segmentation.md §12"]
|
||||
#[ignore = "the plateau pass is a measured no-op; see docs/dev/segmentation.md §12"]
|
||||
fn lower_completion_drains_a_plateau_instead_of_shattering_it() {
|
||||
// F1, asserted rather than eyeballed, and asserted at the level where
|
||||
// it matters.
|
||||
@@ -741,7 +741,7 @@ mod tests {
|
||||
// with no exit anywhere — cannot be drained by a distance that has
|
||||
// nowhere to descend to, and collapsing it fully would need connected
|
||||
// component labelling rather than a local rule. It is not worth it:
|
||||
// see docs/segmentation.md §12.
|
||||
// see docs/dev/segmentation.md §12.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let (w, h) = (96u32, 96u32);
|
||||
let src = DemosaicedImage::from_rgba8(&ctx, &ramp(w, h), w, h).expect("source");
|
||||
|
||||
@@ -160,12 +160,18 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
// Green is measured. Red and blue are interpolated from their own
|
||||
// axis, with a correction from the green Laplacian.
|
||||
//
|
||||
// Malvar "G at R/B locations" kernels, transposed per axis:
|
||||
// chroma along the row: (5c + 4(w1+e1) - (nw+ne+sw+se) - (n2+s2) + 0.5(w2+e2)) / 8
|
||||
// Malvar "R at green in R row" kernel, and its transpose:
|
||||
// chroma along the row: (5c + 4(w1+e1) - (nw+ne+sw+se) - (w2+e2) + 0.5(n2+s2)) / 8
|
||||
//
|
||||
// The -1 goes on the two greens *along* the chroma axis and the +0.5
|
||||
// on the pair across it. Transposed, both kernels still sum to zero
|
||||
// and reconstruct a flat patch exactly, but on an edge the correction
|
||||
// at green sites is half strength and the false colour doubles: a
|
||||
// blue/yellow zipper around every clipped highlight.
|
||||
let along_row =
|
||||
(5.0 * c + 4.0 * (w1 + e1) - diag1 - vert2 + 0.5 * horiz2) * 0.125;
|
||||
(5.0 * c + 4.0 * (w1 + e1) - diag1 - horiz2 + 0.5 * vert2) * 0.125;
|
||||
let along_col =
|
||||
(5.0 * c + 4.0 * (n1 + s1) - diag1 - horiz2 + 0.5 * vert2) * 0.125;
|
||||
(5.0 * c + 4.0 * (n1 + s1) - diag1 - vert2 + 0.5 * horiz2) * 0.125;
|
||||
|
||||
let red_horizontal = red_is_horizontal(gid.x, gid.y);
|
||||
let r = select(along_col, along_row, red_horizontal);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// TRACES: FR-MRG-10 | FR-MRG-11
|
||||
// The merge: one source tile warped into one output chunk, accumulated.
|
||||
//
|
||||
// Two entry points. `warp` runs once per (chunk, frame): for every chunk
|
||||
// pixel it asks which direction that pixel looks along, turns the
|
||||
// direction into the frame's camera, projects it to a source pixel, and
|
||||
// if that pixel is inside the tile that was rendered for this chunk,
|
||||
// samples it and adds it — weighted by its distance from the frame's edge
|
||||
// — into the accumulator. `resolve` runs once per chunk after every frame
|
||||
// has been added: divides the sums by the weights and packs the result as
|
||||
// sixteen-bit samples at the sensor's scale (FR-MRG-3).
|
||||
//
|
||||
// The accumulator is a buffer and not a storage texture, because WebGPU
|
||||
// allows a read-write storage texture only in the 32-bit single-channel
|
||||
// formats, and this wants four channels. The tile is sampled by hand from
|
||||
// four `textureLoad`s rather than through a sampler, because `rgba32float`
|
||||
// is not filterable without an optional feature, and the tile is
|
||||
// `rgba32float` on purpose (panorama.md §5.1).
|
||||
//
|
||||
// The projection maths is `dr_pano::projection` verbatim; the two must
|
||||
// agree, and a golden test compares them.
|
||||
|
||||
struct Params {
|
||||
// Where the chunk's pixel (0, 0) sits in centred output coordinates,
|
||||
// and the chunk's size.
|
||||
chunk_origin: vec2<f32>,
|
||||
chunk_size: vec2<u32>,
|
||||
// 0 perspective, 1 cylindrical, 2 spherical; and the projection's
|
||||
// scale (the cylinder's radius, the sphere's, the plane's distance) in
|
||||
// output pixels.
|
||||
projection: u32,
|
||||
proj_scale: f32,
|
||||
// The frame's focal length in source pixels, and the gain the frame's
|
||||
// exposure is corrected by.
|
||||
focal: f32,
|
||||
gain: f32,
|
||||
// World → this frame's camera: the transpose of its rotation, one row
|
||||
// per vec4 (padded).
|
||||
r0: vec4<f32>,
|
||||
r1: vec4<f32>,
|
||||
r2: vec4<f32>,
|
||||
// The full frame's size in source pixels (for the edge weight), the
|
||||
// tile's origin within the frame, and the tile's size.
|
||||
frame_size: vec2<f32>,
|
||||
tile_origin: vec2<f32>,
|
||||
tile_size: vec2<u32>,
|
||||
// Pixels over which the weight ramps from the edge to full.
|
||||
feather: f32,
|
||||
_pad: f32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: Params;
|
||||
@group(0) @binding(1) var tile: texture_2d<f32>;
|
||||
// rgb·w summed, then w: four floats per chunk pixel.
|
||||
@group(0) @binding(2) var<storage, read_write> acc: array<vec4<f32>>;
|
||||
|
||||
fn to_direction(u: f32, v: f32) -> vec3<f32> {
|
||||
let s = p.proj_scale;
|
||||
if (p.projection == 0u) {
|
||||
return normalize(vec3<f32>(u, v, s));
|
||||
}
|
||||
if (p.projection == 1u) {
|
||||
let theta = u / s;
|
||||
return normalize(vec3<f32>(sin(theta), v / s, cos(theta)));
|
||||
}
|
||||
let theta = u / s;
|
||||
let phi = v / s;
|
||||
return vec3<f32>(sin(theta) * cos(phi), sin(phi), cos(theta) * cos(phi));
|
||||
}
|
||||
|
||||
fn load(x: i32, y: i32) -> vec4<f32> {
|
||||
return textureLoad(tile, vec2<i32>(x, y), 0);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= p.chunk_size.x || gid.y >= p.chunk_size.y) {
|
||||
return;
|
||||
}
|
||||
let u = p.chunk_origin.x + f32(gid.x) + 0.5;
|
||||
let v = p.chunk_origin.y + f32(gid.y) + 0.5;
|
||||
let d = to_direction(u, v);
|
||||
let c = vec3<f32>(dot(p.r0.xyz, d), dot(p.r1.xyz, d), dot(p.r2.xyz, d));
|
||||
if (c.z <= 1e-6) {
|
||||
return;
|
||||
}
|
||||
// Source pixel, in the full frame, with the principal point at its
|
||||
// centre. `- 0.5` puts pixel centres on integer coordinates for the
|
||||
// bilinear fetch below.
|
||||
let sx = p.focal * c.x / c.z + p.frame_size.x * 0.5 - 0.5;
|
||||
let sy = p.focal * c.y / c.z + p.frame_size.y * 0.5 - 0.5;
|
||||
// Weight: distance to the nearest frame edge, in pixels, over the
|
||||
// feather. Zero outside the frame.
|
||||
let edge = min(min(sx, p.frame_size.x - 1.0 - sx), min(sy, p.frame_size.y - 1.0 - sy));
|
||||
if (edge <= 0.0) {
|
||||
return;
|
||||
}
|
||||
let w = clamp(edge / max(p.feather, 1.0), 0.0, 1.0);
|
||||
// Into the tile.
|
||||
let tx = sx - p.tile_origin.x;
|
||||
let ty = sy - p.tile_origin.y;
|
||||
let tw = f32(p.tile_size.x);
|
||||
let th = f32(p.tile_size.y);
|
||||
if (tx < 0.0 || ty < 0.0 || tx > tw - 1.0 || ty > th - 1.0) {
|
||||
return;
|
||||
}
|
||||
let x0 = i32(floor(tx));
|
||||
let y0 = i32(floor(ty));
|
||||
let x1 = min(x0 + 1, i32(p.tile_size.x) - 1);
|
||||
let y1 = min(y0 + 1, i32(p.tile_size.y) - 1);
|
||||
let fx = tx - f32(x0);
|
||||
let fy = ty - f32(y0);
|
||||
// The four texels, with their alpha: the tap writes alpha 0 where the
|
||||
// lens correction found no source pixel, and a sample that touches one
|
||||
// of those is a partial pixel — down-weighted by exactly how much of
|
||||
// it is missing, and dropped when all of it is.
|
||||
let s00 = load(x0, y0);
|
||||
let s10 = load(x1, y0);
|
||||
let s01 = load(x0, y1);
|
||||
let s11 = load(x1, y1);
|
||||
let top = mix(s00, s10, fx);
|
||||
let bot = mix(s01, s11, fx);
|
||||
let s = mix(top, bot, fy);
|
||||
if (s.a <= 0.001) {
|
||||
return;
|
||||
}
|
||||
// Colour is the alpha-weighted mean of the texels that exist.
|
||||
let rgb = s.rgb / s.a * p.gain;
|
||||
let wa = w * s.a;
|
||||
let i = gid.y * p.chunk_size.x + gid.x;
|
||||
acc[i] = acc[i] + vec4<f32>(rgb * wa, wa);
|
||||
}
|
||||
|
||||
// Resolve: the accumulated chunk to sixteen-bit samples.
|
||||
struct ResolveParams {
|
||||
chunk_size: vec2<u32>,
|
||||
// Multiplies a normalised value (1.0 = the sensor's white) back to the
|
||||
// sensor's scale: the source's white minus its black (FR-MRG-3).
|
||||
scale: f32,
|
||||
_pad: f32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> rp: ResolveParams;
|
||||
@group(0) @binding(1) var<storage, read> racc: array<vec4<f32>>;
|
||||
// Two u32 per pixel: (r | g << 16), (b | coverage << 16). Coverage is
|
||||
// 65535 where any frame reached the pixel and 0 where none did, so the
|
||||
// CPU can tell an empty pixel from a black one.
|
||||
@group(0) @binding(2) var<storage, read_write> out: array<vec2<u32>>;
|
||||
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn resolve(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= rp.chunk_size.x || gid.y >= rp.chunk_size.y) {
|
||||
return;
|
||||
}
|
||||
let i = gid.y * rp.chunk_size.x + gid.x;
|
||||
let a = racc[i];
|
||||
if (a.w <= 0.0) {
|
||||
out[i] = vec2<u32>(0u, 0u);
|
||||
return;
|
||||
}
|
||||
let rgb = clamp(a.rgb / a.w * rp.scale, vec3<f32>(0.0), vec3<f32>(65535.0));
|
||||
let r = u32(round(rgb.r));
|
||||
let g = u32(round(rgb.g));
|
||||
let b = u32(round(rgb.b));
|
||||
out[i] = vec2<u32>(r | (g << 16u), b | (65535u << 16u));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Watershed segmentation — the passes behind arm A of S15 (docs/segmentation.md).
|
||||
// Watershed segmentation — the passes behind arm A of S15 (docs/dev/segmentation.md).
|
||||
//
|
||||
// Seven entry points forming one chain:
|
||||
//
|
||||
@@ -197,7 +197,7 @@ fn gradient(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
// lowest-indexed neighbour, which is up and to the left. Each pixel therefore
|
||||
// walks diagonally until it falls off the plateau, and one flat region becomes
|
||||
// a fan of diagonal chains rather than one basin — visible as hatching across
|
||||
// what should be a single area (docs/segmentation.md §12, F1).
|
||||
// what should be a single area (docs/dev/segmentation.md §12, F1).
|
||||
//
|
||||
// The fix is the standard lower-completion: give each plateau pixel its
|
||||
// geodesic distance to the nearest pixel that *does* have a lower neighbour,
|
||||
|
||||
@@ -40,6 +40,10 @@ fn flat_raw(level: u16, curve: BaseCurve) -> RawImage {
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
base_curve: curve,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
||||
@@ -46,6 +46,10 @@ fn flat_raw(level: u16) -> RawImage {
|
||||
// leaving a curve here would test the suppression rather than the
|
||||
// film. `dr-pipeline` asserts the suppression on the generated source.
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
//!
|
||||
//! FR-DSP-3 says a slider updates the visible region within one frame budget at
|
||||
//! proxy resolution. Until this file existed nothing checked it, which made it
|
||||
//! a wish — `docs/display-and-extension.md` §3 is blunt about that, and §7 is
|
||||
//! a wish — `docs/dev/display-and-extension.md` §3 is blunt about that, and §7 is
|
||||
//! blunt about what tagging an unchecked requirement does to the coverage
|
||||
//! figure.
|
||||
//!
|
||||
//! The measurements this guards are in [`docs/frame-budget.md`], produced by
|
||||
//! The measurements this guards are in [`docs/dev/frame-budget.md`], produced by
|
||||
//! `examples/frame_budget.rs`. This file is the part of them that has to keep
|
||||
//! being true: it renders the **whole point-operation chain** through the real
|
||||
//! `render_detailed` for a hundred frames, moving a slider between each, and
|
||||
@@ -17,7 +17,7 @@
|
||||
//! **The neighbourhood stage is deliberately not in the asserted chain.** It is
|
||||
//! over the budget today — clarity alone is 34 ms at 4K, because its kernel is
|
||||
//! a fraction of the frame and reaches a 52-pixel radius there — and
|
||||
//! `docs/frame-budget.md` records that, names the fix (a base computed at
|
||||
//! `docs/dev/frame-budget.md` records that, names the fix (a base computed at
|
||||
//! reduced resolution) and does not pretend otherwise. Asserting a budget the
|
||||
//! code does not meet would produce a red suite that everyone learns to ignore;
|
||||
//! asserting it on a chain that quietly excluded the expensive stage *without
|
||||
@@ -81,7 +81,7 @@ const SOURCE: (u32, u32) = (6000, 4000);
|
||||
/// The viewport the budget is asserted at: a 16:10 desktop display.
|
||||
///
|
||||
/// Not 4K, and the reason is worth stating. At 4K the fused chain still passes
|
||||
/// with room to spare (4.5 ms of GPU; see `docs/frame-budget.md`), but a test
|
||||
/// with room to spare (4.5 ms of GPU; see `docs/dev/frame-budget.md`), but a test
|
||||
/// that renders 8.3 M pixels a hundred times twice over is four seconds of
|
||||
/// suite time to re-establish a conclusion 4.1 M pixels already establishes.
|
||||
const VIEWPORT: (u32, u32) = (2560, 1600);
|
||||
@@ -166,7 +166,7 @@ impl Run {
|
||||
judged <= BUDGET_MS,
|
||||
"{case} at {}x{}: p99 of {FRAMES} frames was {judged:.2} ms, over the \
|
||||
{BUDGET_MS:.0} ms budget (cpu {:.2} ms, gpu {:.2} ms, total {:.2} ms). \
|
||||
FR-DSP-3 is what this violates; docs/frame-budget.md holds the \
|
||||
FR-DSP-3 is what this violates; docs/dev/frame-budget.md holds the \
|
||||
numbers it used to be.",
|
||||
viewport.0,
|
||||
viewport.1,
|
||||
|
||||
@@ -790,6 +790,119 @@ fn the_order_parts_are_joined_in_is_the_mask() {
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// The truth table `docs/dev/mask-editing.md` §13 asks for: one base, one
|
||||
/// part that half-covers it, joined each of the three ways. The base is the
|
||||
/// left half of the frame and the part a dab in the middle, so the four
|
||||
/// quarters of the table are four pixels.
|
||||
#[test]
|
||||
fn a_part_unioned_subtracted_and_intersected_gives_the_three_fields() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let field = split_field(&ctx);
|
||||
let joined = |join: Join| {
|
||||
let mut layer = brighten(MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0],
|
||||
});
|
||||
assert!(layer.push_part(MaskPart::painted("p2", join)));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
render(&ctx, &stack, Some(&field))
|
||||
};
|
||||
|
||||
// (base, part): left outside the dab, left inside, right inside, right
|
||||
// outside.
|
||||
let cells = [(4, 16), (14, 16), (18, 16), (27, 16)];
|
||||
let lit = |pixels: &[u8]| cells.map(|(x, y)| luma_at(pixels, x, y) > 200);
|
||||
|
||||
assert_eq!(
|
||||
lit(&joined(Join::Union)),
|
||||
[true, true, true, false],
|
||||
"union: either"
|
||||
);
|
||||
assert_eq!(
|
||||
lit(&joined(Join::Subtract)),
|
||||
[true, false, false, false],
|
||||
"subtract: the base without the dab"
|
||||
);
|
||||
assert_eq!(
|
||||
lit(&joined(Join::Intersect)),
|
||||
[false, true, false, false],
|
||||
"intersect: only where both are"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// Intersection on soft coverage is the product `Join::apply` defines, on
|
||||
/// either side of the join: a gradient intersected with a region it fills is
|
||||
/// the gradient there and nothing elsewhere, and a region intersected with a
|
||||
/// gradient is the gradient wherever the region is.
|
||||
#[test]
|
||||
fn the_joins_match_their_definition() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let field = split_field(&ctx);
|
||||
let ramp = || MaskSource::Linear {
|
||||
centre: (0.5, 0.5),
|
||||
angle: 0.0,
|
||||
width: 1.0,
|
||||
};
|
||||
let right_half = || MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![1],
|
||||
};
|
||||
let draw = |layer: MaskLayer| {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
render(&ctx, &stack, Some(&field))
|
||||
};
|
||||
|
||||
let alone = draw(brighten(ramp()));
|
||||
|
||||
let mut ramp_then_region = brighten(ramp());
|
||||
assert!(ramp_then_region.push_part(MaskPart::new("p2", Join::Intersect, right_half())));
|
||||
let ramp_then_region = draw(ramp_then_region);
|
||||
|
||||
let mut region_then_ramp = brighten(whole_frame());
|
||||
assert!(region_then_ramp.push_part(MaskPart::new("p2", Join::Intersect, ramp())));
|
||||
let region_then_ramp = draw(region_then_ramp);
|
||||
|
||||
for x in 0..SIZE {
|
||||
let y = SIZE / 2;
|
||||
let want = luma_at(&alone, x, y);
|
||||
// dst · 1 = dst on the right; dst · 0 = 0 on the left. Pixels
|
||||
// within two of the seam are left out: the region's own edge
|
||||
// is soft there, so neither side of the table is 0 or 1.
|
||||
let got = luma_at(&ramp_then_region, x, y);
|
||||
if x.abs_diff(SIZE / 2) <= 2 {
|
||||
// The seam.
|
||||
} else if x > SIZE / 2 {
|
||||
assert!(
|
||||
got.abs_diff(want) <= 1,
|
||||
"x={x}: the ramp survives where the region is ({got} vs {want})"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(got, 128, "x={x}: and nothing survives where it is not");
|
||||
}
|
||||
// 1 · src = src everywhere.
|
||||
let got = luma_at(®ion_then_ramp, x, y);
|
||||
assert!(
|
||||
got.abs_diff(want) <= 1,
|
||||
"x={x}: a full base intersected with the ramp is the ramp ({got} vs {want})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- seeing the mask (FR-DEV-19c) ------------------------------------------
|
||||
|
||||
/// A radial that covers the middle of the frame and nothing near the corners.
|
||||
|
||||
@@ -326,7 +326,7 @@ fn a_proxy_and_an_export_agree_about_the_effect() {
|
||||
|
||||
#[test]
|
||||
fn crossing_the_reduction_threshold_does_not_change_the_picture() {
|
||||
// TRACES: FR-DSP-3 — `docs/technical-debt.md` TD-4, held in pixels.
|
||||
// TRACES: FR-DSP-3 — `docs/dev/technical-debt.md` TD-4, held in pixels.
|
||||
//
|
||||
// Clarity's base is computed on a reduced grid, and how reduced depends on
|
||||
// the viewport: `LocalContrast::reduction` steps 4 -> 2 -> 1 as sigma
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! TRACES: FR-DSP-5
|
||||
//! TRACES: FR-DSP-5 | R5
|
||||
//! Zooming to 1:1 samples the source, pixel for pixel.
|
||||
//!
|
||||
//! FR-DSP-5: *"Fit, 1:1, and arbitrary zoom levels. At 1:1 and above, the
|
||||
@@ -10,7 +10,7 @@
|
||||
//! code path — the zoom is the full-resolution path — which is why the
|
||||
//! requirement has been satisfied for some time without anyone tagging it.
|
||||
//!
|
||||
//! `docs/display-and-extension.md` §7 is the reason this file exists rather
|
||||
//! `docs/dev/display-and-extension.md` §7 is the reason this file exists rather
|
||||
//! than a tag on `framing.rs`: a requirement counts as covered when a `TRACES`
|
||||
//! comment names it, and nothing checks that the code under the tag does the
|
||||
//! thing. `FR-DEV-8` is tagged against plumbing a future operation would use.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
[package]
|
||||
name = "dr-inference-engine"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
# The one crate that names a runtime, a provider, a vendor library or a
|
||||
# device (docs/dev/inference.md §8). `dr-face` and `dr-segment` ask it for a
|
||||
# session by role and never see which of these answered.
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
# `ort` is the API; what supplies it is decided once per process (§3):
|
||||
# `libonnxruntime` found on disk, or `tract`. Both are behind
|
||||
# `alternative-backend`, so nothing here links C on any target.
|
||||
ort = { workspace = true }
|
||||
ort-tract = { workspace = true, optional = true }
|
||||
# dlopen, and the C types of the table it fetches. Both pure Rust;
|
||||
# `libloading` is already in the tree through wgpu.
|
||||
libloading = { version = "0.8", optional = true }
|
||||
ort-sys = { version = "2.0.0-rc.13", default-features = false, features = ["disable-linking"], optional = true }
|
||||
|
||||
# The NVIDIA rungs exist on the desktop only. These features add `ort`'s
|
||||
# option builders and nothing else — no linking under `alternative-backend` —
|
||||
# but an Android binary has no business carrying even the option names, and
|
||||
# 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]
|
||||
ort = { workspace = true, features = ["cuda", "tensorrt"] }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
ort = { workspace = true, features = ["qnn"] }
|
||||
|
||||
[features]
|
||||
# The floor: `tract` supplies the API table when no runtime file is found, or
|
||||
# always, in a build without `native`. Tests want this and nothing else.
|
||||
default = ["tract"]
|
||||
tract = ["dep:ort-tract"]
|
||||
# Look for `libonnxruntime` on disk and hand its table to `ort`.
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! The API table `ort` runs on, chosen once (docs/dev/inference.md §3).
|
||||
//!
|
||||
//! `ort` with `alternative-backend` links no runtime and asks, on first use,
|
||||
//! for an `OrtApi` — a struct of function pointers. Two things can fill it:
|
||||
//! a `libonnxruntime` this module `dlopen`s, or `ort-tract`. The Rust build
|
||||
//! is identical either way; the difference is whether a file was found.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// What supplied the table.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Runtime {
|
||||
/// Pure Rust, one core, every operator these graphs use. The floor.
|
||||
Tract,
|
||||
/// The C++ ONNX Runtime, loaded from `path`.
|
||||
OnnxRuntime { path: PathBuf, version: String },
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Runtime::Tract => "tract".into(),
|
||||
Runtime::OnnxRuntime { version, .. } => format!("ONNX Runtime {version}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_native(&self) -> bool {
|
||||
matches!(self, Runtime::OnnxRuntime { .. })
|
||||
}
|
||||
}
|
||||
|
||||
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
|
||||
|
||||
/// The runtime in use; tract until something installs another.
|
||||
pub fn runtime() -> Runtime {
|
||||
RUNTIME.get().cloned().unwrap_or(Runtime::Tract)
|
||||
}
|
||||
|
||||
/// Install a table if none is installed yet — tract, since no directories
|
||||
/// were named. What a test or an example gets, unless `DARKROOM_ORT_DIR`
|
||||
/// names a runtime: the same variable the desktop honours, so an example
|
||||
/// can be pointed at the runtime the app uses without learning `init`.
|
||||
pub fn ensure_installed() {
|
||||
if RUNTIME.get().is_none() {
|
||||
let dirs: Vec<PathBuf> = std::env::var_os("DARKROOM_ORT_DIR")
|
||||
.map(PathBuf::from)
|
||||
.into_iter()
|
||||
.collect();
|
||||
install(&dirs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Look for `libonnxruntime` in `dirs`, in order, and hand `ort` the first
|
||||
/// table that loads; otherwise tract. Once per process.
|
||||
pub fn install(dirs: &[PathBuf]) -> Runtime {
|
||||
RUNTIME
|
||||
.get_or_init(|| {
|
||||
#[cfg(feature = "native")]
|
||||
for dir in dirs {
|
||||
match load_native(dir) {
|
||||
Ok(rt) => return rt,
|
||||
Err(e) => log::info!("inference: no runtime in {}: {e}", dir.display()),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "native"))]
|
||||
let _ = dirs;
|
||||
install_tract()
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(feature = "tract")]
|
||||
fn install_tract() -> Runtime {
|
||||
let _ = ort::set_api(ort_tract::api());
|
||||
Runtime::Tract
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tract"))]
|
||||
fn install_tract() -> Runtime {
|
||||
// A build with neither tract nor a runtime file has nothing to run
|
||||
// models on; every `open` will report the un-set API rather than panic
|
||||
// somewhere deeper.
|
||||
log::error!("inference: no ONNX Runtime found and tract is not compiled in");
|
||||
Runtime::Tract
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn load_native(dir: &std::path::Path) -> Result<Runtime, String> {
|
||||
let name = if cfg!(target_os = "windows") {
|
||||
"onnxruntime.dll"
|
||||
} else if cfg!(any(target_os = "macos", target_os = "ios")) {
|
||||
"libonnxruntime.dylib"
|
||||
} else {
|
||||
"libonnxruntime.so"
|
||||
};
|
||||
// An empty dir means the bare name: the system loader's search, which on
|
||||
// Android includes the APK's own native libraries.
|
||||
let path = if dir.as_os_str().is_empty() {
|
||||
PathBuf::from(name)
|
||||
} else {
|
||||
find_library(dir, name).ok_or("not present")?
|
||||
};
|
||||
|
||||
// SAFETY: the library's initialisers are ONNX Runtime's own; the symbol
|
||||
// is the documented entry point with the documented signature; the table
|
||||
// is copied out and the library handle is leaked, so every pointer in
|
||||
// the copy stays valid for the life of the process.
|
||||
unsafe {
|
||||
let lib = libloading::Library::new(&path).map_err(|e| e.to_string())?;
|
||||
let get_base: libloading::Symbol<
|
||||
unsafe extern "system" fn() -> *const ort_sys::OrtApiBase,
|
||||
> = lib.get(b"OrtGetApiBase\0").map_err(|e| e.to_string())?;
|
||||
let base = get_base();
|
||||
if base.is_null() {
|
||||
return Err("OrtGetApiBase returned null".into());
|
||||
}
|
||||
let version = std::ffi::CStr::from_ptr(((*base).GetVersionString)())
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let api = ((*base).GetApi)(ort_sys::ORT_API_VERSION);
|
||||
if api.is_null() {
|
||||
return Err(format!(
|
||||
"ONNX Runtime {version} is older than API version {}",
|
||||
ort_sys::ORT_API_VERSION
|
||||
));
|
||||
}
|
||||
if !ort::set_api((*api).clone()) {
|
||||
return Err("an API table was already installed".into());
|
||||
}
|
||||
std::mem::forget(lib);
|
||||
|
||||
// Qualcomm's DSP loader finds the Hexagon skel through this variable,
|
||||
// and only through it; the runtime's own directory is where the APK
|
||||
// put it. Harmless anywhere else.
|
||||
#[cfg(target_os = "android")]
|
||||
if !dir.as_os_str().is_empty() {
|
||||
std::env::set_var("ADSP_LIBRARY_PATH", dir);
|
||||
}
|
||||
|
||||
log::info!("inference: ONNX Runtime {version} from {}", path.display());
|
||||
Ok(Runtime::OnnxRuntime { path, version })
|
||||
}
|
||||
}
|
||||
|
||||
/// `libonnxruntime.so` in `dir`, or a versioned spelling of it —
|
||||
/// `libonnxruntime.so.1.30.0` is what the Python wheel ships, and a package
|
||||
/// that installs only the versioned file is not wrong.
|
||||
#[cfg(feature = "native")]
|
||||
fn find_library(dir: &std::path::Path, name: &str) -> Option<PathBuf> {
|
||||
let exact = dir.join(name);
|
||||
if exact.is_file() {
|
||||
return Some(exact);
|
||||
}
|
||||
let prefix = format!("{name}.");
|
||||
let mut versioned: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||
.ok()?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.is_file()
|
||||
&& p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with(&prefix))
|
||||
})
|
||||
.collect();
|
||||
versioned.sort();
|
||||
versioned.pop()
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Compiled engines: what a rung builds once per device, and the thread that
|
||||
//! builds them before anyone asks (docs/dev/inference.md §5, §6).
|
||||
//!
|
||||
//! TensorRT keeps its own engine cache keyed by graph hash; QNN writes a
|
||||
//! context model. Both are opaque to this crate, which tracks only *that* a
|
||||
//! model compiled — by the hash of its bytes — so [`crate::open`] can tell a
|
||||
//! request whether to expect the rung or its fallback.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{state, Config, Form, Rung};
|
||||
|
||||
enum Source {
|
||||
File(PathBuf),
|
||||
Bytes(&'static [u8]),
|
||||
}
|
||||
|
||||
/// 64-bit FNV-1a. A cache key, not a checksum: two model files that collide
|
||||
/// here would have to also be the same size and the same role, and the cost
|
||||
/// of that is a rebuilt engine.
|
||||
pub fn hash(bytes: &[u8]) -> u64 {
|
||||
let mut h = 0xcbf2_9ce4_8422_2325u64;
|
||||
for &b in bytes {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The cache entry for `bytes` compiled on `rung`.
|
||||
pub fn key(rung: Rung, bytes: &[u8]) -> String {
|
||||
key_of(rung, hash(bytes))
|
||||
}
|
||||
|
||||
/// The same, from a hash already taken.
|
||||
pub fn key_of(rung: Rung, hash: u64) -> String {
|
||||
format!("{}:{:016x}", rung.label(), hash)
|
||||
}
|
||||
|
||||
/// Where QNN's compiled context for `bytes` lives.
|
||||
pub fn context_path(cfg: &Config, bytes: &[u8]) -> PathBuf {
|
||||
cfg.cache_dir
|
||||
.join("qnn")
|
||||
.join(format!("{:016x}_ctx.onnx", hash(bytes)))
|
||||
}
|
||||
|
||||
/// After the probe: compile every configured model the selected rung can
|
||||
/// take, smallest first, recording each as it lands.
|
||||
pub fn run() {
|
||||
let (rung, cfg) = {
|
||||
let s = state().lock().unwrap();
|
||||
(crate::current_rung(&s), s.config.clone())
|
||||
};
|
||||
if !rung.compiles() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Smallest first, so the detector — the one that runs per image — is
|
||||
// ready soonest (§6 step 3).
|
||||
let mut jobs: Vec<(crate::Role, Source, u64)> = cfg
|
||||
.models
|
||||
.iter()
|
||||
.filter(|(role, _)| rung.serves(*role))
|
||||
.filter_map(|(role, path)| {
|
||||
let (path, form) = crate::resolve_model(*role, path);
|
||||
(form == rung.form(*role)).then(|| {
|
||||
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||
(*role, Source::File(path), size)
|
||||
})
|
||||
})
|
||||
.chain(cfg.embedded.iter().filter_map(|(role, bytes)| {
|
||||
// An embedded model has no int8 sibling to offer a rung that
|
||||
// wants one; it runs on that rung's fallback.
|
||||
(rung.serves(*role) && rung.form(*role) == Form::F32).then_some((
|
||||
*role,
|
||||
Source::Bytes(bytes),
|
||||
bytes.len() as u64,
|
||||
))
|
||||
}))
|
||||
.collect();
|
||||
jobs.sort_by_key(|j| j.2);
|
||||
state().lock().unwrap().wanted = jobs.len();
|
||||
|
||||
for (role, source, _) in jobs {
|
||||
let (bytes, name) = match &source {
|
||||
Source::File(path) => match std::fs::read(path) {
|
||||
Ok(b) => (b, path.display().to_string()),
|
||||
Err(_) => continue,
|
||||
},
|
||||
Source::Bytes(b) => (b.to_vec(), format!("embedded {role:?}")),
|
||||
};
|
||||
let key = key(rung, &bytes);
|
||||
if state().lock().unwrap().cache.compiled.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
log::info!("inference: compiling {name} for {}", rung.label());
|
||||
let started = std::time::Instant::now();
|
||||
match crate::session::build(rung, role, &bytes, &cfg) {
|
||||
Ok(session) => {
|
||||
drop(session);
|
||||
let mut s = state().lock().unwrap();
|
||||
s.cache.compiled.insert(key);
|
||||
crate::probe::write_cache(&s.config, &s.cache);
|
||||
log::info!(
|
||||
"inference: {name} ready on {} in {:.1} s",
|
||||
rung.label(),
|
||||
started.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// This model stays on the fallback; the others still get
|
||||
// their engine. A corrected model file changes the hash and
|
||||
// is retried.
|
||||
log::warn!(
|
||||
"inference: {name} will not compile for {}: {e}",
|
||||
rung.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
//! Which runtime, which provider and which model form — decided once per
|
||||
//! device, and the only crate that knows the answer (docs/dev/inference.md).
|
||||
//!
|
||||
//! Consumers ask for a session by [`Role`] and get `ort`'s `Session` back;
|
||||
//! what built it — tract on one core, ONNX Runtime's CPU pool, a TensorRT
|
||||
//! engine, a MIGraphX program, the Hexagon — is this crate's business and
|
||||
//! shows up in [`status`] for the settings row and nowhere else.
|
||||
//!
|
||||
//! 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`
|
||||
//! found on disk or from `tract`. That choice is once per process, because
|
||||
//! `ort::set_api` is; everything after it — which provider, whether an engine
|
||||
//! has been compiled yet — is per session and may change between two calls.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod api;
|
||||
mod engines;
|
||||
mod probe;
|
||||
mod session;
|
||||
|
||||
pub use api::Runtime;
|
||||
pub use ort::session::Session;
|
||||
|
||||
/// What a model is for. The role fixes the precision rule (§7): an embedder
|
||||
/// runs in f32 on every rung, a detector may run in fp16 or int8.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Role {
|
||||
Detector,
|
||||
Embedder,
|
||||
Segmenter,
|
||||
Scene,
|
||||
/// The dense landmark model behind the eye reading (docs/dev/faces.md §7c).
|
||||
Landmarks,
|
||||
/// The eye-state and sunglasses classifiers, a few hundred kilobytes.
|
||||
EyeClassifier,
|
||||
/// XFeat, the panorama keypoint detector (docs/dev/panorama.md).
|
||||
Keypoints,
|
||||
/// MI-GAN, the panorama border filler (docs/dev/panorama.md §12). Plain
|
||||
/// convolutions, so any rung serves it; fp16 on TensorRT and int8 on
|
||||
/// the Hexagon are the point of it.
|
||||
Inpainter,
|
||||
}
|
||||
|
||||
/// Which numeric form of a model a session was built from.
|
||||
///
|
||||
/// `Int8` is a different network from `F32` for a detector — it finds a
|
||||
/// different set of faces — which is why [`form_suffix`] exists and why a
|
||||
/// caller appends it to `model_id`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Form {
|
||||
F32,
|
||||
Int8,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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)]
|
||||
pub enum Rung {
|
||||
/// ONNX Runtime's CPU provider, or tract when no runtime file was found.
|
||||
Cpu,
|
||||
/// NVIDIA, through the CUDA provider. Desktop only.
|
||||
Cuda,
|
||||
/// NVIDIA, through a TensorRT engine compiled on this device. Desktop only.
|
||||
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.
|
||||
Hexagon,
|
||||
}
|
||||
|
||||
impl Rung {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Rung::Cpu => "CPU",
|
||||
Rung::Cuda => "CUDA",
|
||||
Rung::TensorRt => "TensorRT",
|
||||
Rung::MiGraphX => "MIGraphX",
|
||||
Rung::Hexagon => "Hexagon NPU",
|
||||
}
|
||||
}
|
||||
|
||||
/// The rung a request lands on while this one's engine is still being
|
||||
/// compiled (§6 step 2).
|
||||
fn fallback(self) -> Rung {
|
||||
match self {
|
||||
Rung::TensorRt => Rung::Cuda,
|
||||
Rung::MiGraphX | Rung::Hexagon | Rung::Cuda | Rung::Cpu => Rung::Cpu,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a session on this rung needs an engine built first.
|
||||
fn compiles(self) -> bool {
|
||||
matches!(self, Rung::TensorRt | Rung::MiGraphX | Rung::Hexagon)
|
||||
}
|
||||
|
||||
/// The model form this rung wants for a role.
|
||||
fn form(self, _role: Role) -> Form {
|
||||
match self {
|
||||
Rung::Hexagon => Form::Int8,
|
||||
_ => Form::F32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this rung runs `role` at all. The Hexagon takes int8 graphs
|
||||
/// only, and the embedder is never int8 (§7) — it runs on the CPU
|
||||
/// beside a detector on the NPU, so its vectors compare across devices.
|
||||
fn serves(self, role: Role) -> bool {
|
||||
match self {
|
||||
Rung::Hexagon => role != Role::Embedder,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a session outlives its last use unless [`Config::decay`] says
|
||||
/// otherwise: long enough for the next click, short enough that a session's
|
||||
/// GPU or NPU memory does not sit under the develop view for long.
|
||||
pub const DEFAULT_DECAY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// What [`init`] is told once, at launch.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Config {
|
||||
/// Where to look for `libonnxruntime`, in order. An empty path means "the
|
||||
/// bare library name through the system loader", which is how the APK's
|
||||
/// own copy is found on Android.
|
||||
pub runtime_dirs: Vec<PathBuf>,
|
||||
/// Probe cache and compiled engines (§4, §5). Disposable.
|
||||
pub cache_dir: PathBuf,
|
||||
/// The canonical model files on this device, so engines can be compiled
|
||||
/// ahead of the first request for them.
|
||||
pub models: Vec<(Role, PathBuf)>,
|
||||
/// Models compiled into the binary, for the same reason.
|
||||
pub embedded: Vec<(Role, &'static [u8])>,
|
||||
/// The highest rung the user allows; `None` is "the best that works".
|
||||
pub ceiling: Option<Rung>,
|
||||
/// ONNX Runtime's intra-op pool; 0 picks from the core count.
|
||||
pub threads: usize,
|
||||
/// How long an unused session stays loaded. Zero means the default.
|
||||
pub decay: Duration,
|
||||
}
|
||||
|
||||
/// One line for the settings row, and the numbers behind the progress row.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Status {
|
||||
pub runtime: Runtime,
|
||||
/// The rung selected, or the floor while the probe is still running.
|
||||
pub rung: Rung,
|
||||
/// Why — "probe passed", or the failure that demoted the rung above.
|
||||
pub reason: String,
|
||||
pub probing: bool,
|
||||
/// Engines compiled and engines wanted, for a compiling rung; `(0, 0)`
|
||||
/// otherwise.
|
||||
pub engines: (usize, usize),
|
||||
/// Every rung above the selected one that was tried, and why it lost.
|
||||
pub failed: Vec<(Rung, String)>,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// "Hexagon NPU · int8 · ONNX Runtime 1.29" — the settings row's text.
|
||||
pub fn line(&self) -> String {
|
||||
let form = match self.rung {
|
||||
Rung::Hexagon => " · int8",
|
||||
Rung::TensorRt | Rung::MiGraphX => " · fp16",
|
||||
_ => "",
|
||||
};
|
||||
format!("{}{} · {}", self.rung.label(), form, self.runtime.label())
|
||||
}
|
||||
}
|
||||
|
||||
/// A model the caller can run, whatever is or is not loaded right now.
|
||||
///
|
||||
/// Holds the bytes, not a session. [`Model::acquire`] finds the loaded copy
|
||||
/// in the registry — shared with every other holder of the same model —
|
||||
/// or loads one, and every acquire refreshes the copy's last-used time.
|
||||
/// The reaper unloads anything idle for [`Config::decay`]; a scan that runs
|
||||
/// the detector on every image never lets it go idle, a click in the
|
||||
/// develop view lets the segmenter go after a quiet spell, and a handle
|
||||
/// used again after that simply loads again. Nobody states a policy.
|
||||
///
|
||||
/// The registry key includes the rung, so a reload after a compiled engine
|
||||
/// has landed moves up to it by itself (§6 step 4).
|
||||
pub struct Model {
|
||||
role: Role,
|
||||
form: Form,
|
||||
bytes: Arc<[u8]>,
|
||||
/// `engines::hash` of the bytes, taken once: an acquire per tile of a
|
||||
/// border fill must not hash 28 MB each time.
|
||||
hash: u64,
|
||||
}
|
||||
|
||||
/// A loaded session, held for one `run` and its output decoding.
|
||||
pub struct Acquired {
|
||||
entry: Arc<Loaded>,
|
||||
}
|
||||
|
||||
struct Loaded {
|
||||
rung: Rung,
|
||||
session: Mutex<Session>,
|
||||
last_used: Mutex<Instant>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// The loaded session, loading it if the reaper took it. Lock it for
|
||||
/// one run; a scan and a develop click can want the same detector at
|
||||
/// once, and the second waits on the first.
|
||||
pub fn acquire(&self) -> Result<Acquired, Error> {
|
||||
acquire(self.role, self.form, &self.bytes, self.hash)
|
||||
}
|
||||
|
||||
pub fn form(&self) -> Form {
|
||||
self.form
|
||||
}
|
||||
}
|
||||
|
||||
impl Acquired {
|
||||
pub fn lock(&self) -> MutexGuard<'_, Session> {
|
||||
self.entry.session.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Where this session runs.
|
||||
pub fn rung(&self) -> Rung {
|
||||
self.entry.rung
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Acquired {
|
||||
fn drop(&mut self) {
|
||||
// The clock starts when the use ends, not when it began: a long run
|
||||
// is not idle time.
|
||||
*self.entry.last_used.lock().unwrap() = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
type Registry = HashMap<String, Arc<Loaded>>;
|
||||
|
||||
static REGISTRY: OnceLock<Mutex<Registry>> = OnceLock::new();
|
||||
|
||||
fn registry() -> &'static Mutex<Registry> {
|
||||
REGISTRY.get_or_init(|| {
|
||||
std::thread::Builder::new()
|
||||
.name("inference-reaper".into())
|
||||
.spawn(|| loop {
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
release_idle();
|
||||
})
|
||||
.expect("spawn inference reaper");
|
||||
Mutex::new(HashMap::new())
|
||||
})
|
||||
}
|
||||
|
||||
fn acquire(role: Role, form: Form, bytes: &Arc<[u8]>, hash: u64) -> Result<Acquired, Error> {
|
||||
api::ensure_installed();
|
||||
let (rung, cfg) = {
|
||||
let s = state().lock().unwrap();
|
||||
let selected = current_rung(&s);
|
||||
(
|
||||
effective_rung(&s, selected, role, form, hash),
|
||||
s.config.clone(),
|
||||
)
|
||||
};
|
||||
let key = format!("{role:?}:{}", engines::key_of(rung, hash));
|
||||
|
||||
if let Some(entry) = registry().lock().unwrap().get(&key).cloned() {
|
||||
*entry.last_used.lock().unwrap() = Instant::now();
|
||||
return Ok(Acquired { entry });
|
||||
}
|
||||
|
||||
// Built outside the registry lock: a TensorRT or MIGraphX engine load
|
||||
// is long enough that another role's acquire should not wait on it.
|
||||
let session = session::build(rung, role, bytes, &cfg)?;
|
||||
log::debug!("inference: {role:?} loaded on {}", rung.label());
|
||||
let entry = Arc::new(Loaded {
|
||||
rung,
|
||||
session: Mutex::new(session),
|
||||
last_used: Mutex::new(Instant::now()),
|
||||
});
|
||||
let mut reg = registry().lock().unwrap();
|
||||
// Two acquires raced; keep the first, drop this one.
|
||||
let entry = reg.entry(key).or_insert_with(|| entry.clone()).clone();
|
||||
Ok(Acquired { entry })
|
||||
}
|
||||
|
||||
/// Unload every session idle for longer than the decay. The reaper does
|
||||
/// this every five seconds. A session in use survives until its run ends:
|
||||
/// the `Acquired` holds it, the registry merely forgets it.
|
||||
pub fn release_idle() {
|
||||
let decay = match state().lock().unwrap().config.decay {
|
||||
Duration::ZERO => DEFAULT_DECAY,
|
||||
d => d,
|
||||
};
|
||||
let now = Instant::now();
|
||||
registry()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|_, e| now.duration_since(*e.last_used.lock().unwrap()) < decay);
|
||||
}
|
||||
|
||||
/// Unload every session now, decay or not — what a low-memory signal
|
||||
/// asks for. Sessions mid-run finish first.
|
||||
pub fn release_all() {
|
||||
registry().lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Unload every session of `role` now — "I am done segmenting".
|
||||
pub fn unload(role: Role) {
|
||||
let prefix = format!("{role:?}:");
|
||||
registry()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|k, _| !k.starts_with(&prefix));
|
||||
}
|
||||
|
||||
/// How many sessions are loaded, for the settings row and the tests.
|
||||
pub fn loaded() -> usize {
|
||||
registry().lock().unwrap().len()
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Inference(#[from] ort::Error),
|
||||
#[error("reading model: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// What the probe writes and the next launch reads (§4 step 3).
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
struct Cache {
|
||||
/// Runtime, driver, hardware and model identity; any change re-probes.
|
||||
fingerprint: String,
|
||||
rung: Option<Rung>,
|
||||
reason: String,
|
||||
/// Model hashes whose engine exists on disk, per compiling rung.
|
||||
compiled: BTreeSet<String>,
|
||||
/// Rungs that failed under this fingerprint, and why. Not retried until
|
||||
/// the fingerprint changes: a wedged driver must not cost every launch
|
||||
/// thirty seconds.
|
||||
failed: Vec<(Rung, String)>,
|
||||
}
|
||||
|
||||
struct State {
|
||||
config: Config,
|
||||
cache: Cache,
|
||||
probing: bool,
|
||||
wanted: usize,
|
||||
}
|
||||
|
||||
static STATE: OnceLock<Mutex<State>> = OnceLock::new();
|
||||
|
||||
fn state() -> &'static Mutex<State> {
|
||||
STATE.get_or_init(|| {
|
||||
Mutex::new(State {
|
||||
config: Config::default(),
|
||||
cache: Cache::default(),
|
||||
probing: false,
|
||||
wanted: 0,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Choose the runtime and start the probe. Idempotent; the first call wins.
|
||||
///
|
||||
/// Returns at once: the probe and any engine compilation run on their own
|
||||
/// low-priority thread, and every request meanwhile is served by the floor
|
||||
/// (§4). Never blocks the first frame.
|
||||
pub fn init(config: Config) {
|
||||
let runtime = api::install(&config.runtime_dirs);
|
||||
{
|
||||
let mut s = state().lock().unwrap();
|
||||
if s.probing || s.cache.rung.is_some() {
|
||||
return;
|
||||
}
|
||||
s.config = config;
|
||||
s.probing = true;
|
||||
}
|
||||
log::info!("inference: runtime {}", runtime.label());
|
||||
std::thread::Builder::new()
|
||||
.name("inference-probe".into())
|
||||
.spawn(move || {
|
||||
probe::run(runtime);
|
||||
engines::run();
|
||||
})
|
||||
.expect("spawn inference probe");
|
||||
}
|
||||
|
||||
/// Make sure `ort` has an API table, for code that drives `ort` directly.
|
||||
/// [`open`] does this itself; only the M1 probe example needs it by name.
|
||||
pub fn ensure_runtime() {
|
||||
api::ensure_installed();
|
||||
}
|
||||
|
||||
/// The line for the settings row.
|
||||
pub fn status() -> Status {
|
||||
let s = state().lock().unwrap();
|
||||
let rung = current_rung(&s);
|
||||
Status {
|
||||
runtime: api::runtime(),
|
||||
rung,
|
||||
reason: s.cache.reason.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,
|
||||
engines: if rung.compiles() {
|
||||
(s.cache.compiled.len(), s.wanted)
|
||||
} else {
|
||||
(0, 0)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn current_rung(s: &State) -> Rung {
|
||||
if s.probing {
|
||||
Rung::Cpu
|
||||
} else {
|
||||
s.cache.rung.unwrap_or(Rung::Cpu)
|
||||
}
|
||||
}
|
||||
|
||||
/// The file to load for `role` under the current selection, and its form.
|
||||
///
|
||||
/// A rung that wants int8 gets the `.int8.onnx` sibling of the canonical file
|
||||
/// if it exists; otherwise the canonical file, on the rung's fallback. A
|
||||
/// caller adds [`form_suffix`] to the `model_id` it records.
|
||||
pub fn resolve_model(role: Role, canonical: &Path) -> (PathBuf, Form) {
|
||||
let rung = current_rung(&state().lock().unwrap());
|
||||
if rung.serves(role) && rung.form(role) == Form::Int8 {
|
||||
let sibling = int8_sibling(canonical);
|
||||
if sibling.is_file() {
|
||||
return (sibling, Form::Int8);
|
||||
}
|
||||
}
|
||||
(canonical.to_path_buf(), Form::F32)
|
||||
}
|
||||
|
||||
fn int8_sibling(canonical: &Path) -> PathBuf {
|
||||
let stem = canonical
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
canonical.with_file_name(format!("{stem}.int8.onnx"))
|
||||
}
|
||||
|
||||
/// What a form appends to a detector's `model_id` (§7).
|
||||
pub fn form_suffix(form: Form) -> &'static str {
|
||||
match form {
|
||||
Form::F32 => "",
|
||||
Form::Int8 => "_i8",
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle on the model `bytes` in `role`.
|
||||
///
|
||||
/// Loads it once here, so a graph the runtime rejects fails at
|
||||
/// construction and not on the first image; what happens to that session
|
||||
/// afterwards is the registry's business (see [`Model`]).
|
||||
///
|
||||
/// Works without [`init`] — a test, or the examples — by installing tract
|
||||
/// and using the CPU rung, which is exactly what every consumer did before
|
||||
/// this crate existed.
|
||||
pub fn open(role: Role, form: Form, bytes: &[u8]) -> Result<Model, Error> {
|
||||
let bytes: Arc<[u8]> = Arc::from(bytes);
|
||||
let hash = engines::hash(&bytes);
|
||||
acquire(role, form, &bytes, hash)?;
|
||||
Ok(Model {
|
||||
role,
|
||||
form,
|
||||
bytes,
|
||||
hash,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where a request lands: the selected rung unless the role's precision rule,
|
||||
/// the form on offer, or a missing engine says one lower (§6 step 4).
|
||||
fn effective_rung(s: &State, selected: Rung, role: Role, form: Form, hash: u64) -> Rung {
|
||||
let mut rung = selected;
|
||||
if !rung.serves(role) || rung.form(role) != form {
|
||||
// The embedder on a Hexagon device, or an f32 detector where the int8
|
||||
// sibling was missing: neither can go to the NPU.
|
||||
rung = rung.fallback();
|
||||
}
|
||||
if rung.compiles() && !s.cache.compiled.contains(&engines::key_of(rung, hash)) {
|
||||
rung = rung.fallback();
|
||||
}
|
||||
rung
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The registry is one per process, so these run one at a time.
|
||||
static SERIAL: Mutex<()> = Mutex::new(());
|
||||
fn serial() -> MutexGuard<'static, ()> {
|
||||
SERIAL.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// The smallest shipped graph, if this checkout has the weights; a test
|
||||
/// suite that needs a research-licensed download is one that does not
|
||||
/// run in CI (docs/dev/faces.md §3), so absence is a skip.
|
||||
fn probe_bytes() -> Option<Vec<u8>> {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../models/face/scrfd_500m_640.onnx"
|
||||
);
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
(bytes.len() > 100_000).then_some(bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_handles_on_one_model_share_one_session() {
|
||||
let _serial = serial();
|
||||
let Some(bytes) = probe_bytes() else { return };
|
||||
release_all();
|
||||
let a = open(Role::Detector, Form::F32, &bytes).unwrap();
|
||||
let b = open(Role::Detector, Form::F32, &bytes).unwrap();
|
||||
assert_eq!(loaded(), 1);
|
||||
let (x, y) = (a.acquire().unwrap(), b.acquire().unwrap());
|
||||
assert!(Arc::ptr_eq(&x.entry, &y.entry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_released_model_reloads_on_its_next_use() {
|
||||
let _serial = serial();
|
||||
let Some(bytes) = probe_bytes() else { return };
|
||||
release_all();
|
||||
let model = open(Role::Detector, Form::F32, &bytes).unwrap();
|
||||
assert_eq!(loaded(), 1);
|
||||
release_all();
|
||||
assert_eq!(loaded(), 0);
|
||||
let acquired = model.acquire().unwrap();
|
||||
assert_eq!(loaded(), 1);
|
||||
assert_eq!(acquired.lock().inputs().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_idle_session_decays_and_a_used_one_does_not() {
|
||||
let _serial = serial();
|
||||
let Some(bytes) = probe_bytes() else { return };
|
||||
release_all();
|
||||
state().lock().unwrap().config.decay = Duration::from_millis(50);
|
||||
let model = open(Role::Detector, Form::F32, &bytes).unwrap();
|
||||
// Used within the decay: stays.
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
drop(model.acquire().unwrap());
|
||||
release_idle();
|
||||
assert_eq!(loaded(), 1);
|
||||
// Idle past it: goes.
|
||||
std::thread::sleep(Duration::from_millis(80));
|
||||
release_idle();
|
||||
assert_eq!(loaded(), 0);
|
||||
state().lock().unwrap().config.decay = Duration::ZERO;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unload_by_role_leaves_the_other_roles() {
|
||||
let _serial = serial();
|
||||
let Some(bytes) = probe_bytes() else { return };
|
||||
release_all();
|
||||
let _d = open(Role::Detector, Form::F32, &bytes).unwrap();
|
||||
let _s = open(Role::Segmenter, Form::F32, &bytes).unwrap();
|
||||
assert_eq!(loaded(), 2);
|
||||
unload(Role::Segmenter);
|
||||
assert_eq!(loaded(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hexagon_never_takes_the_embedder() {
|
||||
assert!(!Rung::Hexagon.serves(Role::Embedder));
|
||||
assert!(Rung::Hexagon.serves(Role::Detector));
|
||||
assert_eq!(Rung::Hexagon.form(Role::Detector), Form::Int8);
|
||||
// A detector offered in f32 on a Hexagon device lands on the CPU.
|
||||
let s = State {
|
||||
config: Config::default(),
|
||||
cache: Cache {
|
||||
rung: Some(Rung::Hexagon),
|
||||
..Cache::default()
|
||||
},
|
||||
probing: false,
|
||||
wanted: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
effective_rung(
|
||||
&s,
|
||||
Rung::Hexagon,
|
||||
Role::Embedder,
|
||||
Form::F32,
|
||||
engines::hash(b"")
|
||||
),
|
||||
Rung::Cpu
|
||||
);
|
||||
assert_eq!(
|
||||
effective_rung(
|
||||
&s,
|
||||
Rung::Hexagon,
|
||||
Role::Detector,
|
||||
Form::F32,
|
||||
engines::hash(b"")
|
||||
),
|
||||
Rung::Cpu
|
||||
);
|
||||
// An int8 detector whose context is not compiled yet: also the CPU.
|
||||
assert_eq!(
|
||||
effective_rung(
|
||||
&s,
|
||||
Rung::Hexagon,
|
||||
Role::Detector,
|
||||
Form::Int8,
|
||||
engines::hash(b"")
|
||||
),
|
||||
Rung::Cpu
|
||||
);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn the_status_line_reads_as_the_floor_before_init() {
|
||||
let _serial = serial();
|
||||
let s = status();
|
||||
assert_eq!(s.rung, Rung::Cpu);
|
||||
assert!(s.line().starts_with("CPU"), "{}", s.line());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Walk the ladder, once, by building real sessions (docs/dev/inference.md §4).
|
||||
//!
|
||||
//! A rung is taken when a session builds on it, runs, and is faster than
|
||||
//! the floor. Both halves matter: a provider can register and then fail at
|
||||
//! partition time, and a provider can take a graph — or quietly hand most
|
||||
//! of it back to the CPU — and run it slower than the CPU would have. The outcome is cached against a fingerprint of the
|
||||
//! runtime, the driver, the hardware and the models, and trusted until any
|
||||
//! of those changes.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::{api::Runtime, state, Cache, Config, Form, Role, Rung};
|
||||
|
||||
/// The rungs to try on this platform, best first, under the user's ceiling.
|
||||
fn ladder(ceiling: Option<Rung>) -> Vec<Rung> {
|
||||
#[cfg(target_os = "android")]
|
||||
let all = [Rung::Hexagon];
|
||||
// A desktop has one vendor's GPU; the other vendor's providers are
|
||||
// "not enabled in this build" or a library that fails to load, and
|
||||
// either answer arrives in milliseconds.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let all = [Rung::TensorRt, Rung::Cuda, Rung::MiGraphX];
|
||||
all.into_iter()
|
||||
.filter(|r| ceiling.is_none_or(|c| *r <= c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The probe body. Sets the cache and clears `probing` when done; never
|
||||
/// panics out, because a failed probe is a result (the floor) and not an
|
||||
/// error.
|
||||
pub fn run(runtime: Runtime) {
|
||||
let cfg = state().lock().unwrap().config.clone();
|
||||
let fingerprint = fingerprint(&runtime, &cfg);
|
||||
|
||||
if let Some(cached) = read_cache(&cfg) {
|
||||
if cached.fingerprint == fingerprint && cached.rung.is_some() {
|
||||
log::info!(
|
||||
"inference: cached selection {} ({})",
|
||||
cached.rung.unwrap().label(),
|
||||
cached.reason
|
||||
);
|
||||
finish(cached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut cache = Cache {
|
||||
fingerprint,
|
||||
..Cache::default()
|
||||
};
|
||||
|
||||
if !runtime.is_native() {
|
||||
cache.rung = Some(Rung::Cpu);
|
||||
cache.reason = "no ONNX Runtime found; tract on one core".into();
|
||||
write_cache(&cfg, &cache);
|
||||
finish(cache);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((role, canonical)) = probe_model(&cfg) else {
|
||||
cache.rung = Some(Rung::Cpu);
|
||||
cache.reason = "no model to probe with".into();
|
||||
write_cache(&cfg, &cache);
|
||||
finish(cache);
|
||||
return;
|
||||
};
|
||||
|
||||
let floor = match time_rung(Rung::Cpu, role, &canonical, &cfg) {
|
||||
Ok((ms, _)) => ms,
|
||||
Err(e) => {
|
||||
// The CPU provider failing is the runtime failing; there is
|
||||
// nothing below it to try, and the reason is worth reading.
|
||||
cache.rung = Some(Rung::Cpu);
|
||||
cache.reason = format!("CPU provider failed: {e}");
|
||||
write_cache(&cfg, &cache);
|
||||
finish(cache);
|
||||
return;
|
||||
}
|
||||
};
|
||||
log::info!("inference: floor {floor:.1} ms on the CPU provider");
|
||||
|
||||
for rung in ladder(cfg.ceiling) {
|
||||
match time_rung(rung, role, &canonical, &cfg) {
|
||||
Ok((ms, key)) if ms < floor => {
|
||||
cache.rung = Some(rung);
|
||||
cache.reason = format!("{ms:.1} ms against {floor:.1} ms on the CPU");
|
||||
if let Some(key) = key {
|
||||
cache.compiled.insert(key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok((ms, _)) => {
|
||||
let why = format!("{ms:.1} ms, slower than the CPU's {floor:.1} ms");
|
||||
log::info!("inference: {} rejected: {why}", rung.label());
|
||||
cache.failed.push((rung, why));
|
||||
}
|
||||
Err(e) => {
|
||||
log::info!("inference: {} failed: {e}", rung.label());
|
||||
cache.failed.push((rung, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
if cache.rung.is_none() {
|
||||
cache.rung = Some(Rung::Cpu);
|
||||
cache.reason = match cache.failed.first() {
|
||||
Some((r, why)) => format!("{} {}", r.label(), first_line(why)),
|
||||
None => "the only rung on this platform".into(),
|
||||
};
|
||||
}
|
||||
write_cache(&cfg, &cache);
|
||||
finish(cache);
|
||||
}
|
||||
|
||||
fn finish(cache: Cache) {
|
||||
let mut s = state().lock().unwrap();
|
||||
s.cache = cache;
|
||||
s.probing = false;
|
||||
}
|
||||
|
||||
/// The smallest detector, or the smallest model of any role if there is
|
||||
/// none. A ~2 MB detector is the cheapest real test of a provider, and the
|
||||
/// detector is the role the int8 forms exist for — the eye classifiers are
|
||||
/// smaller still, and a Hexagon probed with one would fail for want of a
|
||||
/// form nobody ships.
|
||||
fn probe_model(cfg: &Config) -> Option<(Role, PathBuf)> {
|
||||
let smallest = |want: Option<Role>| {
|
||||
cfg.models
|
||||
.iter()
|
||||
.filter(|(role, _)| want.is_none_or(|w| *role == w))
|
||||
.filter_map(|(role, path)| {
|
||||
let size = std::fs::metadata(path).ok()?.len();
|
||||
Some((size, *role, path.clone()))
|
||||
})
|
||||
.min_by_key(|(size, _, _)| *size)
|
||||
.map(|(_, role, path)| (role, path))
|
||||
};
|
||||
smallest(Some(Role::Detector)).or_else(|| smallest(None))
|
||||
}
|
||||
|
||||
/// Build, run once for the engine, then time three runs; the median in
|
||||
/// milliseconds and, for a compiling rung, the cache key of the engine this
|
||||
/// just built.
|
||||
fn time_rung(
|
||||
rung: Rung,
|
||||
role: Role,
|
||||
canonical: &Path,
|
||||
cfg: &Config,
|
||||
) -> Result<(f64, Option<String>), String> {
|
||||
let want = rung.form(role);
|
||||
let path = match want {
|
||||
Form::Int8 => {
|
||||
let p = crate::int8_sibling(canonical);
|
||||
if !p.is_file() {
|
||||
return Err(format!("no int8 form of {}", canonical.display()));
|
||||
}
|
||||
p
|
||||
}
|
||||
Form::F32 => canonical.to_path_buf(),
|
||||
};
|
||||
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
||||
let started = Instant::now();
|
||||
let mut session =
|
||||
crate::session::build(rung, role, &bytes, cfg).map_err(|e| first_line(&e.to_string()))?;
|
||||
log::info!(
|
||||
"inference: {} session built in {:.1} s",
|
||||
rung.label(),
|
||||
started.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
let shape: Vec<usize> = session.inputs()[0]
|
||||
.dtype()
|
||||
.tensor_shape()
|
||||
.ok_or("model input is not a tensor")?
|
||||
.iter()
|
||||
.map(|&d| if d > 0 { d as usize } else { 1 })
|
||||
.collect();
|
||||
let zeros = vec![0f32; shape.iter().product()];
|
||||
let run = |session: &mut ort::session::Session| -> Result<f64, String> {
|
||||
let input = ort::value::Tensor::from_array((shape.clone(), zeros.clone()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let t = Instant::now();
|
||||
let out = session
|
||||
.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)
|
||||
};
|
||||
run(&mut session)?;
|
||||
let mut times = [run(&mut session)?, run(&mut session)?, run(&mut session)?];
|
||||
times.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let key = rung.compiles().then(|| crate::engines::key(rung, &bytes));
|
||||
Ok((times[1], key))
|
||||
}
|
||||
|
||||
/// The part of a provider's error a person can act on. ONNX Runtime's
|
||||
/// begin with a source path and a C++ template signature; the words —
|
||||
/// "CUDA failure 999: unknown error", "FAIL : Failed to load library" —
|
||||
/// come after, and the settings row has room for one line of them.
|
||||
fn first_line(s: &str) -> String {
|
||||
let line = s.lines().next().unwrap_or("");
|
||||
let start = ["failure", "FAIL :", "Error:", "error:"]
|
||||
.iter()
|
||||
.filter_map(|m| line.find(m))
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
line[start..].chars().take(200).collect()
|
||||
}
|
||||
|
||||
/// Everything a change of which should re-probe: the runtime, where it
|
||||
/// 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 {
|
||||
let mut parts = vec![
|
||||
format!("engine {}", env!("CARGO_PKG_VERSION")),
|
||||
format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
match runtime {
|
||||
Runtime::Tract => "tract".to_string(),
|
||||
Runtime::OnnxRuntime { path, version } => {
|
||||
format!(
|
||||
"ort {version} {} [{}]",
|
||||
path.display(),
|
||||
providers_beside(path)
|
||||
)
|
||||
}
|
||||
},
|
||||
device_identity(),
|
||||
];
|
||||
for (role, bytes) in &cfg.embedded {
|
||||
parts.push(format!(
|
||||
"{role:?} embedded {:016x}",
|
||||
crate::engines::hash(bytes)
|
||||
));
|
||||
}
|
||||
for (role, path) in &cfg.models {
|
||||
let hash = std::fs::read(path)
|
||||
.map(|b| crate::engines::hash(&b))
|
||||
.unwrap_or(0);
|
||||
parts.push(format!("{role:?} {hash:016x}"));
|
||||
let int8 = crate::int8_sibling(path);
|
||||
if let Ok(b) = std::fs::read(&int8) {
|
||||
parts.push(format!("{role:?} int8 {:016x}", crate::engines::hash(&b)));
|
||||
}
|
||||
}
|
||||
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")]
|
||||
fn device_identity() -> String {
|
||||
// The NVIDIA driver's version line, or the ROCm release the AMD stack
|
||||
// came from (`rocm-core` writes it; the kernel driver has no version
|
||||
// of its own). Absent means neither.
|
||||
if let Some(line) = std::fs::read_to_string("/proc/driver/nvidia/version")
|
||||
.ok()
|
||||
.and_then(|s| s.lines().next().map(str::to_string))
|
||||
{
|
||||
return line;
|
||||
}
|
||||
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")]
|
||||
fn device_identity() -> String {
|
||||
// The SoC and the vendor's build: a Hexagon appears or disappears with
|
||||
// either.
|
||||
format!(
|
||||
"{} {}",
|
||||
system_property("ro.soc.model"),
|
||||
system_property("ro.build.version.incremental")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn system_property(name: &str) -> String {
|
||||
extern "C" {
|
||||
fn __system_property_get(
|
||||
name: *const std::ffi::c_char,
|
||||
value: *mut std::ffi::c_char,
|
||||
) -> i32;
|
||||
}
|
||||
let name = std::ffi::CString::new(name).unwrap();
|
||||
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||
// SAFETY: bionic's documented call; the buffer is PROP_VALUE_MAX bytes.
|
||||
let n = unsafe { __system_property_get(name.as_ptr(), buf.as_mut_ptr().cast()) };
|
||||
String::from_utf8_lossy(&buf[..n.max(0) as usize]).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
fn device_identity() -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn cache_path(cfg: &Config) -> PathBuf {
|
||||
cfg.cache_dir.join("backend.json")
|
||||
}
|
||||
|
||||
fn read_cache(cfg: &Config) -> Option<Cache> {
|
||||
let text = std::fs::read_to_string(cache_path(cfg)).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// Written whole and renamed into place, so a reader never sees half.
|
||||
pub fn write_cache(cfg: &Config, cache: &Cache) {
|
||||
if cfg.cache_dir.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
let path = cache_path(cfg);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let _ = std::fs::create_dir_all(&cfg.cache_dir);
|
||||
if let Ok(text) = serde_json::to_string_pretty(cache) {
|
||||
if std::fs::write(&tmp, text).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! One session builder per rung (docs/dev/inference.md §2, §7, §9).
|
||||
|
||||
use ort::session::Session;
|
||||
|
||||
use crate::{Config, Role, Rung};
|
||||
|
||||
/// Build a session for `bytes` on `rung`.
|
||||
///
|
||||
/// Not strict about the CPU: `session.disable_cpu_ep_fallback` was tried as
|
||||
/// the probe's proof that a provider took the graph, and it refuses the
|
||||
/// Hexagon over the ten quantise/dequantise nodes at the graph's edges that
|
||||
/// QNN declines by policy and that cost microseconds. The probe's proof is
|
||||
/// its clock instead (§4): a provider that hands real work to the CPU is
|
||||
/// slower than the CPU floor and rejected by the same measurement.
|
||||
pub fn build(rung: Rung, role: Role, bytes: &[u8], cfg: &Config) -> ort::Result<Session> {
|
||||
// No optimisation level named. ONNX Runtime's default is already its
|
||||
// fullest, and on tract any level but "disabled" means `into_optimized`,
|
||||
// whose optimiser divides by zero inside yolo26n-seg (tract-data
|
||||
// `stack_tensors`) — a panic across the C API, which is an abort. The
|
||||
// app never asked tract for that and does not start now.
|
||||
let mut b = Session::builder()?.with_intra_threads(threads(cfg))?;
|
||||
// A Hexagon session loads the compiled context when there is one and
|
||||
// compiles it from the model when there is not; the engine thread is
|
||||
// what makes the second case rare (§6).
|
||||
let context = (rung == Rung::Hexagon).then(|| crate::engines::context_path(cfg, bytes));
|
||||
let ready = context.as_ref().is_some_and(|p| p.is_file());
|
||||
b = providers(
|
||||
b,
|
||||
rung,
|
||||
role,
|
||||
cfg,
|
||||
if ready { None } else { context.as_deref() },
|
||||
)?;
|
||||
match (ready, context) {
|
||||
(true, Some(path)) => b.commit_from_file(path),
|
||||
_ => b.commit_from_memory(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// The intra-op pool: what the config says, else the cores less two for
|
||||
/// the compositor and the decoder (§9). tract ignores it.
|
||||
fn threads(cfg: &Config) -> usize {
|
||||
if cfg.threads > 0 {
|
||||
return cfg.threads;
|
||||
}
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get().saturating_sub(2).max(1))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn providers(
|
||||
b: ort::session::builder::SessionBuilder,
|
||||
rung: Rung,
|
||||
role: Role,
|
||||
cfg: &Config,
|
||||
_generate_context: Option<&std::path::Path>,
|
||||
) -> ort::Result<ort::session::builder::SessionBuilder> {
|
||||
use ort::ep;
|
||||
match rung {
|
||||
Rung::Cpu => Ok(b),
|
||||
Rung::Cuda => {
|
||||
Ok(b.with_execution_providers([ep::CUDA::default().build().error_on_failure()])?)
|
||||
}
|
||||
Rung::TensorRt => {
|
||||
let cache = cfg.cache_dir.join("tensorrt");
|
||||
let _ = std::fs::create_dir_all(&cache);
|
||||
let cache = cache.to_string_lossy().into_owned();
|
||||
// fp16 for everything but the embedder, whose comparability
|
||||
// across devices is worth more than its 0.2 ms (§7). The
|
||||
// workspace cap keeps the develop view's tiles on the card
|
||||
// (NFR-RES-2). CUDA behind it takes any node TensorRT declines.
|
||||
Ok(b.with_execution_providers([
|
||||
ep::TensorRT::default()
|
||||
.with_fp16(role != Role::Embedder)
|
||||
.with_engine_cache(true)
|
||||
.with_engine_cache_path(&cache)
|
||||
.with_timing_cache(true)
|
||||
.with_timing_cache_path(&cache)
|
||||
.with_max_workspace_size(512 << 20)
|
||||
.build()
|
||||
.error_on_failure(),
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
fn providers(
|
||||
b: ort::session::builder::SessionBuilder,
|
||||
rung: Rung,
|
||||
_role: Role,
|
||||
_cfg: &Config,
|
||||
generate_context: Option<&std::path::Path>,
|
||||
) -> ort::Result<ort::session::builder::SessionBuilder> {
|
||||
use ort::ep;
|
||||
match rung {
|
||||
Rung::Cpu => Ok(b),
|
||||
Rung::Hexagon => {
|
||||
// The HTP compiles the graph once per device (0.8–1.7 s here).
|
||||
// With `ep.context_enable` ONNX Runtime writes the compiled
|
||||
// context beside the probe cache; the next session loads that
|
||||
// file as its model and skips the compile (§5).
|
||||
let mut b = b;
|
||||
if let Some(ctx) = generate_context {
|
||||
let _ = std::fs::create_dir_all(ctx.parent().unwrap());
|
||||
b = b
|
||||
.with_config_entry("ep.context_enable", "1")?
|
||||
.with_config_entry("ep.context_file_path", ctx.to_string_lossy())?
|
||||
.with_config_entry("ep.context_embed_mode", "0")?;
|
||||
}
|
||||
// Quantise/dequantise at the graph's edges stay on the NPU too,
|
||||
// so a strict build is a whole-graph build.
|
||||
Ok(b.with_execution_providers([ep::QNN::default()
|
||||
.with_backend_path("libQnnHtp.so")
|
||||
.with_performance_mode(ep::qnn::PerformanceMode::Burst)
|
||||
.with_offload_graph_io_quantization(false)
|
||||
.build()
|
||||
.error_on_failure()])?)
|
||||
}
|
||||
Rung::Cuda | Rung::TensorRt | Rung::MiGraphX => {
|
||||
unreachable!("no desktop GPU rung on Android")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "dr-pano"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
# Guards against a Git LFS pointer being embedded in place of the weights.
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
# Inference for the learned keypoint detector, on the same footing as
|
||||
# `dr-segment`: `ort` is the API, `dr-inference-engine` decides what runs
|
||||
# it (docs/dev/inference.md), and both are optional so that the geometry —
|
||||
# matching, the rotation solve, the projections — is a dependency-free crate
|
||||
# that tests without a model.
|
||||
ort = { workspace = true, optional = true }
|
||||
dr-inference-engine = { workspace = true, optional = true }
|
||||
ndarray = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# The example aligns real frames from their embedded previews.
|
||||
dr-decode.workspace = true
|
||||
dr-types.workspace = true
|
||||
env_logger.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["xfeat", "embedded-model"]
|
||||
|
||||
# The XFeat detector (FR-MRG-8) and the MI-GAN filler (FR-MRG-4). Off, the
|
||||
# crate has no model and no runtime — a build that only wants the geometry.
|
||||
xfeat = ["dep:ort", "dep:dr-inference-engine", "dep:ndarray"]
|
||||
|
||||
# Compile the weights into the binary, for the same reason `dr-segment` does:
|
||||
# Android hands the app no path to read a model from (ARCH §6.9).
|
||||
embedded-model = ["xfeat"]
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Check the model is a model and not an LFS pointer.
|
||||
//!
|
||||
//! `models/keypoints/*.onnx` is stored in Git LFS (see `.gitattributes`). A
|
||||
//! clone made without git-lfs, or with `GIT_LFS_SKIP_SMUDGE` set, leaves a
|
||||
//! ~130-byte text pointer at that path instead of the weights, and
|
||||
//! `include_bytes!` would embed it without complaint. Same guard as
|
||||
//! `dr-segment`'s, for the same failure.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
const MODELS: &[&str] = &[
|
||||
"../../models/keypoints/xfeat-1024.onnx",
|
||||
"../../models/keypoints/xfeat-768.onnx",
|
||||
];
|
||||
|
||||
fn main() {
|
||||
for m in MODELS {
|
||||
println!("cargo:rerun-if-changed={m}");
|
||||
}
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
if std::env::var_os("CARGO_FEATURE_EMBEDDED_MODEL").is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
for model in MODELS.iter().copied() {
|
||||
check(model);
|
||||
}
|
||||
}
|
||||
|
||||
fn check(model: &str) {
|
||||
let path = Path::new(model);
|
||||
let Ok(bytes) = std::fs::read(path) else {
|
||||
panic!(
|
||||
"\n\n{model} is missing.\n\
|
||||
It ships in Git LFS. Run `git lfs install && git lfs pull`, or build \
|
||||
with `--no-default-features` for a geometry-only build.\n"
|
||||
);
|
||||
};
|
||||
|
||||
if bytes.starts_with(b"version https://git-lfs.github.com/spec/") {
|
||||
panic!(
|
||||
"\n\n{model} is a Git LFS pointer, not the model.\n\
|
||||
Run `git lfs install && git lfs pull`, or build with \
|
||||
`--no-default-features` for a geometry-only build.\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Align real frames from their embedded previews and draw the result.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p dr-pano --example align --release -- fixtures/pano/2025-08-05/*.CR2
|
||||
//! cargo run -p dr-pano --example align --release -- out-prefix frame1.CR2 frame2.CR2 …
|
||||
//! ```
|
||||
//!
|
||||
//! The point of looking rather than asserting: a rotation solve that is
|
||||
//! numerically converged and geometrically wrong — a mirrored axis, a
|
||||
//! transposed homography, an orientation applied the wrong way — produces
|
||||
//! perfectly plausible residuals and a picture that is obviously broken.
|
||||
//! This writes `<prefix>-cyl.ppm`: every frame's preview warped onto a
|
||||
//! cylinder and averaged where they overlap, at a size that fits on a
|
||||
//! screen. Ghosting in the overlaps is the alignment error, made visible.
|
||||
//!
|
||||
//! Previews, not RAW: the alignment runs on proxies in the application too
|
||||
//! (FR-MRG-7), and a camera's embedded JPEG is a proxy the decoder already
|
||||
//! extracts in milliseconds. What is different from the real path is only
|
||||
//! that the pixels are the camera's rendering rather than ours, which the
|
||||
//! geometry does not care about.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use dr_pano::bundle::Cameras;
|
||||
use dr_pano::{align, xfeat::XFeat, AlignOptions, Gray, Projection};
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.is_empty() {
|
||||
eprintln!("usage: align [out-prefix] <frame>...");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let prefix =
|
||||
if args[0].ends_with(".CR2") || args[0].ends_with(".dng") || args[0].ends_with(".jpg") {
|
||||
"align".to_string()
|
||||
} else {
|
||||
args.remove(0)
|
||||
};
|
||||
let paths: Vec<PathBuf> = args.iter().map(PathBuf::from).collect();
|
||||
|
||||
// Previews, oriented, at proxy size.
|
||||
let t = Instant::now();
|
||||
let mut proxies: Vec<Gray> = Vec::new();
|
||||
for p in &paths {
|
||||
let bytes = std::fs::read(p).expect("read");
|
||||
let preview = dr_decode::extract_preview(&bytes, dr_decode::PreviewSize::Full)
|
||||
.expect("embedded preview");
|
||||
let orientation =
|
||||
dr_decode::orientation(&bytes[..bytes.len().min(dr_decode::HEADER_BYTES as usize)])
|
||||
.unwrap_or(dr_types::Orientation::NORMAL);
|
||||
let tag = match orientation.quarter_turns {
|
||||
1 => 6,
|
||||
2 => 3,
|
||||
3 => 8,
|
||||
_ => 1,
|
||||
};
|
||||
let gray = Gray::from_rgba8(
|
||||
&preview.rgba,
|
||||
preview.width as usize,
|
||||
preview.height as usize,
|
||||
)
|
||||
.oriented(tag);
|
||||
let (fitted, _) = gray.fitted(
|
||||
dr_pano::xfeat::INPUT_LONG_EDGE,
|
||||
dr_pano::xfeat::INPUT_LONG_EDGE,
|
||||
);
|
||||
println!(
|
||||
"{:<14} preview {}×{} orientation {} → proxy {}×{}",
|
||||
p.file_name().unwrap().to_string_lossy(),
|
||||
preview.width,
|
||||
preview.height,
|
||||
tag,
|
||||
fitted.width,
|
||||
fitted.height
|
||||
);
|
||||
proxies.push(fitted);
|
||||
}
|
||||
println!("previews in {:?}", t.elapsed());
|
||||
|
||||
// Keypoints.
|
||||
let t = Instant::now();
|
||||
let mut detector = XFeat::embedded().expect("model");
|
||||
let features: Vec<_> = proxies
|
||||
.iter()
|
||||
.map(|g| detector.detect(g).expect("detect"))
|
||||
.collect();
|
||||
for (i, f) in features.iter().enumerate() {
|
||||
println!("frame {i}: {} keypoints", f.len());
|
||||
}
|
||||
println!(
|
||||
"detection in {:?} ({:?} per frame)",
|
||||
t.elapsed(),
|
||||
t.elapsed() / proxies.len() as u32
|
||||
);
|
||||
|
||||
// Alignment.
|
||||
let t = Instant::now();
|
||||
let opts = AlignOptions::default();
|
||||
let alignment = align(&features, &opts).expect("align");
|
||||
println!("alignment in {:?}", t.elapsed());
|
||||
println!(
|
||||
"focal {:.1} px, long edge {} px ({:.1} mm on full frame), rms {:.3} px",
|
||||
alignment.focal,
|
||||
proxies[0].width.max(proxies[0].height),
|
||||
alignment.focal * 36.0 / proxies[0].width.max(proxies[0].height) as f64,
|
||||
alignment.rms_px
|
||||
);
|
||||
for l in &alignment.links {
|
||||
println!(
|
||||
" link {}–{}: {} inliers of {} matches",
|
||||
l.i, l.j, l.inliers, l.matches
|
||||
);
|
||||
}
|
||||
for (k, why) in &alignment.unaligned {
|
||||
println!(" UNALIGNED frame {k}: {why}");
|
||||
}
|
||||
let root = alignment
|
||||
.rotations
|
||||
.iter()
|
||||
.position(|r| *r == Some(dr_pano::linalg::Mat3::IDENTITY))
|
||||
.unwrap_or(0);
|
||||
for (k, r) in alignment.rotations.iter().enumerate() {
|
||||
if let Some(r) = r {
|
||||
// Yaw about y, pitch about x, roll about z, from the matrix's
|
||||
// columns — enough to read a sweep by eye.
|
||||
let yaw = r.0[0][2].atan2(r.0[2][2]).to_degrees();
|
||||
let pitch = (-r.0[1][2]).asin().to_degrees();
|
||||
let roll = r.0[1][0].atan2(r.0[1][1]).to_degrees();
|
||||
println!(
|
||||
" frame {k}: yaw {yaw:7.2}° pitch {pitch:6.2}° roll {roll:6.2}°{}",
|
||||
if k == root { " (reference)" } else { "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !alignment.is_complete() {
|
||||
eprintln!("not drawing: the set is not fully aligned");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Draw: a cylinder, averaged where frames overlap.
|
||||
let t = Instant::now();
|
||||
let cameras: Cameras = alignment.cameras();
|
||||
let (fw, fh) = (proxies[0].width as f64, proxies[0].height as f64);
|
||||
let scale = alignment.focal;
|
||||
let bounds = dr_pano::projection::bounds(Projection::Cylindrical, scale, &cameras, (fw, fh))
|
||||
.expect("bounds");
|
||||
// Fit to 3000 px wide.
|
||||
let out_w = 3000usize;
|
||||
let px = bounds.width() / out_w as f64;
|
||||
let out_h = (bounds.height() / px).ceil() as usize;
|
||||
let mut sum = vec![0.0f32; out_w * out_h];
|
||||
let mut count = vec![0u16; out_w * out_h];
|
||||
for oy in 0..out_h {
|
||||
for ox in 0..out_w {
|
||||
let u = bounds.min_u + (ox as f64 + 0.5) * px;
|
||||
let v = bounds.min_v + (oy as f64 + 0.5) * px;
|
||||
let d = Projection::Cylindrical.to_direction(scale, u, v);
|
||||
for (k, g) in proxies.iter().enumerate() {
|
||||
let Some((x, y)) = cameras.project(k, d) else {
|
||||
continue;
|
||||
};
|
||||
let (x, y) = (x + g.width as f64 / 2.0, y + g.height as f64 / 2.0);
|
||||
if x < 0.0 || y < 0.0 || x >= g.width as f64 - 1.0 || y >= g.height as f64 - 1.0 {
|
||||
continue;
|
||||
}
|
||||
let (x0, y0) = (x as usize, y as usize);
|
||||
let (tx, ty) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
|
||||
let p = |xx: usize, yy: usize| g.data[yy * g.width + xx];
|
||||
let val = (p(x0, y0) * (1.0 - tx) + p(x0 + 1, y0) * tx) * (1.0 - ty)
|
||||
+ (p(x0, y0 + 1) * (1.0 - tx) + p(x0 + 1, y0 + 1) * tx) * ty;
|
||||
sum[oy * out_w + ox] += val;
|
||||
count[oy * out_w + ox] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ppm = format!("P5\n{out_w} {out_h}\n255\n").into_bytes();
|
||||
ppm.extend(sum.iter().zip(&count).map(|(s, c)| {
|
||||
if *c == 0 {
|
||||
0u8
|
||||
} else {
|
||||
((s / f32::from(*c)).clamp(0.0, 1.0) * 255.0) as u8
|
||||
}
|
||||
}));
|
||||
let out = format!("{prefix}-cyl.pgm");
|
||||
std::fs::write(&out, ppm).expect("write");
|
||||
println!("wrote {out} ({out_w}×{out_h}) in {:?}", t.elapsed());
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
//! TRACES: FR-MRG-1 | FR-MRG-5
|
||||
//! From features to cameras: the alignment of a whole set.
|
||||
//!
|
||||
//! 1. Match every pair of frames (`matching`).
|
||||
//! 2. For each pair with enough matches, a robust homography
|
||||
//! (`homography::ransac_homography`); a pair is a *link* when its inliers
|
||||
//! pass Brown & Lowe's test, `n_inliers > 8 + 0.3 · n_matches`, which
|
||||
//! is what separates a real overlap from a coincidence of descriptors.
|
||||
//! 3. The focal length: the median of what the links' homographies imply,
|
||||
//! or the caller's hint if none of them implies anything.
|
||||
//! 4. A spanning tree over the links, strongest first, from the
|
||||
//! best-connected frame; rotations chained along it.
|
||||
//! 5. Bundle adjustment over every link's inliers (`bundle`).
|
||||
//!
|
||||
//! What it refuses to do is guess. A frame the tree does not reach is
|
||||
//! reported by index with the reason (FR-MRG-5) and left out of the
|
||||
//! cameras; the caller decides whether a set with a hole is worth
|
||||
//! stitching, and the requirement says it is not.
|
||||
|
||||
use crate::bundle::{self, AdjustOptions, Cameras, Observation};
|
||||
use crate::features::Features;
|
||||
use crate::homography::{self, RobustHomography};
|
||||
use crate::linalg::Mat3;
|
||||
use crate::matching::{match_features, Match};
|
||||
use crate::PanoError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AlignOptions {
|
||||
/// Descriptor similarity floor for a match (`matching`).
|
||||
pub min_similarity: f32,
|
||||
/// RANSAC agreement distance, in pixels of the features' image.
|
||||
pub ransac_px: f64,
|
||||
pub ransac_iterations: usize,
|
||||
/// A pair needs at least this many inliers to be a link, on top of
|
||||
/// Brown & Lowe's ratio test.
|
||||
pub min_inliers: usize,
|
||||
/// Focal length in pixels of the features' image, if the caller knows
|
||||
/// it (EXIF and a sensor width). Used only when the homographies do not
|
||||
/// determine one.
|
||||
pub focal_hint: Option<f64>,
|
||||
pub adjust: AdjustOptions,
|
||||
/// For RANSAC's sampling: the same seed gives the same alignment
|
||||
/// (NFR-MRG-2).
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl Default for AlignOptions {
|
||||
fn default() -> Self {
|
||||
AlignOptions {
|
||||
min_similarity: 0.82,
|
||||
ransac_px: 3.0,
|
||||
ransac_iterations: 1000,
|
||||
min_inliers: 12,
|
||||
focal_hint: None,
|
||||
adjust: AdjustOptions::default(),
|
||||
seed: 0x5eed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An overlap the alignment trusts.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Link {
|
||||
pub i: usize,
|
||||
pub j: usize,
|
||||
pub matches: usize,
|
||||
pub inliers: usize,
|
||||
/// Maps centred points of `i` to centred points of `j`.
|
||||
pub h: Mat3,
|
||||
}
|
||||
|
||||
/// Why a frame is not in the alignment.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Unaligned {
|
||||
/// Not enough matches with any other frame to try a geometry.
|
||||
NoMatches,
|
||||
/// Matches existed but none survived RANSAC as a real overlap.
|
||||
NoOverlap,
|
||||
/// Overlaps existed but only with frames that are themselves unaligned.
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Unaligned {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Unaligned::NoMatches => "too few matching features with any other frame",
|
||||
Unaligned::NoOverlap => "no consistent overlap with any other frame",
|
||||
Unaligned::Disconnected => "overlaps only with frames that could not be aligned",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The result: cameras for the aligned frames, and the rest named.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Alignment {
|
||||
/// One rotation per input frame, camera to world, for aligned frames;
|
||||
/// `None` for the unaligned. The reference frame is the best-connected
|
||||
/// one and has the identity.
|
||||
pub rotations: Vec<Option<Mat3>>,
|
||||
/// Focal length in pixels of the features' image.
|
||||
pub focal: f64,
|
||||
pub links: Vec<Link>,
|
||||
pub unaligned: Vec<(usize, Unaligned)>,
|
||||
/// Bundle adjustment's RMS reprojection error, in pixels.
|
||||
pub rms_px: f64,
|
||||
}
|
||||
|
||||
impl Alignment {
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.unaligned.is_empty()
|
||||
}
|
||||
|
||||
/// The cameras of the aligned frames, indexed as the input — a frame
|
||||
/// that is not aligned is given the identity, so this is only useful
|
||||
/// when [`Self::is_complete`].
|
||||
pub fn cameras(&self) -> Cameras {
|
||||
Cameras {
|
||||
rotations: self
|
||||
.rotations
|
||||
.iter()
|
||||
.map(|r| r.unwrap_or(Mat3::IDENTITY))
|
||||
.collect(),
|
||||
focal: self.focal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Align a set of frames from their features.
|
||||
///
|
||||
/// Every `Features` must be in its own frame's pixel coordinates with the
|
||||
/// image size filled in; points are centred on the image centre here. The
|
||||
/// frames must all come from the same lens at the same focal length, which
|
||||
/// is the panorama assumption and not checked — the caller has the EXIF.
|
||||
pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, PanoError> {
|
||||
let n = frames.len();
|
||||
if n < 2 {
|
||||
return Err(PanoError::Input(
|
||||
"a panorama needs at least two frames".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let centre = |k: usize, i: usize| -> (f64, f64) {
|
||||
let kp = frames[k].keypoints[i];
|
||||
(
|
||||
f64::from(kp.x) - frames[k].width as f64 / 2.0,
|
||||
f64::from(kp.y) - frames[k].height as f64 / 2.0,
|
||||
)
|
||||
};
|
||||
// Scale for the DLT's conditioning: points of order one.
|
||||
let scale = 1.0
|
||||
/ frames
|
||||
.iter()
|
||||
.map(|f| f.width.max(f.height) as f64)
|
||||
.fold(1.0, f64::max);
|
||||
|
||||
// 1 + 2: every pair.
|
||||
let mut links = Vec::new();
|
||||
let mut observations: Vec<Observation> = Vec::new();
|
||||
let mut matched_any = vec![false; n];
|
||||
let t_match = std::time::Instant::now();
|
||||
for i in 0..n {
|
||||
for j in i + 1..n {
|
||||
let matches: Vec<Match> = match_features(&frames[i], &frames[j], opts.min_similarity);
|
||||
log::debug!("pair {i}-{j}: {} matches", matches.len());
|
||||
if matches.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
matched_any[i] = true;
|
||||
matched_any[j] = true;
|
||||
let pairs: Vec<((f64, f64), (f64, f64))> = matches
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let (a, b) = (centre(i, m.a), centre(j, m.b));
|
||||
((a.0 * scale, a.1 * scale), (b.0 * scale, b.1 * scale))
|
||||
})
|
||||
.collect();
|
||||
let Some(RobustHomography { h, inliers }) = homography::ransac_homography(
|
||||
&pairs,
|
||||
opts.ransac_px * scale,
|
||||
opts.ransac_iterations,
|
||||
opts.seed ^ ((i as u64) << 32 | j as u64),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let needed = (8.0 + 0.3 * matches.len() as f64).ceil() as usize;
|
||||
log::debug!("pair {i}-{j}: {} inliers, {needed} needed", inliers.len());
|
||||
if inliers.len() <= needed || inliers.len() < opts.min_inliers {
|
||||
continue;
|
||||
}
|
||||
// Back to pixels: H_px = S⁻¹ H S.
|
||||
let m = h.0;
|
||||
let h_px = Mat3([
|
||||
[m[0][0], m[0][1], m[0][2] / scale],
|
||||
[m[1][0], m[1][1], m[1][2] / scale],
|
||||
[m[2][0] * scale, m[2][1] * scale, m[2][2]],
|
||||
]);
|
||||
for &k in &inliers {
|
||||
let (a, b) = pairs[k];
|
||||
observations.push(Observation {
|
||||
i,
|
||||
j,
|
||||
pi: (a.0 / scale, a.1 / scale),
|
||||
pj: (b.0 / scale, b.1 / scale),
|
||||
});
|
||||
}
|
||||
links.push(Link {
|
||||
i,
|
||||
j,
|
||||
matches: matches.len(),
|
||||
inliers: inliers.len(),
|
||||
h: h_px,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!("matching and pairwise geometry in {:?}", t_match.elapsed());
|
||||
|
||||
// 3: the focal length.
|
||||
let mut estimates: Vec<f64> = links
|
||||
.iter()
|
||||
.filter_map(|l| homography::focal_from_homography(&l.h))
|
||||
.filter(|f| f.is_finite() && *f > 0.0)
|
||||
.collect();
|
||||
let longest = frames
|
||||
.iter()
|
||||
.map(|f| f.width.max(f.height) as f64)
|
||||
.fold(0.0, f64::max);
|
||||
let focal = if !estimates.is_empty() {
|
||||
estimates.sort_by(f64::total_cmp);
|
||||
let median = estimates[estimates.len() / 2];
|
||||
// A homography of a nearly pure pan can imply almost anything;
|
||||
// clamp to the range a real lens on this sensor can reach.
|
||||
median.clamp(0.3 * longest, 6.0 * longest)
|
||||
} else if let Some(hint) = opts.focal_hint {
|
||||
hint
|
||||
} else {
|
||||
// No overlap said anything and nobody told us: a normal lens.
|
||||
longest
|
||||
};
|
||||
|
||||
// 4: spanning tree, strongest link first, from the best-connected frame.
|
||||
let mut rotations: Vec<Option<Mat3>> = vec![None; n];
|
||||
let mut unaligned = Vec::new();
|
||||
if links.is_empty() {
|
||||
for (k, &matched) in matched_any.iter().enumerate() {
|
||||
unaligned.push((
|
||||
k,
|
||||
if matched {
|
||||
Unaligned::NoOverlap
|
||||
} else {
|
||||
Unaligned::NoMatches
|
||||
},
|
||||
));
|
||||
}
|
||||
return Ok(Alignment {
|
||||
rotations,
|
||||
focal,
|
||||
links,
|
||||
unaligned,
|
||||
rms_px: 0.0,
|
||||
});
|
||||
}
|
||||
let mut degree = vec![0usize; n];
|
||||
for l in &links {
|
||||
degree[l.i] += l.inliers;
|
||||
degree[l.j] += l.inliers;
|
||||
}
|
||||
let root = (0..n).max_by_key(|&k| degree[k]).unwrap_or(0);
|
||||
rotations[root] = Some(Mat3::IDENTITY);
|
||||
loop {
|
||||
// The strongest link from an aligned frame to an unaligned one.
|
||||
let best = links
|
||||
.iter()
|
||||
.filter(|l| rotations[l.i].is_some() != rotations[l.j].is_some())
|
||||
.max_by_key(|l| l.inliers);
|
||||
let Some(l) = best else { break };
|
||||
let r_ij = homography::rotation_from_homography(&l.h, focal);
|
||||
// H_ij takes points of i to j, so bearings b_j = R_ij b_i, and with
|
||||
// world = R_i · cam_i: R_j = R_i · R_ijᵀ.
|
||||
if let Some(ri) = rotations[l.i] {
|
||||
rotations[l.j] = Some((ri * r_ij.transpose()).orthonormalised());
|
||||
} else if let Some(rj) = rotations[l.j] {
|
||||
rotations[l.i] = Some((rj * r_ij).orthonormalised());
|
||||
}
|
||||
}
|
||||
for k in 0..n {
|
||||
if rotations[k].is_none() {
|
||||
let reason = if !matched_any[k] {
|
||||
Unaligned::NoMatches
|
||||
} else if links.iter().any(|l| l.i == k || l.j == k) {
|
||||
Unaligned::Disconnected
|
||||
} else {
|
||||
Unaligned::NoOverlap
|
||||
};
|
||||
unaligned.push((k, reason));
|
||||
}
|
||||
}
|
||||
|
||||
// 5: adjust the aligned frames together. The reference frame must be
|
||||
// index 0 of the adjustment (it holds frame 0 fixed), so the aligned
|
||||
// frames are renumbered with the root first.
|
||||
let aligned: Vec<usize> = std::iter::once(root)
|
||||
.chain((0..n).filter(|&k| k != root && rotations[k].is_some()))
|
||||
.collect();
|
||||
let index_of = |k: usize| aligned.iter().position(|&a| a == k);
|
||||
let start = Cameras {
|
||||
rotations: aligned.iter().map(|&k| rotations[k].unwrap()).collect(),
|
||||
focal,
|
||||
};
|
||||
let obs: Vec<Observation> = observations
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
Some(Observation {
|
||||
i: index_of(o.i)?,
|
||||
j: index_of(o.j)?,
|
||||
pi: o.pi,
|
||||
pj: o.pj,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let t_adjust = std::time::Instant::now();
|
||||
let adjusted = bundle::adjust(start, &obs, &opts.adjust)?;
|
||||
log::debug!(
|
||||
"bundle adjustment: {} observations, {} iterations in {:?}",
|
||||
obs.len(),
|
||||
adjusted.iterations,
|
||||
t_adjust.elapsed()
|
||||
);
|
||||
for (slot, &k) in aligned.iter().enumerate() {
|
||||
rotations[k] = Some(adjusted.cameras.rotations[slot]);
|
||||
}
|
||||
|
||||
Ok(Alignment {
|
||||
rotations,
|
||||
focal: adjusted.cameras.focal,
|
||||
links,
|
||||
unaligned,
|
||||
rms_px: adjusted.rms_px,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::features::{Keypoint, DESCRIPTOR_LEN};
|
||||
use crate::linalg::Vec3;
|
||||
|
||||
/// Frames of a synthetic sweep: world directions with random unit
|
||||
/// descriptors, each frame seeing the ones in its field of view.
|
||||
fn synthetic_sweep(
|
||||
n: usize,
|
||||
step: f64,
|
||||
f: f64,
|
||||
w: usize,
|
||||
h: usize,
|
||||
) -> (Vec<Features>, Cameras) {
|
||||
let mut seed = 777u64;
|
||||
let mut rnd = || {
|
||||
seed = seed
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
((seed >> 33) as f64 / (1u64 << 31) as f64) - 0.5
|
||||
};
|
||||
let rotations: Vec<Mat3> = (0..n)
|
||||
.map(|k| {
|
||||
Mat3::rotation(Vec3::new(0.0, 1.0, 0.0), step * k as f64)
|
||||
* Mat3::rotation(Vec3::new(1.0, 0.0, 0.0), 0.02 * ((k % 3) as f64 - 1.0))
|
||||
})
|
||||
.collect();
|
||||
let truth = Cameras {
|
||||
rotations,
|
||||
focal: f,
|
||||
};
|
||||
let total = step * (n as f64 - 1.0);
|
||||
let mut frames: Vec<Features> = (0..n)
|
||||
.map(|_| Features {
|
||||
keypoints: Vec::new(),
|
||||
descriptors: Vec::new(),
|
||||
width: w,
|
||||
height: h,
|
||||
})
|
||||
.collect();
|
||||
for _ in 0..600 * n {
|
||||
let yaw = rnd() * (total + 0.8) + total / 2.0;
|
||||
let pitch = rnd() * 0.5;
|
||||
let d = Vec3::new(
|
||||
yaw.sin() * pitch.cos(),
|
||||
pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
);
|
||||
let desc: Vec<f32> = (0..DESCRIPTOR_LEN).map(|_| rnd() as f32).collect();
|
||||
let norm = desc.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
let desc: Vec<f32> = desc.iter().map(|v| v / norm).collect();
|
||||
for (k, frame) in frames.iter_mut().enumerate() {
|
||||
if let Some(p) = truth.project(k, d) {
|
||||
let (x, y) = (p.0 + w as f64 / 2.0, p.1 + h as f64 / 2.0);
|
||||
if x >= 0.0 && x < w as f64 && y >= 0.0 && y < h as f64 {
|
||||
frame.keypoints.push(Keypoint {
|
||||
x: (x + rnd() * 0.6) as f32,
|
||||
y: (y + rnd() * 0.6) as f32,
|
||||
score: 1.0,
|
||||
});
|
||||
frame.descriptors.extend_from_slice(&desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(frames, truth)
|
||||
}
|
||||
|
||||
fn angle_between(a: Mat3, b: Mat3) -> f64 {
|
||||
(a.transpose() * b).log().norm()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_synthetic_sweep_is_aligned_to_its_truth() {
|
||||
let (frames, truth) = synthetic_sweep(6, 0.3, 1400.0, 1024, 768);
|
||||
let out = align(&frames, &AlignOptions::default()).expect("aligned");
|
||||
assert!(out.is_complete(), "unaligned: {:?}", out.unaligned);
|
||||
assert_eq!(out.links.len(), 5 + 4, "links: {}", out.links.len());
|
||||
assert!((out.focal - 1400.0).abs() < 15.0, "focal {}", out.focal);
|
||||
assert!(out.rms_px < 1.0, "rms {}", out.rms_px);
|
||||
// Relative rotations match the truth's, whichever frame is the root.
|
||||
let root = out
|
||||
.rotations
|
||||
.iter()
|
||||
.position(|r| *r == Some(Mat3::IDENTITY))
|
||||
.unwrap();
|
||||
for k in 0..6 {
|
||||
let rel_truth = truth.rotations[root].transpose() * truth.rotations[k];
|
||||
let rel_out = out.rotations[k].unwrap();
|
||||
let err = angle_between(rel_truth, rel_out);
|
||||
assert!(err < 2e-3, "frame {k} off by {err} rad");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_from_nowhere_is_named_not_guessed() {
|
||||
let (mut frames, _) = synthetic_sweep(4, 0.3, 1400.0, 1024, 768);
|
||||
// Frame 3 gets descriptors nobody else has.
|
||||
for v in &mut frames[3].descriptors {
|
||||
*v = -*v;
|
||||
}
|
||||
let out = align(&frames, &AlignOptions::default()).expect("aligned");
|
||||
assert_eq!(out.unaligned.len(), 1);
|
||||
assert_eq!(out.unaligned[0].0, 3);
|
||||
assert!(out.rotations[3].is_none());
|
||||
assert!(out.rotations[..3].iter().all(Option::is_some));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_frame_is_refused() {
|
||||
let (frames, _) = synthetic_sweep(1, 0.3, 1400.0, 640, 480);
|
||||
assert!(matches!(
|
||||
align(&frames, &AlignOptions::default()),
|
||||
Err(PanoError::Input(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
//! Bundle adjustment: every rotation and the focal length, refined together.
|
||||
//!
|
||||
//! The pairwise homographies (`homography.rs`) each know about two frames.
|
||||
//! Chained around a loop they disagree with themselves by the accumulated
|
||||
//! error, and a twelve-frame sweep chained end to end drifts by a visible
|
||||
//! amount. This solves for all the rotations at once, against every inlier
|
||||
//! match of every pair, so the error is spread rather than accumulated —
|
||||
//! Brown & Lowe's step 4, with the camera model reduced to what a panorama
|
||||
//! needs: one rotation per frame and one focal length shared by all.
|
||||
//!
|
||||
//! Levenberg–Marquardt with a numerical Jacobian. Analytic derivatives of a
|
||||
//! rotation's projection are not hard, but they are a second place the
|
||||
//! model is written down, and the model is small: forty parameters, a few
|
||||
//! thousand residuals, a Jacobian that costs forty residual evaluations.
|
||||
//! The whole solve is milliseconds. Correctness over cleverness, and one
|
||||
//! definition of the projection to keep right.
|
||||
|
||||
use crate::linalg::{DMat, Mat3, Vec3};
|
||||
use crate::PanoError;
|
||||
|
||||
/// A point in one image, centred on the principal point, in pixels.
|
||||
pub type Point = (f64, f64);
|
||||
|
||||
/// One inlier match between two frames.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Observation {
|
||||
pub i: usize,
|
||||
pub j: usize,
|
||||
pub pi: Point,
|
||||
pub pj: Point,
|
||||
}
|
||||
|
||||
/// What the adjustment starts from and returns: a rotation per frame
|
||||
/// (camera to world; frame 0 is the world) and the focal length in pixels.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Cameras {
|
||||
pub rotations: Vec<Mat3>,
|
||||
pub focal: f64,
|
||||
}
|
||||
|
||||
impl Cameras {
|
||||
/// The unit direction, in world space, that pixel `p` of frame `i` looks
|
||||
/// along.
|
||||
pub fn bearing(&self, i: usize, p: Point) -> Vec3 {
|
||||
self.rotations[i] * Vec3::new(p.0, p.1, self.focal).normalised()
|
||||
}
|
||||
|
||||
/// Where world direction `d` lands in frame `j`, or `None` if it is
|
||||
/// behind the camera.
|
||||
pub fn project(&self, j: usize, d: Vec3) -> Option<Point> {
|
||||
let c = self.rotations[j].transpose() * d;
|
||||
if c.z() <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
Some((self.focal * c.x() / c.z(), self.focal * c.y() / c.z()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AdjustOptions {
|
||||
pub max_iterations: usize,
|
||||
/// Residuals beyond this many pixels are down-weighted (Huber), so a
|
||||
/// mismatch RANSAC let through pulls with bounded force.
|
||||
pub huber_px: f64,
|
||||
/// Whether the focal length is a free parameter. Off, it is held at the
|
||||
/// starting value — for a set whose rotations are all small, the focal
|
||||
/// length is weakly observable and better taken from the homographies'
|
||||
/// median than pulled about by noise.
|
||||
pub refine_focal: bool,
|
||||
}
|
||||
|
||||
impl Default for AdjustOptions {
|
||||
fn default() -> Self {
|
||||
AdjustOptions {
|
||||
max_iterations: 50,
|
||||
huber_px: 3.0,
|
||||
refine_focal: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The adjusted cameras and the fit.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Adjusted {
|
||||
pub cameras: Cameras,
|
||||
/// Root-mean-square reprojection error over all observations, in pixels
|
||||
/// (unweighted, so an outlier RANSAC missed shows here rather than
|
||||
/// hiding under its Huber weight).
|
||||
pub rms_px: f64,
|
||||
pub iterations: usize,
|
||||
}
|
||||
|
||||
/// Refine `start` against `observations`.
|
||||
///
|
||||
/// Frame 0's rotation is held fixed: the world frame is arbitrary and
|
||||
/// fixing one camera removes the freedom. Every other frame must appear in
|
||||
/// at least one observation or its rotation is undetermined and the normal
|
||||
/// equations are singular — the caller (`align`) guarantees it by only
|
||||
/// adjusting frames a spanning tree reached.
|
||||
pub fn adjust(
|
||||
start: Cameras,
|
||||
observations: &[Observation],
|
||||
opts: &AdjustOptions,
|
||||
) -> Result<Adjusted, PanoError> {
|
||||
let n_frames = start.rotations.len();
|
||||
if n_frames < 2 || observations.is_empty() {
|
||||
let rms = rms(&start, observations);
|
||||
return Ok(Adjusted {
|
||||
cameras: start,
|
||||
rms_px: rms,
|
||||
iterations: 0,
|
||||
});
|
||||
}
|
||||
// Every adjustable frame must be constrained by something, or its
|
||||
// block of the normal equations is zero and the solve is meaningless —
|
||||
// checked here, by name, rather than left to surface as a step that
|
||||
// fails to lower the cost.
|
||||
let mut seen = vec![false; n_frames];
|
||||
for o in observations {
|
||||
seen[o.i] = true;
|
||||
seen[o.j] = true;
|
||||
}
|
||||
if let Some(k) = (1..n_frames).find(|&k| !seen[k]) {
|
||||
return Err(PanoError::Geometry(format!(
|
||||
"frame {k} has no observations constraining it"
|
||||
)));
|
||||
}
|
||||
let n_rot = 3 * (n_frames - 1);
|
||||
let n_params = n_rot + usize::from(opts.refine_focal);
|
||||
let n_res = 2 * observations.len();
|
||||
|
||||
// Parameters are *increments* on the current cameras, re-applied each
|
||||
// accepted step: rotation k ← exp(δ_k) · rotation k, focal ← f · exp(δ_f).
|
||||
// Composing on the left keeps the increment in world space, where a
|
||||
// small rotation means the same thing for every frame.
|
||||
let apply = |base: &Cameras, x: &[f64]| -> Cameras {
|
||||
let mut rotations = base.rotations.clone();
|
||||
for k in 1..n_frames {
|
||||
let w = Vec3::new(x[3 * (k - 1)], x[3 * (k - 1) + 1], x[3 * (k - 1) + 2]);
|
||||
rotations[k] = (Mat3::exp(w) * base.rotations[k]).orthonormalised();
|
||||
}
|
||||
let focal = if opts.refine_focal {
|
||||
base.focal * x[n_rot].exp()
|
||||
} else {
|
||||
base.focal
|
||||
};
|
||||
Cameras { rotations, focal }
|
||||
};
|
||||
|
||||
let residuals = |c: &Cameras, out: &mut Vec<f64>| {
|
||||
out.clear();
|
||||
for o in observations {
|
||||
let d = c.bearing(o.i, o.pi);
|
||||
match c.project(o.j, d) {
|
||||
Some((x, y)) => {
|
||||
out.push(x - o.pj.0);
|
||||
out.push(y - o.pj.1);
|
||||
}
|
||||
None => {
|
||||
// Behind the camera: as wrong as a residual can be
|
||||
// without being infinite. The Huber weight caps its pull.
|
||||
out.push(1e4);
|
||||
out.push(1e4);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let weights = |r: &[f64], out: &mut Vec<f64>| {
|
||||
out.clear();
|
||||
for pair in r.chunks_exact(2) {
|
||||
let m = (pair[0] * pair[0] + pair[1] * pair[1]).sqrt();
|
||||
let w = if m > opts.huber_px {
|
||||
opts.huber_px / m
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
out.push(w);
|
||||
out.push(w);
|
||||
}
|
||||
};
|
||||
|
||||
// The robust cost itself, not the weighted sum of squares: the weights
|
||||
// above are the IRLS linearisation for one step, and comparing two
|
||||
// steps by sums taken under different weights would accept the wrong
|
||||
// ones. Huber: quadratic within the threshold, linear beyond it.
|
||||
let cost = |r: &[f64]| -> f64 {
|
||||
r.chunks_exact(2)
|
||||
.map(|pair| {
|
||||
let m = (pair[0] * pair[0] + pair[1] * pair[1]).sqrt();
|
||||
if m <= opts.huber_px {
|
||||
m * m
|
||||
} else {
|
||||
2.0 * opts.huber_px * m - opts.huber_px * opts.huber_px
|
||||
}
|
||||
})
|
||||
.sum()
|
||||
};
|
||||
|
||||
let mut cameras = start;
|
||||
let mut r = Vec::with_capacity(n_res);
|
||||
let mut w = Vec::with_capacity(n_res);
|
||||
residuals(&cameras, &mut r);
|
||||
weights(&r, &mut w);
|
||||
let mut current = cost(&r);
|
||||
|
||||
let mut lambda = 1e-3;
|
||||
let mut jac = vec![0.0f64; n_res * n_params];
|
||||
let mut r_plus = Vec::with_capacity(n_res);
|
||||
let zero = vec![0.0f64; n_params];
|
||||
let mut iterations = 0;
|
||||
|
||||
for _ in 0..opts.max_iterations {
|
||||
iterations += 1;
|
||||
|
||||
// Numerical Jacobian about the current cameras (x = 0).
|
||||
const H: f64 = 1e-6;
|
||||
for p in 0..n_params {
|
||||
let mut x = zero.clone();
|
||||
x[p] = H;
|
||||
let c_plus = apply(&cameras, &x);
|
||||
residuals(&c_plus, &mut r_plus);
|
||||
for (k, (rp, r0)) in r_plus.iter().zip(&r).enumerate() {
|
||||
jac[k * n_params + p] = (rp - r0) / H;
|
||||
}
|
||||
}
|
||||
|
||||
// Normal equations, weighted: (JᵀWJ + λ·diag) δ = −JᵀWr.
|
||||
let mut a = DMat::zeros(n_params);
|
||||
let mut b = vec![0.0f64; n_params];
|
||||
for k in 0..n_res {
|
||||
let row = &jac[k * n_params..(k + 1) * n_params];
|
||||
let wk = w[k];
|
||||
for p in 0..n_params {
|
||||
b[p] -= wk * row[p] * r[k];
|
||||
for q in 0..n_params {
|
||||
a[(p, q)] += wk * row[p] * row[q];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try steps with increasing damping until one lowers the cost.
|
||||
let mut accepted = false;
|
||||
for _ in 0..10 {
|
||||
let mut damped = a.clone();
|
||||
for p in 0..n_params {
|
||||
let d = a[(p, p)];
|
||||
damped[(p, p)] = d + lambda * d.max(1e-9);
|
||||
}
|
||||
let Some(delta) = damped.solve_spd(&b) else {
|
||||
return Err(PanoError::Geometry(
|
||||
"the adjustment's normal equations are singular: a frame has no \
|
||||
observations constraining it"
|
||||
.into(),
|
||||
));
|
||||
};
|
||||
let candidate = apply(&cameras, &delta);
|
||||
residuals(&candidate, &mut r_plus);
|
||||
let c_new = cost(&r_plus);
|
||||
if c_new < current {
|
||||
let improvement = (current - c_new) / current.max(1e-12);
|
||||
let step: f64 = delta.iter().map(|d| d * d).sum::<f64>().sqrt();
|
||||
cameras = candidate;
|
||||
std::mem::swap(&mut r, &mut r_plus);
|
||||
weights(&r, &mut w);
|
||||
current = c_new;
|
||||
lambda = (lambda / 3.0).max(1e-9);
|
||||
accepted = true;
|
||||
// Converged when a *lightly damped* step no longer helps. A
|
||||
// heavily damped step is small by construction and would
|
||||
// pass an improvement test long before the minimum.
|
||||
if step < 1e-10 || (improvement < 1e-8 && lambda < 1e-2) {
|
||||
return Ok(Adjusted {
|
||||
rms_px: rms(&cameras, observations),
|
||||
cameras,
|
||||
iterations,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
lambda *= 5.0;
|
||||
}
|
||||
if !accepted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Adjusted {
|
||||
rms_px: rms(&cameras, observations),
|
||||
cameras,
|
||||
iterations,
|
||||
})
|
||||
}
|
||||
|
||||
/// Unweighted RMS reprojection error in pixels.
|
||||
pub fn rms(c: &Cameras, observations: &[Observation]) -> f64 {
|
||||
if observations.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sum: f64 = observations
|
||||
.iter()
|
||||
.map(|o| match c.project(o.j, c.bearing(o.i, o.pi)) {
|
||||
Some((x, y)) => (x - o.pj.0).powi(2) + (y - o.pj.1).powi(2),
|
||||
None => 1e8,
|
||||
})
|
||||
.sum();
|
||||
(sum / observations.len() as f64).sqrt()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A synthetic sweep: `n` cameras panned by `step` radians each with a
|
||||
/// little pitch and roll, `f` pixels, and matches between neighbours
|
||||
/// from a cloud of world directions.
|
||||
fn sweep(n: usize, step: f64, f: f64, noise_px: f64) -> (Cameras, Vec<Observation>) {
|
||||
let mut rotations = Vec::new();
|
||||
for k in 0..n {
|
||||
let yaw = step * k as f64;
|
||||
let pitch = 0.01 * ((k * 7) % 3) as f64;
|
||||
let roll = 0.005 * ((k * 5) % 4) as f64;
|
||||
let r = Mat3::rotation(Vec3::new(0.0, 1.0, 0.0), yaw)
|
||||
* Mat3::rotation(Vec3::new(1.0, 0.0, 0.0), pitch)
|
||||
* Mat3::rotation(Vec3::new(0.0, 0.0, 1.0), roll);
|
||||
rotations.push(r);
|
||||
}
|
||||
let truth = Cameras {
|
||||
rotations,
|
||||
focal: f,
|
||||
};
|
||||
|
||||
// World directions: a fan across the whole sweep.
|
||||
let mut obs = Vec::new();
|
||||
let mut seed = 12345u64;
|
||||
let mut rnd = || {
|
||||
seed = seed
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
((seed >> 33) as f64 / (1u64 << 31) as f64) - 0.5
|
||||
};
|
||||
let total = step * (n as f64 - 1.0);
|
||||
for _ in 0..400 * n {
|
||||
let yaw = rnd() * (total + 0.8) + total / 2.0;
|
||||
let pitch = rnd() * 0.5;
|
||||
let d = Vec3::new(
|
||||
yaw.sin() * pitch.cos(),
|
||||
pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
)
|
||||
.normalised();
|
||||
// Visible in which frames? Within ±0.35 f of centre.
|
||||
let mut seen: Vec<(usize, Point)> = Vec::new();
|
||||
for k in 0..n {
|
||||
if let Some(p) = truth.project(k, d) {
|
||||
if p.0.abs() < 0.35 * f && p.1.abs() < 0.25 * f {
|
||||
seen.push((k, (p.0 + rnd() * noise_px, p.1 + rnd() * noise_px)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in 0..seen.len() {
|
||||
for b in a + 1..seen.len() {
|
||||
obs.push(Observation {
|
||||
i: seen[a].0,
|
||||
j: seen[b].0,
|
||||
pi: seen[a].1,
|
||||
pj: seen[b].1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
(truth, obs)
|
||||
}
|
||||
|
||||
fn angle_between(a: Mat3, b: Mat3) -> f64 {
|
||||
(a.transpose() * b).log().norm()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_perturbed_start_converges_back_to_the_truth() {
|
||||
let (truth, obs) = sweep(6, 0.3, 1400.0, 0.0);
|
||||
assert!(obs.len() > 500);
|
||||
// Perturb every rotation but the first by ~1°, and the focal by 5%.
|
||||
let mut start = truth.clone();
|
||||
for k in 1..6 {
|
||||
let w = Vec3::new(0.01, -0.015, 0.008) * (k as f64 / 3.0);
|
||||
start.rotations[k] = Mat3::exp(w) * start.rotations[k];
|
||||
}
|
||||
start.focal *= 1.05;
|
||||
let before = rms(&start, &obs);
|
||||
let out = adjust(start, &obs, &AdjustOptions::default()).expect("solvable");
|
||||
assert!(out.rms_px < 1e-3, "rms {} (was {before})", out.rms_px);
|
||||
assert!(
|
||||
(out.cameras.focal - 1400.0).abs() < 0.5,
|
||||
"focal {}",
|
||||
out.cameras.focal
|
||||
);
|
||||
for k in 0..6 {
|
||||
let err = angle_between(out.cameras.rotations[k], truth.rotations[k]);
|
||||
assert!(err < 1e-5, "frame {k} off by {err} rad");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_is_averaged_rather_than_accumulated() {
|
||||
let (truth, obs) = sweep(8, 0.25, 1400.0, 1.0);
|
||||
let mut start = truth.clone();
|
||||
for k in 1..8 {
|
||||
start.rotations[k] =
|
||||
Mat3::exp(Vec3::new(0.0, 0.004 * k as f64, 0.0)) * start.rotations[k];
|
||||
}
|
||||
let out = adjust(start, &obs, &AdjustOptions::default()).expect("solvable");
|
||||
// ±0.5 px of uniform noise on every coordinate has an RMS of 0.41 px
|
||||
// per axis, so the fit's RMS over both axes should sit near 0.58 and
|
||||
// cannot be much below it.
|
||||
assert!(out.rms_px < 0.7, "rms {}", out.rms_px);
|
||||
|
||||
// The focal length and the sweep are nearly degenerate for a
|
||||
// single row: only the perspective inside each overlap pins the
|
||||
// focal, and a pixel of noise is worth about a tenth of a percent of
|
||||
// it. What that error does is scale every yaw by the same factor —
|
||||
// a uniform stretch of the panorama, invisible in the result — so the
|
||||
// absolute rotation error grows linearly along the sweep and is not
|
||||
// the measure of the solve. The residual after removing that stretch
|
||||
// is.
|
||||
let f_ratio = out.cameras.focal / 1400.0;
|
||||
assert!((f_ratio - 1.0).abs() < 5e-3, "focal {}", out.cameras.focal);
|
||||
for k in 0..8 {
|
||||
let yaw_k = 0.25 * k as f64;
|
||||
let expected_stretch = (f_ratio - 1.0).abs() * yaw_k;
|
||||
let err = angle_between(out.cameras.rotations[k], truth.rotations[k]);
|
||||
assert!(
|
||||
err < expected_stretch + 1.5e-4,
|
||||
"frame {k} off by {err} rad, {expected_stretch} of it the focal's"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_without_observations_is_refused() {
|
||||
let (truth, mut obs) = sweep(4, 0.3, 1400.0, 0.0);
|
||||
obs.retain(|o| o.i != 3 && o.j != 3);
|
||||
let err = adjust(truth, &obs, &AdjustOptions::default()).unwrap_err();
|
||||
assert!(matches!(err, PanoError::Geometry(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Keypoints with descriptors, and the decoder that reads them out of
|
||||
//! XFeat's dense maps.
|
||||
//!
|
||||
//! The network (S15.2) produces three maps at an eighth of the input
|
||||
//! resolution and stops; everything from there to a list of keypoints is
|
||||
//! this file, in plain Rust, for the reason `dr-segment` decodes yolo26's
|
||||
//! heads itself: the post-processing is cheap, shape-dependent and exactly
|
||||
//! the kind of graph tract parses badly. It is a port of the reference
|
||||
//! `XFeat.detectAndCompute`, step for step, so that a keypoint here is the
|
||||
//! keypoint the paper's numbers were measured on.
|
||||
|
||||
/// One detected point, in the pixel coordinates of the image it was
|
||||
/// detected in, with the detector's confidence.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Keypoint {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
/// The reliability the detector assigned; higher is better, and the
|
||||
/// scale is the detector's own — comparable within one model only.
|
||||
pub score: f32,
|
||||
}
|
||||
|
||||
/// The keypoints of one image and their descriptors.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Features {
|
||||
pub keypoints: Vec<Keypoint>,
|
||||
/// `keypoints.len() × DESCRIPTOR_LEN`, each row L2-normalised, so that a
|
||||
/// dot product between two rows is their cosine similarity.
|
||||
pub descriptors: Vec<f32>,
|
||||
/// The image the coordinates are in.
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
/// The length of one descriptor. XFeat's is 64; the matcher does not care
|
||||
/// what the number is, only that both sides agree.
|
||||
pub const DESCRIPTOR_LEN: usize = 64;
|
||||
|
||||
impl Features {
|
||||
pub fn len(&self) -> usize {
|
||||
self.keypoints.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.keypoints.is_empty()
|
||||
}
|
||||
|
||||
pub fn descriptor(&self, i: usize) -> &[f32] {
|
||||
&self.descriptors[i * DESCRIPTOR_LEN..(i + 1) * DESCRIPTOR_LEN]
|
||||
}
|
||||
}
|
||||
|
||||
/// XFeat's three output maps, as the network hands them back.
|
||||
///
|
||||
/// All three are `channels × height × width` at an eighth of the input, in
|
||||
/// the NCHW order the ONNX export declares (`feats [1, 64, H/8, W/8]`,
|
||||
/// `keypoints [1, 65, H/8, W/8]`, `heatmap [1, 1, H/8, W/8]`).
|
||||
pub struct XFeatMaps<'a> {
|
||||
/// 64 channels: the dense descriptor field.
|
||||
pub feats: &'a [f32],
|
||||
/// 65 channels: for each 8×8 cell, a logit per position plus one for
|
||||
/// "no keypoint here".
|
||||
pub keypoints: &'a [f32],
|
||||
/// 1 channel: reliability.
|
||||
pub heatmap: &'a [f32],
|
||||
/// The maps' width and height (the input's, divided by eight).
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
/// How the decoder picks keypoints.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct DecodeOptions {
|
||||
/// Keep at most this many, by score. The reference default is 4096.
|
||||
pub top_k: usize,
|
||||
/// A cell position's softmax probability must exceed this to be a
|
||||
/// keypoint at all. The reference default is 0.05.
|
||||
pub threshold: f32,
|
||||
/// Ignore keypoints within this many pixels of the map's edge. A frame
|
||||
/// padded into the detector's fixed input (`Gray::padded`) has a hard
|
||||
/// edge where the padding starts, and the detector fires on it.
|
||||
pub border: usize,
|
||||
}
|
||||
|
||||
impl Default for DecodeOptions {
|
||||
fn default() -> Self {
|
||||
DecodeOptions {
|
||||
top_k: 4096,
|
||||
threshold: 0.05,
|
||||
border: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode keypoints and descriptors from the network's maps.
|
||||
///
|
||||
/// The reference, step for step:
|
||||
/// 1. softmax over the 65 logits of each cell, keep the 64 positions;
|
||||
/// 2. pixel-shuffle those into a full-resolution keypoint heatmap — channel
|
||||
/// `c` of cell `(cx, cy)` is pixel `(cx·8 + c%8, cy·8 + c/8)`;
|
||||
/// 3. 5×5 non-maximum suppression over that heatmap, above `threshold`;
|
||||
/// 4. score each survivor by its heatmap value times the reliability map
|
||||
/// sampled bilinearly at its position;
|
||||
/// 5. keep the `top_k` by score;
|
||||
/// 6. sample the descriptor field bilinearly at each and L2-normalise.
|
||||
///
|
||||
/// Bilinear where the reference samples the descriptor field bicubically:
|
||||
/// a quarter-pixel's difference in a field that is smooth by construction,
|
||||
/// and one interpolator rather than two to keep correct.
|
||||
pub fn decode_xfeat(maps: &XFeatMaps<'_>, opts: &DecodeOptions) -> Features {
|
||||
let (w8, h8) = (maps.width, maps.height);
|
||||
let (w, h) = (w8 * 8, h8 * 8);
|
||||
let cells = w8 * h8;
|
||||
debug_assert_eq!(maps.keypoints.len(), 65 * cells);
|
||||
debug_assert_eq!(maps.feats.len(), DESCRIPTOR_LEN * cells);
|
||||
debug_assert_eq!(maps.heatmap.len(), cells);
|
||||
|
||||
// 1 + 2: softmax per cell, scattered into the full-resolution heatmap.
|
||||
let mut heat = vec![0.0f32; w * h];
|
||||
for cy in 0..h8 {
|
||||
for cx in 0..w8 {
|
||||
let cell = cy * w8 + cx;
|
||||
let logit = |c: usize| maps.keypoints[c * cells + cell];
|
||||
let max = (0..65).map(logit).fold(f32::MIN, f32::max);
|
||||
let mut sum = 0.0f32;
|
||||
let mut exps = [0.0f32; 65];
|
||||
for (c, e) in exps.iter_mut().enumerate() {
|
||||
*e = (logit(c) - max).exp();
|
||||
sum += *e;
|
||||
}
|
||||
for (c, e) in exps.iter().enumerate().take(64) {
|
||||
let (dx, dy) = (c % 8, c / 8);
|
||||
heat[(cy * 8 + dy) * w + cx * 8 + dx] = e / sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3: a pixel survives if it is the maximum of its 5×5 neighbourhood and
|
||||
// above threshold. Ties go to every tied pixel, as the reference's
|
||||
// `x == max_pool(x)` does.
|
||||
let border = opts.border.max(2);
|
||||
let mut survivors: Vec<(usize, usize, f32)> = Vec::new();
|
||||
for y in border..h.saturating_sub(border) {
|
||||
for x in border..w.saturating_sub(border) {
|
||||
let v = heat[y * w + x];
|
||||
if v <= opts.threshold {
|
||||
continue;
|
||||
}
|
||||
let mut is_max = true;
|
||||
'nb: for ny in y - 2..=y + 2 {
|
||||
for nx in x - 2..=x + 2 {
|
||||
if heat[ny * w + nx] > v {
|
||||
is_max = false;
|
||||
break 'nb;
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_max {
|
||||
survivors.push((x, y, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4: heatmap value × reliability, the latter sampled at the keypoint's
|
||||
// position in map coordinates (`align_corners = False`: pixel `x` of the
|
||||
// full image is `x / 8 - 0.5` in the map).
|
||||
let sample = |field: &[f32], channels: usize, c: usize, x: f32, y: f32| -> f32 {
|
||||
let fx = (x / 8.0 - 0.5).clamp(0.0, (w8 - 1) as f32);
|
||||
let fy = (y / 8.0 - 0.5).clamp(0.0, (h8 - 1) as f32);
|
||||
let x0 = fx as usize;
|
||||
let y0 = fy as usize;
|
||||
let x1 = (x0 + 1).min(w8 - 1);
|
||||
let y1 = (y0 + 1).min(h8 - 1);
|
||||
let tx = fx - x0 as f32;
|
||||
let ty = fy - y0 as f32;
|
||||
let at = |xx: usize, yy: usize| field[c * (w8 * h8) + yy * w8 + xx];
|
||||
let _ = channels;
|
||||
let top = at(x0, y0) * (1.0 - tx) + at(x1, y0) * tx;
|
||||
let bot = at(x0, y1) * (1.0 - tx) + at(x1, y1) * tx;
|
||||
top * (1.0 - ty) + bot * ty
|
||||
};
|
||||
let mut scored: Vec<(usize, usize, f32)> = survivors
|
||||
.into_iter()
|
||||
.map(|(x, y, v)| {
|
||||
let r = sample(maps.heatmap, 1, 0, x as f32, y as f32);
|
||||
(x, y, v * r)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 5: best first, then cut. `sort_unstable_by` on a total order of the
|
||||
// score; NaN cannot occur — every input is a probability or a sigmoid.
|
||||
scored.sort_unstable_by(|a, b| b.2.total_cmp(&a.2));
|
||||
scored.truncate(opts.top_k);
|
||||
|
||||
// 6: descriptors.
|
||||
let mut keypoints = Vec::with_capacity(scored.len());
|
||||
let mut descriptors = Vec::with_capacity(scored.len() * DESCRIPTOR_LEN);
|
||||
for (x, y, score) in scored {
|
||||
let (xf, yf) = (x as f32, y as f32);
|
||||
let start = descriptors.len();
|
||||
for c in 0..DESCRIPTOR_LEN {
|
||||
descriptors.push(sample(maps.feats, DESCRIPTOR_LEN, c, xf, yf));
|
||||
}
|
||||
let norm = descriptors[start..]
|
||||
.iter()
|
||||
.map(|v| v * v)
|
||||
.sum::<f32>()
|
||||
.sqrt()
|
||||
.max(1e-12);
|
||||
for v in &mut descriptors[start..] {
|
||||
*v /= norm;
|
||||
}
|
||||
keypoints.push(Keypoint {
|
||||
x: xf,
|
||||
y: yf,
|
||||
score,
|
||||
});
|
||||
}
|
||||
|
||||
Features {
|
||||
keypoints,
|
||||
descriptors,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Maps for a `w8 × h8` grid where every cell says "no keypoint" except
|
||||
/// the listed ones, which put all their weight on one position.
|
||||
fn maps(w8: usize, h8: usize, hot: &[(usize, usize, usize)]) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
|
||||
let cells = w8 * h8;
|
||||
let mut kp = vec![0.0f32; 65 * cells];
|
||||
// "None" strongly preferred everywhere.
|
||||
for cell in 0..cells {
|
||||
kp[64 * cells + cell] = 10.0;
|
||||
}
|
||||
for &(cx, cy, c) in hot {
|
||||
let cell = cy * w8 + cx;
|
||||
kp[64 * cells + cell] = 0.0;
|
||||
kp[c * cells + cell] = 10.0;
|
||||
}
|
||||
let heat = vec![0.5f32; cells];
|
||||
// Descriptors: channel c is constant c across the field, so any
|
||||
// sampled descriptor is the same known vector.
|
||||
let mut feats = vec![0.0f32; DESCRIPTOR_LEN * cells];
|
||||
for c in 0..DESCRIPTOR_LEN {
|
||||
for v in &mut feats[c * cells..(c + 1) * cells] {
|
||||
*v = c as f32;
|
||||
}
|
||||
}
|
||||
(feats, kp, heat)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hot_cell_position_becomes_a_keypoint_at_the_right_pixel() {
|
||||
// Cell (2, 1), channel 8*3 + 5 = 29 → pixel (2*8 + 5, 1*8 + 3).
|
||||
let (f, k, h) = maps(8, 8, &[(2, 1, 29)]);
|
||||
let out = decode_xfeat(
|
||||
&XFeatMaps {
|
||||
feats: &f,
|
||||
keypoints: &k,
|
||||
heatmap: &h,
|
||||
width: 8,
|
||||
height: 8,
|
||||
},
|
||||
&DecodeOptions::default(),
|
||||
);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!((out.keypoints[0].x, out.keypoints[0].y), (21.0, 11.0));
|
||||
assert_eq!((out.width, out.height), (64, 64));
|
||||
// Score is the softmax weight (~1) times the reliability (0.5).
|
||||
assert!((out.keypoints[0].score - 0.5).abs() < 5e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptors_are_unit_length() {
|
||||
let (f, k, h) = maps(8, 8, &[(3, 3, 0), (5, 5, 63)]);
|
||||
let out = decode_xfeat(
|
||||
&XFeatMaps {
|
||||
feats: &f,
|
||||
keypoints: &k,
|
||||
heatmap: &h,
|
||||
width: 8,
|
||||
height: 8,
|
||||
},
|
||||
&DecodeOptions::default(),
|
||||
);
|
||||
assert_eq!(out.len(), 2);
|
||||
for i in 0..2 {
|
||||
let n: f32 = out.descriptor(i).iter().map(|v| v * v).sum();
|
||||
assert!((n - 1.0).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_k_keeps_the_best() {
|
||||
let (f, k, mut h) = maps(8, 8, &[(1, 1, 0), (3, 3, 0), (5, 5, 0)]);
|
||||
// Make cell (3, 3) the most reliable.
|
||||
h[3 * 8 + 3] = 0.9;
|
||||
let out = decode_xfeat(
|
||||
&XFeatMaps {
|
||||
feats: &f,
|
||||
keypoints: &k,
|
||||
heatmap: &h,
|
||||
width: 8,
|
||||
height: 8,
|
||||
},
|
||||
&DecodeOptions {
|
||||
top_k: 1,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!((out.keypoints[0].x, out.keypoints[0].y), (24.0, 24.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_border_is_excluded() {
|
||||
let (f, k, h) = maps(8, 8, &[(0, 0, 0)]);
|
||||
let out = decode_xfeat(
|
||||
&XFeatMaps {
|
||||
feats: &f,
|
||||
keypoints: &k,
|
||||
heatmap: &h,
|
||||
width: 8,
|
||||
height: 8,
|
||||
},
|
||||
&DecodeOptions::default(),
|
||||
);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
//! TRACES: FR-MRG-4
|
||||
//! Filling a composite's uncovered border, tile by tile, with an inpainter.
|
||||
//!
|
||||
//! A merged panorama has a ragged border where no frame reached. FR-MRG-4
|
||||
//! crops it by default; this fills it instead, when the photographer asks,
|
||||
//! with pixels a model invents from the picture around them. Everything
|
||||
//! here is the geometry of that — which tiles to run, what context to hand
|
||||
//! the model, how to put its answers back — and none of it is the model:
|
||||
//! that is the [`Inpainter`] trait, with MI-GAN behind it in `migan.rs`
|
||||
//! and a fake in the tests.
|
||||
//!
|
||||
//! # Context across the edge
|
||||
//!
|
||||
//! An inpainting model is trained on holes *inside* pictures. A panorama's
|
||||
//! border is a hole at the picture's *edge*: real content on one side,
|
||||
//! nothing on the other, and a model given that invents a structure along
|
||||
//! the open side — streaks of road in the sky, on the first try
|
||||
//! (2026-09-19). So the known content is mirrored across the coverage
|
||||
//! edge, column by column for the top and bottom bands and row by row for
|
||||
//! the sides, into the hole and into a padding ring around the picture,
|
||||
//! and the ring is presented as *known*. The model then interpolates
|
||||
//! between real content and its mirror rather than extrapolating into
|
||||
//! nothing. The ring is cut off at the end.
|
||||
//!
|
||||
//! # Structure from far away, texture from near
|
||||
//!
|
||||
//! One tiled pass at the working resolution was not enough: a 512-px tile
|
||||
//! straddling the coverage edge sees a few hundred pixels of real content
|
||||
//! on one side and invents the rest from that, two neighbouring tiles
|
||||
//! invent differently, and the seams and the merge's own fringe leak into
|
||||
//! the fill. [`fill_border`] therefore runs in two stages. A **coarse**
|
||||
//! pass at a quarter of the size, where the whole border and hundreds of
|
||||
//! pixels of real context sit inside a handful of tiles, decides the
|
||||
//! structure — where the slope goes, where the sky stays sky. Then
|
||||
//! **fine** passes regenerate the hole in bands from the real edge
|
||||
//! outward: each band is the only unknown, with real content (or the band
|
||||
//! before, freshly textured) on its near side and the coarse fill,
|
||||
//! upsampled, on its far side — blurry, but the right structure — so the
|
||||
//! model generates texture and a transition, never a large hole from
|
||||
//! nothing.
|
||||
//!
|
||||
//! Tiles overlap by a third and are blended under a raised-cosine window,
|
||||
//! so the seams between tiles do not show; the model's answer replaces
|
||||
//! only the pixels that were unknown, and the picture itself is untouched.
|
||||
|
||||
use crate::PanoError;
|
||||
|
||||
/// A model that fills a square hole from its surroundings.
|
||||
pub trait Inpainter {
|
||||
/// The square tile it takes, in pixels.
|
||||
fn tile(&self) -> usize;
|
||||
|
||||
/// Fill one tile. `rgb` is `tile × tile × 3`, row-major, 0..1, with the
|
||||
/// unknown pixels' values meaningless; `known` is `tile × tile`. The
|
||||
/// result is `tile × tile × 3`, 0..1, of which only the unknown pixels
|
||||
/// are read.
|
||||
fn fill(&mut self, rgb: &[f32], known: &[bool]) -> Result<Vec<f32>, PanoError>;
|
||||
}
|
||||
|
||||
/// What a caller hears from [`fill_border`]: progress, for a page's bar,
|
||||
/// and — for whoever is looking at why a fill went wrong — each stage's
|
||||
/// picture as it lands. A plain `FnMut(usize, usize)` is an observer that
|
||||
/// hears only the progress.
|
||||
pub trait Observer {
|
||||
/// `(done, total)` tiles, the total an estimate until the last band.
|
||||
fn progress(&mut self, done: usize, total: usize);
|
||||
/// A stage's result, `width × height × 3`: `coarse` (at the coarse
|
||||
/// size), `band-N` after each fine band, `feathered` at the end.
|
||||
fn stage(&mut self, _name: &str, _rgb: &[f32], _width: usize, _height: usize) {}
|
||||
}
|
||||
|
||||
impl<F: FnMut(usize, usize)> Observer for F {
|
||||
fn progress(&mut self, done: usize, total: usize) {
|
||||
self(done, total)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the picture is extended with mirrored content before tiling.
|
||||
/// Half a tile: enough that a hole at the edge sits well inside a tile.
|
||||
pub const RING: usize = 256;
|
||||
|
||||
/// The fill's knobs, in pixels of the working image. The defaults are
|
||||
/// what the fixture panorama looked best with on 2026-09-19; the merge
|
||||
/// page exposes every one of them while the fill is experimental, so a
|
||||
/// bad corner can be worked on from the picture rather than the code.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Params {
|
||||
/// The coarse pass's reduction: 1 skips it.
|
||||
pub coarse: usize,
|
||||
/// The fine passes' band width.
|
||||
pub band: usize,
|
||||
/// How deep into the picture the mirrored context reaches, or **zero
|
||||
/// for no mirrored context at all**: the void is then shown to the
|
||||
/// model as it is — reaching the picture's edge with nothing beyond,
|
||||
/// and, beyond the band being filled, still unknown. That is what the
|
||||
/// shipped model was trained on (a fine-tune of MI-GAN on voids cut
|
||||
/// from photographs the way a cylindrical merge cuts them, see
|
||||
/// `docs/dev/panorama.md` §14); a ring would give it a fold to continue.
|
||||
///
|
||||
/// Non-zero is the stock model's crutch: a plain reflection of a deep
|
||||
/// hole pulls in whatever is that far from the edge — a ridge, a peak —
|
||||
/// and the model, told that is what lies beyond, paints it upside down.
|
||||
/// Folding the reflection within this band keeps the ring looking like
|
||||
/// the edge it continues and nothing further away.
|
||||
pub mirror_depth: usize,
|
||||
/// How far inside the real edge the fill also regenerates, the two
|
||||
/// blended by distance. A hard cut between real pixels and invented
|
||||
/// ones is a line whatever the fill's quality; blended over this many
|
||||
/// pixels it is not. Zero is the hard cut.
|
||||
pub feather: usize,
|
||||
/// The step between tiles, at most the tile; two thirds of it usual.
|
||||
pub stride: usize,
|
||||
}
|
||||
|
||||
impl Default for Params {
|
||||
fn default() -> Self {
|
||||
Params {
|
||||
coarse: 1,
|
||||
band: 192,
|
||||
mirror_depth: 0,
|
||||
feather: 24,
|
||||
stride: 384,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill the unknown pixels of `rgb` (`width × height × 3`, 0..1) in place:
|
||||
/// the coarse pass, then the fine bands, then the seam feathered over
|
||||
/// `feather` pixels inside the real edge. Returns the tiles run.
|
||||
///
|
||||
/// `known` is `width × height`. `observer` hears the progress and, if it
|
||||
/// cares, each stage.
|
||||
pub fn fill_border(
|
||||
rgb: &mut [f32],
|
||||
width: usize,
|
||||
height: usize,
|
||||
known: &[bool],
|
||||
model: &mut dyn Inpainter,
|
||||
params: Params,
|
||||
observer: &mut dyn Observer,
|
||||
) -> Result<usize, PanoError> {
|
||||
let Params {
|
||||
coarse: q,
|
||||
band,
|
||||
mirror_depth,
|
||||
feather,
|
||||
stride,
|
||||
} = params;
|
||||
let q = q.max(1);
|
||||
let band = band.max(8);
|
||||
// No ring: the void beyond the band stays unknown, as in the model's
|
||||
// training; with a ring the far side is the coarse fill, presented as
|
||||
// known, which the stock model needed to see something there.
|
||||
let open = mirror_depth == 0;
|
||||
if width == 0 || height == 0 || rgb.len() != width * height * 3 || known.len() != width * height
|
||||
{
|
||||
return Err(PanoError::Input("fill: buffer sizes disagree".into()));
|
||||
}
|
||||
if known.iter().all(|&k| k) {
|
||||
return Ok(0);
|
||||
}
|
||||
// The fill regenerates a margin inside the real edge too, and the
|
||||
// result is blended with the real pixels across it at the end.
|
||||
let real = rgb.to_vec();
|
||||
let outer = known.to_vec();
|
||||
let mut inner = known.to_vec();
|
||||
erode(&mut inner, width, height, feather);
|
||||
let known = &inner[..];
|
||||
let mut done = 0usize;
|
||||
|
||||
// Coarse: a fraction of the size, unknown where any pixel of the cell was.
|
||||
let (cw, ch) = ((width / q).max(1), (height / q).max(1));
|
||||
let mut coarse = vec![0.0f32; cw * ch * 3];
|
||||
let mut cknown = vec![true; cw * ch];
|
||||
for y in 0..ch {
|
||||
for x in 0..cw {
|
||||
let mut sum = [0.0f32; 3];
|
||||
let mut n = 0.0f32;
|
||||
let mut all_known = true;
|
||||
for dy in 0..q {
|
||||
for dx in 0..q {
|
||||
let (sx, sy) = ((x * q + dx).min(width - 1), (y * q + dy).min(height - 1));
|
||||
let i = sy * width + sx;
|
||||
all_known &= known[i];
|
||||
for c in 0..3 {
|
||||
sum[c] += rgb[i * 3 + c];
|
||||
}
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
for c in 0..3 {
|
||||
coarse[(y * cw + x) * 3 + c] = sum[c] / n;
|
||||
}
|
||||
cknown[y * cw + x] = all_known;
|
||||
}
|
||||
}
|
||||
let estimate = |tiles: usize| tiles * 4;
|
||||
done += fill_once(
|
||||
&mut coarse,
|
||||
cw,
|
||||
ch,
|
||||
&cknown,
|
||||
model,
|
||||
stride,
|
||||
mirror_depth,
|
||||
|n, t| observer.progress(n, estimate(t)),
|
||||
)?;
|
||||
observer.stage("coarse", &coarse, cw, ch);
|
||||
|
||||
// The hole starts as the coarse structure, upsampled.
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let i = y * width + x;
|
||||
if known[i] {
|
||||
continue;
|
||||
}
|
||||
let fx = ((x as f32 + 0.5) / q as f32 - 0.5).clamp(0.0, (cw - 1) as f32);
|
||||
let fy = ((y as f32 + 0.5) / q as f32 - 0.5).clamp(0.0, (ch - 1) as f32);
|
||||
let (x0, y0) = (fx as usize, fy as usize);
|
||||
let (x1, y1) = ((x0 + 1).min(cw - 1), (y0 + 1).min(ch - 1));
|
||||
let (tx, ty) = (fx - x0 as f32, fy - y0 as f32);
|
||||
for c in 0..3 {
|
||||
let at = |xx: usize, yy: usize| coarse[(yy * cw + xx) * 3 + c];
|
||||
rgb[i * 3 + c] = (at(x0, y0) * (1.0 - tx) + at(x1, y0) * tx) * (1.0 - ty)
|
||||
+ (at(x0, y1) * (1.0 - tx) + at(x1, y1) * tx) * ty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fine, in bands from the edge outward.
|
||||
let dist = distance_to_known(known, width, height);
|
||||
let mut band_known = vec![true; width * height];
|
||||
let mut b = 0usize;
|
||||
loop {
|
||||
let lo = (b * band).saturating_sub(band / 2) as f32;
|
||||
let hi = ((b + 1) * band) as f32;
|
||||
let mut any = false;
|
||||
for i in 0..width * height {
|
||||
let in_band = !known[i] && dist[i] > lo && dist[i] <= hi;
|
||||
band_known[i] = if open {
|
||||
known[i] || dist[i] <= lo
|
||||
} else {
|
||||
!in_band
|
||||
};
|
||||
any |= in_band;
|
||||
}
|
||||
if !any {
|
||||
break;
|
||||
}
|
||||
let before = done;
|
||||
done += fill_once(
|
||||
rgb,
|
||||
width,
|
||||
height,
|
||||
&band_known,
|
||||
model,
|
||||
stride,
|
||||
mirror_depth,
|
||||
|n, t| observer.progress(before + n, before + estimate(t)),
|
||||
)?;
|
||||
observer.stage(&format!("band-{b}"), rgb, width, height);
|
||||
b += 1;
|
||||
}
|
||||
|
||||
// The seam: across the margin, real on the inside, invented on the
|
||||
// outside, a smooth ramp between by distance from the true hole.
|
||||
if feather > 0 {
|
||||
let to_hole =
|
||||
distance_to_known(&outer.iter().map(|k| !k).collect::<Vec<_>>(), width, height);
|
||||
for i in 0..width * height {
|
||||
if !outer[i] || known[i] {
|
||||
continue;
|
||||
}
|
||||
// In the margin: outer says known, inner says not.
|
||||
let t = (to_hole[i] / feather as f32).clamp(0.0, 1.0);
|
||||
let t = t * t * (3.0 - 2.0 * t);
|
||||
for c in 0..3 {
|
||||
rgb[i * 3 + c] = rgb[i * 3 + c] * (1.0 - t) + real[i * 3 + c] * t;
|
||||
}
|
||||
}
|
||||
}
|
||||
observer.stage("feathered", rgb, width, height);
|
||||
observer.progress(done, done);
|
||||
Ok(done)
|
||||
}
|
||||
|
||||
/// One tiled pass: every unknown pixel regenerated from the tiles that
|
||||
/// touch it, the rest kept. Returns the tiles run.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn fill_once(
|
||||
rgb: &mut [f32],
|
||||
width: usize,
|
||||
height: usize,
|
||||
known: &[bool],
|
||||
model: &mut dyn Inpainter,
|
||||
stride: usize,
|
||||
mirror_depth: usize,
|
||||
mut progress: impl FnMut(usize, usize),
|
||||
) -> Result<usize, PanoError> {
|
||||
let t = model.tile();
|
||||
if t == 0 || known.iter().all(|&k| k) {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// The padded canvas with mirrored context, and the hole within it.
|
||||
let ctx = MirroredContext::build(rgb, width, height, known, mirror_depth, t);
|
||||
let (pw, ph) = (ctx.width, ctx.height);
|
||||
|
||||
// Tiles that touch the hole, on a grid that reaches both far edges.
|
||||
let starts = |n: usize| -> Vec<usize> {
|
||||
if n <= t {
|
||||
return vec![0];
|
||||
}
|
||||
let mut v: Vec<usize> = (0..=n - t).step_by(stride.clamp(1, t)).collect();
|
||||
if *v.last().unwrap_or(&0) != n - t {
|
||||
v.push(n - t);
|
||||
}
|
||||
v
|
||||
};
|
||||
let ys = starts(ph);
|
||||
let xs = starts(pw);
|
||||
let mut tiles = Vec::new();
|
||||
for &y in &ys {
|
||||
for &x in &xs {
|
||||
if y + t > ph || x + t > pw {
|
||||
continue;
|
||||
}
|
||||
let touches =
|
||||
(y..y + t).any(|yy| ctx.hole[yy * pw + x..yy * pw + x + t].iter().any(|&h| h));
|
||||
if touches {
|
||||
tiles.push((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Raised-cosine window, so overlapping tiles blend.
|
||||
let hann: Vec<f32> = (0..t)
|
||||
.map(|i| {
|
||||
let s = ((i as f32 + 1.0) / (t as f32 + 1.0) * std::f32::consts::PI).sin();
|
||||
s * s + 1e-3
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut acc = vec![0.0f32; pw * ph * 3];
|
||||
let mut wsum = vec![0.0f32; pw * ph];
|
||||
let mut tile_rgb = vec![0.0f32; t * t * 3];
|
||||
let mut tile_known = vec![false; t * t];
|
||||
let total = tiles.len();
|
||||
for (n, &(x, y)) in tiles.iter().enumerate() {
|
||||
progress(n, total);
|
||||
for r in 0..t {
|
||||
let src = ((y + r) * pw + x) * 3;
|
||||
tile_rgb[r * t * 3..(r + 1) * t * 3].copy_from_slice(&ctx.rgb[src..src + t * 3]);
|
||||
let ks = (y + r) * pw + x;
|
||||
for c in 0..t {
|
||||
tile_known[r * t + c] = !ctx.hole[ks + c];
|
||||
}
|
||||
}
|
||||
let out = model.fill(&tile_rgb, &tile_known)?;
|
||||
if out.len() != t * t * 3 {
|
||||
return Err(PanoError::Model(format!(
|
||||
"the inpainter returned {} values for a {t}×{t} tile",
|
||||
out.len()
|
||||
)));
|
||||
}
|
||||
for r in 0..t {
|
||||
for c in 0..t {
|
||||
let w = hann[r] * hann[c];
|
||||
let p = (y + r) * pw + (x + c);
|
||||
for ch in 0..3 {
|
||||
acc[p * 3 + ch] += out[(r * t + c) * 3 + ch] * w;
|
||||
}
|
||||
wsum[p] += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
progress(total, total);
|
||||
|
||||
// Back into the picture: only the unknown pixels change.
|
||||
for yy in 0..height {
|
||||
for xx in 0..width {
|
||||
let i = yy * width + xx;
|
||||
if known[i] {
|
||||
continue;
|
||||
}
|
||||
let p = (yy + ctx.ring) * pw + (xx + ctx.ring);
|
||||
if wsum[p] > 0.0 {
|
||||
for ch in 0..3 {
|
||||
rgb[i * 3 + ch] = (acc[p * 3 + ch] / wsum[p]).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Shrink `known` by `iterations` pixels on every side, in place.
|
||||
///
|
||||
/// The merge's coverage edge carries a fringe — the last partly-covered
|
||||
/// pixels of a frame, and whatever the renderer did at the boundary — and
|
||||
/// a fill that stops exactly at the coverage bit leaves it as a dark line
|
||||
/// along the seam. Eight pixels at a quarter of the composite's resolution
|
||||
/// was what it took on the fixture.
|
||||
pub fn erode(known: &mut [bool], width: usize, height: usize, iterations: usize) {
|
||||
let mut next = known.to_vec();
|
||||
for _ in 0..iterations {
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let i = y * width + x;
|
||||
if !known[i] {
|
||||
continue;
|
||||
}
|
||||
let edge = x == 0
|
||||
|| y == 0
|
||||
|| x + 1 == width
|
||||
|| y + 1 == height
|
||||
|| !known[i - 1]
|
||||
|| !known[i + 1]
|
||||
|| !known[i - width]
|
||||
|| !known[i + width];
|
||||
next[i] = !edge;
|
||||
}
|
||||
}
|
||||
known.copy_from_slice(&next);
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance from each pixel to the nearest known one, by two chamfer
|
||||
/// sweeps — within a few percent of Euclidean, and enough to cut bands.
|
||||
fn distance_to_known(known: &[bool], width: usize, height: usize) -> Vec<f32> {
|
||||
let inf = (width + height) as f32;
|
||||
let mut d: Vec<f32> = known.iter().map(|&k| if k { 0.0 } else { inf }).collect();
|
||||
let (a, b) = (1.0f32, std::f32::consts::SQRT_2);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let i = y * width + x;
|
||||
let mut v = d[i];
|
||||
if x > 0 {
|
||||
v = v.min(d[i - 1] + a);
|
||||
}
|
||||
if y > 0 {
|
||||
v = v.min(d[i - width] + a);
|
||||
if x > 0 {
|
||||
v = v.min(d[i - width - 1] + b);
|
||||
}
|
||||
if x + 1 < width {
|
||||
v = v.min(d[i - width + 1] + b);
|
||||
}
|
||||
}
|
||||
d[i] = v;
|
||||
}
|
||||
}
|
||||
for y in (0..height).rev() {
|
||||
for x in (0..width).rev() {
|
||||
let i = y * width + x;
|
||||
let mut v = d[i];
|
||||
if x + 1 < width {
|
||||
v = v.min(d[i + 1] + a);
|
||||
}
|
||||
if y + 1 < height {
|
||||
v = v.min(d[i + width] + a);
|
||||
if x + 1 < width {
|
||||
v = v.min(d[i + width + 1] + b);
|
||||
}
|
||||
if x > 0 {
|
||||
v = v.min(d[i + width - 1] + b);
|
||||
}
|
||||
}
|
||||
d[i] = v;
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Distance beyond the edge to distance inside it, folded within `depth`
|
||||
/// ([`Params::mirror_depth`]): a triangle wave, so the band is read
|
||||
/// forward and back rather than clamped to one row.
|
||||
fn fold(d: usize, depth: usize) -> usize {
|
||||
let period = 2 * depth;
|
||||
let r = d % period;
|
||||
if r <= depth {
|
||||
r
|
||||
} else {
|
||||
period - r
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture on a canvas `RING` wider on every side, with the hole and
|
||||
/// the ring filled by mirroring the known content across the coverage
|
||||
/// edge — the nearest `depth` of it, folded — and the hole, the
|
||||
/// original unknown and nothing else, marked.
|
||||
struct MirroredContext {
|
||||
width: usize,
|
||||
height: usize,
|
||||
/// The padding on every side: `RING` with mirrored context, 0 without.
|
||||
ring: usize,
|
||||
rgb: Vec<f32>,
|
||||
hole: Vec<bool>,
|
||||
}
|
||||
|
||||
impl MirroredContext {
|
||||
fn build(
|
||||
rgb: &[f32],
|
||||
width: usize,
|
||||
height: usize,
|
||||
known: &[bool],
|
||||
depth: usize,
|
||||
tile: usize,
|
||||
) -> Self {
|
||||
if depth == 0 {
|
||||
// Open: the picture as it is, the hole as it is. What the hole
|
||||
// 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 {
|
||||
width: pw,
|
||||
height: ph,
|
||||
ring: 0,
|
||||
rgb: canvas,
|
||||
hole,
|
||||
};
|
||||
}
|
||||
let fold = |d: usize| fold(d, depth);
|
||||
let (pw, ph) = (width + 2 * RING, height + 2 * RING);
|
||||
let mut canvas = vec![0.0f32; pw * ph * 3];
|
||||
let mut kn = vec![false; pw * ph];
|
||||
let mut hole = vec![false; pw * ph];
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let i = y * width + x;
|
||||
let p = (y + RING) * pw + (x + RING);
|
||||
canvas[p * 3..p * 3 + 3].copy_from_slice(&rgb[i * 3..i * 3 + 3]);
|
||||
kn[p] = known[i];
|
||||
hole[p] = !known[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Per column: mirror across the first and last known row.
|
||||
for x in 0..pw {
|
||||
let first = (0..ph).find(|&y| kn[y * pw + x]);
|
||||
let Some(first) = first else { continue };
|
||||
let last = (0..ph).rev().find(|&y| kn[y * pw + x]).unwrap_or(first);
|
||||
for y in 0..first {
|
||||
let m = (first + fold(first - y)).min(last);
|
||||
let (d, s) = ((y * pw + x) * 3, (m * pw + x) * 3);
|
||||
canvas.copy_within(s..s + 3, d);
|
||||
}
|
||||
for y in last + 1..ph {
|
||||
let m = last.saturating_sub(fold(y - last)).max(first);
|
||||
let (d, s) = ((y * pw + x) * 3, (m * pw + x) * 3);
|
||||
canvas.copy_within(s..s + 3, d);
|
||||
}
|
||||
}
|
||||
// Per row, for the sides, over what is there now.
|
||||
for y in 0..ph {
|
||||
let first = (0..pw).find(|&x| kn[y * pw + x]);
|
||||
let Some(first) = first else { continue };
|
||||
let last = (0..pw).rev().find(|&x| kn[y * pw + x]).unwrap_or(first);
|
||||
for x in 0..first {
|
||||
let m = (first + fold(first - x)).min(last);
|
||||
let (d, s) = ((y * pw + x) * 3, (y * pw + m) * 3);
|
||||
canvas.copy_within(s..s + 3, d);
|
||||
}
|
||||
for x in last + 1..pw {
|
||||
let m = last.saturating_sub(fold(x - last)).max(first);
|
||||
let (d, s) = ((y * pw + x) * 3, (y * pw + m) * 3);
|
||||
canvas.copy_within(s..s + 3, d);
|
||||
}
|
||||
}
|
||||
MirroredContext {
|
||||
width: pw,
|
||||
height: ph,
|
||||
ring: RING,
|
||||
rgb: canvas,
|
||||
hole,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The tests' small pictures: a 48-px stride, a given feather.
|
||||
fn test_params(feather: usize) -> Params {
|
||||
Params {
|
||||
stride: 48,
|
||||
feather,
|
||||
..Params::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints every unknown pixel a fixed grey and copies the known ones,
|
||||
/// and remembers what it was shown.
|
||||
struct Flat {
|
||||
tile: usize,
|
||||
seen: Vec<(Vec<f32>, Vec<bool>)>,
|
||||
}
|
||||
|
||||
impl Inpainter for Flat {
|
||||
fn tile(&self) -> usize {
|
||||
self.tile
|
||||
}
|
||||
fn fill(&mut self, rgb: &[f32], known: &[bool]) -> Result<Vec<f32>, PanoError> {
|
||||
self.seen.push((rgb.to_vec(), known.to_vec()));
|
||||
Ok(rgb
|
||||
.chunks_exact(3)
|
||||
.zip(known)
|
||||
.flat_map(|(p, &k)| if k { [p[0], p[1], p[2]] } else { [0.5; 3] })
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn picture(w: usize, h: usize, border: usize) -> (Vec<f32>, Vec<bool>) {
|
||||
let mut rgb = vec![0.0; w * h * 3];
|
||||
let mut known = vec![false; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if y >= border && y < h - border {
|
||||
known[i] = true;
|
||||
rgb[i * 3] = x as f32 / w as f32;
|
||||
rgb[i * 3 + 1] = y as f32 / h as f32;
|
||||
rgb[i * 3 + 2] = 0.25;
|
||||
}
|
||||
}
|
||||
}
|
||||
(rgb, known)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_pixels_take_the_model_and_known_ones_do_not_move() {
|
||||
let (mut rgb, known) = picture(300, 200, 20);
|
||||
let before = rgb.clone();
|
||||
let mut model = Flat {
|
||||
tile: 64,
|
||||
seen: Vec::new(),
|
||||
};
|
||||
let tiles = fill_border(
|
||||
&mut rgb,
|
||||
300,
|
||||
200,
|
||||
&known,
|
||||
&mut model,
|
||||
test_params(0),
|
||||
&mut |_, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tiles > 0);
|
||||
for i in 0..300 * 200 {
|
||||
if known[i] {
|
||||
assert_eq!(&rgb[i * 3..i * 3 + 3], &before[i * 3..i * 3 + 3]);
|
||||
} else {
|
||||
for c in 0..3 {
|
||||
assert!((rgb[i * 3 + c] - 0.5).abs() < 1e-4, "pixel {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_model_is_shown_mirrored_context_not_black() {
|
||||
let (mut rgb, known) = picture(300, 200, 20);
|
||||
let mut model = Flat {
|
||||
tile: 64,
|
||||
seen: Vec::new(),
|
||||
};
|
||||
fill_border(
|
||||
&mut rgb,
|
||||
300,
|
||||
200,
|
||||
&known,
|
||||
&mut model,
|
||||
Params {
|
||||
mirror_depth: 48,
|
||||
..test_params(0)
|
||||
},
|
||||
&mut |_, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
for (tile_rgb, tile_known) in &model.seen {
|
||||
let known_non_black = tile_rgb
|
||||
.chunks_exact(3)
|
||||
.zip(tile_known)
|
||||
.filter(|(_, &k)| k)
|
||||
.any(|(p, _)| p.iter().any(|v| *v > 0.0));
|
||||
assert!(known_non_black);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_void_reaches_the_tile_edge_and_stays_unknown_beyond_the_band() {
|
||||
// A 150-tall hole above and below; bands of 96. With no ring the
|
||||
// first band's tiles sit at the picture's edge, so a tile's top
|
||||
// row is unknown, and the rows deeper than the band are unknown
|
||||
// too — not "known" coarse fill — exactly as the model was trained.
|
||||
let (mut rgb, known) = picture(200, 500, 150);
|
||||
let mut model = Flat {
|
||||
tile: 64,
|
||||
seen: Vec::new(),
|
||||
};
|
||||
fill_border(
|
||||
&mut rgb,
|
||||
200,
|
||||
500,
|
||||
&known,
|
||||
&mut model,
|
||||
Params {
|
||||
band: 96,
|
||||
..test_params(0)
|
||||
},
|
||||
&mut |_, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
// The first pass's tile at the picture's top edge is unknown
|
||||
// through and through: the hole is 150 deep, the tile 64, and
|
||||
// nothing beyond the band was presented as known. With a ring, or
|
||||
// with the far side shown as coarse fill, no tile is ever all hole.
|
||||
assert!(model.seen.iter().any(|(_, k)| k.iter().all(|&v| !v)));
|
||||
for i in 0..200 * 500 {
|
||||
if !known[i] {
|
||||
assert!((rgb[i * 3] - 0.5).abs() < 1e-4, "pixel {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn the_fine_passes_run_in_bands_after_the_coarse_one() {
|
||||
// A 150-tall hole above and below a picture: the coarse pass sees
|
||||
// it at a quarter; the fine passes need two bands of BAND pixels.
|
||||
let (mut rgb, known) = picture(200, 500, 150);
|
||||
let mut model = Flat {
|
||||
tile: 64,
|
||||
seen: Vec::new(),
|
||||
};
|
||||
fill_border(
|
||||
&mut rgb,
|
||||
200,
|
||||
500,
|
||||
&known,
|
||||
&mut model,
|
||||
Params {
|
||||
coarse: 4,
|
||||
band: 96,
|
||||
mirror_depth: 48,
|
||||
..test_params(0)
|
||||
},
|
||||
&mut |_, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(model.seen.len() > 4);
|
||||
// Every unknown pixel was reached.
|
||||
for i in 0..200 * 500 {
|
||||
if !known[i] {
|
||||
assert!((rgb[i * 3] - 0.5).abs() < 1e-4, "pixel {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_seam_ramps_from_real_to_invented_across_the_feather() {
|
||||
let (mut rgb, known) = picture(300, 200, 20);
|
||||
let before = rgb.clone();
|
||||
let mut model = Flat {
|
||||
tile: 64,
|
||||
seen: Vec::new(),
|
||||
};
|
||||
fill_border(
|
||||
&mut rgb,
|
||||
300,
|
||||
200,
|
||||
&known,
|
||||
&mut model,
|
||||
test_params(8),
|
||||
&mut |_, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
// Row 20 is the real edge; the margin runs to row 27. At the edge
|
||||
// the value is the model's grey, eight rows in it is the picture's.
|
||||
let at = |y: usize| rgb[(y * 300 + 150) * 3 + 2];
|
||||
assert!((at(20) - 0.5).abs() < 0.05, "{}", at(20));
|
||||
assert!((at(29) - before[(29 * 300 + 150) * 3 + 2]).abs() < 1e-4);
|
||||
let (lo, hi) = (at(20).min(at(29)), at(20).max(at(29)));
|
||||
assert!(
|
||||
at(23) > lo + 0.02 && at(23) < hi - 0.02,
|
||||
"{} between {lo} and {hi}",
|
||||
at(23)
|
||||
);
|
||||
// The hole itself is the model's.
|
||||
assert!((at(5) - 0.5).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erosion_shrinks_the_known_region_from_every_edge() {
|
||||
let (_, mut known) = picture(20, 20, 4);
|
||||
erode(&mut known, 20, 20, 2);
|
||||
assert!(known[8 * 20 + 10]);
|
||||
assert!(!known[5 * 20 + 10]);
|
||||
assert!(!known[8 * 20 + 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_counts_pixels_from_the_known_region() {
|
||||
let (_, known) = picture(20, 20, 4);
|
||||
let d = distance_to_known(&known, 20, 20);
|
||||
assert_eq!(d[4 * 20 + 10], 0.0);
|
||||
assert!((d[3 * 20 + 10] - 1.0).abs() < 1e-6);
|
||||
assert!((d[10] - 4.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fully_covered_picture_runs_nothing() {
|
||||
let (mut rgb, known) = picture(100, 100, 0);
|
||||
let mut model = Flat {
|
||||
tile: 64,
|
||||
seen: Vec::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
fill_border(
|
||||
&mut rgb,
|
||||
100,
|
||||
100,
|
||||
&known,
|
||||
&mut model,
|
||||
test_params(0),
|
||||
&mut |_, _| {}
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_context_mirrors_the_top_rows_upward() {
|
||||
let (rgb, known) = picture(40, 30, 5);
|
||||
let ctx = MirroredContext::build(&rgb, 40, 30, &known, 48, 64);
|
||||
let x = RING + 10;
|
||||
let first = RING + 5;
|
||||
for k in 1..=4 {
|
||||
let above = ((first - k) * ctx.width + x) * 3;
|
||||
let mirror = ((first + k) * ctx.width + x) * 3;
|
||||
assert_eq!(&ctx.rgb[above..above + 3], &ctx.rgb[mirror..mirror + 3]);
|
||||
}
|
||||
assert!(ctx.hole[(RING + 2) * ctx.width + x]);
|
||||
assert!(!ctx.hole[(RING - 2) * ctx.width + x]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mirror_reaches_no_deeper_than_its_band() {
|
||||
// A ridge 200 rows in must not appear in the ring: beyond the band
|
||||
// the reflection folds back towards the edge rather than on into
|
||||
// the picture.
|
||||
let (mut rgb, known) = picture(40, 400, 5);
|
||||
let ridge = 5 + 200;
|
||||
for x in 0..40 {
|
||||
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, 64);
|
||||
let x = RING + 10;
|
||||
for y in 0..RING + 5 {
|
||||
let p = (y * ctx.width + x) * 3;
|
||||
assert!(
|
||||
ctx.rgb[p] < 0.5,
|
||||
"row {y} of the ring shows the ridge ({:?})",
|
||||
&ctx.rgb[p..p + 3]
|
||||
);
|
||||
}
|
||||
assert_eq!(fold(0, 48), 0);
|
||||
assert_eq!(fold(48, 48), 48);
|
||||
assert_eq!(fold(58, 48), 38);
|
||||
assert_eq!(fold(96, 48), 0);
|
||||
assert_eq!(fold(99, 48), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
//! Pairwise geometry: a homography between two frames, found robustly.
|
||||
//!
|
||||
//! Two frames of a panorama are related by a rotation, and a rotation seen
|
||||
//! through one lens is a homography of the image plane — `H = K R Kᵀ⁻¹`. The
|
||||
//! homography is estimated first, from matches, because it does not need
|
||||
//! the focal length; the focal length is then *read off* it (§ below), and
|
||||
//! the rotation follows from both. This is the order Brown & Lowe (2007)
|
||||
//! and OpenCV's stitcher use, and it is what makes the pipeline work when
|
||||
//! EXIF says nothing about the lens.
|
||||
//!
|
||||
//! Coordinates throughout are **centred**: the principal point is the
|
||||
//! origin. The focal formulae assume it, and centring before the DLT also
|
||||
//! conditions the linear system — Hartley's normalisation, done once by the
|
||||
//! caller rather than inside every solve.
|
||||
|
||||
use crate::linalg::{DMat, Mat3, Vec3};
|
||||
|
||||
/// A point in one image, centred on the principal point.
|
||||
pub type Point = (f64, f64);
|
||||
|
||||
/// Apply a homography to a point.
|
||||
pub fn apply(h: &Mat3, p: Point) -> Option<Point> {
|
||||
let v = *h * Vec3::new(p.0, p.1, 1.0);
|
||||
if v.z().abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
Some((v.x() / v.z(), v.y() / v.z()))
|
||||
}
|
||||
|
||||
/// Least-squares homography from at least four correspondences by the
|
||||
/// direct linear transform, with `h33` fixed at 1.
|
||||
///
|
||||
/// Fixing `h33` turns the homogeneous 8×9 system into an ordinary 8-unknown
|
||||
/// least-squares problem that the normal equations and a Cholesky
|
||||
/// factorisation solve without an SVD. The one homography it cannot
|
||||
/// represent — `h33 = 0`, a point at the origin mapped to infinity — does
|
||||
/// not occur between overlapping frames of one scene.
|
||||
///
|
||||
/// The points should be scaled to order one (divide by the focal length or
|
||||
/// the image size) before calling: the normal equations square the
|
||||
/// conditioning, and pixel coordinates in the thousands make them singular
|
||||
/// in `f64`.
|
||||
pub fn dlt(pairs: &[(Point, Point)]) -> Option<Mat3> {
|
||||
if pairs.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
// Each pair gives two rows of A h = b with h = (h11..h32).
|
||||
// x' = (h11 x + h12 y + h13) / (h31 x + h32 y + 1)
|
||||
// → h11 x + h12 y + h13 - h31 x x' - h32 y x' = x'
|
||||
let mut ata = DMat::zeros(8);
|
||||
let mut atb = [0.0f64; 8];
|
||||
for &((x, y), (xp, yp)) in pairs {
|
||||
let rows: [([f64; 8], f64); 2] = [
|
||||
([x, y, 1.0, 0.0, 0.0, 0.0, -x * xp, -y * xp], xp),
|
||||
([0.0, 0.0, 0.0, x, y, 1.0, -x * yp, -y * yp], yp),
|
||||
];
|
||||
for (a, b) in rows {
|
||||
for i in 0..8 {
|
||||
atb[i] += a[i] * b;
|
||||
for j in 0..8 {
|
||||
ata[(i, j)] += a[i] * a[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let h = ata.solve_spd(&atb)?;
|
||||
Some(Mat3([
|
||||
[h[0], h[1], h[2]],
|
||||
[h[3], h[4], h[5]],
|
||||
[h[6], h[7], 1.0],
|
||||
]))
|
||||
}
|
||||
|
||||
/// A homography with the correspondences that agree with it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RobustHomography {
|
||||
pub h: Mat3,
|
||||
/// Indices into the input pairs.
|
||||
pub inliers: Vec<usize>,
|
||||
}
|
||||
|
||||
/// RANSAC over [`dlt`] on four-point samples, then a final least-squares
|
||||
/// fit over every inlier.
|
||||
///
|
||||
/// `threshold` is the reprojection distance, in the same units as the
|
||||
/// points, within which a pair counts as agreeing. The iteration count
|
||||
/// adapts to the inlier ratio found so far in the usual way, capped at
|
||||
/// `max_iterations`. `seed` makes a run reproducible (NFR-MRG-2): the
|
||||
/// sampling is a small linear congruential generator, not the system's.
|
||||
pub fn ransac_homography(
|
||||
pairs: &[(Point, Point)],
|
||||
threshold: f64,
|
||||
max_iterations: usize,
|
||||
seed: u64,
|
||||
) -> Option<RobustHomography> {
|
||||
if pairs.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let n = pairs.len();
|
||||
let thr2 = threshold * threshold;
|
||||
let mut rng = Lcg(seed);
|
||||
let mut best: Option<(Vec<usize>, Mat3)> = None;
|
||||
let mut iterations = max_iterations;
|
||||
let mut i = 0;
|
||||
while i < iterations {
|
||||
i += 1;
|
||||
let sample = rng.distinct4(n);
|
||||
let Some(h) = dlt(&sample.map(|k| pairs[k])) else {
|
||||
continue;
|
||||
};
|
||||
let inliers: Vec<usize> = (0..n).filter(|&k| agrees(&h, pairs[k], thr2)).collect();
|
||||
if best.as_ref().is_none_or(|(b, _)| inliers.len() > b.len()) {
|
||||
// Adapt: enough iterations to have drawn one all-inlier sample
|
||||
// with probability 0.99, given the ratio seen so far.
|
||||
let w = inliers.len() as f64 / n as f64;
|
||||
let p_all = w.powi(4);
|
||||
if p_all > 0.0 && p_all < 1.0 {
|
||||
let needed = ((1.0 - 0.99f64).ln() / (1.0 - p_all).ln()).ceil() as usize;
|
||||
iterations = iterations.min(needed.max(i + 1));
|
||||
}
|
||||
best = Some((inliers, h));
|
||||
}
|
||||
}
|
||||
let (inliers, h) = best?;
|
||||
if inliers.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
// Refit on every inlier, and keep the refit only if it did not lose
|
||||
// support — a least-squares fit over a set with a few borderline points
|
||||
// can be pulled off the consensus the sample found.
|
||||
let refit: Vec<(Point, Point)> = inliers.iter().map(|&k| pairs[k]).collect();
|
||||
let h = match dlt(&refit) {
|
||||
Some(r) => {
|
||||
let count = (0..n).filter(|&k| agrees(&r, pairs[k], thr2)).count();
|
||||
if count >= inliers.len() {
|
||||
r
|
||||
} else {
|
||||
h
|
||||
}
|
||||
}
|
||||
None => h,
|
||||
};
|
||||
let inliers: Vec<usize> = (0..n).filter(|&k| agrees(&h, pairs[k], thr2)).collect();
|
||||
Some(RobustHomography { h, inliers })
|
||||
}
|
||||
|
||||
fn agrees(h: &Mat3, (p, q): (Point, Point), thr2: f64) -> bool {
|
||||
match apply(h, p) {
|
||||
Some((x, y)) => {
|
||||
let (dx, dy) = (x - q.0, y - q.1);
|
||||
dx * dx + dy * dy <= thr2
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The focal length a homography implies, if it implies one.
|
||||
///
|
||||
/// For `H = K R K⁻¹` with `K = diag(f, f, 1)` and the principal point at the
|
||||
/// origin, the orthonormality of `R` gives two independent estimates of `f²`
|
||||
/// from the first two rows and two from the first two columns; each is
|
||||
/// taken where it is positive and the better-conditioned of the pair is
|
||||
/// chosen, as OpenCV's `focalsFromHomography` does. The geometric mean of
|
||||
/// the row and column estimates is returned. `None` when the homography is
|
||||
/// too close to a pure translation to say anything — every estimate is then
|
||||
/// a ratio of small numbers.
|
||||
pub fn focal_from_homography(h: &Mat3) -> Option<f64> {
|
||||
let m = h.0;
|
||||
let (h00, h01, h02) = (m[0][0], m[0][1], m[0][2]);
|
||||
let (h10, h11, h12) = (m[1][0], m[1][1], m[1][2]);
|
||||
let (h20, h21) = (m[2][0], m[2][1]);
|
||||
|
||||
let pick = |mut v1: f64, mut v2: f64, d1: f64, d2: f64| -> Option<f64> {
|
||||
if v1 < v2 {
|
||||
std::mem::swap(&mut v1, &mut v2);
|
||||
}
|
||||
if v1 > 0.0 && v2 > 0.0 {
|
||||
Some((if d1.abs() > d2.abs() { v1 } else { v2 }).sqrt())
|
||||
} else if v1 > 0.0 {
|
||||
Some(v1.sqrt())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// From the third row.
|
||||
let d1 = h20 * h21;
|
||||
let d2 = (h21 - h20) * (h21 + h20);
|
||||
let f1 = if d1.abs() > 1e-12 || d2.abs() > 1e-12 {
|
||||
let v1 = if d1.abs() > 1e-12 {
|
||||
-(h00 * h01 + h10 * h11) / d1
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
let v2 = if d2.abs() > 1e-12 {
|
||||
(h00 * h00 + h10 * h10 - h01 * h01 - h11 * h11) / d2
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
pick(nan_to_neg(v1), nan_to_neg(v2), d1, d2)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// From the third column.
|
||||
let d1 = h00 * h10 + h01 * h11;
|
||||
let d2 = h00 * h00 + h01 * h01 - h10 * h10 - h11 * h11;
|
||||
let f0 = if d1.abs() > 1e-12 || d2.abs() > 1e-12 {
|
||||
let v1 = if d1.abs() > 1e-12 {
|
||||
-h02 * h12 / d1
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
let v2 = if d2.abs() > 1e-12 {
|
||||
(h12 * h12 - h02 * h02) / d2
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
pick(nan_to_neg(v1), nan_to_neg(v2), d1, d2)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match (f0, f1) {
|
||||
(Some(a), Some(b)) => Some((a * b).sqrt()),
|
||||
(Some(a), None) | (None, Some(a)) => Some(a),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn nan_to_neg(v: f64) -> f64 {
|
||||
if v.is_finite() {
|
||||
v
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The rotation a homography encodes for a known focal length:
|
||||
/// `R = K⁻¹ H K`, re-orthonormalised, with the scale of `H` divided out.
|
||||
pub fn rotation_from_homography(h: &Mat3, f: f64) -> Mat3 {
|
||||
let m = h.0;
|
||||
// K⁻¹ H K with K = diag(f, f, 1): scale the third row by f and the
|
||||
// third column by 1/f.
|
||||
let r = Mat3([
|
||||
[m[0][0], m[0][1], m[0][2] / f],
|
||||
[m[1][0], m[1][1], m[1][2] / f],
|
||||
[m[2][0] * f, m[2][1] * f, m[2][2]],
|
||||
]);
|
||||
r.orthonormalised()
|
||||
}
|
||||
|
||||
/// A small deterministic generator for RANSAC's samples.
|
||||
struct Lcg(u64);
|
||||
|
||||
impl Lcg {
|
||||
fn next(&mut self) -> u64 {
|
||||
// Knuth's MMIX constants.
|
||||
self.0 = self
|
||||
.0
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.0 >> 33
|
||||
}
|
||||
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n as u64) as usize
|
||||
}
|
||||
|
||||
fn distinct4(&mut self, n: usize) -> [usize; 4] {
|
||||
let mut s = [0usize; 4];
|
||||
for i in 0..4 {
|
||||
loop {
|
||||
let k = self.below(n);
|
||||
if !s[..i].contains(&k) {
|
||||
s[i] = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Points under a known rotation seen through a known focal length,
|
||||
/// in centred image coordinates scaled by that focal length.
|
||||
fn synthetic(f: f64, r: Mat3, n: usize, noise: f64, seed: u64) -> Vec<(Point, Point)> {
|
||||
let mut rng = Lcg(seed);
|
||||
let mut out = Vec::new();
|
||||
while out.len() < n {
|
||||
// A point on the first image plane, within ±0.3 f of centre.
|
||||
let x = (rng.below(6001) as f64 - 3000.0) / 10000.0;
|
||||
let y = (rng.below(4001) as f64 - 2000.0) / 10000.0;
|
||||
let b = Vec3::new(x, y, 1.0);
|
||||
let v = r * b;
|
||||
if v.z() <= 0.2 {
|
||||
continue;
|
||||
}
|
||||
let nx = (rng.below(2001) as f64 - 1000.0) / 1000.0 * noise;
|
||||
let ny = (rng.below(2001) as f64 - 1000.0) / 1000.0 * noise;
|
||||
out.push(((x, y), (v.x() / v.z() + nx, v.y() / v.z() + ny)));
|
||||
}
|
||||
let _ = f;
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dlt_recovers_a_known_homography_exactly() {
|
||||
let r = Mat3::exp(Vec3::new(0.05, 0.3, 0.02));
|
||||
let pairs = synthetic(1.0, r, 12, 0.0, 1);
|
||||
let h = dlt(&pairs).expect("solvable");
|
||||
for &(p, q) in &pairs {
|
||||
let (x, y) = apply(&h, p).unwrap();
|
||||
assert!((x - q.0).abs() < 1e-9 && (y - q.1).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ransac_finds_the_consensus_among_outliers() {
|
||||
let r = Mat3::exp(Vec3::new(-0.02, 0.25, 0.01));
|
||||
let mut pairs = synthetic(1.0, r, 60, 0.0005, 2);
|
||||
// Forty outliers: wrong second point.
|
||||
let mut rng = Lcg(9);
|
||||
for _ in 0..40 {
|
||||
let k = rng.below(60);
|
||||
let (p, _) = pairs[k];
|
||||
pairs.push((p, ((rng.below(1000) as f64 - 500.0) / 1000.0, 0.1)));
|
||||
}
|
||||
let robust = ransac_homography(&pairs, 0.003, 500, 3).expect("found");
|
||||
assert!(
|
||||
robust.inliers.len() >= 55,
|
||||
"{} inliers",
|
||||
robust.inliers.len()
|
||||
);
|
||||
assert!(robust.inliers.iter().all(|&k| k < 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focal_is_read_off_a_rotation_homography() {
|
||||
// H in *pixel* coordinates for f = 1400: K R K⁻¹.
|
||||
let f = 1400.0;
|
||||
let r = Mat3::exp(Vec3::new(0.03, 0.35, -0.01));
|
||||
let m = r.0;
|
||||
let h = Mat3([
|
||||
[m[0][0], m[0][1], m[0][2] * f],
|
||||
[m[1][0], m[1][1], m[1][2] * f],
|
||||
[m[2][0] / f, m[2][1] / f, m[2][2]],
|
||||
]);
|
||||
let est = focal_from_homography(&h).expect("estimable");
|
||||
assert!((est - f).abs() / f < 1e-6, "{est}");
|
||||
let back = rotation_from_homography(&h, f);
|
||||
for (row, truth) in back.0.iter().zip(&m) {
|
||||
for (a, b) in row.iter().zip(truth) {
|
||||
assert!((a - b).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_identity_implies_no_focal() {
|
||||
assert!(focal_from_homography(&Mat3::IDENTITY).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! The grayscale proxy a detector reads.
|
||||
//!
|
||||
//! Alignment runs on proxies (FR-MRG-7) — a detector at 1024 px sees
|
||||
//! everything it needs, and the full-resolution frames never leave the GPU.
|
||||
//! This is that proxy: one channel, `f32` in `0.0..=1.0`, upright, and no
|
||||
//! larger than the detector's fixed input.
|
||||
|
||||
/// A single-channel image, row-major, values in `0.0..=1.0`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Gray {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub data: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Gray {
|
||||
/// From tightly packed 8-bit RGBA, by the Rec. 709 luma weights.
|
||||
///
|
||||
/// The proxy is what a detector looks at, not what the photographer
|
||||
/// sees, so which luma is used matters less than that it is the same one
|
||||
/// for every frame — a keypoint's descriptor must not change between two
|
||||
/// frames because they were converted differently.
|
||||
pub fn from_rgba8(rgba: &[u8], width: usize, height: usize) -> Gray {
|
||||
let n = width * height;
|
||||
assert!(
|
||||
rgba.len() >= n * 4,
|
||||
"rgba buffer is short for {width}×{height}"
|
||||
);
|
||||
let data = rgba[..n * 4]
|
||||
.chunks_exact(4)
|
||||
.map(|p| {
|
||||
(0.2126 * f32::from(p[0]) + 0.7152 * f32::from(p[1]) + 0.0722 * f32::from(p[2]))
|
||||
/ 255.0
|
||||
})
|
||||
.collect();
|
||||
Gray {
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an EXIF orientation so the image is upright.
|
||||
///
|
||||
/// Learned detectors are not rotation-invariant — a descriptor of a
|
||||
/// feature seen sideways is a different descriptor — and a portrait set
|
||||
/// (the 6D fixture is one) would match poorly or not at all fed as
|
||||
/// stored. The camera says which way is up; the proxy is turned before
|
||||
/// anything looks at it, and the composite is written upright.
|
||||
///
|
||||
/// The value is the EXIF `Orientation` tag. Mirrored values (2, 4, 5, 7)
|
||||
/// are not produced by any camera and are treated as their unmirrored
|
||||
/// counterparts.
|
||||
pub fn oriented(&self, orientation: u16) -> Gray {
|
||||
match orientation {
|
||||
3 | 4 => self.rotated_180(),
|
||||
6 | 5 => self.rotated_90_cw(),
|
||||
8 | 7 => self.rotated_90_ccw(),
|
||||
_ => self.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rotated_90_cw(&self) -> Gray {
|
||||
let (w, h) = (self.width, self.height);
|
||||
let mut data = vec![0.0; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Source (x, y) lands at (h - 1 - y, x) in an h-wide image.
|
||||
data[x * h + (h - 1 - y)] = self.data[y * w + x];
|
||||
}
|
||||
}
|
||||
Gray {
|
||||
width: h,
|
||||
height: w,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
fn rotated_90_ccw(&self) -> Gray {
|
||||
let (w, h) = (self.width, self.height);
|
||||
let mut data = vec![0.0; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Source (x, y) lands at (y, w - 1 - x) in an h-wide image.
|
||||
data[(w - 1 - x) * h + y] = self.data[y * w + x];
|
||||
}
|
||||
}
|
||||
Gray {
|
||||
width: h,
|
||||
height: w,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
fn rotated_180(&self) -> Gray {
|
||||
let mut data = self.data.clone();
|
||||
data.reverse();
|
||||
Gray {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resample to exactly `width × height` by area averaging on the way
|
||||
/// down and bilinear on the way up.
|
||||
///
|
||||
/// Area averaging, not point sampling, for a reduction: a 5472 px frame
|
||||
/// to 1024 is a factor of five, and picking one source pixel in
|
||||
/// twenty-five aliases every edge the detector is looking for.
|
||||
pub fn resampled(&self, width: usize, height: usize) -> Gray {
|
||||
if width == self.width && height == self.height {
|
||||
return self.clone();
|
||||
}
|
||||
let mut data = vec![0.0f32; width * height];
|
||||
let sx = self.width as f64 / width as f64;
|
||||
let sy = self.height as f64 / height as f64;
|
||||
if sx >= 1.0 && sy >= 1.0 {
|
||||
for oy in 0..height {
|
||||
let y0 = (oy as f64 * sy) as usize;
|
||||
let y1 = (((oy + 1) as f64 * sy) as usize).clamp(y0 + 1, self.height);
|
||||
for ox in 0..width {
|
||||
let x0 = (ox as f64 * sx) as usize;
|
||||
let x1 = (((ox + 1) as f64 * sx) as usize).clamp(x0 + 1, self.width);
|
||||
let mut sum = 0.0f32;
|
||||
for y in y0..y1 {
|
||||
let row = &self.data[y * self.width..(y + 1) * self.width];
|
||||
sum += row[x0..x1].iter().sum::<f32>();
|
||||
}
|
||||
data[oy * width + ox] = sum / ((y1 - y0) * (x1 - x0)) as f32;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for oy in 0..height {
|
||||
let fy = ((oy as f64 + 0.5) * sy - 0.5).max(0.0);
|
||||
let y0 = (fy as usize).min(self.height - 1);
|
||||
let y1 = (y0 + 1).min(self.height - 1);
|
||||
let ty = (fy - y0 as f64) as f32;
|
||||
for ox in 0..width {
|
||||
let fx = ((ox as f64 + 0.5) * sx - 0.5).max(0.0);
|
||||
let x0 = (fx as usize).min(self.width - 1);
|
||||
let x1 = (x0 + 1).min(self.width - 1);
|
||||
let tx = (fx - x0 as f64) as f32;
|
||||
let p = |x: usize, y: usize| self.data[y * self.width + x];
|
||||
let top = p(x0, y0) * (1.0 - tx) + p(x1, y0) * tx;
|
||||
let bot = p(x0, y1) * (1.0 - tx) + p(x1, y1) * tx;
|
||||
data[oy * width + ox] = top * (1.0 - ty) + bot * ty;
|
||||
}
|
||||
}
|
||||
}
|
||||
Gray {
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale so the image fits inside `max_width × max_height`, preserving
|
||||
/// aspect, never enlarging. Returns the image and the scale applied,
|
||||
/// which is what maps a proxy keypoint back to the source.
|
||||
pub fn fitted(&self, max_width: usize, max_height: usize) -> (Gray, f64) {
|
||||
let scale = (max_width as f64 / self.width as f64)
|
||||
.min(max_height as f64 / self.height as f64)
|
||||
.min(1.0);
|
||||
let w = ((self.width as f64 * scale).round() as usize).max(1);
|
||||
let h = ((self.height as f64 * scale).round() as usize).max(1);
|
||||
(self.resampled(w, h), w as f64 / self.width as f64)
|
||||
}
|
||||
|
||||
/// Copy into the top-left of a `width × height` canvas, zero elsewhere.
|
||||
///
|
||||
/// The detector's input is a fixed shape (S15.2), and a frame that fits
|
||||
/// inside it is padded rather than stretched: stretching changes the
|
||||
/// aspect and with it every descriptor.
|
||||
pub fn padded(&self, width: usize, height: usize) -> Gray {
|
||||
assert!(self.width <= width && self.height <= height);
|
||||
let mut data = vec![0.0; width * height];
|
||||
for y in 0..self.height {
|
||||
data[y * width..y * width + self.width]
|
||||
.copy_from_slice(&self.data[y * self.width..(y + 1) * self.width]);
|
||||
}
|
||||
Gray {
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ramp(w: usize, h: usize) -> Gray {
|
||||
Gray {
|
||||
width: w,
|
||||
height: h,
|
||||
data: (0..w * h).map(|i| i as f32).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotating_four_quarter_turns_is_the_identity() {
|
||||
let g = ramp(5, 3);
|
||||
let mut r = g.clone();
|
||||
for _ in 0..4 {
|
||||
r = r.rotated_90_cw();
|
||||
}
|
||||
assert_eq!(r, g);
|
||||
assert_eq!(g.rotated_90_cw().rotated_90_ccw(), g);
|
||||
assert_eq!(g.rotated_180().rotated_180(), g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clockwise_turn_moves_the_top_left_to_the_top_right() {
|
||||
// 2×3 image, pixel values by position.
|
||||
let g = ramp(2, 3);
|
||||
let r = g.rotated_90_cw();
|
||||
assert_eq!((r.width, r.height), (3, 2));
|
||||
// Top-left of source (value 0) is at top-right of result.
|
||||
assert_eq!(r.data[2], 0.0);
|
||||
// Bottom-left of source (value 4) is at top-left of result.
|
||||
assert_eq!(r.data[0], 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_8_is_a_counter_clockwise_turn() {
|
||||
let g = ramp(4, 2);
|
||||
assert_eq!(g.oriented(8), g.rotated_90_ccw());
|
||||
assert_eq!(g.oriented(6), g.rotated_90_cw());
|
||||
assert_eq!(g.oriented(1), g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downsampling_by_two_averages_blocks() {
|
||||
let g = Gray {
|
||||
width: 4,
|
||||
height: 2,
|
||||
data: vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0],
|
||||
};
|
||||
let r = g.resampled(2, 1);
|
||||
assert_eq!(r.data, vec![2.5, 4.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_never_enlarges_and_reports_the_scale() {
|
||||
let g = ramp(100, 50);
|
||||
let (f, s) = g.fitted(1024, 768);
|
||||
assert_eq!((f.width, f.height), (100, 50));
|
||||
assert_eq!(s, 1.0);
|
||||
let (f, s) = g.fitted(50, 50);
|
||||
assert_eq!((f.width, f.height), (50, 25));
|
||||
assert_eq!(s, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_places_the_image_at_the_origin() {
|
||||
let g = ramp(2, 2);
|
||||
let p = g.padded(3, 3);
|
||||
assert_eq!(p.data, vec![0.0, 1.0, 0.0, 2.0, 3.0, 0.0, 0.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! TRACES: FR-MRG-1 | FR-MRG-10
|
||||
//! Panorama geometry — from several frames to the rotations that relate
|
||||
//! them, and the projections that lay them out.
|
||||
//!
|
||||
//! This is the CPU half of a merge (FR-MRG-10): keypoints, matching, the
|
||||
//! rotation solve and the choice of output surface. The per-pixel half —
|
||||
//! rendering, warping, seams, blending — is the GPU's and lives in
|
||||
//! `dr-gpu`, driven from above; nothing here touches a full-resolution
|
||||
//! pixel. The split is the whole design (panorama.md §4): everything in
|
||||
//! this crate is bounded by the number of frames, not the size of the
|
||||
//! composite, and runs on proxies.
|
||||
//!
|
||||
//! # Layout
|
||||
//!
|
||||
//! - [`image`] — the grayscale proxy a detector reads: oriented, resampled.
|
||||
//! - [`features`] — keypoints with descriptors, and the XFeat decoder.
|
||||
//! - [`xfeat`] — the network under tract (feature `xfeat`).
|
||||
//! - [`matching`] — mutual nearest neighbours.
|
||||
//! - [`homography`] — a robust pairwise homography, the focal length read
|
||||
//! off it, and the rotation it implies.
|
||||
//! - [`bundle`] — every rotation and the focal length refined together.
|
||||
//! - [`align`] — the whole thing, from features to cameras, honest about
|
||||
//! what it could not place.
|
||||
//! - [`projection`] — perspective, cylindrical, spherical.
|
||||
//! - [`linalg`] — the small dense algebra all of it uses.
|
||||
//!
|
||||
//! # What it depends on
|
||||
//!
|
||||
//! Nothing, without the `xfeat` feature: the geometry is pure Rust with
|
||||
//! hand-rolled linear algebra (`linalg` says why) so that it tests without
|
||||
//! a model, a GPU or a device, on synthetic sets whose answer is known
|
||||
//! exactly. With the feature it adds the same `ort`-over-tract runtime the
|
||||
//! rest of the application already carries.
|
||||
|
||||
pub mod align;
|
||||
pub mod bundle;
|
||||
pub mod features;
|
||||
pub mod fill;
|
||||
pub mod homography;
|
||||
pub mod image;
|
||||
pub mod linalg;
|
||||
pub mod matching;
|
||||
#[cfg(feature = "xfeat")]
|
||||
pub mod migan;
|
||||
pub mod projection;
|
||||
#[cfg(feature = "xfeat")]
|
||||
pub mod xfeat;
|
||||
|
||||
pub use align::{align, AlignOptions, Alignment, Link, Unaligned};
|
||||
pub use bundle::Cameras;
|
||||
pub use features::{Features, Keypoint};
|
||||
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
|
||||
pub use image::Gray;
|
||||
pub use projection::Projection;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PanoError {
|
||||
#[error("bad input: {0}")]
|
||||
Input(String),
|
||||
#[error("geometry: {0}")]
|
||||
Geometry(String),
|
||||
#[error("model: {0}")]
|
||||
Model(String),
|
||||
#[error("could not read the model: {0}")]
|
||||
ModelRead(#[source] std::io::Error),
|
||||
#[cfg(feature = "xfeat")]
|
||||
#[error("inference: {0}")]
|
||||
Inference(#[source] ort::Error),
|
||||
}
|
||||
|
||||
#[cfg(feature = "xfeat")]
|
||||
impl From<ort::Error> for PanoError {
|
||||
fn from(e: ort::Error) -> Self {
|
||||
PanoError::Inference(e)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "xfeat")]
|
||||
impl From<dr_inference_engine::Error> for PanoError {
|
||||
fn from(e: dr_inference_engine::Error) -> Self {
|
||||
match e {
|
||||
dr_inference_engine::Error::Inference(e) => PanoError::Inference(e),
|
||||
dr_inference_engine::Error::Io(e) => PanoError::ModelRead(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! The small dense linear algebra the geometry needs, and nothing more.
|
||||
//!
|
||||
//! Hand-rolled rather than pulled in, and the decision was made on purpose
|
||||
//! (2026-09-19): the largest system this crate ever solves is a rotation
|
||||
//! per frame plus one focal length — forty unknowns for a dozen frames —
|
||||
//! and everything else is three-vectors. A general linear-algebra crate
|
||||
//! would be the largest dependency in `dr-pano` by an order of magnitude,
|
||||
//! for a Cholesky factorisation that is thirty lines.
|
||||
//!
|
||||
//! `f64` throughout. The geometry is solved once per merge on a few thousand
|
||||
//! matches; there is no reason to give up precision for speed here, and the
|
||||
//! bundle adjustment's normal equations are poorly conditioned enough near
|
||||
//! convergence that `f32` would stall it.
|
||||
|
||||
use std::ops::{Add, Index, IndexMut, Mul, Neg, Sub};
|
||||
|
||||
/// A vector in three dimensions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct Vec3(pub [f64; 3]);
|
||||
|
||||
impl Vec3 {
|
||||
pub const fn new(x: f64, y: f64, z: f64) -> Self {
|
||||
Vec3([x, y, z])
|
||||
}
|
||||
|
||||
pub fn dot(self, o: Vec3) -> f64 {
|
||||
self.0[0] * o.0[0] + self.0[1] * o.0[1] + self.0[2] * o.0[2]
|
||||
}
|
||||
|
||||
pub fn cross(self, o: Vec3) -> Vec3 {
|
||||
Vec3([
|
||||
self.0[1] * o.0[2] - self.0[2] * o.0[1],
|
||||
self.0[2] * o.0[0] - self.0[0] * o.0[2],
|
||||
self.0[0] * o.0[1] - self.0[1] * o.0[0],
|
||||
])
|
||||
}
|
||||
|
||||
pub fn norm(self) -> f64 {
|
||||
self.dot(self).sqrt()
|
||||
}
|
||||
|
||||
/// The unit vector along `self`, or `self` unchanged if it is zero.
|
||||
pub fn normalised(self) -> Vec3 {
|
||||
let n = self.norm();
|
||||
if n > 0.0 {
|
||||
self * (1.0 / n)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn x(self) -> f64 {
|
||||
self.0[0]
|
||||
}
|
||||
pub fn y(self) -> f64 {
|
||||
self.0[1]
|
||||
}
|
||||
pub fn z(self) -> f64 {
|
||||
self.0[2]
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn add(self, o: Vec3) -> Vec3 {
|
||||
Vec3([self.0[0] + o.0[0], self.0[1] + o.0[1], self.0[2] + o.0[2]])
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn sub(self, o: Vec3) -> Vec3 {
|
||||
Vec3([self.0[0] - o.0[0], self.0[1] - o.0[1], self.0[2] - o.0[2]])
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<f64> for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn mul(self, s: f64) -> Vec3 {
|
||||
Vec3([self.0[0] * s, self.0[1] * s, self.0[2] * s])
|
||||
}
|
||||
}
|
||||
|
||||
impl Neg for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn neg(self) -> Vec3 {
|
||||
Vec3([-self.0[0], -self.0[1], -self.0[2]])
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3×3 matrix, row-major.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Mat3(pub [[f64; 3]; 3]);
|
||||
|
||||
impl Mat3 {
|
||||
pub const IDENTITY: Mat3 = Mat3([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
|
||||
/// The matrix whose columns are `a`, `b`, `c`.
|
||||
pub fn from_columns(a: Vec3, b: Vec3, c: Vec3) -> Mat3 {
|
||||
Mat3([
|
||||
[a.0[0], b.0[0], c.0[0]],
|
||||
[a.0[1], b.0[1], c.0[1]],
|
||||
[a.0[2], b.0[2], c.0[2]],
|
||||
])
|
||||
}
|
||||
|
||||
pub fn transpose(self) -> Mat3 {
|
||||
let m = self.0;
|
||||
Mat3([
|
||||
[m[0][0], m[1][0], m[2][0]],
|
||||
[m[0][1], m[1][1], m[2][1]],
|
||||
[m[0][2], m[1][2], m[2][2]],
|
||||
])
|
||||
}
|
||||
|
||||
pub fn column(self, i: usize) -> Vec3 {
|
||||
Vec3([self.0[0][i], self.0[1][i], self.0[2][i]])
|
||||
}
|
||||
|
||||
pub fn trace(self) -> f64 {
|
||||
self.0[0][0] + self.0[1][1] + self.0[2][2]
|
||||
}
|
||||
|
||||
/// The rotation about `axis` (any length) by `angle` radians — Rodrigues.
|
||||
pub fn rotation(axis: Vec3, angle: f64) -> Mat3 {
|
||||
let k = axis.normalised();
|
||||
let (s, c) = angle.sin_cos();
|
||||
let t = 1.0 - c;
|
||||
let (x, y, z) = (k.0[0], k.0[1], k.0[2]);
|
||||
Mat3([
|
||||
[t * x * x + c, t * x * y - s * z, t * x * z + s * y],
|
||||
[t * x * y + s * z, t * y * y + c, t * y * z - s * x],
|
||||
[t * x * z - s * y, t * y * z + s * x, t * z * z + c],
|
||||
])
|
||||
}
|
||||
|
||||
/// The rotation whose axis-angle vector is `w` (direction is the axis,
|
||||
/// length is the angle). The exponential map; [`Self::log`] inverts it.
|
||||
pub fn exp(w: Vec3) -> Mat3 {
|
||||
let angle = w.norm();
|
||||
if angle < 1e-12 {
|
||||
// First-order: I + [w]×, which is what the limit is and avoids
|
||||
// dividing by the angle.
|
||||
let (x, y, z) = (w.0[0], w.0[1], w.0[2]);
|
||||
return Mat3([[1.0, -z, y], [z, 1.0, -x], [-y, x, 1.0]]);
|
||||
}
|
||||
Mat3::rotation(w, angle)
|
||||
}
|
||||
|
||||
/// The axis-angle vector of a rotation matrix. Inverse of [`Self::exp`].
|
||||
pub fn log(self) -> Vec3 {
|
||||
let m = self.0;
|
||||
let cos = ((self.trace() - 1.0) * 0.5).clamp(-1.0, 1.0);
|
||||
let axis = Vec3([m[2][1] - m[1][2], m[0][2] - m[2][0], m[1][0] - m[0][1]]);
|
||||
if cos > 1.0 - 1e-6 {
|
||||
// Small angle: `acos` near 1 loses everything below ~1e-8 to
|
||||
// rounding, but the antisymmetric part is `2 sin θ · axis` and
|
||||
// keeps it. First order, exact to the precision that matters.
|
||||
return axis * 0.5;
|
||||
}
|
||||
let angle = cos.acos();
|
||||
if angle > std::f64::consts::PI - 1e-6 {
|
||||
// Near π the antisymmetric part vanishes; take the axis from the
|
||||
// symmetric part instead. Rare for a panorama, but the solver may
|
||||
// pass through it on a bad start and must not return NaN.
|
||||
let d = Vec3([
|
||||
((m[0][0] + 1.0) * 0.5).max(0.0).sqrt(),
|
||||
((m[1][1] + 1.0) * 0.5).max(0.0).sqrt(),
|
||||
((m[2][2] + 1.0) * 0.5).max(0.0).sqrt(),
|
||||
]);
|
||||
return d.normalised() * angle;
|
||||
}
|
||||
axis * (angle / (2.0 * angle.sin()))
|
||||
}
|
||||
|
||||
/// Re-orthonormalise a matrix that has drifted from a rotation through
|
||||
/// accumulated products. Gram–Schmidt on the columns; cheap and adequate
|
||||
/// for drift of the size floating-point products produce.
|
||||
pub fn orthonormalised(self) -> Mat3 {
|
||||
let a = self.column(0).normalised();
|
||||
let b = (self.column(1) - a * a.dot(self.column(1))).normalised();
|
||||
let c = a.cross(b);
|
||||
Mat3::from_columns(a, b, c)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<Vec3> for Mat3 {
|
||||
type Output = Vec3;
|
||||
fn mul(self, v: Vec3) -> Vec3 {
|
||||
let m = self.0;
|
||||
Vec3([
|
||||
m[0][0] * v.0[0] + m[0][1] * v.0[1] + m[0][2] * v.0[2],
|
||||
m[1][0] * v.0[0] + m[1][1] * v.0[1] + m[1][2] * v.0[2],
|
||||
m[2][0] * v.0[0] + m[2][1] * v.0[1] + m[2][2] * v.0[2],
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul for Mat3 {
|
||||
type Output = Mat3;
|
||||
fn mul(self, o: Mat3) -> Mat3 {
|
||||
let mut r = [[0.0; 3]; 3];
|
||||
for (i, row) in r.iter_mut().enumerate() {
|
||||
for (j, cell) in row.iter_mut().enumerate() {
|
||||
*cell = (0..3).map(|k| self.0[i][k] * o.0[k][j]).sum();
|
||||
}
|
||||
}
|
||||
Mat3(r)
|
||||
}
|
||||
}
|
||||
|
||||
/// A dense square matrix, for the normal equations.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DMat {
|
||||
n: usize,
|
||||
data: Vec<f64>,
|
||||
}
|
||||
|
||||
impl DMat {
|
||||
pub fn zeros(n: usize) -> DMat {
|
||||
DMat {
|
||||
n,
|
||||
data: vec![0.0; n * n],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn n(&self) -> usize {
|
||||
self.n
|
||||
}
|
||||
|
||||
/// Solve `self · x = b` for a symmetric positive-definite `self` by
|
||||
/// Cholesky factorisation. `None` if the matrix is not positive definite,
|
||||
/// which for the normal equations means the problem is not determined by
|
||||
/// the data — a frame with no matches, for instance — and the caller
|
||||
/// should say so rather than proceed.
|
||||
///
|
||||
/// Destroys neither input: the factor is built in a copy. The systems
|
||||
/// here are at most a few dozen unknowns and the copy is nothing.
|
||||
pub fn solve_spd(&self, b: &[f64]) -> Option<Vec<f64>> {
|
||||
let n = self.n;
|
||||
debug_assert_eq!(b.len(), n);
|
||||
let mut l = vec![0.0; n * n];
|
||||
for j in 0..n {
|
||||
let mut d = self[(j, j)];
|
||||
for k in 0..j {
|
||||
d -= l[j * n + k] * l[j * n + k];
|
||||
}
|
||||
if d <= 0.0 || !d.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let djj = d.sqrt();
|
||||
l[j * n + j] = djj;
|
||||
for i in j + 1..n {
|
||||
let mut s = self[(i, j)];
|
||||
for k in 0..j {
|
||||
s -= l[i * n + k] * l[j * n + k];
|
||||
}
|
||||
l[i * n + j] = s / djj;
|
||||
}
|
||||
}
|
||||
// Forward: L y = b.
|
||||
let mut y = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
let mut s = b[i];
|
||||
for k in 0..i {
|
||||
s -= l[i * n + k] * y[k];
|
||||
}
|
||||
y[i] = s / l[i * n + i];
|
||||
}
|
||||
// Back: Lᵀ x = y.
|
||||
let mut x = vec![0.0; n];
|
||||
for i in (0..n).rev() {
|
||||
let mut s = y[i];
|
||||
for k in i + 1..n {
|
||||
s -= l[k * n + i] * x[k];
|
||||
}
|
||||
x[i] = s / l[i * n + i];
|
||||
}
|
||||
Some(x)
|
||||
}
|
||||
}
|
||||
|
||||
impl Index<(usize, usize)> for DMat {
|
||||
type Output = f64;
|
||||
fn index(&self, (i, j): (usize, usize)) -> &f64 {
|
||||
&self.data[i * self.n + j]
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexMut<(usize, usize)> for DMat {
|
||||
fn index_mut(&mut self, (i, j): (usize, usize)) -> &mut f64 {
|
||||
&mut self.data[i * self.n + j]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exp_and_log_are_inverses() {
|
||||
for w in [
|
||||
Vec3::new(0.1, -0.2, 0.3),
|
||||
Vec3::new(1.0, 0.0, 0.0),
|
||||
Vec3::new(0.0, 0.0, 2.5),
|
||||
Vec3::new(1e-9, 0.0, 0.0),
|
||||
] {
|
||||
let back = Mat3::exp(w).log();
|
||||
for i in 0..3 {
|
||||
assert!(close(back.0[i], w.0[i]), "{w:?} -> {back:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotation_is_orthonormal_and_preserves_length() {
|
||||
let r = Mat3::exp(Vec3::new(0.4, 0.5, -0.6));
|
||||
let rt = r.transpose() * r;
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
assert!(close(rt.0[i][j], Mat3::IDENTITY.0[i][j]));
|
||||
}
|
||||
}
|
||||
let v = Vec3::new(1.0, 2.0, 3.0);
|
||||
assert!(close((r * v).norm(), v.norm()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_about_z_turns_x_towards_y() {
|
||||
let r = Mat3::rotation(Vec3::new(0.0, 0.0, 1.0), std::f64::consts::FRAC_PI_2);
|
||||
let v = r * Vec3::new(1.0, 0.0, 0.0);
|
||||
assert!(close(v.x(), 0.0) && close(v.y(), 1.0) && close(v.z(), 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cholesky_solves_a_small_spd_system() {
|
||||
// A = Bᵀ B for a random-ish B is SPD by construction.
|
||||
let b = [
|
||||
[2.0, 1.0, 0.0],
|
||||
[1.0, 3.0, 1.0],
|
||||
[0.0, 1.0, 4.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
];
|
||||
let mut a = DMat::zeros(3);
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
a[(i, j)] = (0..4).map(|k| b[k][i] * b[k][j]).sum();
|
||||
}
|
||||
}
|
||||
let x_true = [1.0, -2.0, 0.5];
|
||||
let rhs: Vec<f64> = (0..3)
|
||||
.map(|i| (0..3).map(|j| a[(i, j)] * x_true[j]).sum())
|
||||
.collect();
|
||||
let x = a.solve_spd(&rhs).expect("spd");
|
||||
for i in 0..3 {
|
||||
assert!(close(x[i], x_true[i]), "{x:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cholesky_refuses_an_indefinite_matrix() {
|
||||
let mut a = DMat::zeros(2);
|
||||
a[(0, 0)] = 1.0;
|
||||
a[(1, 1)] = -1.0;
|
||||
assert!(a.solve_spd(&[1.0, 1.0]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Descriptor matching between two images.
|
||||
//!
|
||||
//! Mutual nearest neighbour on cosine similarity, with a floor on the
|
||||
//! similarity — the reference XFeat's own matcher (`match_mkpts`,
|
||||
//! `min_cossim = 0.82`). For a panorama that is enough: one lens, one
|
||||
//! scene, near-pure rotation and 20–40 % overlap make the matching problem
|
||||
//! easy, and what is hard — sky, repeated structure, exposure drift — is
|
||||
//! handled by the detector's descriptors and by RANSAC downstream, not by a
|
||||
//! cleverer matcher. A learned matcher (LightGlue) is the step after this
|
||||
//! one fails on a real set, and it has not (panorama.md §6).
|
||||
//!
|
||||
//! Brute force. `4096 × 4096 × 64` multiply-adds is a billion per pair,
|
||||
//! and a twelve-frame set has sixty-six pairs: a minute single-threaded
|
||||
//! and scalar (measured 2026-09-19: 51 s), a few seconds vectorised across
|
||||
//! the cores. Not worth an index, but worth doing properly.
|
||||
|
||||
use crate::features::{Features, DESCRIPTOR_LEN};
|
||||
|
||||
const _: () = assert!(DESCRIPTOR_LEN.is_multiple_of(8));
|
||||
|
||||
/// A correspondence: keypoint `a` in the first image matches keypoint `b`
|
||||
/// in the second, with the cosine similarity of their descriptors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Match {
|
||||
pub a: usize,
|
||||
pub b: usize,
|
||||
pub similarity: f32,
|
||||
}
|
||||
|
||||
/// Match two sets of features.
|
||||
///
|
||||
/// A pair is kept when each is the other's nearest neighbour and their
|
||||
/// similarity is at least `min_similarity`.
|
||||
pub fn match_features(a: &Features, b: &Features, min_similarity: f32) -> Vec<Match> {
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let (na, nb) = (a.len(), b.len());
|
||||
|
||||
// The whole similarity matrix, once. Both nearest-neighbour directions
|
||||
// read it, which halves the multiply-adds against computing each
|
||||
// direction on its own; 4096 × 4096 × f32 is 64 MB, transient.
|
||||
let mut sim = vec![0.0f32; na * nb];
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map(usize::from)
|
||||
.unwrap_or(1)
|
||||
.clamp(1, 16);
|
||||
let rows_per = na.div_ceil(threads);
|
||||
std::thread::scope(|scope| {
|
||||
for (t, chunk) in sim.chunks_mut(rows_per * nb).enumerate() {
|
||||
scope.spawn(move || {
|
||||
let first = t * rows_per;
|
||||
for (r, row) in chunk.chunks_mut(nb).enumerate() {
|
||||
let da = a.descriptor(first + r);
|
||||
for (j, cell) in row.iter_mut().enumerate() {
|
||||
*cell = dot(da, b.descriptor(j));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Best in `b` for each `a`, and best in `a` for each `b`.
|
||||
let best_ab: Vec<(usize, f32)> = sim
|
||||
.chunks_exact(nb)
|
||||
.map(|row| {
|
||||
row.iter().enumerate().fold(
|
||||
(0usize, f32::MIN),
|
||||
|acc, (j, &s)| if s > acc.1 { (j, s) } else { acc },
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut best_ba = vec![(0usize, f32::MIN); nb];
|
||||
for (i, row) in sim.chunks_exact(nb).enumerate() {
|
||||
for (j, &s) in row.iter().enumerate() {
|
||||
if s > best_ba[j].1 {
|
||||
best_ba[j] = (i, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best_ab
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(ia, &(ib, s))| {
|
||||
(best_ba[ib].0 == ia && s >= min_similarity).then_some(Match {
|
||||
a: ia,
|
||||
b: ib,
|
||||
similarity: s,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn dot(a: &[f32], b: &[f32]) -> f32 {
|
||||
// Eight independent accumulators over exact 8-lane chunks: the shape
|
||||
// the compiler turns into one vector multiply-add per chunk, and no
|
||||
// bounds checks inside the loop. `DESCRIPTOR_LEN` is a multiple of 8.
|
||||
let (a, b) = (&a[..DESCRIPTOR_LEN], &b[..DESCRIPTOR_LEN]);
|
||||
let mut acc = [0.0f32; 8];
|
||||
for (ca, cb) in a.chunks_exact(8).zip(b.chunks_exact(8)) {
|
||||
for k in 0..8 {
|
||||
acc[k] += ca[k] * cb[k];
|
||||
}
|
||||
}
|
||||
acc.iter().sum()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::features::Keypoint;
|
||||
|
||||
/// Features whose descriptors are unit vectors along the given axes.
|
||||
fn along(axes: &[usize]) -> Features {
|
||||
let mut descriptors = vec![0.0; axes.len() * DESCRIPTOR_LEN];
|
||||
for (i, &ax) in axes.iter().enumerate() {
|
||||
descriptors[i * DESCRIPTOR_LEN + ax] = 1.0;
|
||||
}
|
||||
Features {
|
||||
keypoints: axes
|
||||
.iter()
|
||||
.map(|_| Keypoint {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
score: 1.0,
|
||||
})
|
||||
.collect(),
|
||||
descriptors,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_descriptors_match_mutually() {
|
||||
let a = along(&[0, 1, 2]);
|
||||
let b = along(&[2, 0, 1]);
|
||||
let m = match_features(&a, &b, 0.8);
|
||||
let mut pairs: Vec<(usize, usize)> = m.iter().map(|m| (m.a, m.b)).collect();
|
||||
pairs.sort();
|
||||
assert_eq!(pairs, vec![(0, 1), (1, 2), (2, 0)]);
|
||||
assert!(m.iter().all(|m| (m.similarity - 1.0).abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_descriptor_with_no_counterpart_is_unmatched() {
|
||||
let a = along(&[0, 1, 5]);
|
||||
let b = along(&[0, 1]);
|
||||
let m = match_features(&a, &b, 0.8);
|
||||
assert_eq!(m.len(), 2);
|
||||
assert!(m.iter().all(|m| m.a != 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutuality_breaks_a_one_sided_match() {
|
||||
// b0 is the nearest to both a0 and a1, but a0 is its nearest — a1
|
||||
// must not be matched to it.
|
||||
let mut a = along(&[0, 0]);
|
||||
a.descriptors[DESCRIPTOR_LEN] = 0.9;
|
||||
a.descriptors[DESCRIPTOR_LEN + 1] = (1.0f32 - 0.81).sqrt();
|
||||
let b = along(&[0]);
|
||||
let m = match_features(&a, &b, 0.0);
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!((m[0].a, m[0].b), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_is_empty_output() {
|
||||
assert!(match_features(&along(&[]), &along(&[1]), 0.5).is_empty());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user