Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a2c7789007 | ||
|
|
7c44740d9f | ||
|
|
4f31123b0c | ||
|
|
adf5d6cdd9 | ||
|
|
9d35addd86 | ||
|
|
3d6d69ec90 | ||
|
|
16f3fb41a3 | ||
|
|
8b3abdb787 | ||
|
|
a87139b838 | ||
|
|
936490880b | ||
|
|
d8b9b5a4bb | ||
|
|
ac0aea70ec | ||
|
|
5d175cc668 | ||
|
|
76ad667fd6 | ||
|
|
c045702a47 | ||
|
|
193b35a249 | ||
|
|
404fea47a8 | ||
|
|
d920716a2b | ||
|
|
2d878c2117 | ||
|
|
4c217c9be6 | ||
|
|
e44cb8cbe0 | ||
|
|
0a5eab0487 | ||
|
|
dd14243dba | ||
|
|
5f0b11c1f4 | ||
|
|
30468c4c69 | ||
|
|
428d8c4a51 | ||
|
|
2361b2d4ef | ||
|
|
de32c04a68 | ||
|
|
e13d3a54fc | ||
|
|
df741a8a49 | ||
|
|
9ede23073d | ||
|
|
1e171c6d31 | ||
|
|
577bbd82b0 | ||
|
|
bac5801618 | ||
|
|
af89433aee | ||
|
|
2cd49d1cb7 | ||
|
|
3dc7c184ee | ||
|
|
3f6dbce2aa | ||
|
|
124b2d99c6 | ||
|
|
3994caba12 | ||
|
|
901f51e6c4 | ||
|
|
2584b9ecbc | ||
|
|
9b674a88d8 | ||
|
|
43652bd613 | ||
|
|
1c5c55b4c9 | ||
|
|
5fa4c0772b | ||
|
|
68ebf5d78b | ||
|
|
81b1ae8c42 | ||
|
|
7c3e1d2c54 | ||
|
|
efa9d84aad | ||
|
|
59917c5183 | ||
|
|
e235e99cce | ||
|
|
6a97fdf6f9 | ||
|
|
474dcf0bf6 | ||
|
|
1ad35e2b87 | ||
|
|
ca2a135e28 | ||
|
|
601c984894 | ||
|
|
98fcf8e98e | ||
|
|
a56baa9042 | ||
|
|
f0ee53ec09 | ||
|
|
2841eaf9a1 | ||
|
|
c4ddcbe0f7 | ||
|
|
a1165ef182 | ||
|
|
e17b909d41 | ||
|
|
8e7b1350bf | ||
|
|
95e854b5a2 | ||
|
|
c1069ce07e | ||
|
|
dabf63ed6d | ||
|
|
f6c9343bcc | ||
|
|
710fcbc1bd | ||
|
|
89c4ff1820 | ||
|
|
6acc98baad | ||
|
|
353382c07f | ||
|
|
d44bffa4a8 | ||
|
|
8bf5e13faf | ||
|
|
0ff1e01ec3 | ||
|
|
53dc2d171e | ||
|
|
eed27eb36d | ||
|
|
4dc954f01a | ||
|
|
3d248cfb79 | ||
|
|
a1e361e35a | ||
|
|
4af3b93dfa | ||
|
|
9ddc1273c0 | ||
|
|
e7b526c550 | ||
|
|
144d2e4e84 | ||
|
|
41c655c176 | ||
|
|
d0ebc9f571 | ||
|
|
6783d0c723 | ||
|
|
0a6509de93 | ||
|
|
1d800d56b0 | ||
|
|
7981718d83 | ||
|
|
d48e9f6033 | ||
|
|
1c5849ebe8 | ||
|
|
4efab496c2 | ||
|
|
f87bf6ebc0 | ||
|
|
4f4abd335f | ||
|
|
8131706394 | ||
|
|
bfdb6d5e4b | ||
|
|
d7b851f622 | ||
|
|
f26f1ab694 | ||
|
|
e8b072c815 | ||
|
|
c62edd3317 |
@@ -10,3 +10,13 @@
|
||||
# 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
|
||||
|
||||
@@ -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/**"
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -96,7 +96,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/**"
|
||||
ls -lR models/
|
||||
|
||||
- name: Cache cargo
|
||||
@@ -213,7 +213,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/**"
|
||||
ls -lR models/
|
||||
|
||||
- name: Cache cargo
|
||||
@@ -365,6 +365,112 @@ 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/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"
|
||||
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"
|
||||
[ "$(ls "$INST/models" | wc -l)" = 7 ] || { echo "FAIL: expected 7 model files"; 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
|
||||
|
||||
@@ -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"
|
||||
@@ -23,3 +23,4 @@ tools/film-profiles/upstream/
|
||||
# checkout, so it is larger than the repository it sits in.
|
||||
/.flatpak-builder/
|
||||
/build/
|
||||
__pycache__/
|
||||
|
||||
Generated
+79
-24
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-android"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-plat",
|
||||
@@ -1234,13 +1234,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-desktop"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-plat",
|
||||
"dr-ui",
|
||||
"env_logger",
|
||||
"log",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1407,7 +1408,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dr-bench"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-catalog",
|
||||
@@ -1424,7 +1425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-catalog"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"dr-face",
|
||||
"dr-plat",
|
||||
@@ -1439,7 +1440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-decode"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
@@ -1453,7 +1454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-export"
|
||||
version = "0.10.0"
|
||||
version = "0.13.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.10.0"
|
||||
version = "0.13.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.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1493,11 +1495,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-gpu"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dr-decode",
|
||||
"dr-film",
|
||||
"dr-pano",
|
||||
"dr-pipeline",
|
||||
"dr-segment",
|
||||
"dr-types",
|
||||
@@ -1508,9 +1511,23 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-inference-engine"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"log",
|
||||
"ort",
|
||||
"ort-sys",
|
||||
"ort-tract",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-ingest"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
@@ -1522,15 +1539,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-lens"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"lensfun",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-pano"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-inference-engine",
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
"log",
|
||||
"ndarray",
|
||||
"ort",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-pipeline"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -1539,7 +1570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-plat"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"android-native-keyring-store",
|
||||
"dr-types",
|
||||
@@ -1555,7 +1586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-preset-xmp"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"dr-pipeline",
|
||||
"log",
|
||||
@@ -1565,20 +1596,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.10.0"
|
||||
version = "0.13.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.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-plat",
|
||||
@@ -1592,7 +1623,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-folder"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-sync",
|
||||
@@ -1604,7 +1635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-nextcloud"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-decode",
|
||||
@@ -1626,7 +1657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-thumbs"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"jpeg-encoder",
|
||||
@@ -1638,7 +1669,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-types"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1647,7 +1678,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ui"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1657,7 +1688,10 @@ dependencies = [
|
||||
"dr-face",
|
||||
"dr-film",
|
||||
"dr-gpu",
|
||||
"dr-inference-engine",
|
||||
"dr-ingest",
|
||||
"dr-lens",
|
||||
"dr-pano",
|
||||
"dr-pipeline",
|
||||
"dr-plat",
|
||||
"dr-preset-xmp",
|
||||
@@ -1667,6 +1701,7 @@ dependencies = [
|
||||
"dr-sync-nextcloud",
|
||||
"dr-thumbs",
|
||||
"dr-types",
|
||||
"dr-xmp",
|
||||
"env_logger",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
@@ -1683,6 +1718,16 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-xmp"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
"quick-xml",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drm"
|
||||
version = "0.14.1"
|
||||
@@ -6976,7 +7021,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "traceability"
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -8376,6 +8421,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"
|
||||
|
||||
+27
-6
@@ -8,15 +8,18 @@ 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",
|
||||
"core/dr-sync",
|
||||
"core/dr-sync-folder",
|
||||
"core/dr-sync-nextcloud",
|
||||
"core/dr-xmp",
|
||||
"platform/dr-plat",
|
||||
"ui/dr-ui",
|
||||
"apps/darkroom-desktop",
|
||||
@@ -26,7 +29,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.13.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -44,9 +47,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/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
|
||||
@@ -59,6 +67,7 @@ dr-plat = { path = "platform/dr-plat" }
|
||||
dr-sync = { path = "core/dr-sync" }
|
||||
dr-sync-folder = { path = "core/dr-sync-folder" }
|
||||
dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" }
|
||||
dr-xmp = { path = "core/dr-xmp" }
|
||||
dr-ui = { path = "ui/dr-ui" }
|
||||
|
||||
# GPU + UI
|
||||
@@ -231,13 +240,25 @@ ort-tract = "0.4"
|
||||
ndarray = "0.17"
|
||||
|
||||
[profile.dev]
|
||||
# Dependencies optimised even in dev builds — wgpu and image decoding are
|
||||
# unusably slow otherwise, and they rarely need debugging.
|
||||
# Dev builds are tuned for how fast they *compile*, not for how fast they run.
|
||||
# Optimisation is a release concern; `[profile.release]` below is where it
|
||||
# belongs.
|
||||
#
|
||||
# This deliberately reverses an earlier choice. Dependencies used to be built
|
||||
# at `opt-level = 2` here, because wgpu and image decoding are slow without it.
|
||||
# That is still true, and it is the price: a debug run of the app, and the
|
||||
# decode- and GPU-heavy tests, are slower than they were. What it buys is that
|
||||
# nothing has to be optimised before it can be compiled — which is the cost
|
||||
# paid on every edit, by every worktree, rather than only when something is
|
||||
# actually run.
|
||||
#
|
||||
# If a particular crate turns out to be the one that makes a test unbearable,
|
||||
# raise it alone rather than restoring the blanket rule:
|
||||
#
|
||||
# [profile.dev.package.zune-jpeg]
|
||||
# opt-level = 2
|
||||
opt-level = 0
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -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>.
|
||||
@@ -31,9 +31,50 @@ mod intents;
|
||||
/// Android application entry point, called by android-activity's glue.
|
||||
#[no_mangle]
|
||||
fn android_main(app: slint::android::AndroidApp) {
|
||||
// Before the logger, because the logger needs somewhere to write, and
|
||||
// before any `log::` call at all, because records emitted before this line
|
||||
// reach nothing.
|
||||
// **The first statement in the process, and it has to be.** Everything
|
||||
// between here and `install` returning runs with no logger installed at
|
||||
// all: asking the activity for its external directory, `create_dir_all`
|
||||
// and an `open` on a FUSE-backed volume the system may still be mounting.
|
||||
// A failure or a stall in any of it is invisible on every surface there
|
||||
// is — no file yet, and nothing in logcat either — which is precisely the
|
||||
// kind of launch logcat exists to debug.
|
||||
//
|
||||
// `AndroidLogger` rather than `init_once`, so logcat can be *teed* rather
|
||||
// than replaced: `init_once` installs itself as the global logger and
|
||||
// there is only one of those. Everything that reached logcat before the
|
||||
// file existed still reaches it, at the same level and under the same tag;
|
||||
// the file is strictly additional.
|
||||
let console = android_logger::AndroidLogger::new(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Info)
|
||||
.with_tag("DarkRoom"),
|
||||
);
|
||||
|
||||
// Handed to the logger directly, and **not** written as `log::info!`,
|
||||
// which here would compile and emit nothing: the facade's maximum level is
|
||||
// `Off` until `diagnostics::install` sets it, and the macro tests that
|
||||
// before it reaches any logger at all. This call skips the facade and
|
||||
// reaches `__android_log_write` with nothing in between.
|
||||
//
|
||||
// That independence is the second reason for it. When the log is silent,
|
||||
// this line is what says which half is at fault: present here and absent
|
||||
// below means the `log` wiring, absent in both means liblog is not
|
||||
// delivering this process's records — a question about the device, which
|
||||
// no amount of reading this file can answer.
|
||||
log::Log::log(
|
||||
&console,
|
||||
&log::Record::builder()
|
||||
.level(log::Level::Info)
|
||||
.target(module_path!())
|
||||
.module_path(Some(module_path!()))
|
||||
.args(format_args!(
|
||||
"DarkRoom v{} starting; logcat only until the log file opens",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.build(),
|
||||
);
|
||||
|
||||
// Before the file logger, because it needs somewhere to write.
|
||||
//
|
||||
// **The external directory, not the internal one, and the difference is
|
||||
// the entire point of the file.** Both are app-private and both survive
|
||||
@@ -53,16 +94,6 @@ fn android_main(app: slint::android::AndroidApp) {
|
||||
dr_plat::set_state_dir(dir);
|
||||
}
|
||||
|
||||
// `AndroidLogger` rather than `init_once`, so logcat can be *teed* rather
|
||||
// than replaced: `init_once` installs itself as the global logger and
|
||||
// there is only one of those. Everything that reached logcat before this
|
||||
// change still reaches it, at the same level and under the same tag; the
|
||||
// file is strictly additional.
|
||||
let console = android_logger::AndroidLogger::new(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Info)
|
||||
.with_tag("DarkRoom"),
|
||||
);
|
||||
let logging = dr_plat::diagnostics::install(Box::new(console), log::LevelFilter::Info);
|
||||
|
||||
// Panics go to stderr, and Android discards stderr. Without this hook a
|
||||
@@ -121,8 +152,10 @@ fn android_main(app: slint::android::AndroidApp) {
|
||||
None => log::error!("no internal data path; settings will not persist"),
|
||||
}
|
||||
|
||||
// After the data dir and before anything asks whether a model is present.
|
||||
install_bundled_models(&app);
|
||||
// After the data dir, because it writes beside the catalog. **Not** before
|
||||
// the first frame any more — it starts a worker and returns; see the
|
||||
// function for what it used to cost the launch.
|
||||
install_bundled_models(app.clone());
|
||||
|
||||
// Before `init_with_event_listener`, which takes `app` by value and is the
|
||||
// last moment anything can ask the activity a question. Not an ordering
|
||||
@@ -225,10 +258,58 @@ fn android_main(app: slint::android::AndroidApp) {
|
||||
/// whether or not the tab is opened. Assets are also *stored* rather than
|
||||
/// deflated in the APK (see `assemble-apk.sh`), so unpacking is a copy rather
|
||||
/// than an inflate.
|
||||
///
|
||||
/// # Why it returns before it has done anything
|
||||
///
|
||||
/// **`android_main` runs with the input channel unserviced.** Nothing drains
|
||||
/// it until Slint reaches `poll_events`, and Slint does not reach `poll_events`
|
||||
/// until `dr_ui::run` calls `window.run()`, which is the last line of it. So
|
||||
/// every millisecond spent between the top of `android_main` and that line is a
|
||||
/// millisecond in which Android's input dispatcher gets no answer, and five
|
||||
/// thousand of them is an ANR by definition — the system puts "DarkRoom isn't
|
||||
/// responding" over a window that has never painted, and offers to kill it.
|
||||
///
|
||||
/// This copied **41 MB** on the first launch after an install: 24.9 MB of scene
|
||||
/// model, 13.6 MB of embedder, 2.5 MB of detector, each read whole out of the
|
||||
/// APK and written to `/data`. v0.10.0 added the scene model, which was 60% of
|
||||
/// that total; v0.10.0 is the release the ANR appeared in, and the 8,010 minor
|
||||
/// faults in its report are what 41 MB of freshly touched pages looks like.
|
||||
/// The two further detectors the settings page offers since have made it
|
||||
/// 61 MB, which is the same argument with a larger number.
|
||||
///
|
||||
/// So it runs on a worker (NFR-ARCH-1: nothing blocking on the UI executor) and
|
||||
/// this function returns as soon as the thread is running. Nothing on the
|
||||
/// launch path waits for it, and no other startup step needs its result.
|
||||
///
|
||||
/// # The window in which a model looks absent, and why that is honest enough
|
||||
///
|
||||
/// Until the copy finishes, `library::face_models` and `library::scene_model`
|
||||
/// answer `is_file()` about files that are not written yet, so both report
|
||||
/// their feature unavailable — the same answer they give a build carrying no
|
||||
/// weights at all, which is the ordinary case this whole path was written
|
||||
/// around. It is briefly pessimistic rather than wrong, it lasts about as long
|
||||
/// as it takes to read one screenful of the grid, and the temporary name
|
||||
/// [`unpack_bundled_models`] writes under is what stops it being worse than
|
||||
/// pessimistic: a lookup never sees a half-written file, only an absent one.
|
||||
#[cfg(target_os = "android")]
|
||||
fn install_bundled_models(app: &slint::android::AndroidApp) {
|
||||
fn install_bundled_models(app: slint::android::AndroidApp) {
|
||||
// Detached rather than joined: there is no later moment on the launch path
|
||||
// that wants the answer, and a handle nobody joins is a handle nobody can
|
||||
// forget to. `AndroidApp` is documented `Send` and `Sync` and is an `Arc`
|
||||
// internally, so the clone costs a refcount; `asset_manager` is asked for
|
||||
// on the worker because `AAssetManager` is thread-safe by contract and
|
||||
// reading the pointer takes only the app's read lock, which `poll_events`
|
||||
// also only ever holds shared.
|
||||
std::thread::spawn(move || unpack_bundled_models(&app));
|
||||
}
|
||||
|
||||
/// The copy itself, on the worker [`install_bundled_models`] starts.
|
||||
#[cfg(target_os = "android")]
|
||||
fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
use std::io::Read;
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
|
||||
// The face names are the **shape-fixed** exports, matching what
|
||||
// `library::face_models` looks for: tract cannot parse either InsightFace
|
||||
// graph with its dynamic input dimension, so what ships here has already
|
||||
@@ -238,25 +319,59 @@ fn install_bundled_models(app: &slint::android::AndroidApp) {
|
||||
// decodes to 150 anonymous channels — `library::scene_model` wants the
|
||||
// vocabulary and the category descriptor beside it, and requires all three
|
||||
// before it reports the tab available.
|
||||
const BUNDLED: [(&std::ffi::CStr, &str); 5] = [
|
||||
//
|
||||
// Three detectors, because which one runs is a setting
|
||||
// (`FaceDetector`, docs/faces.md §12.3) and a tablet has no other way to
|
||||
// obtain the one it was not shipped with. Twenty megabytes of APK for
|
||||
// the choice; the embedder is the same for all three.
|
||||
//
|
||||
// Then the three eye-state models (docs/faces.md §17): landmarks, open
|
||||
// or closed, sunglasses. The app indexes without them; with them the
|
||||
// eyes-open filter has something to read, and a tablet has no other way
|
||||
// to get them either.
|
||||
//
|
||||
// The int8 forms beside the three detectors are what the Hexagon runs
|
||||
// (docs/inference.md §5); the engine loads the sibling when the probe
|
||||
// chose that rung and ignores it otherwise.
|
||||
const BUNDLED: [(&std::ffi::CStr, &str); 13] = [
|
||||
(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();
|
||||
let assets = app.asset_manager();
|
||||
let mut copied = 0u64;
|
||||
|
||||
for (asset_path, name) in BUNDLED {
|
||||
let dest = dir.join(name);
|
||||
// Already unpacked. Not re-read on every launch: this is 15 MB through
|
||||
// a decompressor on the startup path, and the file does not change
|
||||
// without the APK changing, at which point the install wiped it anyway.
|
||||
// Already unpacked. Not re-read on every launch: this is 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
|
||||
// install cheap even though the first one is not.
|
||||
if dest.is_file() {
|
||||
continue;
|
||||
}
|
||||
@@ -281,13 +396,46 @@ fn install_bundled_models(app: &slint::android::AndroidApp) {
|
||||
// than a missing one.
|
||||
let part = dir.join(format!("{name}.part"));
|
||||
match std::fs::write(&part, &bytes).and_then(|()| std::fs::rename(&part, &dest)) {
|
||||
Ok(()) => log::info!("installed bundled {name} ({} bytes)", bytes.len()),
|
||||
Ok(()) => {
|
||||
copied += bytes.len() as u64;
|
||||
log::info!("installed bundled {name} ({} bytes)", bytes.len());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("cannot install {name}: {e}");
|
||||
let _ = std::fs::remove_file(&part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The figure this whole function is about. Said even when it is zero, so a
|
||||
// launch that ANRs anyway can be told apart from one that spent its six
|
||||
// seconds here — on a second launch there is nothing left to copy and the
|
||||
// line reads `0 bytes`.
|
||||
log::info!(
|
||||
"bundled models ready: {copied} bytes copied in {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
|
||||
// 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/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
|
||||
|
||||
@@ -15,5 +15,13 @@ 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 = []
|
||||
|
||||
@@ -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/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/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,34 @@ 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/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 Flatpak prefix; then the system library
|
||||
/// directory, for a distribution that ships ONNX Runtime as a package of its
|
||||
/// own. A system copy whose GPU providers do not load is not a problem: 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"));
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
dirs.extend([
|
||||
PathBuf::from("/app/lib/darkroom"),
|
||||
PathBuf::from("/usr/lib/darkroom"),
|
||||
PathBuf::from("/usr/lib"),
|
||||
]);
|
||||
dirs
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ struct Known {
|
||||
crop_px: f32,
|
||||
}
|
||||
|
||||
/// What the catalog holds per face, decoded: photograph, vector, size,
|
||||
/// quality.
|
||||
type Decoded = (u64, Vec<f32>, f32, Option<f32>);
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let Some(path) = args.first() else {
|
||||
@@ -59,10 +63,10 @@ fn main() {
|
||||
|
||||
let model = dr_face::ModelId::new(MODEL_ID.to_string());
|
||||
let stored = faces::embeddings(conn, MODEL_ID).expect("embeddings");
|
||||
let mut embedding_of = HashMap::new();
|
||||
for (id, image, blob, crop_px) in stored {
|
||||
if let Some(e) = dr_face::Embedding::from_f16_bytes(model.clone(), &blob) {
|
||||
embedding_of.insert(id, (image.0, e.v.to_vec(), crop_px));
|
||||
let mut embedding_of: HashMap<faces::FaceId, Decoded> = HashMap::new();
|
||||
for f in stored {
|
||||
if let Some(e) = dr_face::Embedding::from_f16_bytes(model.clone(), &f.embedding) {
|
||||
embedding_of.insert(f.face, (f.image.0, e.v.to_vec(), f.crop_px, f.quality));
|
||||
}
|
||||
}
|
||||
println!("faces with embeddings: {}", embedding_of.len());
|
||||
@@ -82,7 +86,7 @@ fn main() {
|
||||
if !f.confirmed {
|
||||
continue;
|
||||
}
|
||||
if let Some((image, embedding, crop_px)) = embedding_of.get(&f.id) {
|
||||
if let Some((image, embedding, crop_px, _)) = embedding_of.get(&f.id) {
|
||||
mine.push(Known {
|
||||
image: *image,
|
||||
person: p.id,
|
||||
@@ -288,19 +292,22 @@ fn band(label: &str, v: &[f32]) {
|
||||
/// The whole library through the real clusterer, for the numbers it would
|
||||
/// actually write.
|
||||
fn full_library(
|
||||
embedding_of: &HashMap<faces::FaceId, (u64, Vec<f32>, f32)>,
|
||||
embedding_of: &HashMap<faces::FaceId, Decoded>,
|
||||
confirmed: &HashMap<faces::FaceId, u64>,
|
||||
cal: &dr_face::Calibration,
|
||||
) {
|
||||
let mut candidates: Vec<dr_face::Candidate> = embedding_of
|
||||
.iter()
|
||||
.map(|(id, (image, embedding, crop_px))| dr_face::Candidate {
|
||||
face: id.0,
|
||||
image: *image,
|
||||
embedding: embedding.clone(),
|
||||
crop_px: *crop_px,
|
||||
confirmed_person: confirmed.get(id).copied(),
|
||||
})
|
||||
.map(
|
||||
|(id, (image, embedding, crop_px, quality))| dr_face::Candidate {
|
||||
face: id.0,
|
||||
image: *image,
|
||||
embedding: embedding.clone(),
|
||||
crop_px: *crop_px,
|
||||
quality: *quality,
|
||||
confirmed_person: confirmed.get(id).copied(),
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
candidates.sort_by_key(|c| c.face);
|
||||
|
||||
@@ -317,11 +324,13 @@ fn full_library(
|
||||
.collect();
|
||||
let crop_px: Vec<f32> = candidates.iter().map(|c| c.crop_px).collect();
|
||||
let images: Vec<u64> = candidates.iter().map(|c| c.image).collect();
|
||||
let gallery: Vec<bool> = candidates.iter().map(|c| c.in_gallery()).collect();
|
||||
let view = dr_face::neighbours::Faces {
|
||||
embeddings: &flat,
|
||||
dim,
|
||||
crop_px: &crop_px,
|
||||
images: &images,
|
||||
gallery: &gallery,
|
||||
};
|
||||
|
||||
let t = std::time::Instant::now();
|
||||
@@ -335,8 +344,7 @@ fn full_library(
|
||||
let agglomerate = t.elapsed().as_secs_f64() - scan;
|
||||
|
||||
let t = std::time::Instant::now();
|
||||
let _ =
|
||||
dr_face::identity_shares(candidates.len(), &clusters, &evidence, dr_face::TOP_MATCHES);
|
||||
let _ = dr_face::identity_shares(&gallery, &clusters, &evidence, dr_face::TOP_MATCHES);
|
||||
println!(
|
||||
" scan {scan:.2}s ({} evidence pairs) · agglomerate {agglomerate:.2}s · score {:.2}s",
|
||||
evidence.len(),
|
||||
|
||||
@@ -535,6 +535,105 @@ pub fn collections_for_image(
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// One collection a set of images is filed in, and how much of that set is in
|
||||
/// it.
|
||||
///
|
||||
/// `holding` is what makes a removal honest: with forty photographs selected
|
||||
/// and three of them in "Iceland", the row has to say "3 of 40" or the user
|
||||
/// reads it as "this selection is in Iceland" and takes all forty out of a
|
||||
/// collection thirty-seven of them were never in.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Membership {
|
||||
pub id: CollectionId,
|
||||
pub name: String,
|
||||
/// How many of the images asked about are members. Never zero — a
|
||||
/// collection holding none of them is not returned at all.
|
||||
pub holding: usize,
|
||||
}
|
||||
|
||||
/// Every collection the given images are filed in, with how many of them each
|
||||
/// holds.
|
||||
///
|
||||
/// The read behind "which collections is this selection in, and take it out of
|
||||
/// one" — the counterpart to [`collections_for_image`], which answers the same
|
||||
/// question for a single photograph and does not need the counts.
|
||||
///
|
||||
/// Smart collections never appear: they have no `collection_members` rows, so
|
||||
/// there is nothing to remove and offering it would be a button that does
|
||||
/// nothing. Sorted by name, matching the sidebar.
|
||||
///
|
||||
/// # Why this is chunked
|
||||
///
|
||||
/// The image list is a *selection*, which a select-all makes as large as the
|
||||
/// library. SQLite caps the number of bound parameters in one statement, so a
|
||||
/// single `IN (...)` over every selected id fails outright on exactly the
|
||||
/// gesture most likely to produce it. The counts are summed across chunks
|
||||
/// rather than re-queried, so the result is the same as the unchunked query
|
||||
/// would have given.
|
||||
pub fn membership_of(
|
||||
conn: &Connection,
|
||||
images: &[ImageId],
|
||||
) -> Result<Vec<Membership>, CatalogError> {
|
||||
if images.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Well under SQLite's default parameter cap, and large enough that an
|
||||
// ordinary selection is one round trip.
|
||||
const CHUNK: usize = 400;
|
||||
|
||||
let mut totals: std::collections::HashMap<CollectionId, (String, usize)> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for chunk in images.chunks(CHUNK) {
|
||||
let placeholders = std::iter::repeat_n("?", chunk.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
// The placeholder list is built from the id *count*, never from user
|
||||
// text — the same construction `deep_count` uses.
|
||||
let sql = format!(
|
||||
"SELECT c.id, c.name, count(*)
|
||||
FROM collection_members m
|
||||
JOIN collections c ON c.id = m.collection_id
|
||||
WHERE m.image_id IN ({placeholders}) AND c.deleted = 0
|
||||
GROUP BY c.id, c.name"
|
||||
);
|
||||
let params: Vec<rusqlite::types::Value> = chunk
|
||||
.iter()
|
||||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||||
.collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok((
|
||||
CollectionId(r.get::<_, i64>(0)? as u64),
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)? as usize,
|
||||
))
|
||||
})?;
|
||||
|
||||
for row in rows {
|
||||
let (id, name, n) = row?;
|
||||
let entry = totals.entry(id).or_insert((name, 0));
|
||||
entry.1 += n;
|
||||
}
|
||||
}
|
||||
|
||||
let mut out: Vec<Membership> = totals
|
||||
.into_iter()
|
||||
.map(|(id, (name, holding))| Membership { id, name, holding })
|
||||
.collect();
|
||||
// By name, then by id, so two collections sharing a name have a stable
|
||||
// order rather than the hash map's.
|
||||
out.sort_by(|a, b| {
|
||||
a.name
|
||||
.to_lowercase()
|
||||
.cmp(&b.name.to_lowercase())
|
||||
.then(a.id.0.cmp(&b.id.0))
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What kind of collection `id` is, or `None` if there is no such collection.
|
||||
///
|
||||
/// Cheaper than reading the whole [`Collection`] where the caller only needs to
|
||||
@@ -552,6 +651,44 @@ pub fn kind(conn: &Connection, id: CollectionId) -> Result<Option<CollectionKind
|
||||
Ok(found.map(CollectionKind::from_i64))
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-8
|
||||
/// The device-independent name of a collection, from its local id.
|
||||
///
|
||||
/// The pair to [`id_for_uuid`], and the reason both exist: `collections.id` is
|
||||
/// an autoincrement local to one catalog, so anything that travels between
|
||||
/// devices — a place, a merge — has to say which collection it means in the
|
||||
/// only vocabulary they share.
|
||||
///
|
||||
/// `None` for a collection that is not there, or has been tombstoned. A caller
|
||||
/// writing down a scope treats that as "the whole library", which is the
|
||||
/// harmless direction: the alternative is recording a name nothing can resolve.
|
||||
pub fn uuid_of(conn: &Connection, id: CollectionId) -> Result<Option<String>, CatalogError> {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT uuid FROM collections WHERE id = ?1 AND deleted = 0",
|
||||
[id.0 as i64],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-8
|
||||
/// The local id of a collection, from the name every device knows it by.
|
||||
///
|
||||
/// `None` where this device has never heard of it, or has deleted it — a place
|
||||
/// recorded on the tablet inside a collection this machine has not yet merged.
|
||||
/// The caller falls back to the whole library rather than to an empty grid.
|
||||
pub fn id_for_uuid(conn: &Connection, uuid: &str) -> Result<Option<CollectionId>, CatalogError> {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT id FROM collections WHERE uuid = ?1 AND deleted = 0",
|
||||
[uuid],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.optional()?
|
||||
.map(|id| CollectionId(id as u64)))
|
||||
}
|
||||
|
||||
/// A collection and everything beneath it, including itself.
|
||||
///
|
||||
/// Used for cycle checks and for scoping the grid to a parent: selecting a
|
||||
@@ -1400,4 +1537,85 @@ mod tests {
|
||||
let d = descendants(c, a).unwrap();
|
||||
assert!(d.len() <= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_says_how_many_of_the_selection_each_collection_holds() {
|
||||
// The count is the whole point: "3 of 40" is what stops a user taking
|
||||
// forty photographs out of a collection thirty-seven were never in.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let iceland = create(c, "Iceland", None, CollectionKind::Manual).unwrap();
|
||||
let best = create(c, "Best", None, CollectionKind::Manual).unwrap();
|
||||
add_images(c, iceland, &[img(1), img(2), img(3)]).unwrap();
|
||||
add_images(c, best, &[img(1)]).unwrap();
|
||||
|
||||
let m = membership_of(c, &[img(1), img(2), img(3), img(4)]).unwrap();
|
||||
assert_eq!(m.len(), 2);
|
||||
// Sorted by name, so "Best" comes before "Iceland".
|
||||
assert_eq!(m[0].id, best);
|
||||
assert_eq!(m[0].holding, 1);
|
||||
assert_eq!(m[1].id, iceland);
|
||||
assert_eq!(m[1].holding, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_omits_collections_holding_none_of_them() {
|
||||
// A row offering to remove images that are not there would be a button
|
||||
// that does nothing, which is worse than an absent one.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
||||
create(c, "Empty", None, CollectionKind::Manual).unwrap();
|
||||
add_images(c, a, &[img(1)]).unwrap();
|
||||
|
||||
let m = membership_of(c, &[img(1)]).unwrap();
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].id, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_of_nothing_is_nothing() {
|
||||
let cat = seeded();
|
||||
assert!(membership_of(cat.connection(), &[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_skips_a_deleted_collection() {
|
||||
// The tombstone survives the delete, and its member rows are dropped —
|
||||
// but a merge can leave rows behind, and the sheet must not offer a
|
||||
// collection the sidebar does not draw.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
||||
add_images(c, a, &[img(1)]).unwrap();
|
||||
delete(c, a).unwrap();
|
||||
|
||||
assert!(membership_of(c, &[img(1)]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_sums_across_chunks() {
|
||||
// The chunking exists for a select-all, which is exactly the gesture
|
||||
// that would otherwise exceed SQLite's parameter cap. A count that was
|
||||
// per-chunk rather than summed would under-report on the one selection
|
||||
// large enough to need it.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
||||
// Past the fixture's six, so the member rows have images to point at.
|
||||
for i in 7..=900i64 {
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (?1, 1, ?2, 0)",
|
||||
rusqlite::params![i, format!("img{i}.CR3")],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let ids: Vec<ImageId> = (1..=900).map(img).collect();
|
||||
add_images(c, a, &ids).unwrap();
|
||||
|
||||
let m = membership_of(c, &ids).unwrap();
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].holding, 900, "counted across every chunk");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
@@ -76,6 +77,14 @@ pub struct SharedFace {
|
||||
pub confidence: f32,
|
||||
pub embedding: Vec<u8>,
|
||||
pub crop_px: f32,
|
||||
/// See `faces::DetectedFace::quality`. `None` from a shard written before
|
||||
/// the number was kept.
|
||||
pub quality: Option<f32>,
|
||||
/// 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
|
||||
@@ -146,6 +155,29 @@ 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()
|
||||
}
|
||||
|
||||
pub fn contains(&self, file_id: u64, model_id: &str) -> bool {
|
||||
self.index
|
||||
.query_row(
|
||||
@@ -224,8 +256,12 @@ impl FaceShardStore {
|
||||
tx.execute(
|
||||
"INSERT INTO faces
|
||||
(file_id, model_id, x, y, w, h, landmarks, confidence,
|
||||
embedding, crop_px, crop)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
embedding, crop_px, crop, quality,
|
||||
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,
|
||||
@@ -238,6 +274,15 @@ impl FaceShardStore {
|
||||
f.embedding,
|
||||
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()),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -442,9 +487,16 @@ impl FaceShardStore {
|
||||
}
|
||||
let mut fq = src.prepare(&format!(
|
||||
"SELECT f.file_id, f.model_id, f.x, f.y, f.w, f.h, f.landmarks,
|
||||
f.confidence, f.embedding, f.crop_px, {}
|
||||
f.confidence, f.embedding, f.crop_px, {}, {}, {}, {}
|
||||
FROM faces f WHERE f.file_id = ?1 AND f.model_id = ?2",
|
||||
crop_column(&src)
|
||||
column_or_null(&src, "crop"),
|
||||
column_or_null(&src, "quality"),
|
||||
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)?
|
||||
@@ -484,7 +536,9 @@ impl FaceShardStore {
|
||||
let Some(edge) = edge else { return Ok(None) };
|
||||
|
||||
let mut q = conn.prepare(
|
||||
"SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px, crop
|
||||
"SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px,
|
||||
crop, quality, 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
|
||||
@@ -541,6 +595,15 @@ fn upgrade_shard(conn: &Connection) -> Result<(), CatalogError> {
|
||||
for (table, column, decl) in [
|
||||
("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}"))?;
|
||||
@@ -555,16 +618,19 @@ fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool, Cata
|
||||
Ok(stmt.exists(rusqlite::params![table, column])?)
|
||||
}
|
||||
|
||||
/// `f.crop`, or a `NULL` standing in for it.
|
||||
/// `f.<column>`, or a `NULL` standing in for it.
|
||||
///
|
||||
/// A shard downloaded from a peer is opened **read-only** and cannot be
|
||||
/// upgraded, so one written before crops existed has to be read as it is rather
|
||||
/// than repaired. Selecting a literal keeps the column count the same, which is
|
||||
/// what lets [`read_shared_face`] stay a single function.
|
||||
fn crop_column(conn: &Connection) -> &'static str {
|
||||
match has_column(conn, "faces", "crop") {
|
||||
Ok(true) => "f.crop",
|
||||
_ => "NULL",
|
||||
/// upgraded, so one written before a column existed has to be read as it is
|
||||
/// rather than repaired. Selecting a literal keeps the column count the same,
|
||||
/// which is what lets [`read_shared_face`] stay a single function.
|
||||
///
|
||||
/// `column` is one of this module's own names, never anything read from
|
||||
/// outside, which is what makes formatting it into SQL acceptable.
|
||||
fn column_or_null(conn: &Connection, column: &str) -> String {
|
||||
match has_column(conn, "faces", column) {
|
||||
Ok(true) => format!("f.{column}"),
|
||||
_ => "NULL".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,16 +664,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<_, _>>()?;
|
||||
|
||||
@@ -617,7 +688,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);
|
||||
}
|
||||
@@ -637,7 +709,9 @@ pub fn export_to_shards_reporting(
|
||||
continue;
|
||||
}
|
||||
let mut fq = conn.prepare(
|
||||
"SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px, crop
|
||||
"SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px, crop,
|
||||
quality, 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
|
||||
@@ -654,6 +728,9 @@ pub fn export_to_shards_reporting(
|
||||
embedding: r.get(6)?,
|
||||
crop_px: r.get::<_, f64>(7)? as f32,
|
||||
crop: r.get::<_, Option<Vec<u8>>>(8)?.unwrap_or_default(),
|
||||
quality: r.get::<_, Option<f64>>(9)?.map(|q| q as f32),
|
||||
eyes: crate::faces::read_eyes(r, 10)?,
|
||||
landmarks_dense: r.get::<_, Option<Vec<u8>>>(17)?.unwrap_or_default(),
|
||||
})
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
@@ -677,10 +754,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(
|
||||
@@ -688,28 +775,65 @@ 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<_, _>>()?;
|
||||
|
||||
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 {
|
||||
for (file_id, image_id, local) in candidates {
|
||||
let Some(held) = store.held_model(file_id as u64, model_id) else {
|
||||
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 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.
|
||||
//
|
||||
// A missing *eye* reading is not the same case and is adopted. The
|
||||
// measuring pass finds those by the NULL, not by the marker, so
|
||||
// adopting the faces costs the reading nothing (schema V16) — and a
|
||||
// peer that has no eye models may be the only one that has done the
|
||||
// detection at all.
|
||||
if faces.iter().any(|f| f.quality.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let local: Vec<crate::faces::DetectedFace> = faces
|
||||
.into_iter()
|
||||
.map(|f| crate::faces::DetectedFace {
|
||||
@@ -721,6 +845,9 @@ pub fn import_from_shards(
|
||||
confidence: f.confidence,
|
||||
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
|
||||
@@ -732,7 +859,7 @@ pub fn import_from_shards(
|
||||
crate::faces::record_detections(
|
||||
conn,
|
||||
dr_types::ImageId(image_id as u64),
|
||||
model_id,
|
||||
&held,
|
||||
edge,
|
||||
&local,
|
||||
)?;
|
||||
@@ -766,6 +893,9 @@ fn read_shared_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<SharedFace> {
|
||||
embedding: r.get(8)?,
|
||||
crop_px: r.get::<_, f64>(9)? as f32,
|
||||
crop: r.get::<_, Option<Vec<u8>>>(10)?.unwrap_or_default(),
|
||||
quality: r.get::<_, Option<f64>>(11)?.map(|q| q as f32),
|
||||
eyes: crate::faces::read_eyes(r, 12)?,
|
||||
landmarks_dense: r.get::<_, Option<Vec<u8>>>(19)?.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -849,7 +979,24 @@ CREATE TABLE IF NOT EXISTS faces (
|
||||
crop_px REAL NOT NULL,
|
||||
-- The face, cut out. NULL where the face was found before crops were kept,
|
||||
-- or adopted from a peer that did not have one.
|
||||
crop BLOB
|
||||
crop BLOB,
|
||||
-- Length of the raw embedding (`faces::DetectedFace::quality`). NULL from
|
||||
-- a build that did not keep it, and a face the receiving device will not
|
||||
-- adopt -- see `import_from_shards`.
|
||||
quality REAL,
|
||||
-- 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);
|
||||
|
||||
@@ -908,6 +1055,9 @@ mod tests {
|
||||
confidence: 0.87,
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(17.5),
|
||||
eyes: None,
|
||||
landmarks_dense: Vec::new(),
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
}
|
||||
@@ -925,6 +1075,7 @@ mod tests {
|
||||
assert_eq!(edge, 1024);
|
||||
assert_eq!(faces[0].embedding.len(), 1024);
|
||||
assert!((faces[0].crop_px - 180.0).abs() < 1e-3);
|
||||
assert_eq!(faces[0].quality, Some(17.5));
|
||||
}
|
||||
|
||||
/// The case the run marker exists for, carried across the wire: an image
|
||||
@@ -1115,6 +1266,9 @@ mod catalog_round_trip {
|
||||
confidence: 0.9,
|
||||
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],
|
||||
}
|
||||
@@ -1162,9 +1316,92 @@ mod catalog_round_trip {
|
||||
let got = faces::for_image(&b, dr_types::ImageId(90)).unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert!((got[0].crop_px - 180.0).abs() < 1e-3);
|
||||
assert_eq!(got[0].quality, Some(20.0));
|
||||
assert!((got[0].landmarks[2].0 - 0.15).abs() < 1e-5);
|
||||
let emb = faces::embeddings(&b, "w600k_mbf").unwrap();
|
||||
assert!(emb.iter().any(|(_, _, blob, _)| blob[0] == 1));
|
||||
assert!(emb.iter().any(|e| e.embedding[0] == 1));
|
||||
}
|
||||
|
||||
/// 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 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 work this device
|
||||
/// cannot finish, and adopting it would write the marker that stops it
|
||||
/// ever being measured. The image stays outstanding instead.
|
||||
#[test]
|
||||
fn a_peers_unmeasured_faces_are_left_for_this_device_to_index() {
|
||||
let b = device(&[(90, 5001), (91, 5002)]);
|
||||
let mut store = FaceShardStore::open(&tempdir("unmeasured")).unwrap();
|
||||
let shared = |file_id: u64, quality: Option<f32>| SharedFace {
|
||||
file_id,
|
||||
model_id: "w600k_mbf".into(),
|
||||
x: 0.1,
|
||||
y: 0.2,
|
||||
w: 0.15,
|
||||
h: 0.2,
|
||||
landmarks: vec![1; 40],
|
||||
confidence: 0.87,
|
||||
embedding: vec![1; 1024],
|
||||
crop_px: 180.0,
|
||||
quality,
|
||||
eyes: None,
|
||||
landmarks_dense: Vec::new(),
|
||||
crop: Vec::new(),
|
||||
};
|
||||
store
|
||||
.put_image(5001, "w600k_mbf", 2560, &[shared(5001, None)])
|
||||
.unwrap();
|
||||
store
|
||||
.put_image(5002, "w600k_mbf", 2560, &[shared(5002, Some(19.0))])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(import_from_shards(&b, &store, "w600k_mbf").unwrap(), 1);
|
||||
let cov = faces::coverage(&b, "w600k_mbf").unwrap();
|
||||
assert_eq!(cov.indexed, 1);
|
||||
assert_eq!(cov.outstanding(), 1, "the unmeasured image was adopted");
|
||||
assert!(faces::for_image(&b, dr_types::ImageId(90))
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1187,7 +1424,60 @@ mod catalog_round_trip {
|
||||
"a peer's copy replaced work this device had already done"
|
||||
);
|
||||
let emb = faces::embeddings(&b, "w600k_mbf").unwrap();
|
||||
assert_eq!(emb[0].2[0], 9, "B's own embedding was overwritten");
|
||||
assert_eq!(emb[0].embedding[0], 9, "B's own embedding was overwritten");
|
||||
}
|
||||
|
||||
/// A 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.
|
||||
@@ -1281,6 +1571,9 @@ mod catalog_round_trip {
|
||||
confidence: 0.87,
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: None,
|
||||
eyes: None,
|
||||
landmarks_dense: Vec::new(),
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
}
|
||||
@@ -1430,6 +1723,10 @@ mod catalog_round_trip {
|
||||
let (faces, _) = store.get_image(77, "w600k_mbf").unwrap().unwrap();
|
||||
assert_eq!(faces.len(), 1);
|
||||
assert!(faces[0].crop.is_empty(), "a crop was invented from nowhere");
|
||||
assert_eq!(
|
||||
faces[0].quality, None,
|
||||
"a quality was invented from nowhere"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
+1143
-91
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, 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;
|
||||
|
||||
@@ -991,9 +991,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 +1030,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 +1061,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
|
||||
@@ -1977,6 +1983,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 {
|
||||
|
||||
+428
-13
@@ -30,7 +30,7 @@
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
|
||||
use dr_types::{FlagState, ImageId};
|
||||
use dr_types::{ColourLabel, FlagState, ImageId};
|
||||
|
||||
use crate::error::CatalogError;
|
||||
|
||||
@@ -63,6 +63,59 @@ impl Judgement {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-8 | FR-NC-9
|
||||
/// The default version's uuid for a photograph the server knows by `file_id`.
|
||||
///
|
||||
/// # Why this is derived and not generated
|
||||
///
|
||||
/// A version's uuid is the identity a cross-device merge keys on. It used to
|
||||
/// be minted at random per row, and the comment above this function used to
|
||||
/// say that made it unique — which it did, and that was precisely the bug.
|
||||
/// Two devices indexing the same library minted *different* uuids for the same
|
||||
/// photograph, so the sidecar they shared ended up with two `default = 1`
|
||||
/// blocks, `Version::merge` never saw a matching pair to reconcile, and a
|
||||
/// rating made on one device was invisible on the other. `crate::merge` has
|
||||
/// documented the consequence for keywords for as long as it has existed: a
|
||||
/// uuid-keyed join across two catalogs unions nothing at all.
|
||||
///
|
||||
/// `oc:fileid` is the identity that *is* shared. The server assigns it, every
|
||||
/// client pointed at that library sees the same integer, and it survives a
|
||||
/// server-side rename and move — the same three properties that made
|
||||
/// `crate::merge::ASSIGN_BY_FILE_ID` prefer it to a content hash.
|
||||
///
|
||||
/// # The layout
|
||||
///
|
||||
/// A UUIDv8 (RFC 9562: an application-defined layout) carrying the file id
|
||||
/// verbatim across the four variable fields, with a fixed tag in the node
|
||||
/// field saying what minted it. Verbatim rather than hashed so the mapping is
|
||||
/// injective by construction: two file ids cannot collide, which a truncated
|
||||
/// hash could, and a uuid read out of a sidecar can be traced back to the file
|
||||
/// it belongs to by eye.
|
||||
///
|
||||
/// Every device computes this identically from the same integer, which is the
|
||||
/// whole point — there is no negotiation and no first-writer-wins.
|
||||
pub fn derived_version_uuid(file_id: i64) -> String {
|
||||
let id = file_id as u64;
|
||||
format!(
|
||||
// 32 + 16 + 12 + 4 = 64 bits of file id, then the tag.
|
||||
"{:08x}-{:04x}-8{:03x}-{:04x}-{:012x}",
|
||||
(id >> 32) as u32,
|
||||
(id >> 16) as u16,
|
||||
(id >> 4) as u16 & 0x0FFF,
|
||||
// The two high bits are the RFC's variant field and must be `0b10`;
|
||||
// the remaining fourteen carry the file id's last four bits.
|
||||
0x8000u16 | ((id as u16 & 0x000F) << 10),
|
||||
DERIVED_VERSION_TAG,
|
||||
)
|
||||
}
|
||||
|
||||
/// The node field of a [`derived_version_uuid`], identifying what minted it.
|
||||
///
|
||||
/// Fixed and arbitrary. Its only job is to keep a derived uuid from colliding
|
||||
/// with a randomly minted one and to make it recognisable in a sidecar read by
|
||||
/// eye — `…-d0c5ec0de001` is visibly not a v4.
|
||||
const DERIVED_VERSION_TAG: u64 = 0xd0c5_ec0d_e001;
|
||||
|
||||
/// Give every image without one a default version.
|
||||
///
|
||||
/// Idempotent, and cheap on the common path: the `NOT EXISTS` sub-select is
|
||||
@@ -72,8 +125,9 @@ impl Judgement {
|
||||
/// Returns how many were created, so a scan can log the backfill rather than
|
||||
/// silently doing thousands of inserts.
|
||||
///
|
||||
/// The UUID is per row and generated here — it is the merge identity across
|
||||
/// devices (FR-NC-8), so two images must never share one.
|
||||
/// The uuid comes from [`derived_version_uuid`] where the server has named the
|
||||
/// file, so every device computes the same one; only a library with no server
|
||||
/// behind it falls back to a generated id.
|
||||
pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
// One transaction for the batch. A backfill over a 24k-image library is
|
||||
// 24k inserts, and per-statement commits would make it minutes rather
|
||||
@@ -93,13 +147,17 @@ pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError>
|
||||
/// transaction within a transaction". The same split, for the same reason, as
|
||||
/// `collections::add_within`.
|
||||
pub fn ensure_default_versions_within(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
let ids: Vec<i64> = {
|
||||
// The remote id travels with the image so the uuid can be derived from it.
|
||||
// A `LEFT JOIN`, because a library on a folder or a card has no `remote`
|
||||
// row at all and still needs its versions.
|
||||
let ids: Vec<(i64, Option<i64>)> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT i.id FROM images i
|
||||
"SELECT i.id, r.file_id FROM images i
|
||||
LEFT JOIN remote r ON r.image_id = i.id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM versions v WHERE v.image_id = i.id)",
|
||||
)?;
|
||||
let found = stmt
|
||||
.query_map([], |r| r.get(0))?
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
found
|
||||
};
|
||||
@@ -112,14 +170,103 @@ pub fn ensure_default_versions_within(conn: &Connection) -> Result<usize, Catalo
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (?1, ?2, ?3, 1, 0, 0)",
|
||||
)?;
|
||||
for id in &ids {
|
||||
insert.execute(rusqlite::params![id, new_uuid(), DEFAULT_VERSION_NAME])?;
|
||||
for (id, file_id) in &ids {
|
||||
insert.execute(rusqlite::params![
|
||||
id,
|
||||
version_uuid(*file_id),
|
||||
DEFAULT_VERSION_NAME
|
||||
])?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ids.len())
|
||||
}
|
||||
|
||||
/// The uuid to mint for a new default version.
|
||||
///
|
||||
/// Derived from the server's file id where there is one, so two devices agree
|
||||
/// (FR-NC-8); generated where there is not.
|
||||
///
|
||||
/// # What the fallback costs
|
||||
///
|
||||
/// A library with no server behind it — a folder, a card — has no identity two
|
||||
/// devices could both compute, so the split this derivation prevents is still
|
||||
/// reachable there if that folder is synced by something else. That case is
|
||||
/// repaired rather than prevented: `Sidecar::fuse_default_versions` folds the
|
||||
/// rival defaults together the next time either device reads the file.
|
||||
fn version_uuid(file_id: Option<i64>) -> String {
|
||||
match file_id {
|
||||
Some(id) => derived_version_uuid(id),
|
||||
None => new_uuid(),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-8 | FR-NC-9
|
||||
/// Move default versions minted before [`derived_version_uuid`] onto it.
|
||||
///
|
||||
/// Every catalog written by an earlier build holds a randomly minted uuid per
|
||||
/// image, and its peers hold different ones for the same photographs. Deriving
|
||||
/// the uuid only for *new* rows would leave every image already indexed —
|
||||
/// which is all of them, on a library anybody has used — writing to the same
|
||||
/// rival identity it always did.
|
||||
///
|
||||
/// Safe to run repeatedly: it selects only rows whose uuid is not already the
|
||||
/// derived one, so a realigned catalog matches nothing and writes nothing.
|
||||
///
|
||||
/// # Why this cannot collide
|
||||
///
|
||||
/// `versions.uuid` is `UNIQUE`, and `remote.file_id` has a unique index of its
|
||||
/// own, so two images cannot derive the same uuid. The one row that could
|
||||
/// stand in the way is a *virtual copy* (FR-CAT-12) that already holds the
|
||||
/// target — impossible to mint but not impossible to receive from a merge — so
|
||||
/// the update is skipped where the target is taken rather than failing the
|
||||
/// backfill and, with it, the catalog open.
|
||||
///
|
||||
/// # The sidecar side is not this function's business
|
||||
///
|
||||
/// Moving the catalog's uuid alone would leave the file's default under the
|
||||
/// old one and the next write would add a rival rather than amend it. What
|
||||
/// stops that is `library::amend` fusing onto the write's uuid before it looks
|
||||
/// anything up, which renames the file's default to match. This end and that
|
||||
/// one have to land together, and they do.
|
||||
///
|
||||
/// Returns how many rows moved.
|
||||
pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
// Filtered in SQL rather than in the loop: every derived uuid ends in the
|
||||
// tag, so a catalog that has already been realigned selects no rows at all
|
||||
// and this costs one indexed pass instead of twenty-four thousand reads.
|
||||
let already = format!("%-{DERIVED_VERSION_TAG:012x}");
|
||||
let stale: Vec<(i64, i64)> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT v.id, r.file_id
|
||||
FROM versions v
|
||||
JOIN remote r ON r.image_id = v.image_id
|
||||
WHERE v.is_default = 1 AND v.uuid NOT LIKE ?1",
|
||||
)?;
|
||||
let found = stmt
|
||||
.query_map([&already], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
found
|
||||
};
|
||||
if stale.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut moved = 0usize;
|
||||
{
|
||||
// `OR IGNORE` covers the taken-target case described above: the row
|
||||
// keeps the uuid it has, which is the state this build has always
|
||||
// coped with, rather than aborting the transaction.
|
||||
let mut update = tx.prepare("UPDATE OR IGNORE versions SET uuid = ?2 WHERE id = ?1")?;
|
||||
for (row, file_id) in &stale {
|
||||
moved += update.execute(rusqlite::params![row, derived_version_uuid(*file_id)])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// The default version's row id for an image, creating one if it has none.
|
||||
///
|
||||
/// Every write path goes through this rather than assuming a version exists.
|
||||
@@ -128,6 +275,35 @@ pub fn ensure_default_versions_within(conn: &Connection) -> Result<usize, Catalo
|
||||
/// version pass was interrupted between the image insert and the commit.
|
||||
/// Failing a rating because of either would be the wrong answer — the user
|
||||
/// pressed a key and expects a star.
|
||||
/// TRACES: FR-CAT-13
|
||||
/// How `versions.label` encodes a colour label, and back.
|
||||
///
|
||||
/// 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,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
@@ -144,10 +320,21 @@ pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, Cata
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
// Derived from the server's file id where there is one, so the version
|
||||
// this mints is the same one the photographer's other device will mint
|
||||
// (FR-NC-8). A miss here is a library with no server behind it.
|
||||
let file_id: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT file_id FROM remote WHERE image_id = ?1",
|
||||
[image.0 as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (?1, ?2, ?3, 1, 0, 0)",
|
||||
rusqlite::params![image.0 as i64, new_uuid(), DEFAULT_VERSION_NAME],
|
||||
rusqlite::params![image.0 as i64, version_uuid(file_id), DEFAULT_VERSION_NAME],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
@@ -356,15 +543,22 @@ fn flag_from_code(v: i64) -> FlagState {
|
||||
}
|
||||
}
|
||||
|
||||
/// A version UUID.
|
||||
/// A generated version UUID, for a photograph no server has named.
|
||||
///
|
||||
/// Hand-rolled rather than pulling in the `uuid` crate for one function — the
|
||||
/// same reasoning as the date maths in `library_ui`. This needs to be unique
|
||||
/// across devices, not cryptographically unguessable: it keys a merge, and an
|
||||
/// attacker who can write to the sidecar has already won.
|
||||
/// same reasoning as the date maths in `library_ui`. It needs to be unique,
|
||||
/// not cryptographically unguessable: it keys a merge, and an attacker who can
|
||||
/// write to the sidecar has already won.
|
||||
///
|
||||
/// Seeded from the system clock and a per-process counter, so two versions
|
||||
/// created inside the same nanosecond tick still differ.
|
||||
///
|
||||
/// **Unique is not the same as agreed**, which is the distinction that cost a
|
||||
/// photographer a day of culling. Two devices calling this for the same
|
||||
/// photograph get two different answers, and a merge keyed on the result then
|
||||
/// has no pair to reconcile. Anything with a `file_id` behind it must use
|
||||
/// [`derived_version_uuid`]; this is the fallback for libraries that have no
|
||||
/// server to supply one.
|
||||
fn new_uuid() -> String {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -731,3 +925,224 @@ mod tests {
|
||||
assert_eq!(cat.count(&unrated, 0).unwrap(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-8 | FR-NC-9
|
||||
/// The identity two devices have to agree on without talking to each other.
|
||||
#[cfg(test)]
|
||||
mod derived_identity {
|
||||
use super::*;
|
||||
use crate::Catalog;
|
||||
|
||||
/// A catalog whose images the server has named, as a remote scan leaves it.
|
||||
fn with_remote_images(file_ids: &[i64]) -> Catalog {
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
for (i, file_id) in file_ids.iter().enumerate() {
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
|
||||
[format!("img{i:03}.CR3")],
|
||||
)
|
||||
.unwrap();
|
||||
let image = c.last_insert_rowid();
|
||||
c.execute(
|
||||
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
|
||||
rusqlite::params![image, file_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
cat
|
||||
}
|
||||
|
||||
fn default_uuids(cat: &Catalog) -> Vec<String> {
|
||||
let mut stmt = cat
|
||||
.connection()
|
||||
.prepare("SELECT uuid FROM versions WHERE is_default = 1 ORDER BY image_id")
|
||||
.unwrap();
|
||||
stmt.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The whole point: the same photograph, indexed independently on two
|
||||
/// devices, gets one identity. This used to be two.
|
||||
#[test]
|
||||
fn two_devices_derive_the_same_uuid_for_one_photograph() {
|
||||
let laptop = with_remote_images(&[4_812]);
|
||||
let tablet = with_remote_images(&[4_812]);
|
||||
ensure_default_versions(laptop.connection()).unwrap();
|
||||
ensure_default_versions(tablet.connection()).unwrap();
|
||||
|
||||
assert_eq!(default_uuids(&laptop), default_uuids(&tablet));
|
||||
}
|
||||
|
||||
/// And different photographs must still be told apart — the property the
|
||||
/// random uuid did have, which this must not give up to gain agreement.
|
||||
#[test]
|
||||
fn different_photographs_keep_different_uuids() {
|
||||
let cat = with_remote_images(&[1, 2, 3, 0x7FFF_FFFF_FFFF_FFFF]);
|
||||
ensure_default_versions(cat.connection()).unwrap();
|
||||
|
||||
let mut uuids = default_uuids(&cat);
|
||||
let before = uuids.len();
|
||||
uuids.sort();
|
||||
uuids.dedup();
|
||||
assert_eq!(uuids.len(), before, "two photographs share an identity");
|
||||
}
|
||||
|
||||
/// The file id has to survive the layout intact, or two ids that differ
|
||||
/// only in the bits it drops would collide.
|
||||
#[test]
|
||||
fn the_whole_file_id_is_carried() {
|
||||
// A pair differing only in the low four bits, and a pair differing
|
||||
// only in the high thirty-two — the two places a sloppy layout loses
|
||||
// information.
|
||||
assert_ne!(derived_version_uuid(0x10), derived_version_uuid(0x1F));
|
||||
assert_ne!(
|
||||
derived_version_uuid(0x0000_0001_0000_0000),
|
||||
derived_version_uuid(0x0000_0002_0000_0000)
|
||||
);
|
||||
assert_ne!(derived_version_uuid(0), derived_version_uuid(-1));
|
||||
}
|
||||
|
||||
/// Well-formed, and recognisably not a generated one.
|
||||
#[test]
|
||||
fn a_derived_uuid_is_a_well_formed_v8() {
|
||||
let uuid = derived_version_uuid(4_812);
|
||||
let fields: Vec<&str> = uuid.split('-').collect();
|
||||
assert_eq!(fields.len(), 5);
|
||||
assert_eq!(
|
||||
fields.iter().map(|f| f.len()).collect::<Vec<_>>(),
|
||||
vec![8, 4, 4, 4, 12]
|
||||
);
|
||||
assert!(fields[2].starts_with('8'), "version nibble: {uuid}");
|
||||
// The RFC's variant field is the two high bits of the fourth group,
|
||||
// and must read `0b10` — so the first hex digit is 8, 9, a or b.
|
||||
assert!(
|
||||
matches!(fields[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'),
|
||||
"variant: {uuid}"
|
||||
);
|
||||
assert!(uuid.ends_with("d0c5ec0de001"), "tag: {uuid}");
|
||||
}
|
||||
|
||||
/// A library with no server behind it has no shared identity to derive,
|
||||
/// and must still get a version rather than failing.
|
||||
#[test]
|
||||
fn a_library_with_no_server_still_gets_its_versions() {
|
||||
let cat = Catalog::in_memory().unwrap();
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, 'a.CR3', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(ensure_default_versions(c).unwrap(), 1);
|
||||
assert_eq!(default_uuids(&cat).len(), 1);
|
||||
}
|
||||
|
||||
/// The repair. A catalog written by an earlier build holds randomly minted
|
||||
/// uuids, and leaving them there would mean every image already indexed —
|
||||
/// which is all of them — kept writing to its own rival identity.
|
||||
#[test]
|
||||
fn a_catalog_from_an_earlier_build_is_realigned() {
|
||||
let cat = with_remote_images(&[4_812, 4_813]);
|
||||
let c = cat.connection();
|
||||
// As the old code left it.
|
||||
for (i, image) in [1i64, 2].iter().enumerate() {
|
||||
c.execute(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (?1, ?2, 'Default', 1, ?3, 0)",
|
||||
rusqlite::params![image, format!("random-{i}"), (i + 1) as i64],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(align_default_version_uuids(c).unwrap(), 2);
|
||||
assert_eq!(
|
||||
default_uuids(&cat),
|
||||
vec![derived_version_uuid(4_812), derived_version_uuid(4_813)]
|
||||
);
|
||||
|
||||
// The judgement travels with the row — a realignment that dropped the
|
||||
// ratings would be a worse bug than the one it fixes.
|
||||
let ratings: Vec<i64> = {
|
||||
let mut stmt = c
|
||||
.prepare("SELECT rating FROM versions ORDER BY image_id")
|
||||
.unwrap();
|
||||
let v = stmt
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
v
|
||||
};
|
||||
assert_eq!(ratings, vec![1, 2]);
|
||||
}
|
||||
|
||||
/// Runs on every catalog open, so a second pass must select nothing and
|
||||
/// write nothing.
|
||||
#[test]
|
||||
fn realigning_twice_changes_nothing_the_second_time() {
|
||||
let cat = with_remote_images(&[4_812]);
|
||||
ensure_default_versions(cat.connection()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
align_default_version_uuids(cat.connection()).unwrap(),
|
||||
0,
|
||||
"a freshly derived catalog must match nothing"
|
||||
);
|
||||
let before = default_uuids(&cat);
|
||||
align_default_version_uuids(cat.connection()).unwrap();
|
||||
assert_eq!(default_uuids(&cat), before);
|
||||
}
|
||||
|
||||
/// A virtual copy (FR-CAT-12) that already holds the target uuid must not
|
||||
/// take the backfill — and with it the catalog open — down with it.
|
||||
#[test]
|
||||
fn a_taken_target_leaves_the_row_where_it_is() {
|
||||
let cat = with_remote_images(&[4_812]);
|
||||
let c = cat.connection();
|
||||
c.execute(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (1, 'random', 'Default', 1, 0, 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (1, ?1, 'For print', 0, 0, 0)",
|
||||
[derived_version_uuid(4_812)],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(align_default_version_uuids(c).unwrap(), 0);
|
||||
assert_eq!(default_uuids(&cat), vec!["random".to_string()]);
|
||||
}
|
||||
|
||||
/// The backfill is what actually runs this, so it has to be wired in.
|
||||
#[test]
|
||||
fn opening_a_catalog_realigns_it() {
|
||||
let cat = with_remote_images(&[4_812]);
|
||||
cat.connection()
|
||||
.execute(
|
||||
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
|
||||
VALUES (1, 'random', 'Default', 1, 3, 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
crate::schema::backfill(cat.connection()).unwrap();
|
||||
assert_eq!(default_uuids(&cat), vec![derived_version_uuid(4_812)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// Schema version this build writes and understands.
|
||||
pub const SCHEMA_VERSION: i64 = 11;
|
||||
pub const SCHEMA_VERSION: i64 = 18;
|
||||
|
||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
@@ -105,9 +105,99 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 12 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V12)?;
|
||||
tx.pragma_update(None, "user_version", 12)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 13 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V13)?;
|
||||
tx.pragma_update(None, "user_version", 13)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 14 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
// `ALTER TABLE ... ADD COLUMN` has no `IF NOT EXISTS`, and NFR-R5
|
||||
// wants this re-enterable: a catalog whose `user_version` was rewound
|
||||
// by a rollback already has the column, and would otherwise fail its
|
||||
// next open on it.
|
||||
let has_quality: bool = tx
|
||||
.prepare("SELECT 1 FROM pragma_table_info('faces') WHERE name = 'quality'")?
|
||||
.exists([])?;
|
||||
if !has_quality {
|
||||
tx.execute_batch("ALTER TABLE faces ADD COLUMN quality REAL;")?;
|
||||
}
|
||||
tx.execute_batch(V14)?;
|
||||
tx.pragma_update(None, "user_version", 14)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
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()?;
|
||||
}
|
||||
|
||||
Ok(from)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -132,15 +222,27 @@ pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, Catalog
|
||||
|
||||
// v3: every image needs a default version to carry its rating and flag.
|
||||
// Libraries scanned before ratings existed have images and no versions at
|
||||
// all, so there was nowhere for a judgement to go — see
|
||||
// [`crate::rating`]. Backfilled rather than migrated in SQL because the
|
||||
// UUID per row is the cross-device merge identity and must be generated,
|
||||
// not derived.
|
||||
// all, so there was nowhere for a judgement to go — see [`crate::rating`].
|
||||
let n = crate::rating::ensure_default_versions(conn)?;
|
||||
if n > 0 {
|
||||
out.push(("default_versions", n));
|
||||
}
|
||||
|
||||
// TRACES: FR-NC-8 | FR-NC-9
|
||||
// The uuid on those rows is the cross-device merge identity, and it used
|
||||
// to be generated rather than derived. This comment said so, and said it
|
||||
// as though generating it were the point — it was the bug. Two devices
|
||||
// minted different uuids for one photograph, so the sidecar they shared
|
||||
// grew a `default = 1` block each and neither ever saw the other's work.
|
||||
//
|
||||
// Runs after the pass above so a row created a moment ago is already
|
||||
// derived and matches nothing here. Ordering the other way would be
|
||||
// correct too, just wasteful.
|
||||
let n = crate::rating::align_default_version_uuids(conn)?;
|
||||
if n > 0 {
|
||||
out.push(("derived_version_uuids", n));
|
||||
}
|
||||
|
||||
// v6: a vocabulary row for every word some image already carries.
|
||||
//
|
||||
// Three ways a catalog arrives holding assignments with no term behind
|
||||
@@ -158,11 +260,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
|
||||
@@ -225,7 +354,16 @@ pub fn for_attached(schema_name: &str) -> String {
|
||||
format!(
|
||||
"{}\n{}\n{}\n\
|
||||
ALTER TABLE {schema_name}.people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN crop BLOB;",
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN crop BLOB;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN quality REAL;\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),
|
||||
@@ -476,6 +614,224 @@ CREATE TABLE burst_expanded (
|
||||
);
|
||||
"#;
|
||||
|
||||
const V12: &str = r#"
|
||||
-- TRACES: FR-CULL-8
|
||||
-- Forget the runs that were made against a proxy too small to find a face on.
|
||||
--
|
||||
-- Detection used to accept any proxy, and one of the two sweeps detected on
|
||||
-- the stored 1024px tier. On the reference library that produced 0.078 faces
|
||||
-- per image against 1.82 for the same photographs at 2048 or better -- and
|
||||
-- every one of those runs left a `face_index` row behind saying the image had
|
||||
-- been examined. That row is what makes the damage permanent: the work list is
|
||||
-- "images with no row", so a photograph examined badly is indistinguishable
|
||||
-- from one examined well, and is never offered to a later pass.
|
||||
--
|
||||
-- Deleting the marker is the whole repair, and it is deliberately not a
|
||||
-- deletion of anything else. The `faces` rows those runs found stay exactly
|
||||
-- where they are and keep drawing the People screen until a better pass
|
||||
-- replaces them, and `record_detections` carries the user's confirmed names
|
||||
-- across that replacement by box overlap. So this costs a re-fetch of the
|
||||
-- affected images and loses no work the user has done.
|
||||
--
|
||||
-- The threshold is written out rather than taken from `dr_face::MIN_CROP_EDGE`
|
||||
-- on purpose. A migration has to keep meaning what it meant on the day it ran;
|
||||
-- binding it to a constant someone may raise later would silently change what
|
||||
-- an old catalog gets migrated to.
|
||||
DELETE FROM face_index WHERE source_edge <= 1024;
|
||||
"#;
|
||||
|
||||
const V13: &str = r#"
|
||||
-- TRACES: FR-CAT-8 | FR-NC-9
|
||||
-- Which sidecars this device has read, and at what ETag.
|
||||
--
|
||||
-- The sidecar is the authoritative store for a rating and an edit, and until
|
||||
-- this table existed nothing ever read one back into the catalog: judgements
|
||||
-- travelled outward only. A cull done on a tablet reached the server and
|
||||
-- stopped there, because the scan indexes photographs, the derived sync moves
|
||||
-- thumbnails and collections, and the one reader that existed ran when a single
|
||||
-- photograph was opened in develop and fed only the develop graph. The grid
|
||||
-- draws `versions.rating`, so another device's afternoon of culling was
|
||||
-- invisible on this one -- permanently, by every path the app had.
|
||||
--
|
||||
-- What this holds is the ETag, not the content. It is the record of what has
|
||||
-- already been taken in, so a pull fetches only what changed: `dr_sync::scan`
|
||||
-- reports every sidecar it saw in listings it was making anyway, and this
|
||||
-- decides which of them are worth a GET.
|
||||
--
|
||||
-- Keyed on the sidecar's own remote path rather than on an image id. One
|
||||
-- sidecar can describe two images -- a RAW and the JPEG beside it are one
|
||||
-- photograph (FR-CAT-11) and share a document -- and a path is what the scan
|
||||
-- reports and what a fetch addresses, so keying on anything else would mean
|
||||
-- deriving one from the other in two places.
|
||||
--
|
||||
-- Rebuildable like the rest of the catalog: losing this table costs one pass
|
||||
-- that re-reads every sidecar and reaches exactly the same state.
|
||||
--
|
||||
-- `IF NOT EXISTS` because NFR-R5 asks for migrations that are idempotent on
|
||||
-- retry, and this one can genuinely be re-entered: a catalog whose
|
||||
-- `user_version` was rewound -- by a rollback to an older build, or by a
|
||||
-- recovery -- would otherwise fail its next open on a table it already has.
|
||||
CREATE TABLE IF NOT EXISTS sidecars (
|
||||
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
etag TEXT,
|
||||
-- Unix seconds, for diagnosing a pull that is not making progress.
|
||||
read_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(root_id, path)
|
||||
);
|
||||
"#;
|
||||
|
||||
const V14: &str = r#"
|
||||
-- TRACES: FR-CULL-9 | FR-CULL-10
|
||||
-- How recognisable the model found each face, and a second look at the faces
|
||||
-- it was never asked about.
|
||||
--
|
||||
-- The embedder's raw output has a length, and the length is a quality
|
||||
-- reading: it grows with how much of a face the model could make out, and a
|
||||
-- blur, an occlusion or a hard profile comes out short (dr_face::embedding,
|
||||
-- `MIN_GALLERY_QUALITY`). Normalising threw it away. A short vector sits
|
||||
-- near the middle of the sphere and matches a little of everyone, which is
|
||||
-- how one bad crop bridges two people in a grouping pass -- so a face below
|
||||
-- the floor is compared against the others and never compared *against*.
|
||||
--
|
||||
-- Nullable, and NULL means "never measured": every face indexed before this
|
||||
-- version stored the unit vector, whose length is one whatever the crop was.
|
||||
-- A face with no reading is admitted to the gallery, because a rule that
|
||||
-- cannot be checked should admit rather than exclude -- but it is also a
|
||||
-- face this rule is not yet protecting anyone from, and the only way to
|
||||
-- measure it is to embed it again.
|
||||
--
|
||||
-- The `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 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
|
||||
-- when they say so, not here.
|
||||
--
|
||||
-- From this version the `embedding` blob is the **raw** model output rather
|
||||
-- than the unit vector V8 describes -- the length is the quality, and a store
|
||||
-- that kept only the direction had thrown it away. Readers re-normalise on
|
||||
-- load, so a unit blob from before and a raw blob from now compare alike;
|
||||
-- `quality` is that length kept beside the blob for the readers that never
|
||||
-- load the vector, and NULL rather than 1.0 for the old rows, because a unit
|
||||
-- vector reads as a length of one and one is not "unmeasured".
|
||||
--
|
||||
-- The column itself is added in `migrate`, guarded, because ALTER has no
|
||||
-- IF NOT EXISTS and this step has to be re-enterable (NFR-R5).
|
||||
DELETE FROM face_index
|
||||
WHERE EXISTS (SELECT 1 FROM faces f
|
||||
WHERE f.image_id = face_index.image_id
|
||||
AND f.model_id = face_index.model_id);
|
||||
"#;
|
||||
|
||||
const 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)
|
||||
);
|
||||
"#;
|
||||
|
||||
// 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
|
||||
@@ -555,7 +911,7 @@ CREATE TABLE faces (
|
||||
x REAL NOT NULL, y REAL NOT NULL, w REAL NOT NULL, h REAL NOT NULL,
|
||||
landmarks BLOB NOT NULL, -- 5 x (x, y) f32, normalised likewise
|
||||
detector_confidence REAL NOT NULL,
|
||||
embedding BLOB NOT NULL, -- 512 x f16, L2-normalised
|
||||
embedding BLOB NOT NULL, -- 512 x f16; unit length until V14, raw since
|
||||
-- Source pixels across the aligned 112x112 crop (docs/faces.md §7).
|
||||
--
|
||||
-- Not cosmetic: it is the honest quality signal for the UI, a feature in
|
||||
@@ -945,6 +1301,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 {
|
||||
@@ -1232,6 +1642,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();
|
||||
@@ -1268,6 +1707,108 @@ mod tests {
|
||||
assert_eq!(n, 0, "images must not outlive their root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v12_forgets_runs_made_on_a_proxy_too_small_to_see_a_face() {
|
||||
let c = mem();
|
||||
// Migrate to 11, then seed the state V12 exists to repair: markers
|
||||
// written at the 1024 store tier beside ones written on a real
|
||||
// preview.
|
||||
c.pragma_update(None, "user_version", 0).unwrap();
|
||||
migrate(&c).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'test')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (1,1,'a',0),(2,1,'b',0),(3,1,'c',0),(4,1,'d',0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
for (image, edge) in [(1, 896), (2, 1024), (3, 1025), (4, 2560)] {
|
||||
c.execute(
|
||||
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
|
||||
VALUES (?1, 'm', 0, 0, ?2)",
|
||||
rusqlite::params![image, edge],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
c.pragma_update(None, "user_version", 11).unwrap();
|
||||
|
||||
migrate(&c).unwrap();
|
||||
|
||||
let kept: Vec<i64> = c
|
||||
.prepare("SELECT image_id FROM face_index ORDER BY image_id")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
// 1024 goes: it is exactly ThumbSize::Large, the tier that produced
|
||||
// the bad runs. 1025 stays, or the floor and the repair disagree
|
||||
// about the same boundary.
|
||||
assert_eq!(kept, vec![3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v14_forgets_runs_that_found_faces_but_never_measured_them() {
|
||||
let c = mem();
|
||||
c.pragma_update(None, "user_version", 0).unwrap();
|
||||
migrate(&c).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'test')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (1,1,'a',0),(2,1,'b',0),(3,1,'c',0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Image 1 was examined and holds a face; 2 was examined and found
|
||||
// empty; 3 holds a face found by a different model.
|
||||
for (image, model) in [(1, "m"), (2, "m"), (3, "m")] {
|
||||
c.execute(
|
||||
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
|
||||
VALUES (?1, ?2, 0, 0, 2560)",
|
||||
rusqlite::params![image, model],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for (image, model) in [(1, "m"), (3, "other")] {
|
||||
c.execute(
|
||||
"INSERT INTO faces
|
||||
(image_id, x, y, w, h, landmarks, detector_confidence, embedding,
|
||||
crop_px, model_id, detected_at)
|
||||
VALUES (?1, 0.1, 0.1, 0.2, 0.2, X'00', 0.9, X'00', 180.0, ?2, 0)",
|
||||
rusqlite::params![image, model],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
c.pragma_update(None, "user_version", 13).unwrap();
|
||||
|
||||
migrate(&c).unwrap();
|
||||
|
||||
let kept: Vec<i64> = c
|
||||
.prepare("SELECT image_id FROM face_index ORDER BY image_id")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
// 1 goes: it has a face with no quality. 2 stays: nothing on it to
|
||||
// measure. 3 stays: its face belongs to a run this marker does not
|
||||
// describe.
|
||||
assert_eq!(kept, vec![2, 3]);
|
||||
// And the faces themselves are untouched.
|
||||
let faces: i64 = c
|
||||
.query_row("SELECT count(*) FROM faces", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(faces, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_uniqueness_coalesces_rather_than_duplicating() {
|
||||
let c = mem();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,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 +301,10 @@ 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))
|
||||
}
|
||||
|
||||
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 +530,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 +575,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 +658,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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,323 @@
|
||||
//! 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()),
|
||||
..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 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;
|
||||
|
||||
+10
-5
@@ -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/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,6 +31,10 @@ 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
|
||||
@@ -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/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))
|
||||
}
|
||||
@@ -63,15 +63,16 @@ fn main() {
|
||||
let embed_ms = t.elapsed().as_secs_f64() * 1e3;
|
||||
|
||||
println!(
|
||||
" [{i}] conf {:.3} box {:.0},{:.0} {:.0}×{:.0} crop_px {:.0} embed {embed_ms:.0} ms",
|
||||
" [{i}] conf {:.3} box {:.0},{:.0} {:.0}×{:.0} crop_px {:.0} quality {:.1} embed {embed_ms:.0} ms",
|
||||
d.confidence,
|
||||
d.bbox.0,
|
||||
d.bbox.1,
|
||||
d.width(),
|
||||
d.height(),
|
||||
aligned.source_px(),
|
||||
emb.quality,
|
||||
);
|
||||
all.push((path.clone(), i, emb));
|
||||
all.push((path.clone(), i, emb.embedding));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,11 +46,13 @@ fn main() {
|
||||
);
|
||||
for n in sizes {
|
||||
let (embeddings, crop_px, images) = population(n);
|
||||
let gallery = vec![true; n];
|
||||
let faces = Faces {
|
||||
embeddings: &embeddings,
|
||||
dim: EMBEDDING_DIM,
|
||||
crop_px: &crop_px,
|
||||
images: &images,
|
||||
gallery: &gallery,
|
||||
};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
+561
-65
@@ -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
|
||||
@@ -285,34 +290,381 @@ pub fn warp(
|
||||
height: usize,
|
||||
landmarks: &[(f32, f32); 5],
|
||||
) -> Option<Aligned112> {
|
||||
if rgb.len() != width * height * 3 {
|
||||
warp_pixels(Pixels::RgbF32(rgb), width, height, landmarks)
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-8
|
||||
/// What the warp may sample, in whichever layout the caller already holds.
|
||||
///
|
||||
/// # Why the 8-bit variant exists
|
||||
///
|
||||
/// FR-CULL-8 requires the crop to come from the **native** render, and a native
|
||||
/// render is large: a 24 MP frame is 96 MB as `RGBA8` and 288 MB converted to
|
||||
/// the `f32` RGB this module was originally written against. Converting the
|
||||
/// whole frame to sample 112×112 from it is three hundred megabytes allocated
|
||||
/// to read about forty thousand pixels, per image, on a pass that runs over a
|
||||
/// whole library — and on Android it is NFR-RES-2's budget spent outright.
|
||||
///
|
||||
/// So the warp reads whatever the caller has instead. It touches so few pixels
|
||||
/// that the per-sample conversion is free, and the buffer never has to be
|
||||
/// duplicated in another layout.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Pixels<'a> {
|
||||
/// Tightly packed `f32` RGB in `0.0..=1.0`, row-major.
|
||||
RgbF32(&'a [f32]),
|
||||
/// Tightly packed 8-bit RGBA, row-major. Alpha is ignored: a face crop has
|
||||
/// no use for it and carrying it would change what the embedder receives.
|
||||
Rgba8(&'a [u8]),
|
||||
}
|
||||
|
||||
impl Pixels<'_> {
|
||||
/// Whether the buffer is the size `width × height` implies.
|
||||
fn fits(&self, width: usize, height: usize) -> bool {
|
||||
match self {
|
||||
Pixels::RgbF32(v) => v.len() == width * height * 3,
|
||||
Pixels::Rgba8(v) => v.len() == width * height * 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// One channel of one pixel, as `0.0..=1.0`. Outside the buffer reads black.
|
||||
///
|
||||
/// Public because the face *crop* stored for the People screen is cut from
|
||||
/// the same buffer by the same caller, and it should not need a second
|
||||
/// copy of this to do it.
|
||||
pub fn channel(&self, w: usize, h: usize, x: isize, y: isize, c: usize) -> f32 {
|
||||
if x < 0 || y < 0 || x >= w as isize || y >= h as isize {
|
||||
return 0.0;
|
||||
}
|
||||
let i = y as usize * w + x as usize;
|
||||
match self {
|
||||
Pixels::RgbF32(v) => v[i * 3 + c],
|
||||
Pixels::Rgba8(v) => v[i * 4 + c] as f32 / 255.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`warp`], over any layout [`Pixels`] describes.
|
||||
pub fn warp_pixels(
|
||||
px: Pixels<'_>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
landmarks: &[(f32, f32); 5],
|
||||
) -> Option<Aligned112> {
|
||||
if !px.fits(width, height) {
|
||||
return None;
|
||||
}
|
||||
let m = fit_similarity(landmarks, &ARCFACE_TEMPLATE)?;
|
||||
|
||||
let e = ALIGNED_EDGE;
|
||||
let mut pixels = vec![0.0_f32; e * e * 3];
|
||||
for v in 0..e {
|
||||
for u in 0..e {
|
||||
// Pixel centres, so the transform is not off by half a pixel —
|
||||
// which is small enough to survive review and large enough to
|
||||
// matter on a 40-pixel face.
|
||||
let (x, y) = m.invert(u as f32 + 0.5, v as f32 + 0.5);
|
||||
let (x, y) = (x - 0.5, y - 0.5);
|
||||
let out = (v * e + u) * 3;
|
||||
sample_bilinear(rgb, width, height, x, y, &mut pixels[out..out + 3]);
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_bilinear(rgb: &[f32], w: usize, h: usize, x: f32, y: f32, out: &mut [f32]) {
|
||||
/// 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/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/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/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();
|
||||
let fx = x - x0;
|
||||
@@ -321,13 +673,7 @@ fn sample_bilinear(rgb: &[f32], w: usize, h: usize, x: f32, y: f32, out: &mut [f
|
||||
let y0 = y0 as isize;
|
||||
|
||||
for (c, o) in out.iter_mut().enumerate() {
|
||||
let get = |xi: isize, yi: isize| -> f32 {
|
||||
if xi < 0 || yi < 0 || xi >= w as isize || yi >= h as isize {
|
||||
0.0
|
||||
} else {
|
||||
rgb[(yi as usize * w + xi as usize) * 3 + c]
|
||||
}
|
||||
};
|
||||
let get = |xi: isize, yi: isize| -> f32 { px.channel(w, h, xi, yi, c) };
|
||||
let top = get(x0, y0) * (1.0 - fx) + get(x0 + 1, y0) * fx;
|
||||
let bot = get(x0, y0 + 1) * (1.0 - fx) + get(x0 + 1, y0 + 1) * fx;
|
||||
*o = top * (1.0 - fy) + bot * fy;
|
||||
@@ -435,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];
|
||||
@@ -508,6 +973,37 @@ mod tests {
|
||||
/// against a bright sky is low-contrast, and a raw Laplacian variance would
|
||||
/// reject it as blurred — which would quietly throw away every backlit
|
||||
/// portrait in the library.
|
||||
#[test]
|
||||
fn both_pixel_layouts_warp_to_the_same_crop() {
|
||||
// The 8-bit path exists so a native render need not be converted to
|
||||
// f32 whole; it has to agree with the path it replaces to within the
|
||||
// quantisation it introduces.
|
||||
let (w, h) = (64usize, 64usize);
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
let mut rgb = vec![0.0f32; w * h * 3];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let v = [
|
||||
(x * 4 % 256) as u8,
|
||||
(y * 4 % 256) as u8,
|
||||
((x + y) % 256) as u8,
|
||||
];
|
||||
for c in 0..3 {
|
||||
rgba[(y * w + x) * 4 + c] = v[c];
|
||||
rgb[(y * w + x) * 3 + c] = v[c] as f32 / 255.0;
|
||||
}
|
||||
rgba[(y * w + x) * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
let lm = shifted_scaled(0.35, 32.0, 32.0, 0.2);
|
||||
let a = warp_pixels(Pixels::RgbF32(&rgb), w, h, &lm).unwrap();
|
||||
let b = warp_pixels(Pixels::Rgba8(&rgba), w, h, &lm).unwrap();
|
||||
assert_eq!(a.source_px(), b.source_px());
|
||||
for (x, y) in a.pixels().iter().zip(b.pixels()) {
|
||||
assert!((x - y).abs() < 1e-6, "{x} vs {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharpness_survives_the_contrast_being_halved() {
|
||||
let edge = 200;
|
||||
|
||||
+48
-11
@@ -136,11 +136,24 @@ pub const RIVAL_FLOOR: f32 = 0.5;
|
||||
///
|
||||
/// A face in no group, or one with no evidence for anybody, scores 0.
|
||||
///
|
||||
/// `gallery` is one flag per face — which faces may be evidence at all
|
||||
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]). Its length is the face count.
|
||||
/// A pair is evidence *about* either face but only *from* a gallery one: a
|
||||
/// probe learns from the references it matched, and a reference learns nothing
|
||||
/// from a probe that happened to match it, however well. Without that, the one
|
||||
/// short vector in a group would be the strongest match every face in it had.
|
||||
///
|
||||
/// `pairs` must be the *evidence* list — scanned at [`RIVAL_FLOOR`], not at the
|
||||
/// merge threshold. Passing the merge list still works but silently removes
|
||||
/// every rival weaker than a merge, which is most of them, and every uniqueness
|
||||
/// collapses to 1.
|
||||
pub fn identity_shares(faces: usize, clusters: &[Cluster], pairs: &[Pair], top: usize) -> Vec<f32> {
|
||||
pub fn identity_shares(
|
||||
gallery: &[bool],
|
||||
clusters: &[Cluster],
|
||||
pairs: &[Pair],
|
||||
top: usize,
|
||||
) -> Vec<f32> {
|
||||
let faces = gallery.len();
|
||||
// An identity is a *person*, not a group. One person routinely holds
|
||||
// several anchored groups — the same reason they hold several unnamed ones
|
||||
// — and keying this by group had Catherine competing with Catherine, which
|
||||
@@ -174,15 +187,16 @@ pub fn identity_shares(faces: usize, clusters: &[Cluster], pairs: &[Pair], top:
|
||||
}
|
||||
// A pair is evidence in both directions: j's identity hears about i,
|
||||
// and i's identity hears about j. The pair list holds each unordered
|
||||
// pair once, so both have to be recorded here.
|
||||
// pair once, so both have to be recorded here — each only where the
|
||||
// face doing the telling is in the gallery.
|
||||
let (gi, gj) = (group_of[p.i], group_of[p.j]);
|
||||
if gj != usize::MAX {
|
||||
if gj != usize::MAX && gallery[p.j] {
|
||||
evidence[p.i]
|
||||
.entry(key_of[gj])
|
||||
.or_default()
|
||||
.push(p.probability);
|
||||
}
|
||||
if gi != usize::MAX {
|
||||
if gi != usize::MAX && gallery[p.i] {
|
||||
evidence[p.j]
|
||||
.entry(key_of[gi])
|
||||
.or_default()
|
||||
@@ -255,6 +269,11 @@ mod tests {
|
||||
Pair { i, j, probability }
|
||||
}
|
||||
|
||||
/// `n` faces, every one of them fit to be compared against.
|
||||
fn all(n: usize) -> Vec<bool> {
|
||||
vec![true; n]
|
||||
}
|
||||
|
||||
/// The failure the module exists to fix: face 0 matches its own group's
|
||||
/// three members strongly, and the group has forty more it is unrelated to.
|
||||
/// The old within-group mean reported ~0.07 for this.
|
||||
@@ -264,7 +283,7 @@ mod tests {
|
||||
let clusters = vec![cluster(&members)];
|
||||
let pairs = vec![pair(0, 1, 0.99), pair(0, 2, 0.97), pair(0, 3, 0.95)];
|
||||
|
||||
let shares = identity_shares(44, &clusters, &pairs, TOP_MATCHES);
|
||||
let shares = identity_shares(&all(44), &clusters, &pairs, TOP_MATCHES);
|
||||
assert!(
|
||||
(shares[0] - 0.97).abs() < 1e-6,
|
||||
"the mean of its three real matches, undiluted: {}",
|
||||
@@ -284,7 +303,7 @@ mod tests {
|
||||
pair(0, 4, 0.90),
|
||||
];
|
||||
|
||||
let shares = identity_shares(5, &clusters, &pairs, TOP_MATCHES);
|
||||
let shares = identity_shares(&all(5), &clusters, &pairs, TOP_MATCHES);
|
||||
// Coherent at 0.90, and only half of the evidence is its own.
|
||||
assert!(
|
||||
(shares[0] - 0.45).abs() < 1e-6,
|
||||
@@ -298,9 +317,9 @@ mod tests {
|
||||
#[test]
|
||||
fn a_rival_too_weak_to_merge_still_lowers_the_confidence() {
|
||||
let clusters = vec![named(&[0, 1], 1), named(&[2, 3], 2)];
|
||||
let sure = identity_shares(4, &clusters, &[pair(0, 1, 0.95)], TOP_MATCHES);
|
||||
let sure = identity_shares(&all(4), &clusters, &[pair(0, 1, 0.95)], TOP_MATCHES);
|
||||
let contested = identity_shares(
|
||||
4,
|
||||
&all(4),
|
||||
&clusters,
|
||||
&[pair(0, 1, 0.95), pair(0, 2, 0.60)],
|
||||
TOP_MATCHES,
|
||||
@@ -321,7 +340,7 @@ mod tests {
|
||||
fn an_unnamed_group_is_not_treated_as_competition() {
|
||||
let clusters = vec![cluster(&[0, 1]), cluster(&[2, 3])];
|
||||
let shares = identity_shares(
|
||||
4,
|
||||
&all(4),
|
||||
&clusters,
|
||||
&[pair(0, 1, 0.95), pair(0, 2, 0.90)],
|
||||
TOP_MATCHES,
|
||||
@@ -343,7 +362,7 @@ mod tests {
|
||||
let mut pairs: Vec<Pair> = (1..11).map(|j| pair(0, j, 0.90)).collect();
|
||||
pairs.extend((11..62).map(|j| pair(0, j, 0.55)));
|
||||
|
||||
let shares = identity_shares(62, &clusters, &pairs, TOP_MATCHES);
|
||||
let shares = identity_shares(&all(62), &clusters, &pairs, TOP_MATCHES);
|
||||
// Ten at 0.90 against ten at 0.55 — not fifty-one at 0.55.
|
||||
assert!(
|
||||
(shares[0] - 0.90 * (9.0 / 14.5)).abs() < 1e-5,
|
||||
@@ -352,11 +371,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A probe learns from the references it matched; a reference learns
|
||||
/// nothing from a probe. The pair is the same pair — what differs is who
|
||||
/// is doing the telling.
|
||||
#[test]
|
||||
fn a_face_outside_the_gallery_is_nobody_s_evidence() {
|
||||
let clusters = vec![named(&[0, 1, 2], 1)];
|
||||
let gallery = vec![true, true, false];
|
||||
let pairs = vec![pair(0, 1, 0.80), pair(0, 2, 0.99), pair(1, 2, 0.99)];
|
||||
|
||||
let shares = identity_shares(&gallery, &clusters, &pairs, TOP_MATCHES);
|
||||
// Faces 0 and 1 hear only from each other: the 0.99 the probe offered
|
||||
// them is not counted.
|
||||
assert!((shares[0] - 0.80).abs() < 1e-6, "{}", shares[0]);
|
||||
assert!((shares[1] - 0.80).abs() < 1e-6, "{}", shares[1]);
|
||||
// The probe hears from both references.
|
||||
assert!((shares[2] - 0.99).abs() < 1e-6, "{}", shares[2]);
|
||||
}
|
||||
|
||||
/// A face nothing has any evidence about claims nothing.
|
||||
#[test]
|
||||
fn a_face_with_no_evidence_reports_no_confidence() {
|
||||
let clusters = vec![cluster(&[0, 1])];
|
||||
let shares = identity_shares(2, &clusters, &[], TOP_MATCHES);
|
||||
let shares = identity_shares(&all(2), &clusters, &[], TOP_MATCHES);
|
||||
assert_eq!(shares, vec![0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,20 @@
|
||||
//! fails. The exceptions (mirrors, photographs of photographs, collages) are
|
||||
//! rare enough to be noise at this scale.
|
||||
//!
|
||||
//! **Positives have to be earned.** In order of trustworthiness: pairs the user
|
||||
//! has confirmed onto one person; then burst siblings, since FR-CULL-5 already
|
||||
//! groups bursts and two faces in adjacent frames are near-certainly the same
|
||||
//! person. Nothing else — bootstrapping positives from high cosine is circular,
|
||||
//! fitting the calibration to the belief it was supposed to test.
|
||||
//! **Positives have to be earned.** A positive is a pair of faces the user has
|
||||
//! confirmed onto one person (FR-CULL-10), and there is no second source: the
|
||||
//! only labelling this subsystem has is the labelling somebody did by hand.
|
||||
//! Bootstrapping positives from a high cosine is circular — it fits the
|
||||
//! calibration to the belief it was supposed to test — and that is the whole
|
||||
//! of the alternative.
|
||||
//!
|
||||
//! docs/faces.md §8.1 names one more that would cost no labelling at all: two
|
||||
//! faces in adjacent frames of one burst are near-certainly the same person,
|
||||
//! and FR-CULL-5's grouping is sitting there. Nothing draws on it. This crate
|
||||
//! cannot see a catalog, let alone the bursts in one — it is handed cosines by
|
||||
//! whoever assembled the pair — and no caller does that assembly on its behalf
|
||||
//! yet. Until one does, and until the purity of a burst pair is *measured*
|
||||
//! rather than assumed, the positives are the confirmations and nothing else.
|
||||
//!
|
||||
//! Which is why a fresh library has **no valid calibration** — no fit of its
|
||||
//! own — and says so. It is not left without a curve: it uses the reference
|
||||
@@ -44,9 +53,10 @@ const BINS: usize = 200;
|
||||
/// Far stricter than the reference implementation's floor of two positives and
|
||||
/// one negative. That floor is reasonable there: its pairs come from a curated
|
||||
/// gallery of labelled reference portraits, where a positive pair is
|
||||
/// trustworthy by construction. Here the positives are bootstrapped from bursts
|
||||
/// and a handful of early confirmations, and the whole risk is fitting
|
||||
/// confidently to too few of them.
|
||||
/// trustworthy by construction. Here every positive is a pair somebody
|
||||
/// confirmed while working through a young library's suggestions — a handful,
|
||||
/// arriving slowly — and the whole risk is fitting confidently to too few of
|
||||
/// them.
|
||||
pub const MIN_POSITIVE_PAIRS: u64 = 200;
|
||||
pub const MIN_NEGATIVE_PAIRS: u64 = 2_000;
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! TRACES: FR-CULL-8a
|
||||
//! The two small classifiers behind a face's eye state (docs/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)))
|
||||
}
|
||||
}
|
||||
+307
-3
@@ -19,6 +19,23 @@
|
||||
//! and clustering never moves it. Two groups holding confirmations of
|
||||
//! *different* people cannot merge, whatever their similarity says.
|
||||
//!
|
||||
//! # The gallery, and the faces that are only ever compared against it
|
||||
//!
|
||||
//! A third defence, and the cheapest of all: **a short embedding is never a
|
||||
//! reference.** The length of the raw vector is the model's own reading of
|
||||
//! how recognisable the crop was ([`crate::embedding::MIN_GALLERY_QUALITY`]),
|
||||
//! and a short one sits near the centre of the sphere, matching a little of
|
||||
//! everybody. One of those in a group is a bridge to the next group over.
|
||||
//!
|
||||
//! So the population is split. Faces at or above the floor are the
|
||||
//! **gallery**, and they cluster exactly as described below. Faces under it
|
||||
//! are **probes**: each is measured against the finished groups and joins the
|
||||
//! one it fits, by the same average-link rule and under the same constraints
|
||||
//! — but it is measured against the gallery members only, never against
|
||||
//! another probe, and once placed it is never part of what the next face is
|
||||
//! measured against. A blurred photograph of a known person is still named;
|
||||
//! it just cannot vouch for anyone else.
|
||||
//!
|
||||
//! # Average link, not single link
|
||||
//!
|
||||
//! Single-link chains: one bad edge welds two identities together, and it is
|
||||
@@ -117,6 +134,11 @@ pub struct Candidate {
|
||||
pub embedding: Vec<f32>,
|
||||
/// Source pixels across the aligned crop, for the calibration's size term.
|
||||
pub crop_px: f32,
|
||||
/// Length of the raw embedding, where it was recorded
|
||||
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]). `None` for a face indexed
|
||||
/// before it was kept, which is admitted to the gallery — see
|
||||
/// [`Candidate::in_gallery`].
|
||||
pub quality: Option<f32>,
|
||||
/// The person this face is *confirmed* to be, if any.
|
||||
///
|
||||
/// Suggestions are deliberately not passed here. They are this function's
|
||||
@@ -125,6 +147,13 @@ pub struct Candidate {
|
||||
pub confirmed_person: Option<u64>,
|
||||
}
|
||||
|
||||
impl Candidate {
|
||||
/// Whether this face may be compared *against*, as well as compared.
|
||||
pub fn in_gallery(&self) -> bool {
|
||||
crate::embedding::in_gallery(self.quality)
|
||||
}
|
||||
}
|
||||
|
||||
/// One group of faces the clusterer believes are one person.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Cluster {
|
||||
@@ -202,7 +231,7 @@ pub fn cluster_scored(faces: &[Candidate], cal: &Calibration, min_probability: f
|
||||
|
||||
let clusters = build(faces, cal, min_probability, &merges);
|
||||
let confidence = crate::assign::identity_shares(
|
||||
faces.len(),
|
||||
&columns.gallery,
|
||||
&clusters,
|
||||
&evidence,
|
||||
crate::assign::TOP_MATCHES,
|
||||
@@ -225,6 +254,7 @@ struct Columns {
|
||||
dim: usize,
|
||||
crop_px: Vec<f32>,
|
||||
images: Vec<u64>,
|
||||
gallery: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Columns {
|
||||
@@ -245,6 +275,7 @@ impl Columns {
|
||||
dim,
|
||||
crop_px: faces.iter().map(|f| f.crop_px).collect(),
|
||||
images: faces.iter().map(|f| f.image).collect(),
|
||||
gallery: faces.iter().map(Candidate::in_gallery).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,22 +285,171 @@ impl Columns {
|
||||
dim: self.dim,
|
||||
crop_px: &self.crop_px,
|
||||
images: &self.images,
|
||||
gallery: &self.gallery,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Agglomerate the gallery over its pairs, then place the probes.
|
||||
///
|
||||
/// `pairs` is what [`neighbours::above_threshold`] returned: every pair has a
|
||||
/// gallery side, but a pair with a probe on the other side is not a merge —
|
||||
/// it is the evidence [`place_probes`] works from. Only the gallery-to-gallery
|
||||
/// pairs reach the engine, so a probe enters it as a singleton with no edges
|
||||
/// and comes out exactly as it went in.
|
||||
fn build(
|
||||
faces: &[Candidate],
|
||||
cal: &Calibration,
|
||||
min_probability: f32,
|
||||
pairs: &[neighbours::Pair],
|
||||
) -> Vec<Cluster> {
|
||||
let gallery: Vec<bool> = faces.iter().map(Candidate::in_gallery).collect();
|
||||
let (merges, probe_pairs): (Vec<_>, Vec<_>) = pairs
|
||||
.iter()
|
||||
.copied()
|
||||
.partition(|p| gallery[p.i] && gallery[p.j]);
|
||||
|
||||
let mut engine = Engine::new(faces, cal, min_probability);
|
||||
let parts = components(faces.len(), pairs);
|
||||
let parts = components(faces.len(), &merges);
|
||||
for (component, edges) in parts.members.iter().zip(&parts.edges) {
|
||||
engine.agglomerate(component, edges);
|
||||
}
|
||||
engine.finish()
|
||||
let dot = engine.dot;
|
||||
let clusters = engine.finish();
|
||||
if probe_pairs.is_empty() {
|
||||
return clusters;
|
||||
}
|
||||
place_probes(
|
||||
faces,
|
||||
cal,
|
||||
min_probability,
|
||||
dot,
|
||||
&gallery,
|
||||
clusters,
|
||||
&probe_pairs,
|
||||
)
|
||||
}
|
||||
|
||||
/// Put each probe into the finished group it fits, or leave it alone.
|
||||
///
|
||||
/// The same decision the engine makes for a singleton — average link over the
|
||||
/// group, at or above `min_probability`, subject to [`Engine::can_link`]'s two
|
||||
/// constraints — with one difference that is the whole point: the average is
|
||||
/// over the group's **gallery** members. A probe already placed is not part of
|
||||
/// what the next one is measured against, so a run of short vectors cannot
|
||||
/// pull each other in one after another.
|
||||
///
|
||||
/// Probes are placed in index order and each placement is final, which is
|
||||
/// what keeps this deterministic. The group a probe joins gains its
|
||||
/// photograph, so a second face from the same frame cannot follow it — the
|
||||
/// co-occurrence rule, applied exactly as the engine applies it.
|
||||
fn place_probes(
|
||||
faces: &[Candidate],
|
||||
cal: &Calibration,
|
||||
min_probability: f32,
|
||||
dot: neighbours::DotFn,
|
||||
gallery: &[bool],
|
||||
mut clusters: Vec<Cluster>,
|
||||
probe_pairs: &[neighbours::Pair],
|
||||
) -> Vec<Cluster> {
|
||||
// Where each face sits, and what each group's photographs and gallery
|
||||
// members are. The probe's own singleton is here too, and is dropped once
|
||||
// it has moved.
|
||||
let mut group_of = vec![usize::MAX; faces.len()];
|
||||
for (g, c) in clusters.iter().enumerate() {
|
||||
for &m in &c.members {
|
||||
group_of[m] = g;
|
||||
}
|
||||
}
|
||||
let mut images: Vec<HashSet<u64>> = clusters
|
||||
.iter()
|
||||
.map(|c| c.members.iter().map(|&m| faces[m].image).collect())
|
||||
.collect();
|
||||
let references: Vec<Vec<usize>> = clusters
|
||||
.iter()
|
||||
.map(|c| c.members.iter().copied().filter(|&m| gallery[m]).collect())
|
||||
.collect();
|
||||
|
||||
// Which groups each probe has any above-threshold pair into. Only those
|
||||
// can average above the threshold — the argument the module note makes
|
||||
// for the engine holds here unchanged.
|
||||
let mut candidates: Vec<Vec<usize>> = vec![Vec::new(); faces.len()];
|
||||
for p in probe_pairs {
|
||||
let (probe, reference) = if gallery[p.i] { (p.j, p.i) } else { (p.i, p.j) };
|
||||
candidates[probe].push(group_of[reference]);
|
||||
}
|
||||
|
||||
let mut moved: Vec<usize> = Vec::new();
|
||||
for probe in 0..faces.len() {
|
||||
if gallery[probe] || candidates[probe].is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut groups = std::mem::take(&mut candidates[probe]);
|
||||
groups.sort_unstable();
|
||||
groups.dedup();
|
||||
|
||||
let face = &faces[probe];
|
||||
let mut best: Option<(f32, usize)> = None;
|
||||
for g in groups {
|
||||
let target = &clusters[g];
|
||||
if let (Some(mine), Some(theirs)) = (face.confirmed_person, target.person) {
|
||||
if mine != theirs {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if images[g].contains(&face.image) {
|
||||
continue;
|
||||
}
|
||||
let (mut sum, mut count) = (0.0_f64, 0.0_f64);
|
||||
for &r in &references[g] {
|
||||
let cos = dot(&face.embedding, &faces[r].embedding);
|
||||
let min_crop = face.crop_px.min(faces[r].crop_px);
|
||||
sum += cal.probability(cos, min_crop, 0.0) as f64;
|
||||
count += 1.0;
|
||||
}
|
||||
if count == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let p = (sum / count) as f32;
|
||||
// Strictly better wins; on a tie the lowest group index, which is
|
||||
// the engine's own tiebreak.
|
||||
if p >= min_probability && best.is_none_or(|(bp, _)| p > bp) {
|
||||
best = Some((p, g));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((_, g)) = best else { continue };
|
||||
let own = group_of[probe];
|
||||
clusters[g].members.push(probe);
|
||||
clusters[g].members.sort_unstable();
|
||||
clusters[g].person = clusters[g].person.or(face.confirmed_person);
|
||||
images[g].insert(face.image);
|
||||
group_of[probe] = g;
|
||||
moved.push(own);
|
||||
}
|
||||
|
||||
if moved.is_empty() {
|
||||
return clusters;
|
||||
}
|
||||
// The singletons the probes left behind, then the order `Engine::finish`
|
||||
// promises: largest first, lowest member first among equals.
|
||||
let mut vacated = vec![false; clusters.len()];
|
||||
for g in moved {
|
||||
vacated[g] = true;
|
||||
}
|
||||
let mut out: Vec<Cluster> = clusters
|
||||
.into_iter()
|
||||
.zip(vacated)
|
||||
.filter(|(_, gone)| !gone)
|
||||
.map(|(c, _)| c)
|
||||
.collect();
|
||||
out.sort_by(|x, y| {
|
||||
y.members
|
||||
.len()
|
||||
.cmp(&x.members.len())
|
||||
.then(x.members[0].cmp(&y.members[0]))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Split one person's faces into the groups a raised threshold separates them
|
||||
@@ -726,10 +906,19 @@ mod tests {
|
||||
image,
|
||||
embedding: at_cosine(identity, cosine),
|
||||
crop_px: 150.0,
|
||||
quality: None,
|
||||
confirmed_person: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A face too short to be a reference: compared, never compared against.
|
||||
fn probe(face: u64, image: u64, identity: usize, cosine: f32) -> Candidate {
|
||||
Candidate {
|
||||
quality: Some(crate::embedding::MIN_GALLERY_QUALITY - 5.0),
|
||||
..candidate(face, image, identity, cosine)
|
||||
}
|
||||
}
|
||||
|
||||
/// A calibration steep enough that the test's cosines are unambiguous:
|
||||
/// 0.6 is near-certain, 0.1 is near-impossible.
|
||||
fn cal() -> Calibration {
|
||||
@@ -1099,6 +1288,7 @@ mod tests {
|
||||
image,
|
||||
embedding: at_cosine(p, cosine),
|
||||
crop_px: 60.0 + ((out.len() % 11) as f32) * 25.0,
|
||||
quality: None,
|
||||
confirmed_person: None,
|
||||
});
|
||||
image += 1;
|
||||
@@ -1182,6 +1372,7 @@ mod tests {
|
||||
image: 5_000,
|
||||
embedding: at_cosine(200, 1.0),
|
||||
crop_px: 150.0,
|
||||
quality: None,
|
||||
confirmed_person: None,
|
||||
});
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
@@ -1191,4 +1382,117 @@ mod tests {
|
||||
"the outlier was absorbed"
|
||||
);
|
||||
}
|
||||
|
||||
// ── the gallery ───────────────────────────────────────────────────────
|
||||
|
||||
/// A short vector is still somebody: it joins the group it matches.
|
||||
#[test]
|
||||
fn a_probe_joins_the_group_it_matches() {
|
||||
let faces = vec![
|
||||
candidate(1, 10, 0, 1.0),
|
||||
candidate(2, 11, 0, 0.95),
|
||||
probe(3, 12, 0, 0.92),
|
||||
];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].members, vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
/// Two short vectors that resemble each other are noise agreeing with
|
||||
/// noise, and there is nothing in the gallery for either to be measured
|
||||
/// against.
|
||||
#[test]
|
||||
fn two_probes_are_never_grouped_with_each_other() {
|
||||
let faces = vec![probe(1, 10, 0, 1.0), probe(2, 11, 0, 0.98)];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(out.len(), 2, "two probes were grouped: {out:?}");
|
||||
}
|
||||
|
||||
/// The point of measuring against the gallery only: a probe that has been
|
||||
/// placed is not a stepping stone for the next one.
|
||||
#[test]
|
||||
fn a_placed_probe_is_not_what_the_next_probe_is_measured_against() {
|
||||
let mut first = probe(2, 11, 0, 0.6);
|
||||
// 0.6 along identity 0 and 0.8 along its perpendicular: near enough to
|
||||
// the reference to join it, and much nearer to the face below.
|
||||
first.embedding = at_cosine(0, 0.6);
|
||||
let mut second = probe(3, 12, 0, 0.0);
|
||||
second.embedding = at_cosine(0, 0.0);
|
||||
let faces = vec![candidate(1, 10, 0, 1.0), first, second];
|
||||
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
let group = out.iter().find(|c| c.members.contains(&0)).unwrap();
|
||||
assert_eq!(
|
||||
group.members,
|
||||
vec![0, 1],
|
||||
"the first probe should have joined"
|
||||
);
|
||||
assert!(
|
||||
out.iter().any(|c| c.members == vec![2]),
|
||||
"the second probe reached the group through the first: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A confirmation on a probe is still the user's word: the group it joins
|
||||
/// becomes that person, and a group already someone else's is closed to it.
|
||||
#[test]
|
||||
fn a_probe_carries_its_confirmation_and_respects_others() {
|
||||
let mut anchored = probe(3, 12, 0, 0.92);
|
||||
anchored.confirmed_person = Some(7);
|
||||
let faces = vec![
|
||||
candidate(1, 10, 0, 1.0),
|
||||
candidate(2, 11, 0, 0.95),
|
||||
anchored,
|
||||
];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].person, Some(7));
|
||||
|
||||
let mut theirs = candidate(1, 10, 0, 1.0);
|
||||
theirs.confirmed_person = Some(8);
|
||||
let faces = vec![theirs, candidate(2, 11, 0, 0.95), {
|
||||
let mut a = probe(3, 12, 0, 0.92);
|
||||
a.confirmed_person = Some(7);
|
||||
a
|
||||
}];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert!(
|
||||
out.iter()
|
||||
.any(|c| c.members == vec![2] && c.person == Some(7)),
|
||||
"a probe confirmed as one person joined another's group: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The co-occurrence rule follows a probe in: once it has joined, its
|
||||
/// photograph is the group's.
|
||||
#[test]
|
||||
fn a_probe_cannot_join_a_group_holding_a_face_from_its_own_photograph() {
|
||||
let faces = vec![
|
||||
candidate(1, 10, 0, 1.0),
|
||||
candidate(2, 11, 0, 0.95),
|
||||
probe(3, 10, 0, 0.92),
|
||||
];
|
||||
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
assert!(out.iter().any(|c| c.members == vec![2]), "{out:?}");
|
||||
}
|
||||
|
||||
/// A probe's placement is scored like anyone else's, from the references
|
||||
/// it matched — and the references' own scores do not hear from it.
|
||||
#[test]
|
||||
fn a_probe_is_scored_but_is_not_evidence() {
|
||||
let gallery_only = vec![candidate(1, 10, 0, 1.0), candidate(2, 11, 0, 0.95)];
|
||||
let without = cluster_scored(&gallery_only, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
|
||||
let mut with_probe = gallery_only.clone();
|
||||
with_probe.push(probe(3, 12, 0, 0.99));
|
||||
let with = cluster_scored(&with_probe, &cal(), DEFAULT_MERGE_PROBABILITY);
|
||||
|
||||
assert_eq!(with.clusters[0].members, vec![0, 1, 2]);
|
||||
assert!(with.confidence[2] > 0.9, "{}", with.confidence[2]);
|
||||
assert_eq!(
|
||||
&with.confidence[..2],
|
||||
&without.confidence[..],
|
||||
"a probe changed what the references were sure of"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+36
-14
@@ -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/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)?
|
||||
])
|
||||
|
||||
+60
-16
@@ -14,11 +14,47 @@ 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.
|
||||
///
|
||||
/// Two fields rather than a `quality` on [`Embedding`], because every other
|
||||
/// holder of an `Embedding` relies on it being unit length and compares by
|
||||
/// dot product; the length is a separate fact about the same face, and it is
|
||||
/// stored separately too.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Embedded {
|
||||
pub embedding: Embedding,
|
||||
/// L2 norm of the raw model output.
|
||||
///
|
||||
/// The model's own opinion of how recognisable the crop was — see
|
||||
/// [`crate::embedding::MIN_GALLERY_QUALITY`] for what it means and where
|
||||
/// it is used.
|
||||
pub quality: f32,
|
||||
}
|
||||
|
||||
impl Embedded {
|
||||
/// Storage form: the **raw** vector, `512 × f16`.
|
||||
///
|
||||
/// Not the unit vector. The length is the quality, and a store that held
|
||||
/// only the direction would have thrown it away at the one moment it could
|
||||
/// be known — which is what this crate used to do. Readers re-normalise
|
||||
/// ([`Embedding::from_f16_bytes`]), so every comparison is still a dot
|
||||
/// product, and [`crate::embedding::read_f16_bytes`] gives the length back
|
||||
/// to a reader that wants it.
|
||||
///
|
||||
/// f16 costs nothing extra at this scale: its precision is relative, so a
|
||||
/// component of a vector of length 20 is kept to the same three figures as
|
||||
/// the same component scaled to length 1.
|
||||
pub fn to_f16_bytes(&self) -> Vec<u8> {
|
||||
self.embedding.to_f16_bytes_scaled(self.quality)
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded ArcFace graph.
|
||||
pub struct Embedder {
|
||||
session: ort::session::Session,
|
||||
session: Model,
|
||||
model: ModelId,
|
||||
}
|
||||
|
||||
@@ -29,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/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
|
||||
@@ -55,7 +90,12 @@ impl Embedder {
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self { session, model })
|
||||
drop(session);
|
||||
drop(acquired);
|
||||
Ok(Self {
|
||||
session: loaded,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &ModelId {
|
||||
@@ -63,7 +103,7 @@ impl Embedder {
|
||||
}
|
||||
|
||||
/// Embed one aligned face.
|
||||
pub fn embed(&mut self, face: &Aligned112) -> Result<Embedding, FaceError> {
|
||||
pub fn embed(&mut self, face: &Aligned112) -> Result<Embedded, FaceError> {
|
||||
// `(x·255 − 127.5) / 128` — see the `/128` note in `detect::Letterbox`.
|
||||
let px = face.pixels();
|
||||
let mut input = Array4::<f32>::zeros((1, 3, ALIGNED_EDGE, ALIGNED_EDGE));
|
||||
@@ -76,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)?
|
||||
])
|
||||
@@ -95,11 +136,14 @@ impl Embedder {
|
||||
|
||||
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
|
||||
v.copy_from_slice(&data[..EMBEDDING_DIM]);
|
||||
normalise(&mut v);
|
||||
let quality = normalise(&mut v);
|
||||
|
||||
Ok(Embedding {
|
||||
model: self.model.clone(),
|
||||
v,
|
||||
Ok(Embedded {
|
||||
embedding: Embedding {
|
||||
model: self.model.clone(),
|
||||
v,
|
||||
},
|
||||
quality,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+119
-18
@@ -12,6 +12,43 @@
|
||||
/// Embedding dimensionality. Fixed by the model family, not a parameter.
|
||||
pub const EMBEDDING_DIM: usize = 512;
|
||||
|
||||
/// The shortest raw embedding a face may be *compared against*.
|
||||
///
|
||||
/// # What the length of the vector says
|
||||
///
|
||||
/// ArcFace is trained on the direction of its output and nothing else, and
|
||||
/// the length it leaves behind turns out to be a free quality signal: the
|
||||
/// magnitude grows with how recognisable the crop was to the model, and a
|
||||
/// blurred, occluded, badly lit or hard-profile face comes out short. MagFace
|
||||
/// (Meng et al., CVPR 2021) made that the training objective; the plain
|
||||
/// ArcFace heads this crate runs already show it, weaker but usable, which is
|
||||
/// why it is worth keeping the number the normalisation discards.
|
||||
///
|
||||
/// # Why it gates the gallery and not the face
|
||||
///
|
||||
/// A short vector is a bad *reference*: it sits nearer the centre of the
|
||||
/// sphere than a real identity does and matches a little of everyone, which
|
||||
/// is exactly the face that welds two people together in a clustering pass.
|
||||
/// It is not a bad *probe* — the face is still real, still somebody, and
|
||||
/// comparing it against good references is the only way it will ever be named.
|
||||
/// So a face below this floor is compared against the gallery and never
|
||||
/// becomes part of it: see `cluster::Candidate::in_gallery`.
|
||||
///
|
||||
/// 14 is the operating point for `w600k_mbf`, whose norms on the reference
|
||||
/// library run from about 8 on a blur to the high 20s on a clean portrait. A
|
||||
/// face whose quality was never recorded — indexed before the number was kept
|
||||
/// — is not gated, because a rule that cannot be checked should admit, not
|
||||
/// exclude.
|
||||
pub const MIN_GALLERY_QUALITY: f32 = 14.0;
|
||||
|
||||
/// Whether an embedding of this quality may serve as a reference.
|
||||
///
|
||||
/// `None` is "not measured", and is admitted: the rule is about a number that
|
||||
/// was read and found short, not about a number that is missing.
|
||||
pub fn in_gallery(quality: Option<f32>) -> bool {
|
||||
quality.is_none_or(|q| q >= MIN_GALLERY_QUALITY)
|
||||
}
|
||||
|
||||
/// Which model produced an embedding.
|
||||
///
|
||||
/// Embeddings from different models are not comparable, and this is the one
|
||||
@@ -58,10 +95,19 @@ impl Embedding {
|
||||
}
|
||||
|
||||
/// Storage form: `512 × f16`, 1 KB per face (catalog.md §10.1).
|
||||
///
|
||||
/// This writes the unit vector. What the catalog stores is the raw one —
|
||||
/// `embed::Embedded::to_f16_bytes` — because the length is the quality
|
||||
/// and a unit vector has none left to read.
|
||||
pub fn to_f16_bytes(&self) -> Vec<u8> {
|
||||
self.to_f16_bytes_scaled(1.0)
|
||||
}
|
||||
|
||||
/// The unit vector scaled by `length`, as `512 × f16`.
|
||||
pub(crate) fn to_f16_bytes_scaled(&self, length: f32) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(EMBEDDING_DIM * 2);
|
||||
for &x in self.v.iter() {
|
||||
out.extend_from_slice(&f32_to_f16_bits(x).to_le_bytes());
|
||||
out.extend_from_slice(&f32_to_f16_bits(x * length).to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -71,25 +117,42 @@ impl Embedding {
|
||||
/// The f16 round-trip perturbs a unit vector by ~1e-3 in cosine — three
|
||||
/// orders below the separation between a match and a non-match — but the
|
||||
/// drift is free to remove and invisible if left, so it is removed here
|
||||
/// rather than remembered at every call site.
|
||||
/// rather than remembered at every call site. The same pass is what turns
|
||||
/// a stored raw vector back into the unit one every comparison expects.
|
||||
pub fn from_f16_bytes(model: ModelId, bytes: &[u8]) -> Option<Self> {
|
||||
if bytes.len() != EMBEDDING_DIM * 2 {
|
||||
return None;
|
||||
}
|
||||
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
|
||||
for (i, chunk) in bytes.chunks_exact(2).enumerate() {
|
||||
v[i] = f16_bits_to_f32(u16::from_le_bytes([chunk[0], chunk[1]]));
|
||||
}
|
||||
normalise(&mut v);
|
||||
Some(Self { model, v })
|
||||
read_f16_bytes(model, bytes).map(|(e, _)| e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a stored vector back, with the length it was stored at.
|
||||
///
|
||||
/// The length is the quality where the blob is a raw one, and ~1 where it is
|
||||
/// a unit vector from before raw vectors were stored — which is why the
|
||||
/// catalog keeps the quality beside the blob rather than deriving it from
|
||||
/// this: a unit vector reads as a quality of 1, not as "unmeasured".
|
||||
pub fn read_f16_bytes(model: ModelId, bytes: &[u8]) -> Option<(Embedding, f32)> {
|
||||
if bytes.len() != EMBEDDING_DIM * 2 {
|
||||
return None;
|
||||
}
|
||||
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
|
||||
for (i, chunk) in bytes.chunks_exact(2).enumerate() {
|
||||
v[i] = f16_bits_to_f32(u16::from_le_bytes([chunk[0], chunk[1]]));
|
||||
}
|
||||
let length = normalise(&mut v);
|
||||
Some((Embedding { model, v }, length))
|
||||
}
|
||||
|
||||
fn dot(a: &[f32; EMBEDDING_DIM], b: &[f32; EMBEDDING_DIM]) -> f32 {
|
||||
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
pub(crate) fn normalise(v: &mut [f32; EMBEDDING_DIM]) {
|
||||
/// Scale `v` to unit length, and return the length it had.
|
||||
///
|
||||
/// The length is the one thing about the raw output that survives being
|
||||
/// thrown away by everything downstream, and it is a quality signal
|
||||
/// ([`MIN_GALLERY_QUALITY`]) — so it comes back out rather than being lost
|
||||
/// here.
|
||||
pub(crate) fn normalise(v: &mut [f32; EMBEDDING_DIM]) -> f32 {
|
||||
// Clamped rather than checked: a zero-norm embedding is a broken model,
|
||||
// not a runtime condition worth an error path, and dividing by 1e-6 keeps
|
||||
// the NaN out of the catalog.
|
||||
@@ -97,6 +160,7 @@ pub(crate) fn normalise(v: &mut [f32; EMBEDDING_DIM]) {
|
||||
for x in v.iter_mut() {
|
||||
*x /= norm;
|
||||
}
|
||||
norm
|
||||
}
|
||||
|
||||
// ── f16 ───────────────────────────────────────────────────────────────────
|
||||
@@ -113,9 +177,10 @@ fn f32_to_f16_bits(x: f32) -> u16 {
|
||||
let mant = bits & 0x007f_ffff;
|
||||
|
||||
if exp >= 0x1f {
|
||||
// Overflow, inf, or NaN. Embeddings are unit-norm so this is the
|
||||
// broken-model path; infinity is the honest answer, not a clamp that
|
||||
// hides it.
|
||||
// Overflow, inf, or NaN. No component of an embedding exceeds its
|
||||
// length, and the lengths this model produces are in the tens, so
|
||||
// this is the broken-model path; infinity is the honest answer, not a
|
||||
// clamp that hides it.
|
||||
return sign
|
||||
| 0x7c00
|
||||
| if mant != 0 && exp == 0x1f + 112 {
|
||||
@@ -125,9 +190,9 @@ fn f32_to_f16_bits(x: f32) -> u16 {
|
||||
};
|
||||
}
|
||||
if exp <= 0 {
|
||||
// Subnormal or underflow. A component of a unit 512-vector is ~0.04,
|
||||
// nowhere near here, so this branch exists for correctness rather than
|
||||
// for traffic.
|
||||
// Subnormal or underflow. A component of a unit 512-vector is ~0.04
|
||||
// and a stored one is that times the length, nowhere near here, so
|
||||
// this branch exists for correctness rather than for traffic.
|
||||
if exp < -10 {
|
||||
return sign;
|
||||
}
|
||||
@@ -221,6 +286,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalising_reports_the_length_it_removed() {
|
||||
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
|
||||
v[0] = 3.0;
|
||||
v[1] = 4.0;
|
||||
let norm = normalise(&mut v);
|
||||
assert!((norm - 5.0).abs() < 1e-6, "norm {norm}");
|
||||
assert!((v[0] - 0.6).abs() < 1e-6 && (v[1] - 0.8).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The gate admits what it cannot measure: a face from before the number
|
||||
/// was kept is not a face that was found wanting.
|
||||
#[test]
|
||||
fn an_unmeasured_quality_is_admitted_to_the_gallery() {
|
||||
assert!(in_gallery(None));
|
||||
assert!(in_gallery(Some(MIN_GALLERY_QUALITY)));
|
||||
assert!(in_gallery(Some(27.5)));
|
||||
assert!(!in_gallery(Some(MIN_GALLERY_QUALITY - 0.01)));
|
||||
assert!(!in_gallery(Some(8.0)));
|
||||
}
|
||||
|
||||
/// The storage form carries the length, and the length comes back out —
|
||||
/// without touching the direction every comparison is made on.
|
||||
#[test]
|
||||
fn a_raw_vector_round_trips_with_its_length() {
|
||||
let e = unit(3);
|
||||
let raw = e.to_f16_bytes_scaled(21.5);
|
||||
let (back, length) = read_f16_bytes(e.model.clone(), &raw).unwrap();
|
||||
assert!((length - 21.5).abs() < 0.05, "length {length}");
|
||||
assert!(e.cosine(&back).unwrap() > 0.9999);
|
||||
// A unit vector from an older store reads as length 1, not as an
|
||||
// error — see `read_f16_bytes` on why that is not "unmeasured".
|
||||
let (_, one) = read_f16_bytes(e.model.clone(), &e.to_f16_bytes()).unwrap();
|
||||
assert!((one - 1.0).abs() < 1e-2, "length {one}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f16_round_trip_rejects_a_wrong_length_blob() {
|
||||
assert!(Embedding::from_f16_bytes(ModelId::new("m"), &[0u8; 100]).is_none());
|
||||
|
||||
@@ -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/faces.md §17.3 has the measurement behind the number.
|
||||
pub const MIN_EYE_PX: f32 = 12.0;
|
||||
|
||||
/// Least [`Eye::sharpness`] for the eye to be read.
|
||||
///
|
||||
/// The same measure as the face's `min_sharpness`, over the eye patch, and
|
||||
/// chosen the same way: the value under which the open-eyed faces of the
|
||||
/// reference sample were being called closed. docs/faces.md §17.3.
|
||||
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/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/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());
|
||||
}
|
||||
}
|
||||
+59
-21
@@ -1,8 +1,10 @@
|
||||
//! Faces and identity (S14, docs/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
|
||||
@@ -20,7 +22,8 @@
|
||||
//! 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.
|
||||
//! for that to stop being true. The eye-state models are the exception: MIT,
|
||||
//! weights and all, and shipped in `models/face/` (docs/faces.md §17).
|
||||
//!
|
||||
//! # Why the runtime is split behind a feature
|
||||
//!
|
||||
@@ -34,26 +37,65 @@
|
||||
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 use align::{warp, Aligned112, Similarity, ALIGNED_EDGE, ARCFACE_TEMPLATE};
|
||||
/// Smallest long edge a face crop may be sampled from.
|
||||
///
|
||||
/// **A floor on the crop source, not on the detector input.** The distinction
|
||||
/// is the whole of FR-CULL-8 and `docs/faces.md` §7: detection letterboxes
|
||||
/// every buffer into 640×640, so its input resolution decides nothing, while
|
||||
/// [`warp`] samples the 112×112 the embedder sees and so converts source
|
||||
/// resolution directly into embedding quality. FR-CULL-8 requires that crop to
|
||||
/// come from the native render; this is the guard that catches a caller
|
||||
/// sampling from a proxy instead.
|
||||
///
|
||||
/// 1025 rather than 1024 because 1024 is exactly `dr_thumbs::ThumbSize::Large`,
|
||||
/// the stored proxy tier a caller is most likely to reach for by mistake, so
|
||||
/// the floor has to exclude it rather than admit it. Written as a minimum so
|
||||
/// the test is `edge < MIN_CROP_EDGE` with no boundary to get wrong.
|
||||
///
|
||||
/// It is a coarse guard and deliberately so: whether any *individual* crop was
|
||||
/// upsampled is answered exactly by `faces.crop_px` against [`ALIGNED_EDGE`],
|
||||
/// and that is the number §7b measures. This only stops a whole pass reading
|
||||
/// from the wrong tier.
|
||||
pub const MIN_CROP_EDGE: u32 = 1025;
|
||||
|
||||
pub use align::{
|
||||
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,
|
||||
};
|
||||
#[cfg(feature = "inference")]
|
||||
pub use detect::{DetectOptions, Detection, Detector};
|
||||
#[cfg(feature = "inference")]
|
||||
pub use embed::Embedder;
|
||||
pub use embedding::{Embedding, ModelId, EMBEDDING_DIM};
|
||||
pub use embed::{Embedded, Embedder};
|
||||
pub use embedding::{
|
||||
in_gallery, read_f16_bytes, Embedding, ModelId, EMBEDDING_DIM, MIN_GALLERY_QUALITY,
|
||||
};
|
||||
pub use 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.
|
||||
@@ -82,25 +124,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();
|
||||
}
|
||||
|
||||
@@ -117,6 +117,17 @@ pub struct Faces<'a> {
|
||||
/// Which photograph each face came from. Two faces in one frame are not
|
||||
/// the same person, so those pairs are never returned (docs/faces.md §9).
|
||||
pub images: &'a [u64],
|
||||
/// Which faces may be compared *against* — the gallery
|
||||
/// ([`crate::embedding::MIN_GALLERY_QUALITY`]).
|
||||
///
|
||||
/// A pair needs at least one gallery side: a probe measured against a
|
||||
/// reference is a comparison, two short vectors measured against each
|
||||
/// other is noise agreeing with noise, and those pairs are never returned.
|
||||
/// Filtered here rather than by the caller for the same reason
|
||||
/// co-occurrence is: what this module leaves out of the list stays out of
|
||||
/// the graph, the components and the merge order, so nothing downstream
|
||||
/// has to remember the rule.
|
||||
pub gallery: &'a [bool],
|
||||
}
|
||||
|
||||
impl Faces<'_> {
|
||||
@@ -272,8 +283,9 @@ fn scan_rows(scan: &Scan, from: usize, to: usize, out: &mut Vec<Pair>) {
|
||||
let a = faces.row(i);
|
||||
let crop_a = faces.crop_px[i];
|
||||
let image_a = faces.images[i];
|
||||
let gallery_a = faces.gallery[i];
|
||||
for j in start..tile_end {
|
||||
if image_a == faces.images[j] {
|
||||
if image_a == faces.images[j] || !(gallery_a || faces.gallery[j]) {
|
||||
continue;
|
||||
}
|
||||
let cos = dot(a, faces.row(j));
|
||||
@@ -552,6 +564,7 @@ mod tests {
|
||||
embeddings: Vec<f32>,
|
||||
crop_px: Vec<f32>,
|
||||
images: Vec<u64>,
|
||||
gallery: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Set {
|
||||
@@ -561,6 +574,7 @@ mod tests {
|
||||
dim: DIM,
|
||||
crop_px: &self.crop_px,
|
||||
images: &self.images,
|
||||
gallery: &self.gallery,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,10 +602,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let crop_px = vec![150.0; embeddings.len()];
|
||||
let gallery = vec![true; embeddings.len()];
|
||||
Set {
|
||||
embeddings: embeddings.concat(),
|
||||
crop_px,
|
||||
images,
|
||||
gallery,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,7 +617,7 @@ mod tests {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in i + 1..n {
|
||||
if faces.images[i] == faces.images[j] {
|
||||
if faces.images[i] == faces.images[j] || !(faces.gallery[i] || faces.gallery[j]) {
|
||||
continue;
|
||||
}
|
||||
let cos: f32 = faces
|
||||
@@ -750,6 +766,35 @@ mod tests {
|
||||
assert!(above_threshold(&s.faces(), &cal(), 0.9).is_empty());
|
||||
}
|
||||
|
||||
/// A probe against a reference is a comparison; two probes against each
|
||||
/// other is not. The rule lives here so that nothing downstream sees the
|
||||
/// pair at all.
|
||||
#[test]
|
||||
fn two_faces_outside_the_gallery_are_never_paired() {
|
||||
let mut s = population(1, 3, 1.0);
|
||||
s.gallery = vec![false, false, true];
|
||||
let pairs = above_threshold(&s.faces(), &cal(), 0.9);
|
||||
assert!(
|
||||
!pairs.iter().any(|p| p.i == 0 && p.j == 1),
|
||||
"two probes were paired with each other"
|
||||
);
|
||||
// Each probe is still measured against the one reference.
|
||||
assert!(pairs.iter().any(|p| p.i == 0 && p.j == 2));
|
||||
assert!(pairs.iter().any(|p| p.i == 1 && p.j == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gallery_rule_matches_the_reference_at_scale() {
|
||||
let mut s = population(60, 8, 0.97);
|
||||
for (i, g) in s.gallery.iter_mut().enumerate() {
|
||||
*g = i % 3 != 0;
|
||||
}
|
||||
let f = s.faces();
|
||||
let got = above_threshold(&f, &cal(), 0.9);
|
||||
let want = reference(&f, &cal(), 0.9);
|
||||
assert!(same_pairs(&got, &want), "{} vs {}", got.len(), want.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairs_come_back_in_index_order() {
|
||||
let s = population(300, 8, 0.97);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -161,7 +161,7 @@ fn main() {
|
||||
drain.set_param("saturation", ParamId("saturation"), -100.0);
|
||||
// A touch of feather, or the colour stops dead on the model's outline and
|
||||
// the eye goes straight to the edge instead of to the subject.
|
||||
drain.feather = 0.02;
|
||||
drain.base_mut().feather = 0.02;
|
||||
pop.push(drain);
|
||||
|
||||
render_stack(
|
||||
@@ -183,14 +183,14 @@ fn main() {
|
||||
|
||||
let mut brighter = subject_layer("m1", index, subject);
|
||||
brighter.set_param("exposure", ParamId("exposure"), 0.45);
|
||||
brighter.feather = 0.015;
|
||||
brighter.base_mut().feather = 0.015;
|
||||
lift.push(brighter);
|
||||
|
||||
let mut darker = subject_layer("m2", index, subject);
|
||||
darker.invert = true;
|
||||
darker.set_param("exposure", ParamId("exposure"), -0.55);
|
||||
darker.set_param("saturation", ParamId("saturation"), -25.0);
|
||||
darker.feather = 0.03;
|
||||
darker.base_mut().feather = 0.03;
|
||||
lift.push(darker);
|
||||
|
||||
render_stack(
|
||||
@@ -221,9 +221,9 @@ fn main() {
|
||||
let mut layer = subject_layer("m1", index, subject);
|
||||
layer.invert = true;
|
||||
layer.set_param("saturation", ParamId("saturation"), -100.0);
|
||||
layer.feather = 0.004;
|
||||
layer.morphology = morphology;
|
||||
layer.morph_radius = radius;
|
||||
layer.base_mut().feather = 0.004;
|
||||
layer.base_mut().morphology = morphology;
|
||||
layer.base_mut().morph_radius = radius;
|
||||
stack.push(layer);
|
||||
|
||||
render_stack(
|
||||
@@ -307,14 +307,14 @@ fn render_stack(
|
||||
pw as usize,
|
||||
ph as usize,
|
||||
128,
|
||||
match layer.morphology {
|
||||
match layer.base().morphology {
|
||||
Morphology::None => dr_segment::Morphology::None,
|
||||
Morphology::Dilate => dr_segment::Morphology::Dilate,
|
||||
Morphology::Erode => dr_segment::Morphology::Erode,
|
||||
Morphology::Close => dr_segment::Morphology::Close,
|
||||
Morphology::Open => dr_segment::Morphology::Open,
|
||||
},
|
||||
layer.morph_radius * pw.min(ph) as f32,
|
||||
layer.base().morph_radius * pw.min(ph) as f32,
|
||||
)
|
||||
.distance
|
||||
})
|
||||
@@ -326,7 +326,7 @@ fn render_stack(
|
||||
// after the framing map — which is what makes one mask correct at every
|
||||
// output size, zoom and crop.
|
||||
let array = masks
|
||||
.render(stack, None, Some(&subjects), pw, ph)
|
||||
.render(stack, None, Some(&subjects), Some(source), pw, ph)
|
||||
.expect("rasterise masks");
|
||||
|
||||
let shader = compose_full(
|
||||
@@ -335,6 +335,7 @@ fn render_stack(
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
adjust
|
||||
.render_masked(source, &shader, ow, oh, Some(array))
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Every way of editing a mask, as frames you can watch.
|
||||
//!
|
||||
//! The mask tools are hard to review from a still: what makes them right is
|
||||
//! how the mask *moves* as a stroke is painted, as a correction is subtracted,
|
||||
//! as an edge is shaped. This renders that — a synthetic photograph and one
|
||||
//! frame per step of each mode — so the pipeline's behaviour can be watched
|
||||
//! before any of it is wired to a finger.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p dr-gpu --example mask_modes --release -- out
|
||||
//! ffmpeg -y -framerate 12 -i out/paint-%03d.ppm out/paint.gif
|
||||
//! ```
|
||||
//!
|
||||
//! PPM for the reason every other example here writes it: no encoder
|
||||
//! dependency, and ffmpeg, ImageMagick and every viewer read it.
|
||||
//!
|
||||
//! # What it is really showing
|
||||
//!
|
||||
//! The fold that builds a layer's mask (`MaskPass::render`), through the
|
||||
//! composed shader that samples it. A part drawn in the wrong order, a
|
||||
//! subtraction that took the base with it, an erase stroke that punched
|
||||
//! through the selection underneath — each of those is a frame here that looks
|
||||
//! wrong, and none of them is visible in a single rendered still.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack, Morphology};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::spot::SpotSet;
|
||||
use dr_pipeline::{ops, Framing};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
const W: u32 = 480;
|
||||
const H: u32 = 320;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let dir = std::env::args().nth(1).unwrap_or_else(|| "out".into());
|
||||
std::fs::create_dir_all(&dir).expect("output directory");
|
||||
|
||||
let Some(ctx) = pollster::block_on(GpuContext::new_headless()).ok() else {
|
||||
eprintln!("no adapter; nothing to render");
|
||||
return;
|
||||
};
|
||||
|
||||
let source = DemosaicedImage::from_rgba8(&ctx, &scene(), W, H).expect("upload");
|
||||
let mut masks = MaskPass::new(&ctx).expect("mask pass");
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
let mut shot = Shot {
|
||||
source: &source,
|
||||
masks: &mut masks,
|
||||
adjust: &mut adjust,
|
||||
dir: &dir,
|
||||
};
|
||||
|
||||
write_ppm(&format!("{dir}/original.ppm"), &to_rgb(&scene()), W, H);
|
||||
|
||||
paint(&mut shot);
|
||||
erase(&mut shot);
|
||||
subtract(&mut shot);
|
||||
invert(&mut shot);
|
||||
shape(&mut shot);
|
||||
|
||||
println!("frames in {dir}/");
|
||||
}
|
||||
|
||||
/// One layer, brightened hard, so the mask is legible rather than tasteful.
|
||||
fn lifted(source: MaskSource) -> MaskLayer {
|
||||
let mut layer = MaskLayer::new("m1", source);
|
||||
layer.set_param("exposure", ParamId("exposure"), 1.6);
|
||||
layer.set_param("saturation", ParamId("vibrance"), 0.6);
|
||||
layer
|
||||
}
|
||||
|
||||
/// A stroke painted from left to right across the subject, one frame per dab.
|
||||
///
|
||||
/// Each frame is the whole mask rasterised again, which is what the
|
||||
/// application does today on every shape change — so the frames are also a
|
||||
/// crude answer to "is a stroke's cost growing as it is painted".
|
||||
fn paint(shot: &mut Shot) {
|
||||
let mut layer = lifted(MaskSource::brush());
|
||||
layer.begin_stroke(0, false, 0.13, 0.5, 1.0);
|
||||
for (i, x) in steps(0.18, 0.82, 28).enumerate() {
|
||||
layer.extend_stroke(0, x, 0.52 + 0.06 * (x * 9.0).sin());
|
||||
shot.frame("paint", i, &layer);
|
||||
}
|
||||
layer.end_stroke(0);
|
||||
}
|
||||
|
||||
/// The same layer, with an erase stroke taken back through the middle of it.
|
||||
fn erase(shot: &mut Shot) {
|
||||
let mut layer = lifted(MaskSource::brush());
|
||||
layer.begin_stroke(0, false, 0.16, 0.5, 1.0);
|
||||
for x in steps(0.18, 0.82, 20) {
|
||||
layer.extend_stroke(0, x, 0.5);
|
||||
}
|
||||
layer.end_stroke(0);
|
||||
|
||||
for i in 0..8 {
|
||||
shot.frame("erase", i, &layer);
|
||||
}
|
||||
layer.begin_stroke(0, true, 0.09, 0.7, 1.0);
|
||||
for (i, x) in steps(0.25, 0.75, 20).enumerate() {
|
||||
layer.extend_stroke(0, x, 0.5);
|
||||
shot.frame("erase", 8 + i, &layer);
|
||||
}
|
||||
layer.end_stroke(0);
|
||||
}
|
||||
|
||||
/// A correction joined to a radial selection and then taken out of it: the
|
||||
/// part appears, is painted, and the mask loses exactly what it covers.
|
||||
fn subtract(shot: &mut Shot) {
|
||||
let mut layer = lifted(MaskSource::Radial {
|
||||
centre: (0.5, 0.5),
|
||||
radii: (0.42, 0.34),
|
||||
angle: 0.0,
|
||||
feather: 0.35,
|
||||
});
|
||||
|
||||
for i in 0..8 {
|
||||
shot.frame("subtract", i, &layer);
|
||||
}
|
||||
|
||||
layer.push_part(MaskPart::painted("p2", Join::Subtract));
|
||||
for (i, x) in steps(0.3, 0.72, 22).enumerate() {
|
||||
if i == 0 {
|
||||
layer.begin_stroke(1, false, 0.1, 0.6, 1.0);
|
||||
}
|
||||
layer.extend_stroke(1, x, 0.46);
|
||||
shot.frame("subtract", 8 + i, &layer);
|
||||
}
|
||||
layer.end_stroke(1);
|
||||
|
||||
// And off again, which is the half a stroke cannot do: a part is a thing
|
||||
// that can be switched off after the fact.
|
||||
for i in 0..8 {
|
||||
let mut without = layer.clone();
|
||||
without.remove_part(1);
|
||||
shot.frame("subtract", 30 + i, &without);
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer turned over, and back, holding each state long enough to read.
|
||||
fn invert(shot: &mut Shot) {
|
||||
let mut layer = lifted(MaskSource::Radial {
|
||||
centre: (0.42, 0.52),
|
||||
radii: (0.3, 0.32),
|
||||
angle: 0.0,
|
||||
feather: 0.3,
|
||||
});
|
||||
|
||||
for i in 0..24 {
|
||||
layer.invert = (i / 8) % 2 == 1;
|
||||
shot.frame("invert", i, &layer);
|
||||
}
|
||||
}
|
||||
|
||||
/// The edge controls, swept: a feather opening up, then a dilation pushing the
|
||||
/// boundary out and an erosion pulling it back.
|
||||
fn shape(shot: &mut Shot) {
|
||||
let mut layer = lifted(MaskSource::Radial {
|
||||
centre: (0.5, 0.5),
|
||||
radii: (0.3, 0.3),
|
||||
angle: 0.0,
|
||||
feather: 0.02,
|
||||
});
|
||||
layer.base_mut().falloff = dr_pipeline::mask::Falloff::Smooth;
|
||||
|
||||
for (i, f) in steps(0.0, 0.09, 18).enumerate() {
|
||||
layer.base_mut().feather = f;
|
||||
shot.frame("shape", i, &layer);
|
||||
}
|
||||
layer.base_mut().morphology = Morphology::Dilate;
|
||||
for (i, r) in steps(0.0, 0.06, 12).enumerate() {
|
||||
layer.base_mut().morph_radius = r;
|
||||
shot.frame("shape", 18 + i, &layer);
|
||||
}
|
||||
layer.base_mut().morphology = Morphology::Erode;
|
||||
for (i, r) in steps(0.0, 0.06, 12).enumerate() {
|
||||
layer.base_mut().morph_radius = r;
|
||||
shot.frame("shape", 30 + i, &layer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one frame needs, so the mode functions read as what they do.
|
||||
struct Shot<'a> {
|
||||
source: &'a DemosaicedImage,
|
||||
masks: &'a mut MaskPass,
|
||||
adjust: &'a mut AdjustPass,
|
||||
dir: &'a str,
|
||||
}
|
||||
|
||||
impl Shot<'_> {
|
||||
fn frame(&mut self, mode: &str, index: usize, layer: &MaskLayer) {
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer.clone());
|
||||
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
&stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
|
||||
let array = self
|
||||
.masks
|
||||
.render(&stack, None, None, Some(self.source), W, H)
|
||||
.expect("rasterise");
|
||||
self.adjust
|
||||
.render_masked(self.source, &shader, W, H, Some(array))
|
||||
.expect("render");
|
||||
let rgba = self.adjust.export_pixels().expect("readback").0;
|
||||
|
||||
write_ppm(
|
||||
&format!("{}/{mode}-{index:03}.ppm", self.dir),
|
||||
&to_rgb(&rgba),
|
||||
W,
|
||||
H,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `count` values from `from` to `to`, inclusive.
|
||||
fn steps(from: f32, to: f32, count: usize) -> impl Iterator<Item = f32> {
|
||||
(0..count).map(move |i| from + (to - from) * i as f32 / (count.max(2) - 1) as f32)
|
||||
}
|
||||
|
||||
/// A picture with somewhere obvious to put a mask: a graded sky, a ground
|
||||
/// band, and a warm subject sitting on the join.
|
||||
fn scene() -> Vec<u8> {
|
||||
let mut px = vec![0u8; (W * H * 4) as usize];
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let (fx, fy) = (x as f32 / W as f32, y as f32 / H as f32);
|
||||
let sky = [
|
||||
(60.0 + 90.0 * fy) as u8,
|
||||
(110.0 + 90.0 * fy) as u8,
|
||||
(190.0 + 50.0 * fy) as u8,
|
||||
];
|
||||
let ground = [
|
||||
(70.0 + 40.0 * fx) as u8,
|
||||
(85.0 + 30.0 * fx) as u8,
|
||||
(60.0 + 20.0 * fx) as u8,
|
||||
];
|
||||
let mut c = if fy > 0.62 { ground } else { sky };
|
||||
|
||||
// The subject: an ellipse, warm, with a little internal structure
|
||||
// so a feathered edge has something to be soft against.
|
||||
let (dx, dy) = ((fx - 0.5) / 0.22, (fy - 0.52) / 0.3);
|
||||
if dx * dx + dy * dy < 1.0 {
|
||||
let shade = 0.75 + 0.25 * (fx * 40.0).sin() * (fy * 30.0).cos();
|
||||
c = [
|
||||
(205.0 * shade) as u8,
|
||||
(170.0 * shade) as u8,
|
||||
(140.0 * shade) as u8,
|
||||
];
|
||||
}
|
||||
|
||||
let i = ((y * W + x) * 4) as usize;
|
||||
px[i..i + 4].copy_from_slice(&[c[0], c[1], c[2], 255]);
|
||||
}
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
fn to_rgb(rgba: &[u8]) -> Vec<u8> {
|
||||
rgba.chunks_exact(4)
|
||||
.flat_map(|p| [p[0], p[1], p[2]])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_ppm(path: &str, rgb: &[u8], w: u32, h: u32) {
|
||||
use std::io::Write as _;
|
||||
let mut f = std::io::BufWriter::new(std::fs::File::create(path).expect("create"));
|
||||
write!(f, "P6\n{w} {h}\n255\n").expect("header");
|
||||
f.write_all(rgb).expect("body");
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Sweep a range mask's band and show what it selects.
|
||||
//!
|
||||
//! A diagnostic for FR-DEV-10. The other sweep example drives global
|
||||
//! parameters through `EditGraph`; a band is not one of those — it lives on a
|
||||
//! `MaskLayer`, is rasterised by its own pass, and only becomes visible
|
||||
//! through whatever adjustment the layer carries.
|
||||
//!
|
||||
//! So the layer here is given a deliberately blunt adjustment — two stops down
|
||||
//! — because the question this answers is *what does the band select*, not
|
||||
//! *what would a photographer do with it*. A subtle edit would show a subtle
|
||||
//! selection and prove nothing.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p dr-gpu --release --example rangesweep -- IMG.CR2 out/ luminance 21
|
||||
//! cargo run -p dr-gpu --release --example rangesweep -- IMG.CR2 out/ hue 21
|
||||
//! ```
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass};
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::ops;
|
||||
use dr_pipeline::spot::SpotSet;
|
||||
use dr_pipeline::{Framing, ParamId};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
let (Some(input), Some(out_dir), Some(mode)) = (args.next(), args.next(), args.next()) else {
|
||||
eprintln!("usage: rangesweep <file.cr2> <out_dir> <luminance|hue|width> [steps]");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let steps: usize = args
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.filter(|n| *n >= 2)
|
||||
.unwrap_or(21);
|
||||
|
||||
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
|
||||
let image = load(&ctx, &input);
|
||||
std::fs::create_dir_all(&out_dir).expect("create out dir");
|
||||
|
||||
let (full_w, full_h) = image.size();
|
||||
let longest = std::env::var("SWEEP_MAX_PX")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(1000);
|
||||
let scale = (longest as f32 / full_w.max(full_h) as f32).min(1.0);
|
||||
let w = ((full_w as f32 * scale) as u32).max(1);
|
||||
let h = ((full_h as f32 * scale) as u32).max(1);
|
||||
println!("rendering {w} x {h}");
|
||||
|
||||
let mut masks = MaskPass::new(&ctx).expect("mask pass");
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
|
||||
for i in 0..steps {
|
||||
let t = i as f32 / (steps - 1) as f32;
|
||||
|
||||
// What moves, and what the caption should say about it.
|
||||
let (source, label) = match mode.as_str() {
|
||||
// A half-wide band walking from black to white, so the selection
|
||||
// sweeps across the tonal scale rather than merely widening.
|
||||
"luminance" => {
|
||||
let centre = t;
|
||||
let half = 0.15;
|
||||
(
|
||||
MaskSource::luminance_range(centre - half, centre + half, 0.10),
|
||||
format!("luminance band centred {:.2}", centre),
|
||||
)
|
||||
}
|
||||
// The hue circle, at a fixed arc and a chroma floor that keeps the
|
||||
// near-neutral parts of the picture out of it.
|
||||
"hue" => (
|
||||
MaskSource::colour_range(t, 0.08, 0.05, 1.0, 0.10),
|
||||
format!("hue {:.0} deg", t * 360.0),
|
||||
),
|
||||
// The arc opening from nothing to everything, at a fixed hue.
|
||||
"width" => (
|
||||
MaskSource::colour_range(0.08, t * 0.5, 0.05, 1.0, 0.10),
|
||||
format!("hue width {:.0} deg", t * 0.5 * 360.0),
|
||||
),
|
||||
other => {
|
||||
eprintln!("unknown mode `{other}`");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
let mut layer = MaskLayer::new("sweep", source);
|
||||
// Two stops down: blunt on purpose. See the module docs.
|
||||
layer.set_param("exposure", ParamId("exposure"), -2.0);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
|
||||
// No label field and no subject masks: a range needs neither. It is a
|
||||
// weighting over the picture's own values, so the only input it wants
|
||||
// is the picture, which is the `Some(&image)` below.
|
||||
let array = masks
|
||||
.render(&stack, None, None, Some(&image), w, h)
|
||||
.expect("rasterise");
|
||||
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
&stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
adjust
|
||||
.render_masked(&image, &shader, w, h, Some(array))
|
||||
.expect("render");
|
||||
let (pixels, pw, ph) = adjust.export_pixels().expect("readback");
|
||||
|
||||
let path = format!("{out_dir}/{mode}_{i:03}.ppm");
|
||||
write_ppm(&path, &pixels, pw, ph);
|
||||
std::fs::write(format!("{out_dir}/{mode}_{i:03}.txt"), format!("{label}\n"))
|
||||
.expect("write label");
|
||||
println!("{mode}[{i}] {label}");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_ppm(path: &str, rgba: &[u8], w: u32, h: u32) {
|
||||
use std::io::Write;
|
||||
let mut out = Vec::with_capacity((w * h * 3) as usize + 32);
|
||||
out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes());
|
||||
for px in rgba.chunks_exact(4) {
|
||||
out.extend_from_slice(&px[..3]);
|
||||
}
|
||||
std::fs::File::create(path)
|
||||
.expect("create output")
|
||||
.write_all(&out)
|
||||
.expect("write output");
|
||||
}
|
||||
|
||||
/// Load either a RAW or an already-rendered image.
|
||||
///
|
||||
/// A JPEG takes the path `DemosaicedImage::from_rgba8` documents: no CFA to
|
||||
/// interpolate, identity colour matrix, neutral white balance, and the shader
|
||||
/// linearises the gamma-encoded pixels. The controls all still work; their
|
||||
/// neutral is "as the camera left it" rather than "as the sensor recorded it",
|
||||
/// which is worth knowing when reading a sweep made from one.
|
||||
fn load(ctx: &GpuContext, path: &str) -> DemosaicedImage {
|
||||
let bytes = std::fs::read(path).expect("read file");
|
||||
match dr_decode::probe(&bytes) {
|
||||
Some(dr_types::Format::Jpeg) => {
|
||||
let p = dr_decode::decode_jpeg(&bytes).expect("decode jpeg");
|
||||
println!("loaded {} x {} (rendered, not raw)", p.width, p.height);
|
||||
DemosaicedImage::from_rgba8(ctx, &p.rgba, p.width, p.height).expect("upload")
|
||||
}
|
||||
_ => {
|
||||
let raw = dr_decode::decode(&bytes).expect("decode raw");
|
||||
println!("loaded {} x {} (raw)", raw.crop.width, raw.crop.height);
|
||||
let demosaic = Demosaicer::new(ctx).expect("demosaicer");
|
||||
demosaic.run(&raw).expect("demosaic")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Sweep one operation's parameters and write a frame per step.
|
||||
//!
|
||||
//! A diagnostic, not part of the product: it exists to show what a control
|
||||
//! actually does to a photograph, one parameter at a time, so a new node can
|
||||
//! be looked at rather than reasoned about.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p dr-gpu --release --example sweep -- IMG.CR2 out/ colour_grading 21
|
||||
//! ```
|
||||
//!
|
||||
//! It names no operation. The op id arrives as a string, the parameters and
|
||||
//! their ranges come from the graph's own capabilities, and a node declared
|
||||
//! yesterday sweeps on the same terms as one that shipped a year ago — which
|
||||
//! is the property `ops/README.md` promises and the reason this is one example
|
||||
//! rather than one per node.
|
||||
//!
|
||||
//! PPM out, like `develop.rs`, so it needs no encoder dependency; the caller
|
||||
//! turns them into whatever it wants.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
|
||||
use dr_pipeline::{Affects, EditGraph, OpId, ParamId, ParamKind};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let mut args = std::env::args().skip(1);
|
||||
let (Some(input), Some(out_dir), Some(op)) = (args.next(), args.next(), args.next()) else {
|
||||
eprintln!("usage: sweep <file.cr2> <out_dir> <op_id> [steps]");
|
||||
eprintln!(" sweep <file.cr2> <out_dir> --list");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let steps: usize = args
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.filter(|n| *n >= 2)
|
||||
.unwrap_or(21);
|
||||
// Sweep one named parameter rather than all of them.
|
||||
let only: Option<String> = args.next();
|
||||
|
||||
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
|
||||
let image = load(&ctx, &input);
|
||||
|
||||
let mut graph = EditGraph::default_chain();
|
||||
|
||||
// `--list` prints every op and parameter with its range, which is how the
|
||||
// caller learns what there is to sweep without this file holding a list
|
||||
// that would go stale.
|
||||
if op == "--list" {
|
||||
for cap in graph.capabilities() {
|
||||
println!("{}", cap.id.0);
|
||||
for p in &cap.params {
|
||||
match p.kind {
|
||||
ParamKind::Scalar { min, max, .. } => {
|
||||
println!(" {:<20} {min} .. {max} default {}", p.id.0, p.default)
|
||||
}
|
||||
ParamKind::Bool => println!(" {:<20} bool", p.id.0),
|
||||
ParamKind::Enum { ref variants } => {
|
||||
println!(" {:<20} enum, {} variants", p.id.0, variants.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&out_dir).expect("create out dir");
|
||||
|
||||
// `OpId`/`ParamId` hold `&'static str`, and an argument is not static.
|
||||
// Leaking is right rather than expedient here: the ids live as long as the
|
||||
// graph does, and this process exits immediately after.
|
||||
let op_id = OpId(Box::leak(op.clone().into_boxed_str()));
|
||||
|
||||
let cap = graph
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id == op_id)
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("no operation `{op}` — try --list");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// Bounded output rather than full sensor resolution. This is the proxy
|
||||
// path FR-DSP-1 already renders through, so it is the same code the
|
||||
// develop view uses — and a 25 MP frame would be a 75 MB PPM, times
|
||||
// several hundred frames in one sweep.
|
||||
let (full_w, full_h) = image.size();
|
||||
let longest = std::env::var("SWEEP_MAX_PX")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(1100);
|
||||
let scale = (longest as f32 / full_w.max(full_h) as f32).min(1.0);
|
||||
let w = ((full_w as f32 * scale) as u32).max(1);
|
||||
let h = ((full_h as f32 * scale) as u32).max(1);
|
||||
println!("rendering {w} x {h} (from {full_w} x {full_h})");
|
||||
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
|
||||
// The reference frame: every parameter at its default. Written once so a
|
||||
// viewer can see what the sweep is departing from.
|
||||
render_to(
|
||||
&mut adjust,
|
||||
&image,
|
||||
&graph,
|
||||
w,
|
||||
h,
|
||||
&out_dir,
|
||||
"neutral",
|
||||
0,
|
||||
0.0,
|
||||
);
|
||||
|
||||
// Parameters held away from their default for the duration, as
|
||||
// `SWEEP_HOLD=shadow_strength=70,midtone_hue=210`.
|
||||
//
|
||||
// Needed because a parameter is not always meaningful alone. Where a
|
||||
// `presentation:` block groups several into one conceptual control — a
|
||||
// hue and the strength behind it — sweeping one with the other at its
|
||||
// default renders the same frame every time, which looks like a broken
|
||||
// node rather than a correctly declared neutral.
|
||||
let hold = std::env::var("SWEEP_HOLD").unwrap_or_default();
|
||||
for clause in hold.split(',').filter(|c| !c.trim().is_empty()) {
|
||||
let Some((name, value)) = clause.split_once('=') else {
|
||||
eprintln!("SWEEP_HOLD wants name=value, got `{clause}`");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let value: f32 = value.trim().parse().expect("hold value");
|
||||
let name = Box::leak(name.trim().to_string().into_boxed_str());
|
||||
graph.set_param(op_id, ParamId(name), value);
|
||||
println!("holding {name} = {value}");
|
||||
}
|
||||
|
||||
for p in &cap.params {
|
||||
if only.as_deref().is_some_and(|o| o != p.id.0) {
|
||||
continue;
|
||||
}
|
||||
let ParamKind::Scalar { min, max, .. } = p.kind else {
|
||||
eprintln!("skipping {} — only scalars sweep meaningfully", p.id.0);
|
||||
continue;
|
||||
};
|
||||
let param_id = ParamId(Box::leak(p.id.0.to_string().into_boxed_str()));
|
||||
|
||||
for i in 0..steps {
|
||||
let t = i as f32 / (steps - 1) as f32;
|
||||
let value = min + (max - min) * t;
|
||||
graph.set_param(op_id, param_id, value);
|
||||
render_to(
|
||||
&mut adjust,
|
||||
&image,
|
||||
&graph,
|
||||
w,
|
||||
h,
|
||||
&out_dir,
|
||||
p.id.0,
|
||||
i,
|
||||
value,
|
||||
);
|
||||
}
|
||||
// Back to default before the next parameter, so each sweep is of one
|
||||
// control rather than of everything tried so far.
|
||||
graph.set_param(op_id, param_id, p.default);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_to(
|
||||
adjust: &mut AdjustPass,
|
||||
image: &dr_gpu::DemosaicedImage,
|
||||
graph: &EditGraph,
|
||||
w: u32,
|
||||
h: u32,
|
||||
dir: &str,
|
||||
name: &str,
|
||||
index: usize,
|
||||
value: f32,
|
||||
) {
|
||||
// The detail path, always. A neighbourhood node — dehaze, clarity,
|
||||
// sharpening — runs as its own dispatch after the fused pass, and the
|
||||
// fused path refuses a shader composed with one rather than rendering it
|
||||
// wrongly. `render_detailed` falls through to the plain path when the
|
||||
// chain has no detail stage, so this one call serves both kinds of node
|
||||
// and the example never has to know which it was handed.
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(image.size(), (w, h));
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
adjust
|
||||
.render_detailed(image, &shader, w, h, None, &detail, key)
|
||||
.expect("adjust");
|
||||
let (pixels, pw, ph) = adjust.export_pixels().expect("readback");
|
||||
|
||||
let path = format!("{dir}/{name}_{index:03}.ppm");
|
||||
write_ppm(&path, &pixels, pw, ph);
|
||||
|
||||
// The value goes beside the frame rather than into the filename: a caption
|
||||
// wants "-37.5", and a filename that carried it would need escaping and
|
||||
// would sort wrongly.
|
||||
let meta = format!("{dir}/{name}_{index:03}.txt");
|
||||
std::fs::write(meta, format!("{name} {value:.4}\n")).expect("write value");
|
||||
println!("{name}[{index}] = {value:.4}");
|
||||
}
|
||||
|
||||
fn write_ppm(path: &str, rgba: &[u8], w: u32, h: u32) {
|
||||
use std::io::Write;
|
||||
let mut out = Vec::with_capacity((w * h * 3) as usize + 32);
|
||||
out.extend_from_slice(format!("P6\n{w} {h}\n255\n").as_bytes());
|
||||
for px in rgba.chunks_exact(4) {
|
||||
out.extend_from_slice(&px[..3]);
|
||||
}
|
||||
std::fs::File::create(path)
|
||||
.expect("create output")
|
||||
.write_all(&out)
|
||||
.expect("write output");
|
||||
}
|
||||
|
||||
/// Load either a RAW or an already-rendered image.
|
||||
///
|
||||
/// A JPEG takes the path `DemosaicedImage::from_rgba8` documents: no CFA to
|
||||
/// interpolate, identity colour matrix, neutral white balance, and the shader
|
||||
/// linearises the gamma-encoded pixels. The controls all still work; their
|
||||
/// neutral is "as the camera left it" rather than "as the sensor recorded it",
|
||||
/// which is worth knowing when reading a sweep made from one.
|
||||
fn load(ctx: &GpuContext, path: &str) -> DemosaicedImage {
|
||||
let bytes = std::fs::read(path).expect("read file");
|
||||
match dr_decode::probe(&bytes) {
|
||||
Some(dr_types::Format::Jpeg) => {
|
||||
let p = dr_decode::decode_jpeg(&bytes).expect("decode jpeg");
|
||||
println!("loaded {} x {} (rendered, not raw)", p.width, p.height);
|
||||
DemosaicedImage::from_rgba8(ctx, &p.rgba, p.width, p.height).expect("upload")
|
||||
}
|
||||
_ => {
|
||||
let raw = dr_decode::decode(&bytes).expect("decode raw");
|
||||
println!("loaded {} x {} (raw)", raw.crop.width, raw.crop.height);
|
||||
let demosaic = Demosaicer::new(ctx).expect("demosaicer");
|
||||
demosaic.run(&raw).expect("demosaic")
|
||||
}
|
||||
}
|
||||
}
|
||||
+249
-9
@@ -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,
|
||||
@@ -1845,7 +2063,21 @@ mod tests {
|
||||
fused_blocks += usize::from(point);
|
||||
}
|
||||
|
||||
// The count the loop above accumulated, plus framing — which emits a
|
||||
// The lens corrections, which are the third kind of block. They are
|
||||
// not in `descriptors` — they rewrite coordinates rather than
|
||||
// transform a colour, so they are not operations — and they run ahead
|
||||
// of the fetch rather than in either stage the loop above sorts into.
|
||||
let mut warp_blocks = 0;
|
||||
for desc in g.warp_descriptors() {
|
||||
let id = desc.id.0;
|
||||
assert!(
|
||||
shader.source.contains(&format!("---- warp: {id} ----")),
|
||||
"{id} was armed above and did not reach the shader"
|
||||
);
|
||||
warp_blocks += 1;
|
||||
}
|
||||
|
||||
// The counts the loops above accumulated, plus framing — which emits a
|
||||
// stage of its own rather than an operation block and is not in
|
||||
// `descriptors`. Asserted as well as the per-operation exclusive-or
|
||||
// because the two catch different faults: the XOR catches an operation
|
||||
@@ -1853,7 +2085,7 @@ mod tests {
|
||||
// in the chain asked for.
|
||||
assert_eq!(
|
||||
shader.source.matches("---- ").count(),
|
||||
fused_blocks + 1,
|
||||
fused_blocks + warp_blocks + 1,
|
||||
"the fused shader carries a block nothing in the chain asked for"
|
||||
);
|
||||
assert!(
|
||||
@@ -1955,6 +2187,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,
|
||||
@@ -2055,6 +2291,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,
|
||||
@@ -1196,6 +1373,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 +1458,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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+638
-98
@@ -21,6 +21,17 @@
|
||||
//! boxes, one draw each, compositing onto the slice with blend state — see the
|
||||
//! second half of `mask.wgsl`.
|
||||
//!
|
||||
//! # The photograph, bound as an input
|
||||
//!
|
||||
//! A range mask (FR-DEV-10) selects by what a pixel *is*, so this pass reads
|
||||
//! the demosaiced source as well as writing masks. It is bound for every draw
|
||||
//! and looked at by two modes; everything else gets a 1x1 placeholder, for the
|
||||
//! reason the label field below does — the bindings are fixed, and a second
|
||||
//! pipeline differing only in what it ignores costs more than a texel.
|
||||
//!
|
||||
//! Nothing is read back and nothing is rasterised on this side. What crosses
|
||||
//! into CPU memory for a range layer is five floats and a matrix.
|
||||
//!
|
||||
//! # The label field
|
||||
//!
|
||||
//! Region masks index a compacted label field uploaded once per segmentation.
|
||||
@@ -30,10 +41,11 @@
|
||||
//! `region_count`. The compaction is CPU-side and once per image, which is the
|
||||
//! same place and cadence the region adjacency graph is already built at.
|
||||
|
||||
use dr_pipeline::mask::{MaskSource, MaskStack, Stroke, MAX_LAYERS};
|
||||
use dr_pipeline::mask::{Join, MaskSource, MaskStack, Stroke, MAX_LAYERS};
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::{GpuContext, GpuError};
|
||||
use crate::{DemosaicedImage, GpuContext, GpuError};
|
||||
|
||||
/// Modes understood by `mask.wgsl`. Kept beside the shader's `switch`.
|
||||
const MODE_REGIONS: u32 = 0;
|
||||
@@ -43,6 +55,10 @@ const MODE_SUBJECT: u32 = 3;
|
||||
/// Brush layers go through their own entry points rather than the `switch`, so
|
||||
/// this is only ever read by a person looking at a captured frame.
|
||||
const MODE_BRUSH: u32 = 4;
|
||||
/// TRACES: FR-DEV-10
|
||||
const MODE_LUMINANCE: u32 = 5;
|
||||
/// TRACES: FR-DEV-10
|
||||
const MODE_COLOUR: u32 = 6;
|
||||
|
||||
/// Six vertices — two triangles — per stroke. See `vs_brush`.
|
||||
const VERTICES_PER_STROKE: u32 = 6;
|
||||
@@ -66,7 +82,25 @@ struct MaskParams {
|
||||
axis: [f32; 2],
|
||||
softness: f32,
|
||||
angle: f32,
|
||||
_pad1: [f32; 2],
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Source texels per mask texel, per axis. See `image_value` in the
|
||||
/// shader for why a range averages its footprint rather than sampling it.
|
||||
source_step: [f32; 2],
|
||||
|
||||
/// Camera RGB → linear sRGB, one row per `vec4` because that is the
|
||||
/// alignment a uniform gives a three-component vector anyway. Only a
|
||||
/// range mask reads them.
|
||||
cam_to_srgb: [[f32; 4]; 3],
|
||||
/// `rgb`: as-shot white balance. `w`: non-zero for a gamma-encoded source.
|
||||
/// The same packing the generated adjust shader uses, so the two agree by
|
||||
/// construction rather than by inspection.
|
||||
as_shot_wb: [f32; 4],
|
||||
/// Whether this part is turned over before it joins the mask. Read by the
|
||||
/// combine pass and by nothing else — see `fs_combine`.
|
||||
invert: u32,
|
||||
/// A uniform buffer is a multiple of sixteen bytes, and the flag above
|
||||
/// takes four of them.
|
||||
_pad: [u32; 3],
|
||||
}
|
||||
|
||||
/// One stroke, as `mask.wgsl`'s `StrokeHeader` expects it.
|
||||
@@ -355,6 +389,19 @@ pub struct MaskPass {
|
||||
/// `dst(1 - a)` to erase.
|
||||
brush_add: wgpu::RenderPipeline,
|
||||
brush_erase: wgpu::RenderPipeline,
|
||||
/// Reads a part back out of [`Self::scratch`] and blends it into the
|
||||
/// layer's slice. The set operation is the blend state, so these two are
|
||||
/// one shader as well.
|
||||
combine_layout: wgpu::BindGroupLayout,
|
||||
combine_union: wgpu::RenderPipeline,
|
||||
combine_subtract: wgpu::RenderPipeline,
|
||||
/// Where a part is drawn before it is joined.
|
||||
///
|
||||
/// One texture for the whole stack rather than one per layer, because
|
||||
/// layers rasterise in sequence and a part is read back immediately after
|
||||
/// it is drawn. Allocated the first time a layer has more than one part,
|
||||
/// so a library of unedited masks never pays for it.
|
||||
scratch: Option<Scratch>,
|
||||
array: Option<MaskArray>,
|
||||
/// How many times the array texture has been (re)allocated.
|
||||
///
|
||||
@@ -371,6 +418,14 @@ pub struct MaskPass {
|
||||
/// label slots even when rasterising a gradient. A placeholder is cheaper
|
||||
/// and far simpler than two pipelines differing only in what they ignore.
|
||||
placeholder: LabelField,
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Bound at the image slot for every mask that is not a range.
|
||||
///
|
||||
/// Never sampled by those modes, so its contents do not matter — but it is
|
||||
/// cleared rather than left undefined, because a placeholder whose value
|
||||
/// is arbitrary is one that makes a binding mistake look like a mask that
|
||||
/// nearly works.
|
||||
empty_image: wgpu::TextureView,
|
||||
}
|
||||
|
||||
impl MaskPass {
|
||||
@@ -405,6 +460,20 @@ impl MaskPass {
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
// TRACES: FR-DEV-10
|
||||
// The photograph, for a range mask. Unfilterable for the
|
||||
// same reason the field above is: every read is a
|
||||
// `textureLoad`, and this pipeline binds no sampler.
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 6,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -501,6 +570,88 @@ impl MaskPass {
|
||||
"mask-brush-add",
|
||||
blend_state(wgpu::BlendFactor::One, wgpu::BlendFactor::OneMinusSrc),
|
||||
);
|
||||
// The pipelines that join one part to the mask so far. The blend
|
||||
// state is the set operation and the shader is the same three
|
||||
// vertices either way — which is why adding a way to combine masks
|
||||
// cost no shader arithmetic at all.
|
||||
let combine_layout =
|
||||
ctx.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("mask-combine-bgl"),
|
||||
entries: &[
|
||||
uniform_entry(0),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 7,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
// Loaded texel by texel at matching size, so
|
||||
// there is nothing to filter and no sampler.
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let combine_pipeline_layout =
|
||||
ctx.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("mask-combine-layout"),
|
||||
bind_group_layouts: &[Some(&combine_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
let combine = |label, blend| {
|
||||
ctx.device
|
||||
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&combine_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_combine"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: MaskArray::FORMAT,
|
||||
blend: Some(blend),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
|
||||
// `max`, not source-over: a union must not build up where two parts
|
||||
// overlap. Two selections that both half-cover a pixel select it half
|
||||
// — adding them would make the overlap of two soft edges harder than
|
||||
// either, which is a seam exactly where a photographer joined two
|
||||
// things to avoid one.
|
||||
let combine_union = combine(
|
||||
"mask-combine-union",
|
||||
wgpu::BlendState {
|
||||
color: MAX_BLEND,
|
||||
alpha: MAX_BLEND,
|
||||
},
|
||||
);
|
||||
// `dst * (1 - src)`, which is the erase blend one level up: what the
|
||||
// mask had, minus what this part covers, in proportion to how much of
|
||||
// it the part covers.
|
||||
let combine_subtract = combine(
|
||||
"mask-combine-subtract",
|
||||
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
|
||||
);
|
||||
|
||||
// The same, with the deposit thrown away: coverage is only ever taken
|
||||
// off what earlier strokes on this layer put down. There is no negative
|
||||
// coverage to accumulate, so erasing an unpainted layer is a no-op
|
||||
@@ -515,6 +666,7 @@ impl MaskPass {
|
||||
}
|
||||
|
||||
let placeholder = LabelField::upload(ctx, &[0], 1, 1, 0)?;
|
||||
let empty_image = empty_image(ctx);
|
||||
// Everywhere outside, so a layer that somehow reaches this masks
|
||||
// nothing rather than everything.
|
||||
let empty_subject = SubjectMasks::upload(ctx, &[&[-1.0f32][..]], 1, 1)?;
|
||||
@@ -526,10 +678,15 @@ impl MaskPass {
|
||||
brush_layout,
|
||||
brush_add,
|
||||
brush_erase,
|
||||
combine_layout,
|
||||
combine_union,
|
||||
combine_subtract,
|
||||
scratch: None,
|
||||
array: None,
|
||||
allocations: 0,
|
||||
placeholder,
|
||||
empty_subject,
|
||||
empty_image,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -538,17 +695,49 @@ impl MaskPass {
|
||||
/// `labels` may be `None` when no layer is a region mask; a region layer
|
||||
/// without one is skipped rather than drawn wrong, since a mask that
|
||||
/// silently covers the whole frame would apply an edit everywhere.
|
||||
///
|
||||
/// `source` is the photograph a range layer measures (FR-DEV-10), and it
|
||||
/// is skipped on the same rule for the same reason: without it the shader
|
||||
/// would read a blank placeholder, and a band that happens to contain
|
||||
/// black would then cover the whole frame.
|
||||
pub fn render(
|
||||
&mut self,
|
||||
stack: &MaskStack,
|
||||
labels: Option<&LabelField>,
|
||||
subjects: Option<&SubjectMasks>,
|
||||
source: Option<&DemosaicedImage>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<&MaskArray, GpuError> {
|
||||
self.render_revealing(stack, labels, subjects, source, width, height, None)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// [`Self::render`], also drawing the layer being looked at.
|
||||
///
|
||||
/// A selection with no adjustment on it changes no pixel, so it is not
|
||||
/// active and has no slice — which is right until somebody asks to *see*
|
||||
/// it, and that is the state a photographer is in from choosing a subject
|
||||
/// until deciding what to do to it.
|
||||
///
|
||||
/// `reveal` has to be the same one the shader was composed with and the
|
||||
/// same one the distance fields were built for: all three index this array
|
||||
/// by position in [`MaskStack::rendered`], and two of them disagreeing
|
||||
/// shows as an adjustment applied through another layer's mask.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_revealing(
|
||||
&mut self,
|
||||
stack: &MaskStack,
|
||||
labels: Option<&LabelField>,
|
||||
subjects: Option<&SubjectMasks>,
|
||||
source: Option<&DemosaicedImage>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
reveal: Option<&dr_pipeline::mask::Reveal>,
|
||||
) -> Result<&MaskArray, GpuError> {
|
||||
// At least one layer, because a zero-layer texture array is invalid
|
||||
// and the shader binds this slot unconditionally.
|
||||
let active = stack.active_count().clamp(1, MAX_LAYERS) as u32;
|
||||
let active = stack.rendered_count(reveal).clamp(1, MAX_LAYERS) as u32;
|
||||
self.ensure_array(width, height, active)?;
|
||||
|
||||
let mut encoder = self
|
||||
@@ -558,60 +747,143 @@ impl MaskPass {
|
||||
label: Some("mask-encoder"),
|
||||
});
|
||||
|
||||
for (slot, layer) in stack.active().enumerate().take(MAX_LAYERS) {
|
||||
let field = match (&layer.source, labels) {
|
||||
(MaskSource::Regions { .. }, None) => {
|
||||
log::warn!(
|
||||
"mask layer {} is a region mask with no segmentation loaded; skipping",
|
||||
layer.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
(MaskSource::Regions { .. }, Some(f)) => f,
|
||||
(_, _) => &self.placeholder,
|
||||
};
|
||||
for (slot, layer) in stack.rendered(reveal).enumerate().take(MAX_LAYERS) {
|
||||
// **The path a mask with one part takes is the path every mask
|
||||
// took before parts existed**: drawn straight into the layer's
|
||||
// slice, cleared by the draw itself. Nothing about an unedited
|
||||
// library's rendering changes, and the scratch texture is never
|
||||
// allocated for it.
|
||||
//
|
||||
// An inverted base is the exception, because turning a part over
|
||||
// is done where it is read back rather than where it is drawn —
|
||||
// a brush deposits dabs and cannot know what the rest of the
|
||||
// frame is. See `fs_combine`.
|
||||
// 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)?;
|
||||
}
|
||||
|
||||
// A subject layer whose instance is missing is skipped for the
|
||||
// same reason a region layer without a segmentation is: an absent
|
||||
// mask that defaults to "everything" would apply the adjustment to
|
||||
// the whole photograph, which is a much louder failure than none.
|
||||
// Indexed by *slot*, not by the instance the layer names: the
|
||||
// fields are built per layer, in this same order, because two
|
||||
// layers over one subject can carry different morphology.
|
||||
let subject = match &layer.source {
|
||||
// Category alongside Subject: both are model coverage turned
|
||||
// into a distance field, both are built per layer in this same
|
||||
// order, and leaving a category out of here is precisely the
|
||||
// failure the comment above warns about — it binds the 1x1
|
||||
// placeholder, so the mask covers everything and the
|
||||
// adjustment silently goes global.
|
||||
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
|
||||
match subjects.filter(|s| slot < s.len()) {
|
||||
Some(s) => (s, slot),
|
||||
None => {
|
||||
log::warn!("mask layer {} has no distance field; skipping", layer.id);
|
||||
continue;
|
||||
for (index, part) in shown.iter().copied().enumerate() {
|
||||
let base = index == 0;
|
||||
let field = match (&part.source, labels) {
|
||||
(MaskSource::Regions { .. }, None) => {
|
||||
log::warn!(
|
||||
"mask layer {} is a region mask with no segmentation loaded; skipping",
|
||||
layer.id
|
||||
);
|
||||
if base {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
(MaskSource::Regions { .. }, Some(f)) => f,
|
||||
(_, _) => &self.placeholder,
|
||||
};
|
||||
|
||||
// A subject part whose instance is missing is skipped for the
|
||||
// same reason a region part without a segmentation is: an
|
||||
// absent mask that defaults to "everything" would apply the
|
||||
// adjustment to the whole photograph, which is a much louder
|
||||
// failure than none.
|
||||
//
|
||||
// Indexed by *slot*, not by the instance the part names: the
|
||||
// fields are built per layer, in this same order, because two
|
||||
// layers over one subject can carry different morphology.
|
||||
// Which is also why only a base part can have one — a model
|
||||
// part joined to a mask has no field built for it yet, and it
|
||||
// is skipped rather than drawn against a placeholder that
|
||||
// would cover the frame.
|
||||
let subject = match &part.source {
|
||||
// Category alongside Subject: both are model coverage
|
||||
// turned into a distance field, both are built per layer
|
||||
// in this same order, and leaving a category out of here
|
||||
// is precisely the failure the comment above warns about —
|
||||
// it binds the 1x1 placeholder, so the mask covers
|
||||
// everything and the adjustment silently goes global.
|
||||
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
|
||||
match subjects.filter(|s| base && slot < s.len()) {
|
||||
Some(s) => (s, slot),
|
||||
None => {
|
||||
log::warn!(
|
||||
"part {} of mask layer {} has no distance field; skipping",
|
||||
part.id,
|
||||
layer.id
|
||||
);
|
||||
if base {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (&self.empty_subject, 0),
|
||||
};
|
||||
_ => (&self.empty_subject, 0),
|
||||
};
|
||||
|
||||
let params = self.params(layer, field, width, height);
|
||||
match &layer.source {
|
||||
MaskSource::Brush { strokes } => {
|
||||
self.draw_brush(&mut encoder, slot as u32, ¶ms, strokes, width, height)
|
||||
// TRACES: FR-DEV-10
|
||||
// A range part with no photograph bound is skipped rather than
|
||||
// drawn against the placeholder, on exactly the rule the two
|
||||
// cases above follow: an absent mask that defaults to
|
||||
// "everything" takes a local adjustment global, which is a far
|
||||
// quieter failure than a part that visibly did not render.
|
||||
let image = match (&part.source, source) {
|
||||
(s, None) if s.is_range() => {
|
||||
log::warn!(
|
||||
"mask layer {} selects a range with no image loaded; skipping",
|
||||
layer.id
|
||||
);
|
||||
if base {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
(_, image) => image,
|
||||
};
|
||||
|
||||
let params = self.params(part, field, image, width, height);
|
||||
let target = if direct {
|
||||
self.slice_view(slot as u32)
|
||||
} else {
|
||||
self.scratch_view()
|
||||
};
|
||||
|
||||
match &part.source {
|
||||
MaskSource::Brush { strokes } => {
|
||||
self.draw_brush(&mut encoder, &target, ¶ms, strokes, width, height)
|
||||
}
|
||||
_ => {
|
||||
let selected = self.selection_buffer(part, field);
|
||||
self.draw(
|
||||
&mut encoder,
|
||||
&target,
|
||||
¶ms,
|
||||
field,
|
||||
&selected,
|
||||
subject,
|
||||
image,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let selected = self.selection_buffer(layer, field);
|
||||
self.draw(
|
||||
&mut encoder,
|
||||
slot as u32,
|
||||
¶ms,
|
||||
field,
|
||||
&selected,
|
||||
subject,
|
||||
);
|
||||
|
||||
if !direct {
|
||||
// The first part joins a cleared slice, so it lands
|
||||
// exactly as it was drawn whichever way it says it joins —
|
||||
// there is nothing yet for a subtraction to take away
|
||||
// from, and a mask that began by subtracting from nothing
|
||||
// would render as empty however it was painted afterwards.
|
||||
let join = if base { Join::Union } else { part.join };
|
||||
self.combine(&mut encoder, slot as u32, join, base, ¶ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -632,11 +904,36 @@ impl MaskPass {
|
||||
|
||||
fn params(
|
||||
&self,
|
||||
layer: &dr_pipeline::mask::MaskLayer,
|
||||
part: &dr_pipeline::mask::MaskPart,
|
||||
field: &LabelField,
|
||||
source: Option<&DemosaicedImage>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> MaskParams {
|
||||
// TRACES: FR-DEV-10
|
||||
// How much of the photograph one mask texel covers. One when there is
|
||||
// no image bound, which is a value nothing reads — the range modes are
|
||||
// the only readers and they are skipped in that case.
|
||||
let source_step = match source {
|
||||
Some(image) => {
|
||||
let (sw, sh) = image.size();
|
||||
[
|
||||
sw as f32 / width.max(1) as f32,
|
||||
sh as f32 / height.max(1) as f32,
|
||||
]
|
||||
}
|
||||
None => [1.0, 1.0],
|
||||
};
|
||||
// Row-major nine, widened to three `vec4`s. Identity where there is no
|
||||
// image, so a range that somehow reached the shader without one would
|
||||
// read camera values rather than nothing — the same defensive choice
|
||||
// the demosaicer makes for an uncalibrated body.
|
||||
let m = source.map_or([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], |i| {
|
||||
i.color_matrix()
|
||||
});
|
||||
let wb = source.map_or([1.0, 1.0, 1.0], |i| i.as_shot_wb());
|
||||
let non_linear = source.is_some_and(|i| i.is_non_linear());
|
||||
|
||||
let base = MaskParams {
|
||||
width,
|
||||
height,
|
||||
@@ -650,10 +947,18 @@ impl MaskPass {
|
||||
axis: [1.0, 0.0],
|
||||
softness: 0.0,
|
||||
angle: 0.0,
|
||||
_pad1: [0.0, 0.0],
|
||||
source_step,
|
||||
cam_to_srgb: [
|
||||
[m[0], m[1], m[2], 0.0],
|
||||
[m[3], m[4], m[5], 0.0],
|
||||
[m[6], m[7], m[8], 0.0],
|
||||
],
|
||||
as_shot_wb: [wb[0], wb[1], wb[2], if non_linear { 1.0 } else { 0.0 }],
|
||||
invert: u32::from(part.invert),
|
||||
_pad: [0; 3],
|
||||
};
|
||||
|
||||
match &layer.source {
|
||||
match &part.source {
|
||||
// `softness` carries the layer's feather. The model's coverage is
|
||||
// already a soft sigmoid, so zero means "use the edge the model
|
||||
// drew" rather than "hard edge" — the one place in this shader
|
||||
@@ -673,12 +978,12 @@ impl MaskPass {
|
||||
MaskParams {
|
||||
mode: MODE_SUBJECT,
|
||||
// `softness` is the feather half-width in pixels.
|
||||
softness: (layer.feather * short).max(0.0),
|
||||
softness: (part.feather * short).max(0.0),
|
||||
// `angle` carries the morphology offset — reused rather
|
||||
// than padded, since a subject layer has no ellipse to
|
||||
// rotate.
|
||||
angle: morph_offset(layer) * short,
|
||||
falloff: falloff_code(layer.falloff),
|
||||
angle: morph_offset(part) * short,
|
||||
falloff: falloff_code(part.falloff),
|
||||
..base
|
||||
}
|
||||
}
|
||||
@@ -719,6 +1024,33 @@ impl MaskPass {
|
||||
mode: MODE_BRUSH,
|
||||
..base
|
||||
},
|
||||
// TRACES: FR-DEV-10
|
||||
// A band, carried in the fields the gradients measure geometry
|
||||
// in. Reused rather than given their own, and it is not a
|
||||
// shortcut: `centre` and `axis` are two pairs of floats whose
|
||||
// meaning has always been the mode's to decide, and a range that
|
||||
// added four more would grow the uniform every other mask pays
|
||||
// for. What matters is that nothing here is a *coordinate* — a
|
||||
// range is not a function of position at all.
|
||||
MaskSource::Luminance { lo, hi, softness } => MaskParams {
|
||||
mode: MODE_LUMINANCE,
|
||||
axis: [*lo, *hi],
|
||||
softness: *softness,
|
||||
..base
|
||||
},
|
||||
MaskSource::Colour {
|
||||
hue,
|
||||
hue_width,
|
||||
chroma_lo,
|
||||
chroma_hi,
|
||||
softness,
|
||||
} => MaskParams {
|
||||
mode: MODE_COLOUR,
|
||||
centre: [*hue, *hue_width],
|
||||
axis: [*chroma_lo, *chroma_hi],
|
||||
softness: *softness,
|
||||
..base
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,7 +1069,7 @@ impl MaskPass {
|
||||
fn draw_brush(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
slot: u32,
|
||||
target: &wgpu::TextureView,
|
||||
params: &MaskParams,
|
||||
strokes: &[Stroke],
|
||||
width: u32,
|
||||
@@ -799,19 +1131,10 @@ impl MaskPass {
|
||||
})
|
||||
});
|
||||
|
||||
let array = self.array.as_ref().expect("array ensured by caller");
|
||||
let view = array.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("mask-slice"),
|
||||
dimension: Some(wgpu::TextureViewDimension::D2),
|
||||
base_array_layer: slot,
|
||||
array_layer_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("mask-brush-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
view: target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -857,11 +1180,11 @@ impl MaskPass {
|
||||
/// One byte-flag per region, or a single zero for a non-region layer.
|
||||
fn selection_buffer(
|
||||
&self,
|
||||
layer: &dr_pipeline::mask::MaskLayer,
|
||||
part: &dr_pipeline::mask::MaskPart,
|
||||
field: &LabelField,
|
||||
) -> wgpu::Buffer {
|
||||
let mut flags = vec![0u32; field.region_count.max(1) as usize];
|
||||
if let MaskSource::Regions { ids, .. } = &layer.source {
|
||||
if let MaskSource::Regions { ids, .. } = &part.source {
|
||||
for &id in ids {
|
||||
if let Some(slot) = flags.get_mut(id as usize) {
|
||||
*slot = 1;
|
||||
@@ -882,11 +1205,12 @@ impl MaskPass {
|
||||
fn draw(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
slot: u32,
|
||||
target: &wgpu::TextureView,
|
||||
params: &MaskParams,
|
||||
field: &LabelField,
|
||||
selected: &wgpu::Buffer,
|
||||
subject: (&SubjectMasks, usize),
|
||||
source: Option<&DemosaicedImage>,
|
||||
) {
|
||||
let params_buf = self
|
||||
.ctx
|
||||
@@ -924,25 +1248,23 @@ impl MaskPass {
|
||||
}),
|
||||
),
|
||||
},
|
||||
// TRACES: FR-DEV-10
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(
|
||||
source.map_or(&self.empty_image, |i| i.view()),
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// The array slice is selected by the attachment rather than by a
|
||||
// uniform the shader reads — one fewer value that can disagree with
|
||||
// where the pass actually writes.
|
||||
let array = self.array.as_ref().expect("array ensured by caller");
|
||||
let view = array.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("mask-slice"),
|
||||
dimension: Some(wgpu::TextureViewDimension::D2),
|
||||
base_array_layer: slot,
|
||||
array_layer_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("mask-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
view: target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -963,6 +1285,166 @@ impl MaskPass {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// A view of one layer's slice of the array.
|
||||
fn slice_view(&self, slot: u32) -> wgpu::TextureView {
|
||||
// The array slice is selected by the attachment rather than by a
|
||||
// uniform the shader reads — one fewer value that can disagree with
|
||||
// where the pass actually writes.
|
||||
let array = self.array.as_ref().expect("array ensured by caller");
|
||||
array.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("mask-slice"),
|
||||
dimension: Some(wgpu::TextureViewDimension::D2),
|
||||
base_array_layer: slot,
|
||||
array_layer_count: Some(1),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn scratch_view(&self) -> wgpu::TextureView {
|
||||
self.scratch
|
||||
.as_ref()
|
||||
.expect("scratch ensured by caller")
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("mask-part"),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Blend the part sitting in [`Self::scratch`] into a layer's slice.
|
||||
///
|
||||
/// `first` clears the slice instead of loading it, which is both cheaper
|
||||
/// on a tiler and the only thing that makes the fold start from nothing
|
||||
/// covered rather than from whatever the last rasterisation left.
|
||||
fn combine(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
slot: u32,
|
||||
join: Join,
|
||||
first: bool,
|
||||
params: &MaskParams,
|
||||
) {
|
||||
let params_buf = self
|
||||
.ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("mask-combine-params"),
|
||||
contents: bytemuck::bytes_of(params),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
|
||||
let bind_group = self
|
||||
.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("mask-combine-bind"),
|
||||
layout: &self.combine_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: params_buf.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&self.scratch_view()),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let target = self.slice_view(slot);
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("mask-combine-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: if first {
|
||||
wgpu::LoadOp::Clear(wgpu::Color::BLACK)
|
||||
} else {
|
||||
wgpu::LoadOp::Load
|
||||
},
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
|
||||
pass.set_pipeline(match join {
|
||||
Join::Union => &self.combine_union,
|
||||
Join::Subtract => &self.combine_subtract,
|
||||
});
|
||||
pass.set_bind_group(0, &bind_group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// rasterisation size, which is the same size the array is: a part and the
|
||||
/// slice it joins are compared texel for texel, so there is nothing to
|
||||
/// scale and nothing to sample between.
|
||||
fn ensure_scratch(&mut self, width: u32, height: u32) -> Result<(), GpuError> {
|
||||
if self
|
||||
.scratch
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.width == width && s.height == height)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("mask-scratch"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: MaskArray::FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
|
||||
self.scratch = Some(Scratch {
|
||||
texture,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_array(&mut self, width: u32, height: u32, layers: u32) -> Result<(), GpuError> {
|
||||
if self
|
||||
.array
|
||||
@@ -1005,6 +1487,57 @@ impl MaskPass {
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture one part is drawn into on its way into a layer's slice.
|
||||
struct Scratch {
|
||||
texture: wgpu::Texture,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// `max(dst, src)` — the union of two parts.
|
||||
///
|
||||
/// Not source-over, which would build up: two parts that each half-cover a
|
||||
/// pixel select it half, and adding them would make the overlap of two soft
|
||||
/// edges harder than either of them, drawing a seam exactly where a
|
||||
/// photographer joined two selections to avoid one.
|
||||
const MAX_BLEND: wgpu::BlendComponent = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Max,
|
||||
};
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// A single black texel, bound at the image slot for a mask that is not a
|
||||
/// range.
|
||||
///
|
||||
/// Written rather than merely allocated. Undefined contents would be read by
|
||||
/// nothing today, but a binding mistake in a range mask would then produce
|
||||
/// whatever the driver left in memory — a mask that flickers between builds
|
||||
/// and machines, which is the hardest shape of bug this pass could have.
|
||||
fn empty_image(ctx: &GpuContext) -> wgpu::TextureView {
|
||||
let texture = ctx.device.create_texture_with_data(
|
||||
&ctx.queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("mask-empty-image"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: DemosaicedImage::FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
// Four half-floats of zero. Rgba16Float, so eight bytes.
|
||||
&[0u8; 8],
|
||||
);
|
||||
texture.create_view(&wgpu::TextureViewDescriptor::default())
|
||||
}
|
||||
|
||||
/// The shorter edge of the space the mask is rasterised in.
|
||||
///
|
||||
/// Feather and morphology are stored as fractions of it, so the same edit is
|
||||
@@ -1018,11 +1551,11 @@ fn field_short_edge(width: u32, height: u32) -> f32 {
|
||||
/// Zero for closing and opening: those are folded into the field itself when
|
||||
/// it is built, because their second half acts on a shape the original field
|
||||
/// does not describe.
|
||||
fn morph_offset(layer: &dr_pipeline::mask::MaskLayer) -> f32 {
|
||||
fn morph_offset(part: &dr_pipeline::mask::MaskPart) -> f32 {
|
||||
use dr_pipeline::mask::Morphology;
|
||||
match layer.morphology {
|
||||
Morphology::Dilate => layer.morph_radius,
|
||||
Morphology::Erode => -layer.morph_radius,
|
||||
match part.morphology {
|
||||
Morphology::Dilate => part.morph_radius,
|
||||
Morphology::Erode => -part.morph_radius,
|
||||
Morphology::None | Morphology::Close | Morphology::Open => 0.0,
|
||||
}
|
||||
}
|
||||
@@ -1131,7 +1664,7 @@ mod tests {
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass
|
||||
.render(&stack, Some(&field), None, w, h)
|
||||
.render(&stack, Some(&field), None, None, w, h)
|
||||
.expect("render");
|
||||
assert_eq!(array.size(), (w, h));
|
||||
assert_eq!(array.layers(), 1);
|
||||
@@ -1153,7 +1686,7 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
assert!(pass.render(&stack, None, None, 8, 8).is_ok());
|
||||
assert!(pass.render(&stack, None, None, None, 8, 8).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1176,7 +1709,9 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass.render(&stack, None, None, 16, 16).expect("render");
|
||||
let array = pass
|
||||
.render(&stack, None, None, None, 16, 16)
|
||||
.expect("render");
|
||||
assert_eq!(array.layers(), 2, "one slice per active layer");
|
||||
}
|
||||
|
||||
@@ -1186,11 +1721,11 @@ mod tests {
|
||||
fn painted(gestures: &[Gesture]) -> MaskLayer {
|
||||
let mut layer = lit(MaskSource::brush());
|
||||
for (erase, radius, path) in gestures {
|
||||
layer.begin_stroke(*erase, *radius, 0.5, 1.0);
|
||||
layer.begin_stroke(0, *erase, *radius, 0.5, 1.0);
|
||||
for &(x, y) in path {
|
||||
layer.extend_stroke(x, y);
|
||||
layer.extend_stroke(0, x, y);
|
||||
}
|
||||
layer.end_stroke();
|
||||
layer.end_stroke(0);
|
||||
}
|
||||
layer
|
||||
}
|
||||
@@ -1208,7 +1743,9 @@ mod tests {
|
||||
stack.push(painted(&[(false, 0.1, vec![(0.2, 0.2), (0.8, 0.8)])]));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass.render(&stack, None, None, 32, 32).expect("render");
|
||||
let array = pass
|
||||
.render(&stack, None, None, None, 32, 32)
|
||||
.expect("render");
|
||||
assert_eq!(array.layers(), 1);
|
||||
}
|
||||
|
||||
@@ -1258,7 +1795,7 @@ mod tests {
|
||||
};
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass
|
||||
.render(&MaskStack::new(), None, None, 8, 8)
|
||||
.render(&MaskStack::new(), None, None, None, 8, 8)
|
||||
.expect("render");
|
||||
assert_eq!(
|
||||
array.layers(),
|
||||
@@ -1281,17 +1818,20 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
pass.render(&stack, None, None, 32, 32).expect("render");
|
||||
pass.render(&stack, None, None, None, 32, 32)
|
||||
.expect("render");
|
||||
assert_eq!(pass.allocations(), 1);
|
||||
|
||||
pass.render(&stack, None, None, 32, 32).expect("render");
|
||||
pass.render(&stack, None, None, None, 32, 32)
|
||||
.expect("render");
|
||||
assert_eq!(
|
||||
pass.allocations(),
|
||||
1,
|
||||
"same size and layer count should not reallocate"
|
||||
);
|
||||
|
||||
pass.render(&stack, None, None, 64, 64).expect("render");
|
||||
pass.render(&stack, None, None, None, 64, 64)
|
||||
.expect("render");
|
||||
assert_eq!(pass.allocations(), 2, "a resize must reallocate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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))
|
||||
}
|
||||
@@ -28,7 +28,8 @@ struct MaskParams {
|
||||
label_width: u32,
|
||||
label_height: u32,
|
||||
|
||||
// 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush.
|
||||
// 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush,
|
||||
// 5 = luminance range, 6 = colour range.
|
||||
//
|
||||
// A brush does not read this — it has its own entry points, because it is
|
||||
// the one mask that is not a function of the whole frame — but it is set
|
||||
@@ -50,10 +51,49 @@ struct MaskParams {
|
||||
// Linear: (cos, sin) of the ramp direction. Radial: semi-axes.
|
||||
axis: vec2<f32>,
|
||||
// Linear: ramp width. Radial: edge falloff as a fraction of the radius.
|
||||
// A range: the fade at each edge of its band, in the band's own units.
|
||||
softness: f32,
|
||||
// Radial only: rotation of the ellipse.
|
||||
angle: f32,
|
||||
_pad1: vec2<f32>,
|
||||
|
||||
// TRACES: FR-DEV-10
|
||||
// How many source texels one mask texel spans, per axis.
|
||||
//
|
||||
// The mask array is rasterised at a proxy size and the photograph is not,
|
||||
// so one texel here covers several there. A range mask is a function of
|
||||
// pixel *values*, and point-sampling one source texel in four would make
|
||||
// its edge follow the sensor's noise wherever the picture has fine
|
||||
// texture — speckle that is then a mask, and therefore visible in the
|
||||
// adjustment. Averaging the footprint is what makes the band land on the
|
||||
// tone the area actually is.
|
||||
source_step: vec2<f32>,
|
||||
|
||||
// Camera RGB → linear sRGB, one row each. Only a range reads these: it is
|
||||
// the one mask that looks at the photograph, and a hue is the body's own
|
||||
// primaries until this matrix has been applied — so the same stored arc
|
||||
// would select a different set of colours on every make of sensor.
|
||||
cam_to_srgb_0: vec4<f32>,
|
||||
cam_to_srgb_1: vec4<f32>,
|
||||
cam_to_srgb_2: vec4<f32>,
|
||||
// rgb: as-shot white balance. w: non-zero when the source arrived
|
||||
// gamma-encoded rather than linear.
|
||||
as_shot_wb: vec4<f32>,
|
||||
|
||||
// Whether this part is turned over before it joins the mask.
|
||||
//
|
||||
// Read by `fs_combine` and by nothing else, deliberately. A brush deposits
|
||||
// dabs onto an empty field and has no idea what the rest of the frame is,
|
||||
// so a stroke shader cannot invert anything; doing it where the finished
|
||||
// part is read back is the one place that works for every kind of source.
|
||||
invert: u32,
|
||||
// Three scalars rather than a `vec3<u32>`: a three-component vector is
|
||||
// aligned to sixteen bytes in the uniform address space, so it would sit
|
||||
// at offset 144 and make this struct 160 bytes against the Rust side's
|
||||
// 144 — a mismatch wgpu reports as a binding too small for the shader,
|
||||
// several layers away from the padding that caused it.
|
||||
_pad0: u32,
|
||||
_pad1: u32,
|
||||
_pad2: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: MaskParams;
|
||||
@@ -74,6 +114,19 @@ struct MaskParams {
|
||||
// shrinking and feathering free: each is arithmetic on this, so a slider moves
|
||||
// a uniform instead of rebuilding a mask.
|
||||
@group(0) @binding(3) var subject: texture_2d<f32>;
|
||||
// TRACES: FR-DEV-10
|
||||
// The photograph itself, as the demosaicer left it: camera RGB, unbalanced,
|
||||
// with no edit applied. A 1x1 placeholder for every mask that is a shape,
|
||||
// because the bindings are fixed and a second pipeline differing only in what
|
||||
// it ignores would cost more than one texel.
|
||||
//
|
||||
// **The unedited image, and that is the design rather than an accident of
|
||||
// pass order.** A band over the *edited* result would move as the edit was
|
||||
// made: raising the highlights would change which pixels counted as
|
||||
// highlights, so the slider would chase its own mask. Measuring what the
|
||||
// camera recorded means the selection stays where the photographer put it
|
||||
// while they work on it.
|
||||
@group(0) @binding(6) var image: texture_2d<f32>;
|
||||
|
||||
// A full-screen triangle rather than a quad: three vertices instead of six,
|
||||
// no shared edge for the rasteriser to crack along, and no vertex buffer.
|
||||
@@ -221,6 +274,177 @@ fn subject_mask(uv: vec2<f32>) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Range masks (FR-DEV-10)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The masks that select by what a pixel *is* rather than by where it sits.
|
||||
// Nothing below reads `frame_delta`, and that absence is the point: a range is
|
||||
// not a function of position, so it cannot be stretched by an aspect ratio,
|
||||
// cannot drift under a crop, and comes out the same at a proxy size and at an
|
||||
// export because the only thing it depends on is the photograph's own values.
|
||||
//
|
||||
// The band arrives entirely in the fields the gradients use — `axis` is the
|
||||
// pair of bounds, `centre` is a colour range's arc, `softness` is the fade —
|
||||
// so a range costs nothing in the uniform beyond the image transform above.
|
||||
|
||||
// Display-encoded sRGB back to linear.
|
||||
//
|
||||
// A JPEG is uploaded with its bytes untouched, so its values are gamma-encoded
|
||||
// where the demosaicer's are linear. The same undoing the generated adjust
|
||||
// shader does, at the same point and for the same reason: a band over
|
||||
// brightness is meaningless if two sources disagree about what a value means.
|
||||
fn decode_srgb(c: vec3<f32>) -> vec3<f32> {
|
||||
let lo = c / 12.92;
|
||||
let hi = pow((max(c, vec3<f32>(0.04045)) + 0.055) / 1.055, vec3<f32>(2.4));
|
||||
return select(hi, lo, c <= vec3<f32>(0.04045));
|
||||
}
|
||||
|
||||
// One source texel, as linear sRGB.
|
||||
//
|
||||
// This is the prologue of the generated adjust shader, repeated: decode,
|
||||
// balance, pull a clipped pixel back to neutral, then the camera matrix. It is
|
||||
// repeated rather than shared because the composer emits WGSL for the *edit*
|
||||
// and this pass is not one — but it must agree with it, since a range mask
|
||||
// exists to select the values the layer's own adjustments will then see.
|
||||
//
|
||||
// The highlight desaturation is the part that looks skippable and is not. A
|
||||
// fully clipped photosite arrives as (1,1,1), carrying no colour at all; the
|
||||
// as-shot multipliers are far from neutral, so balancing it and passing it
|
||||
// through the matrix produces a strong magenta. A colour range would then
|
||||
// select every blown sky as if the photographer had asked for magenta.
|
||||
fn source_texel(px: vec2<i32>) -> vec3<f32> {
|
||||
var c = textureLoad(image, px, 0).rgb;
|
||||
if (p.as_shot_wb.w > 0.5) {
|
||||
c = decode_srgb(c);
|
||||
}
|
||||
|
||||
let clipped = smoothstep(0.985, 1.0, max(c.r, max(c.g, c.b)));
|
||||
c = c * p.as_shot_wb.rgb;
|
||||
if (clipped > 0.0) {
|
||||
c = mix(c, vec3<f32>(max(c.r, max(c.g, c.b))), clipped);
|
||||
}
|
||||
|
||||
return vec3<f32>(
|
||||
dot(p.cam_to_srgb_0.rgb, c),
|
||||
dot(p.cam_to_srgb_1.rgb, c),
|
||||
dot(p.cam_to_srgb_2.rgb, c),
|
||||
);
|
||||
}
|
||||
|
||||
// The most taps one mask texel averages, per axis.
|
||||
//
|
||||
// A cap rather than the true footprint. At a 1600 px proxy over a 24 MP frame
|
||||
// the ratio is under four, so this is the whole footprint for every ordinary
|
||||
// photograph; past it the taps stride across the footprint instead of
|
||||
// covering it, which is a sample of the area rather than its mean. That is the
|
||||
// right way to run out of budget here — the estimate gets noisier, it does not
|
||||
// start measuring somewhere else.
|
||||
const MAX_SOURCE_TAPS: i32 = 4;
|
||||
|
||||
// The photograph's value under one mask texel, in linear sRGB.
|
||||
fn image_value(px: vec2<i32>) -> vec3<f32> {
|
||||
let dims = vec2<i32>(textureDimensions(image));
|
||||
let last = dims - vec2<i32>(1);
|
||||
|
||||
// The footprint's top-left corner in source texels. Not a centre plus a
|
||||
// radius: the mask texel is a *box* over the source, and sampling
|
||||
// symmetrically about its centre would weight the middle of every
|
||||
// footprint twice at odd tap counts.
|
||||
let origin = vec2<f32>(px) * p.source_step;
|
||||
let taps = clamp(vec2<i32>(ceil(p.source_step)), vec2<i32>(1), vec2<i32>(MAX_SOURCE_TAPS));
|
||||
// `stride`, not `step`: WGSL has a builtin of that name, and a local that
|
||||
// shadows one is legal and unreadable in the same breath.
|
||||
let stride = p.source_step / vec2<f32>(taps);
|
||||
|
||||
var total = vec3<f32>(0.0);
|
||||
for (var y = 0; y < taps.y; y = y + 1) {
|
||||
for (var x = 0; x < taps.x; x = x + 1) {
|
||||
let at = origin + (vec2<f32>(f32(x), f32(y)) + vec2<f32>(0.5)) * stride;
|
||||
total = total + source_texel(clamp(vec2<i32>(at), vec2<i32>(0), last));
|
||||
}
|
||||
}
|
||||
return total / f32(taps.x * taps.y);
|
||||
}
|
||||
|
||||
// A soft band: one inside, nothing outside, a smooth ramp across each edge.
|
||||
//
|
||||
// The `min` rather than a product of the two ramps. A band narrower than twice
|
||||
// its softness has no plateau, and multiplying the rising and falling ramps
|
||||
// would then peak well below one — so "select the highlights" would come out
|
||||
// at sixty per cent and the photographer would compensate with opacity,
|
||||
// against a mask that was quietly weaker than it said. `min` keeps the
|
||||
// plateau where there is one and degrades to a single peak where there is not.
|
||||
fn band(v: f32, lo: f32, hi: f32, soft: f32) -> f32 {
|
||||
if (soft <= 0.0) {
|
||||
return select(0.0, 1.0, v >= lo && v <= hi);
|
||||
}
|
||||
return min(smoothstep(lo - soft, lo, v), 1.0 - smoothstep(hi, hi + soft, v));
|
||||
}
|
||||
|
||||
fn luminance_mask(px: vec2<i32>) -> f32 {
|
||||
let y = dot(image_value(px), vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
// Onto the perceptual position `tone_position` in `ops/_helpers.yaml`
|
||||
// establishes, which is where the stored bounds are measured. Linear light
|
||||
// puts middle grey at 0.18, so a band stated in it would spend four fifths
|
||||
// of its travel inside the shadows.
|
||||
let t = clamp(pow(max(y, 0.0), 1.0 / 3.0), 0.0, 1.0);
|
||||
return band(t, p.axis.x, p.axis.y, p.softness);
|
||||
}
|
||||
|
||||
// Hue in turns, 0 at red and increasing through yellow.
|
||||
//
|
||||
// The plain six-sector definition. Zero for a neutral, which is a value the
|
||||
// caller must not act on — the chroma bound below is what keeps a colour range
|
||||
// away from the greys where this number is rounding noise.
|
||||
fn hue_of(c: vec3<f32>) -> f32 {
|
||||
let hi = max(c.r, max(c.g, c.b));
|
||||
let lo = min(c.r, min(c.g, c.b));
|
||||
let d = hi - lo;
|
||||
if (d <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
var h = 0.0;
|
||||
if (hi == c.r) {
|
||||
h = (c.g - c.b) / d;
|
||||
} else if (hi == c.g) {
|
||||
h = (c.b - c.r) / d + 2.0;
|
||||
} else {
|
||||
h = (c.r - c.g) / d + 4.0;
|
||||
}
|
||||
return fract(h / 6.0);
|
||||
}
|
||||
|
||||
fn colour_mask(px: vec2<i32>) -> f32 {
|
||||
let c = max(image_value(px), vec3<f32>(0.0));
|
||||
let hi = max(c.r, max(c.g, c.b));
|
||||
let lo = min(c.r, min(c.g, c.b));
|
||||
// The max-minus-min chroma `colour_saturation` uses, so the number the
|
||||
// band is stated in is the one the rest of the pipeline means by
|
||||
// 'colourfulness'.
|
||||
var chroma = 0.0;
|
||||
if (hi > 0.0) {
|
||||
chroma = (hi - lo) / hi;
|
||||
}
|
||||
|
||||
// Distance round the circle, so an arc centred near red reaches both ways
|
||||
// past zero. Written as a wrap rather than as two comparisons because red
|
||||
// is exactly where skin sits, and an arc that stopped at the seam would
|
||||
// select half of it.
|
||||
let d = abs(fract(hue_of(c) - p.centre.x + 0.5) - 0.5);
|
||||
var arc = 0.0;
|
||||
if (p.softness <= 0.0) {
|
||||
arc = select(0.0, 1.0, d <= p.centre.y);
|
||||
} else {
|
||||
arc = 1.0 - smoothstep(p.centre.y, p.centre.y + p.softness, d);
|
||||
}
|
||||
|
||||
// Both, not either: an arc alone selects a haze of noise everywhere the
|
||||
// picture is nearly grey, because a hue rounded out of three almost-equal
|
||||
// channels is still a hue.
|
||||
return min(arc, band(chroma, p.axis.x, p.axis.y, p.softness));
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let px = vec2<i32>(i32(pos.x), i32(pos.y));
|
||||
@@ -235,6 +459,8 @@ fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
case 1u: { m = linear_mask(uv); }
|
||||
case 2u: { m = radial_mask(uv); }
|
||||
case 3u: { m = subject_mask(uv); }
|
||||
case 5u: { m = luminance_mask(px); }
|
||||
case 6u: { m = colour_mask(px); }
|
||||
default: { m = 0.0; }
|
||||
}
|
||||
|
||||
@@ -387,3 +613,29 @@ fn fs_brush(in: BrushVertex) -> @location(0) vec4<f32> {
|
||||
|
||||
return vec4<f32>(clamp(coverage * s.flow, 0.0, 1.0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Joining one part to the mask so far
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A layer's mask is a fold over its parts, and the set operation is the *blend
|
||||
// state* rather than arithmetic here: union is `max(dst, src)`, subtraction is
|
||||
// `dst * (1 - src)`. Both are fixed-function, so joining a part costs one
|
||||
// full-screen draw and no second texture beyond the one being read.
|
||||
//
|
||||
// # Why a part is drawn aside first, rather than straight onto the mask
|
||||
//
|
||||
// Because an erase stroke inside a part means "a hole in *this* part", not "a
|
||||
// hole in the mask". Painted straight onto the accumulator it would take away
|
||||
// whatever the parts before it had put there — so a correction that tidied its
|
||||
// own edge would punch through the subject underneath, and the failure would
|
||||
// look like the model's mask had holes in it.
|
||||
|
||||
@group(0) @binding(7) var part_mask: texture_2d<f32>;
|
||||
|
||||
@fragment
|
||||
fn fs_combine(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let v = textureLoad(part_mask, vec2<i32>(i32(pos.x), i32(pos.y)), 0).r;
|
||||
let m = select(v, 1.0 - v, p.invert != 0u);
|
||||
return vec4<f32>(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack, Reveal, RevealStyle};
|
||||
use dr_pipeline::operation::{compose_full, compose_full_revealing};
|
||||
use dr_pipeline::spot::SpotSet;
|
||||
use dr_pipeline::{ops, EditGraph, Framing};
|
||||
use dr_types::ColourSpace;
|
||||
@@ -72,10 +72,13 @@ fn render_at(
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks.render(stack, field, None, w, h).expect("rasterise");
|
||||
let array = masks
|
||||
.render(stack, field, None, None, w, h)
|
||||
.expect("rasterise");
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
@@ -283,11 +286,11 @@ type Gesture = (bool, f32, f32, Vec<(f32, f32)>);
|
||||
fn painted(gestures: &[Gesture]) -> MaskLayer {
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
for (erase, radius, flow, path) in gestures {
|
||||
layer.begin_stroke(*erase, *radius, 0.9, *flow);
|
||||
layer.begin_stroke(0, *erase, *radius, 0.9, *flow);
|
||||
for &(x, y) in path {
|
||||
layer.extend_stroke(x, y);
|
||||
layer.extend_stroke(0, x, y);
|
||||
}
|
||||
layer.end_stroke();
|
||||
layer.end_stroke(0);
|
||||
}
|
||||
layer
|
||||
}
|
||||
@@ -502,9 +505,9 @@ fn hardness_decides_how_quickly_the_edge_falls_away() {
|
||||
|
||||
let edge = |hardness: f32| {
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
layer.begin_stroke(false, 0.4, hardness, 1.0);
|
||||
layer.extend_stroke(0.5, 0.5);
|
||||
layer.end_stroke();
|
||||
layer.begin_stroke(0, false, 0.4, hardness, 1.0);
|
||||
layer.extend_stroke(0, 0.5, 0.5);
|
||||
layer.end_stroke(0);
|
||||
|
||||
let pixels = render(&ctx, &stack_of(layer), None);
|
||||
// How many pixels along the centre row are neither fully painted nor
|
||||
@@ -605,3 +608,417 @@ fn an_empty_stack_renders_exactly_as_the_unmasked_path() {
|
||||
let masked = render(&ctx, &MaskStack::new(), None);
|
||||
assert_eq!(plain, masked, "an empty mask stack must be a no-op");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parts — a mask built from more than one selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Everything, so a part joined to it has something to change.
|
||||
fn whole_frame() -> MaskSource {
|
||||
MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0, 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint one gesture into a part of a layer, at a radius large enough that a
|
||||
/// 32-pixel frame can tell where it landed.
|
||||
fn paint(layer: &mut MaskLayer, part: usize, erase: bool, path: &[(f32, f32)]) {
|
||||
assert!(
|
||||
layer.begin_stroke(part, erase, 0.2, 1.0, 1.0),
|
||||
"the layer had no room for a stroke"
|
||||
);
|
||||
for &(x, y) in path {
|
||||
layer.extend_stroke(part, x, y);
|
||||
}
|
||||
layer.end_stroke(part);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_union_part_adds_what_the_base_did_not_cover() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut layer = brighten(MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0],
|
||||
});
|
||||
assert!(layer.push_part(MaskPart::painted("p2", Join::Union)));
|
||||
paint(&mut layer, 1, false, &[(0.85, 0.5)]);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
|
||||
|
||||
assert!(
|
||||
luma_at(&pixels, 4, 16) > 200,
|
||||
"the base region is still masked"
|
||||
);
|
||||
assert!(
|
||||
luma_at(&pixels, 27, 16) > 200,
|
||||
"and the painted part joined the other half in"
|
||||
);
|
||||
assert_eq!(
|
||||
luma_at(&pixels, 20, 2),
|
||||
128,
|
||||
"while what neither covers is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtract_part_takes_a_bite_out_of_the_mask() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut layer = brighten(whole_frame());
|
||||
assert!(layer.push_part(MaskPart::painted("p2", Join::Subtract)));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
|
||||
|
||||
assert_eq!(
|
||||
luma_at(&pixels, 16, 16),
|
||||
128,
|
||||
"the middle was taken back out of the mask"
|
||||
);
|
||||
assert!(
|
||||
luma_at(&pixels, 1, 1) > 200,
|
||||
"and the corner the stroke never reached is still in it"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The test the scratch texture exists for.** An erase stroke means a hole in
|
||||
/// the part it was painted into, not a hole in the mask: drawn straight onto
|
||||
/// the accumulator it would take away whatever the parts before it had put
|
||||
/// there, so tidying the edge of a correction would punch through the subject
|
||||
/// underneath — and the failure would read as the model's mask having holes.
|
||||
#[test]
|
||||
fn an_erase_stroke_holes_its_own_part_and_not_the_mask() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut layer = brighten(whole_frame());
|
||||
assert!(layer.push_part(MaskPart::painted("p2", Join::Union)));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
paint(&mut layer, 1, true, &[(0.5, 0.5)]);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
|
||||
|
||||
assert!(
|
||||
luma_at(&pixels, 16, 16) > 200,
|
||||
"the base still covers where the correction erased itself"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inverted_part_joins_everything_it_did_not_paint() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// A base that covers nothing, so what shows is the part alone.
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
paint(&mut layer, 0, false, &[(0.1, 0.1)]);
|
||||
assert!(layer.push_part(MaskPart::painted("p2", Join::Union)));
|
||||
layer.parts_mut()[1].invert = true;
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
|
||||
|
||||
assert_eq!(
|
||||
luma_at(&pixels, 16, 16),
|
||||
128,
|
||||
"where the inverted part was painted is outside the mask"
|
||||
);
|
||||
assert!(
|
||||
luma_at(&pixels, 30, 30) > 200,
|
||||
"and everywhere it was not is inside it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Parts fold in order, so the same two selections joined the other way round
|
||||
/// are a different mask. A subtraction that lands before the part it was meant
|
||||
/// to cut into would take away nothing at all.
|
||||
#[test]
|
||||
fn the_order_parts_are_joined_in_is_the_mask() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let render_pair = |cut_first: bool| {
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
if cut_first {
|
||||
layer.push_part(MaskPart::painted("p2", Join::Subtract));
|
||||
layer.push_part(MaskPart::painted("p3", Join::Union));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
paint(&mut layer, 2, false, &[(0.5, 0.5)]);
|
||||
} else {
|
||||
layer.push_part(MaskPart::painted("p2", Join::Union));
|
||||
layer.push_part(MaskPart::painted("p3", Join::Subtract));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
paint(&mut layer, 2, false, &[(0.5, 0.5)]);
|
||||
}
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
render(&ctx, &stack, Some(&split_field(&ctx)))
|
||||
};
|
||||
|
||||
assert!(
|
||||
luma_at(&render_pair(true), 16, 16) > 200,
|
||||
"adding after a subtraction leaves the addition standing"
|
||||
);
|
||||
assert_eq!(
|
||||
luma_at(&render_pair(false), 16, 16),
|
||||
128,
|
||||
"and subtracting after an addition takes it away again"
|
||||
);
|
||||
}
|
||||
|
||||
// --- seeing the mask (FR-DEV-19c) ------------------------------------------
|
||||
|
||||
/// A radial that covers the middle of the frame and nothing near the corners.
|
||||
fn middle() -> MaskSource {
|
||||
MaskSource::Radial {
|
||||
centre: (0.5, 0.5),
|
||||
radii: (0.3, 0.3),
|
||||
angle: 0.0,
|
||||
feather: 0.05,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`render`], with one layer's mask drawn over the result.
|
||||
fn render_revealing(ctx: &GpuContext, stack: &MaskStack, reveal: &Reveal) -> Vec<u8> {
|
||||
let source = grey(ctx);
|
||||
let shader = compose_full_revealing(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
Some(reveal),
|
||||
);
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks
|
||||
.render_revealing(stack, None, None, None, SIZE, SIZE, Some(reveal))
|
||||
.expect("rasterise");
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
|
||||
.expect("render");
|
||||
adjust.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// The state every mask is in for its first few seconds: chosen, and not yet
|
||||
/// used for anything.
|
||||
///
|
||||
/// Such a layer changes no pixel, so it is not active, so it occupied no mask
|
||||
/// slot and was never rasterised — and the reveal drew nothing. That is the
|
||||
/// whole of "I clicked the category and nothing happened": there was a mask,
|
||||
/// and no way to see that there was.
|
||||
#[test]
|
||||
fn a_selection_with_no_adjustment_can_still_be_seen() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
assert!(
|
||||
stack.is_neutral(),
|
||||
"the fixture must be a selection with nothing done to it"
|
||||
);
|
||||
|
||||
let pixels = render_revealing(&ctx, &stack, &Reveal::one("m1", RevealStyle::Alpha));
|
||||
|
||||
assert!(
|
||||
luma_at(&pixels, SIZE / 2, SIZE / 2) > 200,
|
||||
"the middle is inside the mask and should read white"
|
||||
);
|
||||
assert!(
|
||||
luma_at(&pixels, 1, 1) < 40,
|
||||
"the corner is outside it and should read black"
|
||||
);
|
||||
}
|
||||
|
||||
/// And with nobody looking, the same stack changes nothing at all.
|
||||
///
|
||||
/// The other half of the property above: a layer renders *because* it is being
|
||||
/// revealed, so it must stop when the reveal does — otherwise a selection with
|
||||
/// no adjustment would leave a slice in the array for ever.
|
||||
#[test]
|
||||
fn a_mask_nobody_is_looking_at_draws_nothing() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
|
||||
let pixels = render(&ctx, &stack, None);
|
||||
assert_eq!(
|
||||
luma_at(&pixels, SIZE / 2, SIZE / 2),
|
||||
128,
|
||||
"flat grey, exactly as it went in"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// A tint has to leave the photograph visible, or it cannot be judged against
|
||||
/// it — which is the one thing an overlay exists for.
|
||||
#[test]
|
||||
fn a_tint_colours_the_mask_and_leaves_the_rest_alone() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
|
||||
let pixels = render_revealing(&ctx, &stack, &Reveal::one("m1", RevealStyle::Tint));
|
||||
|
||||
let at = |x: u32, y: u32| {
|
||||
let i = ((y * SIZE + x) * 4) as usize;
|
||||
(pixels[i], pixels[i + 1], pixels[i + 2])
|
||||
};
|
||||
|
||||
let (r, g, _) = at(SIZE / 2, SIZE / 2);
|
||||
assert!(r > g + 40, "the mask should read red, got r={r} g={g}");
|
||||
assert!(
|
||||
g > 20,
|
||||
"and not opaque — the photograph under it is what the tint is judged \
|
||||
against, got g={g}"
|
||||
);
|
||||
|
||||
let (r, g, b) = at(1, 1);
|
||||
assert!(
|
||||
(120..=136).contains(&r) && r == g && g == b,
|
||||
"outside the mask the photograph is untouched, got ({r}, {g}, {b})"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// An outline draws where the mask stops and nowhere else — which is the
|
||||
/// point of it, since the other two styles cover the detail the boundary has
|
||||
/// to be judged against.
|
||||
#[test]
|
||||
fn an_outline_draws_the_boundary_and_not_the_interior() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
|
||||
let pixels = render_revealing(&ctx, &stack, &Reveal::one("m1", RevealStyle::Edge));
|
||||
|
||||
// Where the line landed, along the row through the centre. Searched
|
||||
// rather than sampled at one place: the radial's edge crosses this row
|
||||
// about 9.6 pixels out from the middle on a 32px frame, and asserting a
|
||||
// particular pixel would be asserting the rounding.
|
||||
let (at, brightest) = (SIZE / 2..SIZE)
|
||||
.map(|x| (x, luma_at(&pixels, x, SIZE / 2)))
|
||||
.max_by_key(|&(_, v)| v)
|
||||
.expect("the row is not empty");
|
||||
|
||||
assert!(
|
||||
brightest > 160,
|
||||
"there should be a line somewhere on this row, brightest was {brightest}"
|
||||
);
|
||||
assert!(
|
||||
(SIZE / 2 + 7..=SIZE / 2 + 12).contains(&at),
|
||||
"and it should be on the mask's boundary, not somewhere else: x={at}"
|
||||
);
|
||||
assert_eq!(
|
||||
luma_at(&pixels, SIZE / 2, SIZE / 2),
|
||||
128,
|
||||
"the picture inside the mask is untouched"
|
||||
);
|
||||
assert_eq!(
|
||||
luma_at(&pixels, 1, 1),
|
||||
128,
|
||||
"and so is the picture outside it"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// Two masks shown at once come out in two colours, each where its own mask
|
||||
/// is — which is what makes "where do these meet" a question the screen can
|
||||
/// answer.
|
||||
#[test]
|
||||
fn two_shown_masks_are_drawn_each_in_its_own_colour() {
|
||||
use dr_pipeline::mask::RevealedLayer;
|
||||
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// A left half and a right half, as two brush layers with one fat dab each.
|
||||
let half = |id: &str, x: f32| {
|
||||
let mut layer = MaskLayer::new(id, MaskSource::brush());
|
||||
paint(&mut layer, 0, false, &[(x, 0.5)]);
|
||||
layer
|
||||
};
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(half("left", 0.2));
|
||||
stack.push(half("right", 0.8));
|
||||
|
||||
let reveal = Reveal {
|
||||
layers: vec![
|
||||
RevealedLayer {
|
||||
layer: "left".into(),
|
||||
colour: [1.0, 0.0, 0.0],
|
||||
},
|
||||
RevealedLayer {
|
||||
layer: "right".into(),
|
||||
colour: [0.0, 0.0, 1.0],
|
||||
},
|
||||
],
|
||||
style: RevealStyle::Alpha,
|
||||
};
|
||||
let pixels = render_revealing(&ctx, &stack, &reveal);
|
||||
|
||||
let at = |x: u32| {
|
||||
let i = ((SIZE / 2 * SIZE + x) * 4) as usize;
|
||||
(pixels[i], pixels[i + 1], pixels[i + 2])
|
||||
};
|
||||
let (r, _, b) = at(SIZE / 5);
|
||||
assert!(
|
||||
r > 200 && b < 40,
|
||||
"the left mask reads red, got r={r} b={b}"
|
||||
);
|
||||
let (r, _, b) = at(SIZE * 4 / 5);
|
||||
assert!(
|
||||
b > 200 && r < 40,
|
||||
"the right mask reads blue, got r={r} b={b}"
|
||||
);
|
||||
let (r, g, b) = at(SIZE / 2);
|
||||
assert!(
|
||||
r < 40 && g < 40 && b < 40,
|
||||
"between them, alpha shows black: ({r}, {g}, {b})"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ fn brightening_stack() -> MaskStack {
|
||||
},
|
||||
);
|
||||
layer.set_param("exposure", ParamId("exposure"), 2.0);
|
||||
layer.feather = 0.0;
|
||||
layer.base_mut().feather = 0.0;
|
||||
stack.push(layer);
|
||||
stack
|
||||
}
|
||||
@@ -81,7 +81,7 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks
|
||||
.render(stack, None, Some(&subjects), PROXY, PROXY)
|
||||
.render(stack, None, Some(&subjects), None, PROXY, PROXY)
|
||||
.expect("rasterise");
|
||||
|
||||
let shader = compose_full(
|
||||
@@ -90,6 +90,7 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
@@ -108,6 +109,7 @@ fn render_unmasked(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust.render(&source, &shader, out, out).expect("render");
|
||||
@@ -222,7 +224,9 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u
|
||||
let source = DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload");
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks.render(stack, None, None, w, h).expect("rasterise");
|
||||
let array = masks
|
||||
.render(stack, None, None, None, w, h)
|
||||
.expect("rasterise");
|
||||
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
@@ -230,6 +234,7 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// TRACES: FR-DEV-10
|
||||
//! A range mask must select by the photograph's values, and by nothing else.
|
||||
//!
|
||||
//! Two properties, and both are silent when they break. A range mask that
|
||||
//! reads the wrong pixels still produces a plausible-looking selection, and one
|
||||
//! that depends on the size it was rasterised at looks right in the develop
|
||||
//! view and wrong only in the export — the one place nobody is watching.
|
||||
//!
|
||||
//! Everything here goes through the real pass. There is no CPU rasterisation of
|
||||
//! a mask to test against and there must not be (ARCH §5.4), so the mask is
|
||||
//! observed the only way it exists: through the adjustment it weights.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::spot::SpotSet;
|
||||
use dr_pipeline::{ops, Framing};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
/// The source and the output are the same size, so a difference between two
|
||||
/// runs can only have come from the mask.
|
||||
const SIZE: u32 = 64;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A frame whose left half is one colour and right half another.
|
||||
///
|
||||
/// Deliberately not a ramp. A range mask's whole job is to divide the picture
|
||||
/// by value, so a source that is already divided by value makes the assertion
|
||||
/// "the mask found the half it was aimed at" rather than "the mask is roughly
|
||||
/// where it should be".
|
||||
fn split(ctx: &GpuContext, left: [u8; 3], right: [u8; 3]) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..SIZE * SIZE)
|
||||
.flat_map(|i| {
|
||||
let c = if i % SIZE < SIZE / 2 { left } else { right };
|
||||
[c[0], c[1], c[2], 255]
|
||||
})
|
||||
.collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
|
||||
}
|
||||
|
||||
fn brightened(source: MaskSource) -> MaskStack {
|
||||
let mut stack = MaskStack::new();
|
||||
let mut layer = MaskLayer::new("m1", source);
|
||||
// Two stops, so "selected" and "not selected" are not a judgement call.
|
||||
layer.set_param("exposure", ParamId("exposure"), 2.0);
|
||||
stack.push(layer);
|
||||
stack
|
||||
}
|
||||
|
||||
/// Render `stack` over `source`, with the mask rasterised at `raster`.
|
||||
///
|
||||
/// `raster` is a parameter because it is the thing that must not matter: the
|
||||
/// develop view and an export ask for the same mask at different sizes, and a
|
||||
/// range that answered differently at each would be a mask that changes when
|
||||
/// the photograph is exported.
|
||||
fn render(ctx: &GpuContext, source: &DemosaicedImage, stack: &MaskStack, raster: u32) -> Vec<u8> {
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks
|
||||
.render(stack, None, None, Some(source), raster, raster)
|
||||
.expect("rasterise");
|
||||
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render_masked(source, &shader, SIZE, SIZE, Some(array))
|
||||
.expect("render");
|
||||
adjust.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
fn red_at(pixels: &[u8], x: u32, y: u32) -> u8 {
|
||||
pixels[((y * SIZE + x) * 4) as usize]
|
||||
}
|
||||
|
||||
/// The centre of each half, away from the seam the mask's own softness
|
||||
/// straddles.
|
||||
fn halves(pixels: &[u8]) -> (u8, u8) {
|
||||
(
|
||||
red_at(pixels, SIZE / 4, SIZE / 2),
|
||||
red_at(pixels, SIZE * 3 / 4, SIZE / 2),
|
||||
)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
#[test]
|
||||
fn a_tone_band_brightens_only_the_half_inside_it() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
// 200 lands near 0.83 on the perceptual scale and 30 near 0.24, so a band
|
||||
// over the upper half contains one and not the other with room to spare.
|
||||
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
|
||||
let stack = brightened(MaskSource::luminance_range(0.5, 1.0, 0.15));
|
||||
|
||||
let pixels = render(&ctx, &source, &stack, SIZE);
|
||||
let (bright, dark) = halves(&pixels);
|
||||
|
||||
assert!(
|
||||
bright > 230,
|
||||
"the bright half is inside the band and should have been lifted, got {bright}"
|
||||
);
|
||||
assert!(
|
||||
dark < 45,
|
||||
"the dark half is outside the band and must be untouched, got {dark}"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// The property the develop view and the export path share. The mask array is
|
||||
/// rasterised at a proxy size in both, but nothing in this pass may *depend*
|
||||
/// on that size — a range is a function of the photograph's values, and those
|
||||
/// do not change when somebody asks for a bigger picture.
|
||||
#[test]
|
||||
fn a_tone_band_is_the_same_mask_at_any_raster_size() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
|
||||
let stack = brightened(MaskSource::luminance_range(0.5, 1.0, 0.15));
|
||||
|
||||
// A quarter of the source and twice it: one mask texel averaging sixteen
|
||||
// source texels, and one source texel spread over four mask texels.
|
||||
let small = halves(&render(&ctx, &source, &stack, SIZE / 4));
|
||||
let large = halves(&render(&ctx, &source, &stack, SIZE * 2));
|
||||
|
||||
// A tolerance rather than equality: the two rasters land their edges on
|
||||
// different grids, and the assertion is that the *selection* is the same,
|
||||
// not that two resamplings of it are bit-identical.
|
||||
assert!(
|
||||
small.0.abs_diff(large.0) <= 4 && small.1.abs_diff(large.1) <= 4,
|
||||
"the same band selected differently at two raster sizes: {small:?} vs {large:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
#[test]
|
||||
fn a_colour_band_follows_hue_rather_than_brightness() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
// Short of saturation on purpose. A fully clipped channel carries no
|
||||
// colour at all, and the pass pulls such a pixel back to neutral before
|
||||
// the band ever sees it — which is correct, and would make this test about
|
||||
// that instead.
|
||||
let source = split(&ctx, [200, 40, 40], [40, 40, 200]);
|
||||
// A narrow arc at red, above the chroma floor that keeps the greys out.
|
||||
let stack = brightened(MaskSource::colour_range(0.0, 0.05, 0.15, 1.0, 0.05));
|
||||
|
||||
let pixels = render(&ctx, &source, &stack, SIZE);
|
||||
let (red, blue) = halves(&pixels);
|
||||
|
||||
assert!(
|
||||
red > 230,
|
||||
"the red half is inside the arc and should have been lifted, got {red}"
|
||||
);
|
||||
assert!(
|
||||
blue < 60,
|
||||
"the blue half is a third of the circle away and must be untouched, got {blue}"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// The greys a hue arc would otherwise sweep up.
|
||||
///
|
||||
/// A nearly-neutral pixel still has a hue — three almost-equal channels round
|
||||
/// to one — so an arc without a chroma floor selects a haze of noise across
|
||||
/// every desaturated part of the picture. It is the failure that looks like the
|
||||
/// mask working badly rather than like a control that is missing.
|
||||
#[test]
|
||||
fn a_colour_band_ignores_the_greys() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
// Both halves neutral, at the two brightnesses the tone test uses, so the
|
||||
// only reason either could be selected is the hue arc reaching them.
|
||||
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
|
||||
let stack = brightened(MaskSource::colour_range(0.0, 0.5, 0.15, 1.0, 0.05));
|
||||
|
||||
let pixels = render(&ctx, &source, &stack, SIZE);
|
||||
let (light, dark) = halves(&pixels);
|
||||
|
||||
assert!(
|
||||
light < 215 && dark < 45,
|
||||
"a grey frame was selected by a colour mask: {light}, {dark}"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
[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/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).
|
||||
[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"]
|
||||
@@ -0,0 +1,169 @@
|
||||
//! The API table `ort` runs on, chosen once (docs/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/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,620 @@
|
||||
//! Which runtime, which provider and which model form — decided once per
|
||||
//! device, and the only crate that knows the answer (docs/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, 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/faces.md §7c).
|
||||
Landmarks,
|
||||
/// The eye-state and sunglasses classifiers, a few hundred kilobytes.
|
||||
EyeClassifier,
|
||||
/// XFeat, the panorama keypoint detector (docs/panorama.md).
|
||||
Keypoints,
|
||||
/// MI-GAN, the panorama border filler (docs/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.
|
||||
#[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,
|
||||
/// 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::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::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::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),
|
||||
}
|
||||
|
||||
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 => " · 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 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(),
|
||||
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/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_line_reads_as_the_floor_before_init() {
|
||||
let s = status();
|
||||
assert_eq!(s.rung, Rung::Cpu);
|
||||
assert!(s.line().starts_with("CPU"), "{}", s.line());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Walk the ladder, once, by building real sessions (docs/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];
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let all = [Rung::TensorRt, Rung::Cuda];
|
||||
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 and where it
|
||||
/// came from, 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()),
|
||||
},
|
||||
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")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn device_identity() -> String {
|
||||
// The NVIDIA driver's version line; absent means no NVIDIA driver.
|
||||
std::fs::read_to_string("/proc/driver/nvidia/version")
|
||||
.ok()
|
||||
.and_then(|s| s.lines().next().map(str::to_string))
|
||||
.unwrap_or_else(|| "no nvidia driver".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,125 @@
|
||||
//! One session builder per rung (docs/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::Hexagon => unreachable!("the Hexagon rung is not on a desktop ladder"),
|
||||
}
|
||||
}
|
||||
|
||||
#[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 => unreachable!("no NVIDIA 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/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,793 @@
|
||||
//! 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. 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 (sky beside
|
||||
/// sky, grass beside grass) 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: 4,
|
||||
band: 96,
|
||||
mirror_depth: 48,
|
||||
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);
|
||||
let mirror_depth = mirror_depth.max(1);
|
||||
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] = !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);
|
||||
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 + RING) * pw + (xx + 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,
|
||||
rgb: Vec<f32>,
|
||||
hole: Vec<bool>,
|
||||
}
|
||||
|
||||
impl MirroredContext {
|
||||
fn build(rgb: &[f32], width: usize, height: usize, known: &[bool], depth: usize) -> Self {
|
||||
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,
|
||||
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,
|
||||
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 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,
|
||||
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);
|
||||
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);
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! TRACES: FR-MRG-4
|
||||
//! MI-GAN, the border filler, under the inference engine.
|
||||
//!
|
||||
//! Sargsyan et al., ICCV 2023 (Picsart AI Research): inpainting built for
|
||||
//! phones — about six million parameters of plain convolutions, no FFT and
|
||||
//! no attention, so it quantises and runs on a DSP. MIT, code and weights
|
||||
//! (`models/LICENCE.md`). The bare 512 generator is what ships, exported
|
||||
//! at a fixed shape by `tools/export-migan.sh`; its six operator types load
|
||||
//! on every rung, and what they cost is the whole story of whether a fill
|
||||
//! is interactive: 7.4 s a tile under tract, 0.4 s under ONNX Runtime's
|
||||
//! CPU pool, 23 ms in fp16 and 13 ms in int8 on a laptop's TensorRT
|
||||
//! (2026-09-19, docs/panorama.md §12).
|
||||
//!
|
||||
//! The model's contract, from the reference `export_inference_model.py`:
|
||||
//! input `1×4×512×512` float — channel 0 is `mask − 0.5` with 1 where the
|
||||
//! picture is known, channels 1–3 the RGB in −1..1 with the unknown pixels
|
||||
//! zeroed; output `1×3×512×512` in −1..1, of which the caller keeps the
|
||||
//! unknown pixels. That is [`crate::fill::Inpainter`], and the rest —
|
||||
//! which tiles, what context, how to blend — is `fill.rs`.
|
||||
|
||||
use crate::fill::Inpainter;
|
||||
use crate::PanoError;
|
||||
|
||||
/// The tile the shipped export takes.
|
||||
pub const TILE: usize = 512;
|
||||
|
||||
pub struct MiGan {
|
||||
model: dr_inference_engine::Model,
|
||||
}
|
||||
|
||||
impl MiGan {
|
||||
/// From the model file, in whichever form the engine's rung wants
|
||||
/// (`resolve_model` picks an int8 sibling for the Hexagon).
|
||||
pub fn from_path(path: &std::path::Path) -> Result<Self, PanoError> {
|
||||
use dr_inference_engine::{resolve_model, Role};
|
||||
let (path, form) = resolve_model(Role::Inpainter, path);
|
||||
let bytes = std::fs::read(&path).map_err(PanoError::ModelRead)?;
|
||||
Self::from_bytes(&bytes, form)
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8], form: dr_inference_engine::Form) -> Result<Self, PanoError> {
|
||||
use dr_inference_engine::Role;
|
||||
Ok(MiGan {
|
||||
model: dr_inference_engine::open(Role::Inpainter, form, bytes)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the fill runs, for a status line.
|
||||
pub fn rung(&self) -> Result<dr_inference_engine::Rung, PanoError> {
|
||||
Ok(self.model.acquire()?.rung())
|
||||
}
|
||||
}
|
||||
|
||||
impl Inpainter for MiGan {
|
||||
fn tile(&self) -> usize {
|
||||
TILE
|
||||
}
|
||||
|
||||
fn fill(&mut self, rgb: &[f32], known: &[bool]) -> Result<Vec<f32>, PanoError> {
|
||||
let n = TILE * TILE;
|
||||
if rgb.len() != n * 3 || known.len() != n {
|
||||
return Err(PanoError::Input(format!(
|
||||
"MI-GAN takes a {TILE}×{TILE} tile; given {} values and {} mask entries",
|
||||
rgb.len(),
|
||||
known.len()
|
||||
)));
|
||||
}
|
||||
// NCHW: the mask plane, then the three masked colour planes.
|
||||
let mut input = vec![0.0f32; 4 * n];
|
||||
for i in 0..n {
|
||||
let m = if known[i] { 1.0 } else { 0.0 };
|
||||
input[i] = m - 0.5;
|
||||
for c in 0..3 {
|
||||
input[(c + 1) * n + i] = (rgb[i * 3 + c] * 2.0 - 1.0) * m;
|
||||
}
|
||||
}
|
||||
let tensor = ort::value::Tensor::from_array(
|
||||
ndarray::Array::from_shape_vec(ndarray::IxDyn(&[1, 4, TILE, TILE]), input)
|
||||
.expect("shape matches by construction"),
|
||||
)?;
|
||||
let started = std::time::Instant::now();
|
||||
let acquired = self.model.acquire()?;
|
||||
let acquired_at = started.elapsed();
|
||||
let mut session = acquired.lock();
|
||||
let outputs = session.run(ort::inputs![tensor])?;
|
||||
log::trace!(
|
||||
"migan: tile on {} — acquire {:.1} ms, run {:.1} ms",
|
||||
acquired.rung().label(),
|
||||
acquired_at.as_secs_f64() * 1e3,
|
||||
(started.elapsed() - acquired_at).as_secs_f64() * 1e3
|
||||
);
|
||||
let (shape, data) = outputs[0].try_extract_tensor::<f32>()?;
|
||||
let dims: Vec<i64> = shape.iter().copied().collect();
|
||||
if dims != [1, 3, TILE as i64, TILE as i64] {
|
||||
return Err(PanoError::Model(format!(
|
||||
"MI-GAN output is {dims:?}, expected [1, 3, {TILE}, {TILE}]"
|
||||
)));
|
||||
}
|
||||
let mut out = vec![0.0f32; n * 3];
|
||||
for i in 0..n {
|
||||
for c in 0..3 {
|
||||
out[i * 3 + c] = (data[c * n + i] * 0.5 + 0.5).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! TRACES: FR-MRG-4
|
||||
//! The surface the composite is drawn on.
|
||||
//!
|
||||
//! A panorama is a set of directions; a picture is a plane. The projection
|
||||
//! is the map between them, and the three offered are the three every
|
||||
//! stitcher offers because each is right for a different field of view:
|
||||
//! perspective keeps straight lines straight and cannot reach 180°;
|
||||
//! cylindrical keeps verticals vertical and stretches nothing horizontally,
|
||||
//! for the wide single row; spherical for anything that also looks up.
|
||||
//!
|
||||
//! Every function here is the *inverse* map — output pixel to direction —
|
||||
//! because that is what a gather needs (`lens.rs` in `dr-pipeline` says
|
||||
//! why a warp is written that way), and it is the function the WGSL warp
|
||||
//! will repeat verbatim. The forward map exists for bounds only.
|
||||
|
||||
use crate::linalg::Vec3;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Projection {
|
||||
Perspective,
|
||||
Cylindrical,
|
||||
Spherical,
|
||||
}
|
||||
|
||||
impl Projection {
|
||||
/// Which projection a field of view calls for.
|
||||
///
|
||||
/// Perspective stretches the edges by `1 / cos` of the angle from the
|
||||
/// centre, which is 2× at 60° and unbounded at 90°; the switch is where
|
||||
/// that stretch starts to look like a mistake. Spherical is for a set
|
||||
/// that spans enough vertically that a cylinder would stretch the top
|
||||
/// and bottom the same way.
|
||||
pub fn suggest(horizontal_fov: f64, vertical_fov: f64) -> Projection {
|
||||
if horizontal_fov < 70f64.to_radians() && vertical_fov < 70f64.to_radians() {
|
||||
Projection::Perspective
|
||||
} else if vertical_fov < 100f64.to_radians() {
|
||||
Projection::Cylindrical
|
||||
} else {
|
||||
Projection::Spherical
|
||||
}
|
||||
}
|
||||
|
||||
/// The direction an output point looks along. `scale` is the output's
|
||||
/// focal length in pixels: the radius of the cylinder or sphere, or the
|
||||
/// plane's distance. Coordinates are centred on the projection's origin
|
||||
/// (the direction `+z`).
|
||||
pub fn to_direction(self, scale: f64, u: f64, v: f64) -> Vec3 {
|
||||
match self {
|
||||
Projection::Perspective => Vec3::new(u, v, scale).normalised(),
|
||||
Projection::Cylindrical => {
|
||||
let theta = u / scale;
|
||||
Vec3::new(theta.sin(), v / scale, theta.cos()).normalised()
|
||||
}
|
||||
Projection::Spherical => {
|
||||
let theta = u / scale;
|
||||
let phi = v / scale;
|
||||
Vec3::new(theta.sin() * phi.cos(), phi.sin(), theta.cos() * phi.cos())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a direction lands on the output, or `None` where the
|
||||
/// projection cannot show it (behind a perspective plane, at a
|
||||
/// cylinder's poles).
|
||||
pub fn from_direction(self, scale: f64, d: Vec3) -> Option<(f64, f64)> {
|
||||
let (x, y, z) = (d.x(), d.y(), d.z());
|
||||
match self {
|
||||
Projection::Perspective => (z > 1e-9).then(|| (scale * x / z, scale * y / z)),
|
||||
Projection::Cylindrical => {
|
||||
let r = (x * x + z * z).sqrt();
|
||||
(r > 1e-9).then(|| (scale * x.atan2(z), scale * y / r))
|
||||
}
|
||||
Projection::Spherical => {
|
||||
let r = (x * x + z * z).sqrt();
|
||||
Some((scale * x.atan2(z), scale * y.atan2(r)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The output rectangle a set of frames covers, in centred output pixels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Bounds {
|
||||
pub min_u: f64,
|
||||
pub min_v: f64,
|
||||
pub max_u: f64,
|
||||
pub max_v: f64,
|
||||
}
|
||||
|
||||
impl Bounds {
|
||||
pub fn width(&self) -> f64 {
|
||||
self.max_u - self.min_u
|
||||
}
|
||||
pub fn height(&self) -> f64 {
|
||||
self.max_v - self.min_v
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds of the frames' footprints under `projection`, by walking each
|
||||
/// frame's border.
|
||||
///
|
||||
/// `frame_size` is the frames' width and height in the same pixels the
|
||||
/// cameras' focal length is in. The border is sampled rather than only its
|
||||
/// corners because under a cylinder the widest point of a rolled frame is
|
||||
/// not a corner.
|
||||
pub fn bounds(
|
||||
projection: Projection,
|
||||
scale: f64,
|
||||
cameras: &crate::bundle::Cameras,
|
||||
frame_size: (f64, f64),
|
||||
) -> Option<Bounds> {
|
||||
let (w, h) = frame_size;
|
||||
let mut b: Option<Bounds> = None;
|
||||
let steps = 64;
|
||||
for k in 0..cameras.rotations.len() {
|
||||
for s in 0..steps {
|
||||
let t = s as f64 / steps as f64;
|
||||
for p in [
|
||||
(-w / 2.0 + w * t, -h / 2.0),
|
||||
(-w / 2.0 + w * t, h / 2.0),
|
||||
(-w / 2.0, -h / 2.0 + h * t),
|
||||
(w / 2.0, -h / 2.0 + h * t),
|
||||
] {
|
||||
let d = cameras.bearing(k, p);
|
||||
let Some((u, v)) = projection.from_direction(scale, d) else {
|
||||
continue;
|
||||
};
|
||||
b = Some(match b {
|
||||
None => Bounds {
|
||||
min_u: u,
|
||||
min_v: v,
|
||||
max_u: u,
|
||||
max_v: v,
|
||||
},
|
||||
Some(b) => Bounds {
|
||||
min_u: b.min_u.min(u),
|
||||
min_v: b.min_v.min(v),
|
||||
max_u: b.max_u.max(u),
|
||||
max_v: b.max_v.max(v),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn to_and_from_direction_are_inverses() {
|
||||
for proj in [
|
||||
Projection::Perspective,
|
||||
Projection::Cylindrical,
|
||||
Projection::Spherical,
|
||||
] {
|
||||
for (u, v) in [(0.0, 0.0), (300.0, -200.0), (-900.0, 450.0)] {
|
||||
let d = proj.to_direction(1000.0, u, v);
|
||||
let (bu, bv) = proj.from_direction(1000.0, d).expect("in front");
|
||||
assert!(
|
||||
(bu - u).abs() < 1e-9 && (bv - v).abs() < 1e-9,
|
||||
"{proj:?} {u} {v}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_origin_looks_down_z_in_every_projection() {
|
||||
for proj in [
|
||||
Projection::Perspective,
|
||||
Projection::Cylindrical,
|
||||
Projection::Spherical,
|
||||
] {
|
||||
let d = proj.to_direction(500.0, 0.0, 0.0);
|
||||
assert!((d.z() - 1.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cylinder_maps_ninety_degrees_to_a_quarter_turn_of_pixels() {
|
||||
let d = Vec3::new(1.0, 0.0, 0.0);
|
||||
let (u, v) = Projection::Cylindrical.from_direction(100.0, d).unwrap();
|
||||
assert!((u - 100.0 * std::f64::consts::FRAC_PI_2).abs() < 1e-9);
|
||||
assert_eq!(v, 0.0);
|
||||
assert!(Projection::Perspective.from_direction(100.0, d).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestion_widens_with_the_field() {
|
||||
assert_eq!(Projection::suggest(0.5, 0.5), Projection::Perspective);
|
||||
assert_eq!(Projection::suggest(2.5, 0.8), Projection::Cylindrical);
|
||||
assert_eq!(Projection::suggest(3.0, 2.5), Projection::Spherical);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! TRACES: FR-MRG-8
|
||||
//! The XFeat detector — the network under tract, and the decoder after it.
|
||||
//!
|
||||
//! Apache-2.0 weights (`models/LICENCE.md`), exported at a fixed shape by
|
||||
//! `tools/export-xfeat.sh` and loaded through the same `dr-inference-engine`
|
||||
//! `dr-segment` and `dr-face` use, so this adds no runtime and no C to the
|
||||
//! tree; what runs it is the device's business (docs/inference.md). ~300 ms
|
||||
//! per frame on tract on the reference desktop, ~400 ms on the tablet
|
||||
//! (S15.2, S15.4).
|
||||
|
||||
use crate::features::{decode_xfeat, DecodeOptions, Features, XFeatMaps, DESCRIPTOR_LEN};
|
||||
use crate::image::Gray;
|
||||
use crate::PanoError;
|
||||
|
||||
/// The two input shapes the shipped exports were made for: one landscape,
|
||||
/// one portrait, the same weights. A frame is fitted into whichever
|
||||
/// matches its aspect, so a portrait set does not spend half the
|
||||
/// detector's width on padding — which is what the 6D fixture did before
|
||||
/// the second export existed (512 × 768 of a 1024 × 768 input). A
|
||||
/// different size is a different file (`tools/export-xfeat.sh`).
|
||||
pub const INPUT_LANDSCAPE: (usize, usize) = (1024, 768);
|
||||
pub const INPUT_PORTRAIT: (usize, usize) = (768, 1024);
|
||||
|
||||
/// The long edge of the detector's input, for callers sizing a proxy.
|
||||
pub const INPUT_LONG_EDGE: usize = 1024;
|
||||
|
||||
#[cfg(feature = "embedded-model")]
|
||||
const EMBEDDED_LANDSCAPE: &[u8] = include_bytes!("../../../models/keypoints/xfeat-1024.onnx");
|
||||
#[cfg(feature = "embedded-model")]
|
||||
const EMBEDDED_PORTRAIT: &[u8] = include_bytes!("../../../models/keypoints/xfeat-768.onnx");
|
||||
|
||||
/// A loaded detector: the network at both shapes.
|
||||
pub struct XFeat {
|
||||
landscape: dr_inference_engine::Model,
|
||||
portrait: dr_inference_engine::Model,
|
||||
pub options: DecodeOptions,
|
||||
}
|
||||
|
||||
/// The bytes of both exports compiled into the binary, for whoever compiles
|
||||
/// engines ahead of the first request (docs/inference.md §6).
|
||||
#[cfg(feature = "embedded-model")]
|
||||
pub fn embedded_model_bytes() -> [&'static [u8]; 2] {
|
||||
[EMBEDDED_LANDSCAPE, EMBEDDED_PORTRAIT]
|
||||
}
|
||||
|
||||
impl XFeat {
|
||||
/// The weights compiled into the binary.
|
||||
#[cfg(feature = "embedded-model")]
|
||||
pub fn embedded() -> Result<Self, PanoError> {
|
||||
Self::from_bytes(EMBEDDED_LANDSCAPE, EMBEDDED_PORTRAIT)
|
||||
}
|
||||
|
||||
/// From the two exports on disk.
|
||||
pub fn from_paths(
|
||||
landscape: &std::path::Path,
|
||||
portrait: &std::path::Path,
|
||||
) -> Result<Self, PanoError> {
|
||||
let l = std::fs::read(landscape).map_err(PanoError::ModelRead)?;
|
||||
let p = std::fs::read(portrait).map_err(PanoError::ModelRead)?;
|
||||
Self::from_bytes(&l, &p)
|
||||
}
|
||||
|
||||
pub fn from_bytes(landscape: &[u8], portrait: &[u8]) -> Result<Self, PanoError> {
|
||||
use dr_inference_engine::{Form, Role};
|
||||
Ok(XFeat {
|
||||
landscape: dr_inference_engine::open(Role::Keypoints, Form::F32, landscape)?,
|
||||
portrait: dr_inference_engine::open(Role::Keypoints, Form::F32, portrait)?,
|
||||
options: DecodeOptions::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect keypoints in an upright grayscale image.
|
||||
///
|
||||
/// The image is fitted into the network's input of matching aspect —
|
||||
/// scaled down if larger, never up, and padded to the right and bottom
|
||||
/// — and the keypoints come back in the coordinates of `image` itself,
|
||||
/// so a caller that already scaled a frame to a proxy maps them on with
|
||||
/// the scale it used and nothing else.
|
||||
pub fn detect(&mut self, image: &Gray) -> Result<Features, PanoError> {
|
||||
let ((in_w, in_h), model) = if image.height > image.width {
|
||||
(INPUT_PORTRAIT, &self.portrait)
|
||||
} else {
|
||||
(INPUT_LANDSCAPE, &self.landscape)
|
||||
};
|
||||
let acquired = model.acquire()?;
|
||||
let mut session = acquired.lock();
|
||||
let (fitted, scale) = image.fitted(in_w, in_h);
|
||||
let padded = fitted.padded(in_w, in_h);
|
||||
|
||||
let input =
|
||||
ndarray::Array::from_shape_vec(ndarray::IxDyn(&[1, 1, in_h, in_w]), padded.data)
|
||||
.expect("shape matches the buffer by construction");
|
||||
let tensor = ort::value::Tensor::from_array(input).map_err(PanoError::Inference)?;
|
||||
let outputs = session
|
||||
.run(ort::inputs![tensor])
|
||||
.map_err(PanoError::Inference)?;
|
||||
|
||||
let (w8, h8) = (in_w / 8, in_h / 8);
|
||||
let expect = |i: usize, channels: usize| -> Result<Vec<f32>, PanoError> {
|
||||
let (shape, data) = outputs[i]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(PanoError::Inference)?;
|
||||
let dims: Vec<i64> = shape.iter().copied().collect();
|
||||
if dims != [1, channels as i64, h8 as i64, w8 as i64] {
|
||||
return Err(PanoError::Model(format!(
|
||||
"output {i} is {dims:?}, expected [1, {channels}, {h8}, {w8}] — \
|
||||
not the export this decoder was written for"
|
||||
)));
|
||||
}
|
||||
Ok(data.to_vec())
|
||||
};
|
||||
let feats = expect(0, DESCRIPTOR_LEN)?;
|
||||
let keypoints = expect(1, 65)?;
|
||||
let heatmap = expect(2, 1)?;
|
||||
|
||||
let mut features = decode_xfeat(
|
||||
&XFeatMaps {
|
||||
feats: &feats,
|
||||
keypoints: &keypoints,
|
||||
heatmap: &heatmap,
|
||||
width: w8,
|
||||
height: h8,
|
||||
},
|
||||
&self.options,
|
||||
);
|
||||
|
||||
// Back to the caller's image: drop anything the padding produced,
|
||||
// undo the fit.
|
||||
let border = self.options.border as f32;
|
||||
let limit_x = fitted.width as f32 - border;
|
||||
let limit_y = fitted.height as f32 - border;
|
||||
let mut kept_kp = Vec::with_capacity(features.len());
|
||||
let mut kept_desc = Vec::with_capacity(features.descriptors.len());
|
||||
for (i, kp) in features.keypoints.iter().enumerate() {
|
||||
if kp.x >= limit_x || kp.y >= limit_y {
|
||||
continue;
|
||||
}
|
||||
kept_kp.push(crate::features::Keypoint {
|
||||
x: (kp.x / scale as f32),
|
||||
y: (kp.y / scale as f32),
|
||||
score: kp.score,
|
||||
});
|
||||
kept_desc.extend_from_slice(features.descriptor(i));
|
||||
}
|
||||
features.keypoints = kept_kp;
|
||||
features.descriptors = kept_desc;
|
||||
features.width = image.width;
|
||||
features.height = image.height;
|
||||
Ok(features)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
id: capture_sharpen
|
||||
order: 120
|
||||
|
||||
attributes: [detail]
|
||||
rust: CaptureSharpen
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
id: clarity
|
||||
order: 130
|
||||
|
||||
attributes: [detail]
|
||||
rust: Clarity
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
# TRACES: FR-DEV-12
|
||||
id: colour_grading
|
||||
label: op.colour_grading
|
||||
order: 105
|
||||
attributes: [colour]
|
||||
|
||||
doc: |
|
||||
Colour grading — a hue and a strength for the shadows, the midtones and the
|
||||
highlights, and one more cast over the whole frame.
|
||||
|
||||
The distinction from [`colour_mixer`](colour_mixer.yaml) is the whole reason
|
||||
this exists. The mixer reaches for a hue that is *already in the picture*:
|
||||
it will turn the greens that are there, and it can do nothing at all where
|
||||
there are none. This reaches for a tonal *range* and casts colour into it
|
||||
whether any was there or not — which makes it the only control that can warm
|
||||
an already-neutral highlight, and the only one that can tone a monochrome
|
||||
conversion, since a picture with no hue left in it gives the mixer nothing to
|
||||
find.
|
||||
|
||||
Split toning is the case worth naming: cool shadows against warm highlights,
|
||||
which is what a print toned in two baths did and what most of the looks sold
|
||||
since are built on.
|
||||
|
||||
placement: |
|
||||
After every colour correction, the mixer included. Grading is the closing
|
||||
statement rather than a correction, so it should land on the colours the
|
||||
photographer has already settled rather than be argued with by a control
|
||||
further down the chain. Before the detail stage, which is where every
|
||||
neighbourhood operation runs whatever this file says.
|
||||
|
||||
params:
|
||||
shadow_hue:
|
||||
label: param.shadow_hue
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 360
|
||||
default: 0
|
||||
unit: none
|
||||
scale: linear
|
||||
precision: 0
|
||||
doc: |
|
||||
Degrees around the hue wheel, red at zero — the same units the straighten
|
||||
control reports, and for the same reason: a photographer reading "210"
|
||||
knows where on the wheel that is, where a normalised 0..1 has to be
|
||||
translated first.
|
||||
|
||||
The two ends of the travel are the same colour. That is a fact about
|
||||
hue rather than a defect, and it is what a wheel makes obvious and a
|
||||
slider cannot — one of the reasons `presentation:` below asks for one.
|
||||
shadow_strength:
|
||||
label: param.shadow_strength
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 100
|
||||
default: 0
|
||||
unit: percent
|
||||
scale: linear
|
||||
precision: 0
|
||||
doc: |
|
||||
How far from neutral, which is the wheel's radius. It does not go
|
||||
negative: a negative radius is the hue opposite, which the hue control
|
||||
already says, and two ways to spell one colour is how a preset comes
|
||||
back looking like its own complement.
|
||||
|
||||
midtone_hue:
|
||||
label: param.midtone_hue
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 360
|
||||
default: 0
|
||||
unit: none
|
||||
scale: linear
|
||||
precision: 0
|
||||
midtone_strength:
|
||||
label: param.midtone_strength
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 100
|
||||
default: 0
|
||||
unit: percent
|
||||
scale: linear
|
||||
precision: 0
|
||||
doc: |
|
||||
The midtones are where skin lives, so this is the range that goes wrong
|
||||
first and the one most often left at zero. It is offered anyway because
|
||||
a grade that can only reach the ends of the scale cannot answer a cast
|
||||
that sits in the middle of it.
|
||||
|
||||
highlight_hue:
|
||||
label: param.highlight_hue
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 360
|
||||
default: 0
|
||||
unit: none
|
||||
scale: linear
|
||||
precision: 0
|
||||
highlight_strength:
|
||||
label: param.highlight_strength
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 100
|
||||
default: 0
|
||||
unit: percent
|
||||
scale: linear
|
||||
precision: 0
|
||||
|
||||
global_hue:
|
||||
label: param.global_hue
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 360
|
||||
default: 0
|
||||
unit: none
|
||||
scale: linear
|
||||
precision: 0
|
||||
global_strength:
|
||||
label: param.global_strength
|
||||
kind: scalar
|
||||
min: 0
|
||||
max: 100
|
||||
default: 0
|
||||
unit: percent
|
||||
scale: linear
|
||||
precision: 0
|
||||
doc: |
|
||||
The cast that carries no tonal weight — it applies equally at every
|
||||
brightness. Without it, a photographer wanting one colour everywhere and
|
||||
a second in one range has to set all three ranges to the first hue, and
|
||||
can then no longer move any one of them without disturbing the other
|
||||
two.
|
||||
|
||||
# The neutral is *no strength anywhere*, not "nothing has been touched".
|
||||
#
|
||||
# A hue with no strength behind it is a direction with no distance: the
|
||||
# picture is identical, and every uniform below is zero. Under the default
|
||||
# rule, nudging a hue while the strength sat at zero would make the node
|
||||
# active, and it would then cost a uniform block, a helper and a block in the
|
||||
# fused shader for a change nobody can see. Strengths cannot go negative, so
|
||||
# their sum is zero exactly when every one of them is.
|
||||
active: shadow_strength + midtone_strength + highlight_strength + global_strength
|
||||
|
||||
uniforms:
|
||||
shadow_angle:
|
||||
value: shadow_hue * 0.017453292
|
||||
doc: |
|
||||
Degrees to radians, here rather than in the shader. The wheel is a slider
|
||||
on the photographer's side and a cosine on the GPU's, and the conversion
|
||||
belongs at the seam between them — the fragment then has one meaning for
|
||||
an angle rather than two.
|
||||
shadow_amount:
|
||||
value: shadow_strength / 100 * 0.5
|
||||
doc: |
|
||||
Full strength is half a stop of shift on the leading channel, the same
|
||||
ceiling white balance holds itself to and for the same reason: a colour
|
||||
control that can blow a channel on its own is a trap. Four of these can
|
||||
stack, so the honest worst case is a stop, which takes both a maximum
|
||||
global cast and a maximum range on top of it.
|
||||
midtone_angle: midtone_hue * 0.017453292
|
||||
midtone_amount: midtone_strength / 100 * 0.5
|
||||
highlight_angle: highlight_hue * 0.017453292
|
||||
highlight_amount: highlight_strength / 100 * 0.5
|
||||
global_angle: global_hue * 0.017453292
|
||||
global_amount: global_strength / 100 * 0.5
|
||||
|
||||
helpers: [luminance, tone_position]
|
||||
|
||||
define:
|
||||
hue_cast: |
|
||||
// A per-channel gain carrying a hue, centred on no change at all.
|
||||
//
|
||||
// The three channels are cosines 120 degrees apart, which is the hue wheel
|
||||
// written directly as an RGB direction — a round trip through HSV would
|
||||
// buy nothing here and would have to decide what to do with a colour that
|
||||
// has no hue. Their sum is zero at every angle, so exp2 turns them into
|
||||
// three gains whose product is exactly one: the cast tilts the balance
|
||||
// without moving the overall level. That property is what keeps grading
|
||||
// from doubling as an exposure control, which is the failure that has the
|
||||
// photographer chasing brightness with a colour slider.
|
||||
fn hue_cast(angle: f32, amount: f32) -> vec3<f32> {
|
||||
let tilt = vec3<f32>(cos(angle), cos(angle - 2.0943951), cos(angle + 2.0943951));
|
||||
return exp2(tilt * amount);
|
||||
}
|
||||
|
||||
wgsl: |
|
||||
let pos = tone_position(luminance(c));
|
||||
|
||||
// Three weights that partition the tonal scale: at every luminance they sum
|
||||
// to exactly one. The two ends use the same 0.5 midpoint as
|
||||
// highlights_shadows, so "shadows" means the same range of the picture in
|
||||
// both places, and the midtones are defined as whatever the ends leave.
|
||||
//
|
||||
// The partition is what makes an equal setting on all three identical to
|
||||
// the global cast. Weights that overlapped would make the join between two
|
||||
// ranges stronger than either of them, so a split tone would darken or
|
||||
// colour its own midtones as a side effect of the two settings meeting —
|
||||
// an interaction with no control over it.
|
||||
let lo_w = 1.0 - smoothstep(0.0, 0.5, pos);
|
||||
let hi_w = smoothstep(0.5, 1.0, pos);
|
||||
let mid_w = 1.0 - lo_w - hi_w;
|
||||
|
||||
// The ranges compose by multiplication rather than by mixing, because a
|
||||
// product of luminance-neutral triples is another one — so three casts and
|
||||
// a global still leave the tonal relationships the tone controls
|
||||
// established. The global cast takes no weight: it is the whole frame.
|
||||
c = c * hue_cast(shadow_angle, shadow_amount * lo_w)
|
||||
* hue_cast(midtone_angle, midtone_amount * mid_w)
|
||||
* hue_cast(highlight_angle, highlight_amount * hi_w)
|
||||
* hue_cast(global_angle, global_amount);
|
||||
|
||||
presentation:
|
||||
# Four wheels, each a hue and a distance from the centre. Named in pairs
|
||||
# because that is the order a wheel wants them; a frontend that draws none
|
||||
# of these renders eight ordinary sliders and the edit is unchanged, which
|
||||
# is the state this ships in.
|
||||
#
|
||||
# That fallback is why each parameter carries its range in its own name
|
||||
# instead of leaning on the wheel to say which one it belongs to. A flat
|
||||
# list is what the panel produces today, and four sliders all called "Hue"
|
||||
# would be four controls nobody can tell apart.
|
||||
widgets: [colour_wheel]
|
||||
demand:
|
||||
two_dimensional: true
|
||||
# A hue a few degrees off is a slightly different warm, not a wrong
|
||||
# answer, so this is usable with a fingertip. Saying otherwise would take
|
||||
# the wheel away from touch to protect an accuracy nobody needs from it.
|
||||
precise_pointing: false
|
||||
params:
|
||||
- shadow_hue
|
||||
- shadow_strength
|
||||
- midtone_hue
|
||||
- midtone_strength
|
||||
- highlight_hue
|
||||
- highlight_strength
|
||||
- global_hue
|
||||
- global_strength
|
||||
|
||||
tests:
|
||||
- name: it_starts_neutral
|
||||
why: |
|
||||
Neutral means absent: at defaults the node must contribute no code, no
|
||||
uniform and no branch to the fused shader. Every angle and amount being
|
||||
zero is the arithmetic half of that; `expect_active` is the half that
|
||||
keeps it out of the shader at all.
|
||||
expect:
|
||||
shadow_angle: 0.0
|
||||
shadow_amount: 0.0
|
||||
midtone_amount: 0.0
|
||||
highlight_amount: 0.0
|
||||
global_amount: 0.0
|
||||
expect_active: false
|
||||
|
||||
- name: a_hue_with_no_strength_is_still_neutral
|
||||
why: |
|
||||
What `active:` buys. A hue is a direction and a strength is the distance
|
||||
travelled along it, so a hue moved on its own changes nothing — but the
|
||||
default rule ("some parameter has moved") would call the node active and
|
||||
make every fused shader carry it for nothing.
|
||||
set: { shadow_hue: 240, global_hue: 40 }
|
||||
expect_active: false
|
||||
|
||||
- name: strength_alone_reaches_the_shader
|
||||
why: |
|
||||
The complement, and the reason the neutral cannot simply be "nothing
|
||||
touched": red is hue zero, so a grade toward red never moves a hue
|
||||
slider off its default and would otherwise never be applied.
|
||||
set: { shadow_strength: 20 }
|
||||
expect_active: true
|
||||
|
||||
- name: hue_is_converted_to_radians
|
||||
why: |
|
||||
The shader's cosines take radians and this uniform is the only place the
|
||||
conversion happens. Degrees arriving unconverted would be an angle 57
|
||||
times too large — it would wrap the wheel several times and land on a
|
||||
colour with no relation to the one under the pointer, which reads as the
|
||||
control being broken rather than as a missing constant.
|
||||
set: { shadow_hue: 180 }
|
||||
expect: { shadow_angle: 3.1415926 }
|
||||
|
||||
- name: full_strength_is_half_a_stop
|
||||
why: |
|
||||
The ceiling on one range's contribution, matching white balance's. A
|
||||
grade that could take a channel to clipping by itself would make the
|
||||
strength slider unusable over its top third.
|
||||
set: { highlight_strength: 100 }
|
||||
expect: { highlight_amount: 0.5 }
|
||||
|
||||
- name: strength_maps_linearly_onto_the_shift
|
||||
why: |
|
||||
Half the slider must be half the shift. A curve here would make the
|
||||
wheel's radius mean something different at each distance from the
|
||||
centre, and a wheel is read as a distance.
|
||||
set: { midtone_strength: 50 }
|
||||
expect: { midtone_amount: 0.25 }
|
||||
|
||||
- name: the_three_ranges_partition_the_tones
|
||||
why: |
|
||||
The midtone weight is defined as what the other two leave, so the three
|
||||
sum to one at every luminance. Computed independently they would overlap
|
||||
at the joins, and a split tone would then colour its own midtones as a
|
||||
side effect of the shadow and highlight settings meeting.
|
||||
expect_wgsl: ["let mid_w = 1.0 - lo_w - hi_w;"]
|
||||
|
||||
- name: the_global_cast_takes_no_tonal_weight
|
||||
why: |
|
||||
It is the one that means "everywhere". Weighted like the others it would
|
||||
land mostly on the midtones, since that weight is the largest across the
|
||||
range an ordinary photograph occupies, and "global" would quietly become
|
||||
a fourth midtone control.
|
||||
expect_wgsl: ["hue_cast(global_angle, global_amount)"]
|
||||
|
||||
- name: a_cast_does_not_change_the_level
|
||||
why: |
|
||||
The cosines sum to zero at every angle, so the three gains multiply to
|
||||
one and a cast tilts the balance without lifting or dropping the
|
||||
picture. Built any other way — a clamp, an added tint, an HSV round trip
|
||||
— a strong grade would double as an exposure change, and the
|
||||
photographer would correct it with a control that cannot reach it.
|
||||
expect_helper_wgsl:
|
||||
hue_cast: ["return exp2(tilt * amount);"]
|
||||
@@ -1,6 +1,5 @@
|
||||
id: colour_mixer
|
||||
order: 100
|
||||
attributes: [colour]
|
||||
rust: ColourMixer
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# A hand-written node, and a neighbourhood one: the veil it removes is measured
|
||||
# from the pixels around the one it is writing, so it runs in the detail stage
|
||||
# rather than as a fragment in the fused pass. See `../src/detail.rs` for why
|
||||
# that stage exists and `README.md`'s "Nodes that read their neighbours" for
|
||||
# the contract.
|
||||
#
|
||||
# As with every `rust:` node, its descriptor, parameters and behaviour come
|
||||
# from the type; this file exists so that `ops/` remains the one place the
|
||||
# pipeline's order is written down.
|
||||
id: dehaze
|
||||
order: 125
|
||||
|
||||
rust: Dehaze
|
||||
|
||||
why_rust: |
|
||||
Haze is defined by what the pixels around a pixel are doing, and the schema
|
||||
above describes a function of one colour — `wgsl:` is handed `c` and no
|
||||
coordinate, which is the wall the detail stage exists on the other side of.
|
||||
It declares `Affects::Detail` and returns five `DetailPass`es: four that
|
||||
erode the dark channel into a per-pixel veil, and one that inverts the
|
||||
scattering model with the transmission that veil implies.
|
||||
|
||||
Nor is it four facts. The erosion is split into two exact stages per axis so
|
||||
that a patch 1% of the frame wide costs its square root in taps, and the
|
||||
split is arithmetic over the render size that has to be recomputed every
|
||||
frame. Stretching this schema to express it would produce a worse language
|
||||
than Rust, aimed at one caller.
|
||||
|
||||
placement: |
|
||||
First among the compositional detail nodes: after noise reduction and
|
||||
capture sharpening, before clarity and texture.
|
||||
|
||||
After noise reduction because dehaze divides by a transmission below one, so
|
||||
it amplifies whatever noise is in the veiled distance by exactly the factor
|
||||
it recovers the contrast by. Running it first would ask the denoiser to
|
||||
remove grain that dehaze had already multiplied — the same argument clarity
|
||||
records, and stronger here, because the amplification is largest in the low-
|
||||
contrast regions where noise is most visible.
|
||||
|
||||
Before clarity and texture, and for the reason that orders those two against
|
||||
each other: coarse before fine. Dehaze acts on the widest structure in the
|
||||
frame, the veil that varies with distance, and clarity's base should be
|
||||
computed on the picture as the veil has left it rather than on a modelling
|
||||
that is about to be divided out.
|
||||
|
||||
What this cannot honour, and it is worth writing down rather than leaving to
|
||||
be rediscovered: dehaze shifts colour. It subtracts a grey term and rescales,
|
||||
so it changes saturation everywhere the veil is thick, and the colour
|
||||
controls would ideally be correcting the picture that leaves here. They
|
||||
cannot be. The detail stage runs as a *group* after every point operation,
|
||||
because a neighbourhood pass is a separate dispatch reading a texture the
|
||||
fused pass has already finished writing — so an `order:` placing this node
|
||||
ahead of `vibrance` or `colour_mixer` would be a lie the chain cannot tell.
|
||||
Interleaving the two would mean splitting the fused pass in half around this
|
||||
one, which costs a second full-frame dispatch and intermediate on every edit
|
||||
in the catalogue, whether or not it uses dehaze at all.
|
||||
@@ -1,6 +1,9 @@
|
||||
id: film_sim
|
||||
order: 25
|
||||
attributes: [tone, colour]
|
||||
# What this node is *about* is not written here, and cannot be: a `rust:` node
|
||||
# publishes its own descriptor, so `attributes:` in this file would be read,
|
||||
# validated and then ignored. See `Attribute::Effect` on `FilmSim`'s descriptor
|
||||
# in `../src/ops/film_sim.rs`.
|
||||
rust: FilmSim
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
id: noise_reduction
|
||||
order: 110
|
||||
|
||||
attributes: [detail]
|
||||
rust: NoiseReduction
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
id: texture
|
||||
order: 140
|
||||
|
||||
attributes: [detail]
|
||||
rust: Texture
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -12,7 +12,6 @@ order: 60
|
||||
# Both, and this is the case the plural exists for: the RGB curve is
|
||||
# tonal and the per-channel curves are chromatic. Filing it under one
|
||||
# would hide it from half the people looking for it.
|
||||
attributes: [tone, colour]
|
||||
rust: ToneCurve
|
||||
|
||||
why_rust: |
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# A hand-written node, and the first thing in `Attribute::Optics`.
|
||||
#
|
||||
# Written, tested and unreferenced until now: `src/ops/vignetting.rs` has
|
||||
# existed with a full descriptor and a working polynomial, and without an entry
|
||||
# here it was never in `chain()` — so it reached no photograph and no panel.
|
||||
# This file is the whole of what was missing, which is the point of `ops/`
|
||||
# being the one place the pipeline's order is written down.
|
||||
id: vignetting
|
||||
order: 5
|
||||
|
||||
rust: Vignetting
|
||||
|
||||
why_rust: |
|
||||
It carries a lens profile's `pa` coefficients, which are not parameters: they
|
||||
come from the body and lens that took the photograph, not from the
|
||||
photographer, and no `uniforms:` expression could produce them. The one
|
||||
parameter that *is* theirs — the manual trim — is composed with `k1` in Rust,
|
||||
because the profile and the trim have to reach the shader as a single
|
||||
polynomial rather than as two the fragment would have to add up.
|
||||
|
||||
placement: |
|
||||
First in the chain, ahead of white balance and exposure.
|
||||
|
||||
Vignetting is what the lens did to the light before the sensor measured it,
|
||||
so undoing it belongs with reading the file rather than with editing the
|
||||
picture — everything downstream is then working on the frame the lens would
|
||||
have delivered had it been even.
|
||||
|
||||
The ordering is load-bearing rather than tidy. Correcting a corner means
|
||||
*dividing* by an attenuation below one, which pushes those pixels up: a fast
|
||||
prime wide open needs about two stops there. Run after the tonal stages, that
|
||||
recovery happens once the highlights have already been rolled off and
|
||||
clipped, so it lifts values that no longer have anywhere to go and the
|
||||
corners posterise instead of brightening. Run here, the headroom to hold them
|
||||
still exists (ARCH §5.2).
|
||||
|
||||
Before distortion and chromatic aberration in intent, though those are
|
||||
`lens::Warp`s rather than nodes and compose ahead of the fetch, so no `order:`
|
||||
relates the two.
|
||||
@@ -32,6 +32,33 @@ params:
|
||||
label: param.tint
|
||||
kind: amount
|
||||
|
||||
# An eyedropper, where the frontend has a canvas to hang one on.
|
||||
#
|
||||
# Sampling a neutral is the first move of the global tonal pass — every colour
|
||||
# judgement afterwards is measured against where the grey was put — and it is
|
||||
# a thing you do by pointing at the photograph, not by guessing at two sliders
|
||||
# until a wall stops looking green.
|
||||
#
|
||||
# A *hint*, on the usual terms: the two parameters below stay ordinary
|
||||
# addressable scalars, and a frontend with nowhere to host a sampler renders
|
||||
# them as the sliders they already were. This one is additive rather than a
|
||||
# replacement — the picker writes temperature and tint and the photographer
|
||||
# still nudges them afterwards — which is a difference the frontend draws for
|
||||
# itself; nothing here has to say it.
|
||||
#
|
||||
# The order matters and is the widget kind's own contract: the first parameter
|
||||
# trades red against blue, the second green against magenta.
|
||||
presentation:
|
||||
widgets: [white_point]
|
||||
params: [temperature, tint]
|
||||
demand:
|
||||
# A neutral is a point on the picture, so both axes at once.
|
||||
two_dimensional: true
|
||||
# Deliberately false. A grey card, a cloud, a white wall — the things
|
||||
# worth sampling are large, and FR-UI-7 grows the hit region to the
|
||||
# modality in any case, so a thumb is as workable as a mouse.
|
||||
precise_pointing: false
|
||||
|
||||
# Temperature trades red against blue; tint trades green against magenta.
|
||||
# Both are scaled so the full range is a strong but not destructive
|
||||
# correction: ±0.5 in log2 at the extremes — half a stop of channel shift,
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
//! TRACES: FR-DEV-3 | FR-CAT-8
|
||||
//! A model's mask, in a form a sidecar can carry.
|
||||
//!
|
||||
//! [`MaskSource::Subject`](crate::mask::MaskSource::Subject) and
|
||||
//! [`MaskSource::Category`](crate::mask::MaskSource::Category) name what they
|
||||
//! cover — an index, a class, a category — and naming is enough only while
|
||||
//! the run that produced the numbers is still in memory. Reopen the
|
||||
//! photograph, or export it from the grid, and there is no run: the layer
|
||||
//! resolves to nothing and the local adjustment silently is not applied. That
|
||||
//! is what this module exists to stop. It stores the *pixels* the layer
|
||||
//! covered, beside the identity rather than instead of it, so a second model
|
||||
//! pass is an optimisation rather than a precondition.
|
||||
//!
|
||||
//! # Two levels, and why that is not a compromise
|
||||
//!
|
||||
//! The model hands out a byte per pixel, but nothing downstream reads more
|
||||
//! than one bit of it. A subject or category layer becomes a mask by way of
|
||||
//! an exact Euclidean distance field, and that field is measured from
|
||||
//! `coverage >= threshold` — the soft shoulder the model produced is
|
||||
//! discarded on the first line of the transform. Everything soft about the
|
||||
//! rendered edge comes afterwards, from [`MaskLayer::feather`] and
|
||||
//! [`MaskLayer::falloff`], which are read off the *distance*.
|
||||
//!
|
||||
//! [`MaskLayer::feather`]: crate::mask::MaskLayer::feather
|
||||
//! [`MaskLayer::falloff`]: crate::mask::MaskLayer::falloff
|
||||
//!
|
||||
//! So [`RENDERED_LEVELS`] is two, and the result is not an approximation of
|
||||
//! what the model said: it is exactly the part of what the model said that
|
||||
//! reaches a pixel. Storing all 256 levels would be storing 1.7 MB of
|
||||
//! interpolation to reconstruct a predicate — and it would not even compress,
|
||||
//! because a model mask is a bilinear upsample of a coarse grid and therefore
|
||||
//! has almost no two adjacent bytes alike. Measured on a simulated sky and a
|
||||
//! simulated figure at 1600x1067, against 1.71 MB raw: **4.0 kB and 6.5 kB at
|
||||
//! two levels**, 46 kB and 76 kB at sixteen, and 835 kB and 1.43 MB at all
|
||||
//! 256 — the last two being over [`MAX_PAYLOAD`] and therefore not storable
|
||||
//! at all.
|
||||
//!
|
||||
//! [`Coverage::encode`] still takes the level count, and it is written into
|
||||
//! the line, so a later build that finds a use for the shoulder can write
|
||||
//! sixteen levels and this one will read them back correctly rather than
|
||||
//! misreading a stream of lengths as pairs.
|
||||
//!
|
||||
//! # One line, because a node is a line
|
||||
//!
|
||||
//! FR-NC-9 merges the edit graph per node and [`Version::merge`] does that by
|
||||
//! comparing lines, so a stored mask is one `coverage = ...` line inside the
|
||||
//! layer's block — the same shape the `regions = ...` line already had, for
|
||||
//! the same reason. Splitting it over many lines would put a single opaque
|
||||
//! blob into the merge as several independently-winnable keys, which is a
|
||||
//! merge that can produce a mask neither device ever had.
|
||||
//!
|
||||
//! [`Version::merge`]: crate::sidecar::Version::merge
|
||||
//!
|
||||
//! # Hand-rolled, and it has to be
|
||||
//!
|
||||
//! `dr-pipeline` links nothing (ARCH §6.5a), which is what lets the descriptor
|
||||
//! and codegen logic be tested without a device. That rules out `flate2`,
|
||||
//! `serde` and `base64`, so the run-length coder and the digits below are
|
||||
//! written out. It is forty lines, and the alternative was a dependency in the
|
||||
//! one crate that has none.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// The number of coverage levels the renderer can actually tell apart.
|
||||
///
|
||||
/// Two. See the module header: the distance field is built from a threshold,
|
||||
/// so a second level is the whole of the information that survives into a
|
||||
/// rendered frame. Named rather than written as `2` at the call site because
|
||||
/// the number is a *claim about the render path*, and a claim wants somewhere
|
||||
/// to be explained.
|
||||
pub const RENDERED_LEVELS: u32 = 2;
|
||||
|
||||
/// The most encoded payload a stored coverage may take, in bytes.
|
||||
///
|
||||
/// Sidecars sync over WebDAV and are read whole by every device that opens the
|
||||
/// photograph, so a mask that will not compress must not be allowed to make
|
||||
/// the file enormous — it is a *cache* of something a model can produce again,
|
||||
/// and no cache is worth a megabyte of sync traffic per layer.
|
||||
///
|
||||
/// A realistic mask lands between 4 and 7 kB, so this is roughly ten times the
|
||||
/// worst case anyone has measured: enough for genuinely awkward subjects —
|
||||
/// foliage, chain-link, hair against a busy background — and far short of a
|
||||
/// file a human cannot open. Past it [`Coverage::encode`] returns `None`, the
|
||||
/// layer stores nothing, and the behaviour falls back to what it was before
|
||||
/// this module existed: the mask needs the model run. Refusing rather than
|
||||
/// truncating, because half a mask renders as a *wrong* mask, which is the
|
||||
/// failure that announces itself to nobody.
|
||||
pub const MAX_PAYLOAD: usize = 64 * 1024;
|
||||
|
||||
/// The most pixels a coverage read from a file may claim.
|
||||
///
|
||||
/// A file is not trusted. The proxy a mask is built at is bounded by the long
|
||||
/// edge the segmentation runs on — under three megapixels — so this is ample
|
||||
/// headroom, and it is here so that `width * height` from a corrupt line
|
||||
/// cannot ask for an allocation measured in gigabytes.
|
||||
pub const MAX_PIXELS: usize = 16 << 20;
|
||||
|
||||
/// Digits of the payload's base-64 varint. Ordered so the alphabet is stable
|
||||
/// and contains nothing a line-oriented format would have to escape — no
|
||||
/// whitespace, no `=`, no `#`.
|
||||
const DIGITS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
|
||||
/// Bit set in a digit that means "another digit follows".
|
||||
const CONTINUE: u32 = 32;
|
||||
|
||||
/// Value bits carried by one digit.
|
||||
const CHUNK: u32 = 5;
|
||||
|
||||
const fn reverse_digits() -> [u8; 256] {
|
||||
let mut table = [255u8; 256];
|
||||
let mut i = 0;
|
||||
while i < 64 {
|
||||
table[DIGITS[i] as usize] = i as u8;
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
/// Digit value by byte, `255` for anything that is not a digit.
|
||||
const REVERSE: [u8; 256] = reverse_digits();
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// One layer's pixel coverage, held in the form it is stored in.
|
||||
///
|
||||
/// **Encoded, not expanded.** The struct owns the payload text rather than the
|
||||
/// 1.7 MB raster it decodes to, because that raster is wanted exactly once —
|
||||
/// when a distance field is built — and is held by nothing afterwards. Keeping
|
||||
/// it expanded would put a megabyte and a half per layer into every undo
|
||||
/// snapshot the history stack holds, to save a decode that costs far less than
|
||||
/// the exact Euclidean transform immediately following it.
|
||||
///
|
||||
/// It also makes the round trip byte-identical for free: a coverage read from
|
||||
/// a file and written back is the same characters, which is the property that
|
||||
/// lets a caller skip an upload by comparing content
|
||||
/// ([`Sidecar`](crate::Sidecar)).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Coverage {
|
||||
width: usize,
|
||||
height: usize,
|
||||
levels: u32,
|
||||
payload: String,
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
/// Encode one byte-per-pixel coverage, or `None` where it will not fit.
|
||||
///
|
||||
/// `levels` is what the bytes are quantised to on the way in; see
|
||||
/// [`RENDERED_LEVELS`] for why two is the honest answer for a mask that is
|
||||
/// going to be thresholded.
|
||||
///
|
||||
/// `None` for a mismatched length, a nonsensical level count, or a payload
|
||||
/// over [`MAX_PAYLOAD`] — all three meaning "do not store this", which the
|
||||
/// caller can act on identically because the fallback is the same in every
|
||||
/// case.
|
||||
pub fn encode(values: &[u8], width: usize, height: usize, levels: u32) -> Option<Self> {
|
||||
if width == 0 || height == 0 || values.len() != width.checked_mul(height)? {
|
||||
return None;
|
||||
}
|
||||
if !(2..=256).contains(&levels) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let top = levels - 1;
|
||||
let mut payload = String::new();
|
||||
let mut index = 0;
|
||||
while index < values.len() {
|
||||
let level = quantise(values[index], top);
|
||||
let mut run = 1;
|
||||
while index + run < values.len() && quantise(values[index + run], top) == level {
|
||||
run += 1;
|
||||
}
|
||||
push_varint(&mut payload, level as u64);
|
||||
push_varint(&mut payload, run as u64);
|
||||
index += run;
|
||||
|
||||
// Checked inside the loop rather than after it: the pathological
|
||||
// input is one that runs to a payload larger than the raster, and
|
||||
// building the whole of that before deciding to throw it away is
|
||||
// the allocation this bound exists to prevent.
|
||||
if payload.len() > MAX_PAYLOAD {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
width,
|
||||
height,
|
||||
levels,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the value of a sidecar `coverage` line.
|
||||
///
|
||||
/// `None` for anything that does not describe a complete raster. A
|
||||
/// coverage is a cache, so refusing it costs a model run; accepting a
|
||||
/// partial one costs a photograph rendered with a mask that is wrong in a
|
||||
/// way nothing reports.
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
let mut tokens = value.split_whitespace();
|
||||
let width: usize = tokens.next()?.parse().ok()?;
|
||||
let height: usize = tokens.next()?.parse().ok()?;
|
||||
let levels: u32 = tokens.next()?.parse().ok()?;
|
||||
let payload = tokens.next()?;
|
||||
|
||||
let pixels = width.checked_mul(height)?;
|
||||
if pixels == 0 || pixels > MAX_PIXELS || !(2..=256).contains(&levels) {
|
||||
return None;
|
||||
}
|
||||
if payload.len() > MAX_PAYLOAD {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Measured rather than expanded. The payload has to be checked here —
|
||||
// failing at the point of use would put the error in the renderer,
|
||||
// where there is no longer a file to name in the message — but a
|
||||
// library scan parses thousands of sidecars, and materialising a
|
||||
// megabyte and a half per layer to establish that the arithmetic adds
|
||||
// up would make opening the grid pay for masks nobody is rendering.
|
||||
if measure(payload, levels)? != pixels {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
width,
|
||||
height,
|
||||
levels,
|
||||
payload: payload.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The value to write after `coverage = `.
|
||||
pub fn to_text(&self) -> String {
|
||||
format!(
|
||||
"{} {} {} {}",
|
||||
self.width, self.height, self.levels, self.payload
|
||||
)
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.width
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.height
|
||||
}
|
||||
|
||||
pub fn levels(&self) -> u32 {
|
||||
self.levels
|
||||
}
|
||||
|
||||
/// The encoded payload's length in bytes — what this costs a sidecar.
|
||||
pub fn encoded_len(&self) -> usize {
|
||||
self.payload.len()
|
||||
}
|
||||
|
||||
/// Expand back to one byte per pixel, at the size it was stored at.
|
||||
pub fn decode(&self) -> Vec<u8> {
|
||||
decode(&self.payload, self.width * self.height, self.levels)
|
||||
.expect("a Coverage only exists once its payload has been decoded once")
|
||||
}
|
||||
|
||||
/// Expand to `width` x `height`, resampling if that is not the size it was
|
||||
/// stored at.
|
||||
///
|
||||
/// Nearest neighbour, and deliberately: the values are a threshold's two
|
||||
/// sides, so interpolating between them would invent coverage levels that
|
||||
/// mean nothing and move the boundary by a rounding rule rather than by a
|
||||
/// measurement. The resample only runs at all when a build reads a mask
|
||||
/// stored against a different proxy edge — in the ordinary case the sizes
|
||||
/// match and this is the decode.
|
||||
pub fn decode_at(&self, width: usize, height: usize) -> Vec<u8> {
|
||||
let source = self.decode();
|
||||
if (width, height) == (self.width, self.height) {
|
||||
return source;
|
||||
}
|
||||
if width == 0 || height == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut out = vec![0u8; width * height];
|
||||
for y in 0..height {
|
||||
let sy = ((y * self.height) / height).min(self.height - 1);
|
||||
let row = sy * self.width;
|
||||
for x in 0..width {
|
||||
let sx = ((x * self.width) / width).min(self.width - 1);
|
||||
out[y * width + x] = source[row + sx];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// One byte to its level, rounding to nearest.
|
||||
fn quantise(value: u8, top: u32) -> u32 {
|
||||
((value as u32 * top) + 127) / 255
|
||||
}
|
||||
|
||||
/// One level back to a byte, so that the top level is exactly 255.
|
||||
fn dequantise(level: u32, top: u32) -> u8 {
|
||||
(((level * 255) + top / 2) / top).min(255) as u8
|
||||
}
|
||||
|
||||
/// Little-endian base-64 varint: five value bits per digit, the sixth saying
|
||||
/// whether another follows.
|
||||
fn push_varint(out: &mut String, mut value: u64) {
|
||||
loop {
|
||||
let chunk = (value & (CONTINUE - 1) as u64) as u32;
|
||||
value >>= CHUNK;
|
||||
let more = if value != 0 { CONTINUE } else { 0 };
|
||||
let _ = out.write_char(DIGITS[(chunk | more) as usize] as char);
|
||||
if value == 0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one varint, returning it and how many digits it took.
|
||||
fn read_varint(bytes: &[u8]) -> Option<(u64, usize)> {
|
||||
let mut value: u64 = 0;
|
||||
let mut shift = 0;
|
||||
for (taken, &byte) in bytes.iter().enumerate() {
|
||||
let digit = REVERSE[byte as usize];
|
||||
if digit == 255 {
|
||||
return None;
|
||||
}
|
||||
// A run cannot exceed MAX_PIXELS and a level cannot exceed 255, so a
|
||||
// varint past this width is a corrupt line rather than a large number.
|
||||
if shift >= 64 {
|
||||
return None;
|
||||
}
|
||||
value |= ((digit as u64) & (CONTINUE - 1) as u64) << shift;
|
||||
if digit as u32 & CONTINUE == 0 {
|
||||
return Some((value, taken + 1));
|
||||
}
|
||||
shift += CHUNK;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Walk a payload's runs, handing each `(level, length)` to `take`.
|
||||
///
|
||||
/// Returns the total length, or `None` for a payload that is not well formed:
|
||||
/// a digit that is not one, a truncated varint, a zero-length run, or a level
|
||||
/// the declared count does not contain.
|
||||
fn walk(payload: &str, levels: u32, mut take: impl FnMut(u32, usize)) -> Option<usize> {
|
||||
let top = levels - 1;
|
||||
let bytes = payload.as_bytes();
|
||||
let mut total: usize = 0;
|
||||
let mut at = 0;
|
||||
|
||||
while at < bytes.len() {
|
||||
let (level, used) = read_varint(&bytes[at..])?;
|
||||
at += used;
|
||||
let (run, used) = read_varint(&bytes[at..])?;
|
||||
at += used;
|
||||
|
||||
let level = u32::try_from(level).ok()?;
|
||||
if level > top {
|
||||
return None;
|
||||
}
|
||||
let run = usize::try_from(run).ok()?;
|
||||
// A zero-length run is not a shorter way of saying anything, so it is
|
||||
// a corrupt line rather than a run to skip — and left in, two of them
|
||||
// would encode the same raster two ways and break the byte-identical
|
||||
// round trip the sidecar relies on.
|
||||
if run == 0 {
|
||||
return None;
|
||||
}
|
||||
total = total.checked_add(run)?;
|
||||
if total > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
take(level, run);
|
||||
}
|
||||
|
||||
Some(total)
|
||||
}
|
||||
|
||||
/// How many pixels a payload covers, without building any of them.
|
||||
fn measure(payload: &str, levels: u32) -> Option<usize> {
|
||||
walk(payload, levels, |_, _| {})
|
||||
}
|
||||
|
||||
/// Expand a payload to `pixels` bytes, or `None` if it does not describe
|
||||
/// exactly that many.
|
||||
fn decode(payload: &str, pixels: usize, levels: u32) -> Option<Vec<u8>> {
|
||||
let top = levels - 1;
|
||||
let mut out = Vec::with_capacity(pixels);
|
||||
let total = walk(payload, levels, |level, run| {
|
||||
out.resize(out.len() + run, dequantise(level, top));
|
||||
})?;
|
||||
(total == pixels).then_some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Round trip at the level count the renderer actually uses.
|
||||
fn round_trip(values: &[u8], width: usize, height: usize) -> Vec<u8> {
|
||||
let coverage = Coverage::encode(values, width, height, RENDERED_LEVELS)
|
||||
.expect("this mask should encode");
|
||||
let text = coverage.to_text();
|
||||
let read = Coverage::parse(&text).expect("what was written should parse");
|
||||
assert_eq!(read, coverage, "the round trip changed the encoding");
|
||||
assert_eq!(read.to_text(), text, "re-writing must be byte-identical");
|
||||
read.decode()
|
||||
}
|
||||
|
||||
/// Two levels is exactly the predicate the distance transform applies, so
|
||||
/// the round trip must agree with it on every pixel.
|
||||
fn thresholded(values: &[u8]) -> Vec<bool> {
|
||||
values.iter().map(|&v| v >= 128).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_mask_round_trips() {
|
||||
let values = vec![0u8; 64 * 32];
|
||||
let back = round_trip(&values, 64, 32);
|
||||
assert_eq!(back, values);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_mask_round_trips() {
|
||||
let values = vec![255u8; 64 * 32];
|
||||
let back = round_trip(&values, 64, 32);
|
||||
assert_eq!(back, values);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_pixel_mask_round_trips() {
|
||||
let mut values = vec![0u8; 64 * 32];
|
||||
values[17 * 64 + 33] = 255;
|
||||
let back = round_trip(&values, 64, 32);
|
||||
assert_eq!(back, values);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_pixel_raster_round_trips() {
|
||||
assert_eq!(round_trip(&[255], 1, 1), vec![255]);
|
||||
assert_eq!(round_trip(&[0], 1, 1), vec![0]);
|
||||
}
|
||||
|
||||
/// The whole of the fidelity claim: two levels loses nothing the renderer
|
||||
/// could have used, because the renderer thresholds.
|
||||
#[test]
|
||||
fn two_levels_preserve_the_threshold_exactly() {
|
||||
let values: Vec<u8> = (0..=255u8).collect();
|
||||
let back = round_trip(&values, 16, 16);
|
||||
assert_eq!(thresholded(&back), thresholded(&values));
|
||||
// And the shoulder really is gone, which is the cost being paid.
|
||||
assert!(back.iter().all(|&v| v == 0 || v == 255));
|
||||
}
|
||||
|
||||
/// A soft edge quantised to sixteen levels stays within one step of what
|
||||
/// went in, so a later build that wants the shoulder can have it.
|
||||
#[test]
|
||||
fn sixteen_levels_are_within_one_step() {
|
||||
let values: Vec<u8> = (0..256).map(|i| i as u8).collect();
|
||||
let coverage = Coverage::encode(&values, 16, 16, 16).expect("should encode");
|
||||
let back = Coverage::parse(&coverage.to_text())
|
||||
.expect("should parse")
|
||||
.decode();
|
||||
for (a, b) in values.iter().zip(&back) {
|
||||
assert!(
|
||||
(*a as i32 - *b as i32).abs() <= 255 / 15 / 2 + 1,
|
||||
"{a} came back as {b}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The case run-length coding is worst at. It must refuse rather than
|
||||
/// write a payload larger than the raster it came from.
|
||||
#[test]
|
||||
fn alternating_detail_is_refused_rather_than_expanded() {
|
||||
let (w, h) = (512, 512);
|
||||
let values: Vec<u8> = (0..w * h)
|
||||
.map(|i| if i % 2 == 0 { 0 } else { 255 })
|
||||
.collect();
|
||||
assert!(
|
||||
Coverage::encode(&values, w, h, RENDERED_LEVELS).is_none(),
|
||||
"a checkerboard must not be stored"
|
||||
);
|
||||
}
|
||||
|
||||
/// Small enough to fit, and still exact — the bound is on size, not on
|
||||
/// shape, so awkward detail that *does* fit must survive intact.
|
||||
#[test]
|
||||
fn alternating_detail_that_fits_is_exact() {
|
||||
let (w, h) = (64, 64);
|
||||
let values: Vec<u8> = (0..w * h)
|
||||
.map(|i| if i % 2 == 0 { 0 } else { 255 })
|
||||
.collect();
|
||||
let back = round_trip(&values, w, h);
|
||||
assert_eq!(back, values);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mask_of_the_wrong_length_is_refused() {
|
||||
assert!(Coverage::encode(&[0u8; 10], 4, 4, RENDERED_LEVELS).is_none());
|
||||
assert!(Coverage::encode(&[], 0, 0, RENDERED_LEVELS).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_payload_that_does_not_cover_the_raster_is_refused() {
|
||||
let values = vec![0u8; 32];
|
||||
let coverage = Coverage::encode(&values, 8, 4, RENDERED_LEVELS).expect("should encode");
|
||||
let payload = coverage.to_text();
|
||||
let payload = payload.rsplit_once(' ').expect("a payload").1;
|
||||
// The same payload, against a raster twice the size it covers.
|
||||
assert!(Coverage::parse(&format!("8 4 2 {payload}")).is_some());
|
||||
assert!(Coverage::parse(&format!("8 8 2 {payload}")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonsense_is_refused_rather_than_guessed_at() {
|
||||
assert!(Coverage::parse("").is_none());
|
||||
assert!(Coverage::parse("8 4 2").is_none(), "no payload");
|
||||
assert!(
|
||||
Coverage::parse("8 4 1 AA").is_none(),
|
||||
"one level is not a mask"
|
||||
);
|
||||
assert!(Coverage::parse("8 4 2 ****").is_none(), "not digits");
|
||||
assert!(
|
||||
Coverage::parse(&format!("{} {} 2 AA", usize::MAX, usize::MAX)).is_none(),
|
||||
"a size that overflows must not be believed"
|
||||
);
|
||||
assert!(
|
||||
Coverage::parse("100000 100000 2 A_____").is_none(),
|
||||
"a raster past the cap must not be allocated"
|
||||
);
|
||||
}
|
||||
|
||||
/// A level a payload is not allowed to name, in a file that names it.
|
||||
#[test]
|
||||
fn a_level_outside_the_range_is_refused() {
|
||||
// "BB" is level 1, run 1 — the shortest legal payload there is.
|
||||
assert_eq!(decode("BB", 1, 2), Some(vec![255]));
|
||||
// "DB" is level 3, run 1, and a two-level coverage has no level 3.
|
||||
assert!(decode("DB", 1, 2).is_none());
|
||||
// A run of zero says nothing and is refused rather than skipped.
|
||||
assert!(decode("BA", 1, 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampling_lands_on_the_same_shape() {
|
||||
let (w, h) = (32, 32);
|
||||
let mut values = vec![0u8; w * h];
|
||||
for y in 8..24 {
|
||||
for x in 8..24 {
|
||||
values[y * w + x] = 255;
|
||||
}
|
||||
}
|
||||
let coverage = Coverage::encode(&values, w, h, RENDERED_LEVELS).expect("should encode");
|
||||
|
||||
let same = coverage.decode_at(w, h);
|
||||
assert_eq!(same, values, "the matching size must not resample at all");
|
||||
|
||||
let half = coverage.decode_at(16, 16);
|
||||
assert_eq!(half.len(), 256);
|
||||
assert_eq!(half.iter().filter(|&&v| v == 255).count(), 64);
|
||||
|
||||
let double = coverage.decode_at(64, 64);
|
||||
assert_eq!(double.len(), 4096);
|
||||
assert_eq!(double.iter().filter(|&&v| v == 255).count(), 1024);
|
||||
}
|
||||
|
||||
/// Long runs cross rows, which is what makes a flat mask cost almost
|
||||
/// nothing: a 1600x1067 empty frame is two numbers.
|
||||
#[test]
|
||||
fn a_flat_mask_costs_almost_nothing() {
|
||||
let values = vec![0u8; 1600 * 1067];
|
||||
let coverage =
|
||||
Coverage::encode(&values, 1600, 1067, RENDERED_LEVELS).expect("should encode");
|
||||
assert!(
|
||||
coverage.encoded_len() < 16,
|
||||
"an empty mask took {} bytes",
|
||||
coverage.encoded_len()
|
||||
);
|
||||
}
|
||||
|
||||
/// What a real one costs. The shape is a bilinear upsample of a coarse
|
||||
/// grid, which is what both models produce, so the run structure is the
|
||||
/// one a photograph actually gives.
|
||||
#[test]
|
||||
fn a_realistic_mask_fits_in_a_sidecar() {
|
||||
let (w, h) = (1600usize, 1067usize);
|
||||
let (gw, gh) = (160usize, 107usize);
|
||||
let mut grid = vec![0f32; gw * gh];
|
||||
for y in 0..gh {
|
||||
for x in 0..gw {
|
||||
let dx = (x as f32 - 80.0) / 26.0;
|
||||
let dy = (y as f32 - 60.0) / 42.0;
|
||||
let r = (dx * dx + dy * dy).sqrt()
|
||||
+ 0.06 * ((y as f32 * 0.9).sin() * (x as f32 * 0.7).cos());
|
||||
grid[y * gw + x] = 1.0 / (1.0 + ((r - 1.0) * 9.0).exp());
|
||||
}
|
||||
}
|
||||
let mut values = vec![0u8; w * h];
|
||||
for y in 0..h {
|
||||
let fy = ((y as f32 + 0.5) / h as f32 * gh as f32 - 0.5).max(0.0);
|
||||
let (y0, ty) = (fy.floor() as usize, fy.fract());
|
||||
let y1 = (y0 + 1).min(gh - 1);
|
||||
for x in 0..w {
|
||||
let fx = ((x as f32 + 0.5) / w as f32 * gw as f32 - 0.5).max(0.0);
|
||||
let (x0, tx) = (fx.floor() as usize, fx.fract());
|
||||
let x1 = (x0 + 1).min(gw - 1);
|
||||
let a = grid[y0 * gw + x0] * (1.0 - tx) + grid[y0 * gw + x1] * tx;
|
||||
let b = grid[y1 * gw + x0] * (1.0 - tx) + grid[y1 * gw + x1] * tx;
|
||||
values[y * w + x] = ((a * (1.0 - ty) + b * ty) * 255.0) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
let coverage = Coverage::encode(&values, w, h, RENDERED_LEVELS).expect("should encode");
|
||||
let expected: Vec<u8> = values
|
||||
.iter()
|
||||
.map(|&v| if v >= 128 { 255 } else { 0 })
|
||||
.collect();
|
||||
assert_eq!(
|
||||
coverage.decode(),
|
||||
expected,
|
||||
"the stored mask must threshold identically to the model's"
|
||||
);
|
||||
// Measured at 6,464 bytes; the bound is loose enough not to fail over
|
||||
// a change of rounding and tight enough to catch a coder that has
|
||||
// stopped coding. Against 1,707,200 bytes raw.
|
||||
assert!(
|
||||
coverage.encoded_len() < 8 * 1024,
|
||||
"a realistic subject took {} bytes",
|
||||
coverage.encoded_len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user