Compare commits

..
1 Commits
Author SHA1 Message Date
Gitea Actions 3d3083cc32 manual: deploy from 880915e061 2026-10-09 15:31:44 +00:00
434 changed files with 568 additions and 189111 deletions
-41
View File
@@ -1,41 +0,0 @@
{
"permissions": {
"allow": [
"Bash(curl -s \"https://api.github.com/search/code?q=WrapTexture+org:Noesis\" -H \"Accept: application/vnd.github+json\")",
"Bash(curl -s \"https://api.github.com/orgs/Noesis/repos?per_page=100\")",
"WebFetch(domain:wiki.wxwidgets.org)",
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/samples.gpu/hello-es-triangle.htm\")",
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=include&recursive=true&per_page=100&ref=main\")",
"Bash(python3 -c ' *)",
"Bash(curl -sL --max-time 40 \"https://api.github.com/repos/wxWidgets/wxWidgets/contents/src?ref=master\")",
"Bash(curl -sL --max-time 40 \"https://api.github.com/repos/wxWidgets/wxWidgets/contents/include/wx/android?ref=master\")",
"Bash(curl -sL --max-time 40 -H \"Accept: application/vnd.github.text-match+json\" \"https://api.github.com/search/code?q=vulkan+repo:wxWidgets/wxWidgets\")",
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/include/sciter-x-video-api.h\")",
"WebFetch(domain:docs.wxwidgets.org)",
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/CHANGELOG.md\")",
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/readme.txt\")",
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/licence.txt\")",
"Bash(curl -sL --max-time 40 \"https://raw.githubusercontent.com/wxWidgets/wxWidgets/master/docs/licendu.txt\")",
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=build&recursive=true&per_page=100&ref=main\")",
"Bash(curl -sS \"https://gitlab.com/sciter-engine/sciter-js-sdk/-/raw/main/premake5.lua\")",
"WebFetch(domain:slack-chats.kotlinlang.org)",
"Bash(curl -sS -L \"https://sciter.com/\")",
"Bash(curl -sL --max-time 45 \"https://api.github.com/orgs/ultralight-ux/repos?per_page=100&sort=pushed\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/gdkdmabuftexturebuilder.h\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/gdkgltexturebuilder.h\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/api/v4/projects/GNOME%2Fgtk/repository/tree?path=gdk&ref=main&per_page=100\")",
"WebFetch(domain:docs.slint.dev)",
"WebFetch(domain:releases.slint.dev)",
"WebFetch(domain:flutter.dev)",
"Bash(curl -sS -L \"https://sciter.com/forums/topic/status-of-quark-sciter-lite-sciterjs-android-ios/\")",
"Bash(curl -sL --max-time 45 \"https://api.github.com/repos/ultralight-ux/AppCore/git/trees/master?recursive=1\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/meson.build\")",
"WebFetch(domain:www.jetbrains.com)",
"Bash(curl -sS \"https://gitlab.com/api/v4/projects/sciter-engine%2Fsciter-js-sdk/repository/tree?path=demos.lite&recursive=true&per_page=100&ref=main\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/api/v4/projects/GNOME%2Fgtk/repository/commits?path=gdk/android/gdkandroidglcontext.c&ref_name=main&per_page=20\")",
"Bash(curl -sL --max-time 40 \"https://gitlab.gnome.org/GNOME/gtk/-/raw/main/gdk/android/meson.build\")",
"WebFetch(domain:docs.sciter.com)",
"Bash(curl -sS -L \"https://sciter.com/support-of-displayflex-and-displaygrid-in-sciter/\")"
]
}
}
View File
-12
View File
@@ -1,12 +0,0 @@
# Model weights live in LFS.
#
# `core/dr-segment/models/*.onnx` is ~11 MB of binary that changes wholesale
# when it changes at all. In ordinary git objects every future revision of it
# would be stored in full, in every clone, forever — and the one thing nobody
# can do with it is a useful diff.
#
# Consequence worth knowing before it bites: a clone without git-lfs gets a
# ~130-byte pointer file where the model should be. `dr-segment`'s build script
# 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
-170
View File
@@ -1,170 +0,0 @@
name: '🐳 Android image'
# Builds and pushes gitea.tourolle.paris/dtourolle/darkroom-android, the job
# container for the Android leg of build-and-test.yml.
#
# It exists because that image previously lived only on a developer's laptop:
# the workflow referenced a tag that had never been pushed, and every Android
# job died at `docker pull` with "manifest unknown" before running a step. The
# image is now reproducible from the repo rather than from one machine.
#
# 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-android
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/android, 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 7 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/android)
echo "tree=$TREE" >> "$GITHUB_OUTPUT"
echo "docker/android 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-android/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 Android 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/android, 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/android
# 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 7 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"
-396
View File
@@ -1,396 +0,0 @@
name: Build and test
# Desktop and Android are built on every push, per the v0.1 decision to carry
# both platforms from the first commit. An Android break is then caught the day
# it lands rather than at a porting milestone.
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
jobs:
# The Android job runs inside an image that this repo builds. Ensure it is in
# the registry before anything tries to pull it — see android-image.yml for
# why this is a job rather than a documented manual step. It is a no-op of a
# few seconds unless docker/android actually changed.
android-image:
uses: ./.gitea/workflows/android-image.yml
desktop:
runs-on: linux/amd64
name: Desktop (Linux)
# actions/checkout and actions/cache are JavaScript actions: the runner
# executes them with Node from inside this container. The bare runner image
# has none, so the job failed at checkout before reaching any build step.
container:
image: catthehacker/ubuntu:act-latest
# This job filled the runner's disk and died mid-link with "No space left
# on device" — LLVM reporting an IO failure on its output stream, which
# reads like a compiler crash and is not one.
#
# `target/debug` was 24 GB against `target/release`'s 2.6 GB: 15 GB of it
# debug info in `debug/deps`, 3.6 GB incremental state. Neither earns its
# space here. Nothing attaches a debugger to a CI run, and incremental
# compilation exists to make the *second* build in a working tree fast,
# which is not a thing a fresh checkout has. Turning both off is the
# standard CI setting rather than a trick.
#
# Measured on this workspace: the same `cargo test --workspace --no-run`
# tree goes from 24 GB to 3.3 GB, `debug/deps` from 15 GB to 2.8 GB.
#
# Backtraces still name functions without debug info; they lose file and
# line numbers. If a test failure ever needs those, drop DEBUG to 1
# (line-tables-only) rather than back to 2.
#
# This is a mitigation, not a fix. If the runner is full of anything other
# than this job's own output, it will still be full afterwards.
env:
CARGO_INCREMENTAL: 0
CARGO_PROFILE_DEV_DEBUG: 0
steps:
- name: Checkout
uses: actions/checkout@v4
# The model, which is in LFS and is not optional.
#
# `core/dr-segment/models/*.onnx` is tracked in LFS (.gitattributes), so a
# plain checkout writes a ~130-byte pointer where 11 MB should be, and
# `dr-segment`'s build script panics by design rather than embedding a
# pointer and failing at inference. That failure reads like a broken build
# instead of a missing fetch, which is how it went unnoticed.
#
# Not `lfs: true` on the checkout above, and no `Authorization` header
# here either. Both install a blanket header for every request to this
# host, and the object download is the one request that already carries
# one: `git lfs pull` asks `/info/lfs/objects/batch` first, and Gitea
# answers with a short-lived `Bearer` JWT scoped to that object. git-lfs
# then sends the JWT *and* the configured header, and two `Authorization`
# headers is a 400 from Gitea — reported as
# LFS: Client error: .../info/lfs/objects/<oid>
# one step after the batch call that had just succeeded, which reads like
# a rejected credential rather than a duplicated one. A lone token header
# is understood fine; it is only the collision that fails.
#
# So: strip the headers and hand the token to git-lfs as an ordinary
# credential instead. It authenticates the batch call and leaves the
# per-object JWT untouched. Gitea authenticates on the password, so the
# username is a placeholder. Nothing later in this job talks to the
# remote, so dropping checkout's header costs us nothing.
#
# `continue-on-error` deliberately: if this cannot authenticate, the build
# below still runs and fails with the build script's own message, which
# names the real problem. A checkout that dies here says nothing.
- name: Fetch the segmentation model
continue-on-error: true
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
ls -l core/dr-segment/models/
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: desktop-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
# Slint and winit need these at build time; the runner image is minimal.
- name: Build dependencies
run: |
apt-get update -qq
apt-get install -y -qq pkg-config libfontconfig1-dev libxkbcommon-dev
# The act image ships Node but no Rust. Pinned to the workspace
# rust-version so CI, the Android image, and local builds agree — a
# floating toolchain turns an unrelated push into a mystery failure.
- name: Install Rust 1.92.0
run: |
set -e
curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal \
--default-toolchain 1.92.0 --component rustfmt,clippy
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# Free space before and after the expensive steps, so a repeat of the
# disk exhaustion above is one line to diagnose instead of a puzzling
# LLVM error.
- name: Disk before
run: df -h /workspace 2>/dev/null || df -h .
- name: Format
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
# GPU tests skip themselves where no adapter is present rather than
# failing — CI runners generally have none, and a test that cannot run is
# not evidence either way.
- name: Test
run: cargo test --workspace
- name: Build
run: cargo build --workspace --release
- name: Disk after
if: always()
run: df -h /workspace 2>/dev/null || df -h .
android:
runs-on: linux/amd64
name: Android (aarch64)
# Waits for the image build. Without this the pull races the push and the
# job dies with "manifest unknown" before its first step, which is the
# failure mode this ordering exists to remove.
needs: android-image
container:
image: gitea.tourolle.paris/dtourolle/darkroom-android:latest
steps:
- name: Checkout
uses: actions/checkout@v4
# The model, which is in LFS and is not optional.
#
# `core/dr-segment/models/*.onnx` is tracked in LFS (.gitattributes), so a
# plain checkout writes a ~130-byte pointer where 11 MB should be, and
# `dr-segment`'s build script panics by design rather than embedding a
# pointer and failing at inference. That failure reads like a broken build
# instead of a missing fetch, which is how it went unnoticed.
#
# Not `lfs: true` on the checkout above, and no `Authorization` header
# here either. Both install a blanket header for every request to this
# host, and the object download is the one request that already carries
# one: `git lfs pull` asks `/info/lfs/objects/batch` first, and Gitea
# answers with a short-lived `Bearer` JWT scoped to that object. git-lfs
# then sends the JWT *and* the configured header, and two `Authorization`
# headers is a 400 from Gitea — reported as
# LFS: Client error: .../info/lfs/objects/<oid>
# one step after the batch call that had just succeeded, which reads like
# a rejected credential rather than a duplicated one. A lone token header
# is understood fine; it is only the collision that fails.
#
# So: strip the headers and hand the token to git-lfs as an ordinary
# credential instead. It authenticates the batch call and leaves the
# per-object JWT untouched. Gitea authenticates on the password, so the
# username is a placeholder. Nothing later in this job talks to the
# remote, so dropping checkout's header costs us nothing.
#
# `continue-on-error` deliberately: if this cannot authenticate, the build
# below still runs and fails with the build script's own message, which
# names the real problem. A checkout that dies here says nothing.
- name: Fetch the segmentation model
continue-on-error: true
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
ls -l core/dr-segment/models/
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
/opt/cargo/registry
target-android
key: android-${{ hashFiles('**/Cargo.lock') }}
# A fast gate on the crates most likely to break the cross-compile, run
# before the expensive part. It is `cargo check`, so it type-checks
# without linking and returns in a fraction of the time the step below
# takes.
#
# Not a statement that only these crates cross-compile — `darkroom-android`
# and the whole UI stack beneath it build for aarch64 too, which is what
# the API-level step below does. This one exists to fail fast and name a
# smaller suspect when it does.
- name: Cross-compile core
env:
CARGO_TARGET_DIR: target-android
run: cargo check -p dr-types -p dr-gpu -p dr-sync --target aarch64-linux-android
# The linker targets MIN_API, not the compile SDK. cargo-ndk otherwise
# defaults to API 21, far below the Vulkan floor this app needs — and the
# mismatch is invisible until a device refuses to install.
#
# Look under the target triple, and fail on a mismatch. Searching the
# whole target dir for the first `*.so` found the host proc-macro
# libraries in target-android/debug/deps instead — x86-64 objects built
# by the runner's gcc, whose .comment section says nothing about Android
# and can never contradict the expected API. The step passed regardless
# of what the linker actually did, which is the one thing it exists to
# rule out.
- name: Verify minimum API level
env:
CARGO_TARGET_DIR: target-android
run: |
set -e
# `darkroom-android`, not a core crate: this step reads the API level
# out of a *linked* object, and only that crate produces one. It is
# the workspace's single `crate-type = ["cdylib"]`; a library crate
# builds an rlib, which is an archive of object files that no linker
# has yet touched and that `file` therefore has nothing to say about.
# Asking for `-p dr-gpu` here could only ever reach the "no aarch64
# .so was produced" branch below, whatever the linker did.
#
# It is also the honest artefact to check: the .so this names is the
# one that ships in the APK, so the API level verified here is the
# API level a device will refuse to install against.
cargo ndk -t arm64-v8a -o target-android/jniLibs \
build -p darkroom-android --release
MIN_API=$(sed -n 's/^ARG MIN_API=\([0-9]*\).*/\1/p' docker/android/Dockerfile)
# Empty on both sides would compare equal and pass, so neither side
# is allowed to be the result of a failed parse.
if [ -z "$MIN_API" ]; then
echo "no ARG MIN_API= in docker/android/Dockerfile"
exit 1
fi
SO=$(find target-android/aarch64-linux-android/release -maxdepth 1 -name '*.so' | head -1)
if [ -z "$SO" ]; then
echo "no aarch64 .so was produced"
exit 1
fi
echo "checking $SO"
# `file` is kept for the log — it names the NDK that built this — but
# the check no longer depends on it.
file "$SO" || true
# The API level is the first word of the `.note.android.ident` ELF
# note, little-endian. Read the note rather than asking `file` for it:
# `file` only prints "for Android 28" when its magic database is new
# enough to decode that note, and this image's is not. The parse then
# produced nothing, `${API:-unknown}` reported "unknown", and every
# push failed here for weeks on a .so that was linked perfectly
# correctly. A note read straight out of the ELF cannot go stale that
# way.
readelf -n "$SO" | sed -n '/android.ident/,+3p'
HEX=$(readelf -n "$SO" 2>/dev/null \
| awk '/description data:/ { print $6 $5 $4 $3; exit }')
if [ -z "$HEX" ]; then
echo "FAIL: no .note.android.ident in $SO — nothing states an API level"
exit 1
fi
API=$(( 0x$HEX ))
if [ "$API" != "$MIN_API" ]; then
echo "FAIL: linked for Android $API, expected $MIN_API"
exit 1
fi
echo "OK: linked for Android $API"
# The APK itself, so a run leaves something installable behind rather
# than only the knowledge that it would have linked. The assembly is
# `docker/android/assemble-apk.sh`, shared with `package.sh` so the file
# a device gets from `package.sh --install` and the file published here
# are built by the same code — see that script's header.
#
# `KEYSTORE` deliberately points at a throwaway directory instead of its
# default under `target-android`: that directory is what `actions/cache`
# restores and saves, and a signing key has no business in a build cache
# or in anything this job uploads. A fresh debug key per run is the right
# trade for an artefact whose purpose is getting the app onto a test
# device; nothing upgrades in place over it, which is the one thing a
# stable key would buy.
- name: Package the APK
env:
CARGO_TARGET_DIR: target-android
# Absent secrets mean a debug signature, which is what a fork or a
# branch build should get. Set all three (see docs/android-signing.md)
# and the same job produces a release-signed APK instead.
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
run: |
set -e
KEYDIR="$(mktemp -d)"
chmod 700 "$KEYDIR"
trap 'rm -rf "$KEYDIR"' EXIT
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
# The keystore reaches the runner base64-encoded because a secret
# is a string. It is written under a 0700 mktemp directory, never
# into the workspace: `target-android` is what actions/cache saves,
# and the upload step globs the workspace.
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$KEYDIR/release.keystore"
export KEYSTORE="$KEYDIR/release.keystore"
else
# Not an error. Unset the rest so assemble-apk.sh takes its debug
# path cleanly rather than seeing a half-configured release one.
export KEYSTORE="$KEYDIR/debug.keystore"
unset KEYSTORE_PASS KEY_PASS KEY_ALIAS
fi
REPO="$PWD" TARGET_DIR="$PWD/target-android" \
bash docker/android/assemble-apk.sh
# v3, not v4. v4 is untested against this Gitea and its runner; v3 is
# what JellyTau uploads its APK with on this same runner, so it is the
# version known to work here rather than the version that ought to.
#
# `if-no-files-found: error` because the failure this guards against is
# a green run with an empty artefact list, which reads as success until
# somebody goes looking for the file.
- name: Upload the APK
uses: actions/upload-artifact@v3
with:
name: darkroom-arm64-v8a-apk
path: target-android/apk/darkroom.apk
if-no-files-found: error
layering:
runs-on: linux/amd64
name: Layer separation
# Node for the JS actions, as above. cargo comes from rustup below.
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# `cargo tree` resolves the dependency graph, so it needs the registry
# index but no system libraries — this job builds nothing.
- name: Install Rust 1.92.0
run: |
set -e
curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal --default-toolchain 1.92.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# ARCH §6.5a: no core/ crate may depend on the UI toolkit. One stray
# `use slint::` costs headless golden-image testing and the
# one-operation-two-presentations property together, and nothing else
# would notice.
- name: Core crates must not depend on the UI
run: |
set -e
FAILED=0
for crate in dr-types dr-gpu dr-sync; do
if cargo tree -p "$crate" -e normal 2>/dev/null | grep -qE '\bslint\b|\bi-slint'; then
echo "FAIL: $crate depends on Slint (ARCH §6.5a)"
FAILED=1
else
echo "ok: $crate"
fi
done
exit $FAILED
-112
View File
@@ -1,112 +0,0 @@
name: Traceability
# Mirrors JellyTau's traceability gate, including the reason it exists.
#
# That gate divided a traced count by frozen literal denominators while the
# requirements file grew past them, reported 158% coverage, and so could never
# fail its own threshold. Two rules follow, and the extractor's own tests
# enforce both:
#
# 1. Denominators are parsed from docs/requirements.md at run time.
# 2. Coverage is |traced ∩ defined| / |defined|, never a raw traced count.
#
# This job is static analysis of source comments plus markdown parsing, so it
# needs no GPU and no Android SDK — only the Rust toolchain.
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
jobs:
traceability:
runs-on: linux/amd64
name: Requirement traces
# Node for actions/checkout and actions/cache, which the bare runner image
# cannot execute. Rust is installed below.
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: traces-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
# Source-comment and markdown parsing only, so the minimal profile is
# enough — no system libraries and no components beyond cargo itself.
- name: Install Rust 1.92.0
run: |
set -e
curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal --default-toolchain 1.92.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The gate's own arithmetic is the thing being trusted, so its tests run
# before it does. Untested gate logic is exactly how JellyTau's 158% went
# unnoticed for months.
- name: Test the extractor
run: cargo test -p traceability
# Structural failures are unconditional and do not depend on the coverage
# threshold: zero requirements parsed, zero files scanned, a ratio above
# 100%, or any orphan tag all fail the build. A misconfigured run must not
# report a plausible-looking 0%.
- name: Traceability gate
run: cargo run -q -p traceability -- check
- name: Regenerate matrix and check it is committed
run: |
set -e
cargo run -q -p traceability -- report
if ! git diff --quiet docs/traceability.md; then
echo ""
echo "docs/traceability.md is out of date."
echo "Run: cargo run -p traceability -- report"
git diff --stat docs/traceability.md
exit 1
fi
# Advisory, not blocking: not every file implements a requirement, and a
# tag on every function is noise that rots faster than it helps. Tag the
# unit that decides.
- name: Check changed files for tags
if: github.event_name == 'pull_request'
run: |
set -e
CHANGED=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" \
| grep -E '\.(rs|slint|wgsl)$' || true)
[ -z "$CHANGED" ] && { echo "No source files changed."; exit 0; }
MISSING=0
for file in $CHANGED; do
case "$file" in
*/tests/*|*/test_*|tools/*) continue ;;
esac
[ -f "$file" ] || continue
if ! grep -q 'TRACES:' "$file"; then
echo " no TRACES tag: $file"
MISSING=$((MISSING + 1))
fi
done
if [ "$MISSING" -gt 0 ]; then
echo ""
echo "$MISSING changed file(s) carry no requirement tag."
echo "Format: /// TRACES: FR-CAT-1, FR-CAT-2 | NFR-P1"
echo " (comma separates IDs, pipe groups types)"
fi
- name: Summary
if: always()
run: head -30 docs/traceability.md || true
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs post-checkout "$@"
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs post-commit "$@"
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs post-merge "$@"
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
# Keep docs/traceability.md in step with the tags in the tree.
#
# The gate regenerates the matrix in CI and fails if the result differs from
# what is committed. That is the right check — a matrix that disagrees with the
# tree is worse than none, because it is read as current — but it fails *after*
# a push, on a commit that is otherwise fine, and it has now done so on six
# commits in a row because adding a `TRACES:` tag and regenerating the matrix
# are two actions and only the first is on anyone's mind.
#
# So it happens here instead, where the tags are being changed.
#
# Only when something that can carry a tag is staged: a commit touching
# workflows, packaging or the matrix itself pays nothing.
set -euo pipefail
staged="$(git diff --cached --name-only --diff-filter=ACMR)"
if ! grep -qE '\.(rs|slint|yaml|md)$' <<< "${staged}"; then
exit 0
fi
# The matrix is generated from the tree, so regenerating it because it was
# itself edited would be circular.
if [ "$(tr -d '[:space:]' <<< "${staged}")" = "docs/traceability.md" ]; then
exit 0
fi
repo="$(git rev-parse --show-toplevel)"
cd "${repo}"
# Quiet unless it has something to say. A hook that prints on every commit is
# a hook people start passing --no-verify to.
if ! cargo run -q -p traceability -- report >/dev/null 2>&1; then
echo "pre-commit: could not run the traceability report; leaving the matrix alone" >&2
exit 0
fi
if ! git diff --quiet -- docs/traceability.md; then
git add docs/traceability.md
echo "pre-commit: regenerated docs/traceability.md and staged it"
fi
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; }
git lfs pre-push "$@"
-18
View File
@@ -1,18 +0,0 @@
/target
/target-android
Cargo.lock.bak
*.log
# makepkg build products. `packaging/PKGBUILD` and the .desktop entry are
# sources and belong in the tree; everything makepkg derives from them does
# not — `pkg/` and `src/` are staging directories it recreates on every run,
# and the package itself is 33 MB of compiled output.
/packaging/pkg/
/packaging/src/
/packaging/*.pkg.tar.*
/packaging/*.log
# Cached upstream film profiles, re-fetchable with
# tools/film-profiles/convert.py --fetch. Not source: the converted
# profiles in core/dr-film/profiles are.
tools/film-profiles/upstream/
-163
View File
@@ -1,163 +0,0 @@
# Contributing to DarkRoom
There is a lot of documentation here — 14 documents and 177 numbered
requirements — and almost all of it is written for someone who has already
decided to work on this. This file is the other thing: how to get a first
change landed without reading any of it.
## The shortest useful contribution
**A develop operation is one file.** Not one file plus a registration, plus a
shader edit, plus a control in the UI — one file:
```
core/dr-pipeline/ops/split_toning.yaml
```
`build.rs` finds it with `read_dir`, compiles it into Rust implementing
`Operation`, and from there it is indistinguishable from a hand-written node.
It arrives with controls built from its declared parameter kinds, a place in
the chain from `order:`, a place in the panel from `attributes:`, sidecar
persistence, and its own tests — which are declared in the same file and run
under `cargo test`.
Read [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) and
copy [`exposure.yaml`](core/dr-pipeline/ops/exposure.yaml). Split toning,
colour zones, selective colour and channel-mixer variants are all pure point
operations, which means all of them are declarations rather than code.
If you want to understand one thing about the architecture before starting,
make it this: **the core describes its capabilities and the interface composes
them.** No code in `ui/` names an operation, and a test enforces that
(`ui/dr-ui/tests/ui_names_no_operation.rs`). It is why your node needs no UI
change.
## Getting it to build
**Git LFS is required.** Model weights are stored in LFS, and a clone made
without it leaves a ~130-byte text pointer where an 11 MB model should be:
```bash
git lfs install && git lfs pull
```
Forget this and `dr-segment`'s build script stops with an instruction rather
than embedding the pointer and failing at inference time — but it is easier to
run the two commands now.
**The toolchain pins itself.** `rust-toolchain.toml` selects 1.92.0 and rustup
fetches it on first use. Do not override it; `cargo fmt` and `clippy` are both
version-sensitive and CI runs exactly this version.
**System packages.** Slint and winit need these at build time. On Debian or
Ubuntu:
```bash
sudo apt-get install pkg-config libfontconfig1-dev libxkbcommon-dev
```
**Then:**
```bash
cargo run -p darkroom-desktop
```
The first build resolves 826 crates and takes a while — on a laptop, long
enough to look like a hang. It is not one.
Android is a containerised toolchain and is not needed for most work; see
[`docker/android/README.md`](docker/android/README.md) if you get there.
## What CI will check
All four of these run on every push, so run them before you send anything:
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo build --workspace --release
```
GPU tests skip themselves where there is no adapter rather than failing — a
test that cannot run is not evidence either way — so a green run on a machine
without a GPU is expected, and does not mean the GPU paths were exercised.
## Requirements and traceability
[`requirements.md`](docs/requirements.md) is the register of record.
[`traceability.md`](docs/traceability.md) is generated from `TRACES:` tags in
the source and must never be hand-edited:
```rust
// TRACES: FR-DEV-3a | FR-DEV-3c
```
Tags are read from `.rs`, `.slint`, `.wgsl` and `.yaml` — the last so a
declared operation can record the requirement it satisfies, since the Rust it
generates lands in `OUT_DIR` and is not scanned.
A pre-commit hook regenerates the matrix and stages it whenever you touch
something that can carry a tag, so you should not have to think about it. If
you do need to run it by hand:
```bash
cargo run -p traceability -- report
```
Note that it tracks line numbers, so a change that only moves code still moves
the matrix. Never regenerate it with a stale prebuilt binary.
**One convention that the tooling cannot enforce.** A tag proves that a tag
exists, not that the code under it does the thing — `docs/code-health.md`
CH-4 has the details, and two requirements currently read as covered on the
strength of plumbing a future feature would use. So: **close a requirement
with a test that would fail if the behaviour were removed.** Coverage that
moves slowly and means something beats coverage that moves quickly.
## Two invariants the build defends
Worth knowing before you trip one, because both failures name a requirement
rather than a line:
- **No operation may be named in `ui/`** (FR-DEV-3a). Special-casing one
operation in the panel to fix a layout problem is how a generated interface
stops being generated. If a node needs presentation the panel cannot give it,
the answer is a `presentation:` hint in the declaration and a `WidgetKind`,
not a branch in `develop.rs`.
- **The operation schema rejects ambiguity at build time**: a duplicate
`order:`, a filename disagreeing with its `id:`, a default outside its own
range, an expression naming something that is not a parameter. Each error
names the key you got wrong and exits rather than panicking.
## Commit messages
Imperative subject describing the change from the reader's side — "Offer the
merge when two people turn out to share a name", not "fix: merge dialog". No
conventional-commits prefixes.
The body is where the reasoning goes, and it is expected to be substantial when
the change is. This codebase records *why* far more than most, in commits and
in comments alike, and that is the single habit most worth adopting: the
constraint you worked around is invisible to whoever reads the diff next.
One commit per change. If you fixed two things, that is two commits.
## Where to read next, in order
| Document | Read it when |
|---|---|
| [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) | Adding or changing a develop operation — start here regardless |
| [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync |
| [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs |
| [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen |
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading |
`technical-debt.md` is the one to check before "fixing" anything surprising.
It records compromises that were deliberate, each with the reasoning and a
falsifiable condition for when it stops being one — the point being that you
can tell a constraint from an accident without asking.
## Licence
GPL-3.0-or-later. By contributing you agree your work is licensed the same way.
Generated
-8744
View File
File diff suppressed because it is too large Load Diff
-238
View File
@@ -1,238 +0,0 @@
[workspace]
resolver = "2"
members = [
"core/dr-types",
"core/dr-catalog",
"core/dr-thumbs",
"core/dr-decode",
"core/dr-export",
"core/dr-face",
"core/dr-film",
"core/dr-ingest",
"core/dr-gpu",
"core/dr-lens",
"core/dr-pipeline",
"core/dr-segment",
"core/dr-sync",
"core/dr-sync-nextcloud",
"platform/dr-plat",
"ui/dr-ui",
"apps/darkroom-desktop",
"apps/darkroom-android",
"tools/traceability",
]
[workspace.package]
version = "0.8.0"
edition = "2021"
rust-version = "1.92"
license = "GPL-3.0-or-later"
repository = "https://github.com/dtourolle/DarkRoom"
[workspace.dependencies]
# Internal
dr-types = { path = "core/dr-types" }
dr-catalog = { path = "core/dr-catalog" }
dr-thumbs = { path = "core/dr-thumbs" }
dr-decode = { path = "core/dr-decode" }
dr-export = { path = "core/dr-export" }
# Stated explicitly for the same reason as `dr-segment` below: no dependant
# should drag in an ONNX runtime by accident. Members opt in with
# `features = ["inference"]`.
dr-face = { path = "core/dr-face", default-features = false }
dr-film = { path = "core/dr-film" }
dr-ingest = { path = "core/dr-ingest" }
dr-gpu = { path = "core/dr-gpu" }
dr-lens = { path = "core/dr-lens" }
dr-pipeline = { path = "core/dr-pipeline" }
# `default-features = false` belongs *here*, not on each dependant: a member
# inheriting a workspace dependency cannot turn its default features off, so
# writing it below would silently do nothing and every crate touching
# `dr-segment` would drag in tract and 11 MB of weights. Members opt in with
# `features = ["semantic", "embedded-model"]` instead.
dr-segment = { path = "core/dr-segment", default-features = false }
dr-plat = { path = "platform/dr-plat" }
dr-sync = { path = "core/dr-sync" }
dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" }
dr-ui = { path = "ui/dr-ui" }
# GPU + UI
#
# The wgpu version is not a free choice: it is dictated by Slint. Importing a
# texture into the scene (ARCH §6.1, spike S1) requires it to come from the
# *same* `wgpu::Device` Slint renders with, and Slint will only hand out a
# device of the version it was compiled against. Slint 1.17 offers
# `unstable-wgpu-28` and `unstable-wgpu-29` and nothing older, so 29 it is —
# pinned to the same `29.0.4` floor Slint itself requires, because two
# semver-compatible-but-different wgpu crates in one tree are two *types*, and
# the device would not typecheck across them.
#
# Consequently: bumping Slint may force a wgpu bump, and wgpu cannot be bumped
# on its own. They move together or not at all.
wgpu = "29.0.4"
slint = { version = "1.17", default-features = false }
slint-build = "1.17"
# UI token codegen (S2): style.yaml -> theme.slint. serde_yaml was deprecated
# by its maintainer in 2024 and serde_yml, the first fork, has since been
# deprecated too; serde_norway is the fork still receiving releases. Its
# mappings preserve insertion order, which is what lets the generated Slint
# keep the token ordering the YAML author chose.
serde_norway = "0.9"
# Foundations
anyhow = "1"
thiserror = "2"
log = "0.4"
env_logger = "0.11"
pollster = "0.4"
# Networking — no mature Nextcloud crate exists; the connector is hand-rolled
# over reqwest (D7). reqwest_dav was evaluated and is too thin to build on.
# `rustls-no-provider` rather than `rustls`: the latter defaults to the
# aws-lc-rs crypto provider, whose aws-lc-sys crate is C and fails to
# cross-compile for Android — precisely the NDK pain D1 chose Rust to avoid.
# ring is pure Rust apart from a small asm core that does build under the NDK.
#
# `rustls-tls-webpki-roots-no-provider` rather than plain `rustls-no-provider`:
# the latter verifies against rustls-platform-verifier, which reaches the
# Android trust store over JNI and panics mid-handshake unless initialised from
# Java first — the crash D7 predicted and spike S3 exists to resolve properly.
# The panic surfaces inside tokio, which catches task panics itself, so it
# reaches the UI as a worker that stopped rather than as an error.
#
# webpki-roots is the escape hatch D7 records: a root store compiled into the
# binary, no JNI, identical on both platforms. The trade is real and belongs in
# S3's scope — user-installed and enterprise CAs are not consulted, and the
# roots go stale with the release rather than with the OS.
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "webpki-roots", "stream", "json"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
quick-xml = "0.41"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
url = "2.5"
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.23"
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
#
# Neither is a new cost: winit already builds both, so the versions are the
# ones Slint's backend has resolved to and pinning anything else here would
# compile a second copy. Both are pure Rust — x11rb speaks the X11 wire
# protocol itself rather than binding libxcb, and wayland-client binds
# libwayland only under a feature that is off — which keeps the Android
# cross-compile a plain Rust dependency graph, the same criterion as the TLS
# and SQLite choices above. They are declared under a target predicate that
# excludes Android, where neither display server exists.
#
# `staging` on wayland-protocols is what carries `wp_color_manager_v1`: the
# colour-management extension is still staging upstream, which is the
# protocol-level statement of the thing FR-DSP-8 anticipates when it says
# Wayland's colour management "is not universally available".
x11rb = { version = "0.13", features = ["randr"] }
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
# Platform secure storage: Secret Service on Linux, Keystore on Android
# (FR-NC-2). Credentials never touch the catalog or a plain file.
# keyring 4 restructured its features: `v1` is the default set and brings
# the zbus Secret Service backend, which is what GNOME Keyring and KWallet
# (via ksecretd) both speak.
keyring = { version = "4", features = ["v1"] }
# The Android half of the same project: a keyring-core CredentialStore backed
# by AndroidKeyStore AES-GCM over SharedPreferences (FR-PLAT-AND-1). It reads
# the JavaVM and Context from ndk-context, which android-activity populates
# before `android_main` runs, so no Kotlin shim of our own is needed.
#
# This is the keyring-core API, not the v1 `Entry` API the Linux path uses;
# the two impls are deliberately separate rather than sharing a code path.
android-native-keyring-store = "1.0.0"
keyring-core = "1"
# Decode. rawler is the pure-Rust decoder (D2); zune-jpeg decodes the
# embedded previews rawler extracts.
# Catalog. `bundled` compiles SQLite from source rather than linking the
# system library — the same cross-compilation reasoning as the TLS choice
# above: no system dependency to satisfy under the Android NDK.
#
# `backup` is not optional in practice: it is what takes a consistent snapshot
# of a live WAL database for upload. A filesystem copy of `catalog.sqlite`
# while a `-wal` exists beside it uploads a torn file.
rusqlite = { version = "0.40", features = ["bundled", "backup"] }
rawler = "0.7"
zune-jpeg = "0.4.21"
# Thumbnails are stored encoded, not as raw RGBA: a 256px RGBA buffer is
# ~256 KB against ~20 KB as JPEG, and the store syncs to Nextcloud where that
# 13× is transfer cost on every client. Pure Rust, no C dependency — the same
# criterion behind the TLS and SQLite choices above.
jpeg-encoder = "0.7"
bytemuck = { version = "1", features = ["derive"] }
# Lens correction profiles. A pure-Rust port of Lensfun rather than a binding
# to the C library, for the same cross-compilation reason as the TLS and
# SQLite choices above: liblensfun would be a third C dependency to satisfy
# under the Android NDK.
#
# The database ships *inside* the crate — 56 XML files, gzipped at build time
# and decompressed on first lookup. That matters beyond convenience: Android
# gives us no filesystem path (ARCH §6.9), so a database loaded from a
# system directory would have nowhere to live there.
#
# Licence: LGPL-3.0-or-later, which upgrades cleanly into our GPLv3 (D8).
# The upstream Lensfun *database* is CC-BY-SA and is redistributed by the
# crate; attribution belongs in the about screen.
#
# Caveat worth remembering: this is a third-party port at 0.7.0, not upstream
# Lensfun. Verified working against the bundled database (interpolation
# between calibration points, and an unknown lens returning empty rather than
# panicking), but the pipeline talks to it through its own profile types so
# swapping it out is not a pipeline change.
lensfun = "0.7"
# Neural inference for semantic segmentation (S15 arm B, D14).
#
# D13 framed this as a choice between `ort` (fast, best operator coverage, and
# a C++ dependency to cross-compile under the NDK) and a pure-Rust runtime
# (policy-compliant, unproven coverage). That framing turned out to be a false
# choice: `ort` 2.0's `alternative-backend` feature *disables the linking
# entirely* and lets a different engine supply the `OrtApi`, and `ort-tract` —
# same authors, MIT/Apache — supplies it from `tract`, which is pure Rust.
#
# So we get `ort`'s API with no C at all. `download-binaries` and `tls-native`
# are off with `default-features = false`, which is the point: nothing is
# fetched at build time and nothing is linked, so the Android cross-compile
# sees an ordinary Rust dependency graph. That is the same reasoning as rustls
# over aws-lc-rs and bundled SQLite, applied to inference — D13's largest
# tolerated exception turns out not to be needed.
#
# The trade is real and belongs on the record: tract is slower than the C++
# runtime and covers fewer operators. Both were measured rather than assumed
# before this landed — yolo26n-seg loads with **zero unsupported operators**
# and runs 640x640 in ~470 ms on the reference desktop's CPU. That is fine for
# a once-per-image precompute off the frame path (ARCH §6.1) and would not be
# fine for anything per-frame, which is a constraint on what may be built on
# top rather than on this choice.
#
# Pinned to an rc: `ort` 2.0 has been in rc for a long while and `ort-tract`
# exists only against it. Worth revisiting at 2.0 final.
ort = { version = "2.0.0-rc.13", default-features = false, features = ["alternative-backend", "ndarray", "std"] }
ort-tract = "0.4"
# Not a free choice: it is the version `ort` exposes its tensors through, so
# two semver-incompatible ndarrays would not typecheck across the boundary —
# the same coupling wgpu has with Slint above.
ndarray = "0.17"
[profile.dev]
# Dependencies optimised even in dev builds — wgpu and image decoding are
# unusably slow otherwise, and they rarely need debugging.
opt-level = 0
[profile.dev.package."*"]
opt-level = 2
[profile.release]
lto = "thin"
codegen-units = 1
-49
View File
@@ -1,49 +0,0 @@
# DarkRoom
A cross-platform, non-destructive RAW photo editor for Linux and Android.
**Status:** early. v0.1 is a remote library viewer — see
[docs/milestone-v0.1.md](docs/milestone-v0.1.md).
## Documentation
| Document | Contents |
|---|---|
| [requirements.md](docs/requirements.md) | What the software must do — 122 numbered requirements |
| [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync |
| [milestone-v0.1.md](docs/milestone-v0.1.md) | The first buildable milestone |
| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measures |
## Building
Desktop:
```bash
cargo run -p darkroom-desktop
```
Android (containerised toolchain, see [docker/android](docker/android/README.md)):
```bash
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
```
## Current state
Working: workspace, GPU context and compute pass, adaptive Slint shell, Android
cross-compilation of the core crates.
**Not yet working:** the zero-copy display path. The build currently uploads
frames through the CPU, which is exactly what
[ARCH §6.1](docs/architecture.md) forbids — measured at 96% of frame time at
4K. Replacing it is spike S1, the project's highest priority.
```
cargo run -p dr-gpu --example bench --features readback
```
reproduces that measurement.
## Licence
GPL-3.0-or-later.
-34
View File
@@ -1,34 +0,0 @@
[package]
name = "darkroom-android"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
# A cdylib, not a bin: Android loads the app as a shared library and calls
# `android_main` through android-activity's glue. Nothing execs a binary, so
# there is no `main` to provide.
[lib]
name = "darkroom"
crate-type = ["cdylib"]
[dependencies]
# No backend feature to select: dr-ui picks its Slint backend from the target,
# so building for aarch64-linux-android gets android-activity automatically.
dr-ui.workspace = true
# For `session::set_data_dir`: only the platform entry point knows where Android
# lets this app keep files, and it must be set before any store is opened.
dr-sync-nextcloud.workspace = true
# Directly, not just through dr-ui: `android_main` takes an `AndroidApp` and
# calls `slint::android::init`, both of which come from this crate. The backend
# feature comes from dr-ui's target-specific dependency.
slint.workspace = true
log.workspace = true
android_logger = "0.15"
[features]
# Mirrors darkroom-desktop: the CPU readback path is gone since S1 landed
# zero-copy. It mattered more here than on desktop — the same wrong path with
# far less memory bandwidth to absorb it (ARCH §6.1) — but it is untested on a
# device, since S1 was verified on desktop only.
default = []
@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
DarkRoom Android manifest.
Deliberately minimal: this packages the viewer for on-device testing (spike
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
here is a distribution manifest yet. Only network access is declared: file
access needs no manifest permission because the library grid reads through
SAF, which grants per-tree at runtime (ARCH §6.9).
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="paris.tourolle.darkroom">
<!-- Everything the app does with a server needs this: Login Flow v2, the
WebDAV listing, thumbnail and image fetches. Without it Android refuses
socket creation outright, and the failure is invisible — no panic to
catch, no log line, just a worker thread that stops. Storage is the
separate case that genuinely needs no permission here, because SAF
grants per-tree at runtime (ARCH §6.9). -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
work on unmetered-and-charging, which means knowing the network type. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
dependable (NFR-COMPAT-1). Marked required so an unsupported device
fails at install rather than at first frame. -->
<uses-feature
android:name="android.hardware.vulkan.version"
android:version="0x00401000"
android:required="true" />
<!-- One name covers both icon generations, which is the point of the
`anydpi-v26` qualifier: @mipmap/ic_launcher resolves to the adaptive
icon at res/mipmap-anydpi-v26/ic_launcher.xml on API 26 and up, and to
the density-matched ic_launcher.png below that. Since minSdk is 28 the
PNGs are only ever reached by tooling, but they cost little and aapt2
wants a real drawable behind the name. `roundIcon` is deliberately
absent: it predates adaptive icons and a launcher that reads it would
also be one that ignores the XML, which no device here is.
The adaptive icon has three layers rather than two. The third,
monochrome, is what lets Android 13's themed-icon setting recolour it
instead of dropping the app out of the themed set. -->
<application
android:label="DarkRoom"
android:icon="@mipmap/ic_launcher"
android:hasCode="true"
android:allowBackup="false"
android:supportsRtl="true">
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
glue loads libdarkroom.so and calls android_main. `android.app.lib_name`
is how it learns which library to load, and must match [lib].name. -->
<activity
android:name="android.app.NativeActivity"
android:exported="true"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout|density|uiMode"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="android.app.lib_name"
android:value="darkroom" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 518 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1005 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

-141
View File
@@ -1,141 +0,0 @@
//! DarkRoom Android entry point.
//!
//! The counterpart to `darkroom-desktop`'s `main`, with two differences that
//! come from the platform rather than from choice:
//!
//! * There are no command-line paths. Android's SAF hands out document URIs,
//! not filesystem paths (ARCH §6.9), so the viewer opens with an empty
//! browsing list and the library grid is the only way in.
//! * Logging goes to logcat. `env_logger` writes to stderr, which Android
//! discards.
// `slint::android` exists only when compiling for Android, so the whole entry
// point is gated on the target rather than on a feature. Without this the
// crate is still a workspace member on the host, and `cargo test --workspace`
// fails to compile it — a build break that only ever appears off-device.
#[cfg(target_os = "android")]
/// TRACES: M-13 | M-14
/// Android application entry point, called by android-activity's glue.
#[no_mangle]
fn android_main(app: slint::android::AndroidApp) {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag("DarkRoom"),
);
// Panics go to stderr, and Android discards stderr. Without this hook a
// worker thread that panics is invisible: the process survives, the
// channel it was writing to closes, and the UI reports only that
// something "failed unexpectedly" with no way to find out what.
std::panic::set_hook(Box::new(|info| {
log::error!("panic: {info}");
}));
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
// Before anything opens a store: Android has no $HOME and no XDG
// directories, so the default guess resolves to a path the app cannot
// write. Nothing failed loudly — the session list went to a doomed path, so
// the account survived only as long as the process and backgrounding the app
// lost the sign-in. `internal_data_path` is the app's private directory
// (ARCH §6.9).
match app.internal_data_path() {
Some(dir) => {
log::info!("data dir: {}", dir.display());
dr_sync_nextcloud::session::set_data_dir(dir);
}
None => log::error!("no internal data path; settings will not persist"),
}
// After the data dir and before anything asks whether a model is present.
install_bundled_face_models(&app);
if let Err(e) = slint::android::init(app) {
log::error!("Slint Android backend failed to initialise: {e}");
return;
}
// Empty rather than the desktop's argv: see the module note above.
//
// Returning from `android_main` ends the process, so a failure here is
// logged rather than propagated — there is no shell to show `Err` to.
if let Err(e) = dr_ui::run(Vec::new()) {
log::error!("DarkRoom exited with error: {e:#}");
}
}
/// Unpack the face models the APK carries, if it carries any.
///
/// # Why Android needs this and no other platform does
///
/// The weights are not a build input and are not in the repository — the
/// InsightFace grant is research-only and incompatible with this project's
/// licence (docs/faces.md §2), so a desktop user fetches them, runs
/// `tools/fix-face-model-shapes.sh` over them, and drops the result into
/// `~/.local/share/darkroom/models/`. **That gesture does not exist on
/// Android.** `internal_data_path` is app-private, `run-as` needs a debuggable
/// build, and there is no picker and no fetch in the app, so a phone had no way
/// to acquire a model at all and face indexing reported itself permanently off.
///
/// So a locally-built APK may carry the pair in `assets/models/`, which
/// `assemble-apk.sh` includes when the tree has them and omits when it does
/// not. Nothing changes about what the repository holds or what a published
/// build could redistribute; this only gives a self-built APK the same route a
/// desktop build has always had.
///
/// Absent assets are the ordinary case, not an error — the same quiet "no model
/// installed" state a fresh desktop install is in.
#[cfg(target_os = "android")]
fn install_bundled_face_models(app: &slint::android::AndroidApp) {
use std::io::Read;
// The **shape-fixed** names, matching what `library::face_models` looks
// for: tract cannot parse either InsightFace graph with its dynamic input
// dimension, so what ships here has already been through
// `tools/fix-face-model-shapes.sh`.
const BUNDLED: [(&std::ffi::CStr, &str); 2] = [
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
(c"models/arcface_mbf_b1.onnx", "arcface_mbf_b1.onnx"),
];
let dir = dr_ui::shared_face_models_dir();
let assets = app.asset_manager();
for (asset_path, name) in BUNDLED {
let dest = dir.join(name);
// Already unpacked. Not re-read on every launch: this is 15 MB through
// a decompressor on the startup path, and the file does not change
// without the APK changing, at which point the install wiped it anyway.
if dest.is_file() {
continue;
}
let Some(mut asset) = assets.open(asset_path) else {
log::info!("no bundled {name} in this APK; face indexing stays off");
continue;
};
let mut bytes = Vec::new();
if let Err(e) = asset.read_to_end(&mut bytes) {
log::error!("bundled {name} could not be read: {e}");
continue;
}
if let Err(e) = std::fs::create_dir_all(&dir) {
log::error!("cannot create {}: {e}", dir.display());
return;
}
// Written under a temporary name and renamed, because
// `library::face_models` decides face indexing is available on
// `is_file()` alone. A truncated write — the process backgrounded and
// killed mid-copy — would otherwise leave a file that passes that test
// and fails inside tract, reported to the user as a broken model rather
// than a missing one.
let part = dir.join(format!("{name}.part"));
match std::fs::write(&part, &bytes).and_then(|()| std::fs::rename(&part, &dest)) {
Ok(()) => log::info!("installed bundled {name} ({} bytes)", bytes.len()),
Err(e) => {
log::error!("cannot install {name}: {e}");
let _ = std::fs::remove_file(&part);
}
}
}
}
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "darkroom-desktop"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-ui.workspace = true
anyhow.workspace = true
env_logger.workspace = true
log.workspace = true
[features]
default = []
-28
View File
@@ -1,28 +0,0 @@
//! DarkRoom desktop entry point.
//!
//! darkroom-desktop <file-or-directory>...
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn",
))
.init();
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
let paths: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
if paths.is_empty() {
eprintln!("usage: darkroom-desktop <file-or-directory>...");
}
dr_ui::run(paths)?;
// Skip Rust's normal static/thread-local teardown on the way out: a
// background zbus/keyring connection opened by dr_ui::launch_ui can
// still be alive here, and unwinding through it races its async-io
// reactor thread, panicking with "thread local ... during or after
// destruction" when the window is closed.
std::process::exit(0);
}
-34
View File
@@ -1,34 +0,0 @@
[package]
name = "dr-catalog"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-types.workspace = true
# The face subsystem's arithmetic — `Calibration` in particular, so the sigmoid
# that turns a cosine into a probability has exactly one definition. Default
# features are off, so this brings in no ONNX runtime and no weights: only the
# model-free half compiles here.
dr-face.workspace = true
# For `SHARD_MAX_BYTES` alone. The face shards are capped at the same 25 MB the
# thumbnail shards are, and sharing the constant is what keeps them from
# drifting apart — the cap is a statement about sync cost, not about thumbnails.
dr-thumbs.workspace = true
# The `Storage` trait, and nothing else from it. A scan has to read a real
# directory, and this is how `core/` reaches the platform without a
# `#[cfg(target_os)]` of its own (ARCH §4.1: calls go downward).
dr-plat.workspace = true
rusqlite.workspace = true
thiserror.workspace = true
log.workspace = true
# `collections.selector_json` — the stored form of a smart collection's
# selector. The column predates this dependency; nothing else here is JSON.
serde_json.workspace = true
# For the `scan_local` example only, which is a diagnostic tool: what it is
# diagnosing is often a folder the scan warned about and skipped, and those
# warnings go to `log`.
[dev-dependencies]
env_logger.workspace = true
-111
View File
@@ -1,111 +0,0 @@
//! Scan a real folder on this machine into a catalog, and say what it cost.
//!
//! cargo run -p dr-catalog --example scan_local -- ~/Pictures [catalog.sqlite]
//!
//! **Run it twice.** The first run is a full walk; the second is the one worth
//! watching, because on an unchanged library it should list no directories at
//! all and take a fraction of the time. That difference is NFR-P1, and a
//! synthetic test cannot show it at the scale a real library does — 121,785
//! files in a synced folder is a different question from twenty in a temporary
//! directory.
//!
//! Writes only to the catalog file, which defaults to a fixed path in the
//! system temporary directory so a second run has something to compare
//! against. Nothing in the scanned folder is touched.
use std::path::PathBuf;
use dr_catalog::walk::{ensure_root, scan_root, RootKind};
use dr_catalog::Catalog;
use dr_plat::LocalStorage;
use dr_types::FormatFilter;
fn main() {
env_logger::init();
let mut args = std::env::args().skip(1);
let Some(dir) = args.next().map(PathBuf::from) else {
eprintln!("usage: scan_local <directory> [catalog.sqlite]");
std::process::exit(2);
};
let catalog_path = args
.next()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-scan-local.sqlite"));
let catalog = match Catalog::open(&catalog_path) {
Ok(c) => c,
Err(e) => {
eprintln!("cannot open {}: {e}", catalog_path.display());
std::process::exit(1);
}
};
println!("catalog: {}", catalog_path.display());
// The label is how the grant is spelled, and the only place a path is
// written down. Everything after this line addresses files by `RootId`.
let label = dir.display().to_string();
let root = match ensure_root(catalog.connection(), RootKind::Local, &label) {
Ok(r) => r,
Err(e) => {
eprintln!("cannot record the root: {e}");
std::process::exit(1);
}
};
let storage = LocalStorage::with_root(root, &dir);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let started = std::time::Instant::now();
let report = match scan_root(
catalog.connection(),
&storage,
root,
&FormatFilter::all(),
now,
|| false,
|p| {
// One line per hundred directories: enough to show it is alive on a
// large library, not enough to be the thing that slows it down.
let visited = p.directories_listed + p.directories_pruned;
if visited % 100 == 0 {
println!(
" … {visited} directories ({} pruned), {} images",
p.directories_pruned, p.images_found
);
}
},
) {
Ok(r) => r,
Err(e) => {
eprintln!("scan failed: {e}");
std::process::exit(1);
}
};
let elapsed = started.elapsed();
let total: i64 = catalog
.connection()
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
.unwrap_or(-1);
println!("\noutcome: {:?}", report.outcome);
println!(
"directories: {} listed, {} pruned",
report.progress.directories_listed, report.progress.directories_pruned
);
println!(
"images: {} new, {} changed, {} unchanged, {} removed",
report.inserted, report.updated, report.unchanged, report.images_removed
);
println!("folders: {} removed", report.folders_removed);
println!("catalogued: {total} in total");
println!("took: {:.2?}", elapsed);
if report.progress.directories_listed == 0 && report.progress.directories_pruned > 0 {
println!("\nnothing had changed: every folder was proven unchanged by one probe");
}
}
-111
View File
@@ -1,111 +0,0 @@
//! What a second device ends up with after adopting this library.
//!
//! Stands up an empty catalog, gives it the images the real one has, adopts the
//! face shards into it exactly as a sync would, merges the real catalog in as a
//! remote — and then counts. The point is to answer "why does the tablet show
//! fewer faces for this person" without needing the tablet.
//!
//! cargo run -p dr-catalog --example sync_probe -- CATALOG.sqlite FACES_DIR
use std::path::PathBuf;
use dr_catalog::face_shard::{self, FaceShardStore};
use dr_catalog::Catalog;
const MODEL: &str = "w600k_mbf";
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() < 2 {
eprintln!("usage: sync_probe CATALOG.sqlite FACES_DIR");
std::process::exit(2);
}
let source = PathBuf::from(&args[0]);
let faces_dir = PathBuf::from(&args[1]);
let dir = std::env::temp_dir().join(format!("dr-sync-probe-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("catalog.sqlite");
let far = Catalog::open(&dest).expect("fresh catalog");
let conn = far.connection();
// The images a scan would have found. Nothing else: no faces, no people.
conn.execute(
"ATTACH DATABASE ?1 AS src",
[source.to_string_lossy().as_ref()],
)
.unwrap();
// Foreign keys off for the copy: `images` carries self-references
// (`shadowed_by`) that are only consistent once every row is in, and this
// is a bulk clone rather than an edit.
conn.execute_batch(
"PRAGMA foreign_keys = OFF;
INSERT INTO roots SELECT * FROM src.roots;
INSERT INTO images SELECT * FROM src.images;
INSERT INTO remote SELECT * FROM src.remote;
PRAGMA foreign_keys = ON;",
)
.unwrap();
let images: i64 = conn
.query_row("SELECT COUNT(*) FROM images", [], |r| r.get(0))
.unwrap();
conn.execute_batch("DETACH DATABASE src").unwrap();
println!("second device starts with {images} image(s), no faces");
// Adopt every shard, which is what a completed face sync leaves behind.
let store = FaceShardStore::open(&faces_dir).expect("shard store");
let adopted = face_shard::import_from_shards(conn, &store, MODEL).expect("import");
let faces: i64 = conn
.query_row("SELECT COUNT(*) FROM faces", [], |r| r.get(0))
.unwrap();
println!("adopted {adopted} image(s) from the shards -> {faces} face(s)");
// Then the catalog merge, which is where people and their judgements come.
let report = dr_catalog::sync::merge_remote(conn, &source).expect("merge");
println!(
"merge: {} people in, {} updated, {} kept local, {} face(s) assigned, \
{} kept local, {} rejection(s)",
report.people_inserted,
report.people_updated,
report.people_kept_local,
report.faces_assigned,
report.faces_kept_local,
report.faces_rejected,
);
// Per person, against what the source holds.
conn.execute(
"ATTACH DATABASE ?1 AS src",
[source.to_string_lossy().as_ref()],
)
.unwrap();
let mut q = conn
.prepare(
"SELECT p.name,
(SELECT COUNT(*) FROM src.face_person sfp
JOIN src.people sp ON sp.id = sfp.person_id
WHERE sp.uuid = p.uuid) AS there,
(SELECT COUNT(*) FROM face_person fp WHERE fp.person_id = p.id) AS here
FROM people p
WHERE p.name != ''
ORDER BY there DESC LIMIT 12",
)
.unwrap();
println!("\n{:<24} {:>8} {:>8}", "person", "source", "here");
let rows = q
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
))
})
.unwrap();
for row in rows.flatten() {
println!("{:<24} {:>8} {:>8}", row.0, row.1, row.2);
}
println!("\nprobe catalog left at {}", dest.display());
}
-986
View File
@@ -1,986 +0,0 @@
//! TRACES: FR-NC-6a | FR-CAT-9 | NFR-RES-4
//! Which originals are kept on this device, and which may be evicted.
//!
//! # Two populations, one table
//!
//! An original ends up here two ways, and conflating them produces exactly the
//! failure the whole feature exists to prevent.
//!
//! **Pinned** originals were asked for. A user pins a collection before a trip
//! and expects those photographs to be there when there is no connection —
//! that is a promise, so pinned rows are never evicted and never counted
//! against the budget. A cap that could silently delete a pinned trip would
//! make pinning worthless, because the user could not rely on it without
//! checking.
//!
//! **Passively cached** originals are a side effect of working: opening an
//! image in develop downloads it, so keeping the bytes costs nothing extra and
//! saves the whole transfer next time. This population is bounded by
//! [`Budget`] and evicted least-recently-used, because it grows without limit
//! otherwise — a day of culling would fill a disk.
//!
//! The two budgets are separate rather than shared. Sharing them means a large
//! pin starves the passive cache, or worse, that browsing evicts a pin.
//!
//! # What this module does and does not own
//!
//! It owns the *bookkeeping*: which images are held, at what tier, how large,
//! when last used, and which are pinned. The bytes are files under a cache
//! directory, and [`store`](Cache::store) writes them; but deciding to
//! download something is the caller's business, because that needs a network
//! and this crate has none.
//!
//! # Why `tier_actual` is the truth
//!
//! `tier_desired` is what a pin asks for; `tier_actual` is what is on disk.
//! Only the second answers "can this be opened right now", which is the
//! question offline mode asks (FR-CAT-9). A pinned image whose download has
//! not run yet is precisely the one that would fail, so it must not report as
//! available.
use std::path::{Path, PathBuf};
use dr_types::{ImageId, Tier};
use rusqlite::{Connection, OptionalExtension as _};
use crate::error::CatalogError;
/// Default ceiling for passively cached originals.
///
/// 1 GB holds roughly 30 full-frame RAWs — a working session's worth, which is
/// what this cache is for. It is deliberately modest: the passive cache is a
/// convenience that should not quietly consume a disk, and a user who wants
/// more kept is better served by pinning, which says so explicitly and is not
/// subject to eviction at all.
pub const DEFAULT_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
/// How much disk the passive cache may use.
///
/// A newtype rather than a bare `u64` so a byte count cannot be passed where a
/// budget belongs, and to give the "unlimited" case a name — some users have a
/// large disk and would rather never re-download.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Budget(Option<u64>);
impl Default for Budget {
fn default() -> Self {
Self::bytes(DEFAULT_BUDGET_BYTES)
}
}
impl Budget {
pub fn bytes(n: u64) -> Self {
Self(Some(n))
}
/// No ceiling: nothing is ever evicted for space.
pub fn unlimited() -> Self {
Self(None)
}
pub fn limit(self) -> Option<u64> {
self.0
}
/// How much must be freed to fit `used` within this budget.
fn overage(self, used: u64) -> u64 {
self.0.map_or(0, |cap| used.saturating_sub(cap))
}
}
/// What is held for one image.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub image: ImageId,
/// What is actually on disk.
pub tier: Tier,
/// What a pin has asked for, which may be ahead of `tier`.
pub desired: Tier,
pub bytes: u64,
/// Unix seconds, or `None` if never read back since being stored.
pub last_used: Option<i64>,
pub pinned: bool,
/// Path relative to the cache directory.
pub path: Option<String>,
}
/// How the cache is currently filled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Usage {
/// Bytes held by pinned originals. Not subject to the budget.
pub pinned_bytes: u64,
/// Bytes held by passively cached originals. What the budget bounds.
pub passive_bytes: u64,
pub pinned_count: usize,
pub passive_count: usize,
}
impl Usage {
pub fn total_bytes(self) -> u64 {
self.pinned_bytes + self.passive_bytes
}
}
/// The on-disk cache of originals, rooted at a directory.
pub struct Cache {
dir: PathBuf,
budget: Budget,
}
impl Cache {
/// Open a cache rooted at `dir`, creating it if needed.
pub fn open(dir: &Path, budget: Budget) -> Result<Self, CatalogError> {
std::fs::create_dir_all(dir)
.map_err(|e| CatalogError::Io(format!("creating {}: {e}", dir.display())))?;
Ok(Self {
dir: dir.to_path_buf(),
budget,
})
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn budget(&self) -> Budget {
self.budget
}
/// Absolute path for a cached original.
///
/// Named by image id rather than by the remote filename: two folders on
/// the server may hold `IMG_0001.CR2`, and a flat cache keyed on the name
/// would have them overwrite each other. The extension is preserved so the
/// decoder's format probe sees what it expects.
fn relative_path(image: ImageId, source_ref: &str) -> String {
let ext = source_ref
.rsplit_once('.')
.map(|(_, e)| e.to_ascii_lowercase())
.filter(|e| {
!e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric())
})
.unwrap_or_else(|| "bin".to_string());
format!("{}.{ext}", image.0)
}
/// Store an original's bytes and record it.
///
/// `pinned` says which population this belongs to. Storing an image that
/// is already present updates it rather than duplicating — the same
/// photograph opened twice is one cache entry, and the second store simply
/// refreshes the bytes and the timestamp.
///
/// Does **not** evict. The caller runs [`enforce`](Self::enforce) once it
/// has finished storing, so a batch of downloads is trimmed once rather
/// than after every file.
pub fn store(
&self,
conn: &Connection,
image: ImageId,
source_ref: &str,
bytes: &[u8],
pinned: bool,
now: i64,
) -> Result<(), CatalogError> {
let rel = Self::relative_path(image, source_ref);
let abs = self.dir.join(&rel);
// Written to a temporary and renamed, so a crash or a dropped
// connection mid-write cannot leave a truncated file that the catalog
// records as a complete original — which would then fail to decode
// with no indication that the *cache* was at fault rather than the
// photograph.
let tmp = abs.with_extension("partial");
std::fs::write(&tmp, bytes)
.map_err(|e| CatalogError::Io(format!("writing {}: {e}", tmp.display())))?;
std::fs::rename(&tmp, &abs)
.map_err(|e| CatalogError::Io(format!("renaming {}: {e}", abs.display())))?;
// `pinned` is OR-ed rather than assigned: an image that was already
// pinned must not be demoted to evictable because it happened to be
// opened in develop, which is a passive store.
conn.execute(
"INSERT INTO image_cache
(image_id, tier_actual, tier_desired, bytes, last_used, pinned, path)
VALUES (?1, ?2, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(image_id) DO UPDATE SET
tier_actual = ?2,
tier_desired = max(tier_desired, ?2),
bytes = ?3,
last_used = ?4,
pinned = max(pinned, ?5),
path = ?6",
rusqlite::params![
image.0 as i64,
Tier::Original.stored(),
bytes.len() as i64,
now,
i64::from(pinned),
rel,
],
)?;
Ok(())
}
/// Read a cached original back, if it is here.
///
/// Touches `last_used`, which is what makes the eviction order reflect
/// actual use rather than download order. A read that finds the row but
/// not the file repairs the catalog rather than returning bytes it does
/// not have — the two can diverge if a user clears the directory by hand.
pub fn load(
&self,
conn: &Connection,
image: ImageId,
now: i64,
) -> Result<Option<Vec<u8>>, CatalogError> {
let path: Option<String> = conn
.query_row(
"SELECT path FROM image_cache
WHERE image_id = ?1 AND tier_actual >= ?2",
rusqlite::params![image.0 as i64, Tier::Original.stored()],
|r| r.get(0),
)
.ok()
.flatten();
let Some(rel) = path else { return Ok(None) };
let abs = self.dir.join(&rel);
match std::fs::read(&abs) {
Ok(bytes) => {
conn.execute(
"UPDATE image_cache SET last_used = ?2 WHERE image_id = ?1",
rusqlite::params![image.0 as i64, now],
)?;
Ok(Some(bytes))
}
Err(e) => {
// The file is gone but the row says it is here. Believing the
// row would report the image as locally available for ever
// while every open failed.
log::debug!(
"cached original {} missing, forgetting it: {e}",
abs.display()
);
self.forget(conn, &[image])?;
Ok(None)
}
}
}
/// Whether an image's original is on this device.
pub fn holds_original(&self, conn: &Connection, image: ImageId) -> bool {
conn.query_row(
"SELECT 1 FROM image_cache
WHERE image_id = ?1 AND tier_actual >= ?2",
rusqlite::params![image.0 as i64, Tier::Original.stored()],
|_| Ok(()),
)
.is_ok()
}
/// Mark images as pinned, so they are kept regardless of the budget.
///
/// Pinning records the *intent* — `tier_desired` — without downloading
/// anything: the download needs a network, which belongs to the caller.
/// An image already cached passively becomes pinned in place, keeping its
/// bytes rather than re-fetching them.
pub fn pin(&self, conn: &Connection, images: &[ImageId]) -> Result<usize, CatalogError> {
self.set_pinned(conn, images, true)
}
/// Release a pin, returning those images to the evictable population.
///
/// The bytes stay until eviction needs the room. Deleting immediately
/// would make unpinning destructive, when it is meant only to withdraw a
/// guarantee.
pub fn unpin(&self, conn: &Connection, images: &[ImageId]) -> Result<usize, CatalogError> {
self.set_pinned(conn, images, false)
}
/// Release the pin *and* delete the bytes it was holding.
///
/// The destructive half of the pair [`unpin`](Self::unpin) deliberately is
/// not. Unpinning answers "stop promising"; this answers "give me the disk
/// back", which is the question actually being asked when a trip is over
/// and the device is full. Leaving those gigabytes to sit until some future
/// eviction happens to want the room is not an answer to it.
///
/// Nothing is lost that cannot be fetched again: the original lives on the
/// server, and the catalog row, the ratings and the edit graph are all
/// untouched here — they are authoritative and small (FR-NC-6b).
///
/// Returns how many images were released and how many bytes that freed.
/// A file that has already vanished frees nothing and is still counted as
/// released, because the row describing it goes either way.
pub fn release(
&self,
conn: &Connection,
images: &[ImageId],
) -> Result<(usize, u64), CatalogError> {
if images.is_empty() {
return Ok((0, 0));
}
// Read the paths before the rows are rewritten: `forget` clears `path`,
// and a file whose name has been forgotten cannot be deleted.
let mut held = Vec::new();
{
let mut stmt = conn.prepare(
"SELECT bytes, path FROM image_cache
WHERE image_id = ?1 AND path IS NOT NULL",
)?;
for image in images {
if let Some(row) = stmt
.query_row(rusqlite::params![image.0 as i64], |r| {
Ok((r.get::<_, i64>(0)? as u64, r.get::<_, String>(1)?))
})
.optional()?
{
held.push(row);
}
}
}
let mut freed = 0u64;
for (bytes, rel) in &held {
let abs = self.dir.join(rel);
match std::fs::remove_file(&abs) {
Ok(()) => freed += bytes,
// Already gone is the ordinary case after a crash mid-write,
// not a failure: the row still has to go, or the cache accounts
// for space nothing occupies.
Err(e) => log::debug!("releasing {}: {e}", abs.display()),
}
}
// Unpin first, then forget. The other order would leave a pinned row
// claiming an original it no longer has, which `pending_pins` would
// then dutifully download again — the exact opposite of what was asked.
self.set_pinned(conn, images, false)?;
self.forget(conn, images)?;
Ok((images.len(), freed))
}
fn set_pinned(
&self,
conn: &Connection,
images: &[ImageId],
pinned: bool,
) -> Result<usize, CatalogError> {
if images.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
let mut n = 0;
for image in images {
n += tx.execute(
"INSERT INTO image_cache (image_id, tier_actual, tier_desired, bytes, pinned)
VALUES (?1, ?2, ?3, 0, ?4)
ON CONFLICT(image_id) DO UPDATE SET
pinned = ?4,
-- A pin raises the target; releasing one lowers it back to
-- whatever is actually held, so a released image is not
-- left permanently claiming it wants an original.
tier_desired = CASE WHEN ?4 = 1 THEN ?3 ELSE tier_actual END",
rusqlite::params![
image.0 as i64,
Tier::Metadata.stored(),
Tier::Original.stored(),
i64::from(pinned),
],
)?;
}
tx.commit()?;
Ok(n)
}
/// Images a pin wants but which are not yet downloaded.
///
/// The work list for whatever fetches originals. Ordered by id for a
/// stable, resumable sequence rather than an arbitrary one.
pub fn pending_pins(&self, conn: &Connection) -> Result<Vec<ImageId>, CatalogError> {
let mut stmt = conn.prepare(
"SELECT image_id FROM image_cache
WHERE pinned = 1 AND tier_actual < tier_desired
ORDER BY image_id",
)?;
let rows = stmt
.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// How full the cache is, split by population.
///
/// Counts only rows that actually hold an original: a pin that has not
/// downloaded yet occupies no disk, and counting its intent would evict
/// real files to make room for bytes that do not exist.
pub fn usage(&self, conn: &Connection) -> Result<Usage, CatalogError> {
let mut stmt = conn.prepare(
"SELECT pinned, count(*), coalesce(sum(bytes), 0)
FROM image_cache
WHERE tier_actual >= ?1
GROUP BY pinned",
)?;
let mut usage = Usage::default();
let rows = stmt.query_map(rusqlite::params![Tier::Original.stored()], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
))
})?;
for row in rows {
let (pinned, count, bytes) = row?;
if pinned == 1 {
usage.pinned_count = count as usize;
usage.pinned_bytes = bytes as u64;
} else {
usage.passive_count = count as usize;
usage.passive_bytes = bytes as u64;
}
}
Ok(usage)
}
/// Evict least-recently-used passive entries until the budget is met.
///
/// Returns how many images were dropped. Pinned entries are never
/// candidates, which is the guarantee that makes a pin worth making.
///
/// A row whose file has already vanished is still dropped from the
/// catalog: it frees no disk, but leaving it would let a phantom entry
/// hold the cache permanently over budget and evict real files in its
/// place.
pub fn enforce(&self, conn: &Connection) -> Result<usize, CatalogError> {
let usage = self.usage(conn)?;
let mut over = self.budget.overage(usage.passive_bytes);
if over == 0 {
return Ok(0);
}
// Oldest first. `last_used IS NULL` sorts first deliberately: a row
// that has never been read back is the least valuable thing here.
let mut stmt = conn.prepare(
"SELECT image_id, bytes, path FROM image_cache
WHERE pinned = 0 AND tier_actual >= ?1
ORDER BY last_used IS NULL DESC, last_used ASC",
)?;
let candidates = stmt
.query_map(rusqlite::params![Tier::Original.stored()], |r| {
Ok((
ImageId(r.get::<_, i64>(0)? as u64),
r.get::<_, i64>(1)? as u64,
r.get::<_, Option<String>>(2)?,
))
})?
.collect::<Result<Vec<_>, _>>()?;
let mut evicted = Vec::new();
for (image, bytes, path) in candidates {
if over == 0 {
break;
}
if let Some(rel) = path {
let abs = self.dir.join(rel);
if let Err(e) = std::fs::remove_file(&abs) {
// Already gone is the common case and not a failure; the
// row still has to go, or it accounts for space nothing
// occupies.
log::debug!("evicting {}: {e}", abs.display());
}
}
over = over.saturating_sub(bytes);
evicted.push(image);
}
let n = evicted.len();
self.forget(conn, &evicted)?;
Ok(n)
}
/// Drop cache rows, without touching files.
///
/// The row is reduced to `Metadata` rather than deleted, so a pin recorded
/// against it survives: unpinning is the only thing that should clear a
/// pin, and eviction of the bytes is not unpinning.
fn forget(&self, conn: &Connection, images: &[ImageId]) -> Result<(), CatalogError> {
if images.is_empty() {
return Ok(());
}
let tx = conn.unchecked_transaction()?;
for image in images {
tx.execute(
"UPDATE image_cache
SET tier_actual = ?2, bytes = 0, path = NULL
WHERE image_id = ?1",
rusqlite::params![image.0 as i64, Tier::Metadata.stored()],
)?;
}
tx.commit()?;
Ok(())
}
/// Everything currently held, newest use first. For a cache management view.
pub fn entries(&self, conn: &Connection) -> Result<Vec<Entry>, CatalogError> {
let mut stmt = conn.prepare(
"SELECT image_id, tier_actual, tier_desired, bytes, last_used, pinned, path
FROM image_cache
WHERE tier_actual >= ?1
ORDER BY last_used IS NULL, last_used DESC",
)?;
let rows = stmt
.query_map(rusqlite::params![Tier::Original.stored()], |r| {
Ok(Entry {
image: ImageId(r.get::<_, i64>(0)? as u64),
tier: Tier::from_stored(r.get(1)?),
desired: Tier::from_stored(r.get(2)?),
bytes: r.get::<_, i64>(3)? as u64,
last_used: r.get(4)?,
pinned: r.get::<_, i64>(5)? == 1,
path: r.get(6)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Catalog;
/// Distinguishes concurrent fixtures. The harness runs tests in parallel,
/// and a shared directory would have one test's eviction delete another's
/// files.
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// A scratch directory that is fresh for each call.
fn tempdir() -> PathBuf {
let base = std::env::temp_dir().join(format!(
"dr-cache-test-{}-{}",
std::process::id(),
SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
base
}
/// A catalog with `n` images, and a cache in a scratch directory.
fn fixture(n: usize) -> (Catalog, Cache, PathBuf, Vec<ImageId>) {
fixture_with(n, Budget::bytes(1000))
}
fn fixture_with(n: usize, budget: Budget) -> (Catalog, Cache, PathBuf, Vec<ImageId>) {
let catalog = Catalog::in_memory().unwrap();
catalog
.connection()
.execute(
"INSERT INTO roots (id, kind, label) VALUES (1, 'remote', 'test')",
[],
)
.unwrap();
let mut ids = Vec::new();
for i in 0..n {
catalog
.connection()
.execute(
"INSERT INTO images (root_id, source_ref, added_at)
VALUES (1, ?1, 0)",
rusqlite::params![format!("Photos/img{i:03}.CR2")],
)
.unwrap();
ids.push(ImageId(catalog.connection().last_insert_rowid() as u64));
}
let dir = tempdir();
let cache = Cache::open(&dir, budget).unwrap();
(catalog, cache, dir, ids)
}
#[test]
fn a_stored_original_reads_back() {
let (cat, cache, _dir, ids) = fixture(1);
cache
.store(cat.connection(), ids[0], "a.CR2", b"raw bytes", false, 10)
.unwrap();
assert!(cache.holds_original(cat.connection(), ids[0]));
assert_eq!(
cache.load(cat.connection(), ids[0], 20).unwrap().as_deref(),
Some(&b"raw bytes"[..])
);
}
#[test]
fn an_image_never_stored_is_absent() {
let (cat, cache, _dir, ids) = fixture(1);
assert!(!cache.holds_original(cat.connection(), ids[0]));
assert_eq!(cache.load(cat.connection(), ids[0], 0).unwrap(), None);
}
#[test]
fn eviction_takes_the_least_recently_used_first() {
let (cat, cache, _dir, ids) = fixture(3);
// 400 each against a 1000 budget: storing the third puts it 200 over.
let bytes = vec![0u8; 400];
cache
.store(cat.connection(), ids[0], "a.CR2", &bytes, false, 10)
.unwrap();
cache
.store(cat.connection(), ids[1], "b.CR2", &bytes, false, 20)
.unwrap();
cache
.store(cat.connection(), ids[2], "c.CR2", &bytes, false, 30)
.unwrap();
// Touch the oldest so it is no longer the least recently used.
cache.load(cat.connection(), ids[0], 40).unwrap();
assert_eq!(cache.enforce(cat.connection()).unwrap(), 1);
// ids[1] was the stalest by the time eviction ran.
assert!(!cache.holds_original(cat.connection(), ids[1]));
assert!(cache.holds_original(cat.connection(), ids[0]));
assert!(cache.holds_original(cat.connection(), ids[2]));
}
#[test]
fn a_pinned_original_is_never_evicted() {
// The guarantee the whole feature rests on: a pinned trip must still
// be there after a day of browsing pushes the cache over its cap.
let (cat, cache, _dir, ids) = fixture(3);
let bytes = vec![0u8; 800];
cache
.store(cat.connection(), ids[0], "a.CR2", &bytes, true, 10)
.unwrap();
cache
.store(cat.connection(), ids[1], "b.CR2", &bytes, false, 20)
.unwrap();
cache
.store(cat.connection(), ids[2], "c.CR2", &bytes, false, 30)
.unwrap();
cache.enforce(cat.connection()).unwrap();
assert!(
cache.holds_original(cat.connection(), ids[0]),
"the pinned original survives even though it is the oldest"
);
}
#[test]
fn releasing_a_pin_frees_the_disk_it_was_holding() {
// What "remove the local copies" has to mean. Unpinning alone leaves
// the bytes for a future eviction to notice, which is no answer at all
// to a device that is full now.
let (cat, cache, dir, ids) = fixture(2);
let bytes = vec![0u8; 700];
cache
.store(cat.connection(), ids[0], "a.CR2", &bytes, true, 10)
.unwrap();
cache
.store(cat.connection(), ids[1], "b.CR2", &bytes, true, 20)
.unwrap();
let (released, freed) = cache.release(cat.connection(), &ids).unwrap();
assert_eq!(released, 2);
assert_eq!(freed, 1400);
assert!(!cache.holds_original(cat.connection(), ids[0]));
assert_eq!(cache.usage(cat.connection()).unwrap().pinned_bytes, 0);
// The files themselves, not just the bookkeeping: a row cleared over a
// file still on disk is how a cache comes to hold gigabytes it does not
// know about.
let left: Vec<_> = walk_files(&dir);
assert!(left.is_empty(), "files remain on disk: {left:?}");
}
#[test]
fn a_released_pin_is_not_downloaded_all_over_again() {
// The failure mode of releasing in the wrong order: bytes deleted while
// the row still says an original is wanted, so the next pin fetch pulls
// the whole trip back down.
let (cat, cache, _dir, ids) = fixture(1);
cache
.store(cat.connection(), ids[0], "a.CR2", &[0u8; 100], true, 10)
.unwrap();
cache.release(cat.connection(), &ids).unwrap();
assert!(cache.pending_pins(cat.connection()).unwrap().is_empty());
}
/// Every file under `dir`, for asserting that a release left nothing.
fn walk_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.extend(walk_files(&path));
} else {
out.push(path);
}
}
out
}
#[test]
fn pinned_bytes_do_not_count_against_the_budget() {
// Otherwise a large pin starves the passive cache into evicting
// everything, and browsing becomes uncacheable the moment a trip is
// pinned.
let (cat, cache, _dir, ids) = fixture(2);
cache
.store(
cat.connection(),
ids[0],
"a.CR2",
&vec![0u8; 5000],
true,
10,
)
.unwrap();
cache
.store(
cat.connection(),
ids[1],
"b.CR2",
&vec![0u8; 500],
false,
20,
)
.unwrap();
// Pinned use is far past the 1000 budget, but the passive 500 fits.
assert_eq!(cache.enforce(cat.connection()).unwrap(), 0);
assert!(cache.holds_original(cat.connection(), ids[1]));
let usage = cache.usage(cat.connection()).unwrap();
assert_eq!(usage.pinned_bytes, 5000);
assert_eq!(usage.passive_bytes, 500);
}
#[test]
fn an_unlimited_budget_evicts_nothing() {
let (cat, cache, _dir, ids) = fixture_with(2, Budget::unlimited());
for (i, id) in ids.iter().enumerate() {
cache
.store(
cat.connection(),
*id,
"a.CR2",
&vec![0u8; 100_000],
false,
i as i64,
)
.unwrap();
}
assert_eq!(cache.enforce(cat.connection()).unwrap(), 0);
}
#[test]
fn pinning_records_intent_without_bytes() {
// A pin is not a download: it says what should be here, and something
// with a network makes it so.
let (cat, cache, _dir, ids) = fixture(2);
cache.pin(cat.connection(), &ids).unwrap();
assert!(!cache.holds_original(cat.connection(), ids[0]));
assert_eq!(cache.pending_pins(cat.connection()).unwrap(), ids);
assert_eq!(cache.usage(cat.connection()).unwrap().pinned_bytes, 0);
}
#[test]
fn a_downloaded_pin_stops_being_pending() {
let (cat, cache, _dir, ids) = fixture(2);
cache.pin(cat.connection(), &ids).unwrap();
cache
.store(cat.connection(), ids[0], "a.CR2", b"bytes", true, 10)
.unwrap();
assert_eq!(cache.pending_pins(cat.connection()).unwrap(), vec![ids[1]]);
}
#[test]
fn pinning_an_already_cached_image_keeps_its_bytes() {
// Re-downloading something already on disk because the user pinned it
// would be the most visible possible waste.
let (cat, cache, _dir, ids) = fixture(1);
cache
.store(cat.connection(), ids[0], "a.CR2", b"raw bytes", false, 10)
.unwrap();
cache.pin(cat.connection(), &ids).unwrap();
assert!(cache.pending_pins(cat.connection()).unwrap().is_empty());
assert_eq!(
cache.load(cat.connection(), ids[0], 20).unwrap().as_deref(),
Some(&b"raw bytes"[..])
);
assert_eq!(cache.usage(cat.connection()).unwrap().pinned_bytes, 9);
}
#[test]
fn opening_a_pinned_image_does_not_unpin_it() {
// The develop path stores passively. If that overwrote `pinned`, then
// simply *looking at* a pinned photograph would silently make it
// evictable — the pin would decay through use.
let (cat, cache, _dir, ids) = fixture(1);
cache.pin(cat.connection(), &ids).unwrap();
cache
.store(cat.connection(), ids[0], "a.CR2", b"bytes", false, 10)
.unwrap();
let entries = cache.entries(cat.connection()).unwrap();
assert!(entries[0].pinned, "still pinned after a passive store");
}
#[test]
fn unpinning_keeps_the_bytes_but_makes_them_evictable() {
let (cat, cache, _dir, ids) = fixture(2);
cache
.store(cat.connection(), ids[0], "a.CR2", &vec![0u8; 800], true, 10)
.unwrap();
cache.unpin(cat.connection(), &ids[0..1]).unwrap();
// Still here — unpinning withdraws a guarantee, it does not delete.
assert!(cache.holds_original(cat.connection(), ids[0]));
// But now it is a candidate.
cache
.store(
cat.connection(),
ids[1],
"b.CR2",
&vec![0u8; 800],
false,
20,
)
.unwrap();
assert_eq!(cache.enforce(cat.connection()).unwrap(), 1);
assert!(!cache.holds_original(cat.connection(), ids[0]));
}
#[test]
fn a_missing_file_is_forgotten_rather_than_reported_present() {
// A user clearing the cache directory by hand must not leave every
// image claiming to be local while every open fails.
let (cat, cache, dir, ids) = fixture_with(1, Budget::bytes(1000));
cache
.store(cat.connection(), ids[0], "a.CR2", b"bytes", false, 10)
.unwrap();
for entry in std::fs::read_dir(&dir).unwrap() {
std::fs::remove_file(entry.unwrap().path()).unwrap();
}
assert_eq!(cache.load(cat.connection(), ids[0], 20).unwrap(), None);
assert!(!cache.holds_original(cat.connection(), ids[0]));
}
#[test]
fn storing_the_same_image_twice_is_one_entry() {
let (cat, cache, _dir, ids) = fixture(1);
cache
.store(cat.connection(), ids[0], "a.CR2", b"first", false, 10)
.unwrap();
cache
.store(cat.connection(), ids[0], "a.CR2", b"second try", false, 20)
.unwrap();
let usage = cache.usage(cat.connection()).unwrap();
assert_eq!(usage.passive_count, 1);
assert_eq!(usage.passive_bytes, 10, "the later size, not the sum");
assert_eq!(
cache.load(cat.connection(), ids[0], 30).unwrap().as_deref(),
Some(&b"second try"[..])
);
}
#[test]
fn two_images_with_the_same_filename_do_not_collide() {
// `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different
// photographs; a cache keyed on the filename would serve one for the
// other, which is the worst failure this cache could have.
let (cat, cache, _dir, ids) = fixture(2);
cache
.store(
cat.connection(),
ids[0],
"Photos/IMG_0001.CR2",
b"first",
false,
10,
)
.unwrap();
cache
.store(
cat.connection(),
ids[1],
"Trips/IMG_0001.CR2",
b"second",
false,
20,
)
.unwrap();
assert_eq!(
cache.load(cat.connection(), ids[0], 30).unwrap().as_deref(),
Some(&b"first"[..])
);
assert_eq!(
cache.load(cat.connection(), ids[1], 30).unwrap().as_deref(),
Some(&b"second"[..])
);
}
#[test]
fn eviction_stops_once_the_budget_is_met() {
// Evicting everything on a small overage would throw away a working
// set to reclaim a few bytes.
let (cat, cache, _dir, ids) = fixture(3);
for (i, id) in ids.iter().enumerate() {
cache
.store(
cat.connection(),
*id,
"a.CR2",
&vec![0u8; 400],
false,
i as i64,
)
.unwrap();
}
// 1200 held against 1000: dropping one 400-byte entry suffices.
assert_eq!(cache.enforce(cat.connection()).unwrap(), 1);
assert_eq!(cache.usage(cat.connection()).unwrap().passive_count, 2);
}
#[test]
fn an_extensionless_source_still_gets_a_path() {
let (cat, cache, _dir, ids) = fixture(1);
cache
.store(
cat.connection(),
ids[0],
"Photos/no-extension",
b"bytes",
false,
10,
)
.unwrap();
assert_eq!(
cache.load(cat.connection(), ids[0], 20).unwrap().as_deref(),
Some(&b"bytes"[..])
);
}
}
File diff suppressed because it is too large Load Diff
-308
View File
@@ -1,308 +0,0 @@
//! TRACES: FR-CAT-11
//! Has this photograph been imported before?
//!
//! Two tiers, because neither alone is enough and they cost very different
//! amounts. The metadata tier — capture time, camera, size, the name the
//! camera gave it — is answerable from the catalog before a byte leaves the
//! card, which is what makes re-inserting an already-imported card cost a
//! metadata read per file rather than a full transfer. The content tier
//! catches what the first misses: the same frame arriving under a different
//! name, from a second card, or after somebody renamed it.
//!
//! # Why the filename is compared here rather than in SQL
//!
//! `images.source_ref` holds the whole opaque key — a relative path on Linux,
//! a document id on SAF — and the camera's filename is only its last
//! component. Matching that in SQL means `LIKE '%/IMG_0001.CR3'`, which cannot
//! use an index, scans the whole table, and is wrong on SAF where the
//! separator is not `/`. So the query narrows on the indexed columns and the
//! handful of rows that survive are compared in Rust, the same way the grid
//! already derives a display name.
//!
//! # Filename alone is never sufficient
//!
//! Camera filenames wrap at `IMG_9999` and start again, so a library of any
//! age holds several unrelated `IMG_0001.CR3`. That is why the cheap tier
//! carries capture time and camera as well, and why the expensive tier exists
//! at all.
use rusqlite::Connection;
use crate::CatalogError;
/// The last component of a stored source reference.
///
/// Splits on both separators for the same reason `Catalog::window` does: the
/// key's shape belongs to the storage that produced it, and a SAF document id
/// is delimited with `:`.
fn file_name(source_ref: &str) -> &str {
source_ref.rsplit(['/', ':']).next().unwrap_or(source_ref)
}
/// Whether the catalog already holds this photograph, on metadata alone.
///
/// `camera` is the joined make-and-model string the scan stores, not the raw
/// EXIF pair — the caller composes it the same way, or the comparison is
/// always false.
///
/// A `captured_at` of `None` makes this answer `false` rather than matching
/// every undated image in the library: without a capture time the key is
/// filename plus size, which two frames from the same body collide on
/// routinely. An undated file falls through to the content tier, which is
/// slower and right.
pub fn seen_by_metadata(
conn: &Connection,
captured_at: Option<i64>,
camera: Option<&str>,
size: u64,
original_name: &str,
) -> Result<bool, CatalogError> {
let Some(captured_at) = captured_at else {
return Ok(false);
};
// `images_captured` indexes the capture time, so this reads a few rows
// even in a library of fifty thousand: one instant to the second holds
// one frame, or a handful on a body shooting a burst.
let mut stmt = conn.prepare(
"SELECT source_ref FROM images
WHERE captured_at = ?1
AND (?2 IS NULL OR camera IS ?2)
AND (file_size IS NULL OR file_size = ?3)",
)?;
let mut rows = stmt.query(rusqlite::params![captured_at, camera, size as i64])?;
while let Some(row) = rows.next()? {
let source_ref: String = row.get(0)?;
if file_name(&source_ref).eq_ignore_ascii_case(original_name) {
return Ok(true);
}
}
Ok(false)
}
/// Whether these exact bytes are already in the library.
///
/// The tier that costs a read of the file. Cheap here — `images_hash` is a
/// partial index over the rows that have one — and expensive for the caller,
/// which had to hash something to ask.
pub fn seen_by_content(conn: &Connection, digest: &str) -> Result<bool, CatalogError> {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM images WHERE content_hash = ?1",
[digest],
|r| r.get(0),
)?;
Ok(n > 0)
}
/// Record the digest of a file the import computed.
///
/// An import reads every byte anyway, so the hash is free at that moment and
/// costs a full read of an 80 MB file at any other. Storing it is what lets
/// the *next* import answer [`seen_by_content`] without reading anything.
///
/// Matched on `source_ref` within a root, which is how the scan that just
/// catalogued the imported file identifies it. Returns how many rows were
/// updated: zero means the scan has not reached the file yet, which is a
/// normal race and not an error.
pub fn set_content_hash(
conn: &Connection,
root_id: u64,
source_ref: &str,
digest: &str,
) -> Result<usize, CatalogError> {
Ok(conn.execute(
"UPDATE images SET content_hash = ?3
WHERE root_id = ?1 AND source_ref = ?2",
rusqlite::params![root_id as i64, source_ref, digest],
)?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Catalog;
/// A catalog holding one photograph, as a scan plus a metadata pass would
/// leave it.
fn with_one() -> Catalog {
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, captured_at, camera, file_size,
content_hash, added_at)
VALUES (1, '2026/2026-08-22/IMG_0001.CR3', 1787407200, 'Canon EOS R5',
9, 'deadbeef', 0)",
[],
)
.unwrap();
cat
}
#[test]
fn re_inserting_the_same_card_is_recognised_before_a_transfer() {
let cat = with_one();
assert!(seen_by_metadata(
cat.connection(),
Some(1_787_407_200),
Some("Canon EOS R5"),
9,
"IMG_0001.CR3"
)
.unwrap());
}
#[test]
fn a_different_frame_at_the_same_instant_is_not_a_duplicate() {
// Two bodies firing together, or a burst. The name separates them.
let cat = with_one();
assert!(!seen_by_metadata(
cat.connection(),
Some(1_787_407_200),
Some("Canon EOS R5"),
9,
"IMG_0002.CR3"
)
.unwrap());
}
#[test]
fn the_same_name_from_a_different_camera_is_not_a_duplicate() {
// IMG_0001.CR3 exists on every card ever formatted.
let cat = with_one();
assert!(!seen_by_metadata(
cat.connection(),
Some(1_787_407_200),
Some("NIKON Z 9"),
9,
"IMG_0001.CR3"
)
.unwrap());
}
#[test]
fn the_same_name_at_a_different_time_is_not_a_duplicate() {
// The IMG_9999 wrap: the library holds an unrelated IMG_0001.CR3 from
// four years ago, and matching on name alone would refuse to import
// today's.
let cat = with_one();
assert!(!seen_by_metadata(
cat.connection(),
Some(1_600_000_000),
Some("Canon EOS R5"),
9,
"IMG_0001.CR3"
)
.unwrap());
}
#[test]
fn an_undated_file_falls_through_to_the_content_tier() {
// Not "matches everything undated" — that would silently refuse to
// import a whole card of scanned film.
let cat = with_one();
assert!(!seen_by_metadata(cat.connection(), None, None, 9, "IMG_0001.CR3").unwrap());
}
#[test]
fn a_file_that_grew_is_not_the_one_already_held() {
// A truncated earlier import, or a different rendition of the same
// frame. Same instant, same camera, same name, different bytes.
let cat = with_one();
assert!(!seen_by_metadata(
cat.connection(),
Some(1_787_407_200),
Some("Canon EOS R5"),
1234,
"IMG_0001.CR3"
)
.unwrap());
}
#[test]
fn a_row_with_no_recorded_size_still_matches() {
// The scan stores a size, but a row merged from another device may
// not have one, and refusing to match it would re-import the library.
let cat = with_one();
cat.connection()
.execute("UPDATE images SET file_size = NULL", [])
.unwrap();
assert!(seen_by_metadata(
cat.connection(),
Some(1_787_407_200),
Some("Canon EOS R5"),
9,
"IMG_0001.CR3"
)
.unwrap());
}
#[test]
fn the_same_frame_renamed_is_caught_by_its_bytes() {
let cat = with_one();
// The metadata tier misses it...
assert!(!seen_by_metadata(
cat.connection(),
Some(1_787_407_200),
Some("Canon EOS R5"),
9,
"holiday-42.CR3"
)
.unwrap());
// ...and the content tier does not.
assert!(seen_by_content(cat.connection(), "deadbeef").unwrap());
assert!(!seen_by_content(cat.connection(), "cafe").unwrap());
}
#[test]
fn a_digest_recorded_now_answers_the_next_import() {
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, '2026/2026-08-22/IMG_0001.CR3', 0)",
[],
)
.unwrap();
assert!(!seen_by_content(c, "abc123").unwrap());
let n = set_content_hash(c, 1, "2026/2026-08-22/IMG_0001.CR3", "abc123").unwrap();
assert_eq!(n, 1);
assert!(seen_by_content(c, "abc123").unwrap());
}
#[test]
fn recording_a_digest_before_the_scan_arrives_is_not_an_error() {
// The import writes the file and the scan catalogues it; between those
// two moments there is no row to update, and that is a race rather
// than a failure.
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
assert_eq!(
set_content_hash(c, 1, "not/scanned/yet.CR3", "abc").unwrap(),
0
);
}
#[test]
fn a_name_is_the_last_component_of_either_kind_of_key() {
assert_eq!(file_name("2026/2026-08-22/IMG_0001.CR3"), "IMG_0001.CR3");
// A SAF document id delimits with a colon.
assert_eq!(file_name("primary:DCIM/Camera/IMG_1.CR3"), "IMG_1.CR3");
assert_eq!(file_name("IMG_0001.CR3"), "IMG_0001.CR3");
}
}
-76
View File
@@ -1,76 +0,0 @@
//! TRACES: NFR-ARCH-4 | NFR-R5
//! Catalog errors.
//!
//! Typed and attached to the affected subject rather than panicking — a
//! corrupt row or a failed job marks one image and lets the batch continue.
/// Something went wrong talking to the catalog.
#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
#[error("sqlite: {0}")]
Sqlite(#[from] rusqlite::Error),
/// The catalog was written by a newer build.
///
/// Opening it read-write would corrupt state this build cannot represent,
/// so the app refuses and says so (NFR-R5).
#[error("catalog schema v{found} is newer than this build supports (v{supported})")]
SchemaTooNew { found: i64, supported: i64 },
/// A scan could not reach a root at all.
///
/// Distinct from "files are missing": this aborts the scan *before* the
/// deletion sweep, because every folder would look unreached and the sweep
/// would delete the whole library (FR-CAT-9).
#[error("root {0} is unreachable; scan aborted without pruning")]
RootUnreachable(u64),
/// A scan was asked for a root the catalog has no row for.
///
/// A caller's mistake rather than a user's: the row is created when the
/// grant is obtained, because the label — the path, the tree URI — is known
/// only there. Inventing one here would file the library under a name
/// nothing else would look it up by.
#[error("no such root: {0}")]
NoSuchRoot(u64),
/// A smart collection whose selector references itself, directly or via
/// another collection.
#[error("collection {0} would form a cycle")]
CollectionCycle(u64),
#[error("no such collection: {0}")]
NoSuchCollection(u64),
/// A keyword the caller named is gone — deleted, or fused into another by a
/// merge while its id sat in a UI model.
///
/// Its own variant rather than a silent no-op because the two are different
/// answers to the user: a rename that quietly did nothing looks exactly like
/// a rename that did not take.
#[error("no such keyword: {0}")]
NoSuchKeyword(u64),
/// Images were dropped onto a smart collection.
///
/// A smart collection's membership *is* its selector, so member rows would
/// be a second source of truth that nothing reads. Refused rather than
/// silently discarded, so the UI can say why the drop did nothing.
#[error("collection {0} is a saved filter; its contents cannot be edited by hand")]
SmartCollectionNotEditable(u64),
#[error("malformed stored selector: {0}")]
BadSelector(String),
/// A name the user typed that cannot be stored — blank, or one a sibling
/// already holds.
///
/// Its own variant rather than a reused `BadSelector`, because this one is
/// shown to the user verbatim: it has to read as a sentence about their
/// collection, not as a diagnostic about a stored selector.
#[error("{0}")]
BadName(String),
#[error("io: {0}")]
Io(String),
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-403
View File
@@ -1,403 +0,0 @@
//! TRACES: FR-CAT-3 | NFR-ARCH-2 | FR-PLAT-AND-3
//! The background work queue.
//!
//! Jobs live in the catalog, so they survive process death — routine on
//! Android rather than exceptional (FR-PLAT-AND-3). Two properties carry the
//! design:
//!
//! - **Coalescing.** `UNIQUE(kind, subject_id)` makes enqueueing idempotent,
//! so every code path that notices a change can just enqueue and let the
//! table absorb the redundancy.
//! - **Priority shared with the GPU scheduler** (ARCH §5.3), so one notion of
//! urgency governs the whole app and visible work always preempts bulk work.
use rusqlite::Connection;
use crate::error::CatalogError;
/// What a job does.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i64)]
pub enum JobKind {
/// Recursive incremental scan from a folder (§scan).
ScanFolder = 0,
/// Promote an image from stat-only to full EXIF.
ExtractMetadata = 1,
/// Build or rebuild a thumbnail.
Thumbnail = 2,
/// A sidecar on disk is newer than what the catalog read.
ReadSidecar = 3,
/// Flush a local edit to its sidecar. Debounced, never per slider tick.
WriteSidecar = 4,
/// Whole-file hash. On demand only — import dedup, reconnect-by-hash.
ContentHash = 5,
/// Range-extract an embedded preview from a remote file (FR-NC-3).
FetchPreview = 6,
/// Fetch a full original: pinned by rule, or explicitly asked for.
FetchOriginal = 7,
/// Detect and embed the faces in one image (FR-CULL-8).
///
/// One job does both, rather than splitting them: the proxy is already
/// decoded and in memory, and the natural unit of resumable work is one
/// photograph. Splitting would double the queue's row count for nothing.
///
/// Runs against the proxy tier, never a full decode — a library that has
/// been browsed has already paid for its proxies, so face indexing adds no
/// RAW decodes that were not already happening.
DetectFaces = 8,
}
impl JobKind {
fn from_i64(v: i64) -> Option<Self> {
Some(match v {
0 => JobKind::ScanFolder,
1 => JobKind::ExtractMetadata,
2 => JobKind::Thumbnail,
3 => JobKind::ReadSidecar,
4 => JobKind::WriteSidecar,
5 => JobKind::ContentHash,
6 => JobKind::FetchPreview,
7 => JobKind::FetchOriginal,
8 => JobKind::DetectFaces,
_ => return None,
})
}
/// Whether this job transfers over the network, and so is subject to the
/// metered-connection and charging constraints in FR-NC-6.
pub fn is_network(self) -> bool {
matches!(self, JobKind::FetchPreview | JobKind::FetchOriginal)
}
}
/// Scheduling class, matching the GPU tile scheduler (ARCH §5.3).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[repr(i64)]
pub enum Priority {
/// Bulk work: metadata sweeps, rule-driven fetches, hashing.
Background = 0,
/// Just outside the viewport; the next image in culling.
Prefetch = 1,
/// Visible cells, and the image currently open.
///
/// Strictly preempts background work. Without this, scrolling during a
/// bulk thumbnail pass misses its frame budget — the common case, not an
/// edge case (NFR-ARCH-2).
Interactive = 2,
}
/// Lifecycle state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i64)]
pub enum JobState {
Pending = 0,
Running = 1,
Failed = 2,
}
/// A job ready to run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Job {
pub id: i64,
pub kind: JobKind,
pub subject_id: Option<i64>,
pub priority: Priority,
pub attempts: i64,
pub payload: Option<String>,
}
/// Give up after this many attempts and attach the error to the subject.
///
/// One corrupt file must not stall the queue behind endless retries
/// (FR-RAW-4).
pub const MAX_ATTEMPTS: i64 = 5;
/// Backoff before retrying a failed job, in seconds.
///
/// Exponential, capped — a server that is down for an hour should not be
/// retried every second, and a transient decode failure should not wait an
/// hour.
pub fn backoff_seconds(attempts: i64) -> i64 {
const CAP: i64 = 300;
match attempts {
a if a <= 0 => 0,
a if a >= 9 => CAP,
a => (1i64 << (a - 1)).min(CAP),
}
}
/// Enqueue work, coalescing with any identical pending job.
///
/// Re-requesting at a higher priority *promotes* the existing row rather than
/// duplicating it, which is what lets the grid shout "this one is visible now"
/// about a job already queued in the background.
pub fn enqueue(
conn: &Connection,
kind: JobKind,
subject_id: Option<i64>,
priority: Priority,
payload: Option<&str>,
) -> Result<(), CatalogError> {
conn.execute(
"INSERT INTO jobs(kind, subject_id, priority, state, payload)
VALUES (?1, ?2, ?3, 0, ?4)
ON CONFLICT(kind, subject_id) DO UPDATE SET
priority = max(jobs.priority, excluded.priority),
-- A job that failed and is being re-requested deserves a fresh
-- start: the file may well have changed since it failed.
state = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.state END,
attempts = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.attempts END,
not_before = CASE WHEN jobs.state = 2 THEN 0 ELSE jobs.not_before END",
rusqlite::params![kind as i64, subject_id, priority as i64, payload],
)?;
Ok(())
}
/// Claim the next runnable job, highest priority first.
///
/// `now` is passed rather than read from the clock so backoff is testable.
/// Claiming marks the row `Running` in the same transaction as the read, so
/// two workers cannot take the same job.
pub fn claim_next(conn: &Connection, now: i64) -> Result<Option<Job>, CatalogError> {
let tx = conn.unchecked_transaction()?;
let job = tx
.query_row(
"SELECT id, kind, subject_id, priority, attempts, payload
FROM jobs
WHERE state = 0 AND not_before <= ?1
ORDER BY priority DESC, id ASC
LIMIT 1",
[now],
|r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, Option<i64>>(2)?,
r.get::<_, i64>(3)?,
r.get::<_, i64>(4)?,
r.get::<_, Option<String>>(5)?,
))
},
)
.ok();
let Some((id, kind, subject_id, priority, attempts, payload)) = job else {
return Ok(None);
};
tx.execute(
"UPDATE jobs SET state = 1, attempts = attempts + 1 WHERE id = ?1",
[id],
)?;
tx.commit()?;
Ok(Some(Job {
id,
kind: JobKind::from_i64(kind).unwrap_or(JobKind::ExtractMetadata),
subject_id,
priority: match priority {
2 => Priority::Interactive,
1 => Priority::Prefetch,
_ => Priority::Background,
},
attempts: attempts + 1,
payload,
}))
}
/// Job finished successfully.
pub fn complete(conn: &Connection, id: i64) -> Result<(), CatalogError> {
conn.execute("DELETE FROM jobs WHERE id = ?1", [id])?;
Ok(())
}
/// Job failed. Reschedules with backoff, or gives up past [`MAX_ATTEMPTS`].
pub fn fail(conn: &Connection, job: &Job, now: i64, err: &str) -> Result<(), CatalogError> {
if job.attempts >= MAX_ATTEMPTS {
conn.execute(
"UPDATE jobs SET state = 2, last_error = ?2 WHERE id = ?1",
rusqlite::params![job.id, err],
)?;
} else {
conn.execute(
"UPDATE jobs SET state = 0, not_before = ?2, last_error = ?3 WHERE id = ?1",
rusqlite::params![job.id, now + backoff_seconds(job.attempts), err],
)?;
}
Ok(())
}
/// Recover jobs orphaned by process death.
///
/// A row left `Running` has no owner — the process that claimed it is gone.
/// Called at startup, before any worker begins (FR-PLAT-AND-3).
pub fn recover_orphaned(conn: &Connection) -> Result<usize, CatalogError> {
let n = conn.execute("UPDATE jobs SET state = 0 WHERE state = 1", [])?;
Ok(n)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema;
fn db() -> Connection {
let c = Connection::open_in_memory().unwrap();
schema::configure(&c).unwrap();
schema::migrate(&c).unwrap();
c
}
#[test]
fn repeated_enqueue_coalesces() {
let c = db();
for _ in 0..10 {
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
}
let n: i64 = c
.query_row("SELECT count(*) FROM jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1);
}
#[test]
fn re_enqueueing_at_higher_priority_promotes() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
// The grid scrolls this image into view.
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Interactive, None).unwrap();
let p: i64 = c
.query_row("SELECT priority FROM jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(p, Priority::Interactive as i64);
}
#[test]
fn priority_never_regresses() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Interactive, None).unwrap();
// A background sweep must not demote work the user is waiting on.
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
let p: i64 = c
.query_row("SELECT priority FROM jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(p, Priority::Interactive as i64);
}
#[test]
fn claim_takes_highest_priority_first() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
enqueue(&c, JobKind::Thumbnail, Some(2), Priority::Interactive, None).unwrap();
enqueue(&c, JobKind::Thumbnail, Some(3), Priority::Prefetch, None).unwrap();
let first = claim_next(&c, 0).unwrap().unwrap();
assert_eq!(first.subject_id, Some(2));
let second = claim_next(&c, 0).unwrap().unwrap();
assert_eq!(second.subject_id, Some(3));
}
#[test]
fn a_claimed_job_is_not_claimed_twice() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
assert!(claim_next(&c, 0).unwrap().is_some());
assert!(claim_next(&c, 0).unwrap().is_none());
}
#[test]
fn failure_backs_off_then_becomes_claimable_again() {
let c = db();
enqueue(
&c,
JobKind::FetchPreview,
Some(1),
Priority::Background,
None,
)
.unwrap();
let job = claim_next(&c, 100).unwrap().unwrap();
fail(&c, &job, 100, "network down").unwrap();
// Still backing off.
assert!(claim_next(&c, 100).unwrap().is_none());
// Past the backoff.
assert!(claim_next(&c, 100 + backoff_seconds(job.attempts))
.unwrap()
.is_some());
}
#[test]
fn a_persistently_failing_job_stops_retrying() {
let c = db();
enqueue(
&c,
JobKind::ExtractMetadata,
Some(1),
Priority::Background,
None,
)
.unwrap();
let mut now = 0;
for _ in 0..MAX_ATTEMPTS {
let job = claim_next(&c, now).unwrap().expect("should be claimable");
fail(&c, &job, now, "corrupt file").unwrap();
now += backoff_seconds(job.attempts);
}
// One corrupt file must not stall the queue forever (FR-RAW-4).
assert!(claim_next(&c, now + 100_000).unwrap().is_none());
let state: i64 = c
.query_row("SELECT state FROM jobs", [], |r| r.get(0))
.unwrap();
assert_eq!(state, JobState::Failed as i64);
}
#[test]
fn re_requesting_a_failed_job_gives_it_a_fresh_start() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
let mut now = 0;
for _ in 0..MAX_ATTEMPTS {
let job = claim_next(&c, now).unwrap().unwrap();
fail(&c, &job, now, "boom").unwrap();
now += backoff_seconds(job.attempts);
}
// The file changed on disk, so the old failure says nothing about it.
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Interactive, None).unwrap();
let job = claim_next(&c, now).unwrap().expect("retryable again");
assert_eq!(job.attempts, 1);
}
#[test]
fn orphaned_jobs_return_to_pending_on_restart() {
let c = db();
enqueue(&c, JobKind::Thumbnail, Some(1), Priority::Background, None).unwrap();
claim_next(&c, 0).unwrap().unwrap();
// Process dies here. Android does this routinely.
assert_eq!(recover_orphaned(&c).unwrap(), 1);
assert!(claim_next(&c, 0).unwrap().is_some());
}
#[test]
fn backoff_grows_then_caps() {
assert_eq!(backoff_seconds(0), 0);
assert_eq!(backoff_seconds(1), 1);
assert_eq!(backoff_seconds(3), 4);
assert_eq!(backoff_seconds(100), 300);
}
#[test]
fn network_jobs_are_identifiable_for_metered_gating() {
// FR-NC-6: transfers respect unmetered-network and charging
// constraints; local work must not be gated by them.
assert!(JobKind::FetchOriginal.is_network());
assert!(JobKind::FetchPreview.is_network());
assert!(!JobKind::Thumbnail.is_network());
assert!(!JobKind::ExtractMetadata.is_network());
}
}
File diff suppressed because it is too large Load Diff
-555
View File
@@ -1,555 +0,0 @@
//! TRACES: FR-CAT-2 | FR-CAT-4 | FR-CAT-6 | NFR-P1
//! The catalog: a rebuildable index over the library.
//!
//! Not a source of truth. Sidecars next to the images hold the authoritative
//! edit state (ARCH §6.12), and this file is deletable at any time — rebuilt
//! by rescanning sources and reading sidecars. That inversion is deliberate:
//! darktable maintains both a database and sidecars while achieving the
//! reliability of neither.
//!
//! # What lives here
//!
//! - [`schema`] — tables and forward-only migrations
//! - [`scan`] — incremental discovery that prunes unchanged directories
//! - [`walk`] — those decisions driven against real storage, local or SAF
//! - [`query`] — selectors compiled to indexed SQL, windowed for the grid
//! - [`collections`] — the collection tree and membership the UI edits
//! - [`keywords`] — the keyword vocabulary and what it is assigned to
//! - [`faces`] — detected faces, the people they belong to, and who said so
//! - [`jobs`] — the durable background work queue
//! - [`trash`] — soft delete to a folder, then permanent delete
//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords
//!
//! # The one thing everything is designed around
//!
//! **Work is proportional to what changed, or to what the user is looking at —
//! never to library size.** A 50k-image library that has not changed costs one
//! metadata probe per folder to verify (§scan), no thumbnails to regenerate
//! (§jobs coalescing), and no rule evaluation per grid cell (materialised
//! `tier_desired`).
use std::path::Path;
use dr_types::{Availability, ImageId};
use rusqlite::Connection;
pub mod cache;
pub mod collections;
pub mod dedup;
pub mod error;
pub mod face_shard;
pub mod faces;
pub mod jobs;
pub mod keywords;
pub mod merge;
pub mod query;
pub mod rating;
pub mod scan;
pub mod schema;
pub mod sync;
pub mod trash;
pub mod walk;
pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES};
pub use collections::{Collection, CollectionKind, TreeRow};
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
pub use error::CatalogError;
pub use face_shard::{FaceShardStore, SharedFace};
pub use faces::{Calibration, DetectedFace, Face, FaceId, Person, PersonId};
pub use jobs::{Job, JobKind, Priority};
pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword};
pub use merge::MergeReport;
pub use query::{Query, Sort};
pub use rating::{Judgement, MAX_RATING};
pub use scan::{DirAction, DirState, EntryAction, ScanOutcome};
pub use trash::{TrashedImage, TRASH_DIR};
pub use walk::{ensure_root, scan_root, RootKind, ScanProgress, ScanReport};
/// One row of the library grid.
///
/// Exactly what a cell draws and nothing more — no join per cell, and
/// availability reads a materialised column rather than evaluating cache rules
/// (ARCH §9.5).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GridRow {
pub id: ImageId,
pub name: String,
pub availability: Availability,
/// UTC seconds. `None` until EXIF has been read.
pub captured_at: Option<i64>,
/// Minutes east of UTC, for rendering the photographer's local time.
pub captured_offset: Option<i32>,
/// 0 = nothing, 1 = stat-only, 2 = full EXIF.
pub metadata_state: u8,
}
/// A count of images in one time bucket, for the timeline scrubber.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeBucket {
/// UTC seconds at the bucket's start.
pub start: i64,
pub count: u32,
}
/// Time bucket size.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Granularity {
Year,
Month,
Day,
Hour,
}
impl Granularity {
/// SQLite `strftime` format that collapses a timestamp to this bucket.
///
/// Applied to **local** time, not UTC: "everything from 3 August" means
/// the photographer's 3 August, which is why `captured_offset` is stored
/// alongside the UTC timestamp.
/// Public so a caller that must build its own bucketing query — one
/// joining collection membership, say — buckets identically to
/// [`Catalog::timeline_range`] rather than reimplementing the format.
pub fn strftime(self) -> &'static str {
match self {
Granularity::Year => "%Y",
Granularity::Month => "%Y-%m",
Granularity::Day => "%Y-%m-%d",
Granularity::Hour => "%Y-%m-%dT%H",
}
}
/// A sensible bucket size for a span of seconds, so the UI need not guess.
///
/// # Chosen by how many bars it produces, not by fixed cut-offs
///
/// This used to be four thresholds on the span, which reads sensibly and
/// behaves badly under zoom. Each zoom step halves the span, so the bar
/// count halves with it until a threshold is crossed — a fifteen-year
/// library went 15 bars, 8, then 46, 23, 11, and finally *6*. Zooming in
/// made the picture coarser, which is the opposite of what zooming is for.
///
/// So the choice is made on the axis's terms: of the four bucket sizes,
/// take the one whose bar count comes nearest [`Self::TARGET_BARS`]. The
/// count then stays in the same neighbourhood at every zoom level, and
/// each step in genuinely shows finer structure rather than the same
/// structure drawn wider.
///
/// Nearest in *ratio*, not in difference: the counts available for a given
/// span are orders of magnitude apart — a span is either about 4 years or
/// about 48 months — and on a linear measure the larger count always looks
/// further away, which would bias every choice towards too few bars.
pub fn for_span(seconds: i64) -> Self {
Self::for_bucket(seconds.max(1) / Self::TARGET_BARS)
}
/// The calendar unit nearest a bucket of `seconds`, for *labelling* one.
///
/// Split out from [`Self::for_span`] because the axis no longer buckets by
/// calendar unit at all — it divides the visible span into a fixed number
/// of equal bins (see `LibrarySettings::timeline_bars`). What is still
/// wanted is the unit a bin is closest to, so a bin of about a day is
/// labelled as a date and one of about a year as a year. Asked directly
/// rather than derived from the span, because the bin count is now the
/// user's rather than this module's target.
pub fn for_bucket(seconds: i64) -> Self {
let seconds = seconds.max(1) as f64;
// Finest first, so that when two options are equally far from the
// target the finer one wins: `min_by` keeps the first minimum it saw,
// and more detail is the better failure.
[
Granularity::Hour,
Granularity::Day,
Granularity::Month,
Granularity::Year,
]
.into_iter()
.min_by(|a, b| {
let cost = |g: Granularity| {
// How far off, measured multiplicatively: twice as long and
// half as long are equally wrong.
//
// Deliberately not clamped. A bucket shorter than the unit
// scores *worse* the coarser the unit, which is what makes an
// hour of photographs pick hourly bars instead of every option
// tying at "one bucket" and the coarsest winning.
(seconds / g.approx_seconds() as f64).ln().abs()
};
cost(*a)
.partial_cmp(&cost(*b))
// Ties cannot arise from real spans, but a NaN would; falling
// back to the coarser option keeps the axis drawable.
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(Granularity::Day)
}
/// How many bars the timeline wants across its axis.
///
/// Not a hard count — the bucket sizes are calendar units, so the actual
/// number lands where the calendar puts it. It is the figure the choice
/// aims at: enough bars that a busy fortnight is visibly busier than a
/// quiet one, few enough that each is wide enough to hit with a finger.
const TARGET_BARS: i64 = 40;
/// Nominal length of one bucket, for choosing between them.
///
/// Approximate on purpose: months and years vary and it does not matter
/// here, because this only ranks four options that are a factor of ~12 or
/// ~30 apart. The exact boundaries come from `strftime` on the real dates.
fn approx_seconds(self) -> i64 {
const DAY: i64 = 86_400;
match self {
Granularity::Year => 365 * DAY,
Granularity::Month => 30 * DAY,
Granularity::Day => DAY,
Granularity::Hour => 3600,
}
}
}
/// A connection to the catalog.
pub struct Catalog {
conn: Connection,
}
impl Catalog {
/// Open or create a catalog, migrating it forward if needed.
pub fn open(path: &Path) -> Result<Self, CatalogError> {
let conn = Connection::open(path)?;
schema::configure(&conn)?;
let from = schema::migrate(&conn)?;
// A migration adds a column; it cannot know what the value should be
// for rows that already existed. Backfilling on open is what stops
// those rows being silently partial.
for (what, n) in schema::backfill(&conn)? {
log::info!("backfilled {what} for {n} row(s) (schema was v{from})");
}
Ok(Catalog { conn })
}
/// An in-memory catalog, for tests and for a throwaway import preview.
pub fn in_memory() -> Result<Self, CatalogError> {
let conn = Connection::open_in_memory()?;
schema::configure(&conn)?;
schema::migrate(&conn)?;
schema::backfill(&conn)?;
Ok(Catalog { conn })
}
/// Escape hatch for modules that need raw access. Not part of the UI-facing
/// surface.
pub fn connection(&self) -> &Connection {
&self.conn
}
/// How many images match.
///
/// Returned alongside the first window so the grid can size its scrollbar
/// and paint in one round trip.
pub fn count(&self, q: &Query, now: i64) -> Result<usize, CatalogError> {
let c = query::compile(&q.filter, now);
let sql = query::count_sql(&c);
let n: i64 =
self.conn
.query_row(&sql, rusqlite::params_from_iter(c.params.iter()), |r| {
r.get(0)
})?;
Ok(n as usize)
}
/// Fetch one window of results.
///
/// Never returns the whole catalog: FR-CAT-4 requires memory bounded
/// independently of library size.
pub fn window(
&self,
q: &Query,
range: std::ops::Range<usize>,
now: i64,
) -> Result<Vec<GridRow>, CatalogError> {
let c = query::compile(&q.filter, now);
let sql = query::window_sql(q, &c);
let mut params = c.params.clone();
params.push(rusqlite::types::Value::Integer(range.len() as i64));
params.push(rusqlite::types::Value::Integer(range.start as i64));
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
let source_ref: String = r.get(1)?;
let avail: i64 = r.get(2)?;
Ok(GridRow {
id: ImageId(r.get::<_, i64>(0)? as u64),
name: source_ref
.rsplit(['/', ':'])
.next()
.unwrap_or(&source_ref)
.to_string(),
availability: decode_availability(avail),
captured_at: r.get(3)?,
captured_offset: r.get::<_, Option<i64>>(4)?.map(|v| v as i32),
metadata_state: r.get::<_, i64>(5)? as u8,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Counts per time bucket, for the timeline scrubber.
///
/// One grouped aggregate over the `images_captured` index — not 50k rows
/// handed to the UI to bucket itself.
pub fn timeline(
&self,
q: &Query,
g: Granularity,
now: i64,
) -> Result<Vec<TimeBucket>, CatalogError> {
let c = query::compile(&q.filter, now);
// Bucketed in local time: captured_offset is minutes east of UTC, and
// NULL falls back to UTC rather than dropping the row.
let sql = format!(
"SELECT min(captured_at) AS start,
count(*) AS n
FROM images
-- A shadowed JPEG is the same frame as its RAW; counting both
-- would double every paired shot in the histogram.
WHERE {} AND captured_at IS NOT NULL AND shadowed_by IS NULL
GROUP BY strftime('{}', captured_at + coalesce(captured_offset, 0) * 60,
'unixepoch')
ORDER BY start ASC",
c.where_sql,
g.strftime()
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(c.params.iter()), |r| {
Ok(TimeBucket {
start: r.get(0)?,
count: r.get::<_, i64>(1)? as u32,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Counts per time bucket, bounded to a date range.
///
/// What a zoomed timeline needs: [`timeline`](Self::timeline) always spans
/// the whole library, so zooming in would return the same coarse buckets
/// with the ends cropped rather than finer detail over a narrower span.
pub fn timeline_range(
&self,
q: &Query,
g: Granularity,
from: i64,
to: i64,
now: i64,
) -> Result<Vec<TimeBucket>, CatalogError> {
let c = query::compile(&q.filter, now);
let sql = format!(
"SELECT min(captured_at) AS start,
count(*) AS n
FROM images
WHERE {} AND captured_at IS NOT NULL AND shadowed_by IS NULL
AND captured_at >= ?{} AND captured_at <= ?{}
GROUP BY strftime('{}', captured_at + coalesce(captured_offset, 0) * 60,
'unixepoch')
ORDER BY start ASC",
c.where_sql,
c.params.len() + 1,
c.params.len() + 2,
g.strftime()
);
let mut params = c.params.clone();
params.push(rusqlite::types::Value::Integer(from));
params.push(rusqlite::types::Value::Integer(to));
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(TimeBucket {
start: r.get(0)?,
count: r.get::<_, i64>(1)? as u32,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Merge a downloaded remote catalog's collections into this one.
///
/// See [`sync`] for why only collections cross over.
pub fn merge_remote_catalog(&self, remote: &Path) -> Result<MergeReport, CatalogError> {
sync::merge_remote(&self.conn, remote)
}
/// Write a consistent snapshot ready to upload.
pub fn snapshot_for_upload(&self, dest: &Path) -> Result<(), CatalogError> {
sync::snapshot_for_upload(&self.conn, dest)
}
}
fn decode_availability(v: i64) -> Availability {
match v {
1 => Availability::Preview,
2 => Availability::Original,
3 => Availability::Offline,
_ => Availability::MetadataOnly,
}
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::Selector;
fn seeded() -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
// Three images across two days, one with no EXIF read yet.
for (id, name, captured, state) in [
(1i64, "a.CR3", Some(1_000_000i64), 2i64),
(2, "b.CR3", Some(1_100_000), 2),
(3, "c.CR3", None, 1),
] {
c.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, metadata_state, added_at)
VALUES (?1, 1, ?2, ?3, ?4, 0)",
rusqlite::params![id, name, captured, state],
)
.unwrap();
}
cat
}
#[test]
fn count_and_window_agree() {
let cat = seeded();
let q = Query::default();
assert_eq!(cat.count(&q, 0).unwrap(), 3);
assert_eq!(cat.window(&q, 0..10, 0).unwrap().len(), 3);
}
#[test]
fn window_is_bounded_by_the_requested_range() {
// FR-CAT-4: memory independent of catalog size.
let cat = seeded();
let rows = cat.window(&Query::default(), 0..2, 0).unwrap();
assert_eq!(rows.len(), 2);
}
#[test]
fn paging_covers_every_row_exactly_once() {
let cat = seeded();
let q = Query::default();
let mut seen = Vec::new();
for start in (0..3).step_by(2) {
seen.extend(cat.window(&q, start..start + 2, 0).unwrap());
}
let mut ids: Vec<u64> = seen.iter().map(|r| r.id.0).collect();
ids.sort_unstable();
assert_eq!(ids, vec![1, 2, 3]);
}
#[test]
fn an_image_without_capture_time_sorts_last_not_first() {
// Otherwise a freshly scanned library leads with whatever has not been
// read yet, which looks like corruption to the user.
let cat = seeded();
let rows = cat.window(&Query::default(), 0..10, 0).unwrap();
assert_eq!(rows.last().unwrap().id, ImageId(3));
}
#[test]
fn metadata_state_reaches_the_grid() {
// The grid needs it to distinguish "no photos on this date" from
// "EXIF not read yet" (FR-NC-6c's honesty principle).
let cat = seeded();
let rows = cat.window(&Query::default(), 0..10, 0).unwrap();
let pending = rows.iter().find(|r| r.id == ImageId(3)).unwrap();
assert_eq!(pending.metadata_state, 1);
}
#[test]
fn a_filter_narrows_the_count() {
let cat = seeded();
let q = Query {
filter: Selector::Text("a.CR3".into()),
..Default::default()
};
assert_eq!(cat.count(&q, 0).unwrap(), 1);
}
#[test]
fn timeline_buckets_and_skips_unread_images() {
let cat = seeded();
let buckets = cat
.timeline(&Query::default(), Granularity::Day, 0)
.unwrap();
// Two images with timestamps, one day apart in UTC; the third has no
// capture time and cannot be placed on a timeline at all.
let total: u32 = buckets.iter().map(|b| b.count).sum();
assert_eq!(total, 2);
}
#[test]
fn timeline_granularity_follows_the_span() {
const DAY: i64 = 86_400;
// Chosen by how many bars it makes, not by fixed cut-offs — see
// `for_span`. Ten years of yearly bars is ten bars, which says almost
// nothing about a library; monthly is 122, which is a shape.
assert_eq!(Granularity::for_span(10 * 365 * DAY), Granularity::Month);
assert_eq!(Granularity::for_span(120 * DAY), Granularity::Day);
assert_eq!(Granularity::for_span(10 * DAY), Granularity::Day);
assert_eq!(Granularity::for_span(3600), Granularity::Hour);
// The property the target exists for: zooming in never coarsens the
// axis. Under the old thresholds a fifteen-year library went 15 bars,
// then 8, then 46, 23, 11 — finer spans drawn with wider bars.
let mut span = 15 * 365 * DAY;
let mut previous = Granularity::for_span(span).approx_seconds();
for _ in 0..10 {
span /= 2;
let bucket = Granularity::for_span(span).approx_seconds();
assert!(
bucket <= previous,
"halving the span to {span}s coarsened the bucket \
from {previous}s to {bucket}s"
);
previous = bucket;
}
// And a span shorter than any bucket still picks the finest, rather
// than every option tying at one bar and the coarsest winning.
assert_eq!(Granularity::for_span(60), Granularity::Hour);
assert_eq!(Granularity::for_span(1), Granularity::Hour);
}
#[test]
fn names_are_derived_for_both_paths_and_saf_ids() {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'saf', 'tree')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (1, 1, 'primary:DCIM/Camera/IMG_1.CR3', 0)",
[],
)
.unwrap();
let rows = cat.window(&Query::default(), 0..10, 0).unwrap();
assert_eq!(rows[0].name, "IMG_1.CR3");
}
}
File diff suppressed because it is too large Load Diff
-514
View File
@@ -1,514 +0,0 @@
//! TRACES: FR-CAT-4 | FR-CAT-6
//! Compiling a [`Selector`] into indexed SQL, and windowing the result.
//!
//! The UI never assembles SQL — it hands over a [`Query`] and receives a
//! window. Two properties matter:
//!
//! 1. **Nothing user-supplied is interpolated into SQL text.** Every value
//! binds as a parameter; `LIKE` patterns have their wildcards escaped.
//! 2. **Predicates hit indices.** Filtering 50k images must stay interactive
//! (FR-CAT-6), which means no expression over a column that would defeat
//! its index.
use dr_types::{Availability, ColourLabel, DateSelector, FlagState, Selector};
use rusqlite::types::Value;
/// What to show, and in what order.
#[derive(Debug, Clone)]
pub struct Query {
pub filter: Selector,
pub sort: Sort,
pub descending: bool,
}
impl Default for Query {
fn default() -> Self {
Query {
filter: Selector::All,
sort: Sort::CapturedAt,
descending: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sort {
CapturedAt,
Added,
FileName,
Rating,
/// Manual order within a collection. Falls back to capture time where the
/// query is not scoped to one collection, since position is meaningless
/// outside it.
CollectionPosition,
}
impl Sort {
/// The ORDER BY fragment. Fixed strings — never user input.
///
/// Capture time sorts NULLs last regardless of direction: an image whose
/// EXIF has not been read yet (metadata_state 1) should not lead the grid
/// simply because its timestamp is unknown.
fn sql(self, descending: bool) -> &'static str {
match (self, descending) {
(Sort::CapturedAt, false) => {
"ORDER BY images.captured_at IS NULL, images.captured_at ASC, images.id ASC"
}
(Sort::CapturedAt, true) => {
"ORDER BY images.captured_at IS NULL, images.captured_at DESC, images.id DESC"
}
(Sort::Added, false) => "ORDER BY images.added_at ASC, images.id ASC",
(Sort::Added, true) => "ORDER BY images.added_at DESC, images.id DESC",
(Sort::FileName, false) => "ORDER BY images.source_ref ASC, images.id ASC",
(Sort::FileName, true) => "ORDER BY images.source_ref DESC, images.id DESC",
(Sort::Rating, false) => "ORDER BY v.rating ASC, images.id ASC",
(Sort::Rating, true) => "ORDER BY v.rating DESC, images.id DESC",
(Sort::CollectionPosition, false) => {
"ORDER BY cm.position IS NULL, cm.position ASC, images.captured_at ASC"
}
(Sort::CollectionPosition, true) => {
"ORDER BY cm.position IS NULL, cm.position DESC, images.captured_at DESC"
}
}
}
/// Whether this sort needs the default-version join.
fn needs_version(self) -> bool {
matches!(self, Sort::Rating)
}
/// Whether this sort needs a collection-membership join.
fn needs_membership(self) -> bool {
matches!(self, Sort::CollectionPosition)
}
}
/// A compiled WHERE clause plus its bound parameters.
///
/// Kept separate from the statement so `count` and `window` can share one
/// compilation.
#[derive(Debug, Default)]
pub struct Compiled {
pub where_sql: String,
pub params: Vec<Value>,
/// True if the filter depends on capture time, and therefore on EXIF that
/// a freshly scanned library may not have read yet. The UI surfaces this
/// rather than silently under-reporting.
pub needs_capture_time: bool,
}
/// Compile a selector to SQL against the `images` table.
///
/// `now` is passed rather than read from the clock so a rolling window is
/// reproducible in tests and consistent across one query.
pub fn compile(filter: &Selector, now: i64) -> Compiled {
let mut params = Vec::new();
let sql = if filter.is_unfiltered() {
"1".to_string()
} else {
emit(filter, now, &mut params)
};
Compiled {
where_sql: sql,
params,
needs_capture_time: filter.needs_capture_time(),
}
}
fn emit(s: &Selector, now: i64, p: &mut Vec<Value>) -> String {
match s {
Selector::All => "1".into(),
Selector::Collection(id) => {
p.push(Value::Integer(id.0 as i64));
format!(
"EXISTS (SELECT 1 FROM collection_members m
WHERE m.image_id = images.id AND m.collection_id = ?{})",
p.len()
)
}
Selector::Folder {
root,
path,
recursive,
} => {
p.push(Value::Integer(root.0 as i64));
let root_ix = p.len();
if *recursive {
// Prefix match on the folder path. `like_prefix` escapes the
// pattern metacharacters, so a folder literally named "50%"
// matches itself and not everything.
p.push(Value::Text(like_prefix(path)));
format!(
"images.folder_id IN (
SELECT id FROM folders
WHERE root_id = ?{root_ix}
AND (path = ?{p} OR path LIKE ?{p} || '/%' ESCAPE '\\'))",
p = p.len()
)
} else {
p.push(Value::Text(path.clone()));
format!(
"images.folder_id IN (
SELECT id FROM folders WHERE root_id = ?{root_ix} AND path = ?{})",
p.len()
)
}
}
Selector::DateRange(d) => emit_date(d, now, p),
Selector::Rating { min } => {
p.push(Value::Integer(*min as i64));
format!("{} >= ?{}", default_version_scalar("rating"), p.len())
}
Selector::Label(l) => {
p.push(Value::Integer(label_code(*l)));
format!("{} = ?{}", default_version_scalar("label"), p.len())
}
Selector::Flag(f) => {
p.push(Value::Integer(flag_code(*f)));
format!("{} = ?{}", default_version_scalar("flag"), p.len())
}
Selector::Keyword(k) => {
p.push(Value::Text(k.clone()));
format!(
"EXISTS (SELECT 1 FROM keywords kw
JOIN versions kv ON kv.id = kw.version_id
WHERE kv.image_id = images.id AND kw.keyword = ?{})",
p.len()
)
}
Selector::Camera(c) => {
p.push(Value::Text(c.clone()));
format!("images.camera = ?{}", p.len())
}
Selector::Lens(l) => {
p.push(Value::Text(l.clone()));
format!("images.lens = ?{}", p.len())
}
Selector::IsoRange { min, max } => {
p.push(Value::Integer(*min as i64));
let lo = p.len();
p.push(Value::Integer(*max as i64));
format!("images.iso BETWEEN ?{lo} AND ?{}", p.len())
}
Selector::Availability(a) => {
p.push(Value::Integer(availability_code(*a)));
format!("images.availability = ?{}", p.len())
}
Selector::Text(t) => {
// Substring over filename and keywords. A LIKE scan is adequate at
// 50k; if free text over title and description becomes a real
// workflow, FTS5 is the answer and it is additive.
p.push(Value::Text(format!("%{}%", escape_like(t))));
let ix = p.len();
format!(
"(images.source_ref LIKE ?{ix} ESCAPE '\\'
OR EXISTS (SELECT 1 FROM keywords kw
JOIN versions kv ON kv.id = kw.version_id
WHERE kv.image_id = images.id
AND kw.keyword LIKE ?{ix} ESCAPE '\\'))"
)
}
// An empty conjunction is vacuously true; an empty disjunction matches
// nothing. Both arise from a UI that lets every term be cleared, and
// conflating them would show the whole library when the user meant the
// opposite.
Selector::All_(v) if v.is_empty() => "1".into(),
Selector::Any(v) if v.is_empty() => "0".into(),
Selector::All_(v) => join(v, " AND ", now, p),
Selector::Any(v) => join(v, " OR ", now, p),
Selector::Not(inner) => format!("NOT ({})", emit(inner, now, p)),
}
}
fn join(items: &[Selector], op: &str, now: i64, p: &mut Vec<Value>) -> String {
let parts: Vec<String> = items.iter().map(|s| emit(s, now, p)).collect();
format!("({})", parts.join(op))
}
fn emit_date(d: &DateSelector, now: i64, p: &mut Vec<Value>) -> String {
match d {
DateSelector::Between { from, to } => {
p.push(Value::Integer(*from));
let lo = p.len();
p.push(Value::Integer(*to));
// Half-open, so adjacent ranges neither overlap nor gap.
format!(
"(images.captured_at >= ?{lo} AND images.captured_at < ?{})",
p.len()
)
}
DateSelector::Rolling { days } => {
let from = now - (*days as i64) * 86_400;
p.push(Value::Integer(from));
format!("images.captured_at >= ?{}", p.len())
}
DateSelector::CollectionSpan(id) => {
p.push(Value::Integer(id.0 as i64));
let ix = p.len();
format!(
"images.captured_at BETWEEN
(SELECT min(i2.captured_at) FROM images i2
JOIN collection_members m2 ON m2.image_id = i2.id
WHERE m2.collection_id = ?{ix})
AND (SELECT max(i2.captured_at) FROM images i2
JOIN collection_members m2 ON m2.image_id = i2.id
WHERE m2.collection_id = ?{ix})"
)
}
}
}
/// Rating, label, and flag live on the *default* version, not the image.
///
/// A correlated subquery rather than a join, so these compose inside `OR` and
/// `NOT` without the join multiplying rows.
fn default_version_scalar(col: &str) -> String {
format!(
"(SELECT dv.{col} FROM versions dv
WHERE dv.image_id = images.id AND dv.is_default = 1 LIMIT 1)"
)
}
/// Escape LIKE metacharacters so a literal `%` or `_` in user text matches
/// itself. Paired with `ESCAPE '\'` in every LIKE that uses it.
fn escape_like(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, '%' | '_' | '\\') {
out.push('\\');
}
out.push(c);
}
out
}
fn like_prefix(path: &str) -> String {
escape_like(path.trim_end_matches('/'))
}
fn label_code(l: ColourLabel) -> i64 {
match l {
ColourLabel::Red => 1,
ColourLabel::Yellow => 2,
ColourLabel::Green => 3,
ColourLabel::Blue => 4,
ColourLabel::Purple => 5,
}
}
fn flag_code(f: FlagState) -> i64 {
match f {
FlagState::Unflagged => 0,
FlagState::Pick => 1,
FlagState::Reject => 2,
}
}
/// The stored form of an availability. Shared with [`crate::walk`], which
/// writes the column this reads — two spellings of the same mapping would
/// filter for a state nothing ever writes.
pub(crate) fn availability_code(a: Availability) -> i64 {
match a {
Availability::MetadataOnly => 0,
Availability::Preview => 1,
Availability::Original => 2,
Availability::Offline => 3,
}
}
/// Build the full SELECT for a window of results.
///
/// Joins are added only where the sort needs them, so an unsorted-by-rating
/// grid query touches one table.
pub fn window_sql(q: &Query, compiled: &Compiled) -> String {
let mut joins = String::new();
if q.sort.needs_version() {
joins.push_str(" LEFT JOIN versions v ON v.image_id = images.id AND v.is_default = 1");
}
if q.sort.needs_membership() {
// Only meaningful when the filter scopes to one collection; elsewhere
// position is NULL and the sort falls through to capture time.
joins.push_str(" LEFT JOIN collection_members cm ON cm.image_id = images.id");
}
format!(
"SELECT images.id, images.source_ref, images.availability, images.captured_at, \
images.captured_offset, images.metadata_state \
FROM images{joins} WHERE {} {} LIMIT ? OFFSET ?",
compiled.where_sql,
q.sort.sql(q.descending)
)
}
/// Build the COUNT for the same filter.
pub fn count_sql(compiled: &Compiled) -> String {
format!("SELECT count(*) FROM images WHERE {}", compiled.where_sql)
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::{CollectionId, RootId};
#[test]
fn unfiltered_compiles_to_a_constant() {
let c = compile(&Selector::All, 0);
assert_eq!(c.where_sql, "1");
assert!(c.params.is_empty());
}
#[test]
fn empty_conjunction_and_disjunction_differ() {
// The distinction that decides whether clearing a filter shows
// everything or nothing.
assert_eq!(compile(&Selector::All_(vec![]), 0).where_sql, "1");
assert_eq!(compile(&Selector::Any(vec![]), 0).where_sql, "0");
}
#[test]
fn values_bind_rather_than_interpolate() {
// The injection guard: a hostile keyword must appear in params, never
// in SQL text.
let evil = "'; DROP TABLE images; --";
let c = compile(&Selector::Keyword(evil.into()), 0);
assert!(!c.where_sql.contains("DROP"));
assert_eq!(c.params, vec![Value::Text(evil.into())]);
}
#[test]
fn like_metacharacters_are_escaped() {
// A search for "50%" must not match everything containing "50".
let c = compile(&Selector::Text("50%".into()), 0);
assert_eq!(c.params, vec![Value::Text("%50\\%%".into())]);
assert!(c.where_sql.contains("ESCAPE"));
}
#[test]
fn a_backslash_in_search_text_is_itself_escaped() {
let c = compile(&Selector::Text("a\\b".into()), 0);
assert_eq!(c.params, vec![Value::Text("%a\\\\b%".into())]);
}
#[test]
fn rolling_window_resolves_against_supplied_now() {
// Passed in rather than read from the clock, so the window is stable
// across one query and reproducible in a test.
let now = 1_000_000i64;
let c = compile(
&Selector::DateRange(DateSelector::Rolling { days: 90 }),
now,
);
assert_eq!(c.params, vec![Value::Integer(now - 90 * 86_400)]);
}
#[test]
fn between_is_half_open() {
let c = compile(
&Selector::DateRange(DateSelector::Between { from: 10, to: 20 }),
0,
);
// Half-open so adjacent day buckets neither overlap nor leave a gap.
assert!(c.where_sql.contains(">= ?1"));
assert!(c.where_sql.contains("< ?2"));
}
#[test]
fn nested_composition_numbers_parameters_in_order() {
let s = Selector::All_(vec![
Selector::Rating { min: 4 },
Selector::Any(vec![
Selector::Camera("X-T5".into()),
Selector::Not(Box::new(Selector::Lens("XF 35".into()))),
]),
]);
let c = compile(&s, 0);
assert_eq!(
c.params,
vec![
Value::Integer(4),
Value::Text("X-T5".into()),
Value::Text("XF 35".into()),
]
);
assert!(c.where_sql.contains("?1"));
assert!(c.where_sql.contains("?2"));
assert!(c.where_sql.contains("?3"));
}
#[test]
fn recursive_folder_matches_the_folder_itself_and_below() {
let c = compile(
&Selector::Folder {
root: RootId(1),
path: "2026/08".into(),
recursive: true,
},
0,
);
// Both branches: the folder's own images and those in subfolders.
assert!(c.where_sql.contains("path = ?2"));
assert!(c.where_sql.contains("|| '/%'"));
}
#[test]
fn collection_span_binds_its_id_once_and_reuses_it() {
let c = compile(
&Selector::DateRange(DateSelector::CollectionSpan(CollectionId(7))),
0,
);
assert_eq!(c.params, vec![Value::Integer(7)]);
}
#[test]
fn capture_time_dependency_is_reported() {
let c = compile(&Selector::DateRange(DateSelector::Rolling { days: 7 }), 0);
assert!(c.needs_capture_time);
let c = compile(&Selector::Rating { min: 5 }, 0);
assert!(!c.needs_capture_time);
}
#[test]
fn capture_sort_puts_unknown_timestamps_last_in_both_directions() {
// An image whose EXIF has not been read yet must not lead the grid
// just because its timestamp is NULL.
assert!(Sort::CapturedAt.sql(true).contains("IS NULL"));
assert!(Sort::CapturedAt.sql(false).contains("IS NULL"));
}
#[test]
fn window_sql_joins_only_when_the_sort_needs_it() {
let c = compile(&Selector::All, 0);
let plain = window_sql(
&Query {
filter: Selector::All,
sort: Sort::CapturedAt,
descending: true,
},
&c,
);
assert!(!plain.contains("JOIN"));
let rated = window_sql(
&Query {
filter: Selector::All,
sort: Sort::Rating,
descending: true,
},
&c,
);
assert!(rated.contains("JOIN versions"));
}
}
-733
View File
@@ -1,733 +0,0 @@
//! TRACES: FR-CAT-5 | FR-CAT-6 | FR-CULL-4
//! Star ratings and pick/reject flags — the judgement a cull produces.
//!
//! # Why this hangs off `versions` rather than `images`
//!
//! The schema already carries `rating`, `label` and `flag` on `versions`, and
//! [`crate::query`] already compiles [`dr_types::Selector::Rating`] and
//! [`dr_types::Selector::Flag`] against the *default* version. What was
//! missing is that nothing ever created a version row: a scan inserts into
//! `images` and stops, so every image had no version, and therefore nowhere
//! to record a rating. The whole library sat permanently unrated with no way
//! out of that state.
//!
//! So this module's first job is [`ensure_default_versions`] — every image
//! gets exactly one default version, created at scan time and backfilled by
//! the v2 migration for libraries scanned before this existed.
//!
//! Keeping judgement on the version rather than the image is what makes
//! FR-CAT-12's virtual copies coherent: two crops of one frame are two
//! photographs to the photographer, and one may be a keeper while the other
//! is a reject. Hoisting the rating onto the image would force them to agree.
//!
//! # Unrated is a real state, not a zero
//!
//! `rating = 0` means *not yet judged*, and that is precisely what "filter to
//! unjudged" selects (FR-CULL-4). It is deliberately not conflated with "one
//! star" or with "rejected" — those are three different answers, and a cull
//! that cannot distinguish "I have not looked at this" from "I looked and it
//! is poor" cannot be resumed.
use rusqlite::{Connection, OptionalExtension};
use dr_types::{FlagState, ImageId};
use crate::error::CatalogError;
/// Highest star rating. Five, as every photo tool has settled on.
pub const MAX_RATING: u8 = 5;
/// Name given to the version created for an image that has none.
///
/// Matches what [`crate::collections`] and the sidecar both expect to see for
/// the original, unmodified frame.
pub const DEFAULT_VERSION_NAME: &str = "Default";
/// The judgement recorded against one image's default version.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Judgement {
/// 0..=5. Zero means *unrated*, which is a state in its own right.
pub rating: u8,
pub flag: FlagState,
}
impl Judgement {
/// Whether this image has been judged at all.
///
/// Either axis counts: a photographer who flags without starring, or stars
/// without flagging, has still made a decision about the frame. "Filter to
/// unjudged" (FR-CULL-4) is the negation of this, and getting it wrong
/// means a resumed session re-presents work already done.
pub fn is_judged(self) -> bool {
self.rating > 0 || self.flag != FlagState::Unflagged
}
}
/// Give every image without one a default version.
///
/// Idempotent, and cheap on the common path: the `NOT EXISTS` sub-select is
/// answered by the `versions_image` index, so a library that already has its
/// versions costs one indexed scan and writes nothing.
///
/// 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.
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
// than seconds.
let tx = conn.unchecked_transaction()?;
let n = ensure_default_versions_within(&tx)?;
tx.commit()?;
Ok(n)
}
/// [`ensure_default_versions`] without opening a transaction.
///
/// Separate because SQLite has no nested `BEGIN`: [`crate::merge`] needs the
/// invariant restored *inside* the merge transaction — an incoming keyword
/// lands on a default version, so an image without one would silently drop it —
/// and calling the public form there fails at runtime with "cannot start a
/// 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> = {
let mut stmt = conn.prepare(
"SELECT i.id FROM images i
WHERE NOT EXISTS (SELECT 1 FROM versions v WHERE v.image_id = i.id)",
)?;
let found = stmt
.query_map([], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
found
};
if ids.is_empty() {
return Ok(0);
}
{
let mut insert = conn.prepare(
"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])?;
}
}
Ok(ids.len())
}
/// The default version's row id for an image, creating one if it has none.
///
/// Every write path goes through this rather than assuming a version exists.
/// An image can arrive without one in two ways that are not worth trying to
/// prevent: a row inserted by a build predating this module, and a scan whose
/// version pass was interrupted between the image insert and the commit.
/// Failing a rating because of either would be the wrong answer — the user
/// pressed a key and expects a star.
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
let existing: Option<i64> = conn
.query_row(
"SELECT id FROM versions
WHERE image_id = ?1
ORDER BY is_default DESC, id ASC
LIMIT 1",
[image.0 as i64],
|r| r.get(0),
)
.optional()?;
if let Some(id) = existing {
return Ok(id);
}
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],
)?;
Ok(conn.last_insert_rowid())
}
/// Set the star rating for one image, clamped to 0..=[`MAX_RATING`].
///
/// Clamped rather than rejected: the value comes from a keystroke or a click
/// on a star strip, and there is no useful error to show a photographer who
/// pressed a key. Out of range can only mean a UI bug, and losing the
/// keystroke would be a worse symptom than recording five.
pub fn set_rating(conn: &Connection, image: ImageId, rating: u8) -> Result<(), CatalogError> {
let version = default_version_id(conn, image)?;
conn.execute(
"UPDATE versions SET rating = ?2 WHERE id = ?1",
rusqlite::params![version, rating.min(MAX_RATING) as i64],
)?;
Ok(())
}
/// Set the pick/reject flag for one image.
pub fn set_flag(conn: &Connection, image: ImageId, flag: FlagState) -> Result<(), CatalogError> {
let version = default_version_id(conn, image)?;
conn.execute(
"UPDATE versions SET flag = ?2 WHERE id = ?1",
rusqlite::params![version, flag_code(flag)],
)?;
Ok(())
}
/// Apply a rating to many images in one transaction.
///
/// The bulk path exists because rating a selection is one gesture: the user
/// selects forty frames and presses `3`. Forty separate transactions would be
/// forty fsyncs for what is conceptually a single edit, and a crash partway
/// through would leave the selection half-rated.
pub fn set_rating_many(
conn: &Connection,
images: &[ImageId],
rating: u8,
) -> Result<usize, CatalogError> {
apply_many(conn, images, |conn, id| set_rating(conn, id, rating))
}
/// Apply a flag to many images in one transaction. See [`set_rating_many`].
pub fn set_flag_many(
conn: &Connection,
images: &[ImageId],
flag: FlagState,
) -> Result<usize, CatalogError> {
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
}
/// Shared bulk wrapper, so the two axes cannot drift in their commit
/// behaviour — a partially-committed rating and a fully-committed flag from
/// the same keystroke would be hard to explain and harder to notice.
fn apply_many(
conn: &Connection,
images: &[ImageId],
mut one: impl FnMut(&Connection, ImageId) -> Result<(), CatalogError>,
) -> Result<usize, CatalogError> {
if images.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
for id in images {
one(&tx, *id)?;
}
tx.commit()?;
Ok(images.len())
}
/// Read the judgement for one image.
///
/// An image with no version reads as unrated and unflagged rather than as an
/// error: that is exactly what it is.
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
let row: Option<(i64, i64)> = conn
.query_row(
"SELECT rating, flag FROM versions
WHERE image_id = ?1
ORDER BY is_default DESC, id ASC
LIMIT 1",
[image.0 as i64],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
Ok(match row {
Some((rating, flag)) => Judgement {
rating: rating.clamp(0, MAX_RATING as i64) as u8,
flag: flag_from_code(flag),
},
None => Judgement::default(),
})
}
/// Judgements for a window of images, in one statement.
///
/// The grid needs a star strip per cell, and one query per cell would be 120
/// round trips on every scroll — the same reasoning as
/// `collections_ui::sync_badges`. Images with no version simply do not appear
/// in the result, and the caller treats a miss as unrated.
pub fn judgements(
conn: &Connection,
images: &[ImageId],
) -> Result<std::collections::HashMap<ImageId, Judgement>, CatalogError> {
let mut out = std::collections::HashMap::new();
if images.is_empty() {
return Ok(out);
}
// Placeholders are generated from the *count* of ids, never from any text
// that came from outside — the same rule `read_cells_scoped` follows.
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT image_id, rating, flag FROM versions
WHERE image_id IN ({placeholders}) AND is_default = 1"
);
let params: Vec<rusqlite::types::Value> = images
.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((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
))
})?;
for (image, rating, flag) in rows.flatten() {
out.insert(
ImageId(image as u64),
Judgement {
rating: rating.clamp(0, MAX_RATING as i64) as u8,
flag: flag_from_code(flag),
},
);
}
Ok(out)
}
/// How the library divides by rating, for the filter bar's counts.
///
/// Index `n` is the number of images rated `n`, so index 0 is the unrated
/// count. Shown beside each filter button so the user can see there is
/// something behind it before narrowing to it — a filter that silently
/// empties the grid reads as a broken filter.
pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
let mut out = [0usize; 6];
// LEFT JOIN, so an image whose version row is missing still counts as
// unrated rather than vanishing from the totals. The histogram has to sum
// to the library size or it is not believable.
let mut stmt = conn.prepare(
"SELECT coalesce(v.rating, 0) AS r, count(*)
FROM images i
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
GROUP BY r",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
for (rating, count) in rows.flatten() {
if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) {
*slot += count as usize;
}
}
Ok(out)
}
/// How many images carry each flag: `(picks, rejects)`.
pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> {
let picks: i64 = conn.query_row(
"SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 1",
[],
|r| r.get(0),
)?;
let rejects: i64 = conn.query_row(
"SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 2",
[],
|r| r.get(0),
)?;
Ok((picks as usize, rejects as usize))
}
/// The stored integer for a flag. Matches [`crate::query::flag_code`]'s
/// mapping — the two must agree or a filter will not find what a write stored.
fn flag_code(f: FlagState) -> i64 {
match f {
FlagState::Unflagged => 0,
FlagState::Pick => 1,
FlagState::Reject => 2,
}
}
fn flag_from_code(v: i64) -> FlagState {
match v {
1 => FlagState::Pick,
2 => FlagState::Reject,
_ => FlagState::Unflagged,
}
}
/// A version UUID.
///
/// 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.
///
/// Seeded from the system clock and a per-process counter, so two versions
/// created inside the same nanosecond tick still differ.
fn new_uuid() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
// Mixed so successive ids do not share a long common prefix, which makes
// them easier to tell apart when reading a sidecar by eye.
let a = nanos ^ (n.wrapping_mul(0x9E37_79B9_7F4A_7C15));
let b = nanos
.rotate_left(32)
.wrapping_add(n.wrapping_mul(0xBF58_476D_1CE4_E5B9));
format!(
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
(a >> 32) as u32,
(a >> 16) as u16,
(a & 0x0FFF) as u16,
// Variant bits, so this is a well-formed v4-shaped UUID rather than
// something that merely looks like one.
((b >> 48) as u16 & 0x3FFF) | 0x8000,
b & 0xFFFF_FFFF_FFFF,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Catalog;
/// A catalog holding `n` images and nothing else — the state a scan
/// leaves behind before this module runs.
fn with_images(n: usize) -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
for i in 0..n {
c.execute(
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
[format!("img{i:03}.CR3")],
)
.unwrap();
}
cat
}
fn ids(cat: &Catalog) -> Vec<ImageId> {
let mut stmt = cat
.connection()
.prepare("SELECT id FROM images ORDER BY id")
.unwrap();
stmt.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))
.unwrap()
.map(Result::unwrap)
.collect()
}
#[test]
fn every_scanned_image_gets_a_default_version() {
// The gap this module exists to close: a scan inserted images and no
// versions, so there was nowhere for a rating to go.
let cat = with_images(5);
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 5);
let n: i64 = cat
.connection()
.query_row(
"SELECT count(*) FROM versions WHERE is_default = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(n, 5);
}
#[test]
fn images_enter_unrated_rather_than_at_one_star() {
// "Not yet judged" is the state a cull starts from and resumes to.
let cat = with_images(3);
ensure_default_versions(cat.connection()).unwrap();
for id in ids(&cat) {
let j = judgement(cat.connection(), id).unwrap();
assert_eq!(j.rating, 0);
assert_eq!(j.flag, FlagState::Unflagged);
assert!(!j.is_judged());
}
}
#[test]
fn backfilling_twice_creates_nothing_the_second_time() {
// Runs on every scan, so a second pass must not double every version.
let cat = with_images(4);
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 4);
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 0);
let n: i64 = cat
.connection()
.query_row("SELECT count(*) FROM versions", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 4, "one version per image, not two");
}
#[test]
fn version_uuids_are_unique_across_a_batch() {
// The uuid is the cross-device merge identity: two images sharing one
// silently fuse their edits at the next sync.
let cat = with_images(200);
ensure_default_versions(cat.connection()).unwrap();
let distinct: i64 = cat
.connection()
.query_row("SELECT count(DISTINCT uuid) FROM versions", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(distinct, 200);
}
#[test]
fn a_rating_round_trips() {
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 4).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().rating, 4);
}
#[test]
fn rating_an_image_with_no_version_creates_one() {
// A library scanned by a build predating this module, or a scan that
// died between the image insert and the version pass. The keystroke
// must still land.
let cat = with_images(1);
let id = ids(&cat)[0];
// Deliberately *not* calling ensure_default_versions first.
set_rating(cat.connection(), id, 3).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().rating, 3);
}
#[test]
fn an_out_of_range_rating_is_clamped_rather_than_stored() {
// A stored 9 would sort above five stars forever and no filter would
// reach it.
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 99).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().rating, MAX_RATING);
}
#[test]
fn rating_back_to_zero_returns_an_image_to_unrated() {
// Pressing 0 is how a mistake is undone, so it has to be reachable —
// not a floor at one star.
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 5).unwrap();
set_rating(cat.connection(), id, 0).unwrap();
let j = judgement(cat.connection(), id).unwrap();
assert_eq!(j.rating, 0);
assert!(!j.is_judged(), "back to unjudged, so a cull re-presents it");
}
#[test]
fn flags_and_stars_are_independent_axes() {
// Rejecting a four-star frame is a normal thing to do while culling,
// and one axis must not clear the other.
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 4).unwrap();
set_flag(cat.connection(), id, FlagState::Reject).unwrap();
let j = judgement(cat.connection(), id).unwrap();
assert_eq!(j.rating, 4);
assert_eq!(j.flag, FlagState::Reject);
}
#[test]
fn a_flag_alone_counts_as_judged() {
// Filter-to-unjudged must not re-present a frame the user already
// picked, merely because they did not also star it.
let cat = with_images(1);
let id = ids(&cat)[0];
set_flag(cat.connection(), id, FlagState::Pick).unwrap();
assert!(judgement(cat.connection(), id).unwrap().is_judged());
}
#[test]
fn a_bulk_rating_applies_to_the_whole_selection() {
// One gesture: select forty, press 3.
let cat = with_images(10);
let all = ids(&cat);
let chosen = &all[2..7];
assert_eq!(set_rating_many(cat.connection(), chosen, 3).unwrap(), 5);
for id in chosen {
assert_eq!(judgement(cat.connection(), *id).unwrap().rating, 3);
}
// And nothing outside the selection moved.
assert_eq!(judgement(cat.connection(), all[0]).unwrap().rating, 0);
assert_eq!(judgement(cat.connection(), all[9]).unwrap().rating, 0);
}
#[test]
fn a_bulk_write_over_an_empty_selection_is_a_no_op() {
let cat = with_images(3);
assert_eq!(set_rating_many(cat.connection(), &[], 5).unwrap(), 0);
assert_eq!(
set_flag_many(cat.connection(), &[], FlagState::Pick).unwrap(),
0
);
}
#[test]
fn judgements_reads_a_whole_window_in_one_query() {
// The grid draws a star strip per cell; one query per cell would be
// 120 round trips on every scroll.
let cat = with_images(6);
let all = ids(&cat);
set_rating(cat.connection(), all[1], 2).unwrap();
set_flag(cat.connection(), all[3], FlagState::Pick).unwrap();
let map = judgements(cat.connection(), &all).unwrap();
assert_eq!(map.get(&all[1]).unwrap().rating, 2);
assert_eq!(map.get(&all[3]).unwrap().flag, FlagState::Pick);
// Never rated, so it is either absent or explicitly unrated — both
// mean the same thing to the caller.
assert_eq!(
map.get(&all[5]).copied().unwrap_or_default(),
Judgement::default()
);
}
#[test]
fn the_histogram_sums_to_the_library_size() {
// A histogram that disagrees with the image count is not believable,
// and the unrated bucket is the one a fresh library lives in.
let cat = with_images(8);
ensure_default_versions(cat.connection()).unwrap();
let all = ids(&cat);
set_rating(cat.connection(), all[0], 5).unwrap();
set_rating(cat.connection(), all[1], 5).unwrap();
set_rating(cat.connection(), all[2], 3).unwrap();
let h = rating_histogram(cat.connection()).unwrap();
assert_eq!(h[5], 2);
assert_eq!(h[3], 1);
assert_eq!(h[0], 5, "the rest are still unrated");
assert_eq!(h.iter().sum::<usize>(), 8);
}
#[test]
fn the_histogram_counts_images_with_no_version_as_unrated() {
// They are unrated. Dropping them would make the counts disagree with
// the grid, which is the failure the LEFT JOIN exists to prevent.
let cat = with_images(4);
// No ensure_default_versions call at all.
let h = rating_histogram(cat.connection()).unwrap();
assert_eq!(h[0], 4);
assert_eq!(h.iter().sum::<usize>(), 4);
}
#[test]
fn flag_counts_separate_picks_from_rejects() {
let cat = with_images(5);
let all = ids(&cat);
set_flag(cat.connection(), all[0], FlagState::Pick).unwrap();
set_flag(cat.connection(), all[1], FlagState::Pick).unwrap();
set_flag(cat.connection(), all[2], FlagState::Reject).unwrap();
assert_eq!(flag_counts(cat.connection()).unwrap(), (2, 1));
}
#[test]
fn unflagging_removes_an_image_from_both_counts() {
let cat = with_images(2);
let all = ids(&cat);
set_flag(cat.connection(), all[0], FlagState::Reject).unwrap();
set_flag(cat.connection(), all[0], FlagState::Unflagged).unwrap();
assert_eq!(flag_counts(cat.connection()).unwrap(), (0, 0));
}
#[test]
fn a_rating_survives_the_selector_that_queries_it() {
// The end-to-end property: what this module writes is what
// `dr_catalog::query` compiles `Selector::Rating` to find. These are
// two independent pieces of SQL and they must agree on where a rating
// lives, or rating an image would appear to do nothing.
use crate::Query;
use dr_types::Selector;
let cat = with_images(6);
let all = ids(&cat);
ensure_default_versions(cat.connection()).unwrap();
set_rating(cat.connection(), all[0], 5).unwrap();
set_rating(cat.connection(), all[1], 4).unwrap();
set_rating(cat.connection(), all[2], 1).unwrap();
let q = Query {
filter: Selector::Rating { min: 4 },
..Default::default()
};
assert_eq!(cat.count(&q, 0).unwrap(), 2);
}
#[test]
fn a_flag_survives_the_selector_that_queries_it() {
// Same contract for the other axis: `flag_code` here and in `query`
// are separate mappings and must not drift.
use crate::Query;
use dr_types::Selector;
let cat = with_images(4);
let all = ids(&cat);
ensure_default_versions(cat.connection()).unwrap();
set_flag(cat.connection(), all[0], FlagState::Pick).unwrap();
set_flag(cat.connection(), all[1], FlagState::Reject).unwrap();
let picks = Query {
filter: Selector::Flag(FlagState::Pick),
..Default::default()
};
assert_eq!(cat.count(&picks, 0).unwrap(), 1);
let rejects = Query {
filter: Selector::Flag(FlagState::Reject),
..Default::default()
};
assert_eq!(cat.count(&rejects, 0).unwrap(), 1);
}
#[test]
fn unjudged_is_reachable_as_a_filter() {
// FR-CULL-4's "filter to unjudged", which is what lets a session
// resume where it stopped.
use crate::Query;
use dr_types::Selector;
let cat = with_images(5);
let all = ids(&cat);
ensure_default_versions(cat.connection()).unwrap();
set_rating(cat.connection(), all[0], 2).unwrap();
let q = Query {
filter: Selector::Rating { min: 0 },
..Default::default()
};
// `min: 0` matches everything, so unjudged needs the negation.
assert_eq!(cat.count(&q, 0).unwrap(), 5);
let unrated = Query {
filter: Selector::Not(Box::new(Selector::Rating { min: 1 })),
..Default::default()
};
assert_eq!(cat.count(&unrated, 0).unwrap(), 4);
}
}
-237
View File
@@ -1,237 +0,0 @@
//! TRACES: FR-CAT-1 | FR-CAT-9 | NFR-P1
//! Incremental scan: the local analogue of ETag pruning.
//!
//! Nextcloud propagates ETags up the tree, so one request proves a whole
//! library unchanged (ARCH §8.4). A filesystem offers no such guarantee — a
//! directory's mtime moves when its *direct* entries change and not when a
//! grandchild does, so there is no cheap "did anything below here change"
//! probe.
//!
//! Local scan therefore prunes at each level rather than at the root: one
//! metadata probe per directory when nothing changed, instead of one per file.
//! A 50k-image library in ~2k folders costs 2k probes, which is the difference
//! between meeting and missing NFR-P1 on SAF.
//!
//! This module holds the decision logic and the deletion-sweep rules; walking
//! an actual directory belongs to the platform layer, which supplies
//! [`DirState`] and [`DirEntry`]. [`crate::walk`] is what puts the two
//! together.
pub use dr_types::{DirEntry, DirState};
use dr_types::FormatFilter;
/// What the scanner should do with a directory, before listing it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirAction {
/// Contents unchanged. Skip the listing, but still recurse into known
/// children — without upward propagation, a deep change is invisible from
/// here.
RecurseOnly,
/// List and reconcile, then recurse.
ListAndRecurse,
}
/// Decide whether a directory needs listing.
pub fn classify_dir(stored: Option<DirState>, current: DirState) -> DirAction {
match stored {
Some(s) if s == current => DirAction::RecurseOnly,
_ => DirAction::ListAndRecurse,
}
}
/// What reconciling one listed entry against the catalog implies.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryAction {
/// Not catalogued. Insert at `metadata_state = 1` and queue EXIF.
Insert,
/// Catalogued and unchanged. The common case, and it must cost nothing.
Unchanged,
/// Size or mtime moved: re-read metadata, rebuild the thumbnail, and drop
/// the content hash, which is no longer valid.
Changed,
/// Recognised but not a format the user asked to scan for.
Ignored,
}
/// What the catalog already holds for a source.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KnownFile {
pub size: u64,
pub mtime: i64,
}
/// Classify one listed file.
pub fn classify_entry(
entry: &DirEntry,
known: Option<KnownFile>,
formats: &FormatFilter,
) -> EntryAction {
if !formats.allows_name(&entry.name) {
return EntryAction::Ignored;
}
match known {
None => EntryAction::Insert,
Some(k) if k.size == entry.size && k.mtime == entry.mtime => EntryAction::Unchanged,
Some(_) => EntryAction::Changed,
}
}
/// Outcome of a scan, which decides whether pruning may run.
///
/// `Cancelled` is the default because a scan that has not run has proven
/// nothing absent, and every default in this area must fail towards keeping
/// photographs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScanOutcome {
/// Every reachable folder was visited.
Complete,
/// The user cancelled. Partial state is valid — jobs are resumable — but
/// unvisited folders must not be read as deleted.
#[default]
Cancelled,
/// The root itself could not be opened: drive unplugged, SAF grant
/// revoked, share unmounted.
RootUnreachable,
/// Some subtree failed while the root was fine.
PartialFailure,
}
impl ScanOutcome {
/// Whether the deletion sweep may run.
///
/// **The most dangerous decision in the catalog.** The sweep deletes every
/// folder not reached by this scan's generation. After an incomplete scan
/// that is most of the library, so it runs only on `Complete`.
///
/// FR-CAT-9 draws exactly this line: a source *proven absent* may leave
/// the catalog; a source merely *unreachable* is marked offline and kept,
/// with its ratings and edits intact.
pub fn may_prune(self) -> bool {
matches!(self, ScanOutcome::Complete)
}
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::Format;
const A: DirState = DirState {
mtime: 100,
entry_count: 5,
};
#[test]
fn unchanged_directory_is_not_listed() {
assert_eq!(classify_dir(Some(A), A), DirAction::RecurseOnly);
}
#[test]
fn a_never_seen_directory_is_listed() {
assert_eq!(classify_dir(None, A), DirAction::ListAndRecurse);
}
#[test]
fn changed_mtime_forces_a_listing() {
let now = DirState { mtime: 101, ..A };
assert_eq!(classify_dir(Some(A), now), DirAction::ListAndRecurse);
}
#[test]
fn entry_count_catches_what_mtime_misses() {
// A file added within the same timestamp tick: mtime is unchanged, so
// mtime alone would skip this directory and lose the new image.
let now = DirState {
mtime: 100,
entry_count: 6,
};
assert_eq!(classify_dir(Some(A), now), DirAction::ListAndRecurse);
}
#[test]
fn unchanged_file_costs_nothing() {
let e = DirEntry {
name: "IMG_0001.CR3".into(),
is_dir: false,
size: 30_000_000,
mtime: 500,
};
let known = KnownFile {
size: 30_000_000,
mtime: 500,
};
assert_eq!(
classify_entry(&e, Some(known), &FormatFilter::all()),
EntryAction::Unchanged
);
}
#[test]
fn a_resaved_file_is_reprocessed() {
let e = DirEntry {
name: "IMG_0001.CR3".into(),
is_dir: false,
size: 30_000_001,
mtime: 900,
};
let known = KnownFile {
size: 30_000_000,
mtime: 500,
};
assert_eq!(
classify_entry(&e, Some(known), &FormatFilter::all()),
EntryAction::Changed
);
}
#[test]
fn format_filter_excludes_unwanted_types() {
let jpeg = DirEntry {
name: "IMG_0001.JPG".into(),
is_dir: false,
size: 1,
mtime: 1,
};
assert_eq!(
classify_entry(&jpeg, None, &FormatFilter::raw_only()),
EntryAction::Ignored
);
assert_eq!(
classify_entry(&jpeg, None, &FormatFilter::all()),
EntryAction::Insert
);
}
#[test]
fn a_placeholder_is_catalogued_as_the_image_it_stands_for() {
// 121,785 of these in a real synced folder (ARCH §9.0). Each must
// enter the catalog as a CR2 marked offline, not be skipped as an
// unknown ".nextcloud" type.
let stub = DirEntry {
name: "_MG_4130.CR2.nextcloud".into(),
is_dir: false,
size: 1,
mtime: 1,
};
assert_eq!(
classify_entry(&stub, None, &FormatFilter::from_formats([Format::Cr2])),
EntryAction::Insert
);
}
#[test]
fn pruning_requires_a_complete_scan() {
assert!(ScanOutcome::Complete.may_prune());
}
#[test]
fn an_unreachable_root_never_prunes() {
// The guard that stops an unplugged drive from deleting the library:
// every folder would look unreached, so the sweep would take all of
// them (FR-CAT-9).
assert!(!ScanOutcome::RootUnreachable.may_prune());
assert!(!ScanOutcome::Cancelled.may_prune());
assert!(!ScanOutcome::PartialFailure.may_prune());
}
}
File diff suppressed because it is too large Load Diff
-347
View File
@@ -1,347 +0,0 @@
//! TRACES: FR-CAT-7 | FR-NC-9 | NFR-R1
//! Preparing the catalog file for upload, and taking in a remote one.
//!
//! # The hazard this module exists to handle
//!
//! A WAL-mode SQLite database is not one file. Committed transactions can live
//! in `catalog.sqlite-wal` with the main file lagging behind, so copying
//! `catalog.sqlite` alone uploads a **torn snapshot**: internally consistent as
//! of some older point, missing everything since. Worse, a naive copy taken
//! while a writer is mid-transaction can be structurally corrupt.
//!
//! So an upload never copies the live file. It runs a TRUNCATE checkpoint to
//! fold the WAL back into the main file, then uses SQLite's own backup API to
//! take a consistent snapshot — which serialises correctly against concurrent
//! writers rather than racing them.
//!
//! # What is actually synced
//!
//! Only the *user's judgements about their library* merge: collections, and the
//! keyword vocabulary with its assignments (see [`crate::merge`]). The rest of
//! the catalog is a *local index* of *local* storage — folder mtimes, cache
//! paths, job rows — and copying another device's version of those in would be
//! actively wrong. The remote file is read for those two and then discarded.
//!
//! This is why the catalog remains disposable in the ARCH §6.12 sense: nothing
//! here makes the local database authoritative for anything a rebuild could
//! not recover.
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use crate::error::CatalogError;
use crate::merge::{self, MergeReport};
/// Schema name the downloaded remote catalog is attached under.
const REMOTE_SCHEMA: &str = "remote_cat";
/// Fold the WAL into the main database file.
///
/// TRUNCATE rather than PASSIVE: passive checkpointing gives up when a reader
/// holds the WAL open, which would leave recent commits out of the snapshot
/// without saying so.
pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> {
conn.pragma_update(None, "wal_checkpoint", "TRUNCATE")?;
Ok(())
}
/// Write a consistent snapshot of the catalog to `dest`, ready to upload.
///
/// Uses the backup API rather than a filesystem copy so the snapshot is
/// coherent even with writers active. Callers should still prefer a quiet
/// moment — this competes with background jobs for the write lock.
pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> {
checkpoint(conn)?;
let mut out = Connection::open(dest)?;
let backup = rusqlite::backup::Backup::new(conn, &mut out)?;
// SQLite's own "copy everything" sentinel is -1, but rusqlite asserts a
// positive page count, so ask for more pages than a catalog will ever
// have. The effect is the same: one step, no interleaved writers, no
// progress callback. A 50k-image catalog is tens of megabytes.
backup.run_to_completion(i32::MAX, std::time::Duration::ZERO, None)?;
drop(backup);
strip_face_crops(&out)?;
Ok(())
}
/// Drop the stored face crops from a snapshot before it is uploaded.
///
/// The snapshot is the *whole catalog*, uploaded on every sync and downloaded
/// by every device. Face crops are a few KB each and a fully indexed library
/// holds tens of thousands of them, so leaving them in would put tens of MB on
/// every round trip — the exact cost `face_shard`'s 25 MB cap exists to bound,
/// and the reason the bulk per-face data lives in shards in the first place.
///
/// Crops are not lost by this: they travel in the face shards
/// ([`crate::face_shard::export_to_shards`]), which are written once and
/// downloaded once. Nothing reads a crop out of a merged remote catalog —
/// [`merge_all`] touches collections and keywords only — so removing them here
/// costs a receiving device nothing it would otherwise have had.
///
/// `VACUUM` afterwards because SQLite does not return freed pages to the file
/// on its own, and an upload sized by the file rather than by its contents
/// would keep paying for bytes that are no longer there.
fn strip_face_crops(snapshot: &Connection) -> Result<(), CatalogError> {
// A catalog older than the crop column is a legitimate input here — a
// snapshot taken mid-migration, or a test fixture built from an earlier
// schema — so an absent column is nothing to fail over.
let has_crop = snapshot
.prepare("SELECT crop FROM faces LIMIT 1")
.map(|_| true)
.unwrap_or(false);
if !has_crop {
return Ok(());
}
snapshot.execute("UPDATE faces SET crop = NULL WHERE crop IS NOT NULL", [])?;
snapshot.execute_batch("VACUUM")?;
Ok(())
}
/// Whether a downloaded remote catalog is worth merging.
///
/// Cheap guard before attaching: a remote written by a newer build may contain
/// tables and columns this one cannot read, and attempting the merge would
/// fail mid-transaction rather than declining cleanly.
pub fn remote_is_mergeable(remote: &Path) -> Result<bool, CatalogError> {
let conn = Connection::open_with_flags(
remote,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
Ok(v <= crate::schema::SCHEMA_VERSION)
}
/// Attach a downloaded remote catalog, merge its collections, detach.
///
/// The remote file is opened **read-only** — this device never writes to
/// another device's catalog, it only reads collections out of it.
pub fn merge_remote(conn: &Connection, remote: &Path) -> Result<MergeReport, CatalogError> {
if !remote_is_mergeable(remote)? {
return Err(CatalogError::SchemaTooNew {
found: -1,
supported: crate::schema::SCHEMA_VERSION,
});
}
// Path binds as a parameter; ATTACH accepts one, so a path containing a
// quote cannot break out into SQL.
conn.execute(
&format!("ATTACH DATABASE ?1 AS {REMOTE_SCHEMA}"),
[remote.to_string_lossy().as_ref()],
)?;
let result = merge::merge_all(conn);
// Detach even if the merge failed, or the next attempt errors with
// "database remote_cat is already in use".
let detach = conn.execute(&format!("DETACH DATABASE {REMOTE_SCHEMA}"), []);
if let Err(e) = detach {
log::warn!("failed to detach remote catalog: {e}");
}
result
}
/// Where the catalog snapshot and the downloaded remote live.
///
/// Both are transient working files, not the catalog itself, so they belong in
/// the cache directory rather than beside the live database.
#[derive(Debug, Clone)]
pub struct SyncPaths {
pub upload_snapshot: PathBuf,
pub downloaded_remote: PathBuf,
}
impl SyncPaths {
pub fn in_dir(cache_dir: &Path) -> Self {
SyncPaths {
upload_snapshot: cache_dir.join("catalog-upload.sqlite"),
downloaded_remote: cache_dir.join("catalog-remote.sqlite"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema;
fn seeded(path: &Path) -> Connection {
let c = Connection::open(path).unwrap();
schema::configure(&c).unwrap();
schema::migrate(&c).unwrap();
c
}
#[test]
fn snapshot_captures_committed_data() {
let dir = tempdir();
let live = dir.join("catalog.sqlite");
let snap = dir.join("snap.sqlite");
let c = seeded(&live);
c.execute(
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
VALUES ('u1', 'Iceland', 0, 0, 1, 1)",
[],
)
.unwrap();
snapshot_for_upload(&c, &snap).unwrap();
// The snapshot must hold the row even though it was written after the
// database was created — the torn-file failure this guards against.
let s = Connection::open(&snap).unwrap();
let name: String = s
.query_row("SELECT name FROM collections", [], |r| r.get(0))
.unwrap();
assert_eq!(name, "Iceland");
}
#[test]
fn a_remote_from_a_newer_build_is_declined_not_attempted() {
let dir = tempdir();
let remote = dir.join("remote.sqlite");
let r = seeded(&remote);
r.pragma_update(None, "user_version", schema::SCHEMA_VERSION + 1)
.unwrap();
drop(r);
assert!(!remote_is_mergeable(&remote).unwrap());
let local = seeded(&dir.join("local.sqlite"));
assert!(matches!(
merge_remote(&local, &remote),
Err(CatalogError::SchemaTooNew { .. })
));
}
#[test]
fn merge_remote_round_trips_a_collection() {
let dir = tempdir();
let remote_path = dir.join("remote.sqlite");
{
let r = seeded(&remote_path);
r.execute(
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
VALUES ('u-remote', 'Portugal', 0, 0, 1, 1)",
[],
)
.unwrap();
checkpoint(&r).unwrap();
}
let local = seeded(&dir.join("local.sqlite"));
local
.execute(
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
VALUES ('u-local', 'Iceland', 0, 0, 1, 1)",
[],
)
.unwrap();
let report = merge_remote(&local, &remote_path).unwrap();
assert_eq!(report.inserted, 1);
let n: i64 = local
.query_row("SELECT count(*) FROM collections", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 2);
}
#[test]
fn the_remote_can_be_merged_twice_without_attach_conflict() {
// Detach must happen even on the failure path, or the second attempt
// errors with "database remote_cat is already in use".
let dir = tempdir();
let remote_path = dir.join("remote.sqlite");
{
let r = seeded(&remote_path);
r.execute(
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
VALUES ('u-remote', 'Portugal', 0, 0, 1, 1)",
[],
)
.unwrap();
checkpoint(&r).unwrap();
}
let local = seeded(&dir.join("local.sqlite"));
merge_remote(&local, &remote_path).unwrap();
let second = merge_remote(&local, &remote_path).unwrap();
assert!(!second.local_changed());
}
/// A scratch directory that cleans up with the test.
fn tempdir() -> PathBuf {
let base = std::env::temp_dir().join(format!(
"dr-catalog-test-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
base
}
/// The whole reason crops live in the shards: a snapshot is uploaded whole,
/// on every sync, to every device.
#[test]
fn the_snapshot_carries_no_face_crops() {
let dir = tempdir();
let live = dir.join("catalog.sqlite");
let snap = dir.join("snap.sqlite");
let c = seeded(&live);
c.execute(
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (1, 1, 'a.CR3', 0)",
[],
)
.unwrap();
c.execute(
"INSERT INTO faces
(image_id, x, y, w, h, landmarks, detector_confidence, embedding,
crop_px, model_id, detected_at, crop)
VALUES (1, 0.1, 0.1, 0.2, 0.2, X'00', 0.9, X'00', 180.0, 'm', 0, ?1)",
[vec![7u8; 4096]],
)
.unwrap();
snapshot_for_upload(&c, &snap).unwrap();
let out = Connection::open(&snap).unwrap();
let crops: i64 = out
.query_row(
"SELECT COUNT(*) FROM faces WHERE crop IS NOT NULL",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(crops, 0, "the snapshot still carries face crops");
// The face itself must still be there — only the pixels are dropped.
let faces: i64 = out
.query_row("SELECT COUNT(*) FROM faces", [], |r| r.get(0))
.unwrap();
assert_eq!(faces, 1);
// And the local catalog keeps its crop: this strips the copy, never
// the original.
let kept: i64 = c
.query_row(
"SELECT COUNT(*) FROM faces WHERE crop IS NOT NULL",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(kept, 1, "stripping the snapshot damaged the live catalog");
}
}
-636
View File
@@ -1,636 +0,0 @@
//! TRACES: FR-CAT-15 | NFR-R2
//! Soft delete, restore, and the permanent delete that follows.
//!
//! # Why the trash is a folder and not a flag
//!
//! The catalog is a *rebuildable index* (ARCH §6.12): delete `catalog.sqlite`
//! and it is reconstructed by rescanning sources. A trash implemented as a
//! column alone would therefore not survive its own design — a rebuild would
//! find every trashed file still sitting in the library and re-index it as an
//! ordinary photograph, silently undoing every delete the user had made.
//!
//! So a soft delete **moves the file** into `.darkroom-trash/` under the library
//! root, and the catalog merely records that this happened. The folder is the
//! durable fact; the row is the convenience. Recovering by hand needs no
//! DarkRoom at all, which is the property that matters when the thing being
//! risked is a photograph.
//!
//! `dr_sync::scan::is_excluded` keeps the scanner out of that folder. Without
//! it the next scan re-indexes the trash and the delete comes undone — the two
//! halves are one mechanism and neither works alone.
//!
//! # The two steps
//!
//! **Soft** ([`trash`]) — `MOVE` to the trash folder, record `trashed_at` and
//! the path it came from. Reversible by [`restore`], which is why the original
//! path has to be remembered: the trash is flat, and the folder structure cannot
//! be recovered from the trashed name.
//!
//! **Hard** ([`purge`]) — `DELETE` the file, then delete the row. Irreversible
//! from DarkRoom's side, though the server's own trashbin may still hold it.
//! Ordered file-first deliberately: see [`purge_order`].
//!
//! # What this module does not do
//!
//! It performs no I/O. Every function here records or reads catalog state, and
//! the caller pairs it with the remote operation — because the remote call is
//! async and the catalog is not, and because the *order* of the two is a
//! correctness property that belongs in one visible place rather than buried in
//! a transaction.
use rusqlite::{Connection, OptionalExtension};
use dr_types::ImageId;
use crate::error::CatalogError;
/// Directory holding soft-deleted images, under the library root.
///
/// The same constant `dr_sync::scan` excludes. Duplicated as a `const` here
/// rather than depended upon because `dr-catalog` does not (and should not)
/// depend on `dr-sync`; the pairing is asserted by a test.
pub const TRASH_DIR: &str = ".darkroom-trash";
/// One trashed image, as the trash view lists it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrashedImage {
pub image_id: ImageId,
/// Where the file is *now* — inside the trash folder.
pub source_ref: String,
/// Where it was before, and where [`restore`] will put it back.
pub trashed_from: String,
/// UTC seconds when it was trashed.
pub trashed_at: i64,
/// `oc:fileid`, preserved across the move. What the thumbnail store keys on,
/// and what makes a restore free rather than a re-download.
pub file_id: Option<u64>,
pub size: u64,
}
/// The path a soft-deleted image should be moved to.
///
/// Flat: the trash is a holding area, not an archive, and mirroring the library
/// tree inside it would mean creating directories on the way to deleting things.
/// The original path is remembered in the catalog instead, which is what
/// [`restore`] reads.
///
/// **Collisions are resolved rather than allowed to overwrite.** Two files named
/// `IMG_0001.CR2` from different folders are different photographs, and a `MOVE`
/// onto an existing name would destroy one of them — the precise failure a trash
/// exists to prevent. The image id disambiguates, and being already unique it
/// needs no retry loop.
pub fn trash_path(root: &str, image: ImageId, original: &str) -> String {
let name = original.rsplit(['/', ':']).next().unwrap_or(original);
let prefix = if root.is_empty() {
String::new()
} else {
format!("{root}/")
};
format!("{prefix}{TRASH_DIR}/{}-{name}", image.0)
}
/// Where a trashed image goes back to.
///
/// The stored original path, verbatim. Returns `None` where the image is not
/// trashed, so a caller cannot restore something that was never deleted.
pub fn restore_path(conn: &Connection, image: ImageId) -> Result<Option<String>, CatalogError> {
let path: Option<String> = conn
.query_row(
"SELECT trashed_from FROM images
WHERE id = ?1 AND trashed_at IS NOT NULL",
[image.0 as i64],
|r| r.get(0),
)
.optional()?
.flatten();
Ok(path)
}
/// Record that images have been moved to the trash.
///
/// Call **after** the move succeeds. Recording first and moving second would
/// leave the catalog claiming a file is trashed while it sits in the library,
/// where the next scan finds it — and since the scan excludes the trash folder,
/// the row would never be corrected.
///
/// `moved` pairs each image with the path it now occupies, which is what
/// [`trash_path`] produced for it.
///
/// Idempotent on `trashed_at`: re-trashing an already-trashed image keeps the
/// *original* timestamp and original path, so a retry after a partial failure
/// cannot rewrite `trashed_from` to a path inside the trash — which would make
/// the image unrestorable.
pub fn record_trashed(
conn: &Connection,
moved: &[(ImageId, String)],
now: i64,
) -> Result<usize, CatalogError> {
if moved.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
let mut n = 0;
{
let mut stmt = tx.prepare(
"UPDATE images
SET trashed_from = CASE
WHEN trashed_at IS NULL THEN source_ref
ELSE trashed_from
END,
source_ref = ?2,
trashed_at = coalesce(trashed_at, ?3)
WHERE id = ?1",
)?;
for (image, path) in moved {
n += stmt.execute(rusqlite::params![image.0 as i64, path, now])?;
}
}
tx.commit()?;
Ok(n)
}
/// Record that images have been moved back out of the trash.
///
/// Call after the move succeeds, for the same reason as [`record_trashed`].
/// Clears both columns: a restored image is an ordinary one, and leaving
/// `trashed_from` set would make the next trash-and-restore cycle restore it to
/// a stale location.
pub fn record_restored(
conn: &Connection,
restored: &[(ImageId, String)],
) -> Result<usize, CatalogError> {
if restored.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
let mut n = 0;
{
let mut stmt = tx.prepare(
"UPDATE images
SET source_ref = ?2, trashed_at = NULL, trashed_from = NULL
WHERE id = ?1 AND trashed_at IS NOT NULL",
)?;
for (image, path) in restored {
n += stmt.execute(rusqlite::params![image.0 as i64, path])?;
}
}
tx.commit()?;
Ok(n)
}
/// Forget images whose files have been permanently deleted.
///
/// Call **after** the remote delete succeeds — see [`purge_order`].
///
/// Deletes the catalog rows outright rather than tombstoning them. There is
/// nothing to merge: unlike a collection, an image row is derived from a file
/// that no longer exists, so a rescan on another device will not reintroduce it
/// and needs no tombstone to be told so. `ON DELETE CASCADE` takes the versions,
/// keywords, remote mapping and cache rows with it.
///
/// Returns how many rows went.
pub fn forget(conn: &Connection, images: &[ImageId]) -> Result<usize, CatalogError> {
if images.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
let mut n = 0;
{
let mut stmt = tx.prepare("DELETE FROM images WHERE id = ?1")?;
for image in images {
n += stmt.execute([image.0 as i64])?;
}
}
tx.commit()?;
Ok(n)
}
/// Why the file is deleted before the row.
///
/// Not a function — a note with a name, so the reasoning is findable from the
/// call site.
///
/// **File first, then the row.** If the delete succeeds and the process dies
/// before the row goes, the catalog holds a trashed row whose file is gone; the
/// user sees it in the trash, empties again, gets a `404`, and it is treated as
/// already-deleted (see [`is_already_gone`]). Recoverable, and visible.
///
/// The other order loses the file silently. Dropping the row first and dying
/// before the delete leaves an orphan in `.darkroom-trash/` that nothing in the
/// UI lists, nothing counts, and no scan will ever find — because the scanner
/// excludes that folder. It consumes quota forever and the user has no way to
/// learn it is there.
pub const fn purge_order() {}
/// Whether a delete failure means the file was already gone.
///
/// A `404` on the way to deleting something is success: the goal state is
/// "this file does not exist", and it does not. Treating it as an error would
/// wedge an empty-trash operation on a file the user had removed by hand, and
/// no amount of retrying would clear it.
pub fn is_already_gone(status: Option<u16>) -> bool {
matches!(status, Some(404) | Some(410))
}
/// List what is in the trash, newest first.
///
/// Newest first because the trash is reviewed to undo a recent mistake, not
/// browsed chronologically.
pub fn list(conn: &Connection, limit: usize) -> Result<Vec<TrashedImage>, CatalogError> {
let mut stmt = conn.prepare(
"SELECT i.id, i.source_ref, i.trashed_from, i.trashed_at, r.file_id, i.file_size
FROM images i
LEFT JOIN remote r ON r.image_id = i.id
WHERE i.trashed_at IS NOT NULL
ORDER BY i.trashed_at DESC, i.id DESC
LIMIT ?1",
)?;
let rows = stmt
.query_map([limit as i64], |r| {
let source_ref: String = r.get(1)?;
Ok(TrashedImage {
image_id: ImageId(r.get::<_, i64>(0)? as u64),
// A row with no `trashed_from` predates nothing — it cannot
// happen through this module — but a hand-edited or
// partially-migrated catalog could produce one. Falling back to
// the current path keeps it listed and deletable rather than
// invisible; a restore to the trash folder is a no-op the user
// can see, where a hidden row is not.
trashed_from: r
.get::<_, Option<String>>(2)?
.unwrap_or_else(|| source_ref.clone()),
source_ref,
trashed_at: r.get(3)?,
file_id: r.get::<_, Option<i64>>(4)?.map(|v| v as u64),
size: r.get::<_, Option<i64>>(5)?.unwrap_or(0) as u64,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Every trashed image id, for emptying the whole trash.
///
/// Separate from [`list`] because emptying needs all of them, not a window, and
/// wants no per-row detail.
pub fn all_trashed(conn: &Connection) -> Result<Vec<ImageId>, CatalogError> {
let mut stmt = conn.prepare("SELECT id FROM images WHERE trashed_at IS NOT NULL")?;
let rows = stmt
.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// How many images are in the trash, and how many bytes they hold.
///
/// The bytes are the point: "empty trash" is a destructive action, and the
/// amount being freed is what tells the user whether they meant it.
pub fn summary(conn: &Connection) -> Result<(usize, u64), CatalogError> {
let (n, bytes): (i64, i64) = conn.query_row(
"SELECT count(*), coalesce(sum(file_size), 0)
FROM images WHERE trashed_at IS NOT NULL",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
Ok((n as usize, bytes as u64))
}
/// `oc:fileid`s of trashed images, so their thumbnails can be dropped.
///
/// The thumbnail store is keyed on the stable file id and shared with other
/// clients, so a purge that left its entries behind would keep serving previews
/// of photographs that no longer exist — and the shards sync, so it would keep
/// doing so on every other device too.
pub fn file_ids_for(conn: &Connection, images: &[ImageId]) -> Result<Vec<u64>, CatalogError> {
if images.is_empty() {
return Ok(Vec::new());
}
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!("SELECT file_id FROM remote WHERE image_id IN ({placeholders})");
let params: Vec<rusqlite::types::Value> = images
.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(r.get::<_, i64>(0)? as u64)
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Catalog;
fn seeded() -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'PhotosRaw')",
[],
)
.unwrap();
for i in 1..=4i64 {
c.execute(
"INSERT INTO images(id, root_id, source_ref, file_size, added_at)
VALUES (?1, 1, ?2, ?3, 0)",
rusqlite::params![i, format!("PhotosRaw/2019/IMG_{i:04}.CR2"), 30_000_000 * i],
)
.unwrap();
c.execute(
"INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)",
rusqlite::params![i, 1000 + i],
)
.unwrap();
}
cat
}
fn img(i: u64) -> ImageId {
ImageId(i)
}
/// Trash one image the way the UI does: compute the path, then record.
fn do_trash(cat: &Catalog, i: u64, now: i64) -> String {
let c = cat.connection();
let original: String = c
.query_row(
"SELECT source_ref FROM images WHERE id = ?1",
[i as i64],
|r| r.get(0),
)
.unwrap();
let to = trash_path("PhotosRaw", img(i), &original);
record_trashed(c, &[(img(i), to.clone())], now).unwrap();
to
}
#[test]
fn the_trash_directory_matches_the_one_the_scanner_excludes() {
// These are two constants in two crates that must agree, or the scan
// re-indexes the trash and every soft delete comes undone.
assert_eq!(TRASH_DIR, dr_sync_trash_dir());
}
/// The scanner's constant, quoted rather than imported — `dr-catalog` does
/// not depend on `dr-sync`, and adding that dependency for one string would
/// invert the layering.
fn dr_sync_trash_dir() -> &'static str {
".darkroom-trash"
}
#[test]
fn trashing_moves_the_path_and_remembers_where_it_came_from() {
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 5_000);
let (source, from, at): (String, String, i64) = c
.query_row(
"SELECT source_ref, trashed_from, trashed_at FROM images WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
// `source_ref` follows the bytes: this is where a fetch must now look.
assert!(source.contains(TRASH_DIR), "{source}");
// And the original is remembered, or a restore has nowhere to go.
assert_eq!(from, "PhotosRaw/2019/IMG_0001.CR2");
assert_eq!(at, 5_000);
}
#[test]
fn the_trash_path_keeps_the_original_filename_recognisable() {
// The user reviewing the trash needs to recognise the photograph; an
// opaque id alone would make the list unreadable.
let p = trash_path("PhotosRaw", img(7), "PhotosRaw/2019/IMG_0042.CR2");
assert!(p.ends_with("IMG_0042.CR2"), "{p}");
assert!(p.starts_with("PhotosRaw/.darkroom-trash/"), "{p}");
}
#[test]
fn two_files_with_the_same_name_do_not_collide_in_the_trash() {
// The failure a trash exists to prevent: a MOVE onto an existing name
// destroys one of two different photographs.
let a = trash_path("PhotosRaw", img(1), "PhotosRaw/2019/IMG_0001.CR2");
let b = trash_path("PhotosRaw", img(2), "PhotosRaw/2024/IMG_0001.CR2");
assert_ne!(a, b);
}
#[test]
fn a_whole_account_root_yields_no_leading_slash() {
// The root is empty when the library is the whole account; a path
// beginning "/" would resolve differently on the server.
let p = trash_path("", img(3), "2019/IMG_0003.CR2");
assert_eq!(p, ".darkroom-trash/3-IMG_0003.CR2");
}
#[test]
fn restoring_puts_the_original_path_back_and_clears_the_flag() {
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 5_000);
let back = restore_path(c, img(1))
.unwrap()
.expect("knows where it came from");
assert_eq!(back, "PhotosRaw/2019/IMG_0001.CR2");
record_restored(c, &[(img(1), back.clone())]).unwrap();
let (source, at): (String, Option<i64>) = c
.query_row(
"SELECT source_ref, trashed_at FROM images WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(source, back);
assert_eq!(at, None, "a restored image is an ordinary one");
assert!(restore_path(c, img(1)).unwrap().is_none());
}
#[test]
fn a_trash_restore_trash_cycle_restores_to_the_right_place_twice() {
// If `trashed_from` were not cleared on restore, the second trash would
// record a stale origin and the second restore would put the file
// somewhere it never was.
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 1_000);
let first = restore_path(c, img(1)).unwrap().unwrap();
record_restored(c, &[(img(1), first.clone())]).unwrap();
do_trash(&cat, 1, 2_000);
let second = restore_path(c, img(1)).unwrap().unwrap();
assert_eq!(
first, second,
"the origin is the library path, not the trash"
);
}
#[test]
fn re_trashing_does_not_overwrite_the_original_path() {
// A retry after a partial failure must not record a trash-folder path as
// the origin — that makes the image unrestorable.
let cat = seeded();
let c = cat.connection();
let to = do_trash(&cat, 1, 1_000);
// Second attempt, as a retry would do.
record_trashed(c, &[(img(1), to)], 9_999).unwrap();
let (from, at): (String, i64) = c
.query_row(
"SELECT trashed_from, trashed_at FROM images WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(from, "PhotosRaw/2019/IMG_0001.CR2");
assert_eq!(at, 1_000, "the original timestamp survives a retry");
}
#[test]
fn restoring_something_that_was_never_trashed_does_nothing() {
let cat = seeded();
let c = cat.connection();
assert!(restore_path(c, img(2)).unwrap().is_none());
assert_eq!(
record_restored(c, &[(img(2), "elsewhere".into())]).unwrap(),
0
);
// And its path is untouched.
let source: String = c
.query_row("SELECT source_ref FROM images WHERE id = 2", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(source, "PhotosRaw/2019/IMG_0002.CR2");
}
#[test]
fn the_trash_lists_newest_first() {
// Reviewed to undo a recent mistake, not browsed chronologically.
let cat = seeded();
do_trash(&cat, 1, 1_000);
do_trash(&cat, 2, 3_000);
do_trash(&cat, 3, 2_000);
let listed = list(cat.connection(), 100).unwrap();
let order: Vec<u64> = listed.iter().map(|t| t.image_id.0).collect();
assert_eq!(order, vec![2, 3, 1]);
}
#[test]
fn the_trash_list_carries_the_file_id_a_restore_needs() {
// Without it a restore cannot find the thumbnail it already has, and
// re-downloads a preview it is holding.
let cat = seeded();
do_trash(&cat, 1, 1_000);
let listed = list(cat.connection(), 10).unwrap();
assert_eq!(listed[0].file_id, Some(1001));
}
#[test]
fn the_summary_reports_what_emptying_would_free() {
// "Empty trash" is destructive; the size is what tells the user whether
// they meant it.
let cat = seeded();
do_trash(&cat, 1, 1_000);
do_trash(&cat, 2, 1_000);
let (n, bytes) = summary(cat.connection()).unwrap();
assert_eq!(n, 2);
assert_eq!(bytes, 30_000_000 + 60_000_000);
}
#[test]
fn an_empty_trash_summarises_as_zero_rather_than_erroring() {
let cat = seeded();
assert_eq!(summary(cat.connection()).unwrap(), (0, 0));
assert!(all_trashed(cat.connection()).unwrap().is_empty());
}
#[test]
fn purging_removes_the_row_and_everything_hanging_off_it() {
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 1_000);
assert_eq!(forget(c, &[img(1)]).unwrap(), 1);
let n: i64 = c
.query_row("SELECT count(*) FROM images WHERE id = 1", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 0);
// The remote mapping must go too, or a later scan could pair a new file
// with a dead image's id.
let n: i64 = c
.query_row("SELECT count(*) FROM remote WHERE image_id = 1", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(n, 0, "cascaded");
}
#[test]
fn purging_leaves_untrashed_images_alone() {
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 1_000);
forget(c, &all_trashed(c).unwrap()).unwrap();
let n: i64 = c
.query_row("SELECT count(*) FROM images", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 3, "only the trashed one went");
}
#[test]
fn file_ids_are_collected_so_thumbnails_can_be_dropped() {
// The shards sync to the server; a purge that left them would serve
// previews of deleted photographs on every device.
let cat = seeded();
let c = cat.connection();
do_trash(&cat, 1, 1_000);
do_trash(&cat, 2, 1_000);
let mut ids = file_ids_for(c, &[img(1), img(2)]).unwrap();
ids.sort_unstable();
assert_eq!(ids, vec![1001, 1002]);
}
#[test]
fn a_missing_file_counts_as_already_deleted() {
// Otherwise one file removed by hand wedges every future empty-trash,
// and no amount of retrying clears it.
assert!(is_already_gone(Some(404)));
assert!(is_already_gone(Some(410)));
assert!(!is_already_gone(Some(403)), "a permission failure is real");
assert!(!is_already_gone(Some(500)));
assert!(!is_already_gone(None));
}
#[test]
fn empty_batches_are_no_ops_rather_than_errors() {
// The UI can reach these with nothing selected.
let cat = seeded();
let c = cat.connection();
assert_eq!(record_trashed(c, &[], 0).unwrap(), 0);
assert_eq!(record_restored(c, &[]).unwrap(), 0);
assert_eq!(forget(c, &[]).unwrap(), 0);
assert!(file_ids_for(c, &[]).unwrap().is_empty());
}
}
File diff suppressed because it is too large Load Diff
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "dr-decode"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-types.workspace = true
rawler.workspace = true
# The camera profile database is data, not code (FR-DEV-3e): a YAML file that
# ships with the binary and is superseded by a newer one on disk. serde_norway
# is the workspace's YAML crate — the fork still receiving releases — and it is
# already in the tree for `dr-pipeline`'s node declarations and `dr-ui`'s style
# tokens. Pure Rust, so it costs nothing under the Android NDK.
serde = { workspace = true }
serde_norway.workspace = true
zune-jpeg.workspace = true
thiserror.workspace = true
log.workspace = true
[dev-dependencies]
env_logger.workspace = true
-52
View File
@@ -1,52 +0,0 @@
//! Report the defect map a raw file carries, if it carries one.
//!
//! ```text
//! cargo run -p dr-decode --example defects -- IMG_6320.dng photo.cr2
//! ```
//!
//! Exists because whether this is worth building a correction stage for is a
//! question about *your files*, not about the specification: DNGs written by
//! cameras that map their own sensors carry `OpcodeList1`, conversions from a
//! proprietary raw usually do not, and no CR2 or scanner TIFF ever does.
//! Rather than guess, point this at the library and see.
fn main() {
let files: Vec<String> = std::env::args().skip(1).collect();
if files.is_empty() {
eprintln!("usage: defects <raw file>...");
std::process::exit(2);
}
for path in &files {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
println!("{path}: unreadable — {e}");
continue;
}
};
let found = dr_decode::defects(&bytes);
if found.is_empty() {
println!("{path}: no defect map");
continue;
}
println!(
"{path}: {} bad pixel(s), {} bad line(s)",
found.pixels.len(),
found.lines.len()
);
// A handful, so the output stays readable on a sensor reporting
// hundreds — the count above is the number that matters.
for p in found.pixels.iter().take(8) {
println!(" pixel at {},{}", p.x, p.y);
}
for l in found.lines.iter().take(8) {
match l {
dr_decode::BadLine::Column(x) => println!(" dead column {x}"),
dr_decode::BadLine::Row(y) => println!(" dead row {y}"),
}
}
}
}
-19
View File
@@ -1,19 +0,0 @@
fn main() {
for p in std::env::args().skip(1) {
let Ok(d) = std::fs::read(&p) else { continue };
let n = p.rsplit('/').next().unwrap();
// Exactly what the sweep sees: the first HEADER_BYTES only.
let head = &d[..d.len().min(dr_decode::HEADER_BYTES as usize)];
match dr_decode::metadata(head) {
Ok(m) => println!(
"{n}: header-only at={:?} model={:?}",
m.captured_at, m.model
),
Err(e) => println!("{n}: header-only ERROR {e}"),
}
match dr_decode::metadata(&d) {
Ok(m) => println!("{n}: whole-file at={:?}", m.captured_at),
Err(e) => println!("{n}: whole-file ERROR {e}"),
}
}
}
-61
View File
@@ -1,61 +0,0 @@
//! Print what `decode` extracts from a RAW file.
//!
//! A sanity check on the pipeline's inputs: black and white levels, the CFA
//! pattern after re-phasing, as-shot white balance, and the camera→sRGB
//! matrix. Wrong values here produce a wrong image no shader can fix, so it
//! is worth being able to see them directly.
//!
//! ```sh
//! cargo run -p dr-decode --example rawinfo -- IMG.CR2
//! ```
fn main() {
let Some(path) = std::env::args().nth(1) else {
eprintln!("usage: rawinfo <file.cr2>");
std::process::exit(2);
};
let bytes = std::fs::read(&path).expect("read file");
let raw = dr_decode::decode(&bytes).expect("decode");
println!("file {path}");
println!("readout {} × {}", raw.width, raw.height);
println!(
"crop {} × {} at ({}, {})",
raw.crop.width, raw.crop.height, raw.crop.x, raw.crop.y
);
let (dx, dy) = raw.crop.shifts_cfa_phase();
println!(
"cfa {:?} (rephased: {dx}, {dy})",
raw.cfa_pattern
);
println!("black {:?}", raw.black_level);
println!("white {}", raw.white_level);
println!("wb_coeffs {:?}", raw.wb_coeffs);
match raw.color_matrix {
Some(m) => {
println!("cam→srgb");
for row in m.chunks(3) {
println!(
" [{:>8.4} {:>8.4} {:>8.4}]",
row[0], row[1], row[2]
);
}
// Each row should sum to roughly 1: a neutral camera-space colour
// must stay neutral in sRGB. Far from 1 means the normalisation
// or the matrix composition is wrong.
let sums: Vec<f32> = m.chunks(3).map(|r| r.iter().sum()).collect();
println!("row sums {sums:.4?} (≈1.0 each if correct)");
}
None => println!("cam→srgb none — uncalibrated body"),
}
// Sample the actual data range, which reveals a black-level or bit-depth
// mistake faster than any amount of staring at metadata.
let (min, max) = raw
.data
.iter()
.fold((u16::MAX, 0u16), |(lo, hi), &v| (lo.min(v), hi.max(v)));
println!("sample range {min} … {max}");
}
-146
View File
@@ -1,146 +0,0 @@
//! Smoke test against real RAW files.
//!
//! cargo run -p dr-decode --example smoke -- <file-or-dir>...
//!
//! Reports, per file, what each entry point costs — which is the whole reason
//! they are separate (ARCH §3.2).
use std::path::{Path, PathBuf};
use std::time::Instant;
fn main() {
env_logger::init();
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
eprintln!("usage: smoke <file-or-dir>...");
std::process::exit(2);
}
let mut files = Vec::new();
for a in &args {
let p = PathBuf::from(a);
if p.is_dir() {
collect(&p, &mut files);
} else {
files.push(p);
}
}
files.sort();
files.truncate(8);
println!(
"{:<20} {:>7} {:>8} {:>9} {:>13} {:>9} {:>13}",
"file", "size", "meta", "thumb", "thumb dims", "full", "full dims"
);
println!("{}", "-".repeat(88));
let (mut ok, mut failed) = (0, 0);
for f in &files {
match run_one(f) {
Ok(line) => {
println!("{line}");
ok += 1;
}
Err(e) => {
println!("{:<22} {e}", truncate(&name(f), 22));
failed += 1;
}
}
}
println!("\n{ok} ok, {failed} failed");
if failed > 0 {
std::process::exit(1);
}
}
fn run_one(path: &Path) -> Result<String, String> {
let size = std::fs::metadata(path).map_err(|e| e.to_string())?.len();
// The culling path: read only the header region, not the whole file.
let probe_bytes =
read_prefix(path, dr_decode::PREVIEW_PROBE_BYTES).map_err(|e| e.to_string())?;
let t0 = Instant::now();
let fmt = dr_decode::probe(&probe_bytes);
let meta = dr_decode::metadata(&probe_bytes).ok();
let meta_ms = t0.elapsed().as_secs_f64() * 1000.0;
let all = std::fs::read(path).map_err(|e| e.to_string())?;
// The culling rung.
let t1 = Instant::now();
let thumb = dr_decode::extract_preview(&all, dr_decode::PreviewSize::Thumbnail)
.map_err(|e| format!("thumb: {e}"))?;
let thumb_ms = t1.elapsed().as_secs_f64() * 1000.0;
// The full-resolution rung, for comparison.
let t2 = Instant::now();
let full = dr_decode::extract_preview(&all, dr_decode::PreviewSize::Full)
.map_err(|e| format!("full: {e}"))?;
let full_ms = t2.elapsed().as_secs_f64() * 1000.0;
let model = meta
.as_ref()
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "?".into());
let budget = if thumb_ms <= 50.0 {
""
} else {
" OVER BUDGET"
};
Ok(format!(
"{:<20} {:>6.1}M {:>6.1}ms {:>7.1}ms {:>7}x{:<5} {:>7.1}ms {:>7}x{:<5} {:?} {}{}",
truncate(&name(path), 20),
size as f64 / 1e6,
meta_ms,
thumb_ms,
thumb.width,
thumb.height,
full_ms,
full.width,
full.height,
fmt,
model.trim(),
budget,
))
}
fn read_prefix(path: &Path, n: u64) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let mut f = std::fs::File::open(path)?;
let mut buf = vec![0u8; n as usize];
let read = f.read(&mut buf)?;
buf.truncate(read);
Ok(buf)
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_file() {
let ext = p
.extension()
.map(|s| s.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if dr_types::Format::from_extension(&ext).is_some() {
out.push(p);
}
}
}
}
fn name(p: &Path) -> String {
p.file_name().unwrap_or_default().to_string_lossy().into()
}
fn truncate(s: &str, n: usize) -> String {
if s.len() <= n {
s.to_string()
} else {
format!("{}…", &s[..n - 1])
}
}
-160
View File
@@ -1,160 +0,0 @@
# DarkRoom camera base curves (FR-DEV-3e).
#
# ---------------------------------------------------------------------------
# Adding a body is editing this file. It is not a code change.
# ---------------------------------------------------------------------------
#
# The copy you are reading is compiled into the binary as a floor. At startup
# `dr_decode::base_curve::load` also looks for `base_curves.yaml` in:
#
# 1. $DARKROOM_PROFILES/ (set it while you are tuning)
# 2. $XDG_DATA_HOME/darkroom/profiles/
# or $HOME/.local/share/darkroom/profiles/
#
# and uses the first one it finds *whose `version:` is higher than this one's*.
# So: bump `version`, drop the file in that directory, restart. A body added
# this afternoon renders correctly this afternoon, with no release and no
# rebuild — which is what the requirement asks for, and what makes these
# contributable under the GPL.
#
# The version check runs both ways on purpose. A file older than the built-in
# copy is ignored with a log line, so upgrading DarkRoom cannot silently lose
# curves to a pack somebody downloaded a year ago.
#
# ---------------------------------------------------------------------------
# What the numbers mean
# ---------------------------------------------------------------------------
#
# Five `[x, y]` control points on a monotone spline (Fritsch-Carlson, the same
# one the tone curve widget draws). Both axes are **linear**:
#
# x scene-referred camera RGB after white balance, 1.0 = sensor saturation
# y display-referred linear; the sRGB transfer function is applied later,
# at the end of the shader, so do not pre-apply a gamma here
#
# The identity is y = x, and it is what an unrecognised body gets if `default:`
# is removed. It is also the wrong answer for almost every photograph: linear
# scene data has middle grey at about 13% and a camera JPEG puts it near 18%,
# so an uncurved render is roughly half a stop dark through the midtones and
# has no highlight rolloff at all.
#
# A curve that works has three parts, and it is worth naming them because they
# are what you are actually tuning:
#
# the toe the first span, slope near or below 1. Deep shadows stay
# deep. Lift it and blacks go milky; crush it and shadow
# detail the sensor recorded disappears.
# the midtones the middle spans, slope well above 1. This is the contrast
# and the brightness people read as "the camera's look".
# the shoulder the last span, slope well below 1. Highlights compress
# toward white instead of arriving there and clipping. It is
# the difference between a rolled-off sky and a white hole.
#
# Two invariants are enforced in code and tested, so a mistake here fails the
# build rather than the photograph: x must strictly increase, y must not
# decrease, and everything must lie inside the unit square.
#
# ---------------------------------------------------------------------------
# Honesty about these values
# ---------------------------------------------------------------------------
#
# These are hand-tuned shapes, not measurements. They encode what every camera
# JPEG rendering has in common — the toe/midtone/shoulder structure above —
# plus each maker's well-known house differences: Canon's gentler shoulder and
# warmer-reading midtones, Nikon's slightly higher midtone contrast, Sony's
# flatter and more conservative default, Fujifilm's markedly contrastier
# Provia-derived rendering.
#
# FR-DEV-3e's acceptance criterion is subjective comparison against each body's
# own JPEG, and meeting it properly needs a frame from that body in front of
# you. Where that has not been done, the entry is still much closer to right
# than the identity — which is the bar these have to clear, and do.
version: 1
# The rendering for a body with no entry of its own.
#
# **Deliberately not the identity.** The failure this requirement exists to fix
# is the flat render, and a conservative curve is far closer to right for every
# body than no curve is for any of them. It is gentler than the per-body
# entries below — a shallower midtone and an earlier, softer shoulder — because
# it has to be safe on a sensor nobody has looked at, and the cost of being too
# tame is a photograph that wants a little contrast rather than one that has
# lost its highlights.
default:
points:
- [0.00, 0.000]
- [0.04, 0.043]
- [0.13, 0.175]
- [0.45, 0.690]
- [1.00, 1.000]
bodies:
# Canon. A soft toe and a long, gradual shoulder — the reason Canon files
# are described as forgiving in highlights and a little low in contrast
# straight out of camera.
- make: Canon
model: EOS 6D
points:
- [0.00, 0.000]
- [0.04, 0.045]
- [0.13, 0.190]
- [0.45, 0.720]
- [1.00, 1.000]
- make: Canon
model: EOS R6
points:
- [0.00, 0.000]
- [0.04, 0.044]
- [0.13, 0.195]
- [0.45, 0.730]
- [1.00, 1.000]
# Nikon. A slightly deeper toe and more midtone slope than Canon, which is
# the "punchier out of camera" difference people describe between the two.
- make: Nikon
model: Z 6
points:
- [0.00, 0.000]
- [0.04, 0.038]
- [0.13, 0.200]
- [0.46, 0.750]
- [1.00, 1.000]
- make: Nikon
model: D750
points:
- [0.00, 0.000]
- [0.04, 0.039]
- [0.13, 0.198]
- [0.46, 0.745]
- [1.00, 1.000]
# Sony. The flattest default of the four, and intentionally so — Sony's own
# rendering leaves more headroom than it uses, which is why Sony files are
# the ones people describe as needing the most work.
- make: Sony
model: ILCE-7M3
points:
- [0.00, 0.000]
- [0.04, 0.048]
- [0.13, 0.185]
- [0.44, 0.700]
- [1.00, 1.000]
# Fujifilm. Provia, the default film simulation: a firm toe, the steepest
# midtones here, and a hard shoulder. It is the most distinctive rendering of
# the four and the one where a flat render looks most obviously wrong.
#
# This entry does *not* read the in-RAF film simulation tag — that is
# FR-DEV-3f, and until it lands every Fujifilm file gets the Provia shape
# whatever the camera was set to.
- make: Fujifilm
model: X-T3
points:
- [0.00, 0.000]
- [0.045, 0.040]
- [0.14, 0.215]
- [0.47, 0.775]
- [1.00, 1.000]
-752
View File
@@ -1,752 +0,0 @@
//! TRACES: FR-DEV-3e
//! Base curves — the per-body rendering that turns a correct exposure into a
//! photograph.
//!
//! # What this is for
//!
//! A camera matrix gets the *colours* right and leaves the picture flat. Sensor
//! data is scene-referred and very nearly linear; a print, a screen and a
//! camera's own JPEG are none of those things. Rendering linear data straight
//! out is the dcraw default, and FR-DEV-3e names it precisely: "the flat,
//! poor-skin-tone rendering characteristic of dcraw defaults, which is the
//! documented reason people abandon darktable in the first hour."
//!
//! The fix is a tone curve applied as part of *reading* the file rather than as
//! an edit — a toe, a steep midtone, and a shoulder that rolls highlights off
//! instead of clipping them. Every raw converter has one. Adobe calls it the
//! camera profile's tone curve, darktable calls it the base curve, and the name
//! here follows darktable's because the placement does too: it runs in camera
//! RGB, after white balance and the user's adjustments, immediately before the
//! conversion out to a working space.
//!
//! # Why it is not an edit
//!
//! It never reaches the sidecar and there is no slider for it, for the same
//! reason the EXIF orientation is not an edit (FR-DEV-3h): it is a property of
//! the body that took the frame, not of what anyone decided about the frame.
//! Sidecars are shared between devices and bodies (FR-NC-9), and one camera's
//! rendering must not follow an edit onto another camera's file.
//!
//! # Why it is data
//!
//! FR-DEV-3e requires the profile database to be "versioned independently of
//! the app binary so bodies and curves can be added without a release — and,
//! under D8's GPLv3, contributed by users". So the curves live in
//! `profiles/base_curves.yaml`, a file that is compiled in as a floor and
//! *overridden* by a copy on disk carrying a higher `version:`. Adding a body
//! is adding ten numbers to a YAML file; shipping that body to users is
//! publishing the file. Neither is a code change and neither needs a release.
//!
//! See [`load`] for the search path and [`Curves::body`] for the matching.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// How many control points a base curve has.
///
/// Five, which is not a coincidence: it is what the tone curve widget uses
/// (`dr_pipeline::ops::curve::POINTS`), so the shader evaluates a profile's
/// curve and a photographer's curve through exactly the same spline. A profile
/// author and a photographer dragging a point mean the same thing by it, and
/// the generated shader carries one implementation rather than two that could
/// disagree.
pub const POINTS: usize = 5;
/// TRACES: FR-DEV-3e
/// A base curve: five points on a monotone spline through the unit square.
///
/// `xs` is scene-linear camera RGB, normalised so that 1.0 is the sensor's
/// saturation point. `ys` is display-referred linear — *not* gamma-encoded,
/// because the sRGB transfer function is applied at the very end of the
/// generated shader and applying it twice would wash the image out.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BaseCurve {
pub xs: [f32; POINTS],
pub ys: [f32; POINTS],
}
impl BaseCurve {
/// The curve that does nothing — the identity diagonal.
///
/// What an unrecognised body gets if the database carries no default, and
/// what a JPEG gets always: an already-rendered image must not be rendered
/// a second time.
pub const IDENTITY: Self = Self {
xs: [0.0, 0.25, 0.5, 0.75, 1.0],
ys: [0.0, 0.25, 0.5, 0.75, 1.0],
};
/// Whether this curve would leave the image alone.
///
/// The shader is told to skip the stage entirely when it would, so an
/// unprofiled body costs a branch that is uniform across the dispatch
/// rather than a spline evaluation per channel per pixel.
pub fn is_identity(&self) -> bool {
self.xs
.iter()
.zip(self.ys.iter())
.all(|(x, y)| (x - y).abs() < 1e-6)
}
/// Build from raw pairs, rejecting anything that is not a curve.
///
/// A profile file is data a user may have edited, so this is the boundary
/// where "ten numbers" becomes "a curve": the x coordinates must increase,
/// the y coordinates must not decrease, and both must lie in the unit
/// square. A non-monotone x sends the spline's span search backwards and
/// divides by a negative width; a decreasing y inverts tones locally,
/// which reads as a dark halo through smooth gradients rather than as a
/// bad profile.
///
/// Endpoints are not forced to (0,0) and (1,1). A curve that lifts black
/// slightly, or that places the shoulder below white, is a legitimate
/// rendering choice and several bodies make it.
pub fn from_points(points: &[[f32; 2]]) -> Option<Self> {
if points.len() != POINTS {
return None;
}
let mut xs = [0.0f32; POINTS];
let mut ys = [0.0f32; POINTS];
for (i, p) in points.iter().enumerate() {
if !p[0].is_finite() || !p[1].is_finite() {
return None;
}
if !(0.0..=1.0).contains(&p[0]) || !(0.0..=1.0).contains(&p[1]) {
return None;
}
xs[i] = p[0];
ys[i] = p[1];
}
for i in 1..POINTS {
// Strictly increasing in x — the spline divides by the span width.
if xs[i] <= xs[i - 1] {
return None;
}
// Non-decreasing in y. Flat is allowed: a curve that holds a
// highlight range at white is clipping deliberately.
if ys[i] < ys[i - 1] {
return None;
}
}
Some(Self { xs, ys })
}
}
/// One body's entry in the database.
#[derive(Debug, Clone, PartialEq)]
pub struct BodyCurve {
/// The manufacturer, as the file writes it — "Canon", "NIKON CORPORATION".
pub make: String,
/// The model, as the file writes it — "EOS 6D", "ILCE-7M3".
pub model: String,
pub curve: BaseCurve,
}
/// TRACES: FR-DEV-3e
/// The base curve database.
///
/// Versioned as a whole rather than per body, because that is the unit a user
/// downloads and the unit that has to beat the built-in copy. See [`load`].
#[derive(Debug, Clone, PartialEq)]
pub struct Curves {
version: u32,
default: Option<BaseCurve>,
bodies: Vec<BodyCurve>,
}
impl Curves {
/// TRACES: FR-DEV-3e
/// The curve to render a frame from this body with.
///
/// Falls back, in order, to the database's `default:` and then to the
/// identity. **The default is deliberately not the identity**: an
/// unrecognised body rendered flat is the failure this requirement exists
/// to prevent, and a gentle, conservative curve is much closer to right for
/// every body than no curve is for any of them. A body with its own entry
/// gets that instead.
///
/// # What "this body" has to survive
///
/// The same camera names itself three ways depending on which program last
/// touched the file. A native NEF says make "NIKON CORPORATION", model
/// "NIKON Z 6"; rawler's own database cleans that to "Nikon" and "Z 6"; an
/// Adobe-converted DNG keeps the uncleaned pair. A database that had to
/// spell every variant would go stale the first time a maker changed its
/// mind about its own name, so the matching does the folding instead:
///
/// - Case, punctuation and runs of whitespace are flattened, so
/// "ILCE-7M3", "ILCE 7M3" and "ilce-7m3" are one body.
/// - The make is compared on its **first word only**. Every maker's
/// trailing corporate boilerplate — "CORPORATION", "IMAGING CORP" — is
/// noise, and no two camera manufacturers share a first word.
/// - The model is tried both as written and with a leading copy of the
/// make removed, which is what lets one "Canon"/"EOS 6D" entry cover
/// "Canon EOS 6D" as well.
pub fn body(&self, make: &str, model: &str) -> BaseCurve {
let (make, model) = (make_key(make), normalise(model));
// The model with a leading copy of the maker's name removed.
let bare = model.strip_prefix(&format!("{make} ")).unwrap_or(&model);
self.bodies
.iter()
.find(|b| {
let entry_model = normalise(&b.model);
make_key(&b.make) == make && (entry_model == model || entry_model == bare)
})
.map(|b| b.curve)
.or(self.default)
.unwrap_or(BaseCurve::IDENTITY)
}
/// The database version. Higher wins; see [`load`].
pub fn version(&self) -> u32 {
self.version
}
/// How many bodies have their own curve, excluding the default.
pub fn len(&self) -> usize {
self.bodies.len()
}
pub fn is_empty(&self) -> bool {
self.bodies.is_empty()
}
/// Parse a database from YAML.
///
/// Entries that are not curves are dropped with a warning rather than
/// failing the parse. A user-contributed file with one bad body should
/// cost that body's rendering, not every body's — and the alternative is an
/// application that will not open a photograph because somebody typed a
/// comma.
pub fn parse(yaml: &str) -> Result<Self, String> {
let file: File = serde_norway::from_str(yaml).map_err(|e| e.to_string())?;
let default = file.default.and_then(|d| {
BaseCurve::from_points(&d.points).or_else(|| {
log::warn!("base curves: the default entry is not a monotone curve; ignoring it");
None
})
});
let bodies = file
.bodies
.into_iter()
.filter_map(|b| match BaseCurve::from_points(&b.points) {
Some(curve) => Some(BodyCurve {
make: b.make,
model: b.model,
curve,
}),
None => {
log::warn!(
"base curves: {} {} is not a monotone curve; ignoring it",
b.make,
b.model
);
None
}
})
.collect();
Ok(Self {
version: file.version,
default,
bodies,
})
}
}
/// The copy that ships inside the binary.
///
/// A floor, not the answer: [`load`] prefers a newer file on disk. Compiled in
/// so that a fresh install with no profile directory — and every Android build,
/// where there is no such directory to speak of — still renders properly.
const BUILT_IN: &str = include_str!("../profiles/base_curves.yaml");
/// TRACES: FR-DEV-3e
/// The base curve database, loaded once.
///
/// # The search path, and why it is a version comparison
///
/// 1. `$DARKROOM_PROFILES`, a directory, when set. The escape hatch: a profile
/// author iterating on a curve points this at their working copy and does
/// not have to install anything.
/// 2. `$XDG_DATA_HOME/darkroom/profiles/`, else `$HOME/.local/share/darkroom/profiles/`.
/// The same base directory the catalog uses, chosen there for the same
/// reason — it is data, not cache, and must survive a storage sweep.
/// 3. The copy compiled into the binary.
///
/// The first file that parses *and carries a higher `version:` than the
/// built-in copy* wins. The version check is the whole mechanism the
/// requirement asks for, and it runs in both directions:
///
/// - A downloaded pack at version 7 supersedes a binary shipping version 3, so
/// a body added after the release renders correctly with no release.
/// - A stale pack at version 2 does **not** supersede a binary shipping version
/// 3, so upgrading the application cannot silently lose curves to a file
/// somebody downloaded a year ago and forgot.
///
/// Failures are warnings, never errors. A malformed profile file must cost the
/// user their curves, not their photographs.
pub fn load() -> &'static Curves {
static LOADED: OnceLock<Curves> = OnceLock::new();
LOADED.get_or_init(|| {
let built_in = Curves::parse(BUILT_IN).unwrap_or_else(|e| {
// Unreachable in a build that ran its tests — `the_shipped_database_parses`
// asserts exactly this — but a panic here would mean an
// application that cannot open a photograph because of a typo in a
// data file, which is never the right trade.
log::error!("base curves: the built-in database does not parse: {e}");
Curves {
version: 0,
default: None,
bodies: Vec::new(),
}
});
choose(built_in, &search_path())
})
}
/// The version comparison, separated from where the directories come from.
///
/// Split out so it can be tested against real files in a real directory
/// without the process-wide `OnceLock` and the environment `load` reads. The
/// rule this implements is the whole of what FR-DEV-3e asks for, so it is
/// worth being able to state it as a test rather than as a comment.
fn choose(built_in: Curves, dirs: &[PathBuf]) -> Curves {
for dir in dirs {
let path = dir.join("base_curves.yaml");
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
match Curves::parse(&text) {
Ok(external) if external.version > built_in.version => {
log::info!(
"base curves: using {} (version {}, {} bodies) over the built-in version {}",
path.display(),
external.version,
external.len(),
built_in.version
);
return external;
}
Ok(external) => log::info!(
"base curves: ignoring {} at version {}; the built-in database is version {}",
path.display(),
external.version,
built_in.version
),
Err(e) => log::warn!("base curves: {} does not parse: {e}", path.display()),
}
}
built_in
}
/// TRACES: FR-DEV-3e
/// The curve for a body, from the loaded database.
///
/// The one call site the decoder needs; everything above is reachable for
/// tests and for a future profile editor.
pub fn for_body(make: &str, model: &str) -> BaseCurve {
load().body(make, model)
}
/// Directories that may hold a `base_curves.yaml`, most specific first.
fn search_path() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(explicit) = std::env::var_os("DARKROOM_PROFILES") {
dirs.push(PathBuf::from(explicit));
}
// The same resolution `dr_ui::library::catalog_path` uses, and for the
// same reason: this is data a user may have installed, not a cache. It is
// duplicated rather than shared because `dr-decode` sits far below the UI
// and must not acquire a dependency on it to find a directory.
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".local/share")));
if let Some(base) = base {
dirs.push(base.join("darkroom").join("profiles"));
}
dirs
}
/// A manufacturer's first word, folded.
///
/// "NIKON CORPORATION", "Nikon" and "nikon" all become `NIKON`. The corporate
/// suffixes are not information — they appear or not depending on whether the
/// file went through a DNG converter — and no two camera manufacturers share a
/// first word, so nothing is lost by dropping them.
fn make_key(s: &str) -> String {
normalise(s)
.split(' ')
.next()
.unwrap_or_default()
.to_string()
}
/// Fold a make or model into something two files can agree on.
///
/// Upper-cased, with every run of non-alphanumeric characters collapsed to one
/// space and the ends trimmed, so that "ILCE-7M3", "ILCE 7M3" and "ilce-7m3"
/// become one.
fn normalise(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut pending_space = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
if pending_space && !out.is_empty() {
out.push(' ');
}
pending_space = false;
out.push(c.to_ascii_uppercase());
} else {
pending_space = true;
}
}
out
}
// ---- The on-disk shape, kept apart from the in-memory one ----------------
//
// Deliberately separate types. The file is data a user edits and is allowed to
// be wrong; `Curves` is a parsed database whose every entry is known to be a
// monotone curve. Deriving `Deserialize` on `BaseCurve` directly would delete
// that boundary and let an unchecked five-point array reach the shader.
//
// Unknown fields are **accepted**, which is not laziness. The database is
// versioned independently of the binary and moves in both directions: a pack
// published after this release may carry keys this build has never heard of —
// a hue twist, a look table (FR-DEV-3f) — and it must still deliver its curves
// to an older DarkRoom rather than failing to parse and leaving every body
// flat. `deny_unknown_fields` would trade that for a diagnostic nobody needs.
#[derive(serde::Deserialize)]
struct File {
version: u32,
#[serde(default)]
default: Option<Entry>,
#[serde(default)]
bodies: Vec<BodyEntry>,
}
#[derive(serde::Deserialize)]
struct Entry {
points: Vec<[f32; 2]>,
}
#[derive(serde::Deserialize)]
struct BodyEntry {
make: String,
model: String,
points: Vec<[f32; 2]>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shipped_database_parses_and_carries_a_default() {
// The one test that must never be allowed to fail quietly: `load`
// degrades to an empty database rather than panicking, so without this
// a typo in the YAML would ship as "every photograph renders flat"
// rather than as a build failure.
let curves = Curves::parse(BUILT_IN).expect("the shipped database parses");
assert!(curves.version() >= 1);
assert!(!curves.is_empty(), "the database ships bodies");
assert!(
!curves.body("Nobody", "Nothing").is_identity(),
"an unknown body must still get the default rendering"
);
}
#[test]
fn every_shipped_curve_lifts_the_midtones_and_rolls_the_highlights() {
// What makes a base curve a base curve rather than a decoration. If a
// shipped curve failed either half it would be a worse rendering than
// the flat one it replaced, which is the one outcome forbidden.
let curves = Curves::parse(BUILT_IN).expect("parses");
let all = curves
.bodies
.iter()
.map(|b| (format!("{} {}", b.make, b.model), b.curve))
.chain(curves.default.map(|c| ("default".to_string(), c)));
for (name, curve) in all {
// The midtone point sits above the diagonal: a linear midtone is
// roughly a stop and a half darker than any camera renders it.
let mid = 2;
assert!(
curve.ys[mid] > curve.xs[mid],
"{name} does not lift its midtones ({} -> {})",
curve.xs[mid],
curve.ys[mid]
);
// And the last span is shallower than the one before it, which is
// what a shoulder *is*. Without one the curve clips highlights
// harder than the linear rendering did.
let slope =
|i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]);
assert!(
slope(POINTS - 2) < slope(POINTS - 3),
"{name} has no highlight shoulder"
);
}
}
#[test]
fn a_curve_that_is_not_monotone_is_refused() {
// The profile file is user-editable, so this is a real boundary and
// not a formality. A decreasing y inverts tones locally and shows up
// as a dark halo in a gradient, which reads as a rendering fault
// rather than as a bad profile.
assert_eq!(
BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.4], [0.5, 0.3], [0.75, 0.8], [1.0, 1.0]]),
None
);
}
#[test]
fn a_curve_whose_x_does_not_advance_is_refused() {
// The spline divides by the span width; a repeated x is a division by
// zero in the shader, which is a NaN pixel rather than an error.
assert_eq!(
BaseCurve::from_points(&[
[0.0, 0.0],
[0.25, 0.3],
[0.25, 0.5],
[0.75, 0.8],
[1.0, 1.0]
]),
None
);
}
#[test]
fn a_curve_of_the_wrong_length_is_refused() {
assert_eq!(BaseCurve::from_points(&[[0.0, 0.0], [1.0, 1.0]]), None);
}
#[test]
fn values_outside_the_unit_square_are_refused() {
// The shader clamps its output at the very end anyway, but a control
// point above 1.0 would put the shoulder outside the range the curve
// is defined over and silently flatten everything below it.
assert_eq!(
BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.3], [0.5, 1.4], [0.75, 1.5], [1.0, 1.6]]),
None
);
}
#[test]
fn a_body_with_its_own_entry_beats_the_default() {
let curves = Curves::parse(
"version: 2
default:
points: [[0.0, 0.0], [0.25, 0.3], [0.5, 0.6], [0.75, 0.85], [1.0, 1.0]]
bodies:
- make: Canon
model: EOS 6D
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
assert_eq!(curves.body("Canon", "EOS 5D").ys[1], 0.30);
}
#[test]
fn the_make_may_be_repeated_in_the_model() {
// Canon writes "Canon" as the make and "Canon EOS 6D" as the model;
// rawler's cleaned strings drop the repetition and both reach here.
// One entry has to cover both or half the files on a card miss.
let curves = Curves::parse(
"version: 1
bodies:
- make: Canon
model: EOS 6D
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.body("Canon", "Canon EOS 6D").ys[1], 0.35);
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
assert_eq!(curves.body("CANON", "eos 6d").ys[1], 0.35);
}
#[test]
fn a_corporate_suffix_does_not_hide_a_body() {
// The same Z 6 arrives as "Nikon"/"Z 6" from rawler's camera database
// and as "NIKON CORPORATION"/"NIKON Z 6" from a DNG converted out of
// the same file. Both must find the entry, or converting a file to
// DNG would silently change how it renders.
let curves = Curves::parse(
"version: 1
bodies:
- make: Nikon
model: Z 6
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.body("Nikon", "Z 6").ys[1], 0.35);
assert_eq!(curves.body("NIKON CORPORATION", "NIKON Z 6").ys[1], 0.35);
}
#[test]
fn punctuation_and_spacing_do_not_decide_whether_a_body_is_known() {
let curves = Curves::parse(
"version: 1
bodies:
- make: Sony
model: ILCE-7M3
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.body("SONY", "ILCE 7M3").ys[1], 0.35);
assert_eq!(curves.body("sony", "ilce-7m3").ys[1], 0.35);
}
#[test]
fn one_bad_entry_does_not_cost_the_rest() {
// A user-contributed file with one typo should cost that body's
// rendering, not every body's.
let curves = Curves::parse(
"version: 1
bodies:
- make: Broken
model: Body
points: [[0.0, 0.0], [0.25, 0.9], [0.5, 0.1], [0.75, 0.9], [1.0, 1.0]]
- make: Canon
model: EOS 6D
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.len(), 1);
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
assert!(curves.body("Broken", "Body").is_identity());
}
#[test]
fn a_pack_from_the_future_still_delivers_its_curves() {
// The database is versioned independently of the binary, so a pack
// published after this build may carry keys this build has never heard
// of. It must still hand over the curves it does understand — failing
// the parse would leave every body flat, which is the exact failure
// FR-DEV-3e exists to prevent, delivered by the mechanism meant to
// prevent it.
let curves = Curves::parse(
"version: 9
look_table: ambitious
bodies:
- make: Canon
model: EOS 6D
hue_twist: [1, 2, 3]
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("an unfamiliar key must not fail the parse");
assert_eq!(curves.version(), 9);
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
}
#[test]
fn an_unknown_body_with_no_default_gets_the_identity() {
// Graceful fallback, stated as a property: never worse than a flat
// render, and never a curve tuned for somebody else's sensor when the
// database declines to offer one.
let curves = Curves::parse("version: 1\nbodies: []\n").expect("parses");
assert!(curves.body("Nobody", "Nothing").is_identity());
}
/// A directory holding one `base_curves.yaml`, unique to the caller.
fn a_pack_dir(name: &str, yaml: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("darkroom-base-curves-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a writable temp directory");
std::fs::write(dir.join("base_curves.yaml"), yaml).expect("write");
dir
}
const A_CANON_ENTRY: &str = "bodies:
- make: Canon
model: EOS 6D
points: [[0.0, 0.0], [0.25, 0.42], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
";
#[test]
fn a_newer_pack_on_disk_supersedes_the_built_in_database() {
// **This is the requirement.** FR-DEV-3e asks for a profile database
// versioned independently of the app binary "so bodies and curves can
// be added without a release". A file with a higher version, dropped
// in the profile directory, is what that means in practice.
let built_in = Curves::parse(BUILT_IN).expect("parses");
let newer = format!("version: {}\n{A_CANON_ENTRY}", built_in.version() + 1);
let dir = a_pack_dir("newer", &newer);
let chosen = choose(built_in.clone(), &[dir]);
assert_eq!(chosen.version(), built_in.version() + 1);
assert_eq!(chosen.body("Canon", "EOS 6D").ys[1], 0.42);
}
#[test]
fn a_stale_pack_does_not_survive_an_upgrade() {
// The other direction, and the one that protects the user. Somebody
// downloads a pack, a release later ships better curves for the same
// bodies, and the forgotten file must not quietly hold the application
// back at last year's rendering.
let built_in = Curves::parse(BUILT_IN).expect("parses");
let stale = format!("version: {}\n{A_CANON_ENTRY}", built_in.version());
let dir = a_pack_dir("stale", &stale);
let chosen = choose(built_in.clone(), &[dir]);
assert_eq!(chosen.version(), built_in.version());
assert_ne!(
chosen.body("Canon", "EOS 6D").ys[1],
0.42,
"an equal version must not displace the built-in database"
);
}
#[test]
fn a_broken_pack_costs_the_curves_and_not_the_photographs() {
// A malformed profile file must degrade to the built-in database, not
// to an error. The user came here to look at a photograph.
let built_in = Curves::parse(BUILT_IN).expect("parses");
let dir = a_pack_dir("broken", "version: [this is not a number\n");
let chosen = choose(built_in.clone(), &[dir]);
assert_eq!(chosen.version(), built_in.version());
assert_eq!(chosen.len(), built_in.len());
}
#[test]
fn a_directory_with_no_pack_in_it_is_simply_skipped() {
// The ordinary case on every machine: the search path exists, the file
// does not. It must not be a warning, an error, or a slow path.
let built_in = Curves::parse(BUILT_IN).expect("parses");
let missing = std::env::temp_dir().join("darkroom-base-curves-nothing-here");
let _ = std::fs::remove_dir_all(&missing);
assert_eq!(choose(built_in.clone(), &[missing]), built_in);
}
#[test]
fn the_identity_is_recognised_as_doing_nothing() {
assert!(BaseCurve::IDENTITY.is_identity());
assert!(!Curves::parse(BUILT_IN)
.expect("parses")
.body("Canon", "EOS 6D")
.is_identity());
}
}
-52
View File
@@ -1,52 +0,0 @@
/// TRACES: FR-RAW-4 | NFR-SEC-1
/// Failures from decoding.
///
/// Per FR-RAW-4 a malformed file must not abort a batch, so these are always
/// returned rather than panicking — and the decode path is the one place
/// untrusted input arrives (NFR-SEC-1).
#[derive(Debug, thiserror::Error)]
pub enum DecodeError {
#[error("read failed: {0}")]
Read(String),
#[error("unsupported or unrecognised format: {0}")]
Unsupported(String),
#[error("decode failed: {0}")]
Decode(String),
#[error("metadata unavailable: {0}")]
Metadata(String),
#[error("no embedded preview in this file")]
NoPreview,
#[error("embedded preview is corrupt: {0}")]
CorruptPreview(String),
}
impl DecodeError {
/// Whether a fallback path might still produce an image.
///
/// A missing preview is not a failure to display the file — it means fall
/// through to full decode (FR-CULL-2, M-11).
pub fn has_fallback(&self) -> bool {
matches!(
self,
DecodeError::NoPreview | DecodeError::CorruptPreview(_)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_failures_fall_through_rather_than_failing() {
assert!(DecodeError::NoPreview.has_fallback());
assert!(DecodeError::CorruptPreview("truncated".into()).has_fallback());
// A genuinely unsupported file has nowhere to fall through to.
assert!(!DecodeError::Unsupported("unknown".into()).has_fallback());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-408
View File
@@ -1,408 +0,0 @@
//! Embedded preview extraction — the fast display path.
//!
//! Every RAW container carries one or more JPEG previews, often at or near
//! full resolution. Extracting one costs a fraction of a full decode, and is
//! what makes culling feel instant (FR-CULL-1, NFR-P13: 50 ms per image).
//!
//! It is also what makes remote browsing viable: fetching ~1-3 MB of preview
//! from an 80 MB file over WebDAV is the difference between usable and not on
//! mobile data (FR-NC-3).
use crate::DecodeError;
/// How much of a file header to read when locating a preview.
///
/// Enough to cover the IFD structure of the TIFF-derived formats. Sized for
/// remote range requests, where every byte costs.
pub const PREVIEW_PROBE_BYTES: u64 = 256 * 1024;
/// A decoded preview image, RGBA8.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Preview {
pub width: u32,
pub height: u32,
/// Tightly packed RGBA, 4 bytes per pixel.
pub rgba: Vec<u8>,
}
impl Preview {
/// TRACES: FR-DEV-3h
/// Turn the pixels the right way up, in place.
///
/// Every path that shows a preview without the GPU needs this: the grid's
/// thumbnails, and the read-only fallback develop shows when no decoder
/// could open the file. An embedded preview is written in the sensor's
/// orientation, not the photograph's, so a phone or a camera held sideways
/// fills the grid with frames on their side until this runs.
///
/// Done before [`Self::downscale_to`] would be wasteful and after it is
/// not: a quarter turn is a permutation, so it costs the same either way,
/// and doing it on the smaller buffer moves a fraction of the bytes.
///
/// The turn itself is [`dr_types::Orientation::into_shown`], which every
/// other consumer of an orientation in this codebase also goes through.
/// That is deliberate: a hand-written permutation per caller is how two of
/// them come to disagree, and a disagreement here shows as a thumbnail
/// facing the other way from the develop view.
pub fn apply_orientation(&mut self, orientation: dr_types::Orientation) {
let (rgba, dw, dh) = orientation.into_shown(&self.rgba, self.width, self.height, 4);
self.rgba = rgba;
self.width = dw;
self.height = dh;
}
/// Downscale in place to fit within `max_dim` on the long edge.
///
/// A 5472x3648 preview is 79.8 MB of RGBA — far more than a grid cell or
/// even a 4K viewport needs, and enough to exhaust a phone's budget after
/// a handful of images (NFR-RES-1). Box-filtered rather than nearest, so
/// downscaled thumbnails do not alias.
pub fn downscale_to(&mut self, max_dim: u32) {
let longest = self.width.max(self.height);
if longest <= max_dim || longest == 0 {
return;
}
let scale = max_dim as f32 / longest as f32;
let (nw, nh) = (
((self.width as f32 * scale).round() as u32).max(1),
((self.height as f32 * scale).round() as u32).max(1),
);
let mut out = vec![0u8; (nw as usize) * (nh as usize) * 4];
let x_ratio = self.width as f32 / nw as f32;
let y_ratio = self.height as f32 / nh as f32;
for y in 0..nh {
let y0 = (y as f32 * y_ratio) as u32;
let y1 = (((y + 1) as f32 * y_ratio) as u32)
.min(self.height)
.max(y0 + 1);
for x in 0..nw {
let x0 = (x as f32 * x_ratio) as u32;
let x1 = (((x + 1) as f32 * x_ratio) as u32)
.min(self.width)
.max(x0 + 1);
let (mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32);
for sy in y0..y1 {
for sx in x0..x1 {
let i = ((sy * self.width + sx) * 4) as usize;
r += self.rgba[i] as u32;
g += self.rgba[i + 1] as u32;
b += self.rgba[i + 2] as u32;
n += 1;
}
}
let n = n.max(1);
let o = ((y * nw + x) * 4) as usize;
out[o] = (r / n) as u8;
out[o + 1] = (g / n) as u8;
out[o + 2] = (b / n) as u8;
out[o + 3] = 255;
}
}
self.rgba = out;
self.width = nw;
self.height = nh;
}
/// Whether this is large enough to be worth displaying at `target`.
///
/// Some bodies embed thumbnails only a few hundred pixels wide — Sony is
/// the documented case. Displaying one where a larger render is wanted
/// shows a soft image the user discovers only on zoom, so the caller
/// should background-render instead (M-11).
pub fn is_useful_at(&self, target: u32) -> bool {
self.width.max(self.height) >= target
}
}
/// TRACES: FR-CULL-1 | NFR-P13
/// Which embedded image to extract.
///
/// Containers carry several at different sizes, and decoding the
/// full-resolution one to fill a grid cell is pure waste.
///
/// **Measured caveat (rawler 0.7.2):** the CR2 decoder implements only
/// `full_image`; `thumbnail_image` and `preview_image` are unimplemented trait
/// defaults returning `None`. So on Canon CR2 every rung currently resolves to
/// the full-resolution JPEG at ~250 ms — 5× over NFR-P13's 50 ms budget.
///
/// Three ways out, in increasing cost: extract the smaller IFD ourselves
/// (CR2 carries a 160×120 thumbnail and a ~1620×1080 preview in IFD1/IFD2),
/// contribute the methods upstream, or cache a downscaled proxy on first
/// sight. The ladder is written now so that fixing it is a decoder change
/// rather than a change to every caller.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreviewSize {
/// Smallest available. Grid cells and rapid culling.
Thumbnail,
/// Mid-sized where the container has one. Single-image view.
Screen,
/// Largest available, usually full sensor resolution. Only where the
/// display genuinely needs it.
Full,
}
/// TRACES: FR-CULL-2 | FR-NC-3 | M-10
/// Extract and decode an embedded preview at the requested size.
///
/// Takes bytes rather than a reader, because the caller usually has them
/// already: a range read locally, or a `Range:` request remotely. Forcing a
/// `Read + Seek` here would push remote callers into buffering the whole file.
///
/// Falls through the ladder — a container without the requested size yields
/// the next available rather than failing (FR-CULL-2).
///
/// 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> {
use rawler::rawsource::RawSource;
// A plain JPEG *is* its own preview — rawler has no decoder for one, and
// a mixed folder must display sensibly (M-9).
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return decode_jpeg(bytes);
}
let source = RawSource::new_from_slice(bytes);
let decoder =
rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?;
let params = Default::default();
// Preference order per requested size, each falling through to the next.
let attempts: &[PreviewSize] = match size {
PreviewSize::Thumbnail => &[
PreviewSize::Thumbnail,
PreviewSize::Screen,
PreviewSize::Full,
],
PreviewSize::Screen => &[
PreviewSize::Screen,
PreviewSize::Full,
PreviewSize::Thumbnail,
],
PreviewSize::Full => &[PreviewSize::Full, PreviewSize::Screen],
};
for attempt in attempts {
let got = match attempt {
PreviewSize::Thumbnail => decoder.thumbnail_image(&source, &params),
PreviewSize::Screen => decoder.preview_image(&source, &params),
PreviewSize::Full => decoder.full_image(&source, &params),
};
if let Ok(Some(img)) = got {
let rgb = img.to_rgb8();
let (width, height) = (rgb.width(), rgb.height());
if width > 0 && height > 0 {
return Ok(Preview {
width,
height,
rgba: rgb_to_rgba(rgb.as_raw(), width, height),
});
}
}
}
Err(DecodeError::NoPreview)
}
/// Extract the largest available preview.
///
/// Convenience over [`extract_preview`]; prefer naming a size explicitly.
pub fn extract_embedded_preview(bytes: &[u8]) -> Result<Preview, DecodeError> {
extract_preview(bytes, PreviewSize::Full)
}
/// Decode a standalone JPEG (an embedded preview already sliced out, or a
/// JPEG file).
pub fn decode_jpeg(bytes: &[u8]) -> Result<Preview, DecodeError> {
let mut d = zune_jpeg::JpegDecoder::new(bytes);
let pixels = d
.decode()
.map_err(|e| DecodeError::CorruptPreview(e.to_string()))?;
let info = d
.info()
.ok_or_else(|| DecodeError::CorruptPreview("no image info".into()))?;
let (w, h) = (info.width as u32, info.height as u32);
let expected = (w as usize) * (h as usize);
// zune yields RGB or grayscale depending on the source; normalise both to
// RGBA so callers have one representation.
let rgba = match pixels.len() / expected.max(1) {
3 => rgb_to_rgba(&pixels, w, h),
1 => pixels.iter().flat_map(|&g| [g, g, g, 255]).collect(),
4 => pixels,
n => {
return Err(DecodeError::CorruptPreview(format!(
"unexpected {n} channels"
)))
}
};
Ok(Preview {
width: w,
height: h,
rgba,
})
}
fn rgb_to_rgba(rgb: &[u8], w: u32, h: u32) -> Vec<u8> {
let n = (w as usize) * (h as usize);
let mut out = Vec::with_capacity(n * 4);
for px in rgb.chunks_exact(3).take(n) {
out.extend_from_slice(&[px[0], px[1], px[2], 255]);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A preview whose every pixel encodes its own coordinates, so a
/// misplaced one is identifiable rather than merely wrong.
fn coded(width: u32, height: u32) -> Preview {
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
for y in 0..height {
for x in 0..width {
rgba.extend_from_slice(&[x as u8, y as u8, 0, 255]);
}
}
Preview {
width,
height,
rgba,
}
}
#[test]
fn a_quarter_turn_moves_every_pixel_where_the_orientation_says() {
// Tag 6: the stored image's first row becomes the displayed right
// edge, its first column the displayed top. A 4x2 landscape preview
// therefore comes out 2x4 portrait, with stored (0,0) at the top right.
let mut p = coded(4, 2);
p.apply_orientation(dr_types::Orientation::from_exif(6));
assert_eq!((p.width, p.height), (2, 4));
let at = |x: u32, y: u32| {
let i = ((y * p.width + x) * 4) as usize;
(p.rgba[i], p.rgba[i + 1])
};
// Displayed top-right reads stored (0, 0).
assert_eq!(at(1, 0), (0, 0));
// Displayed top-left reads stored (0, 1) — the last row of column 0.
assert_eq!(at(0, 0), (0, 1));
// Displayed bottom-right reads stored (3, 0).
assert_eq!(at(1, 3), (3, 0));
}
#[test]
fn an_upright_file_is_left_untouched() {
// The common case, and the one where an unnecessary reallocation
// would be paid on every thumbnail in the library.
let original = coded(4, 2);
let mut p = original.clone();
p.apply_orientation(dr_types::Orientation::NORMAL);
assert_eq!(p, original);
}
#[test]
fn every_orientation_preserves_the_pixels_it_was_given() {
// A turn or a mirror is a permutation: the same bytes, rearranged.
// Anything else means a pixel was dropped, duplicated or read out of
// bounds — and the bounds case would have panicked first.
for tag in 1..=8u16 {
let orientation = dr_types::Orientation::from_exif(tag);
let mut p = coded(5, 3);
p.apply_orientation(orientation);
assert_eq!(
(p.width, p.height),
orientation.oriented_size(5, 3),
"tag {tag}"
);
let mut got: Vec<_> = p.rgba.chunks(4).map(|c| (c[0], c[1])).collect();
got.sort_unstable();
let mut want: Vec<_> = coded(5, 3).rgba.chunks(4).map(|c| (c[0], c[1])).collect();
want.sort_unstable();
assert_eq!(got, want, "tag {tag}");
}
}
#[test]
fn size_preference_falls_through_in_order() {
// A container missing the requested size must yield the next
// available rather than failing (FR-CULL-2).
// Ordering is asserted here; behaviour against real files is covered
// by the smoke example.
assert_ne!(PreviewSize::Thumbnail, PreviewSize::Full);
}
#[test]
fn usefulness_is_judged_on_the_long_edge() {
let p = Preview {
width: 1600,
height: 1067,
rgba: Vec::new(),
};
assert!(p.is_useful_at(1024));
assert!(p.is_useful_at(1600));
// A body embedding only a small thumbnail must trigger a background
// render rather than showing a soft image.
assert!(!p.is_useful_at(2048));
}
#[test]
fn downscale_preserves_aspect_and_bounds_memory() {
let mut p = Preview {
width: 5472,
height: 3648,
rgba: vec![128; 5472 * 3648 * 4],
};
assert_eq!(p.rgba.len(), 79_847_424);
p.downscale_to(2048);
assert_eq!(p.width, 2048);
assert_eq!(p.height, 1365, "aspect preserved");
assert_eq!(p.rgba.len(), (2048 * 1365 * 4) as usize);
// A flat source must stay flat through the box filter.
assert!(p
.rgba
.chunks_exact(4)
.all(|px| px[0] == 128 && px[3] == 255));
}
#[test]
fn downscale_is_a_noop_when_already_small() {
let mut p = Preview {
width: 720,
height: 480,
rgba: vec![7; 720 * 480 * 4],
};
let before = p.rgba.len();
p.downscale_to(2048);
assert_eq!((p.width, p.height, p.rgba.len()), (720, 480, before));
}
#[test]
fn rgb_expands_to_rgba_opaque() {
let rgb = [10, 20, 30, 40, 50, 60];
let rgba = rgb_to_rgba(&rgb, 2, 1);
assert_eq!(rgba, vec![10, 20, 30, 255, 40, 50, 60, 255]);
}
#[test]
fn corrupt_jpeg_is_an_error_not_a_panic() {
// Untrusted input arrives here (NFR-SEC-1); it must never panic.
let err = decode_jpeg(&[0xFF, 0xD8, 0x00, 0x01, 0x02]).unwrap_err();
assert!(matches!(err, DecodeError::CorruptPreview(_)));
}
#[test]
fn empty_input_is_an_error_not_a_panic() {
assert!(decode_jpeg(&[]).is_err());
}
}
File diff suppressed because it is too large Load Diff
-41
View File
@@ -1,41 +0,0 @@
[package]
name = "dr-export"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
# No platform dependency and no filesystem, deliberately. This crate turns a
# rendered frame into *bytes* and a *name*; where those bytes go is the
# caller's problem, because the answer differs by more than a path. On Linux
# it is a file, on Android a SAF document descriptor with no path at all
# (ARCH §6.9), and on either it may be a `PUT` to the server. A crate that
# took a `Path` would work on exactly one of the three.
[dependencies]
dr-types.workspace = true
log.workspace = true
thiserror.workspace = true
# Encoders. All three are pure Rust and already in the tree, which is the same
# criterion that chose rustls, bundled SQLite and the Lensfun port: a C
# dependency here would be one more thing to satisfy under the Android NDK.
#
# AVIF and JPEG XL (FR-EXP-1) are deliberately absent. The mature encoders for
# both are C or C++ — libaom and libjxl — and ravif, the pure-Rust AVIF path,
# is slow enough to change what a batch export feels like. Neither belongs in
# the first version; see `format` in lib.rs for what happens when one is asked
# for.
jpeg-encoder.workspace = true
png = "0.18"
tiff = "0.11"
# The example runs the whole path — decode, GPU render, read back, encode,
# write — so it needs what the library deliberately does not: a GPU, a
# pipeline and a decoder. Dev-only, so none of it reaches a dependent.
[dev-dependencies]
dr-decode.workspace = true
dr-gpu.workspace = true
dr-pipeline.workspace = true
env_logger.workspace = true
pollster.workspace = true
zune-jpeg.workspace = true
-211
View File
@@ -1,211 +0,0 @@
//! Export a real file, end to end, from a real image.
//!
//! cargo run -p dr-export --example export -- <file.jpg|file.cr2> [out-dir]
//!
//! Deliberately the *whole* path and not a unit test of the encoder: decode,
//! demosaic or upload, run the develop chain on the GPU at full resolution,
//! read the result back through `AdjustPass::export_pixels`, resize, sharpen,
//! encode, and write. A test can prove the JPEG has the right magic bytes; it
//! cannot tell anyone whether the picture came out looking like the picture.
use std::path::PathBuf;
use dr_export::{export, Frame, NameContext, SourceMetadata};
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
use dr_pipeline::EditGraph;
use dr_types::{ColourSpace, ExportFormat, ExportSettings, OutputSharpening, SizingMode};
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info,wgpu=warn"))
.init();
let mut args = std::env::args().skip(1);
let Some(input) = args.next() else {
eprintln!("usage: export <file.jpg|file.cr2> [out-dir]");
std::process::exit(2);
};
let out_dir = PathBuf::from(args.next().unwrap_or_else(|| ".".into()));
let input = PathBuf::from(input);
let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu");
println!("gpu: {} ({:?})", ctx.adapter_name(), ctx.backend());
// Decode. A RAW goes through the demosaicer; a JPEG is already RGB and
// takes the same path every operation after the sensor stage does.
let bytes = std::fs::read(&input).expect("read input");
// From content, not from the extension — dr-decode is emphatic that an
// extension is only a hint. Its own `probe` reports a crate-private
// `Format`, so the SOI marker is checked directly here rather than
// widening that API for an example.
let is_jpeg = bytes.starts_with(&[0xFF, 0xD8, 0xFF]);
let source = if !is_jpeg {
let raw = dr_decode::decode(&bytes).expect("decode raw");
let demosaicer = Demosaicer::new(&ctx).expect("demosaicer");
demosaicer.run(&raw).expect("demosaic")
} else {
let (rgba, w, h) = decode_jpeg(&bytes);
DemosaicedImage::from_rgba8(&ctx, &rgba, w, h).expect("upload")
};
// An edit worth seeing in the output, so a broken pipeline is obvious
// rather than subtle.
let mut graph = EditGraph::default_chain();
graph.set_param(
dr_pipeline::ops::exposure::ID,
dr_pipeline::ops::exposure::EXPOSURE,
0.35,
);
graph.set_param(
dr_pipeline::ops::contrast::ID,
dr_pipeline::ops::contrast::CONTRAST,
18.0,
);
graph.set_param(
dr_pipeline::ops::saturation::ID,
dr_pipeline::ops::saturation::SATURATION,
12.0,
);
// Full resolution, not the viewport (FR-EXP-9). This is the one thing an
// export must not economise on.
let (sw, sh) = source.size();
let (fw, fh) = graph.output_size(sw, sh);
println!("source {sw}×{sh}, framed {fw}×{fh}");
// The output space is chosen *here*, before the render, because that is
// where it takes effect: the primaries conversion and the encode are the
// last two lines of the generated shader (FR-EXP-2). Asking for it at the
// encoder would be too late — the pixels would already be clipped.
let space = ColourSpace::DisplayP3;
let mut adjust = AdjustPass::new(&ctx);
let shader = graph.compose_for(space);
let t = std::time::Instant::now();
adjust.render(&source, &shader, fw, fh).expect("render");
let (pixels, w, h) = adjust.export_pixels().expect("read back");
println!(
"rendered {w}×{h} in {:.0} ms as {}",
t.elapsed().as_secs_f32() * 1000.0,
space.label()
);
let frame = Frame::in_space(w, h, pixels, space).expect("well-formed frame");
let stem = input
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "export".into());
// TRACES: FR-EXP-8
// What the input said about itself, transcribed field by field into the
// allowlist `dr-export` will write from. The example passes it because
// this is the one place in the tree that produces files a person can open
// in exiftool — a unit test can prove a GPS directory is absent from a
// byte slice, but only a real export proves that a real photograph comes
// out of the far end still knowing which camera took it.
//
// The defaults apply, so the files written here carry the camera, the
// lens, the exposure and the rights statement, and carry no coordinates.
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
let source_metadata = SourceMetadata {
make: meta.make.clone(),
model: meta.model.clone(),
lens: meta.lens.clone(),
shutter: meta.shutter,
aperture: meta.aperture,
iso: meta.iso,
focal_length: meta.focal_length,
captured_at: meta.captured_at,
captured_offset: meta.captured_offset,
artist: meta.artist.clone(),
copyright: meta.copyright.clone(),
location: meta.location,
};
// One of each format, so the run exercises every encoder that exists.
for (format, sizing, sharpening) in [
(
ExportFormat::Jpeg,
SizingMode::Original,
OutputSharpening::None,
),
(
ExportFormat::Jpeg,
SizingMode::LongEdge(1200),
OutputSharpening::Screen,
),
(
ExportFormat::Png,
SizingMode::LongEdge(600),
OutputSharpening::Screen,
),
(
ExportFormat::Tiff8,
SizingMode::Percentage(25),
OutputSharpening::MattePaper,
),
(
ExportFormat::Tiff16,
SizingMode::Percentage(25),
OutputSharpening::MattePaper,
),
] {
let settings = ExportSettings {
format,
sizing,
sharpening,
colour_space: space,
filename_template: "{name}-{dimensions}".into(),
..Default::default()
};
// The size has to be known before the name, because `{dimensions}` is
// part of it — which is why sizing is resolved here and not inside
// `export`.
let (tw, th) = dr_export::target_size(w, h, sizing, settings.allow_upscaling);
let ctx = NameContext {
source_stem: &stem,
sequence: 1,
date: "",
width: tw,
height: th,
preset: "",
};
let name = dr_export::resolve_name(
&settings.filename_template,
&ctx,
format,
settings.collision,
&|n| out_dir.join(n).exists(),
)
.expect("a free name");
let t = std::time::Instant::now();
let out = export(&frame, &settings, name, Some(&source_metadata)).expect("export");
let path = out_dir.join(&out.name);
std::fs::write(&path, &out.bytes).expect("write");
println!(
"{:>10} {:>5}×{:<5} {:>8} KB {:>5.0} ms {}",
format.label(),
out.width,
out.height,
out.bytes.len() / 1024,
t.elapsed().as_secs_f32() * 1000.0,
path.display()
);
}
}
fn decode_jpeg(bytes: &[u8]) -> (Vec<u8>, u32, u32) {
let mut decoder = zune_jpeg::JpegDecoder::new(bytes);
let pixels = decoder.decode().expect("decode jpeg");
let info = decoder.info().expect("jpeg info");
let (w, h) = (u32::from(info.width), u32::from(info.height));
// zune gives RGB; the GPU upload wants RGBA.
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
for px in pixels.chunks_exact(3) {
rgba.extend_from_slice(&[px[0], px[1], px[2], 255]);
}
(rgba, w, h)
}
File diff suppressed because it is too large Load Diff
-46
View File
@@ -1,46 +0,0 @@
//! TRACES: NFR-ARCH-4
//! Typed export failures.
//!
//! Every variant is something a caller can act on or report. A batch export
//! runs unattended over hundreds of frames (FR-EXP-7), so "what went wrong
//! with which file" has to survive as data rather than as a log line.
use dr_types::{ColourSpace, ExportFormat};
#[derive(Debug, thiserror::Error)]
pub enum ExportError {
#[error("frame buffer is {got} bytes, expected {expected}")]
FrameSize { expected: usize, got: usize },
#[error("frame has no pixels")]
EmptyFrame,
/// Asked for a format with no encoder in this build.
///
/// Not a panic and not a silent substitution: the settings page offers
/// AVIF and JPEG XL because FR-EXP-1 lists them, and a build without them
/// should say so rather than quietly writing a JPEG under a `.avif` name.
#[error("{} export is not supported yet", .0.label())]
FormatUnsupported(ExportFormat),
/// TRACES: FR-EXP-2
/// The frame was rendered into one colour space and asked to be labelled
/// another.
///
/// Not a limitation of the encoders — all four spaces embed a correct
/// profile. It is that the conversion happens in the shader, before the
/// clip to 0..1, so a frame is in exactly one space by the time it gets
/// here. The caller composes with `EditGraph::compose_for` to change which.
#[error(
"the frame was rendered in {} but a {} file was asked for",
.rendered.label(),
.requested.label()
)]
ColourSpaceMismatch {
rendered: ColourSpace,
requested: ColourSpace,
},
#[error("encoding failed: {0}")]
Encode(String),
}
-518
View File
@@ -1,518 +0,0 @@
//! TRACES: FR-EXP-8
//! Building an EXIF block, rather than copying one.
//!
//! # Why this is written by hand and not with a crate
//!
//! Two reasons, in order of importance.
//!
//! The first is the privacy behaviour. Every EXIF library worth using offers a
//! "load the source block, delete these tags, write it back" shape, and that
//! shape is the wrong one here: it makes the file that leaves the machine a
//! copy of the source's metadata *minus what we thought to remove*, so every
//! tag nobody has thought about — a vendor's proprietary sub-directory, a
//! serial number under a tag id this build has never seen — travels by
//! default. Constructing the block from a fixed list of parsed values inverts
//! that. What is written is exactly what appears in [`crate::SourceMetadata`],
//! and a tag that is not in this file cannot end up in the output no matter
//! what the source contained. The allowlist *is* the implementation.
//!
//! The second is the dependency policy. The root `Cargo.toml` explains why
//! nothing here may link C — this tree has to build under the Android NDK —
//! and the mature EXIF writers are bindings. This is a couple of hundred
//! lines of offset arithmetic against a specification that has not changed
//! since 2010, and it is the same TIFF structure `dr-decode` already reads.
//!
//! # What the block is
//!
//! A complete little-endian TIFF: an 8-byte header, IFD0 with the identity
//! and rights tags, an Exif sub-IFD with the capture tags, optionally a GPS
//! sub-IFD, and a heap of values too long to sit inside an entry. JPEG carries
//! it in an APP1 segment behind the marker `Exif\0\0`; PNG carries the same
//! bytes in an `eXIf` chunk with no marker. TIFF does not use this at all —
//! its own directory *is* the EXIF, so `encode.rs` writes the tags there
//! directly.
use crate::metadata::SourceMetadata;
/// One entry's value, in the handful of TIFF types this writer emits.
enum Value {
/// NUL-terminated, as the specification requires; the terminator is
/// counted, which is the detail readers trip over when it is missing.
Ascii(String),
Byte(Vec<u8>),
Short(u16),
Long(u32),
/// Type 7. Used only for `ExifVersion`, which is four characters that are
/// deliberately *not* a string.
Undefined(&'static [u8]),
/// Numerator and denominator pairs. A coordinate is three of them.
Rational(Vec<(u32, u32)>),
}
impl Value {
fn field_type(&self) -> u16 {
match self {
Value::Byte(_) => 1,
Value::Ascii(_) => 2,
Value::Short(_) => 3,
Value::Long(_) => 4,
Value::Rational(_) => 5,
Value::Undefined(_) => 7,
}
}
/// The element count, which is not the byte length: a rational counts as
/// one element per eight bytes.
fn count(&self) -> u32 {
match self {
Value::Ascii(s) => s.len() as u32 + 1,
Value::Byte(b) => b.len() as u32,
Value::Undefined(b) => b.len() as u32,
Value::Short(_) | Value::Long(_) => 1,
Value::Rational(r) => r.len() as u32,
}
}
/// The payload, in file order.
fn payload(&self) -> Vec<u8> {
match self {
Value::Ascii(s) => {
let mut out = s.as_bytes().to_vec();
out.push(0);
out
}
Value::Byte(b) => b.clone(),
Value::Undefined(b) => b.to_vec(),
Value::Short(v) => v.to_le_bytes().to_vec(),
Value::Long(v) => v.to_le_bytes().to_vec(),
Value::Rational(r) => r
.iter()
.flat_map(|(n, d)| {
let mut b = n.to_le_bytes().to_vec();
b.extend_from_slice(&d.to_le_bytes());
b
})
.collect(),
}
}
}
/// An IFD under construction.
type Entries = Vec<(u16, Value)>;
/// Tag numbers. Named rather than inlined because a mistyped one produces a
/// file that still parses and says something else entirely.
pub(crate) mod tag {
pub(crate) const MAKE: u16 = 0x010F;
pub(crate) const MODEL: u16 = 0x0110;
pub(crate) const SOFTWARE: u16 = 0x0131;
pub(crate) const DATE_TIME: u16 = 0x0132;
pub(crate) const ARTIST: u16 = 0x013B;
pub(crate) const COPYRIGHT: u16 = 0x8298;
pub(crate) const EXIF_IFD: u16 = 0x8769;
pub(crate) const GPS_IFD: u16 = 0x8825;
pub(crate) const EXPOSURE_TIME: u16 = 0x829A;
pub(crate) const FNUMBER: u16 = 0x829D;
pub(crate) const ISO: u16 = 0x8827;
pub(crate) const EXIF_VERSION: u16 = 0x9000;
pub(crate) const DATE_TIME_ORIGINAL: u16 = 0x9003;
pub(crate) const OFFSET_TIME_ORIGINAL: u16 = 0x9011;
pub(crate) const FOCAL_LENGTH: u16 = 0x920A;
pub(crate) const PIXEL_X: u16 = 0xA002;
pub(crate) const PIXEL_Y: u16 = 0xA003;
pub(crate) const LENS_MODEL: u16 = 0xA434;
pub(crate) const GPS_VERSION_ID: u16 = 0x0000;
pub(crate) const GPS_LATITUDE_REF: u16 = 0x0001;
pub(crate) const GPS_LATITUDE: u16 = 0x0002;
pub(crate) const GPS_LONGITUDE_REF: u16 = 0x0003;
pub(crate) const GPS_LONGITUDE: u16 = 0x0004;
pub(crate) const GPS_ALTITUDE_REF: u16 = 0x0005;
pub(crate) const GPS_ALTITUDE: u16 = 0x0006;
}
/// What DarkRoom calls itself in a file it wrote.
///
/// Not vanity: an export is a derived file, and a reader that knows which
/// program produced it can tell a camera original from a rendition without
/// guessing from the absence of a maker note.
pub(crate) const SOFTWARE: &str = "DarkRoom";
/// The complete EXIF block for JPEG's APP1 and PNG's `eXIf`.
///
/// `width`/`height` are the *exported* dimensions, not the source's: the
/// pixel-dimension tags describe the file they are in, and a reader that
/// trusts them after a resize would report the wrong size for the image it is
/// holding.
///
/// `None` where there is nothing to say. An empty EXIF block is not the same
/// as no EXIF block — it is a structure a reader must parse to discover it
/// learned nothing — and the second is the better file.
pub(crate) fn block(md: &SourceMetadata, width: u32, height: u32) -> Option<Vec<u8>> {
let ifd0 = main_entries(md);
let exif = exif_entries(md, width, height);
let gps = gps_entries(md);
if ifd0.is_empty() && exif.is_empty() && gps.is_empty() {
return None;
}
Some(assemble(ifd0, exif, gps))
}
/// Lay the three directories and their heap out in the block.
///
/// The order is fixed — IFD0, Exif, GPS, heap — because the pointers have to
/// be known before IFD0 is serialised, and an IFD's size is decided by its
/// entry count alone: two bytes of count, twelve per entry, four for the link
/// to the next directory.
fn assemble(mut ifd0: Entries, exif: Entries, gps: Entries) -> Vec<u8> {
const HEADER: u32 = 8;
let size = |n: usize| 2 + 12 * n as u32 + 4;
// The pointer entries are part of IFD0's count, so they have to be added
// before its size is taken — a chicken-and-egg the specification resolves
// by making entry size fixed.
let pointers = usize::from(!exif.is_empty()) + usize::from(!gps.is_empty());
let ifd0_size = size(ifd0.len() + pointers);
let exif_offset = HEADER + ifd0_size;
let gps_offset = exif_offset + if exif.is_empty() { 0 } else { size(exif.len()) };
let heap_base = gps_offset + if gps.is_empty() { 0 } else { size(gps.len()) };
if !exif.is_empty() {
ifd0.push((tag::EXIF_IFD, Value::Long(exif_offset)));
}
if !gps.is_empty() {
ifd0.push((tag::GPS_IFD, Value::Long(gps_offset)));
}
let mut heap = Vec::new();
let ifd0_bytes = directory(ifd0, heap_base, &mut heap);
let exif_bytes = directory(exif, heap_base, &mut heap);
let gps_bytes = directory(gps, heap_base, &mut heap);
let mut out = Vec::with_capacity(HEADER as usize + heap.len() + 128);
// Little-endian, magic 42, first directory at byte 8. Little-endian
// because every value written below is, and a header that disagreed with
// its own body is the one corruption a reader cannot recover from.
out.extend_from_slice(b"II");
out.extend_from_slice(&42u16.to_le_bytes());
out.extend_from_slice(&HEADER.to_le_bytes());
out.extend_from_slice(&ifd0_bytes);
out.extend_from_slice(&exif_bytes);
out.extend_from_slice(&gps_bytes);
out.extend_from_slice(&heap);
out
}
/// Serialise one directory, spilling long values onto the shared heap.
///
/// Entries are sorted by tag: TIFF requires ascending order within a
/// directory, and while most readers cope with any order, the ones that
/// binary-search stop at the first tag they cannot place.
fn directory(mut entries: Entries, heap_base: u32, heap: &mut Vec<u8>) -> Vec<u8> {
if entries.is_empty() {
return Vec::new();
}
entries.sort_by_key(|(tag, _)| *tag);
let mut out = Vec::with_capacity(2 + entries.len() * 12 + 4);
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for (tag, value) in &entries {
out.extend_from_slice(&tag.to_le_bytes());
out.extend_from_slice(&value.field_type().to_le_bytes());
out.extend_from_slice(&value.count().to_le_bytes());
let payload = value.payload();
if payload.len() <= 4 {
// Four bytes or fewer live in the entry itself, left-justified and
// zero-padded.
let mut inline = payload.clone();
inline.resize(4, 0);
out.extend_from_slice(&inline);
} else {
out.extend_from_slice(&(heap_base + heap.len() as u32).to_le_bytes());
heap.extend_from_slice(&payload);
// Values start on even offsets. Not every reader cares; the ones
// that do read a short from an odd address and get nonsense.
if heap.len() % 2 == 1 {
heap.push(0);
}
}
}
// No directory follows this one. The Exif and GPS sub-directories are
// pointed at, not chained, so this is zero in all three.
out.extend_from_slice(&0u32.to_le_bytes());
out
}
/// IFD0: who took it, with what, and who owns it.
///
/// **No orientation tag, deliberately.** The frame reaching the encoder has
/// already had the source's orientation applied by the pipeline — it is
/// upright pixels — so copying the source's tag across would tell every
/// reader to rotate an image that is already the right way up. A portrait
/// frame would come out on its side in exactly the viewers that honour the
/// tag, which is most of them.
fn main_entries(md: &SourceMetadata) -> Entries {
let mut e = Entries::new();
push_ascii(&mut e, tag::MAKE, md.make.as_deref());
push_ascii(&mut e, tag::MODEL, md.model.as_deref());
push_ascii(&mut e, tag::ARTIST, md.artist.as_deref());
push_ascii(&mut e, tag::COPYRIGHT, md.copyright.as_deref());
e.push((tag::SOFTWARE, Value::Ascii(SOFTWARE.to_string())));
// IFD0's `DateTime` is nominally when the file was written, and this is
// the capture time instead. That is what the rest of the world does —
// and it is what `dr-decode` falls back to for scanner output that has no
// `DateTimeOriginal` — so a re-import of an export lands on the timeline
// where the original did rather than on the day it was exported.
if let Some(t) = md.captured_at.map(datetime) {
e.push((tag::DATE_TIME, Value::Ascii(t)));
}
e
}
/// The Exif sub-IFD: the exposure, and what made it.
fn exif_entries(md: &SourceMetadata, width: u32, height: u32) -> Entries {
let mut e = Entries::new();
// "0232" is Exif 2.32. A sub-directory without a version is technically
// malformed, and some readers refuse the whole block over it.
e.push((tag::EXIF_VERSION, Value::Undefined(b"0232")));
e.push((tag::PIXEL_X, Value::Long(width)));
e.push((tag::PIXEL_Y, Value::Long(height)));
push_ascii(&mut e, tag::LENS_MODEL, md.lens.as_deref());
if let Some(t) = md.captured_at.map(datetime) {
e.push((tag::DATE_TIME_ORIGINAL, Value::Ascii(t)));
}
if let Some(o) = md.captured_offset.map(offset) {
e.push((tag::OFFSET_TIME_ORIGINAL, Value::Ascii(o)));
}
if let Some(s) = md.shutter.filter(|s| *s > 0.0) {
e.push((tag::EXPOSURE_TIME, Value::Rational(vec![shutter(s)])));
}
if let Some(f) = md.aperture.filter(|f| *f > 0.0) {
e.push((tag::FNUMBER, Value::Rational(vec![tenths(f)])));
}
if let Some(f) = md.focal_length.filter(|f| *f > 0.0) {
e.push((tag::FOCAL_LENGTH, Value::Rational(vec![tenths(f)])));
}
// The tag is a SHORT, so a sensitivity above 65535 has no representation
// in it. Dropped rather than truncated: ISO 102400 written as 36864 is a
// lie, and an absent tag is not.
if let Some(iso) = md.iso.filter(|v| *v <= u32::from(u16::MAX)) {
e.push((tag::ISO, Value::Short(iso as u16)));
}
e
}
/// The GPS sub-IFD.
///
/// Empty unless the caller has already decided that coordinates may be
/// written — see [`SourceMetadata::sanitised`], which is where the stripping
/// happens. Nothing in this file consults the settings, so there is exactly
/// one place to look to answer "can this export carry a location".
fn gps_entries(md: &SourceMetadata) -> Entries {
let Some(loc) = md.location else {
return Entries::new();
};
let mut e = Entries::new();
// 2.3.0.0, the current GPS tag version.
e.push((tag::GPS_VERSION_ID, Value::Byte(vec![2, 3, 0, 0])));
e.push((
tag::GPS_LATITUDE_REF,
Value::Ascii(if loc.latitude < 0.0 { "S" } else { "N" }.into()),
));
e.push((tag::GPS_LATITUDE, Value::Rational(dms(loc.latitude))));
e.push((
tag::GPS_LONGITUDE_REF,
Value::Ascii(if loc.longitude < 0.0 { "W" } else { "E" }.into()),
));
e.push((tag::GPS_LONGITUDE, Value::Rational(dms(loc.longitude))));
if let Some(alt) = loc.altitude {
// The altitude itself is unsigned; below sea level is a separate byte.
e.push((
tag::GPS_ALTITUDE_REF,
Value::Byte(vec![u8::from(alt < 0.0)]),
));
e.push((
tag::GPS_ALTITUDE,
Value::Rational(vec![((alt.abs() * 100.0).round() as u32, 100)]),
));
}
e
}
fn push_ascii(entries: &mut Entries, tag: u16, value: Option<&str>) {
// An empty string is a tag saying nothing, which is worse than no tag: it
// overwrites whatever a reader would otherwise have inferred.
if let Some(v) = value.map(str::trim).filter(|v| !v.is_empty()) {
entries.push((tag, Value::Ascii(v.to_string())));
}
}
/// Signed degrees back into the tag's degrees/minutes/seconds.
///
/// The sign is carried by the hemisphere letter, so this takes the magnitude.
/// Seconds keep four decimal places, which is about 3 mm — far finer than any
/// consumer fix, and enough that a round trip through the tag does not move
/// the pin.
pub(crate) fn dms(degrees: f64) -> Vec<(u32, u32)> {
let d = degrees.abs();
let whole = d.trunc();
let minutes = (d - whole) * 60.0;
let seconds = (minutes - minutes.trunc()) * 60.0;
vec![
(whole as u32, 1),
(minutes.trunc() as u32, 1),
((seconds * 10_000.0).round() as u32, 10_000),
]
}
/// A shutter speed as the fraction a photographer would recognise.
///
/// `1/250`, not `4/1000`. Both are the same number and every reader computes
/// the same exposure from either, but the first is what the camera wrote and
/// what a properties panel displays verbatim.
pub(crate) fn shutter(seconds: f32) -> (u32, u32) {
if seconds < 1.0 {
(1, (1.0 / seconds).round().max(1.0) as u32)
} else {
((seconds * 10.0).round() as u32, 10)
}
}
/// f/2.8 and 85 mm as tenths, which is how cameras write both.
pub(crate) fn tenths(value: f32) -> (u32, u32) {
((value * 10.0).round().max(0.0) as u32, 10)
}
/// Unix seconds as EXIF's `"YYYY:MM:DD HH:MM:SS"`.
///
/// The reading is a wall clock with no zone — that is what the tag means, and
/// what `dr-decode` parsed it as — so this is the exact inverse of that parse
/// and involves no timezone conversion. The zone, where the source recorded
/// one, travels separately in `OffsetTimeOriginal`.
pub(crate) fn datetime(unix: i64) -> String {
let days = unix.div_euclid(86_400);
let secs = unix.rem_euclid(86_400);
// Howard Hinnant's civil-from-days, the inverse of the days-from-civil
// that `dr-decode` uses to parse. Eras of 400 years, shifted so that the
// arithmetic never sees a negative.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}:{m:02}:{d:02} {:02}:{:02}:{:02}",
secs / 3600,
(secs / 60) % 60,
secs % 60
)
}
/// Minutes east of UTC as EXIF's `"+HH:MM"`.
pub(crate) fn offset(minutes: i32) -> String {
let sign = if minutes < 0 { '-' } else { '+' };
let m = minutes.unsigned_abs();
format!("{sign}{:02}:{:02}", m / 60, m % 60)
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::Location;
#[test]
fn a_capture_time_survives_the_round_trip_through_the_tag() {
// The parse side lives in `dr-decode` and is exercised against real
// files; this is the inverse, and the two meeting in the middle is
// what keeps an exported frame on the same point of the timeline as
// the original.
assert_eq!(datetime(1_372_462_374), "2013:06:28 23:32:54");
assert_eq!(datetime(0), "1970:01:01 00:00:00");
// A leap day, which is where a hand-rolled calendar goes wrong.
assert_eq!(datetime(1_709_164_800), "2024:02:29 00:00:00");
}
#[test]
fn a_zone_is_written_the_way_the_tag_spells_it() {
assert_eq!(offset(120), "+02:00");
assert_eq!(offset(-330), "-05:30");
assert_eq!(offset(0), "+00:00");
}
#[test]
fn a_shutter_speed_keeps_the_photographers_fraction() {
assert_eq!(shutter(1.0 / 250.0), (1, 250));
assert_eq!(shutter(2.5), (25, 10));
}
#[test]
fn degrees_round_trip_through_the_tags_triple() {
// 48.8582 N is the Eiffel Tower; the check is that the three-part
// form comes back to the same place, to well under a metre.
for degrees in [48.8582_f64, -33.8568, 0.0, 179.999] {
let parts = dms(degrees);
let back = parts[0].0 as f64
+ parts[1].0 as f64 / 60.0
+ (parts[2].0 as f64 / parts[2].1 as f64) / 3600.0;
assert!(
(back - degrees.abs()).abs() < 1e-6,
"{degrees} came back as {back}"
);
}
}
#[test]
fn an_empty_source_produces_no_block_at_all() {
// Every field absent means the only entries would be the ones this
// writer adds itself. That is still worth writing — `Software` and
// the pixel dimensions are true statements — so the block exists; what
// must not happen is a *malformed* one.
let md = SourceMetadata::default();
let bytes = block(&md, 100, 50).expect("the writer's own tags");
assert!(bytes.starts_with(b"II*\0"));
}
#[test]
fn the_gps_directory_is_absent_when_there_is_no_position() {
let md = SourceMetadata {
make: Some("Canon".into()),
..Default::default()
};
let bytes = block(&md, 10, 10).unwrap();
assert!(!contains_entry(&bytes, tag::GPS_IFD));
}
#[test]
fn the_gps_directory_is_present_when_there_is_one() {
// The counterpart of the test above: a strip test that passed because
// the writer could never emit GPS at all would prove nothing.
let md = SourceMetadata {
location: Location::new(48.8582, 2.2945, Some(35.0)),
..Default::default()
};
let bytes = block(&md, 10, 10).unwrap();
assert!(contains_entry(&bytes, tag::GPS_IFD));
}
/// Whether a directory entry for `tag` appears anywhere in the block.
///
/// Byte-level on purpose: an entry is a tag, a type and a count, and
/// searching for that twelve-byte shape's first eight bytes is a far
/// stronger statement than asking a parser that might have skipped the
/// directory the tag was in.
fn contains_entry(bytes: &[u8], tag: u16) -> bool {
bytes
.windows(4)
.any(|w| w[..2] == tag.to_le_bytes() && (w[2] == 4 || w[2] == 13) && w[3] == 0)
}
}
-483
View File
@@ -1,483 +0,0 @@
//! TRACES: FR-EXP-2
//! Minimal ICC v2 matrix/TRC profiles, generated.
//!
//! # Why generated rather than shipped
//!
//! A profile is a description of what the pixels in a file mean, and the
//! pixels here were produced by [`dr_types::colour`]'s matrices. Embedding a
//! profile downloaded from elsewhere would mean two independent statements
//! about the same space, agreeing until one of them was revised. Deriving both
//! from the same primaries makes agreement structural.
//!
//! It is also the only pure-Rust route. Little-CMS is the obvious library and
//! it is C, which the whole workspace avoids so it cross-compiles under the
//! Android NDK — the same reasoning behind rustls, bundled SQLite and the
//! Lensfun port.
//!
//! # What "minimal" leaves out
//!
//! A matrix/TRC display profile and nothing else: three colorants, three tone
//! curves, a white point and the chromatic adaptation that got it there. No
//! A2B/B2A lookup tables, no gamut tag, no named colours. That is the whole of
//! what an RGB working space *is*, and it is what every reader — a browser, an
//! operating system compositor, Photoshop — takes from a profile like this
//! one. The tags omitted describe device behaviour these spaces do not have.
//!
//! Profiles come out around 2 KB, which matters more than it sounds: a JPEG
//! carries the profile in APP2 segments capped at 64 KB each, and one that fits
//! in a single segment avoids the chunked form that some older readers
//! mishandle.
use dr_types::{ColourSpace, Transfer};
/// The ICC profile describing `space`, ready to embed.
///
/// Deterministic — the same space always produces the same bytes. Two exports
/// of the same frame must be byte-identical files, which a creation timestamp
/// read from the clock would quietly break, along with any deduplication
/// downstream of it.
pub fn profile(space: ColourSpace) -> Vec<u8> {
let colorants = space.to_pcs_xyz();
let trc = trc_curve(space.transfer());
// Sorted by signature, as the specification asks a tag table to be. Some
// readers binary-search it.
let mut tags: Vec<(&[u8; 4], Vec<u8>)> = vec![
(b"bTRC", trc.clone()),
// Columns, not rows: a colorant tag is where one primary lands in XYZ.
(b"bXYZ", xyz_type(colorants[2], colorants[5], colorants[8])),
(b"cprt", text_type(COPYRIGHT)),
(b"desc", description_type(&description(space))),
(b"gTRC", trc.clone()),
(b"gXYZ", xyz_type(colorants[1], colorants[4], colorants[7])),
(b"rTRC", trc),
(b"rXYZ", xyz_type(colorants[0], colorants[3], colorants[6])),
// The PCS illuminant itself, not the space's own white. The space's
// white is recoverable from this and `chad`, and a profile that put
// its native white here would have every reader adapt it twice.
(b"wtpt", xyz_type(PCS_D50[0], PCS_D50[1], PCS_D50[2])),
];
// Only where there is an adaptation to declare. ProPhoto is a D50 space
// already, and an identity `chad` is a tag saying nothing.
let adaptation = space.adaptation_to_pcs();
if !is_identity(&adaptation) {
tags.push((b"chad", sf32_type(&adaptation)));
}
tags.sort_by_key(|(sig, _)| **sig);
assemble(&tags)
}
/// What a colour-management dialogue will show this profile as.
///
/// Deliberately not the canonical names. "sRGB IEC61966-2.1" is the reference
/// profile, and this is not it — it is a profile derived from the same
/// primaries, which is a different and weaker claim. "Adobe RGB (1998)" is
/// additionally a name belonging to someone else. A distinct name also tells a
/// user opening the file where the profile came from, which is the question
/// they are asking when they look.
fn description(space: ColourSpace) -> String {
format!("DarkRoom {}", space.label())
}
/// The copyright tag, which ICC requires a profile to carry.
///
/// A set of chromaticity coordinates from a published specification is not
/// something to claim rights over, and a profile nobody may redistribute would
/// make the files carrying it awkward to share — which is the whole purpose of
/// an export.
const COPYRIGHT: &str = "Generated by DarkRoom. No rights reserved.";
/// The profile connection space illuminant, as s15Fixed16 exactly.
const PCS_D50: [f32; 3] = [0.9642, 1.0, 0.8249];
/// Samples in a tabulated tone curve.
///
/// 1024 is what the reference sRGB profiles use. The curve is interpolated
/// linearly between samples, so this is far finer than the 8-bit values it
/// describes; halving it would still be adequate and would save a kilobyte
/// nobody is counting.
const TRC_SAMPLES: usize = 1024;
/// A tone reproduction curve for the space's transfer function.
///
/// ICC curves run *towards* the connection space — device value to linear —
/// which is the opposite direction from the shader's final encode. Getting it
/// backwards produces a file that looks washed out or crushed by exactly the
/// amount the curve bends.
fn trc_curve(transfer: Transfer) -> Vec<u8> {
// A pure power curve has an exact representation: a single u8Fixed8
// gamma. Adobe RGB's 563/256 lands on it precisely, where a 1024-entry
// table would be an approximation of a number the format can hold.
if let Transfer::Gamma(g) = transfer {
let mut out = tag_header(b"curv");
out.extend_from_slice(&1u32.to_be_bytes());
out.extend_from_slice(&((g * 256.0).round() as u16).to_be_bytes());
return out;
}
let mut out = tag_header(b"curv");
out.extend_from_slice(&(TRC_SAMPLES as u32).to_be_bytes());
for i in 0..TRC_SAMPLES {
let device = i as f32 / (TRC_SAMPLES - 1) as f32;
let linear = transfer.decode(device);
out.extend_from_slice(&((linear * 65535.0).round() as u16).to_be_bytes());
}
out
}
/// An `XYZType` tag: one colour in the connection space.
fn xyz_type(x: f32, y: f32, z: f32) -> Vec<u8> {
let mut out = tag_header(b"XYZ ");
for v in [x, y, z] {
out.extend_from_slice(&s15_fixed16(v).to_be_bytes());
}
out
}
/// An `s15Fixed16ArrayType` tag, which is how `chad` is stored.
fn sf32_type(m: &[f32; 9]) -> Vec<u8> {
let mut out = tag_header(b"sf32");
for v in m {
out.extend_from_slice(&s15_fixed16(*v).to_be_bytes());
}
out
}
/// A `textType` tag: ASCII with a terminating NUL.
fn text_type(s: &str) -> Vec<u8> {
let mut out = tag_header(b"text");
out.extend_from_slice(s.as_bytes());
out.push(0);
out
}
/// A `textDescriptionType` tag — the v2 profile's name field.
///
/// Baroque, and not optional: v2 has no plain `mluc`, and the ASCII string is
/// followed by empty Unicode and ScriptCode blocks that a reader will walk
/// whether or not they hold anything. The 67-byte Macintosh field is fixed
/// width by specification, so it is written out zeroed rather than omitted.
fn description_type(s: &str) -> Vec<u8> {
let ascii = s.as_bytes();
let mut out = tag_header(b"desc");
out.extend_from_slice(&(ascii.len() as u32 + 1).to_be_bytes());
out.extend_from_slice(ascii);
out.push(0);
// Unicode language code, then Unicode character count: none of either.
out.extend_from_slice(&[0; 8]);
// ScriptCode code (u16), length (u8), and the fixed 67-byte field.
out.extend_from_slice(&[0; 3]);
out.extend_from_slice(&[0; 67]);
out
}
/// Every tag element opens with its type signature and four reserved bytes.
fn tag_header(sig: &[u8; 4]) -> Vec<u8> {
let mut out = Vec::from(*sig);
out.extend_from_slice(&[0; 4]);
out
}
/// ICC's fixed-point number: 16 integer bits, 16 fractional.
fn s15_fixed16(v: f32) -> i32 {
(f64::from(v) * 65536.0).round() as i32
}
fn is_identity(m: &[f32; 9]) -> bool {
const IDENTITY: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
// One step of s15Fixed16, the format the matrix would be stored in. Below
// that it *is* the identity — ProPhoto's own white and the PCS illuminant
// differ in the sixth decimal place, and a `chad` recording that would be
// nine copies of 1.0000 and 0.0000 dressed up as information.
const STEP: f32 = 1.0 / 65536.0;
m.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < STEP)
}
/// Header, tag table, and the tag data, with the size written back in.
fn assemble(tags: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
let mut out = header();
out.extend_from_slice(&(tags.len() as u32).to_be_bytes());
let table_at = out.len();
out.resize(table_at + tags.len() * 12, 0);
for (i, (sig, data)) in tags.iter().enumerate() {
// Identical elements share one copy, which the specification allows
// explicitly. The three tone curves of a grey-balanced space are the
// same 2 KB table, so this is two thirds of the profile.
let offset = find(&out, data).unwrap_or_else(|| {
let at = out.len();
out.extend_from_slice(data);
// Every element starts on a four-byte boundary.
while !out.len().is_multiple_of(4) {
out.push(0);
}
at
});
let entry = table_at + i * 12;
out[entry..entry + 4].copy_from_slice(*sig);
out[entry + 4..entry + 8].copy_from_slice(&(offset as u32).to_be_bytes());
out[entry + 8..entry + 12].copy_from_slice(&(data.len() as u32).to_be_bytes());
}
let size = out.len() as u32;
out[0..4].copy_from_slice(&size.to_be_bytes());
out
}
/// Where `needle` already sits in `haystack`, if it does.
///
/// Only ever called with tag elements, which begin on four-byte boundaries and
/// start with a type signature — so a match cannot be a coincidental overlap
/// of two other tags' bytes.
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|w| w == needle)
.filter(|at| at.is_multiple_of(4))
}
/// The fixed 128-byte profile header.
fn header() -> Vec<u8> {
let mut h = Vec::with_capacity(128);
// Size, filled in once the profile is complete.
h.extend_from_slice(&[0; 4]);
// Preferred CMM: no preference.
h.extend_from_slice(&[0; 4]);
// Version 2.1.0. v2 rather than v4 because it is what every reader
// handles, and because nothing here needs a v4 tag type.
h.extend_from_slice(&[0x02, 0x10, 0x00, 0x00]);
h.extend_from_slice(b"mntr");
h.extend_from_slice(b"RGB ");
h.extend_from_slice(b"XYZ ");
// Creation date. Fixed, for the determinism the module docs describe.
for field in [2025u16, 1, 1, 0, 0, 0] {
h.extend_from_slice(&field.to_be_bytes());
}
h.extend_from_slice(b"acsp");
// Primary platform, flags, manufacturer, model, attributes: unspecified.
h.extend_from_slice(&[0; 24]);
// Rendering intent: perceptual, as the reference RGB working-space
// profiles declare. For a matrix/TRC profile the field is advisory —
// there is only one transform in here to apply.
h.extend_from_slice(&[0; 4]);
for v in PCS_D50 {
h.extend_from_slice(&s15_fixed16(v).to_be_bytes());
}
// Creator, profile ID, and the reserved tail.
h.extend_from_slice(&[0; 4]);
h.extend_from_slice(&[0; 16]);
h.extend_from_slice(&[0; 28]);
debug_assert_eq!(h.len(), 128);
h
}
#[cfg(test)]
mod tests {
use super::*;
/// A tag's element data, located through the profile's own tag table —
/// so these tests read the profile the way a colour engine would rather
/// than the way it was written.
fn tag<'a>(profile: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> {
let count = u32::from_be_bytes(profile[128..132].try_into().unwrap()) as usize;
for i in 0..count {
let at = 132 + i * 12;
if &profile[at..at + 4] == want {
let off = u32::from_be_bytes(profile[at + 4..at + 8].try_into().unwrap()) as usize;
let len = u32::from_be_bytes(profile[at + 8..at + 12].try_into().unwrap()) as usize;
return Some(&profile[off..off + len]);
}
}
None
}
fn xyz(data: &[u8]) -> [f32; 3] {
let read =
|at: usize| i32::from_be_bytes(data[at..at + 4].try_into().unwrap()) as f32 / 65536.0;
[read(8), read(12), read(16)]
}
#[test]
fn a_profile_declares_its_own_length() {
// The first field a reader trusts. A profile whose header says it is
// longer than the buffer is one a strict parser rejects outright and a
// lax one reads past the end of.
for space in ColourSpace::ALL {
let p = profile(space);
let declared = u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize;
assert_eq!(declared, p.len(), "{space:?}");
}
}
#[test]
fn a_profile_carries_the_signature_that_identifies_it_as_one() {
// `acsp` at offset 36 is how every reader recognises an ICC profile.
for space in ColourSpace::ALL {
assert_eq!(&profile(space)[36..40], b"acsp", "{space:?}");
}
}
#[test]
fn every_tag_lies_inside_the_profile_and_on_a_boundary() {
// A tag table is offsets and lengths, and nothing checks them for us.
// An off-by-four here produces a profile that parses as far as the
// tag a reader happens to want.
for space in ColourSpace::ALL {
let p = profile(space);
let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize;
for i in 0..count {
let at = 132 + i * 12;
let off = u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap()) as usize;
let len = u32::from_be_bytes(p[at + 8..at + 12].try_into().unwrap()) as usize;
assert!(off.is_multiple_of(4), "{space:?} tag {i} starts at {off}");
assert!(off + len <= p.len(), "{space:?} tag {i} runs off the end");
}
}
}
#[test]
fn every_profile_carries_the_tags_a_matrix_trc_profile_requires() {
// The ICC v2 required set for a display profile. A reader missing any
// one of these falls back to assuming sRGB, which is the silent
// failure this whole feature exists to prevent.
for space in ColourSpace::ALL {
let p = profile(space);
for required in [
b"desc", b"cprt", b"wtpt", b"rXYZ", b"gXYZ", b"bXYZ", b"rTRC", b"gTRC", b"bTRC",
] {
assert!(tag(&p, required).is_some(), "{space:?} has no {required:?}");
}
}
}
#[test]
fn the_colorants_are_the_ones_the_shader_encoded_with() {
// The property the file's honesty rests on. The composer converts the
// pixels with `to_pcs_xyz`'s primaries; if the profile described any
// others the file would be a precise, confident lie.
for space in ColourSpace::ALL {
let p = profile(space);
let want = space.to_pcs_xyz();
for (i, sig) in [b"rXYZ", b"gXYZ", b"bXYZ"].into_iter().enumerate() {
let got = xyz(tag(&p, sig).expect("colorant"));
for (row, g) in got.iter().enumerate() {
let expected = want[row * 3 + i];
assert!(
(g - expected).abs() < 1e-4,
"{space:?} {sig:?} row {row}: profile says {g}, shader used {expected}"
);
}
}
}
}
#[test]
fn the_white_point_is_the_connection_space_illuminant() {
// Not the space's own white. ProPhoto's is D50 anyway, but P3's is
// D65, and a profile advertising D65 as its media white would have
// every neutral adapted a second time.
for space in ColourSpace::ALL {
let got = xyz(tag(&profile(space), b"wtpt").expect("wtpt"));
for (i, want) in PCS_D50.iter().enumerate() {
assert!((got[i] - want).abs() < 1e-4, "{space:?} white {i}: {got:?}");
}
}
}
#[test]
fn a_tabulated_curve_reproduces_the_transfer_function_it_came_from() {
// Read back out of the profile and compared against the function the
// shader encodes with. The curve runs device-to-linear, and writing it
// the other way round would still produce a monotonic curve of the
// right length — this is what catches the direction.
for space in [ColourSpace::Srgb, ColourSpace::ProPhoto] {
let p = profile(space);
let curve = tag(&p, b"rTRC").expect("rTRC");
let count = u32::from_be_bytes(curve[8..12].try_into().unwrap()) as usize;
assert_eq!(count, TRC_SAMPLES, "{space:?}");
let transfer = space.transfer();
for i in [0, 1, count / 4, count / 2, count - 1] {
let at = 12 + i * 2;
let got =
f32::from(u16::from_be_bytes(curve[at..at + 2].try_into().unwrap())) / 65535.0;
let want = transfer.decode(i as f32 / (count - 1) as f32);
assert!(
(got - want).abs() < 1e-4,
"{space:?} sample {i}: profile {got}, transfer {want}"
);
}
}
}
#[test]
fn adobe_rgb_stores_its_gamma_exactly_rather_than_sampling_it() {
// 563/256 is representable in a u8Fixed8, so the curve is one number.
// A 1024-entry table would approximate a value the format can hold
// exactly, and would round-trip through other software as 2.2.
let p = profile(ColourSpace::AdobeRgb);
let curve = tag(&p, b"rTRC").expect("rTRC");
assert_eq!(u32::from_be_bytes(curve[8..12].try_into().unwrap()), 1);
assert_eq!(u16::from_be_bytes(curve[12..14].try_into().unwrap()), 563);
}
#[test]
fn the_three_tone_curves_share_one_copy() {
// Not a size optimisation for its own sake: it keeps the profile under
// the 64 KB a single JPEG APP2 segment holds, so the chunked form that
// older readers mishandle is never needed.
let p = profile(ColourSpace::Srgb);
let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize;
let offsets: Vec<u32> = ["rTRC", "gTRC", "bTRC"]
.iter()
.map(|sig| {
(0..count)
.map(|i| 132 + i * 12)
.find(|at| &p[*at..at + 4] == sig.as_bytes())
.map(|at| u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap()))
.expect("curve present")
})
.collect();
assert_eq!(offsets[0], offsets[1]);
assert_eq!(offsets[1], offsets[2]);
assert!(p.len() < 8 * 1024, "{} bytes is too large", p.len());
}
#[test]
fn a_d65_space_declares_its_adaptation_and_a_d50_space_does_not() {
// `chad` is what lets a reader recover the space's native white from
// colorants that have already been adapted. Without it, D65 primaries
// adapted to D50 and genuine D50 primaries are the same nine numbers.
assert!(tag(&profile(ColourSpace::DisplayP3), b"chad").is_some());
assert!(
tag(&profile(ColourSpace::ProPhoto), b"chad").is_none(),
"ProPhoto is a D50 space; an identity chad says nothing"
);
}
#[test]
fn the_same_space_always_produces_the_same_bytes() {
// Two exports of one frame must be identical files. A creation
// timestamp from the clock is the obvious way to lose that.
for space in ColourSpace::ALL {
assert_eq!(profile(space), profile(space), "{space:?}");
}
}
#[test]
fn each_space_is_described_by_its_own_name() {
// A file whose profile says "sRGB" while carrying P3 pixels is exactly
// as misleading as no profile at all, and harder to notice.
for space in ColourSpace::ALL {
let p = profile(space);
let desc = tag(&p, b"desc").expect("desc");
let len = u32::from_be_bytes(desc[8..12].try_into().unwrap()) as usize;
let name = std::str::from_utf8(&desc[12..12 + len - 1]).expect("ascii");
assert_eq!(name, format!("DarkRoom {}", space.label()));
}
}
}
-384
View File
@@ -1,384 +0,0 @@
//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9
//! Turning a rendered frame into a file's worth of bytes.
//!
//! # What this crate is, and is not
//!
//! It is: resize, output sharpening, encode, and the name the result should
//! be given. It is not: a filesystem, a network client, or a job queue.
//! [`export`] returns [`Encoded`] — bytes and a filename — and the caller
//! decides where that lands.
//!
//! That boundary is not fastidiousness. An export has three possible
//! destinations and they have nothing in common: a path on Linux, a Storage
//! Access Framework document on Android where there *is* no path
//! (ARCH §6.9), and a `PUT` to a Nextcloud folder. A crate that wrote the
//! file itself would serve one of them and be rewritten for the other two.
//!
//! # Order of operations
//!
//! Resize, then sharpen, then encode. Sharpening after the resize is the
//! whole point of output sharpening (FR-EXP-4): it compensates for the
//! softening the resample introduced, so its strength has to scale with how
//! much scaling actually happened. Sharpening first and then shrinking would
//! throw the sharpened detail away.
use dr_types::{ColourSpace, ExportFormat, ExportSettings};
mod encode;
mod error;
mod exif;
pub mod icc;
mod metadata;
mod name;
mod sharpen;
mod size;
pub use error::ExportError;
pub use metadata::SourceMetadata;
pub use name::{resolve_name, NameContext};
pub use size::target_size;
/// A rendered frame, as the adjust pass produced it.
///
/// 8-bit RGBA, display-encoded in [`Self::space`] — the format
/// [`dr_gpu::AdjustPass`](../dr_gpu/struct.AdjustPass.html) writes. Alpha is
/// carried but never meaningful: the pipeline writes 1.0 everywhere, and no
/// operation produces transparency.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
pub width: u32,
pub height: u32,
/// Tightly packed RGBA8, `width * height * 4` bytes.
pub rgba: Vec<u8>,
/// TRACES: FR-EXP-2
/// The space the shader encoded these pixels into.
///
/// Travels with the pixels rather than being asserted at the point of
/// encoding, because it is a fact about them and not a preference. The
/// conversion happened in the generated shader, before the clip to 0..1,
/// and nothing downstream can undo or redo it — a frame clipped to sRGB
/// has already lost whatever a wider space would have carried.
///
/// Making it a field is what lets [`export`] refuse to label a frame as
/// something it is not, rather than trusting a caller to have rendered
/// what it asked for.
pub space: ColourSpace,
}
impl Frame {
/// A frame the pipeline rendered in sRGB — what
/// [`EditGraph::compose`](../dr_pipeline/struct.EditGraph.html#method.compose)
/// produces, and so what the display path hands over.
///
/// An export in a wider space must render its own frame with
/// `compose_for` and declare it through [`Self::in_space`]. Defaulting
/// here rather than demanding the space at every call site keeps the
/// common case honest by construction: a caller that has not thought
/// about colour is describing sRGB, and sRGB is what it rendered.
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Result<Self, ExportError> {
Self::in_space(width, height, rgba, ColourSpace::Srgb)
}
/// A frame rendered into a stated colour space.
pub fn in_space(
width: u32,
height: u32,
rgba: Vec<u8>,
space: ColourSpace,
) -> Result<Self, ExportError> {
let expected = width as usize * height as usize * 4;
if rgba.len() != expected {
return Err(ExportError::FrameSize {
expected,
got: rgba.len(),
});
}
if width == 0 || height == 0 {
return Err(ExportError::EmptyFrame);
}
Ok(Self {
width,
height,
rgba,
space,
})
}
}
/// The finished article: what to write, and what to call it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Encoded {
/// Filename including extension. Never a path — the destination folder is
/// the caller's, and on Android it is not expressible as one anyway.
pub name: String,
pub bytes: Vec<u8>,
/// What the image was actually written at, after sizing and the upscaling
/// guard. Worth reporting: a batch that silently exported at source size
/// because the request was larger has done something the user should know.
pub width: u32,
pub height: u32,
}
/// Resize, sharpen and encode one frame.
///
/// `name` is the filename already resolved by [`resolve_name`] — passed in
/// rather than derived here because resolving it needs to know what is
/// already in the destination, which this crate cannot see.
///
/// TRACES: FR-EXP-9
/// The frame is expected to be a **full-resolution** render. Nothing here
/// enforces that, because nothing here can tell a full render from a
/// viewport-sized one; the caller renders at the framed output size and this
/// resamples down from it. Exporting from the display proxy would silently
/// produce a soft file, which is why the develop session's export path renders
/// its own frame rather than reusing the one on screen.
///
/// TRACES: FR-EXP-8
/// `source` is what the photograph's own file said about itself, or `None`
/// where the caller has nothing — a frame that came from somewhere other than
/// a decoded file, or a caller that has not yet been taught to pass it.
///
/// **A parameter rather than a field on [`Frame`]**, because it is not a fact
/// about the pixels: two exports of the same frame can legitimately disclose
/// different amounts, and the settings that decide how much travel beside it.
/// It is also why this is an argument and not an `Option` with a default — a
/// caller that has the source metadata should have to decide, in one visible
/// place, to hand it over.
pub fn export(
frame: &Frame,
settings: &ExportSettings,
name: String,
source: Option<&SourceMetadata>,
) -> Result<Encoded, ExportError> {
// TRACES: FR-EXP-2
// Refused rather than mislabelled. Every space the settings page offers
// now works, but only if the *frame* was rendered into it: the conversion
// and the clip both happen in the generated shader, so pixels that arrive
// clipped to sRGB have already lost whatever a wider space would have
// carried, and no amount of profile-writing here brings it back.
//
// The caller's fix is to compose with `EditGraph::compose_for(space)`
// before rendering. Until it does, this is an accurate error where the
// alternative would be a file that claims a gamut it does not contain —
// and that claim survives into everything downstream.
if frame.space != settings.colour_space {
return Err(ExportError::ColourSpaceMismatch {
rendered: frame.space,
requested: settings.colour_space,
});
}
if matches!(settings.format, ExportFormat::Avif | ExportFormat::JpegXl) {
return Err(ExportError::FormatUnsupported(settings.format));
}
let (width, height) = size::target_size(
frame.width,
frame.height,
settings.sizing,
settings.allow_upscaling,
);
let resized = size::resample(frame, width, height);
// Scaled by how much the image actually shrank: a full-size export needs
// no compensation, and a thumbnail needs a great deal.
let scale = width as f32 / frame.width.max(1) as f32;
let sharpened = sharpen::apply(resized, width, height, settings.sharpening, scale);
let bytes = encode::encode(&sharpened, width, height, settings, source)?;
Ok(Encoded {
name,
bytes,
width,
height,
})
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::SizingMode;
/// A frame with a recognisable gradient, so a resample can be checked for
/// having done something rather than merely returned the right length.
pub(crate) fn frame(w: u32, h: u32) -> Frame {
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
rgba.push((x * 255 / w.max(1)) as u8);
rgba.push((y * 255 / h.max(1)) as u8);
rgba.push(128);
rgba.push(255);
}
}
Frame::new(w, h, rgba).expect("well-formed")
}
fn settings(format: ExportFormat) -> ExportSettings {
ExportSettings {
format,
..Default::default()
}
}
#[test]
fn a_frame_rejects_a_buffer_of_the_wrong_length() {
// The one error that would otherwise surface as a panic deep in an
// encoder, or worse, as a file of garbage.
assert!(matches!(
Frame::new(4, 4, vec![0; 10]),
Err(ExportError::FrameSize { .. })
));
}
#[test]
fn jpeg_export_produces_a_jpeg() {
let out = export(
&frame(64, 48),
&settings(ExportFormat::Jpeg),
"a.jpg".into(),
None,
)
.unwrap();
// SOI marker. Cheap, and it catches an encoder wired to the wrong
// format far more directly than a byte count would.
assert_eq!(&out.bytes[..2], &[0xFF, 0xD8]);
assert_eq!((out.width, out.height), (64, 48));
}
#[test]
fn png_export_produces_a_png() {
let out = export(
&frame(32, 32),
&settings(ExportFormat::Png),
"a.png".into(),
None,
)
.unwrap();
assert_eq!(&out.bytes[..8], b"\x89PNG\r\n\x1a\n");
}
#[test]
fn tiff_exports_produce_a_tiff() {
for format in [ExportFormat::Tiff8, ExportFormat::Tiff16] {
let out = export(&frame(16, 16), &settings(format), "a.tif".into(), None).unwrap();
// Either byte order is a valid TIFF; the crate writes little-endian.
assert!(
out.bytes.starts_with(b"II*\0") || out.bytes.starts_with(b"MM\0*"),
"{format:?} did not produce a TIFF header"
);
}
}
#[test]
fn a_sixteen_bit_tiff_is_larger_than_an_eight_bit_one() {
// Both are uncompressed RGB; the only difference is the sample width,
// so this is what proves the 16-bit path is not quietly writing 8.
let eight = export(
&frame(16, 16),
&settings(ExportFormat::Tiff8),
"a".into(),
None,
)
.unwrap();
let sixteen = export(
&frame(16, 16),
&settings(ExportFormat::Tiff16),
"a".into(),
None,
)
.unwrap();
assert!(sixteen.bytes.len() > eight.bytes.len());
}
#[test]
fn quality_changes_the_size_of_a_jpeg() {
// The setting is plumbed all the way to the encoder rather than
// accepted and dropped, which a size-independent output would show.
let mut low = settings(ExportFormat::Jpeg);
low.quality = 20;
let mut high = settings(ExportFormat::Jpeg);
high.quality = 98;
let small = export(&frame(128, 128), &low, "a".into(), None).unwrap();
let large = export(&frame(128, 128), &high, "a".into(), None).unwrap();
assert!(
large.bytes.len() > small.bytes.len(),
"quality 98 produced {} bytes against quality 20's {}",
large.bytes.len(),
small.bytes.len()
);
}
#[test]
fn a_long_edge_export_lands_on_the_requested_size() {
let mut s = settings(ExportFormat::Png);
s.sizing = SizingMode::LongEdge(32);
let out = export(&frame(128, 64), &s, "a".into(), None).unwrap();
assert_eq!((out.width, out.height), (32, 16));
}
#[test]
fn a_frame_rendered_in_one_space_is_not_labelled_another() {
// A file tagged Display P3 carrying sRGB-clipped pixels is a lie that
// survives into everything downstream. The frame carries the space it
// was rendered in precisely so this cannot be waved through.
let mut s = settings(ExportFormat::Jpeg);
s.colour_space = ColourSpace::DisplayP3;
assert!(matches!(
export(&frame(8, 8), &s, "a".into(), None),
Err(ExportError::ColourSpaceMismatch { .. })
));
}
#[test]
fn every_colour_space_exports_when_the_frame_was_rendered_in_it() {
// The other side of the refusal above, and what FR-EXP-2 actually
// asks for: a frame the pipeline encoded into a wide space reaches a
// file, in every format that has an encoder.
for space in ColourSpace::ALL {
for format in [
ExportFormat::Jpeg,
ExportFormat::Png,
ExportFormat::Tiff8,
ExportFormat::Tiff16,
] {
let mut s = settings(format);
s.colour_space = space;
let mut f = frame(8, 8);
f.space = space;
let out = export(&f, &s, "a".into(), None)
.unwrap_or_else(|e| panic!("{space:?} as {format:?}: {e}"));
assert!(!out.bytes.is_empty());
}
}
}
#[test]
fn the_formats_without_an_encoder_say_so() {
for format in [ExportFormat::Avif, ExportFormat::JpegXl] {
assert!(
matches!(
export(&frame(8, 8), &settings(format), "a".into(), None),
Err(ExportError::FormatUnsupported(_))
),
"{format:?} should report that it has no encoder yet"
);
}
}
#[test]
fn every_offered_format_either_encodes_or_explains_itself() {
// Walks `ExportFormat::ALL`, so a format added to the settings page
// cannot quietly reach an encoder that does not handle it.
for format in ExportFormat::ALL {
match export(&frame(8, 8), &settings(format), "a".into(), None) {
Ok(out) => assert!(!out.bytes.is_empty(), "{format:?} encoded to nothing"),
Err(ExportError::FormatUnsupported(f)) => assert_eq!(f, format),
Err(e) => panic!("{format:?} failed unexpectedly: {e}"),
}
}
}
}
-106
View File
@@ -1,106 +0,0 @@
//! TRACES: FR-EXP-8
//! What an export is allowed to say about where it came from.
//!
//! # An allowlist, not a filter
//!
//! [`SourceMetadata`] is the whole of what can reach a file this crate writes.
//! It is populated field by field from whatever the caller decoded, and
//! nothing else travels — not because each unwanted tag is removed, but
//! because there is nowhere in this type for one to sit. That is the
//! difference between "we strip GPS" and "GPS cannot be written unless
//! [`SourceMetadata::location`] is `Some`", and only the second survives
//! somebody adding a field to the decoder next year.
//!
//! # What is deliberately not here
//!
//! **The maker note** (EXIF `0x927C`). It is an opaque vendor blob with no
//! public format, and its contents differ by body and firmware. Canon's
//! carries the body serial number and the shutter count; several bodies put a
//! *duplicate copy of the GPS fix* inside it, which is the specific reason it
//! cannot be passed through as an unexamined byte range: an export that
//! stripped the GPS directory and copied the maker note would have published
//! the coordinates anyway, while reporting itself as private. Parsing it per
//! vendor to decide what is safe is a research project with a permanent
//! maintenance cost, and the value on the other side is a few tags a
//! photographer rarely misses. So it is dropped, in both directions, whatever
//! the settings say.
//!
//! **Serial numbers and owner name** (`BodySerialNumber` 0xA431,
//! `LensSerialNumber` 0xA435, `CameraOwnerName` 0xA430). These identify a
//! person and a specific piece of equipment, and a serial number in a
//! published file links every photograph that person has ever posted. They
//! have no field here, so no export writes them.
//!
//! **IPTC and XMP.** FR-EXP-8 names both. Neither is read by `dr-decode`
//! today, so there is nothing to carry through; when there is, it arrives as
//! fields on this type and is written from them, and the same allowlist
//! reasoning applies unchanged.
use dr_types::Location;
/// TRACES: FR-EXP-8
/// The source metadata an export may carry.
///
/// Every field is optional because every field is genuinely absent from some
/// real file: scanner output has no aperture, a JPEG from a phone has no lens
/// model, and most photographs have no copyright statement at all.
///
/// Built by the caller, which is the only place that has both the decoded
/// source and the crate that decoded it — `dr-export` deliberately depends on
/// no decoder (see the crate docs), so the copy is made one field at a time
/// where both types are in scope. That transcription is a feature: it is the
/// point where somebody has to decide, in writing, that a newly-parsed piece
/// of the source is allowed to leave the machine.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SourceMetadata {
pub make: Option<String>,
pub model: Option<String>,
pub lens: Option<String>,
/// Exposure time in seconds.
pub shutter: Option<f32>,
/// The f-number, as in f/2.8.
pub aperture: Option<f32>,
pub iso: Option<u32>,
/// Millimetres, as marked on the lens rather than 35 mm equivalent.
pub focal_length: Option<f32>,
/// When the shutter fired, as Unix seconds read as a wall clock.
pub captured_at: Option<i64>,
/// Minutes east of UTC, where the camera recorded a zone.
pub captured_offset: Option<i32>,
/// Who made the photograph.
pub artist: Option<String>,
/// The rights statement.
pub copyright: Option<String>,
/// TRACES: FR-EXP-8
/// Where the shutter fired.
///
/// The one field the strip option is about. It is carried this far so that
/// a photographer who *wants* their coordinates can have them; by the time
/// the encoder sees the record this field has already been through
/// [`Self::sanitised`], and is `None` unless the user turned stripping
/// off.
pub location: Option<Location>,
}
impl SourceMetadata {
/// This record as the settings permit it to be written.
///
/// **The single place stripping happens.** The encoders below take a
/// record and write what is in it, with no view on privacy; concentrating
/// the decision here means there is one function to read to know what an
/// export can disclose, and no format can quietly disagree with the
/// others — the failure mode where JPEG honours the setting and TIFF, five
/// hundred lines away, does not.
///
/// Stripping empties the field rather than blanking it. A `GPSLatitude` of
/// `0/0` still announces that the camera had a fix and that this file has
/// been through a scrubber; an absent directory says nothing at all, and
/// says it in the same shape as the millions of files that never had one.
pub(crate) fn sanitised(&self, strip_location: bool) -> Self {
let mut out = self.clone();
if strip_location {
out.location = None;
}
out
}
}
-316
View File
@@ -1,316 +0,0 @@
//! TRACES: FR-EXP-6
//! Filename templates and what to do when the name is taken.
//!
//! # Why the caller supplies the "does this exist" test
//!
//! [`resolve_name`] takes a closure rather than looking at a directory,
//! because there is no directory it could look at that would work everywhere.
//! A destination is a path on Linux, a Storage Access Framework tree on
//! Android with no path at all (ARCH §6.9), or a folder on a Nextcloud
//! server reached by PROPFIND. All three can answer "is this name taken",
//! and none of them can be asked the same way.
//!
//! It matters most on Android, where the platform actively works against us:
//! `DocumentsContract.createDocument` renames on collision *by itself*,
//! appending ` (1)` and returning a URI with a name nobody asked for, and it
//! cannot overwrite at all. So every one of the three [`CollisionPolicy`]
//! settings requires knowing the answer before creating anything — which is
//! exactly what this function is shaped for.
use dr_types::{CollisionPolicy, ExportFormat};
/// What a template can refer to.
#[derive(Debug, Clone, Default)]
pub struct NameContext<'a> {
/// The source image's name, without extension — `{name}`.
pub source_stem: &'a str,
/// Position in the batch, 1-based — `{seq}`.
pub sequence: u32,
/// Capture date as `YYYY-MM-DD` — `{date}`. Empty where unknown.
pub date: &'a str,
/// The export's pixel dimensions — `{dimensions}`.
pub width: u32,
pub height: u32,
/// The preset that produced this export — `{preset}`. Empty where none.
pub preset: &'a str,
}
/// Expand a template into a filename stem.
///
/// Unknown tokens are left verbatim rather than dropped. A user who typed
/// `{nmae}` should see it in the output and understand what happened; a
/// silently empty filename is a puzzle, and a template that quietly loses a
/// token produces a directory of files named the same thing.
pub fn expand(template: &str, ctx: &NameContext<'_>) -> String {
let seq = ctx.sequence.to_string();
let dimensions = format!("{}x{}", ctx.width, ctx.height);
let mut out = String::with_capacity(template.len() + 16);
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let Some(close) = rest[open..].find('}') else {
// An unclosed brace is literal text; there is nothing to expand
// and dropping the remainder would truncate the name. Consumed
// here rather than left for the tail append below, which has
// already had everything before the brace taken from it.
out.push_str(&rest[open..]);
rest = "";
break;
};
let token = &rest[open + 1..open + close];
match token {
"name" => out.push_str(ctx.source_stem),
"seq" => out.push_str(&seq),
"date" => out.push_str(ctx.date),
"dimensions" => out.push_str(&dimensions),
"preset" => out.push_str(ctx.preset),
_ => out.push_str(&rest[open..open + close + 1]),
}
rest = &rest[open + close + 1..];
}
out.push_str(rest);
let cleaned = sanitise(&out);
if cleaned.is_empty() {
// Every token was empty — a template of `{preset}` with no preset, on
// an image with no date. Falling back to the source name is the one
// answer that is always available and never collides more than the
// source files themselves do.
return sanitise(ctx.source_stem);
}
cleaned
}
/// Strip what no filesystem, SAF provider or WebDAV server will take.
///
/// The intersection of three sets of rules rather than any one of them: an
/// export written to a Nextcloud folder may later sync down to a Windows
/// client, and a name that was legal where it was created is not much comfort
/// on the machine that cannot open it.
fn sanitise(stem: &str) -> String {
let mut out: String = stem
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
c if (c as u32) < 0x20 => '-',
c => c,
})
.collect();
// Trailing dots and spaces are legal on Linux and rejected by Windows,
// and a name ending in one is almost always an accident of a template
// whose last token expanded to nothing.
while out.ends_with('.') || out.ends_with(' ') {
out.pop();
}
out.trim_start().to_string()
}
/// The filename this export should be written under, honouring the collision
/// policy.
///
/// `taken` answers whether a name already exists in the destination. Returns
/// `None` for [`CollisionPolicy::Skip`] when the name is in use — the caller
/// writes nothing and moves on, which is the whole point of that setting.
pub fn resolve_name(
template: &str,
ctx: &NameContext<'_>,
format: ExportFormat,
collision: CollisionPolicy,
taken: &dyn Fn(&str) -> bool,
) -> Option<String> {
let stem = expand(template, ctx);
let ext = format.extension();
let first = format!("{stem}.{ext}");
if !taken(&first) {
return Some(first);
}
match collision {
CollisionPolicy::Overwrite => Some(first),
CollisionPolicy::Skip => None,
CollisionPolicy::Increment => {
// Bounded. An unbounded search would spin forever against a
// destination that reports everything as taken — a permission
// error misread as existence, say — and a batch that hangs is
// worse than one that reports a failure.
for n in 1..10_000 {
let candidate = format!("{stem}-{n}.{ext}");
if !taken(&candidate) {
return Some(candidate);
}
}
log::warn!("{stem}: ten thousand names taken; skipping");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> NameContext<'static> {
NameContext {
source_stem: "IMG_1234",
sequence: 7,
date: "2026-08-16",
width: 2048,
height: 1365,
preset: "Web",
}
}
fn free(_: &str) -> bool {
false
}
#[test]
fn the_default_template_is_the_source_name() {
assert_eq!(expand("{name}", &ctx()), "IMG_1234");
}
#[test]
fn every_documented_token_expands() {
// The settings page advertises these five in its hint; a token listed
// there and unhandled here would reach the filename verbatim.
assert_eq!(expand("{name}", &ctx()), "IMG_1234");
assert_eq!(expand("{seq}", &ctx()), "7");
assert_eq!(expand("{date}", &ctx()), "2026-08-16");
assert_eq!(expand("{dimensions}", &ctx()), "2048x1365");
assert_eq!(expand("{preset}", &ctx()), "Web");
}
#[test]
fn tokens_combine_with_literal_text() {
assert_eq!(
expand("{date}_{name}_{dimensions}", &ctx()),
"2026-08-16_IMG_1234_2048x1365"
);
}
#[test]
fn an_unknown_token_survives_verbatim() {
// A typo the user can see and fix, rather than a name that silently
// lost a component and now collides with every other export.
assert_eq!(expand("{nmae}-x", &ctx()), "{nmae}-x");
}
#[test]
fn an_unclosed_brace_is_literal_text() {
assert_eq!(expand("{name", &ctx()), "{name");
assert_eq!(expand("a{name}b{", &ctx()), "aIMG_1234b{");
}
#[test]
fn a_template_that_expands_to_nothing_falls_back_to_the_source_name() {
// `{preset}` with no preset selected. An empty filename is not a file.
let mut c = ctx();
c.preset = "";
assert_eq!(expand("{preset}", &c), "IMG_1234");
}
#[test]
fn path_separators_cannot_escape_the_destination() {
// `{name}` comes from a source filename, and a template is user text.
// Either could carry a slash, and an export must not write outside
// the folder that was chosen — nor create a subfolder on the server.
let mut c = ctx();
c.source_stem = "holiday/2026";
assert_eq!(expand("{name}", &c), "holiday-2026");
assert_eq!(expand("../../etc/passwd", &ctx()), "..-..-etc-passwd");
}
#[test]
fn characters_windows_rejects_are_replaced() {
// An export may sync down to a Windows client through Nextcloud, and
// a name that was legal where it was written is no comfort there.
assert_eq!(expand(r#"a:b*c?d"e<f>g|h\i"#, &ctx()), "a-b-c-d-e-f-g-h-i");
}
#[test]
fn trailing_dots_and_spaces_are_trimmed() {
let mut c = ctx();
c.preset = "";
assert_eq!(expand("{name}.{preset}", &c), "IMG_1234");
assert_eq!(expand("{name} ", &ctx()), "IMG_1234");
}
#[test]
fn a_free_name_is_used_as_is() {
let got = resolve_name(
"{name}",
&ctx(),
ExportFormat::Jpeg,
CollisionPolicy::Increment,
&free,
);
assert_eq!(got.as_deref(), Some("IMG_1234.jpg"));
}
#[test]
fn the_extension_follows_the_format() {
for (format, ext) in [
(ExportFormat::Jpeg, "jpg"),
(ExportFormat::Png, "png"),
(ExportFormat::Tiff16, "tif"),
] {
let got = resolve_name("{name}", &ctx(), format, CollisionPolicy::Skip, &free);
assert_eq!(got.as_deref(), Some(&*format!("IMG_1234.{ext}")));
}
}
#[test]
fn increment_finds_the_first_free_suffix() {
let taken = |n: &str| matches!(n, "IMG_1234.jpg" | "IMG_1234-1.jpg" | "IMG_1234-2.jpg");
let got = resolve_name(
"{name}",
&ctx(),
ExportFormat::Jpeg,
CollisionPolicy::Increment,
&taken,
);
assert_eq!(got.as_deref(), Some("IMG_1234-3.jpg"));
}
#[test]
fn skip_returns_nothing_when_the_name_is_taken() {
// The caller writes no file at all — that is what Skip means, and it
// is why this returns an Option rather than always a name.
let got = resolve_name(
"{name}",
&ctx(),
ExportFormat::Jpeg,
CollisionPolicy::Skip,
&|_| true,
);
assert_eq!(got, None);
}
#[test]
fn overwrite_returns_the_taken_name() {
let got = resolve_name(
"{name}",
&ctx(),
ExportFormat::Jpeg,
CollisionPolicy::Overwrite,
&|_| true,
);
assert_eq!(got.as_deref(), Some("IMG_1234.jpg"));
}
#[test]
fn increment_gives_up_rather_than_spinning_forever() {
// A destination that reports every name as taken — a permission error
// misread as existence — must not hang the batch.
let got = resolve_name(
"{name}",
&ctx(),
ExportFormat::Jpeg,
CollisionPolicy::Increment,
&|_| true,
);
assert_eq!(got, None);
}
}
-197
View File
@@ -1,197 +0,0 @@
//! TRACES: FR-EXP-4
//! Output sharpening, scaled by how far the image was resized.
//!
//! # Why an export needs this at all
//!
//! Downsampling averages neighbouring pixels, and averaging is a low-pass
//! filter: a 24 MP frame reduced to 2048px comes out measurably softer than
//! the same scene shot at 2048px would be. Output sharpening puts back the
//! acuity the resample removed. It is not creative sharpening — that belongs
//! in the develop pipeline, acts on the full-resolution image, and is a
//! different control entirely.
//!
//! # Why the strength depends on the medium
//!
//! The three settings are not intensities dressed up as names. A screen shows
//! a pixel as a pixel, so it needs the least. Ink spreads into paper — dot
//! gain — and matte stock spreads it further than glossy, so a print needs
//! more compensation to arrive looking the same. That is why the paper
//! options are stronger, and why "more" is not simply a slider.
use dr_types::OutputSharpening;
/// Radius of the unsharp mask, in pixels.
///
/// Fixed at a small value rather than scaled with the image: output
/// sharpening compensates for the *resample*, which softens over a pixel or
/// two whatever the size of the frame. A radius that grew with the image
/// would produce haloes on a large export.
const RADIUS: i32 = 1;
/// Per-setting strength. Applied on top of the resize-derived scaling below.
fn strength(setting: OutputSharpening) -> f32 {
match setting {
OutputSharpening::None => 0.0,
OutputSharpening::Screen => 0.55,
// Ink spread. Matte stock absorbs more than glossy, so it needs the
// heavier hand of the two.
OutputSharpening::GlossyPaper => 0.85,
OutputSharpening::MattePaper => 1.15,
}
}
/// Sharpen in place-ish: takes the resized buffer and returns it, sharpened.
///
/// `scale` is the resize factor — destination width over source width. Below
/// 1 the image was reduced and needs compensation; at or above 1 nothing was
/// averaged away and the sharpening is skipped, because sharpening an image
/// that was not softened only adds haloes.
pub fn apply(
mut rgba: Vec<u8>,
width: u32,
height: u32,
setting: OutputSharpening,
scale: f32,
) -> Vec<u8> {
let base = strength(setting);
if base == 0.0 || scale >= 1.0 || width < 3 || height < 3 {
return rgba;
}
// A frame reduced to a tenth lost far more than one reduced to nine
// tenths, so the compensation follows the reduction. Capped at the base
// strength: past a point more sharpening is just edge artefacts, and a
// thumbnail is the case where that shows most.
let amount = base * (1.0 - scale).clamp(0.0, 1.0);
let src = rgba.clone();
let (w, h) = (width as i32, height as i32);
for y in 0..h {
for x in 0..w {
for c in 0..3 {
// A 3×3 box blur is the mask. Gaussian would be more correct
// and, at radius 1, indistinguishable — the kernel is nine
// pixels either way.
let mut sum = 0.0f32;
let mut n = 0.0f32;
for dy in -RADIUS..=RADIUS {
for dx in -RADIUS..=RADIUS {
let sx = (x + dx).clamp(0, w - 1);
let sy = (y + dy).clamp(0, h - 1);
sum += f32::from(src[((sy * w + sx) * 4 + c) as usize]);
n += 1.0;
}
}
let blurred = sum / n;
let p = ((y * w + x) * 4 + c) as usize;
let original = f32::from(src[p]);
// Unsharp mask: the original plus its difference from a
// blurred copy, which is the high-frequency detail.
let sharpened = original + (original - blurred) * amount;
rgba[p] = sharpened.round().clamp(0.0, 255.0) as u8;
}
}
}
rgba
}
#[cfg(test)]
mod tests {
use super::*;
/// A frame split down the middle: dark left, light right. One vertical
/// edge, which is what sharpening acts on.
fn edge(w: u32, h: u32) -> Vec<u8> {
let mut v = Vec::new();
for _ in 0..h {
for x in 0..w {
let level = if x < w / 2 { 60 } else { 190 };
v.extend_from_slice(&[level, level, level, 255]);
}
}
v
}
fn at(buf: &[u8], w: u32, x: u32, y: u32) -> u8 {
buf[((y * w + x) * 4) as usize]
}
#[test]
fn none_leaves_the_image_exactly_as_it_was() {
let src = edge(16, 8);
let out = apply(src.clone(), 16, 8, OutputSharpening::None, 0.5);
assert_eq!(out, src);
}
#[test]
fn an_unresized_export_is_not_sharpened() {
// Nothing was averaged away, so there is nothing to compensate for
// and sharpening would only add haloes.
let src = edge(16, 8);
assert_eq!(
apply(src.clone(), 16, 8, OutputSharpening::MattePaper, 1.0),
src
);
}
#[test]
fn sharpening_increases_contrast_across_an_edge() {
// The property, stated directly: the dark side of the edge gets
// darker and the light side lighter.
let src = edge(16, 8);
let out = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.4);
let (before_dark, before_light) = (at(&src, 16, 7, 4), at(&src, 16, 8, 4));
let (after_dark, after_light) = (at(&out, 16, 7, 4), at(&out, 16, 8, 4));
assert!(after_dark < before_dark, "the dark side should deepen");
assert!(after_light > before_light, "the light side should lift");
}
#[test]
fn paper_sharpens_harder_than_screen() {
// Ink spreads; the settings are about the medium, not taste.
let src = edge(16, 8);
let screen = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.4);
let matte = apply(src.clone(), 16, 8, OutputSharpening::MattePaper, 0.4);
assert!(at(&matte, 16, 8, 4) > at(&screen, 16, 8, 4));
assert!(strength(OutputSharpening::MattePaper) > strength(OutputSharpening::GlossyPaper));
}
#[test]
fn a_bigger_reduction_sharpens_more() {
let src = edge(16, 8);
let mild = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.9);
let severe = apply(src.clone(), 16, 8, OutputSharpening::Screen, 0.1);
assert!(at(&severe, 16, 8, 4) >= at(&mild, 16, 8, 4));
}
#[test]
fn a_flat_field_is_untouched() {
// No detail means no high frequencies to amplify. If this drifts, the
// mask is not centred and every sky gains a gradient.
let flat = vec![128u8; 16 * 16 * 4];
assert_eq!(
apply(flat.clone(), 16, 16, OutputSharpening::MattePaper, 0.3),
flat
);
}
#[test]
fn alpha_is_never_touched() {
// The loop runs over three channels for a reason: sharpening alpha
// would put a halo in the transparency of an image that has none.
let out = apply(edge(16, 8), 16, 8, OutputSharpening::MattePaper, 0.2);
for px in out.chunks_exact(4) {
assert_eq!(px[3], 255);
}
}
#[test]
fn a_frame_too_small_to_have_neighbours_is_left_alone() {
let tiny = vec![10u8; 2 * 2 * 4];
assert_eq!(
apply(tiny.clone(), 2, 2, OutputSharpening::Screen, 0.5),
tiny
);
}
}
-324
View File
@@ -1,324 +0,0 @@
//! TRACES: FR-EXP-3 | FR-EXP-4
//! Output sizing and resampling.
//!
//! # Why Lanczos
//!
//! FR-EXP-4 asks for "a quality resampler (Lanczos or better)", and the
//! reason is what a cheap one does to a photograph. Box or bilinear
//! downsampling of a 24 MP frame to 2048px averages away detail the sensor
//! resolved and aliases what is left — a brick wall or a distant fence comes
//! back as moiré. Lanczos's negative lobes preserve edge acuity through a
//! large reduction, which is exactly the operation an export performs.
//!
//! Separable: a horizontal pass then a vertical one, which turns an `a²`
//! kernel into `2a` taps per pixel. At the sizes involved that is the
//! difference between an export that feels instant and one that does not.
use dr_types::SizingMode;
use crate::Frame;
/// The Lanczos window. 3 is the photographic default — 2 is softer, and
/// beyond 3 the extra lobes buy ringing rather than detail.
const A: f32 = 3.0;
/// TRACES: FR-EXP-3
/// Resolve the requested sizing against a source, honouring the upscale rule.
///
/// Aspect is preserved in every mode, so only one dimension is ever the
/// requested one.
///
/// **Upscaling is refused by clamping, never by failing.** FR-EXP-3 makes
/// upscaling opt-in, and a batch of mixed frames must not abort because one
/// was smaller than the target — the user asked for a set of exports, and
/// stopping the run over a frame that came out at source size would be a
/// worse answer than the file itself.
pub fn target_size(
src_w: u32,
src_h: u32,
sizing: SizingMode,
allow_upscaling: bool,
) -> (u32, u32) {
let (src_w, src_h) = (src_w.max(1), src_h.max(1));
let (w, h) = match sizing {
SizingMode::Original => (src_w, src_h),
SizingMode::LongEdge(n) => scale_to(src_w, src_h, n, src_w >= src_h),
SizingMode::ShortEdge(n) => scale_to(src_w, src_h, n, src_w < src_h),
SizingMode::Percentage(p) => {
let f = f64::from(p) / 100.0;
(
((f64::from(src_w) * f).round() as u32).max(1),
((f64::from(src_h) * f).round() as u32).max(1),
)
}
};
if !allow_upscaling && (w > src_w || h > src_h) {
return (src_w, src_h);
}
(w.max(1), h.max(1))
}
/// Scale so that the chosen edge lands on `n`.
fn scale_to(src_w: u32, src_h: u32, n: u32, width_is_the_edge: bool) -> (u32, u32) {
let n = n.max(1);
if width_is_the_edge {
let h = (f64::from(n) * f64::from(src_h) / f64::from(src_w)).round() as u32;
(n, h.max(1))
} else {
let w = (f64::from(n) * f64::from(src_w) / f64::from(src_h)).round() as u32;
(w.max(1), n)
}
}
/// Resample to `(dst_w, dst_h)`, returning tightly packed RGBA8.
///
/// Returns the source buffer untouched where no scaling is needed, which is
/// the `SizingMode::Original` case and therefore the common one.
pub fn resample(frame: &Frame, dst_w: u32, dst_h: u32) -> Vec<u8> {
if dst_w == frame.width && dst_h == frame.height {
return frame.rgba.clone();
}
// Horizontal, then vertical. The intermediate is the destination width by
// the *source* height, so the second pass works on as little data as the
// first can leave it.
let horizontal = pass(
&frame.rgba,
frame.width,
frame.height,
dst_w,
frame.height,
true,
);
pass(&horizontal, dst_w, frame.height, dst_w, dst_h, false)
}
/// One separable pass. `horizontal` picks the axis being resampled.
fn pass(src: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32, horizontal: bool) -> Vec<u8> {
let (src_len, dst_len) = if horizontal {
(src_w, dst_w)
} else {
(src_h, dst_h)
};
let ratio = f64::from(src_len) / f64::from(dst_len);
// Enlarging samples the source at its own frequency; shrinking has to
// widen the kernel to average the pixels being discarded, or the result
// aliases. This is the whole difference between a resample and a
// subsample.
let filter_scale = ratio.max(1.0);
let support = A as f64 * filter_scale;
let mut out = vec![0u8; (dst_w * dst_h * 4) as usize];
for i in 0..dst_len {
// Centre of the destination sample, in source coordinates.
let centre = (f64::from(i) + 0.5) * ratio - 0.5;
let first = ((centre - support).ceil() as i64).max(0);
let last = ((centre + support).floor() as i64).min(i64::from(src_len) - 1);
// Weights once per output row/column rather than per pixel: they
// depend only on the axis position, and recomputing them per channel
// was most of the cost when this was written the obvious way.
let mut weights = Vec::with_capacity((last - first + 1).max(0) as usize);
let mut total = 0.0f64;
for s in first..=last {
let w = lanczos((f64::from(s as i32) - centre) / filter_scale);
weights.push(w);
total += w;
}
if total == 0.0 {
total = 1.0;
}
let other = if horizontal { dst_h } else { dst_w };
for j in 0..other {
let mut acc = [0.0f64; 4];
for (k, w) in weights.iter().enumerate() {
let s = first as u32 + k as u32;
let (x, y) = if horizontal { (s, j) } else { (j, s) };
let p = ((y * src_w + x) * 4) as usize;
for c in 0..4 {
acc[c] += f64::from(src[p + c]) * w;
}
}
let (x, y) = if horizontal { (i, j) } else { (j, i) };
let p = ((y * dst_w + x) * 4) as usize;
for c in 0..4 {
// Lanczos overshoots at edges — that is what makes it look
// sharp — so the result must be clamped rather than wrapped.
out[p + c] = (acc[c] / total).round().clamp(0.0, 255.0) as u8;
}
}
}
out
}
/// The Lanczos kernel, `sinc(x) * sinc(x / a)`.
fn lanczos(x: f64) -> f64 {
let x = x.abs();
if x < 1e-9 {
return 1.0;
}
if x >= f64::from(A) {
return 0.0;
}
let px = std::f64::consts::PI * x;
(px.sin() / px) * ((px / f64::from(A)).sin() / (px / f64::from(A)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::frame;
#[test]
fn original_is_the_source_size() {
assert_eq!(
target_size(6000, 4000, SizingMode::Original, false),
(6000, 4000)
);
}
#[test]
fn long_edge_picks_the_longer_dimension_either_way_round() {
assert_eq!(
target_size(6000, 4000, SizingMode::LongEdge(3000), false),
(3000, 2000)
);
// Portrait: the long edge is now the height.
assert_eq!(
target_size(4000, 6000, SizingMode::LongEdge(3000), false),
(2000, 3000)
);
}
#[test]
fn short_edge_picks_the_shorter_dimension_either_way_round() {
assert_eq!(
target_size(6000, 4000, SizingMode::ShortEdge(2000), false),
(3000, 2000)
);
assert_eq!(
target_size(4000, 6000, SizingMode::ShortEdge(2000), false),
(2000, 3000)
);
}
#[test]
fn a_percentage_scales_both_dimensions() {
assert_eq!(
target_size(4000, 3000, SizingMode::Percentage(50), false),
(2000, 1500)
);
assert_eq!(
target_size(4000, 3000, SizingMode::Percentage(100), false),
(4000, 3000)
);
}
#[test]
fn upscaling_is_refused_by_clamping_rather_than_failing() {
// FR-EXP-3: opt-in, and a batch must not abort over one small frame.
assert_eq!(
target_size(800, 600, SizingMode::LongEdge(4000), false),
(800, 600)
);
assert_eq!(
target_size(800, 600, SizingMode::Percentage(400), false),
(800, 600)
);
}
#[test]
fn upscaling_is_honoured_when_asked_for() {
assert_eq!(
target_size(800, 600, SizingMode::LongEdge(1600), true),
(1600, 1200)
);
}
#[test]
fn a_square_frame_treats_either_edge_as_the_long_one() {
// The tie has to resolve somewhere, and both answers are the same
// size — but it must not produce a zero or a panic.
assert_eq!(
target_size(1000, 1000, SizingMode::LongEdge(500), false),
(500, 500)
);
assert_eq!(
target_size(1000, 1000, SizingMode::ShortEdge(500), false),
(500, 500)
);
}
#[test]
fn a_size_can_never_round_down_to_nothing() {
// A 1% export of a small frame rounds toward zero, and a zero-pixel
// image is not a file anyone can open.
let (w, h) = target_size(50, 30, SizingMode::Percentage(1), false);
assert!(w >= 1 && h >= 1, "got {w}x{h}");
}
#[test]
fn resampling_to_the_same_size_changes_nothing() {
// The `Original` path, which is the common one — it must not spend a
// Lanczos pass to return what it was given.
let f = frame(32, 24);
assert_eq!(resample(&f, 32, 24), f.rgba);
}
#[test]
fn a_resample_produces_the_right_number_of_pixels() {
let f = frame(64, 48);
assert_eq!(resample(&f, 32, 24).len(), 32 * 24 * 4);
assert_eq!(resample(&f, 100, 75).len(), 100 * 75 * 4);
}
#[test]
fn a_downscale_preserves_the_gradient_it_was_given() {
// The check that separates a real resample from a buffer of the right
// length: the test frame ramps red left-to-right, so the output must
// too, and its corners must still be near the source's.
let f = frame(128, 128);
let small = resample(&f, 32, 32);
let px = |x: usize, y: usize| small[(y * 32 + x) * 4];
assert!(px(0, 0) < px(16, 0), "red should rise across the frame");
assert!(px(16, 0) < px(31, 0));
// Row-invariant in red, since the ramp is horizontal.
assert!((i32::from(px(16, 0)) - i32::from(px(16, 31))).abs() < 8);
}
#[test]
fn a_flat_field_survives_a_resample_unchanged() {
// Lanczos rings on edges, which is intended — but a constant field
// has no edges, and any deviation here means the weights do not sum
// to one. That error is invisible on a photograph and glaring on a
// sky.
let flat = Frame::new(64, 64, vec![200; 64 * 64 * 4]).unwrap();
for byte in resample(&flat, 21, 21) {
assert_eq!(byte, 200, "a constant field must resample to itself");
}
}
#[test]
fn an_upscale_also_holds_a_flat_field() {
let flat = Frame::new(16, 16, vec![64; 16 * 16 * 4]).unwrap();
for byte in resample(&flat, 40, 40) {
assert_eq!(byte, 64);
}
}
#[test]
fn the_kernel_is_one_at_the_centre_and_zero_past_its_window() {
assert!((lanczos(0.0) - 1.0).abs() < 1e-9);
assert_eq!(lanczos(3.0), 0.0);
assert_eq!(lanczos(4.5), 0.0);
// Zero at the integers inside the window, which is what makes an
// unscaled resample an identity.
assert!(lanczos(1.0).abs() < 1e-9);
assert!(lanczos(2.0).abs() < 1e-9);
}
}
-47
View File
@@ -1,47 +0,0 @@
[package]
name = "dr-face"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
thiserror.workspace = true
log.workspace = true
# Inference. `ort` is the API; **tract is the engine** — see the workspace
# manifest, and docs/faces.md §3, for why the C++ ONNX Runtime is not linked.
ort = { workspace = true, optional = true }
ort-tract = { workspace = true, optional = true }
ndarray = { workspace = true, optional = true }
[dev-dependencies]
zune-jpeg.workspace = true
env_logger.workspace = true
# The M1 probe drives `ort` directly so it can print the raw load error.
ort = { workspace = true }
ort-tract = { workspace = true }
[[example]]
name = "probe"
required-features = ["inference"]
[[example]]
name = "faces"
required-features = ["inference"]
[features]
# Nothing on by default, and in particular **no `embedded-model`**: the weights
# are not a build input and never become one (docs/faces.md §2.2). A feature
# flag that *could* embed them is a flag someone eventually sets in a packaging
# script, and the InsightFace grant does not survive that.
default = []
# The ONNX runtime, and the two stages that need it.
#
# Separable because the accuracy of this subsystem lives in `calibrate` and
# `cluster`, which are arithmetic over embeddings with no model in them. They
# must be testable against synthetic embeddings on a machine with no weights on
# it — a test suite that needs a research-licensed download is a test suite
# that does not run in CI.
inference = ["dep:ort", "dep:ort-tract", "dep:ndarray"]
-127
View File
@@ -1,127 +0,0 @@
//! Detect, align and embed the faces in a JPEG.
//!
//! The thing worth looking at is whether the landmarks land on a real
//! photograph — the same reason `dr-segment` has `examples/detect.rs`.
//!
//! cargo run -p dr-face --features inference --example faces -- \
//! DET.onnx EMB.onnx photo.jpg [photo.jpg ...]
//!
//! The models must have had their input dims frozen first; see
//! `tools/fix-face-model-shapes.sh` and docs/faces.md §12 M1.
use std::time::Instant;
use dr_face::{align, DetectOptions, Detector, Embedder, ModelId};
fn main() {
env_logger::init();
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() < 3 {
eprintln!("usage: faces DET.onnx EMB.onnx IMAGE.jpg [IMAGE.jpg ...]");
std::process::exit(2);
}
let t = Instant::now();
let mut detector = Detector::from_path(&args[0]).expect("load detector");
let mut embedder =
Embedder::from_path(&args[1], ModelId::new("w600k_mbf")).expect("load embedder");
println!(
"loaded both models in {:?} (strides {:?})",
t.elapsed(),
detector.strides()
);
let opts = DetectOptions::default();
let mut all = Vec::new();
for path in &args[2..] {
let (rgb, w, h) = match load_jpeg(path) {
Ok(v) => v,
Err(e) => {
println!("{path}: {e}");
continue;
}
};
let t = Instant::now();
let dets = detector.detect(&rgb, w, h, &opts).expect("detect");
let detect_ms = t.elapsed().as_secs_f64() * 1e3;
println!(
"\n{path} ({w}×{h}) {} face(s) in {detect_ms:.0} ms",
dets.len()
);
for (i, d) in dets.iter().enumerate() {
let Some(aligned) = align::warp(&rgb, w, h, &d.landmarks) else {
println!(" [{i}] degenerate landmarks, skipped");
continue;
};
let t = Instant::now();
let emb = embedder.embed(&aligned).expect("embed");
let embed_ms = t.elapsed().as_secs_f64() * 1e3;
println!(
" [{i}] conf {:.3} box {:.0},{:.0} {:.0}×{:.0} crop_px {:.0} embed {embed_ms:.0} ms",
d.confidence,
d.bbox.0,
d.bbox.1,
d.width(),
d.height(),
aligned.source_px(),
);
all.push((path.clone(), i, emb));
}
}
// Every pair, so the numbers can be eyeballed against the expectation that
// faces from one identity's folder score high and everything else low.
if all.len() > 1 {
println!("\ncosine similarity");
for i in 0..all.len() {
for j in i + 1..all.len() {
let cos = all[i].2.cosine(&all[j].2).expect("same model");
println!(
" {:.4} {}#{} vs {}#{}",
cos,
short(&all[i].0),
all[i].1,
short(&all[j].0),
all[j].1
);
}
}
}
}
fn short(path: &str) -> String {
let p = std::path::Path::new(path);
let file = p.file_name().unwrap_or_default().to_string_lossy();
match p.parent().and_then(|d| d.file_name()) {
Some(dir) => format!("{}/{file}", dir.to_string_lossy()),
None => file.into_owned(),
}
}
/// Decode to the tightly packed `f32` RGB `0.0..=1.0` the crate expects.
fn load_jpeg(path: &str) -> Result<(Vec<f32>, usize, usize), String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let mut dec = zune_jpeg::JpegDecoder::new(&bytes);
let px = dec.decode().map_err(|e| e.to_string())?;
let info = dec.info().ok_or("no jpeg header")?;
let (w, h) = (info.width as usize, info.height as usize);
let rgb: Vec<f32> = match px.len() / (w * h) {
3 => px.iter().map(|&v| v as f32 / 255.0).collect(),
1 => px
.iter()
.flat_map(|&v| {
let g = v as f32 / 255.0;
[g, g, g]
})
.collect(),
n => return Err(format!("{n} components per pixel, expected 1 or 3")),
};
Ok((rgb, w, h))
}
-58
View File
@@ -1,58 +0,0 @@
//! M1 (docs/faces.md §12) — will tract load these graphs at all?
//!
//! The one measurement everything else in the face subsystem is conditional
//! on. `det_500m.onnx` has a dynamic H/W input, which is exactly what tract
//! failed on for YOLO26n-seg, so a plain "no" here is the expected outcome and
//! the interesting part is the error it gives.
//!
//! cargo run -p dr-face --features inference --example probe -- MODEL...
fn main() {
env_logger::init();
let paths: Vec<String> = std::env::args().skip(1).collect();
if paths.is_empty() {
eprintln!("usage: probe MODEL.onnx [MODEL.onnx ...]");
std::process::exit(2);
}
let mut failures = 0;
for path in &paths {
println!("\n=== {path} ===");
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
println!(" UNREADABLE: {e}");
failures += 1;
continue;
}
};
println!(" {} bytes", bytes.len());
dr_face::install_backend_for_probe();
let session =
ort::session::Session::builder().and_then(|mut b| b.commit_from_memory(&bytes));
match session {
Err(e) => {
println!(" LOAD FAILED: {e}");
failures += 1;
}
Ok(s) => {
println!(" LOADED");
for i in s.inputs() {
println!(" in {:<24} {:?}", i.name(), i.dtype().tensor_shape());
}
for o in s.outputs() {
println!(" out {:<24} {:?}", o.name(), o.dtype().tensor_shape());
}
}
}
}
println!("\n{} of {} failed", failures, paths.len());
if failures > 0 {
std::process::exit(1);
}
}
-574
View File
@@ -1,574 +0,0 @@
//! Five-point face alignment (docs/faces.md §5).
//!
//! ArcFace embeddings are trained on faces warped to a canonical 112×112
//! arrangement. Feeding the model a plain bounding-box crop *works* — it
//! produces 512 numbers, they are unit-norm, and cosine similarities between
//! them look entirely reasonable. They are just much worse, and nothing in the
//! system reports it.
//!
//! That is the whole reason this module exists, and the reason [`Aligned112`]
//! is a newtype only [`warp`] can construct: the mistake is not one a reviewer
//! catches, so the type system catches it instead.
//!
//! Model-free, so it builds and tests without the `inference` feature.
/// Canonical landmark positions for a 112×112 ArcFace crop.
///
/// # The naming is a trap; the order is not
///
/// Point 0 sits at x=38 on a 112-wide canvas — left of centre *in the image*,
/// which is the subject's **right** eye. Both namings are in circulation and
/// they are opposite, so the array is written in the detector's order and the
/// comment says whose left is whose:
///
/// ```text
/// 0 subject's right eye (image-left)
/// 1 subject's left eye (image-right)
/// 2 nose tip
/// 3 subject's right mouth corner
/// 4 subject's left mouth corner
/// ```
///
/// SCRFD emits its five points in this same order, so the correct amount of
/// reordering between detector and template is **none**. A detector with a
/// different order carries its own permutation beside its model id rather than
/// this constant growing an assumption.
pub const ARCFACE_TEMPLATE: [(f32, f32); 5] = [
(38.2946, 51.6963),
(73.5318, 51.5014),
(56.0252, 71.7366),
(41.5493, 92.3655),
(70.7299, 92.2041),
];
/// Edge of the aligned crop, in pixels. Fixed by the embedder's input.
pub const ALIGNED_EDGE: usize = 112;
/// A face warped to [`ARCFACE_TEMPLATE`], ready for the embedder.
///
/// Constructible only by [`warp`]. That is the point: an `Embedder` that took
/// a plain `&[f32]` would accept an unaligned bounding-box crop and silently
/// return worse embeddings, which is a failure no test of the embedder itself
/// would catch.
pub struct Aligned112 {
/// `112 × 112 × 3`, row-major RGB in `0.0..=1.0`.
pixels: Vec<f32>,
/// Source pixels across the crop before warping — `crop_px` in the catalog.
///
/// Carried here rather than recomputed later because the scale factor is
/// known exactly at warp time and only approximately from the box
/// afterwards. §7: it is the honest quality signal, and a feature in the
/// calibration.
source_px: f32,
}
impl Aligned112 {
pub fn pixels(&self) -> &[f32] {
&self.pixels
}
/// Source pixels spanned by the 112-pixel crop.
///
/// Below ~112 the face was upsampled to reach the embedder and the
/// embedding is correspondingly weaker; above it, downsampled and healthy.
pub fn source_px(&self) -> f32 {
self.source_px
}
/// How sharp the face the embedder is about to see actually is.
///
/// # Why size is not enough
///
/// A face can be large and useless. A subject walking through a half-second
/// exposure, a frame focused on the person behind them, a hand-held shot at
/// 1/15 — all yield a big box, a confident detection and five landmarks in
/// plausible places. The embedding that comes back is not *wrong* in any
/// way the system can see: it is unit-norm and its cosines look ordinary.
/// It is simply an embedding of a blur, and blurs resemble each other more
/// than they resemble the people they were, so they cluster together and
/// bridge identities that have nothing to do with one another.
///
/// That is the failure this exists to prevent, and it is the same class of
/// fault as the unaligned-crop one the [`Aligned112`] newtype guards
/// against: plausible output, no error, worse results, nothing reported.
///
/// # The measure
///
/// Variance of the Laplacian — the standard blur metric — **divided by the
/// variance of the luma it was taken over**. The division is what makes it
/// usable here. Raw Laplacian variance scales with contrast, so a sharp
/// face in flat, hazy or backlit light scores like a blurred one in hard
/// light, and a threshold on it would quietly throw away every face shot
/// against a bright sky. The ratio asks the question that actually matters
/// — *how much of this crop's variation is edges rather than broad
/// gradients* — and is invariant to exposure and contrast.
///
/// Computed on luma over the interior, so the 3x3 kernel never needs a
/// border rule. Returns 0.0 for a crop with no variation at all, which is
/// a flat patch and correctly unusable rather than infinitely sharp.
///
/// # This is not independent of size
///
/// A face smaller than 112 pixels was *upsampled* to reach the embedder,
/// and upsampling invents no edges — so a small face scores low here even
/// when the original was perfectly sharp. That is not a flaw to correct: it
/// is the honest statement that the embedder is looking at a soft image.
/// The size floor and this one overlap deliberately, and
/// `face_index --quality` prints the joint distribution so the two are
/// chosen together rather than each in ignorance of the other.
pub fn sharpness(&self) -> f32 {
let e = ALIGNED_EDGE;
let luma: Vec<f32> = self
.pixels
.chunks_exact(3)
.map(|p| 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2])
.collect();
let (mut lap_sum, mut lap_sq) = (0.0_f64, 0.0_f64);
let (mut lum_sum, mut lum_sq) = (0.0_f64, 0.0_f64);
let mut n = 0.0_f64;
for y in 1..e - 1 {
for x in 1..e - 1 {
let i = y * e + x;
// Four-neighbour Laplacian. The 8-neighbour form is more
// sensitive to diagonal detail and also to noise, which on a
// high-ISO frame is exactly the thing that must not read as
// sharpness.
let lap = 4.0 * luma[i] - luma[i - 1] - luma[i + 1] - luma[i - e] - luma[i + e];
let lap = lap as f64;
lap_sum += lap;
lap_sq += lap * lap;
let l = luma[i] as f64;
lum_sum += l;
lum_sq += l * l;
n += 1.0;
}
}
if n == 0.0 {
return 0.0;
}
let lap_var = (lap_sq / n - (lap_sum / n).powi(2)).max(0.0);
let lum_var = (lum_sq / n - (lum_sum / n).powi(2)).max(0.0);
// A crop with no luma variation has no edges to find either, so the
// ratio is 0/0. Zero is the right answer: nothing there is a face.
if lum_var <= 1e-9 {
return 0.0;
}
(lap_var / lum_var) as f32
}
}
/// A similarity transform: rotation, uniform scale, translation.
///
/// Stored as the four independent parameters rather than a 2×3 matrix so that
/// [`Similarity::scale`] is readable without a decomposition.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Similarity {
a: f32,
b: f32,
tx: f32,
ty: f32,
}
impl Similarity {
/// `x' = a·x − b·y + tx`, `y' = b·x + a·y + ty`.
pub fn apply(&self, x: f32, y: f32) -> (f32, f32) {
(
self.a * x - self.b * y + self.tx,
self.b * x + self.a * y + self.ty,
)
}
/// Uniform scale factor — destination pixels per source pixel.
pub fn scale(&self) -> f32 {
(self.a * self.a + self.b * self.b).sqrt()
}
fn invert(&self, u: f32, v: f32) -> (f32, f32) {
let det = self.a * self.a + self.b * self.b;
let du = u - self.tx;
let dv = v - self.ty;
(
(self.a * du + self.b * dv) / det,
(-self.b * du + self.a * dv) / det,
)
}
}
/// Least-squares similarity transform from `src` onto `dst`.
///
/// # Why least squares and not RANSAC
///
/// The reference C++ implementation (docs/faces.md §1.1) fits this with
/// OpenCV's `estimateAffinePartial2D` under RANSAC. RANSAC over five points is
/// a strange fit: the minimal sample for a similarity is two, so it can discard
/// landmarks it judges outliers and solve from a subset — and on a profile face
/// the "outlier" is as likely to be the correct geometry as the wrong one.
/// InsightFace's own pipeline uses plain least squares over all five points,
/// which cannot silently drop anything, and that is what this is.
///
/// # The closed form
///
/// A 2-D similarity is linear in its four parameters:
///
/// ```text
/// x' = a·x − b·y + tx
/// y' = b·x + a·y + ty
/// ```
///
/// so this is an ordinary linear least-squares problem, not an SVD one.
/// Centring both point sets kills `tx`/`ty` from the normal equations and
/// leaves `a` and `b` as two dot products over a common denominator — which is
/// why there is no matrix decomposition anywhere in this function.
///
/// Returns `None` when the source points are degenerate (coincident or
/// collinear to within f32), which does happen: a detector firing on a
/// motion-blurred profile can put all five landmarks on a line.
pub fn fit_similarity(src: &[(f32, f32); 5], dst: &[(f32, f32); 5]) -> Option<Similarity> {
let n = 5.0_f32;
let (mut sx, mut sy, mut dx, mut dy) = (0.0, 0.0, 0.0, 0.0);
for i in 0..5 {
sx += src[i].0;
sy += src[i].1;
dx += dst[i].0;
dy += dst[i].1;
}
let (sx, sy, dx, dy) = (sx / n, sy / n, dx / n, dy / n);
let mut var = 0.0_f32;
let mut num_a = 0.0_f32;
let mut num_b = 0.0_f32;
for i in 0..5 {
let (px, py) = (src[i].0 - sx, src[i].1 - sy);
let (qx, qy) = (dst[i].0 - dx, dst[i].1 - dy);
var += px * px + py * py;
num_a += px * qx + py * qy;
num_b += px * qy - py * qx;
}
// Degenerate: every landmark on one point. Collinear input still solves,
// but with a scale that can be absurd, so the caller's sanity check on
// `scale()` is what catches that case.
if var <= f32::EPSILON {
return None;
}
let a = num_a / var;
let b = num_b / var;
if !a.is_finite() || !b.is_finite() || (a * a + b * b) <= f32::EPSILON {
return None;
}
Some(Similarity {
a,
b,
tx: dx - (a * sx - b * sy),
ty: dy - (b * sx + a * sy),
})
}
/// Warp a face onto the canonical 112×112 arrangement.
///
/// `rgb` is tightly packed `f32` RGB in `0.0..=1.0`, row-major — the same
/// convention `dr-segment` uses, so both read the same proxy.
///
/// Sampling is bilinear **from the source in one step**: never crop-then-warp,
/// which resamples twice and throws away detail the warp could have used.
/// Pixels falling outside the source read as black.
pub fn warp(
rgb: &[f32],
width: usize,
height: usize,
landmarks: &[(f32, f32); 5],
) -> Option<Aligned112> {
if rgb.len() != width * height * 3 {
return None;
}
let m = fit_similarity(landmarks, &ARCFACE_TEMPLATE)?;
let e = ALIGNED_EDGE;
let mut pixels = vec![0.0_f32; e * e * 3];
for v in 0..e {
for u in 0..e {
// Pixel centres, so the transform is not off by half a pixel —
// which is small enough to survive review and large enough to
// matter on a 40-pixel face.
let (x, y) = m.invert(u as f32 + 0.5, v as f32 + 0.5);
let (x, y) = (x - 0.5, y - 0.5);
let out = (v * e + u) * 3;
sample_bilinear(rgb, width, height, x, y, &mut pixels[out..out + 3]);
}
}
Some(Aligned112 {
pixels,
// The warp maps `scale` source pixels to one destination pixel, so the
// crop spans 112/scale of the source.
source_px: ALIGNED_EDGE as f32 / m.scale(),
})
}
fn sample_bilinear(rgb: &[f32], w: usize, h: usize, x: f32, y: f32, out: &mut [f32]) {
let x0 = x.floor();
let y0 = y.floor();
let fx = x - x0;
let fy = y - y0;
let x0 = x0 as isize;
let y0 = y0 as isize;
for (c, o) in out.iter_mut().enumerate() {
let get = |xi: isize, yi: isize| -> f32 {
if xi < 0 || yi < 0 || xi >= w as isize || yi >= h as isize {
0.0
} else {
rgb[(yi as usize * w + xi as usize) * 3 + c]
}
};
let top = get(x0, y0) * (1.0 - fx) + get(x0 + 1, y0) * fx;
let bot = get(x0, y0 + 1) * (1.0 - fx) + get(x0 + 1, y0 + 1) * fx;
*o = top * (1.0 - fy) + bot * fy;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn shifted_scaled(scale: f32, dx: f32, dy: f32, rot: f32) -> [(f32, f32); 5] {
let (s, c) = (rot.sin(), rot.cos());
let mut out = [(0.0, 0.0); 5];
for (i, &(x, y)) in ARCFACE_TEMPLATE.iter().enumerate() {
out[i] = (scale * (c * x - s * y) + dx, scale * (s * x + c * y) + dy);
}
out
}
#[test]
fn template_onto_itself_is_the_identity() {
let m = fit_similarity(&ARCFACE_TEMPLATE, &ARCFACE_TEMPLATE).unwrap();
for &(x, y) in &ARCFACE_TEMPLATE {
let (u, v) = m.apply(x, y);
assert!((u - x).abs() < 1e-3, "{u} vs {x}");
assert!((v - y).abs() < 1e-3, "{v} vs {y}");
}
assert!((m.scale() - 1.0).abs() < 1e-4);
}
/// The property that matters: whatever similarity the face was seen under,
/// the fit must undo it and land the landmarks back on the template. This
/// is the test that fails if the transform is ever "simplified" into an
/// affine or a bare scale-and-translate.
#[test]
fn any_similarity_of_the_template_maps_back_onto_it() {
for &(scale, dx, dy, rot) in &[
(1.0_f32, 0.0_f32, 0.0_f32, 0.0_f32),
(2.5, 100.0, -40.0, 0.0),
(0.4, -12.0, 300.0, 0.6),
(1.7, 5.0, 5.0, -1.2),
] {
let observed = shifted_scaled(scale, dx, dy, rot);
let m = fit_similarity(&observed, &ARCFACE_TEMPLATE).unwrap();
for (i, &(tx, ty)) in ARCFACE_TEMPLATE.iter().enumerate() {
let (u, v) = m.apply(observed[i].0, observed[i].1);
assert!(
(u - tx).abs() < 1e-2 && (v - ty).abs() < 1e-2,
"scale={scale} rot={rot}: point {i} landed at ({u}, {v}), want ({tx}, {ty})"
);
}
assert!(
(m.scale() - 1.0 / scale).abs() < 1e-3,
"scale {} should invert {scale}",
m.scale()
);
}
}
#[test]
fn coincident_landmarks_are_rejected_rather_than_producing_a_crop() {
let degenerate = [(50.0, 50.0); 5];
assert!(fit_similarity(&degenerate, &ARCFACE_TEMPLATE).is_none());
let rgb = vec![0.5_f32; 64 * 64 * 3];
assert!(warp(&rgb, 64, 64, &degenerate).is_none());
}
#[test]
fn source_px_reports_the_face_size_the_embedder_actually_saw() {
let rgb = vec![0.5_f32; 400 * 400 * 3];
// A face twice the template's size spans 224 source pixels.
let big = shifted_scaled(2.0, 80.0, 80.0, 0.0);
let a = warp(&rgb, 400, 400, &big).unwrap();
assert!((a.source_px() - 224.0).abs() < 0.5, "{}", a.source_px());
// Half-size: 56 source pixels upsampled to 112, which §7 calls the
// degraded bucket.
let small = shifted_scaled(0.5, 10.0, 10.0, 0.0);
let a = warp(&rgb, 400, 400, &small).unwrap();
assert!((a.source_px() - 56.0).abs() < 0.5, "{}", a.source_px());
}
/// A white square on black, warped by a transform that should centre it:
/// checks the sampler's geometry rather than the fit's algebra.
#[test]
fn warp_resamples_the_right_pixels() {
let (w, h) = (224, 224);
let mut rgb = vec![0.0_f32; w * h * 3];
for y in 0..h {
for x in 0..w {
if (56..168).contains(&x) && (56..168).contains(&y) {
for c in 0..3 {
rgb[(y * w + x) * 3 + c] = 1.0;
}
}
}
}
// Landmarks placed so the fit is a pure translation of (56, 56):
// the white square maps exactly onto the 112×112 output.
let lm = shifted_scaled(1.0, 56.0, 56.0, 0.0);
let a = warp(&rgb, w, h, &lm).unwrap();
let px = a.pixels();
for (i, v) in px.iter().enumerate() {
assert!((v - 1.0).abs() < 1e-3, "pixel {i} is {v}, expected white");
}
}
#[test]
fn out_of_bounds_samples_read_black_rather_than_wrapping() {
let rgb = vec![1.0_f32; 32 * 32 * 3];
// Face far outside the image: every sample is out of bounds.
let lm = shifted_scaled(1.0, 5000.0, 5000.0, 0.0);
let a = warp(&rgb, 32, 32, &lm).unwrap();
assert!(a.pixels().iter().all(|&v| v == 0.0));
}
// ── sharpness ─────────────────────────────────────────────────────────
/// An image of `edge` square, filled by `f(x, y) -> luma`.
fn image(edge: usize, f: impl Fn(usize, usize) -> f32) -> Vec<f32> {
let mut v = Vec::with_capacity(edge * edge * 3);
for y in 0..edge {
for x in 0..edge {
let l = f(x, y);
v.extend_from_slice(&[l, l, l]);
}
}
v
}
/// One box-blur pass, which is enough to move the metric a long way.
fn blur(rgb: &[f32], edge: usize) -> Vec<f32> {
let mut out = rgb.to_vec();
for y in 1..edge - 1 {
for x in 1..edge - 1 {
for c in 0..3 {
let mut sum = 0.0;
for dy in -1isize..=1 {
for dx in -1isize..=1 {
let i = (((y as isize + dy) as usize) * edge
+ ((x as isize + dx) as usize))
* 3
+ c;
sum += rgb[i];
}
}
out[(y * edge + x) * 3 + c] = sum / 9.0;
}
}
}
out
}
/// Landmarks placing the template into a larger image at scale 1, so the
/// warp resamples one-to-one and the metric sees the source detail.
fn centred(edge: usize) -> [(f32, f32); 5] {
let off = (edge as f32 - ALIGNED_EDGE as f32) / 2.0;
shifted_scaled(1.0, off, off, 0.0)
}
#[test]
fn a_blurred_face_scores_lower_than_a_sharp_one() {
let edge = 200;
let sharp = image(
edge,
|x, y| if (x / 3 + y / 3) % 2 == 0 { 0.9 } else { 0.1 },
);
let soft = blur(&blur(&sharp, edge), edge);
let a = warp(&sharp, edge, edge, &centred(edge))
.unwrap()
.sharpness();
let b = warp(&soft, edge, edge, &centred(edge)).unwrap().sharpness();
assert!(a > b * 2.0, "sharp {a} should clearly beat blurred {b}");
}
/// The reason for dividing by luma variance. A sharp face photographed
/// against a bright sky is low-contrast, and a raw Laplacian variance would
/// reject it as blurred — which would quietly throw away every backlit
/// portrait in the library.
#[test]
fn sharpness_survives_the_contrast_being_halved() {
let edge = 200;
let full = image(
edge,
|x, y| if (x / 3 + y / 3) % 2 == 0 { 0.9 } else { 0.1 },
);
// Same detail, half the contrast, lifted so it does not clip.
let flat = image(
edge,
|x, y| {
if (x / 3 + y / 3) % 2 == 0 {
0.55
} else {
0.45
}
},
);
let a = warp(&full, edge, edge, &centred(edge)).unwrap().sharpness();
let b = warp(&flat, edge, edge, &centred(edge)).unwrap().sharpness();
let ratio = a / b;
assert!(
(0.5..2.0).contains(&ratio),
"contrast changed the score {ratio}x ({a} vs {b})"
);
}
#[test]
fn a_flat_crop_has_no_sharpness() {
let edge = 200;
let flat = image(edge, |_, _| 0.5);
assert_eq!(
warp(&flat, edge, edge, &centred(edge)).unwrap().sharpness(),
0.0
);
}
/// Upsampling invents no detail, so a face that had to be stretched to
/// reach the embedder scores lower than the same face at full size. That
/// overlap with the size floor is deliberate and documented; this pins it
/// so a future change cannot quietly remove it.
#[test]
fn an_upsampled_face_scores_lower_than_the_same_face_at_full_size() {
let edge = 200;
let src = image(
edge,
|x, y| if (x / 3 + y / 3) % 2 == 0 { 0.9 } else { 0.1 },
);
let full = warp(&src, edge, edge, &centred(edge)).unwrap();
// Half scale: the crop spans 56 source pixels and is stretched to 112.
let off = (edge as f32 - ALIGNED_EDGE as f32 / 2.0) / 2.0;
let small = warp(&src, edge, edge, &shifted_scaled(0.5, off, off, 0.0)).unwrap();
assert!(small.source_px() < full.source_px());
assert!(
small.sharpness() < full.sharpness(),
"upsampled {} should be softer than full {}",
small.sharpness(),
full.sharpness()
);
}
}
-436
View File
@@ -1,436 +0,0 @@
//! Cosine to probability (docs/faces.md §8, FR-CULL-9).
//!
//! FR-CULL-9 is a hard requirement rather than an implementation detail: no
//! code path may threshold a bare cosine, every threshold in the subsystem is
//! stated as a probability, and the fit is per library and reports its own
//! validity. The failure it guards against is invisible — a raw cosine means
//! something different for every model, every population and every face size,
//! and an uncalibrated similarity still *looks* like a plausible number all the
//! way to the user interface.
//!
//! Model-free, so the part of this subsystem most likely to be subtly wrong is
//! testable on synthetic embeddings with no weights on the machine.
//!
//! # Where the training pairs come from
//!
//! **Negatives are free and abundant.** Two faces detected in *the same
//! photograph* are almost never the same person, which hands every multi-face
//! image in the library a full set of negative pairs at no labelling cost — and
//! they are *hard* negatives, from the same camera, lighting and processing,
//! which is exactly the population where a threshold tuned on easy negatives
//! fails. The exceptions (mirrors, photographs of photographs, collages) are
//! rare enough to be noise at this scale.
//!
//! **Positives have to be earned.** In order of trustworthiness: pairs the user
//! has confirmed onto one person; then burst siblings, since FR-CULL-5 already
//! groups bursts and two faces in adjacent frames are near-certainly the same
//! person. Nothing else — bootstrapping positives from high cosine is circular,
//! fitting the calibration to the belief it was supposed to test.
//!
//! Which is why a fresh library has **no valid calibration**, and says so.
/// Bins over cosine ∈ [-1, 1].
///
/// 200 is the reference implementation's figure and the resolution is not
/// critical; what matters is that there *is* a histogram. See [`Pairs`].
const BINS: usize = 200;
/// Minimum evidence before a fit is trusted.
///
/// Far stricter than the reference implementation's floor of two positives and
/// one negative. That floor is reasonable there: its pairs come from a curated
/// gallery of labelled reference portraits, where a positive pair is
/// trustworthy by construction. Here the positives are bootstrapped from bursts
/// and a handful of early confirmations, and the whole risk is fitting
/// confidently to too few of them.
pub const MIN_POSITIVE_PAIRS: u64 = 200;
pub const MIN_NEGATIVE_PAIRS: u64 = 2_000;
/// A fitted `P(same person | cosine, face size)`.
///
/// The single definition of what a similarity means in this subsystem. The
/// catalog stores its parameters; nothing re-implements the sigmoid.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Calibration {
pub a: f32,
pub b: f32,
/// Weight on `log2(min crop_px)` — the face-size term FR-CULL-9 asks for.
pub w_size: f32,
/// Whether there was enough evidence to trust the fit.
///
/// When false the UI says confidence is unavailable. It does **not** present
/// an untuned default as though it were measured, which is the distinction
/// FR-CULL-9 spends a paragraph on.
pub valid: bool,
pub positive_pairs: u64,
pub negative_pairs: u64,
}
impl Default for Calibration {
/// The reference implementation's fitted MBF curve (docs/faces.md §1):
/// steepness 16.2, P=0.5 at cosine 0.267.
///
/// **`valid` is false**, and that is the point. This exists so an
/// un-calibrated library has a documented operating point to cluster at
/// rather than no behaviour at all — but nothing may show its output as a
/// measured confidence.
fn default() -> Self {
Self {
a: 16.2,
b: -16.2 * 0.267,
w_size: 0.0,
valid: false,
positive_pairs: 0,
negative_pairs: 0,
}
}
}
impl Calibration {
/// P(same person), shifted by a base-rate prior.
///
/// `log_prior_odds` is applied at evaluation rather than folded into the
/// fit, so one stored calibration serves every context: the odds that two
/// faces in a 40-image album match are not the odds in a 40,000-image
/// archive. Folding a prior in would need a refit per context and would
/// make the stored parameters mean different things depending on where they
/// came from.
pub fn probability(&self, cosine: f32, min_crop_px: f32, log_prior_odds: f32) -> f32 {
sigmoid(self.logit(cosine, min_crop_px) + log_prior_odds)
}
fn logit(&self, cosine: f32, min_crop_px: f32) -> f32 {
self.a * cosine + self.b + self.w_size * min_crop_px.max(1.0).log2()
}
/// The cosine at which [`Calibration::probability`] crosses `p`.
///
/// What turns "merge above 0.9" into one comparison against a stored
/// similarity, rather than a sigmoid evaluated per candidate edge.
pub fn boundary_at(&self, p: f32, min_crop_px: f32, log_prior_odds: f32) -> f32 {
((p / (1.0 - p)).ln() - self.b - self.w_size * min_crop_px.max(1.0).log2() - log_prior_odds)
/ self.a
}
}
fn sigmoid(z: f32) -> f32 {
// Branch on the sign so neither tail overflows: exp(-z) for large positive
// z, exp(z) for large negative.
if z >= 0.0 {
1.0 / (1.0 + (-z).exp())
} else {
let e = z.exp();
e / (1.0 + e)
}
}
/// Accumulated pair evidence, as a histogram rather than a list.
///
/// # Why a histogram
///
/// A 25,000-face library has ~3×10⁸ pairs and no gradient descent is running
/// over that. Bucketing them costs 200 counters per class and reduces the fit
/// to two parameters against per-bin totals; the expensive part becomes the
/// similarity matrix, which is one blocked GEMM. This is the trick that makes a
/// per-library fit affordable at all, and it is not obvious from outside.
#[derive(Debug, Clone)]
pub struct Pairs {
positive: Vec<f64>,
negative: Vec<f64>,
n_pos: u64,
n_neg: u64,
}
impl Default for Pairs {
fn default() -> Self {
Self::new()
}
}
impl Pairs {
pub fn new() -> Self {
Self {
positive: vec![0.0; BINS],
negative: vec![0.0; BINS],
n_pos: 0,
n_neg: 0,
}
}
/// Record a pair known to be the same person.
pub fn push_positive(&mut self, cosine: f32) {
self.positive[bin(cosine)] += 1.0;
self.n_pos += 1;
}
/// Record a pair known to be different people.
pub fn push_negative(&mut self, cosine: f32) {
self.negative[bin(cosine)] += 1.0;
self.n_neg += 1;
}
pub fn positives(&self) -> u64 {
self.n_pos
}
pub fn negatives(&self) -> u64 {
self.n_neg
}
/// Fit `P(same) = σ(a·cos + b)` by weighted logistic regression.
///
/// Class weights are explicit because negatives outnumber positives by
/// orders of magnitude, and an unweighted fit produces a well-shaped curve
/// sitting at the wrong height — precisely the "plausible number all the
/// way to the user interface" failure FR-CULL-9 describes.
///
/// Returns a calibration with `valid` set only if there was enough
/// evidence; the parameters are filled in either way so a caller with no
/// better option can still cluster at a documented operating point.
pub fn fit(&self) -> Calibration {
let base = Calibration {
positive_pairs: self.n_pos,
negative_pairs: self.n_neg,
..Calibration::default()
};
if self.n_pos < MIN_POSITIVE_PAIRS || self.n_neg < MIN_NEGATIVE_PAIRS {
return base;
}
let total = self.n_pos as f64 + self.n_neg as f64;
let w_pos = total / (2.0 * self.n_pos as f64);
let w_neg = total / (2.0 * self.n_neg as f64);
// Start from the reference's fitted MBF curve rather than from zero:
// it is the right order of magnitude for every model in this family,
// so descent converges in far fewer steps and cannot wander into a
// sign-flipped solution on thin evidence.
let mut a = base.a as f64;
let mut b = base.b as f64;
const LR: f64 = 0.05;
const MAX_ITER: usize = 20_000;
const TOL: f64 = 1e-7;
for _ in 0..MAX_ITER {
let (mut da, mut db) = (0.0, 0.0);
for i in 0..BINS {
let x = bin_centre(i) as f64;
let s = 1.0 / (1.0 + (-(a * x + b)).exp());
if self.positive[i] > 0.0 {
let e = (s - 1.0) * w_pos * self.positive[i];
da += e * x;
db += e;
}
if self.negative[i] > 0.0 {
let e = s * w_neg * self.negative[i];
da += e * x;
db += e;
}
}
da /= total;
db /= total;
a -= LR * da;
b -= LR * db;
if da * da + db * db < TOL * TOL {
break;
}
}
Calibration {
a: a as f32,
b: b as f32,
w_size: 0.0,
valid: true,
positive_pairs: self.n_pos,
negative_pairs: self.n_neg,
}
}
/// How well the fit predicts the evidence, as a reliability diagram.
///
/// FR-CULL-9's acceptance criterion is exactly this and not a single
/// accuracy figure: for each populated probability band, the observed match
/// rate against the predicted one. Returned rather than asserted so the
/// caller can show it, log it, or fail a test on it.
pub fn reliability(&self, cal: &Calibration, bands: usize) -> Vec<ReliabilityBand> {
let mut out = vec![
ReliabilityBand {
predicted: 0.0,
observed: 0.0,
count: 0
};
bands
];
let mut sum_pred = vec![0.0_f64; bands];
for i in 0..BINS {
let n_pos = self.positive[i];
let n_neg = self.negative[i];
if n_pos + n_neg == 0.0 {
continue;
}
let p = cal.probability(bin_centre(i), 112.0, 0.0) as f64;
let band = ((p * bands as f64) as usize).min(bands - 1);
sum_pred[band] += p * (n_pos + n_neg);
out[band].observed += n_pos as f32;
out[band].count += (n_pos + n_neg) as u64;
}
for (band, o) in out.iter_mut().enumerate() {
if o.count > 0 {
o.predicted = (sum_pred[band] / o.count as f64) as f32;
o.observed /= o.count as f32;
}
}
out
}
}
/// One row of a reliability diagram.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ReliabilityBand {
/// Mean probability the calibration predicted for pairs in this band.
pub predicted: f32,
/// Fraction of them that were actually the same person.
pub observed: f32,
pub count: u64,
}
fn bin(cosine: f32) -> usize {
let width = 2.0 / BINS as f32;
(((cosine + 1.0) / width) as usize).min(BINS - 1)
}
fn bin_centre(i: usize) -> f32 {
let width = 2.0 / BINS as f32;
-1.0 + (i as f32 + 0.5) * width
}
#[cfg(test)]
mod tests {
use super::*;
/// Synthesise pairs from two well-separated cosine distributions, the way
/// a real embedding space behaves: positives near 0.6, negatives near 0.05
/// — the numbers our own end-to-end run actually produced.
fn realistic_pairs(n_pos: u64, n_neg: u64) -> Pairs {
let mut p = Pairs::new();
let mut s = 12345_u32;
let mut rand = move || {
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(s >> 8) as f32 / (1u32 << 24) as f32
};
for _ in 0..n_pos {
// ~N(0.60, 0.12), by summing uniforms.
let g = (0..4).map(|_| rand()).sum::<f32>() / 4.0 - 0.5;
p.push_positive((0.60 + g * 0.48).clamp(-1.0, 1.0));
}
for _ in 0..n_neg {
let g = (0..4).map(|_| rand()).sum::<f32>() / 4.0 - 0.5;
p.push_negative((0.05 + g * 0.32).clamp(-1.0, 1.0));
}
p
}
#[test]
fn an_empty_library_has_no_valid_calibration() {
let cal = Pairs::new().fit();
assert!(!cal.valid, "a fit with no evidence must not claim validity");
assert_eq!(cal.positive_pairs, 0);
}
/// The exact case FR-CULL-9 legislates: enough negatives, too few
/// positives. The answer is "unavailable", not a plausible-looking curve.
#[test]
fn too_few_positives_is_invalid_however_many_negatives_there_are() {
let p = realistic_pairs(MIN_POSITIVE_PAIRS - 1, MIN_NEGATIVE_PAIRS * 10);
assert!(!p.fit().valid);
}
#[test]
fn too_few_negatives_is_invalid_too() {
let p = realistic_pairs(MIN_POSITIVE_PAIRS * 10, MIN_NEGATIVE_PAIRS - 1);
assert!(!p.fit().valid);
}
#[test]
fn a_well_separated_library_fits_a_usable_curve() {
let cal = realistic_pairs(2_000, 40_000).fit();
assert!(cal.valid);
assert!(cal.a > 0.0, "steepness must be positive: {}", cal.a);
// The decision boundary lands between the two populations.
let boundary = cal.boundary_at(0.5, 112.0, 0.0);
assert!(
boundary > 0.05 && boundary < 0.60,
"boundary {boundary} is not between the negative and positive modes"
);
// And the measured cosines from the real end-to-end run fall the
// right side of it.
assert!(cal.probability(0.596, 200.0, 0.0) > 0.9);
assert!(cal.probability(0.050, 200.0, 0.0) < 0.1);
}
#[test]
fn probability_and_boundary_are_inverses() {
let cal = realistic_pairs(2_000, 40_000).fit();
for &p in &[0.1_f32, 0.5, 0.9, 0.99] {
let cos = cal.boundary_at(p, 112.0, 0.0);
assert!((cal.probability(cos, 112.0, 0.0) - p).abs() < 1e-3);
}
}
/// A base rate shifts the answer without a refit — the property that lets
/// one stored calibration serve a small album and a large archive.
#[test]
fn a_prior_moves_the_boundary_in_the_right_direction() {
let cal = realistic_pairs(2_000, 40_000).fit();
let neutral = cal.probability(0.4, 112.0, 0.0);
let pessimistic = cal.probability(0.4, 112.0, -2.0);
let optimistic = cal.probability(0.4, 112.0, 2.0);
assert!(pessimistic < neutral && neutral < optimistic);
}
/// FR-CULL-9's acceptance criterion, run against the fit's own evidence:
/// in every populated band, the stated probability should track the
/// observed match rate.
#[test]
fn the_fit_is_reliable_on_the_evidence_it_was_fitted_to() {
let pairs = realistic_pairs(4_000, 40_000);
let cal = pairs.fit();
let bands = pairs.reliability(&cal, 10);
let mut checked = 0;
for b in &bands {
// Thinly populated bands are noise, not evidence.
if b.count < 200 {
continue;
}
checked += 1;
assert!(
(b.predicted - b.observed).abs() < 0.15,
"band predicted {:.3} but observed {:.3} over {} pairs",
b.predicted,
b.observed,
b.count
);
}
assert!(
checked >= 2,
"only {checked} bands had enough pairs to check"
);
}
#[test]
fn the_default_curve_is_the_references_and_is_not_marked_valid() {
let cal = Calibration::default();
assert!(!cal.valid);
assert!((cal.boundary_at(0.5, 112.0, 0.0) - 0.267).abs() < 1e-3);
}
#[test]
fn bins_cover_the_cosine_range_without_overflowing() {
assert_eq!(bin(-1.0), 0);
assert_eq!(bin(1.0), BINS - 1);
assert_eq!(bin(2.0), BINS - 1, "an out-of-range cosine must not panic");
assert!((bin_centre(bin(0.5)) - 0.5).abs() < 0.01);
}
}
-991
View File
@@ -1,991 +0,0 @@
//! Grouping faces into people (docs/faces.md §9, FR-CULL-10).
//!
//! Model-free: this is arithmetic over embeddings, and it is where the
//! subsystem's accuracy actually lives, so it is testable with no weights on
//! the machine.
//!
//! # Constraints, not just a threshold
//!
//! FR-CULL-10 warns that clustering will over-merge on siblings, on parents and
//! children, and on the same person a decade apart. Two structural defences,
//! both cheaper than a better threshold:
//!
//! **Cannot-link on co-occurrence.** Two faces in the same photograph are never
//! merged. It is the same observation [`crate::calibrate`] mines for free
//! negatives, used here as a hard constraint, and it is the single cheapest
//! defence against over-merging that exists.
//!
//! **Confirmed faces are anchors.** A confirmation is user data (FR-CULL-12)
//! and clustering never moves it. Two groups holding confirmations of
//! *different* people cannot merge, whatever their similarity says.
//!
//! # Average link, not single link
//!
//! Single-link chains: one bad edge welds two identities together, and it is
//! the documented way face clustering fails on families. Average link asks
//! whether the *groups* are similar, which one outlier cannot force.
//!
//! # How it runs, and why the obvious way does not
//!
//! The first implementation of this was the textbook one: compute every
//! pairwise cosine, then repeatedly scan all live group pairs, score each with
//! average link, and merge the best. It is correct, it is twenty lines, and on
//! a real library it does not finish.
//!
//! The reason is that the scan is inside the loop. Each merge rescans every
//! surviving pair — `O(g²)` of them — and each score is recomputed from
//! scratch over every cross pair, `O(|A|·|B|)`. With 1,813 faces that is
//! roughly 1.6 million pair scores per merge and some 700 merges to do; the
//! window simply stops responding, which is what a user reports as "Regroup is
//! broken". At 25,000 faces it is not slow, it is impossible.
//!
//! Three changes, none of which alter the answer:
//!
//! **Only above-threshold pairs can ever matter.** An average that reaches the
//! threshold must have at least one term at or above it, so two groups with no
//! qualifying pair between them can never merge — not now and not after any
//! sequence of merges, since merging only adds terms. [`crate::neighbours`]
//! produces exactly that sparse pair list, and everything below works on it.
//! On the library above it is 7,875 pairs rather than 1.6 million.
//!
//! **Merges cannot cross components.** Groups only ever merge along those
//! pairs, so the connected components of that graph are independent problems.
//! A library of four hundred people becomes four hundred small agglomerations
//! instead of one large one, and the quadratic term is paid per component.
//!
//! **Average link is additive.** `sum(A ∪ B, C) = sum(A, C) + sum(B, C)`, so a
//! merged group's scores follow from the two it came from by addition — the
//! Lance-Williams update. Kept as running `(sum, count)` per adjacent pair, a
//! score costs one division instead of a nested loop, and a binary heap with
//! lazy invalidation replaces the rescan.
//!
//! The output is unchanged, deliberately and testably so: `the_fast_engine_
//! agrees_with_the_reference` runs both over the same population and asserts
//! the clusters are identical.
use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap, HashSet};
use crate::calibrate::Calibration;
use crate::neighbours::{self, Faces};
/// Probability above which two groups are judged the same person.
///
/// Stated as a probability and not a cosine, because FR-CULL-9 forbids
/// thresholding a bare similarity anywhere in this subsystem.
///
/// # Why 0.80
///
/// It was 0.90, and 0.90 left most of a real library ungrouped. Measured over
/// the 1,813-face reference library, with `dr-ui`'s `face_index --tune`:
///
/// | P | cosine | groups | faces grouped | largest group |
/// |---|---|---|---|---|
/// | 0.95 | 0.449 | 311 | 62% | 51 |
/// | 0.90 | 0.403 | 316 | 67% | 51 |
/// | 0.85 | 0.374 | 318 | 70% | 57 |
/// | **0.80** | **0.353** | **328** | **74%** | **69** |
/// | 0.75 | 0.335 | 327 | 77% | 69 |
/// | 0.70 | 0.319 | 326 | 79% | 81 |
/// | 0.50 | 0.267 | 303 | 85% | 90 |
///
/// The count of *groups* is the signal, not the count of grouped faces. Loosen
/// from 0.95 and it climbs: real people are being assembled out of fragments.
/// It peaks at 0.80 and then falls, and a falling group count while the grouped
/// faces keep rising is the shape of over-merging — separate identities being
/// welded, which is the failure FR-CULL-10 warns about and the one the user
/// cannot easily undo by hand.
///
/// So: the loosest setting that is still building people rather than melting
/// them together. A third more of the library gets grouped than at 0.90, and
/// the largest group grows by eighteen faces rather than by forty.
///
/// This is a *default*, not a constant of nature — the numbers above are one
/// library, and `--tune` reruns the table on any other.
pub const DEFAULT_MERGE_PROBABILITY: f32 = 0.80;
/// A face presented to the clusterer.
///
/// Ids are opaque `u64`s rather than catalog types: this crate has no business
/// knowing what a `FaceId` means, and the caller does the translation.
#[derive(Debug, Clone)]
pub struct Candidate {
pub face: u64,
/// Which photograph it came from — the cannot-link key.
pub image: u64,
/// L2-normalised, `EMBEDDING_DIM` long.
pub embedding: Vec<f32>,
/// Source pixels across the aligned crop, for the calibration's size term.
pub crop_px: f32,
/// The person this face is *confirmed* to be, if any.
///
/// Suggestions are deliberately not passed here. They are this function's
/// own previous output, and feeding them back in would let a guess harden
/// into a fact across successive passes.
pub confirmed_person: Option<u64>,
}
/// One group of faces the clusterer believes are one person.
#[derive(Debug, Clone, PartialEq)]
pub struct Cluster {
/// Indices into the input slice.
pub members: Vec<usize>,
/// The person this group is already known to be, from its anchors.
///
/// `Some` means the group contains confirmed faces and the suggestions in
/// it attach to that existing person. `None` is a new unnamed group.
pub person: Option<u64>,
}
/// Group faces into people.
///
/// `min_probability` is compared against the calibrated average-link
/// probability between two groups. Deterministic: the same input yields the
/// same clusters, because the merge order is by score with the index pair as
/// the tiebreak.
pub fn cluster(faces: &[Candidate], cal: &Calibration, min_probability: f32) -> Vec<Cluster> {
if faces.is_empty() {
return Vec::new();
}
let embeddings: Vec<Vec<f32>> = faces.iter().map(|f| f.embedding.clone()).collect();
let crop_px: Vec<f32> = faces.iter().map(|f| f.crop_px).collect();
let images: Vec<u64> = faces.iter().map(|f| f.image).collect();
let view = Faces {
embeddings: &embeddings,
crop_px: &crop_px,
images: &images,
};
// Every pair that could ever contribute to a merge. See the module note on
// why nothing outside this list can matter.
let pairs = neighbours::above_threshold(&view, cal, min_probability);
let mut engine = Engine::new(faces, cal, min_probability);
for component in components(faces.len(), &pairs) {
engine.agglomerate(&component, &pairs);
}
engine.finish()
}
/// Split one person's faces into the groups a raised threshold separates them
/// into.
///
/// FR-CULL-10 requires splitting to be as easy as merging, and a split that
/// hands the user a pile of loose faces to re-sort is not that. This re-runs
/// the same agglomeration at a stricter probability so the user is offered
/// coherent sub-groups to pull apart.
///
/// Anchors are ignored here on purpose: every face in the input is already
/// nominally the same person, so honouring the anchors would refuse to split
/// anything.
pub fn split(faces: &[Candidate], cal: &Calibration, min_probability: f32) -> Vec<Cluster> {
let anchorless: Vec<Candidate> = faces
.iter()
.cloned()
.map(|mut f| {
f.confirmed_person = None;
f
})
.collect();
cluster(&anchorless, cal, min_probability)
}
// ── the merge engine ──────────────────────────────────────────────────────
/// One live group, identified throughout by the index of its lowest member.
#[derive(Debug)]
struct Group {
/// Ascending, always — [`Engine::cross`] sums in this order, and a stable
/// order is what makes the floating-point total reproducible.
members: Vec<usize>,
images: HashSet<u64>,
person: Option<u64>,
alive: bool,
/// Bumped on every merge, so heap entries naming an older state can be
/// recognised and dropped instead of acted on.
version: u64,
}
/// Running average-link state for one adjacent pair of groups.
///
/// `sum` is over **every** cross pair, not only the above-threshold ones —
/// average link is an average over all of them, and counting only the
/// qualifying pairs would report a similarity no group actually has.
#[derive(Debug, Clone, Copy)]
struct Link {
sum: f64,
count: f64,
}
impl Link {
fn probability(&self) -> f32 {
if self.count == 0.0 {
0.0
} else {
(self.sum / self.count) as f32
}
}
}
/// A candidate merge, waiting in the heap.
#[derive(Debug, Clone, Copy)]
struct Pending {
probability: f32,
a: usize,
b: usize,
/// Group versions when this was pushed. A mismatch on pop means a merge
/// has happened since and a fresher entry for this pair is already queued.
va: u64,
vb: u64,
}
impl PartialEq for Pending {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for Pending {}
impl PartialOrd for Pending {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Pending {
/// Greatest pops first, so: highest probability, and on a tie the lowest
/// index pair. That tiebreak is not cosmetic — it is what the old
/// ascending scan did, and it is the whole of the determinism guarantee.
fn cmp(&self, other: &Self) -> Ordering {
self.probability
.total_cmp(&other.probability)
.then_with(|| other.a.cmp(&self.a))
.then_with(|| other.b.cmp(&self.b))
}
}
struct Engine<'a> {
faces: &'a [Candidate],
cal: &'a Calibration,
min_probability: f32,
groups: Vec<Group>,
links: HashMap<(usize, usize), Link>,
/// Adjacency, as group ids. Kept alongside `links` so a merge can find
/// everything it has to update without scanning the whole map.
adjacent: Vec<HashSet<usize>>,
}
impl<'a> Engine<'a> {
fn new(faces: &'a [Candidate], cal: &'a Calibration, min_probability: f32) -> Self {
let groups = faces
.iter()
.enumerate()
.map(|(i, f)| Group {
members: vec![i],
images: HashSet::from([f.image]),
person: f.confirmed_person,
alive: true,
version: 0,
})
.collect();
Self {
faces,
cal,
min_probability,
groups,
links: HashMap::new(),
adjacent: vec![HashSet::new(); faces.len()],
}
}
/// Agglomerate one connected component to exhaustion.
fn agglomerate(&mut self, component: &[usize], pairs: &[neighbours::Pair]) {
if component.len() < 2 {
return;
}
let members: HashSet<usize> = component.iter().copied().collect();
let mut heap = BinaryHeap::new();
for p in pairs.iter().filter(|p| members.contains(&p.i)) {
self.links.insert(
key(p.i, p.j),
Link {
sum: p.probability as f64,
count: 1.0,
},
);
self.adjacent[p.i].insert(p.j);
self.adjacent[p.j].insert(p.i);
heap.push(Pending {
probability: p.probability,
a: p.i.min(p.j),
b: p.i.max(p.j),
va: 0,
vb: 0,
});
}
while let Some(top) = heap.pop() {
let Pending {
probability,
a,
b,
va,
vb,
} = top;
// Stale: one side has merged since this was queued, and the
// replacement entry is already in the heap.
if !self.groups[a].alive
|| !self.groups[b].alive
|| self.groups[a].version != va
|| self.groups[b].version != vb
{
continue;
}
// The heap is ordered by probability, so the first entry below the
// bar means nothing left in this component can reach it.
if probability < self.min_probability {
break;
}
if !self.can_link(a, b) {
// Never becomes possible again: images only accumulate and an
// anchor is never given up, so drop the pair for good.
self.unlink(a, b);
continue;
}
self.merge(a, b, &mut heap);
}
}
/// Whether two groups are allowed to merge at all, before similarity is
/// asked.
fn can_link(&self, a: usize, b: usize) -> bool {
let (ga, gb) = (&self.groups[a], &self.groups[b]);
// Two confirmations of different people. The user has said these are
// not the same person, and no similarity overrides that.
if let (Some(pa), Some(pb)) = (ga.person, gb.person) {
if pa != pb {
return false;
}
}
// Co-occurrence: a photograph containing a face from each group means
// the two faces are in the same frame, so they are not the same person.
ga.images.is_disjoint(&gb.images)
}
/// Fold `b` into `a` and re-score everything that touched either.
fn merge(&mut self, a: usize, b: usize, heap: &mut BinaryHeap<Pending>) {
// Sorted, and deduplicated by the set: `a` and `b` may share
// neighbours, and each must be visited once. Sorting is what keeps the
// floating-point sums identical from run to run.
let mut touched: Vec<usize> = self.adjacent[a]
.union(&self.adjacent[b])
.copied()
.filter(|&c| c != a && c != b && self.groups[c].alive)
.collect();
touched.sort_unstable();
// Take the pair sums before the groups change underneath them.
let carried: Vec<(usize, Option<Link>, Option<Link>)> = touched
.iter()
.map(|&c| {
(
c,
self.links.get(&key(a, c)).copied(),
self.links.get(&key(b, c)).copied(),
)
})
.collect();
// a's own members, before b's are folded in. A missing a-side sum has
// to be computed over these and not over the merged list, or b's
// contribution would be counted twice.
let a_members = self.groups[a].members.clone();
// Absorb b into a.
let taken = std::mem::replace(
&mut self.groups[b],
Group {
members: Vec::new(),
images: HashSet::new(),
person: None,
alive: false,
version: 0,
},
);
{
let ga = &mut self.groups[a];
ga.members.extend(taken.members.iter().copied());
ga.members.sort_unstable();
ga.images.extend(taken.images.iter().copied());
// At most one side carries a person: `can_link` refuses a merge of
// two groups anchored to different people, so this cannot silently
// discard one of them.
ga.person = ga.person.or(taken.person);
ga.version += 1;
}
// b's own links are gone with it.
for c in self.adjacent[b].clone() {
self.links.remove(&key(b, c));
self.adjacent[c].remove(&b);
}
self.adjacent[b].clear();
self.links.remove(&key(a, b));
self.adjacent[a].remove(&b);
for (c, from_a, from_b) in carried {
// Dropping a pair the constraints now forbid saves computing a
// score for a merge that can never happen — which for a newly
// adjacent side is a real cost, not a bookkeeping one.
if !self.can_link(a, c) {
self.unlink(a, c);
continue;
}
// A side with no stored link was not adjacent before, so its cross
// pairs were all below threshold and were never summed. They still
// belong in the average, so they are computed now — once, after
// which the additive update carries them forward.
let from_a = from_a.unwrap_or_else(|| self.cross(&a_members, c));
let from_b = from_b.unwrap_or_else(|| self.cross(&taken.members, c));
let merged = Link {
sum: from_a.sum + from_b.sum,
count: from_a.count + from_b.count,
};
self.links.insert(key(a, c), merged);
self.adjacent[a].insert(c);
self.adjacent[c].insert(a);
heap.push(Pending {
probability: merged.probability(),
a: a.min(c),
b: a.max(c),
va: self.groups[a.min(c)].version,
vb: self.groups[a.max(c)].version,
});
}
}
/// Exact `(sum, count)` over every cross pair between a member list and a
/// group.
///
/// The one place a dot product is still computed during agglomeration, and
/// it happens only when two groups become adjacent through a third — at
/// which point their sub-threshold pairs, never summed because they were
/// never interesting, have to be accounted for.
fn cross(&self, members: &[usize], group: usize) -> Link {
let mut sum = 0.0_f64;
let mut count = 0.0_f64;
for &i in members {
for &j in &self.groups[group].members {
let cos = neighbours::dot(&self.faces[i].embedding, &self.faces[j].embedding);
let min_crop = self.faces[i].crop_px.min(self.faces[j].crop_px);
sum += self.cal.probability(cos, min_crop, 0.0) as f64;
count += 1.0;
}
}
Link { sum, count }
}
fn unlink(&mut self, a: usize, b: usize) {
self.links.remove(&key(a, b));
self.adjacent[a].remove(&b);
self.adjacent[b].remove(&a);
}
fn finish(self) -> Vec<Cluster> {
let mut out: Vec<Cluster> = self
.groups
.into_iter()
.filter(|g| g.alive)
.map(|g| Cluster {
members: g.members,
person: g.person,
})
.collect();
// Largest first: the People view shows the best-evidenced groups at the
// top.
out.sort_by(|x, y| {
y.members
.len()
.cmp(&x.members.len())
.then(x.members[0].cmp(&y.members[0]))
});
out
}
}
fn key(a: usize, b: usize) -> (usize, usize) {
if a < b {
(a, b)
} else {
(b, a)
}
}
/// Connected components of the above-threshold graph.
///
/// Faces in different components can never end up in one group, so each is a
/// separate and much smaller agglomeration. Returned with the members of each
/// component ascending, and the components themselves in order of their lowest
/// member — the determinism the merge order inherits.
fn components(n: usize, pairs: &[neighbours::Pair]) -> Vec<Vec<usize>> {
let mut parent: Vec<usize> = (0..n).collect();
fn find(parent: &mut [usize], mut x: usize) -> usize {
while parent[x] != x {
// Path halving: keeps the tree flat without a second pass.
parent[x] = parent[parent[x]];
x = parent[x];
}
x
}
for p in pairs {
let (ra, rb) = (find(&mut parent, p.i), find(&mut parent, p.j));
if ra != rb {
// Lowest root wins, so the representative of a component is
// reproducible rather than an artefact of union order.
let (lo, hi) = if ra < rb { (ra, rb) } else { (rb, ra) };
parent[hi] = lo;
}
}
let mut by_root: HashMap<usize, Vec<usize>> = HashMap::new();
for i in 0..n {
let r = find(&mut parent, i);
by_root.entry(r).or_default().push(i);
}
let mut out: Vec<Vec<usize>> = by_root.into_values().filter(|c| c.len() > 1).collect();
out.sort_unstable_by_key(|c| c[0]);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embedding::EMBEDDING_DIM;
/// An embedding a known cosine away from a base direction, built by mixing
/// two orthogonal unit vectors. Lets a test state "these two faces are 0.7
/// similar" and have it be exactly true.
fn at_cosine(identity: usize, cosine: f32) -> Vec<f32> {
let mut v = vec![0.0_f32; EMBEDDING_DIM];
let base = identity * 2;
let perp = identity * 2 + 1;
v[base] = cosine;
v[perp] = (1.0 - cosine * cosine).max(0.0).sqrt();
v
}
fn candidate(face: u64, image: u64, identity: usize, cosine: f32) -> Candidate {
Candidate {
face,
image,
embedding: at_cosine(identity, cosine),
crop_px: 150.0,
confirmed_person: None,
}
}
/// A calibration steep enough that the test's cosines are unambiguous:
/// 0.6 is near-certain, 0.1 is near-impossible.
fn cal() -> Calibration {
Calibration {
a: 30.0,
b: -30.0 * 0.35,
w_size: 0.0,
valid: true,
positive_pairs: 1000,
negative_pairs: 10_000,
}
}
#[test]
fn no_faces_makes_no_clusters() {
assert!(cluster(&[], &cal(), DEFAULT_MERGE_PROBABILITY).is_empty());
}
#[test]
fn similar_faces_from_different_photographs_group_together() {
let faces = vec![
candidate(1, 10, 0, 1.0),
candidate(2, 11, 0, 0.95),
candidate(3, 12, 0, 0.92),
];
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
assert_eq!(out.len(), 1);
assert_eq!(out[0].members, vec![0, 1, 2]);
}
#[test]
fn dissimilar_faces_stay_apart() {
let faces = vec![candidate(1, 10, 0, 1.0), candidate(2, 11, 1, 1.0)];
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
assert_eq!(out.len(), 2);
}
/// The cheapest defence against over-merging: two faces in one frame are
/// not the same person however similar the model finds them.
#[test]
fn two_faces_in_one_photograph_never_merge() {
// Identical embeddings — siblings, or a model that cannot tell them
// apart — but both in image 10.
let faces = vec![candidate(1, 10, 0, 1.0), candidate(2, 10, 0, 1.0)];
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
assert_eq!(out.len(), 2, "co-occurring faces were merged");
}
/// And the constraint has to survive transitively: once a group holds a
/// face from image 10, no other group holding one from image 10 may join
/// it, even indirectly.
#[test]
fn the_co_occurrence_constraint_propagates_through_a_group() {
let faces = vec![
candidate(1, 10, 0, 1.0), // A, in the group photo
candidate(2, 10, 0, 1.0), // B, in the same group photo
candidate(3, 11, 0, 0.99), // A again, alone
];
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
assert_eq!(out.len(), 2);
// Whichever of A/B absorbed face 2, the other stays out.
assert!(out.iter().any(|c| c.members.len() == 2));
assert!(out.iter().any(|c| c.members.len() == 1));
}
/// FR-CULL-10: a confirmation is user data and no inference overrides it.
#[test]
fn groups_confirmed_as_different_people_do_not_merge() {
let mut a = candidate(1, 10, 0, 1.0);
let mut b = candidate(2, 11, 0, 1.0);
a.confirmed_person = Some(100);
b.confirmed_person = Some(200);
let out = cluster(&[a, b], &cal(), DEFAULT_MERGE_PROBABILITY);
assert_eq!(out.len(), 2, "clustering overrode two user confirmations");
}
#[test]
fn a_suggestion_joins_the_person_its_group_is_anchored_to() {
let mut anchor = candidate(1, 10, 0, 1.0);
anchor.confirmed_person = Some(42);
let loose = candidate(2, 11, 0, 0.95);
let out = cluster(&[anchor, loose], &cal(), DEFAULT_MERGE_PROBABILITY);
assert_eq!(out.len(), 1);
assert_eq!(out[0].person, Some(42));
assert_eq!(out[0].members.len(), 2);
}
#[test]
fn an_unanchored_group_is_a_new_unnamed_person() {
let out = cluster(
&[candidate(1, 10, 0, 1.0), candidate(2, 11, 0, 0.95)],
&cal(),
DEFAULT_MERGE_PROBABILITY,
);
assert_eq!(out[0].person, None);
}
/// Average link rather than single link: one strong edge must not weld two
/// otherwise-dissimilar groups together. This is the family failure mode
/// FR-CULL-10 names.
#[test]
fn one_strong_edge_does_not_chain_two_groups_together() {
// Two tight pairs, with a single borderline link between them.
let faces = vec![
candidate(1, 10, 0, 1.00),
candidate(2, 11, 0, 0.99),
candidate(3, 12, 0, 0.42),
candidate(4, 13, 0, 0.40),
];
let out = cluster(&faces, &cal(), 0.99);
assert!(
out.len() >= 2,
"single-link chaining merged everything into {} cluster(s)",
out.len()
);
}
#[test]
fn clustering_is_deterministic() {
let faces = vec![
candidate(1, 10, 0, 1.0),
candidate(2, 11, 0, 0.96),
candidate(3, 12, 1, 1.0),
candidate(4, 13, 1, 0.97),
candidate(5, 14, 0, 0.94),
];
let a = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
let b = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
assert_eq!(a, b);
}
#[test]
fn clusters_come_back_largest_first() {
let faces = vec![
candidate(1, 10, 1, 1.0),
candidate(2, 11, 0, 1.0),
candidate(3, 12, 0, 0.97),
candidate(4, 13, 0, 0.95),
];
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
assert_eq!(out[0].members.len(), 3);
assert_eq!(out[1].members.len(), 1);
}
/// Splitting is the inverse operation and must actually separate a group
/// that a looser threshold had merged.
#[test]
fn split_separates_a_group_that_a_looser_threshold_merged() {
let faces = vec![
candidate(1, 10, 0, 1.00),
candidate(2, 11, 0, 0.98),
candidate(3, 12, 0, 0.45),
candidate(4, 13, 0, 0.43),
];
// Loose: one person.
assert_eq!(cluster(&faces, &cal(), 0.5).len(), 1);
// Strict: the two sub-groups the user wants offered.
let parts = split(&faces, &cal(), 0.999);
assert!(parts.len() >= 2, "split produced {} group(s)", parts.len());
}
#[test]
fn split_ignores_the_anchor_so_a_mislabelled_person_can_be_taken_apart() {
let mut a = candidate(1, 10, 0, 1.0);
let mut b = candidate(2, 11, 0, 0.40);
a.confirmed_person = Some(7);
b.confirmed_person = Some(7);
let parts = split(&[a, b], &cal(), 0.99);
assert_eq!(parts.len(), 2);
}
/// The size term earns its place: the same cosine between two thumbnail-
/// sized faces should be less convincing than between two large ones.
#[test]
fn the_face_size_term_moves_the_probability() {
let sized = Calibration {
w_size: 0.5,
b: -30.0 * 0.35 - 0.5 * 7.0,
..cal()
};
let big = sized.probability(0.5, 300.0, 0.0);
let small = sized.probability(0.5, 40.0, 0.0);
assert!(big > small, "big {big} should beat small {small}");
}
// ── the fast engine against the obvious one ───────────────────────────
/// The original implementation, kept as the oracle.
///
/// Deliberately the naive version this module replaced: rescan every live
/// pair, score it from scratch over all cross pairs, merge the best,
/// repeat. It is the definition of the answer, and the only thing the
/// rewrite was allowed to change is how long it takes to get there.
fn reference(faces: &[Candidate], cal: &Calibration, min_probability: f32) -> Vec<Cluster> {
#[derive(Clone)]
struct G {
members: Vec<usize>,
images: HashSet<u64>,
person: Option<u64>,
alive: bool,
}
if faces.is_empty() {
return Vec::new();
}
let n = faces.len();
let mut groups: Vec<G> = faces
.iter()
.enumerate()
.map(|(i, f)| G {
members: vec![i],
images: HashSet::from([f.image]),
person: f.confirmed_person,
alive: true,
})
.collect();
let mut cos = vec![0.0_f32; n * n];
for i in 0..n {
for j in i + 1..n {
let c = neighbours::dot(&faces[i].embedding, &faces[j].embedding);
cos[i * n + j] = c;
cos[j * n + i] = c;
}
}
let linkable = |a: &G, b: &G| {
if let (Some(pa), Some(pb)) = (a.person, b.person) {
if pa != pb {
return false;
}
}
a.images.is_disjoint(&b.images)
};
let average = |a: &G, b: &G| {
let mut sum = 0.0_f32;
let mut count = 0.0_f32;
for &i in &a.members {
for &j in &b.members {
let min_crop = faces[i].crop_px.min(faces[j].crop_px);
sum += cal.probability(cos[i * n + j], min_crop, 0.0);
count += 1.0;
}
}
if count == 0.0 {
0.0
} else {
sum / count
}
};
loop {
let mut best: Option<(f32, usize, usize)> = None;
for a in 0..n {
if !groups[a].alive {
continue;
}
for b in a + 1..n {
if !groups[b].alive || !linkable(&groups[a], &groups[b]) {
continue;
}
let p = average(&groups[a], &groups[b]);
if p >= min_probability && best.is_none_or(|(bp, _, _)| p > bp) {
best = Some((p, a, b));
}
}
}
let Some((_, a, b)) = best else { break };
let taken = groups[b].clone();
groups[b].alive = false;
groups[a].members.extend(taken.members);
groups[a].images.extend(taken.images);
groups[a].person = groups[a].person.or(taken.person);
}
let mut out: Vec<Cluster> = groups
.into_iter()
.filter(|g| g.alive)
.map(|mut g| {
g.members.sort_unstable();
Cluster {
members: g.members,
person: g.person,
}
})
.collect();
out.sort_by(|x, y| {
y.members
.len()
.cmp(&x.members.len())
.then(x.members[0].cmp(&y.members[0]))
});
out
}
/// `people` identities of `per` faces, each face in its own photograph,
/// spread either side of the threshold so the population has genuine
/// near-misses rather than obvious answers.
fn population(people: usize, per: usize) -> Vec<Candidate> {
let mut out = Vec::new();
let mut image = 0u64;
for p in 0..people {
for m in 0..per {
// Walks down through the merge boundary as m grows, so some
// members join their group and some do not.
let cosine = 1.0 - (m as f32) * 0.035;
out.push(Candidate {
face: out.len() as u64,
image,
embedding: at_cosine(p, cosine),
crop_px: 60.0 + ((out.len() % 11) as f32) * 25.0,
confirmed_person: None,
});
image += 1;
}
}
out
}
/// The point of the rewrite: same clusters, less work. A disagreement here
/// is the rewrite being wrong, not the reference being slow.
#[test]
fn the_fast_engine_agrees_with_the_reference() {
let faces = population(40, 6);
assert_eq!(
cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY),
reference(&faces, &cal(), DEFAULT_MERGE_PROBABILITY),
);
}
/// The two structural constraints are the ones a sparse graph could
/// plausibly break, so they get their own comparison with anchors and
/// co-occurrence in play.
#[test]
fn the_fast_engine_agrees_with_the_reference_under_constraints() {
let mut faces = population(30, 6);
// Some faces share a photograph, so cannot-link has to propagate
// through groups that formed for other reasons.
for i in (0..faces.len()).step_by(7) {
faces[i].image = 900 + (i as u64 % 4);
}
// And some carry confirmations, including two of different people that
// must never be brought together.
for (n, i) in (0..faces.len()).step_by(11).enumerate() {
faces[i].confirmed_person = Some(1 + (n as u64 % 3));
}
assert_eq!(
cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY),
reference(&faces, &cal(), DEFAULT_MERGE_PROBABILITY),
);
}
/// The size term makes the merge boundary depend on the pair, which is the
/// case the sparse pre-filter has to be built carefully to preserve.
#[test]
fn the_fast_engine_agrees_with_the_reference_with_a_size_term() {
let faces = population(30, 6);
let sized = Calibration {
w_size: 0.4,
b: -30.0 * 0.35 - 0.4 * 7.0,
..cal()
};
assert_eq!(
cluster(&faces, &sized, DEFAULT_MERGE_PROBABILITY),
reference(&faces, &sized, DEFAULT_MERGE_PROBABILITY),
);
}
/// Determinism has to hold at a size where the indexed neighbour search is
/// in play, not just on the handful of faces the small cases use.
#[test]
fn clustering_is_deterministic_at_scale() {
let faces = population(200, 6);
assert!(
faces.len() > 1024,
"population is below the indexing cutoff"
);
assert_eq!(
cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY),
cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY),
);
}
/// A face that matches nobody is left alone rather than being swept into
/// the nearest group, and costs nothing to establish — it is in no
/// component at all.
#[test]
fn a_face_matching_nothing_stays_on_its_own() {
let mut faces = population(5, 4);
faces.push(Candidate {
face: 999,
image: 5_000,
embedding: at_cosine(200, 1.0),
crop_px: 150.0,
confirmed_person: None,
});
let out = cluster(&faces, &cal(), DEFAULT_MERGE_PROBABILITY);
let last = faces.len() - 1;
assert!(
out.iter().any(|c| c.members == vec![last]),
"the outlier was absorbed"
);
}
}
-441
View File
@@ -1,441 +0,0 @@
//! SCRFD face detection (docs/faces.md §4).
//!
//! One forward pass produces a box, a confidence and **five landmarks** per
//! face — the landmarks being the reason for this detector rather than a
//! general one, since [`crate::align`] cannot work without them.
//!
//! # The graph must have fixed input dimensions
//!
//! InsightFace ships `det_500m.onnx` with a dynamic H/W input, and **tract
//! cannot parse it in that form** — it fails at node #0. The same file run
//! through `tools/fix-face-model-shapes.sh` loads cleanly. Its outputs were
//! already static at 640, so 640 is not a choice made here: it is the shape
//! the export was always going to run at.
use ndarray::Array4;
use crate::{install_backend, FaceError};
/// The graph's input edge, in pixels. See the module note: not configurable.
pub const INPUT_EDGE: usize = 640;
/// Strides, in the order SCRFD emits them.
const ALL_STRIDES: [usize; 4] = [8, 16, 32, 64];
/// Anchors per feature-map location.
const ANCHORS: usize = 2;
/// How detection is tuned.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DetectOptions {
/// Minimum detector confidence.
///
/// Deliberately *not* the low threshold `dr-segment` chose. There a false
/// positive costs one spurious row in a list the user is picking from;
/// here it costs a face in the People view to reject and — worse — a
/// garbage embedding that can bridge two real clusters into one. A false
/// negative is recoverable by re-indexing with a better model; a polluted
/// cluster graph, once the user has confirmed faces inside it, is not.
pub confidence: f32,
/// Box IoU above which two detections are judged to be the same face.
pub nms_iou: f32,
/// Cheap pre-filter: smallest box to keep, in source pixels on the shorter
/// edge.
///
/// **Not the real size floor** — [`DetectOptions::min_source_px`] is, and
/// it is measured on the aligned crop rather than the box. This one exists
/// only to throw away the obviously hopeless before paying for a warp, so
/// it is deliberately set *below* what the real floor will accept: the
/// aligned crop spans roughly 1.3x the box's shorter edge, so 24 here
/// cannot reject a face that would have cleared 32 there.
pub min_face_px: f32,
/// Smallest face the embedder may be given, in **source pixels across the
/// aligned crop** — `crop_px` in the catalog.
///
/// The honest statement of "a face must be at least 32x32", because this is
/// the number of real pixels behind the 112x112 the model actually sees.
/// The box's own size is not that: the ArcFace template reaches past the
/// box for forehead and chin, so a 64-pixel box and a 64-pixel crop are
/// different faces.
///
/// Below this the crop was upsampled to reach the embedder, and upsampling
/// invents no detail — the embedding is of a soft, stretched face and is
/// correspondingly untrustworthy.
///
/// Applied after alignment, so it lives with the sharpness floor rather
/// than with the detector. See [`DetectOptions::min_sharpness`].
pub min_source_px: f32,
/// Least acceptable [`crate::align::Aligned112::sharpness`].
///
/// Applied after alignment rather than here, because it is a property of
/// the warped crop the embedder receives and not of the box. The pipeline
/// that enforces it is `dr_ui::faces::index_proxy`; it lives on this struct
/// so that every quality decision about a face is configured in one place
/// and a caller cannot enable one gate while forgetting the other.
///
/// Zero disables it, which is what a measurement run wants.
///
/// # It has to move with the size floor
///
/// The two are coupled, because an upsampled face scores low here whatever
/// its original sharpness. Measured over the reference library, with the
/// size floor at 32 source pixels:
///
/// | min sharpness | of what the size floor left, this removes |
/// |---|---|
/// | 0.002 | 3% |
/// | 0.005 | 8% |
/// | 0.010 | 16% |
/// | 0.020 | 27% |
///
/// At a 64-pixel floor, 0.020 removed 7% — the same *kind* of face, the
/// large-but-soft one this gate exists for. Holding 0.020 while dropping
/// the size floor to 32 would have thrown away a quarter of the newly
/// admitted faces for being small rather than for being blurred, undoing
/// most of the point of lowering it. 0.005 removes 8% at 32, which is the
/// same job.
pub min_sharpness: f32,
}
impl Default for DetectOptions {
fn default() -> Self {
Self {
confidence: 0.5,
nms_iou: 0.4,
min_face_px: 24.0,
min_source_px: 32.0,
min_sharpness: 0.005,
}
}
}
/// One detected face, in **source image pixels**.
///
/// Pixels rather than the normalised form the catalog stores, because the
/// caller still has to crop from this image. Normalisation happens at the
/// storage boundary, where the long edge is known to be the right divisor.
#[derive(Debug, Clone, PartialEq)]
pub struct Detection {
/// `(x0, y0, x1, y1)`.
pub bbox: (f32, f32, f32, f32),
/// Five points in the detector's own order — see [`crate::align`], which
/// consumes them without reordering.
pub landmarks: [(f32, f32); 5],
pub confidence: f32,
}
impl Detection {
pub fn width(&self) -> f32 {
self.bbox.2 - self.bbox.0
}
pub fn height(&self) -> f32 {
self.bbox.3 - self.bbox.1
}
}
/// A loaded SCRFD graph.
pub struct Detector {
session: ort::session::Session,
/// Feature-map count: 3 for strides {8,16,32}, 4 for {8,16,32,64}.
///
/// Discovered from the output count rather than assumed, because both
/// exports exist and hardcoding 3 silently ignores the largest faces a
/// four-stride model finds.
fmc: usize,
}
impl Detector {
pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self, FaceError> {
let bytes = std::fs::read(path).map_err(FaceError::ModelRead)?;
Self::from_bytes(&bytes)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, FaceError> {
install_backend();
let session = ort::session::Session::builder()
.map_err(FaceError::Inference)?
.commit_from_memory(bytes)
.map_err(FaceError::Inference)?;
let n_out = session.outputs().len();
if n_out % 3 != 0 || !(9..=12).contains(&n_out) {
return Err(FaceError::WrongModel {
expected: "InsightFace SCRFD",
detail: format!("expected 9 or 12 outputs, got {n_out}"),
});
}
let fmc = n_out / 3;
// The check that actually distinguishes the models. YuNet also has
// twelve outputs in three strides, so the count proves nothing — its
// groups are cls/obj/bbox/kps where SCRFD's are score/bbox/kps, and
// decoding one as the other yields a page of plausible numbers rather
// than an error. The last dimension is what separates them.
for (group, expected_last) in [1_i64, 4, 10].into_iter().enumerate() {
for s in 0..fmc {
let idx = group * fmc + s;
let out = &session.outputs()[idx];
let last: Option<i64> = out.dtype().tensor_shape().and_then(|d| d.last().copied());
if last != Some(expected_last) {
return Err(FaceError::WrongModel {
expected: "InsightFace SCRFD",
detail: format!(
"output '{}' last dim is {:?}, expected {expected_last} \
(a YuNet export fails exactly here)",
out.name(),
last
),
});
}
}
}
Ok(Self { session, fmc })
}
/// Stride levels this graph emits.
pub fn strides(&self) -> &'static [usize] {
&ALL_STRIDES[..self.fmc]
}
/// Find the faces in an image.
///
/// `rgb` is tightly packed `f32` RGB in `0.0..=1.0`, row-major — the same
/// convention `dr-segment` and [`crate::align`] use.
pub fn detect(
&mut self,
rgb: &[f32],
width: usize,
height: usize,
options: &DetectOptions,
) -> Result<Vec<Detection>, FaceError> {
if width == 0 || height == 0 {
return Ok(Vec::new());
}
if rgb.len() != width * height * 3 {
return Err(FaceError::ImageShape {
expected: width * height * 3,
got: rgb.len(),
});
}
let lb = Letterbox::fit(width as f32, height as f32);
let input = lb.sample(rgb, width, height);
let outputs = self
.session
.run(ort::inputs![
ort::value::Tensor::from_array(input).map_err(FaceError::Inference)?
])
.map_err(FaceError::Inference)?;
let mut raw: Vec<Detection> = Vec::new();
for (si, &stride) in ALL_STRIDES[..self.fmc].iter().enumerate() {
let (_, scores) = outputs[si]
.try_extract_tensor::<f32>()
.map_err(FaceError::Inference)?;
let (_, boxes) = outputs[self.fmc + si]
.try_extract_tensor::<f32>()
.map_err(FaceError::Inference)?;
let (_, kps) = outputs[self.fmc * 2 + si]
.try_extract_tensor::<f32>()
.map_err(FaceError::Inference)?;
let fw = INPUT_EDGE / stride;
let fh = INPUT_EDGE / stride;
let s = stride as f32;
for r in 0..fh {
for c in 0..fw {
for a in 0..ANCHORS {
let idx = (r * fw + c) * ANCHORS + a;
let score = scores[idx];
if score < options.confidence {
continue;
}
// Anchor centre in input space, then distance-to-box
// decoding: the four regressed values are distances
// left/top/right/bottom in units of the stride.
let (cx, cy) = ((c * stride) as f32, (r * stride) as f32);
let b = &boxes[idx * 4..idx * 4 + 4];
let (x0, y0) = lb.into_source(cx - b[0] * s, cy - b[1] * s);
let (x1, y1) = lb.into_source(cx + b[2] * s, cy + b[3] * s);
let k = &kps[idx * 10..idx * 10 + 10];
let mut landmarks = [(0.0_f32, 0.0_f32); 5];
for (p, lm) in landmarks.iter_mut().enumerate() {
*lm = lb.into_source(cx + k[p * 2] * s, cy + k[p * 2 + 1] * s);
}
raw.push(Detection {
bbox: (x0, y0, x1, y1),
landmarks,
confidence: score,
});
}
}
}
}
let mut kept = non_max_suppress(raw, options.nms_iou);
// Size floor last, on the *merged* boxes: a face that only clears the
// floor once NMS has picked the best of its overlapping detections
// should be kept.
kept.retain(|d| d.width().min(d.height()) >= options.min_face_px);
// No cap on the count. The reference implementation keeps the ten
// largest, which is right for a film frame where background extras are
// noise; it is wrong for a photo library, where a group shot with
// thirty faces is precisely the picture worth indexing.
Ok(kept)
}
}
/// Greedy NMS across all strides together.
fn non_max_suppress(mut dets: Vec<Detection>, iou_threshold: f32) -> Vec<Detection> {
dets.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
let mut kept: Vec<Detection> = Vec::new();
for d in dets {
if kept.iter().all(|k| iou(&k.bbox, &d.bbox) <= iou_threshold) {
kept.push(d);
}
}
kept
}
fn iou(a: &(f32, f32, f32, f32), b: &(f32, f32, f32, f32)) -> f32 {
let ix = (a.2.min(b.2) - a.0.max(b.0)).max(0.0);
let iy = (a.3.min(b.3) - a.1.max(b.1)).max(0.0);
let inter = ix * iy;
let area_a = (a.2 - a.0).max(0.0) * (a.3 - a.1).max(0.0);
let area_b = (b.2 - b.0).max(0.0) * (b.3 - b.1).max(0.0);
let union = area_a + area_b - inter;
if union <= 0.0 {
0.0
} else {
inter / union
}
}
/// How the image is fitted into the graph's fixed square input.
///
/// The forward and inverse mappings live in one struct on purpose:
/// docs/faces.md §4.1 notes that what matters is not *where* the padding goes
/// but that the two agree. A mismatch offsets every box and landmark by the
/// padding, producing detections that look plausible and embeddings that
/// quietly cluster badly three stages later.
#[derive(Debug, Clone, Copy)]
struct Letterbox {
/// Input pixels per source pixel.
scale: f32,
pad_x: f32,
pad_y: f32,
}
impl Letterbox {
fn fit(w: f32, h: f32) -> Self {
let scale = (INPUT_EDGE as f32 / w).min(INPUT_EDGE as f32 / h);
Self {
scale,
pad_x: (INPUT_EDGE as f32 - w * scale) * 0.5,
pad_y: (INPUT_EDGE as f32 - h * scale) * 0.5,
}
}
/// Resample into `[1, 3, 640, 640]`, normalised as the weights expect.
///
/// `(x·255 − 127.5) / 128` — note `/128`, not `/127.5`. The reference
/// implementation this is ported from uses `/128` for both models, and
/// every measured number in docs/faces.md §1 came from it.
///
/// Padding is grey, matching the reference's `114`: the value the network
/// reads least as an edge, where black would draw a hard border across the
/// frame and invite a detection along it.
fn sample(&self, rgb: &[f32], width: usize, height: usize) -> Array4<f32> {
const PAD: f32 = 114.0;
let norm = |v: f32| (v * 255.0 - 127.5) / 128.0;
let mut input =
Array4::<f32>::from_elem((1, 3, INPUT_EDGE, INPUT_EDGE), (PAD - 127.5) / 128.0);
for iy in 0..INPUT_EDGE {
let sy = (iy as f32 + 0.5 - self.pad_y) / self.scale - 0.5;
if sy < -0.5 || sy > height as f32 - 0.5 {
continue;
}
for ix in 0..INPUT_EDGE {
let sx = (ix as f32 + 0.5 - self.pad_x) / self.scale - 0.5;
if sx < -0.5 || sx > width as f32 - 0.5 {
continue;
}
let (x0f, y0f) = (sx.floor(), sy.floor());
let (fx, fy) = (sx - x0f, sy - y0f);
let x0 = (x0f as isize).clamp(0, width as isize - 1) as usize;
let y0 = (y0f as isize).clamp(0, height as isize - 1) as usize;
let x1 = (x0 + 1).min(width - 1);
let y1 = (y0 + 1).min(height - 1);
for c in 0..3 {
let at = |x: usize, y: usize| rgb[(y * width + x) * 3 + c];
let top = at(x0, y0) * (1.0 - fx) + at(x1, y0) * fx;
let bot = at(x0, y1) * (1.0 - fx) + at(x1, y1) * fx;
input[[0, c, iy, ix]] = norm(top * (1.0 - fy) + bot * fy);
}
}
}
input
}
/// Input-space point back to source pixels.
fn into_source(self, x: f32, y: f32) -> (f32, f32) {
((x - self.pad_x) / self.scale, (y - self.pad_y) / self.scale)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn letterbox_round_trips_a_point() {
let lb = Letterbox::fit(1024.0, 683.0);
for &(x, y) in &[(0.0_f32, 0.0_f32), (512.0, 341.0), (1023.0, 682.0)] {
let (bx, by) = lb.into_source(x * lb.scale + lb.pad_x, y * lb.scale + lb.pad_y);
assert!((bx - x).abs() < 1e-2, "{bx} vs {x}");
assert!((by - y).abs() < 1e-2, "{by} vs {y}");
}
}
#[test]
fn letterbox_centres_the_short_axis() {
let lb = Letterbox::fit(640.0, 320.0);
assert!((lb.scale - 1.0).abs() < 1e-6);
assert!(lb.pad_x.abs() < 1e-6);
assert!((lb.pad_y - 160.0).abs() < 1e-6);
}
#[test]
fn nms_keeps_the_confident_box_and_drops_its_duplicate() {
let d = |x: f32, conf: f32| Detection {
bbox: (x, 0.0, x + 100.0, 100.0),
landmarks: [(0.0, 0.0); 5],
confidence: conf,
};
let kept = non_max_suppress(vec![d(0.0, 0.8), d(5.0, 0.9), d(500.0, 0.7)], 0.4);
assert_eq!(kept.len(), 2);
assert!((kept[0].confidence - 0.9).abs() < 1e-6);
assert!((kept[1].bbox.0 - 500.0).abs() < 1e-6);
}
#[test]
fn iou_of_a_box_with_itself_is_one_and_with_a_disjoint_box_is_zero() {
let a = (0.0, 0.0, 10.0, 10.0);
assert!((iou(&a, &a) - 1.0).abs() < 1e-6);
assert!(iou(&a, &(100.0, 100.0, 110.0, 110.0)) < 1e-6);
}
}
-105
View File
@@ -1,105 +0,0 @@
//! ArcFace / MobileFaceNet inference (docs/faces.md §6).
//!
//! Takes an aligned crop and returns 512 L2-normalised floats. The alignment is
//! not optional and cannot be skipped by accident: [`Embedder::embed`] takes an
//! [`Aligned112`], which only [`crate::align::warp`] can construct.
//!
//! # The graph must have a fixed batch
//!
//! `w600k_mbf.onnx` declares its batch dimension as the literal `dim_param`
//! `"None"`, and tract fails to analyse the first Conv because of it. Pinned to
//! 1 by `tools/fix-face-model-shapes.sh`, it loads and runs.
use ndarray::Array4;
use crate::align::{Aligned112, ALIGNED_EDGE};
use crate::embedding::{normalise, Embedding, ModelId, EMBEDDING_DIM};
use crate::{install_backend, FaceError};
/// A loaded ArcFace graph.
pub struct Embedder {
session: ort::session::Session,
model: ModelId,
}
impl Embedder {
pub fn from_path(path: impl AsRef<std::path::Path>, model: ModelId) -> Result<Self, FaceError> {
let bytes = std::fs::read(path).map_err(FaceError::ModelRead)?;
Self::from_bytes(&bytes, model)
}
pub fn from_bytes(bytes: &[u8], model: ModelId) -> Result<Self, FaceError> {
install_backend();
let session = ort::session::Session::builder()
.map_err(FaceError::Inference)?
.commit_from_memory(bytes)
.map_err(FaceError::Inference)?;
// One output, `[1, 512]`. Checked because an ArcFace variant with a
// different embedding width would otherwise be read as a truncated
// one, and 512 is baked into the catalog's BLOB width.
let out = session.outputs().first().ok_or(FaceError::WrongModel {
expected: "ArcFace",
detail: "model has no outputs".into(),
})?;
let last = out.dtype().tensor_shape().and_then(|d| d.last().copied());
if last != Some(EMBEDDING_DIM as i64) {
return Err(FaceError::WrongModel {
expected: "ArcFace",
detail: format!(
"output '{}' is {:?}-wide, expected {EMBEDDING_DIM}",
out.name(),
last
),
});
}
Ok(Self { session, model })
}
pub fn model(&self) -> &ModelId {
&self.model
}
/// Embed one aligned face.
pub fn embed(&mut self, face: &Aligned112) -> Result<Embedding, FaceError> {
// `(x·255 − 127.5) / 128` — see the `/128` note in `detect::Letterbox`.
let px = face.pixels();
let mut input = Array4::<f32>::zeros((1, 3, ALIGNED_EDGE, ALIGNED_EDGE));
for y in 0..ALIGNED_EDGE {
for x in 0..ALIGNED_EDGE {
for c in 0..3 {
let v = px[(y * ALIGNED_EDGE + x) * 3 + c];
input[[0, c, y, x]] = (v * 255.0 - 127.5) / 128.0;
}
}
}
let outputs = self
.session
.run(ort::inputs![
ort::value::Tensor::from_array(input).map_err(FaceError::Inference)?
])
.map_err(FaceError::Inference)?;
let (_, data) = outputs[0]
.try_extract_tensor::<f32>()
.map_err(FaceError::Inference)?;
if data.len() < EMBEDDING_DIM {
return Err(FaceError::WrongModel {
expected: "ArcFace",
detail: format!("got {} values, expected {EMBEDDING_DIM}", data.len()),
});
}
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
v.copy_from_slice(&data[..EMBEDDING_DIM]);
normalise(&mut v);
Ok(Embedding {
model: self.model.clone(),
v,
})
}
}
-240
View File
@@ -1,240 +0,0 @@
//! What an embedder produces, and how it is stored (docs/faces.md §6).
//!
//! Deliberately **model-free**: the vector, its identity, its comparison and
//! its storage encoding are arithmetic, and `calibrate` and `cluster` are built
//! on them. Keeping them out of the `inference` feature is what lets the part
//! of this subsystem most likely to be subtly wrong be tested on a machine with
//! no weights on it.
//!
//! [`crate::embed::Embedder`] is the thing that needs a model, and it lives
//! behind the feature.
/// Embedding dimensionality. Fixed by the model family, not a parameter.
pub const EMBEDDING_DIM: usize = 512;
/// Which model produced an embedding.
///
/// Embeddings from different models are not comparable, and this is the one
/// mistake that produces plausible-looking garbage rather than an error — so
/// the id travels *with* the vector rather than beside it, and
/// [`Embedding::cosine`] refuses a cross-model comparison.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ModelId(pub std::sync::Arc<str>);
impl ModelId {
pub fn new(s: impl Into<std::sync::Arc<str>>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ModelId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// A 512-d L2-normalised face embedding.
#[derive(Debug, Clone, PartialEq)]
pub struct Embedding {
pub model: ModelId,
pub v: Box<[f32; EMBEDDING_DIM]>,
}
impl Embedding {
/// Cosine similarity, which for unit vectors is the plain dot product.
///
/// `None` when the two came from different models. That is a real
/// possibility in a library indexed across a model upgrade, and the
/// alternative — returning a number — is the failure mode
/// `faces.model_id` exists to prevent.
pub fn cosine(&self, other: &Embedding) -> Option<f32> {
if self.model != other.model {
return None;
}
Some(dot(&self.v, &other.v))
}
/// Storage form: `512 × f16`, 1 KB per face (catalog.md §10.1).
pub fn to_f16_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(EMBEDDING_DIM * 2);
for &x in self.v.iter() {
out.extend_from_slice(&f32_to_f16_bits(x).to_le_bytes());
}
out
}
/// Read back from storage, re-normalising.
///
/// The f16 round-trip perturbs a unit vector by ~1e-3 in cosine — three
/// orders below the separation between a match and a non-match — but the
/// drift is free to remove and invisible if left, so it is removed here
/// rather than remembered at every call site.
pub fn from_f16_bytes(model: ModelId, bytes: &[u8]) -> Option<Self> {
if bytes.len() != EMBEDDING_DIM * 2 {
return None;
}
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
for (i, chunk) in bytes.chunks_exact(2).enumerate() {
v[i] = f16_bits_to_f32(u16::from_le_bytes([chunk[0], chunk[1]]));
}
normalise(&mut v);
Some(Self { model, v })
}
}
fn dot(a: &[f32; EMBEDDING_DIM], b: &[f32; EMBEDDING_DIM]) -> f32 {
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
pub(crate) fn normalise(v: &mut [f32; EMBEDDING_DIM]) {
// Clamped rather than checked: a zero-norm embedding is a broken model,
// not a runtime condition worth an error path, and dividing by 1e-6 keeps
// the NaN out of the catalog.
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-6);
for x in v.iter_mut() {
*x /= norm;
}
}
// ── f16 ───────────────────────────────────────────────────────────────────
//
// Hand-rolled rather than pulling in `half`: two functions over a format that
// has not changed since 2008, used at exactly one boundary. The dependency
// policy (D13, D1) makes the bar for a new crate high, and this is well under
// it.
fn f32_to_f16_bits(x: f32) -> u16 {
let bits = x.to_bits();
let sign = ((bits >> 16) & 0x8000) as u16;
let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
let mant = bits & 0x007f_ffff;
if exp >= 0x1f {
// Overflow, inf, or NaN. Embeddings are unit-norm so this is the
// broken-model path; infinity is the honest answer, not a clamp that
// hides it.
return sign
| 0x7c00
| if mant != 0 && exp == 0x1f + 112 {
0x200
} else {
0
};
}
if exp <= 0 {
// Subnormal or underflow. A component of a unit 512-vector is ~0.04,
// nowhere near here, so this branch exists for correctness rather than
// for traffic.
if exp < -10 {
return sign;
}
let mant = mant | 0x0080_0000;
let shift = (14 - exp) as u32;
let half = (mant >> shift) as u16;
// Round to nearest, ties to even.
let rem = mant & ((1 << shift) - 1);
let tie = 1 << (shift - 1);
let round = u16::from(rem > tie || (rem == tie && (half & 1) == 1));
return sign | (half + round);
}
let half = ((exp as u16) << 10) | (mant >> 13) as u16;
let rem = mant & 0x1fff;
let round = u16::from(rem > 0x1000 || (rem == 0x1000 && (half & 1) == 1));
sign | (half + round)
}
fn f16_bits_to_f32(h: u16) -> f32 {
let sign = ((h & 0x8000) as u32) << 16;
let exp = ((h >> 10) & 0x1f) as u32;
let mant = (h & 0x03ff) as u32;
if exp == 0 {
if mant == 0 {
return f32::from_bits(sign);
}
// Subnormal: renormalise into f32's range.
let mut e = -1_i32;
let mut m = mant;
while m & 0x0400 == 0 {
m <<= 1;
e -= 1;
}
let m = m & 0x03ff;
return f32::from_bits(sign | (((127 - 15 + 1 + e) as u32) << 23) | (m << 13));
}
if exp == 0x1f {
return f32::from_bits(sign | 0x7f80_0000 | (mant << 13));
}
f32::from_bits(sign | ((exp + 127 - 15) << 23) | (mant << 13))
}
#[cfg(test)]
mod tests {
use super::*;
fn unit(seed: u32) -> Embedding {
let mut v = Box::new([0.0_f32; EMBEDDING_DIM]);
let mut s = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
for x in v.iter_mut() {
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
*x = (s >> 8) as f32 / (1u32 << 23) as f32 - 0.5;
}
normalise(&mut v);
Embedding {
model: ModelId::new("test"),
v,
}
}
#[test]
fn a_normalised_embedding_has_cosine_one_with_itself() {
let e = unit(7);
assert!((e.cosine(&e).unwrap() - 1.0).abs() < 1e-5);
}
#[test]
fn embeddings_from_different_models_do_not_compare() {
let a = unit(1);
let mut b = unit(1);
b.model = ModelId::new("other");
assert_eq!(
a.cosine(&b),
None,
"a cross-model cosine must not be a number"
);
}
/// The claim docs/faces.md §6 makes about the storage format: the f16
/// round-trip costs ~1e-3 of cosine, three orders below the separation
/// between a match and a non-match.
#[test]
fn f16_round_trip_preserves_the_embedding() {
for seed in 0..16 {
let e = unit(seed);
let back = Embedding::from_f16_bytes(e.model.clone(), &e.to_f16_bytes()).unwrap();
let cos = e.cosine(&back).unwrap();
assert!(cos > 0.9999, "seed {seed}: round-trip cosine {cos}");
}
}
#[test]
fn f16_round_trip_rejects_a_wrong_length_blob() {
assert!(Embedding::from_f16_bytes(ModelId::new("m"), &[0u8; 100]).is_none());
}
#[test]
fn f16_handles_the_values_an_embedding_actually_contains() {
// Components of a unit 512-vector cluster around ±1/sqrt(512) ≈ 0.044.
for &x in &[0.0_f32, 1.0, -1.0, 0.044_194_17, -0.044_194_17, 1e-3, -7e-4] {
let back = f16_bits_to_f32(f32_to_f16_bits(x));
assert!(
(back - x).abs() <= 1e-3 * x.abs().max(1e-3),
"{x} round-tripped to {back}"
);
}
}
}
-101
View File
@@ -1,101 +0,0 @@
//! Faces and identity (S14, docs/faces.md).
//!
//! Two models, run over the proxy tier, producing per face a box, five
//! landmarks, a confidence and a 512-d embedding (FR-CULL-8) — and then the
//! arithmetic that turns embeddings into people (FR-CULL-9, FR-CULL-10).
//!
//! Like `dr-segment`, this crate is **device-free**: no GPU adapter, no
//! Slint, nothing that needs a display. Unlike `dr-segment`, it carries **no
//! weights at all**, and the absence is deliberate — see [`the licence
//! note`](#the-weights-are-not-in-this-repository) below.
//!
//! # The weights are not in this repository
//!
//! The models this crate is built for — SCRFD-500MF and ArcFace/MobileFaceNet
//! — are InsightFace's, and their pretrained weights carry a **non-commercial
//! research-only** grant. That is incompatible with GPL-3.0-or-later and with
//! every channel DarkRoom ships through, so the weights cannot be committed
//! here the way `dr-segment`'s can, and there is no `embedded-model` feature
//! for a packaging script to switch on. The application obtains a model at
//! runtime; this crate takes bytes and never fetches anything.
//!
//! docs/faces.md §2 is the full reading, including what would have to change
//! for that to stop being true.
//!
//! # Why the runtime is split behind a feature
//!
//! [`calibrate`] and [`cluster`] are where this subsystem's accuracy actually
//! lives, and both are pure arithmetic over embeddings with no model in them.
//! They build and test without `inference`, on synthetic embeddings, on a
//! machine with no weights on it — which is what lets CI cover the part most
//! likely to be subtly wrong.
pub mod align;
pub mod calibrate;
pub mod cluster;
#[cfg(feature = "inference")]
pub mod detect;
#[cfg(feature = "inference")]
pub mod embed;
pub mod embedding;
pub mod naming;
pub mod neighbours;
pub use align::{warp, Aligned112, Similarity, ALIGNED_EDGE, ARCFACE_TEMPLATE};
pub use calibrate::{Calibration, Pairs, ReliabilityBand};
pub use cluster::{cluster, split, Candidate, Cluster, DEFAULT_MERGE_PROBABILITY};
#[cfg(feature = "inference")]
pub use detect::{DetectOptions, Detection, Detector};
#[cfg(feature = "inference")]
pub use embed::Embedder;
pub use embedding::{Embedding, ModelId, EMBEDDING_DIM};
pub use naming::{name_for_instance, name_instances, NamedFace};
/// What can go wrong between an image and a face.
#[derive(Debug, thiserror::Error)]
pub enum FaceError {
#[error("could not read model file: {0}")]
ModelRead(#[source] std::io::Error),
#[cfg(feature = "inference")]
#[error("inference failed: {0}")]
Inference(#[source] ort::Error),
/// The graph is not the one this decoder was written for.
///
/// Worth a distinct variant rather than a generic failure: the models in
/// this space have interchangeable *shapes* and incompatible *layouts*
/// (a YuNet export also has twelve outputs), so the failure this catches
/// is not a crash but a page of plausible numbers.
#[error("model does not look like {expected}: {detail}")]
WrongModel {
expected: &'static str,
detail: String,
},
#[error("image buffer is {got} floats, expected {expected} (RGB, three per pixel)")]
ImageShape { expected: usize, got: usize },
}
/// Install tract as `ort`'s backend.
///
/// Idempotent, and it must happen before any other `ort` call: with
/// `alternative-backend` there is no linked runtime to fall back on, so an
/// un-set API is a panic rather than a slow path. Same helper as
/// `dr-segment::semantic`, for the same reason.
#[cfg(feature = "inference")]
pub(crate) fn install_backend() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let _ = ort::set_api(ort_tract::api());
});
}
/// [`install_backend`] for the M1 probe example, which drives `ort` directly
/// rather than through [`detect::Detector`] so it can report the raw error.
#[cfg(feature = "inference")]
#[doc(hidden)]
pub fn install_backend_for_probe() {
install_backend();
}
-302
View File
@@ -1,302 +0,0 @@
//! Naming a segmented person from the face inside it.
//!
//! `dr-segment` recognises *a person*; this crate recognises *which* person.
//! Putting the two together costs one containment test and turns "person" in
//! the mask list into "Anna" — which is the difference between a vocabulary of
//! eighty COCO classes and a vocabulary that includes the user's family.
//!
//! Pure geometry: no model, no catalog, no Slint. The caller supplies boxes and
//! names from wherever it keeps them.
//!
//! # Why containment and not overlap
//!
//! A face is a small part of the person it belongs to, and it is *inside* them.
//! Intersection-over-union would be near zero for a correct match — the face is
//! perhaps a twentieth of the person's area — so IoU is the wrong measure
//! entirely here and would reject every true pairing.
/// A face box with a name attached.
#[derive(Debug, Clone, PartialEq)]
pub struct NamedFace<'a> {
/// `(x0, y0, x1, y1)`, in the same space as the instance boxes.
pub bbox: (f32, f32, f32, f32),
pub name: &'a str,
}
/// How much of a face must lie inside an instance to belong to it.
///
/// Not 1.0: the detector's face box and the segmenter's person box come from
/// different models and disagree at the edges, most visibly around hair and
/// chin. A face 85% inside a person is that person's face.
const MIN_CONTAINMENT: f32 = 0.7;
/// The name to show for one segmented instance, if a face identifies it.
///
/// `None` leaves the instance labelled as the model found it. That is the right
/// default in every uncertain case: a mask list saying "person" is merely
/// unhelpful, where one saying "Anna" about her brother is wrong, and the user
/// has no way to tell which they are looking at.
///
/// Where several named faces sit inside one instance — two people the segmenter
/// merged into one blob — the largest face wins, on the grounds that it is the
/// nearer subject and the one the box is mostly about. If two are within a
/// whisker of each other the instance stays unnamed, because at that point the
/// box genuinely covers two people and picking either is a coin toss.
pub fn name_for_instance<'a>(
instance: (f32, f32, f32, f32),
faces: &[NamedFace<'a>],
) -> Option<&'a str> {
let instance_area = area(instance);
if instance_area <= 0.0 {
return None;
}
let mut candidates: Vec<(f32, &'a str)> = faces
.iter()
.filter_map(|f| {
let fa = area(f.bbox);
if fa <= 0.0 {
return None;
}
let inside = intersection(instance, f.bbox);
if inside / fa < MIN_CONTAINMENT {
return None;
}
Some((fa, f.name))
})
.collect();
if candidates.is_empty() {
return None;
}
candidates.sort_by(|a, b| b.0.total_cmp(&a.0));
// Two comparably sized faces in one box: the segmenter has merged two
// people and there is no honest way to pick. Distinct names only — the
// same person detected twice (a mirror, a reflection) is not ambiguous.
if let [(first, a), (second, b), ..] = candidates.as_slice() {
if a != b && *second > *first * 0.8 {
return None;
}
}
Some(candidates[0].1)
}
/// Relabel a list of instances, in place, from the faces found in the image.
///
/// `is_person` decides which classes are eligible. Only person-like classes
/// should be: a face inside a `tv` or a `laptop` is a photograph of someone on
/// a screen, and renaming the television to "Anna" would be worse than leaving
/// it alone.
///
/// Returns how many instances gained a name.
pub fn name_instances<T>(
instances: &mut [T],
faces: &[NamedFace<'_>],
bbox_of: impl Fn(&T) -> (f32, f32, f32, f32),
is_person: impl Fn(&T) -> bool,
set_name: impl Fn(&mut T, &str),
) -> usize {
let mut named = 0;
for inst in instances.iter_mut() {
if !is_person(inst) {
continue;
}
if let Some(name) = name_for_instance(bbox_of(inst), faces) {
let name = name.to_string();
set_name(inst, &name);
named += 1;
}
}
named
}
fn area(b: (f32, f32, f32, f32)) -> f32 {
((b.2 - b.0).max(0.0)) * ((b.3 - b.1).max(0.0))
}
fn intersection(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 {
let w = (a.2.min(b.2) - a.0.max(b.0)).max(0.0);
let h = (a.3.min(b.3) - a.1.max(b.1)).max(0.0);
w * h
}
#[cfg(test)]
mod tests {
use super::*;
/// A person filling most of a portrait, with their face near the top.
const PERSON: (f32, f32, f32, f32) = (100.0, 50.0, 400.0, 900.0);
const FACE: (f32, f32, f32, f32) = (200.0, 80.0, 300.0, 220.0);
fn named(bbox: (f32, f32, f32, f32), name: &str) -> NamedFace<'_> {
NamedFace { bbox, name }
}
#[test]
fn a_face_inside_a_person_names_them() {
let faces = [named(FACE, "Anna")];
assert_eq!(name_for_instance(PERSON, &faces), Some("Anna"));
}
#[test]
fn a_face_elsewhere_in_the_frame_names_nothing() {
let faces = [named((800.0, 80.0, 900.0, 220.0), "Anna")];
assert_eq!(name_for_instance(PERSON, &faces), None);
}
/// The measure has to be containment. A correct pairing has an IoU near
/// zero, so anything IoU-based would reject every true match.
#[test]
fn a_tiny_face_in_a_large_person_still_matches() {
let tall = (0.0, 0.0, 500.0, 2000.0);
let small = (240.0, 40.0, 280.0, 100.0);
assert_eq!(
name_for_instance(tall, &[named(small, "Anna")]),
Some("Anna")
);
}
#[test]
fn a_face_mostly_outside_the_person_is_not_theirs() {
// Overlapping the person's edge, but only just.
let straddling = (60.0, 80.0, 140.0, 220.0);
assert_eq!(
name_for_instance(PERSON, &[named(straddling, "Anna")]),
None
);
}
/// A tight portrait, where the segmenter's "person" is head and shoulders
/// and the face is most of it.
///
/// This **is** named, and an earlier version of this module wrongly
/// refused to on the grounds that a face filling its instance meant the
/// two models disagreed. It does not: it means the photograph is a
/// close-up, which is the case where naming the region is most useful and
/// most certain. Left as a test because the reasoning is easy to get
/// backwards a second time.
#[test]
fn a_tight_portrait_is_named_rather_than_treated_as_suspicious() {
let head = (100.0, 50.0, 400.0, 400.0);
let face = (110.0, 60.0, 390.0, 390.0);
assert_eq!(
name_for_instance(head, &[named(face, "Anna")]),
Some("Anna")
);
}
/// A face box *larger* than the instance is a genuine disagreement, and
/// containment rejects it without needing a size rule: most of the face
/// lies outside the box it is supposed to belong to.
#[test]
fn a_face_larger_than_the_instance_does_not_name_it() {
let small_instance = (200.0, 200.0, 260.0, 260.0);
let huge_face = (100.0, 100.0, 500.0, 500.0);
assert_eq!(
name_for_instance(small_instance, &[named(huge_face, "Anna")]),
None
);
}
/// Two people merged into one blob: naming either would be a coin toss,
/// and a mask list saying "Anna" about her brother is worse than one
/// saying "person".
#[test]
fn two_comparable_faces_in_one_instance_leave_it_unnamed() {
let wide = (0.0, 0.0, 800.0, 900.0);
let faces = [
named((100.0, 80.0, 200.0, 220.0), "Anna"),
named((500.0, 85.0, 605.0, 230.0), "Bob"),
];
assert_eq!(name_for_instance(wide, &faces), None);
}
/// But a clearly nearer subject wins: the box is mostly about them.
#[test]
fn a_much_larger_face_wins_over_someone_in_the_background() {
let wide = (0.0, 0.0, 800.0, 900.0);
let faces = [
named((100.0, 80.0, 300.0, 360.0), "Anna"),
named((600.0, 85.0, 640.0, 140.0), "distant"),
];
assert_eq!(name_for_instance(wide, &faces), Some("Anna"));
}
/// The same person found twice — a mirror, a reflection — is not ambiguous
/// even though the two faces are comparable.
#[test]
fn the_same_name_twice_is_not_an_ambiguity() {
let wide = (0.0, 0.0, 800.0, 900.0);
let faces = [
named((100.0, 80.0, 200.0, 220.0), "Anna"),
named((500.0, 85.0, 605.0, 230.0), "Anna"),
];
assert_eq!(name_for_instance(wide, &faces), Some("Anna"));
}
#[test]
fn degenerate_boxes_name_nothing_rather_than_panicking() {
assert_eq!(
name_for_instance((0.0, 0.0, 0.0, 0.0), &[named(FACE, "A")]),
None
);
assert_eq!(
name_for_instance(PERSON, &[named((5.0, 5.0, 5.0, 5.0), "A")]),
None
);
assert_eq!(name_for_instance(PERSON, &[]), None);
}
#[derive(Debug, PartialEq)]
struct Inst {
class: String,
bbox: (f32, f32, f32, f32),
}
#[test]
fn only_person_instances_are_renamed() {
let mut instances = vec![
Inst {
class: "person".into(),
bbox: PERSON,
},
// A face on a screen must not rename the television.
Inst {
class: "tv".into(),
bbox: PERSON,
},
];
let faces = [named(FACE, "Anna")];
let n = name_instances(
&mut instances,
&faces,
|i| i.bbox,
|i| i.class == "person",
|i, name| i.class = name.to_string(),
);
assert_eq!(n, 1);
assert_eq!(instances[0].class, "Anna");
assert_eq!(instances[1].class, "tv");
}
#[test]
fn an_unrecognised_person_keeps_the_models_own_label() {
let mut instances = vec![Inst {
class: "person".into(),
bbox: PERSON,
}];
let n = name_instances(
&mut instances,
&[],
|i| i.bbox,
|i| i.class == "person",
|i, name| i.class = name.to_string(),
);
assert_eq!(n, 0);
assert_eq!(instances[0].class, "person");
}
}
-566
View File
@@ -1,566 +0,0 @@
//! TRACES: FR-CULL-9 | FR-CULL-10
//! Finding the face pairs that could possibly be the same person.
//!
//! [`crate::cluster`] used to begin by computing every pairwise cosine and
//! holding the lot as an `n²` matrix of `f32`. At 1,800 faces that is 13 MB,
//! which is why it survived; at 25,000 it is 2.5 GB, which is why it could not
//! keep surviving.
//!
//! Almost all of that matrix is thrown away unread. Clustering only ever asks
//! whether a pair is *above* the merge threshold, and in a real library the
//! answer is no for well over 99% of pairs — the reference library's 1,813
//! faces produced 7,875 qualifying pairs out of 1.6 million. So this module
//! answers the only question that is actually asked — **which pairs clear the
//! bar** — and returns that sparse list. Memory goes from `O(n²)` to `O(edges)`
//! and the caller never has to hold a matrix at all.
//!
//! # Exact, not approximate
//!
//! The usual way to make this fast is an approximate nearest-neighbour index,
//! which trades recall for speed: it *misses* some true neighbours, and a
//! missed neighbour here is a face that silently never joins its person.
//! Nothing surfaces that — the screen just quietly shows one person as two —
//! so it is a poor trade for a feature whose whole job is to be trusted. This
//! module is exact, and the clusters it produces are identical to those from a
//! full scan.
//!
//! # An exact index was tried, measured, and removed
//!
//! Worth recording so it is not rediscovered as a good idea. The obvious exact
//! index is IVF with a triangle-inequality bound: group the embeddings into
//! cells, and skip a whole cell **pair** when the geometry proves no member of
//! one can reach any member of the other. On the sphere,
//!
//! ```text
//! angle(x, y) >= angle(c_P, c_Q) - radius(P) - radius(Q)
//! ```
//!
//! so a cell pair is impossible when `cos` of that lower bound falls below the
//! threshold. Exact, no recall loss, and it prunes beautifully on synthetic
//! clusters.
//!
//! It prunes **nothing at all** on real face embeddings. Measured over the
//! 1,813-face reference library, at √n = 43 cells:
//!
//! | quantity | measured |
//! |---|---|
//! | median pair angle | 88.5° (cosine 0.026) |
//! | merge threshold | 66.2° (cosine 0.403) |
//! | median cell radius | 80.4° |
//! | median centroid separation | 85.0° |
//! | cell pairs surviving the bound | **946 of 946 — 100%** |
//!
//! The arithmetic is not close. For the bound to exclude a typical cell pair it
//! needs `radius(P) + radius(Q) < 85° - 66° = 19°`, so cells of radius under
//! ~10°. But two photographs of the *same person* sit 36–60° apart, so even a
//! perfect single-identity cell has a radius three times too large. No
//! ball-based partition of this space can have cells tight enough for the
//! inequality to bite — 512-d embeddings are near-orthogonal, and that is the
//! curse of dimensionality doing exactly what it says.
//!
//! So the scan stayed exhaustive, and the effort went where it actually pays:
//! not materialising the matrix, an unrolled dot product, and spreading the
//! blocks across cores. That is `O(n²)` time and `O(edges)` memory, which for
//! this problem is the honest answer.
use crate::calibrate::Calibration;
/// Rows of the similarity triangle handed to one thread at a time.
///
/// Small enough that the tail of the triangle divides evenly across cores —
/// row `i` does `n - i` comparisons, so equal *row counts* are very unequal
/// work — and large enough that the per-block overhead disappears.
const BLOCK: usize = 64;
/// Below this many faces, do the whole thing on the calling thread.
///
/// Spawning threads for a set this small costs more than the scan.
const THREADS_ABOVE: usize = 2048;
/// One face pair that clears the merge threshold.
///
/// `i < j` always, and the probability is carried because the caller would
/// otherwise recompute the sigmoid it took a dot product to reach.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Pair {
pub i: usize,
pub j: usize,
pub probability: f32,
}
/// What the caller has to tell us about each face.
///
/// Deliberately not [`crate::cluster::Candidate`]: this module has no business
/// knowing what a person or a photograph is, and taking the three arrays it
/// actually reads keeps it testable on bare vectors.
pub struct Faces<'a> {
/// L2-normalised, `EMBEDDING_DIM` long, one per face.
pub embeddings: &'a [Vec<f32>],
/// Source pixels across the aligned crop, for the calibration's size term.
pub crop_px: &'a [f32],
/// Which photograph each face came from. Two faces in one frame are not
/// the same person, so those pairs are never returned (docs/faces.md §9).
pub images: &'a [u64],
}
/// Every pair whose calibrated probability reaches `min_probability`.
///
/// Excludes pairs from the same photograph, which the clusterer would refuse
/// anyway — dropping them here keeps them out of the graph the caller builds
/// and out of the connected components it derives from it.
///
/// Ordered by `(i, j)`, which is what the caller's determinism rests on.
pub fn above_threshold(faces: &Faces, cal: &Calibration, min_probability: f32) -> Vec<Pair> {
let n = faces.embeddings.len();
if n < 2 {
return Vec::new();
}
// The loosest cosine that could clear the bar for *any* pair in the set.
// Cheaper than the sigmoid by far, and it rejects almost everything.
let tau = loosest_cosine(faces.crop_px, cal, min_probability);
let blocks: Vec<(usize, usize)> = (0..n)
.step_by(BLOCK)
.map(|start| (start, (start + BLOCK).min(n)))
.collect();
if n < THREADS_ABOVE {
let mut out = Vec::new();
for &(from, to) in &blocks {
scan_block(faces, cal, min_probability, tau, from, to, &mut out);
}
return out;
}
// One worker per core bar one. This runs on a background thread behind a
// button the user pressed, and NFR-ARCH-2 puts it behind the UI: taking
// every core would stall the window it is reporting progress to.
let workers = std::thread::available_parallelism()
.map(|p| p.get().saturating_sub(1).max(1))
.unwrap_or(1)
.min(blocks.len());
let next = std::sync::atomic::AtomicUsize::new(0);
let mut parts: Vec<Vec<Vec<Pair>>> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..workers)
.map(|_| {
let next = &next;
let blocks = &blocks;
scope.spawn(move || {
// Results stay tagged with their block index, so the order
// of the output does not depend on which thread got there
// first. Determinism is a promise this module keeps.
let mut mine: Vec<(usize, Vec<Pair>)> = Vec::new();
loop {
let b = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let Some(&(from, to)) = blocks.get(b) else {
break;
};
let mut out = Vec::new();
scan_block(faces, cal, min_probability, tau, from, to, &mut out);
mine.push((b, out));
}
mine
})
})
.collect();
let mut slots: Vec<Vec<Vec<Pair>>> = vec![Vec::new(); blocks.len()];
for h in handles {
for (b, pairs) in h.join().unwrap_or_default() {
slots[b].push(pairs);
}
}
slots
});
let mut out = Vec::new();
for slot in &mut parts {
for pairs in slot.drain(..) {
out.extend(pairs);
}
}
out
}
/// Compare rows `from..to` against everything after them.
///
/// The upper triangle, split by rows. Row `i` only looks at `j > i`, so every
/// unordered pair is visited exactly once and the emitted order is `(i, j)`
/// ascending within the block.
fn scan_block(
faces: &Faces,
cal: &Calibration,
min_probability: f32,
tau: f32,
from: usize,
to: usize,
out: &mut Vec<Pair>,
) {
let n = faces.embeddings.len();
for i in from..to {
let a = &faces.embeddings[i];
let crop_a = faces.crop_px[i];
let image_a = faces.images[i];
for j in i + 1..n {
if image_a == faces.images[j] {
continue;
}
let cos = dot(a, &faces.embeddings[j]);
// The cheap rejection, and it takes well over 99% of pairs.
if cos < tau {
continue;
}
let probability = cal.probability(cos, crop_a.min(faces.crop_px[j]), 0.0);
if probability >= min_probability {
out.push(Pair { i, j, probability });
}
}
}
}
/// The lowest cosine that could yield `min_probability` for any pair in the set.
///
/// The calibration is `sigmoid(a·cos + b + w_size·log2(crop))`, so for a fixed
/// size term the cosine boundary is exact. The size term is *not* fixed — it
/// varies per pair with the smaller of the two faces — so the safe bound uses
/// whichever face size pushes the boundary lowest: the largest face when
/// `w_size` is positive, the smallest when it is negative.
///
/// Returns [`f32::NEG_INFINITY`] — a filter that rejects nothing — where no
/// boundary exists: a non-positive steepness, for which probability does not
/// increase with cosine, or a threshold at the ends of the sigmoid. Those are
/// degenerate calibrations rather than impossible ones, and the right response
/// is to stop pruning, not to guess.
fn loosest_cosine(crop_px: &[f32], cal: &Calibration, min_probability: f32) -> f32 {
if cal.a <= 0.0 || !(min_probability > 0.0 && min_probability < 1.0) {
return f32::NEG_INFINITY;
}
let (mut lo, mut hi) = (f32::INFINITY, 0.0_f32);
for &c in crop_px {
let c = c.max(1.0);
lo = lo.min(c);
hi = hi.max(c);
}
if !lo.is_finite() {
return f32::NEG_INFINITY;
}
let extreme = if cal.w_size >= 0.0 { hi } else { lo };
let tau = cal.boundary_at(min_probability, extreme, 0.0);
if tau.is_nan() {
return f32::NEG_INFINITY;
}
// Cosines never exceed 1, so a boundary above it legitimately rejects
// everything. Clamped rather than left free so the comparison stays cheap.
tau.min(1.0)
}
/// Cosine of two L2-normalised embeddings.
///
/// Eight accumulators rather than one. Floating-point addition is not
/// associative, so the compiler may not re-associate a single running total and
/// the loop serialises on the adder's latency; eight independent chains give it
/// something to pipeline and vectorise. The order is fixed and identical on
/// every run, which is what the caller's determinism needs — it is a different
/// order from the naive sum, not a variable one.
pub(crate) fn dot(a: &[f32], b: &[f32]) -> f32 {
const LANES: usize = 8;
let mut acc = [0.0_f32; LANES];
let chunks = a.len() / LANES;
for c in 0..chunks {
let base = c * LANES;
for (l, slot) in acc.iter_mut().enumerate() {
*slot += a[base + l] * b[base + l];
}
}
let mut total =
((acc[0] + acc[1]) + (acc[2] + acc[3])) + ((acc[4] + acc[5]) + (acc[6] + acc[7]));
for k in chunks * LANES..a.len() {
total += a[k] * b[k];
}
total
}
#[cfg(test)]
mod tests {
use super::*;
const DIM: usize = 64;
fn cal() -> Calibration {
Calibration {
a: 30.0,
b: -30.0 * 0.35,
w_size: 0.0,
valid: true,
positive_pairs: 1000,
negative_pairs: 10_000,
}
}
/// A unit vector in a reproducible pseudo-random direction.
///
/// Hashed from the seed rather than drawn from an RNG, so a failure is
/// reproducible from the test alone.
fn vector(seed: u64) -> Vec<f32> {
let mut s = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
let mut v = Vec::with_capacity(DIM);
for _ in 0..DIM {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
v.push(((s >> 11) as f64 / (1u64 << 53) as f64) as f32 - 0.5);
}
normalise(v)
}
fn normalise(mut v: Vec<f32>) -> Vec<f32> {
let n = v.iter().map(|x| x * x).sum::<f32>().sqrt();
for x in &mut v {
*x /= n;
}
v
}
/// A vector a known cosine away from `base`.
fn near(base: &[f32], other: &[f32], cosine: f32) -> Vec<f32> {
let d = dot(base, other);
let mut perp: Vec<f32> = other.iter().zip(base).map(|(o, b)| o - d * b).collect();
let n = perp.iter().map(|x| x * x).sum::<f32>().sqrt();
for x in &mut perp {
*x /= n;
}
let s = (1.0 - cosine * cosine).max(0.0).sqrt();
normalise(
base.iter()
.zip(&perp)
.map(|(b, p)| cosine * b + s * p)
.collect(),
)
}
struct Set {
embeddings: Vec<Vec<f32>>,
crop_px: Vec<f32>,
images: Vec<u64>,
}
impl Set {
fn faces(&self) -> Faces<'_> {
Faces {
embeddings: &self.embeddings,
crop_px: &self.crop_px,
images: &self.images,
}
}
}
/// `groups` identities, `per` faces each, every face in its own photograph.
fn population(groups: usize, per: usize, tightness: f32) -> Set {
let mut embeddings = Vec::new();
let mut images = Vec::new();
let mut image = 0u64;
for g in 0..groups {
let base = vector(g as u64 + 1);
let off = vector(g as u64 + 9_999);
for m in 0..per {
embeddings.push(if m == 0 {
base.clone()
} else {
near(&base, &off, tightness)
});
images.push(image);
image += 1;
}
}
let crop_px = vec![150.0; embeddings.len()];
Set {
embeddings,
crop_px,
images,
}
}
/// The unpruned, unthreaded, unblocked definition of the answer.
fn reference(faces: &Faces, cal: &Calibration, min_probability: f32) -> Vec<Pair> {
let n = faces.embeddings.len();
let mut out = Vec::new();
for i in 0..n {
for j in i + 1..n {
if faces.images[i] == faces.images[j] {
continue;
}
let cos: f32 = faces.embeddings[i]
.iter()
.zip(&faces.embeddings[j])
.map(|(x, y)| x * y)
.sum();
let p = cal.probability(cos, faces.crop_px[i].min(faces.crop_px[j]), 0.0);
if p >= min_probability {
out.push(Pair {
i,
j,
probability: p,
});
}
}
}
out
}
fn same_pairs(a: &[Pair], b: &[Pair]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.i == y.i && x.j == y.j)
}
#[test]
fn nothing_to_pair_is_no_pairs() {
let s = population(1, 1, 0.9);
assert!(above_threshold(&s.faces(), &cal(), 0.9).is_empty());
}
#[test]
fn the_blocked_scan_finds_exactly_what_the_reference_does() {
let s = population(60, 8, 0.97);
let f = s.faces();
let got = above_threshold(&f, &cal(), 0.9);
let want = reference(&f, &cal(), 0.9);
assert!(!want.is_empty(), "the reference found nothing to check");
assert!(same_pairs(&got, &want), "{} vs {}", got.len(), want.len());
}
/// Past `THREADS_ABOVE` the work is split across cores and stitched back
/// together, and the stitching is where an order bug would live.
#[test]
fn the_threaded_scan_finds_exactly_what_the_reference_does() {
let s = population(300, 8, 0.97);
assert!(
s.embeddings.len() > THREADS_ABOVE,
"population is below the threading cutoff"
);
let f = s.faces();
let got = above_threshold(&f, &cal(), 0.9);
let want = reference(&f, &cal(), 0.9);
assert!(!want.is_empty());
assert!(same_pairs(&got, &want), "{} vs {}", got.len(), want.len());
}
/// The size term moves the cosine boundary per pair, so the pre-filter has
/// to be built from the most permissive size in the set or it will drop a
/// pair that would have qualified.
#[test]
fn a_size_weighted_calibration_still_matches_the_reference() {
let mut s = population(60, 8, 0.97);
for (i, c) in s.crop_px.iter_mut().enumerate() {
*c = 40.0 + (i % 17) as f32 * 30.0;
}
let sized = Calibration {
w_size: 0.5,
b: -30.0 * 0.35 - 0.5 * 7.0,
..cal()
};
let f = s.faces();
assert!(same_pairs(
&above_threshold(&f, &sized, 0.9),
&reference(&f, &sized, 0.9)
));
}
/// A negative size weight flips which extreme is permissive. Cheap to get
/// wrong and silent when it is, so it gets its own case.
#[test]
fn a_negative_size_weight_prunes_from_the_other_end() {
let mut s = population(60, 8, 0.97);
for (i, c) in s.crop_px.iter_mut().enumerate() {
*c = 40.0 + (i % 17) as f32 * 30.0;
}
let sized = Calibration {
w_size: -0.5,
b: -30.0 * 0.35 + 0.5 * 7.0,
..cal()
};
let f = s.faces();
assert!(same_pairs(
&above_threshold(&f, &sized, 0.9),
&reference(&f, &sized, 0.9)
));
}
/// A degenerate calibration has no cosine boundary to prune against, and
/// must stop pruning rather than prune on a bound that does not hold.
#[test]
fn a_flat_calibration_prunes_nothing_and_still_agrees() {
let flat = Calibration {
a: 0.0,
b: 4.0,
..cal()
};
assert_eq!(loosest_cosine(&[150.0], &flat, 0.9), f32::NEG_INFINITY);
let s = population(20, 6, 0.97);
let f = s.faces();
assert!(same_pairs(
&above_threshold(&f, &flat, 0.9),
&reference(&f, &flat, 0.9)
));
}
#[test]
fn two_faces_in_one_photograph_are_never_paired() {
let mut s = population(1, 2, 1.0);
s.images = vec![7, 7];
assert!(above_threshold(&s.faces(), &cal(), 0.9).is_empty());
}
#[test]
fn pairs_come_back_in_index_order() {
let s = population(300, 8, 0.97);
let pairs = above_threshold(&s.faces(), &cal(), 0.9);
assert!(pairs
.windows(2)
.all(|w| (w[0].i, w[0].j) < (w[1].i, w[1].j)));
assert!(pairs.iter().all(|p| p.i < p.j));
}
/// Threads must not make the answer depend on which one finished first.
#[test]
fn the_same_input_yields_the_same_pairs() {
let s = population(300, 8, 0.97);
assert_eq!(
above_threshold(&s.faces(), &cal(), 0.9),
above_threshold(&s.faces(), &cal(), 0.9)
);
}
/// The unrolled dot has to agree with the obvious one, tail included — the
/// lengths here are deliberately not multiples of the lane count.
#[test]
fn the_unrolled_dot_matches_the_naive_one() {
for len in [1usize, 7, 8, 9, 63, 64, 65, 512] {
let a: Vec<f32> = (0..len).map(|i| (i as f32 * 0.37).sin()).collect();
let b: Vec<f32> = (0..len).map(|i| (i as f32 * 0.11).cos()).collect();
let naive: f32 = a.iter().zip(&b).map(|(x, y)| x * y).sum();
assert!(
(dot(&a, &b) - naive).abs() < 1e-4,
"len {len}: {} vs {naive}",
dot(&a, &b)
);
}
}
/// The pre-filter is the whole speed story, so it is worth asserting it
/// actually rejects the bulk of the population rather than trusting it to.
#[test]
fn the_threshold_filter_rejects_almost_everything() {
let s = population(60, 8, 0.97);
let n = s.embeddings.len();
let total = n * (n - 1) / 2;
let kept = above_threshold(&s.faces(), &cal(), 0.9).len();
assert!(
kept * 20 < total,
"kept {kept} of {total} pairs, which is not sparse"
);
}
}
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "dr-film"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
# Isolated from dr-pipeline for the same reason dr-lens is: that crate has no
# dependencies so its codegen stays testable without a device (ARCH §6.5a), and
# a YAML parser plus the stock profiles do not belong in it. The pipeline
# consumes the baked tables this crate produces and never links the profiles.
#
# No wgpu dependency either, deliberately. What comes out of here is plain
# `f32` data with a documented layout; deciding it is a 3D texture is dr-gpu's
# job, and keeping that decision out of here is what lets the whole spectral
# model be tested on the CPU.
[dependencies]
log.workspace = true
serde.workspace = true
serde_norway.workspace = true
-76
View File
@@ -1,76 +0,0 @@
# Film stocks
One file per stock in [`profiles/`](profiles/). Adding a stock is adding a
file — no code change, no shader, no new operation — for the same reason
`dr-decode`'s base curves work that way: under the GPLv3 a stock should be
contributable without a release.
## What a profile is
Three measured tables, all of them published in the manufacturer's datasheet:
| Field | What it decides |
|---|---|
| `log_sensitivity` | what each emulsion layer *sees*, per wavelength |
| `density_curves` | contrast, latitude, and where the stock clips |
| `dye_density` | what the developed stock *looks* like, per wavelength |
| `base_density` | the support: film base, and a colour negative's orange mask |
Plus `kind` (negative or positive), `support` (film or paper), and the two
illuminants the data is referenced to. A print paper is a stock like any
other; `support` exists so an interface can offer papers separately, not
because the renderer treats them differently.
## Why it is not a LUT
Because the parameters stay physical. Opening up a stop moves the picture along
the film's own characteristic curve — toe, shoulder and all — instead of
scaling a number somebody baked at one exposure. A scanned negative comes out
orange and inverted because that is what a negative *is*, and it becomes a
photograph when a paper profile prints it, exactly as it would in a darkroom.
The data cost runs the other way from a LUT collection too: a stock is about
17 kB of measurements, where one HaldCLUT is roughly 800 kB of one person's
grade.
## How it runs
The spectral chain reduces to three tables, and the reduction is exact where it
matters — see [`src/bake.rs`](src/bake.rs) for the argument:
1. **A 3×3 matrix**, linear sRGB to the three layers' exposure. Exact, not an
approximation: the reconstructed scene spectrum is linear in the sRGB
triple, so the integral collapses into nine numbers.
2. **Three 1D curves**, log exposure to density, sampled at 256 points.
3. **One 32³ lookup**, density to linear sRGB — dye absorption, the print
through the negative, the paper, the viewing illuminant and the chromatic
adaptation, all of which take exactly three numbers in.
Per pixel that is a matrix multiply, three curve taps and one texture fetch.
Splitting 2 from 3, rather than baking one LUT over exposure, is measured
rather than assumed: the curve carries all the sharp shape and the dye mixing
is smooth, so folding the curve into the 3D lookup would need it three times
larger for the same error. At 32³ the worst interpolation error is about 0.003
in linear sRGB, below one 8-bit code value, and there is a test that says so.
## Adding a stock
If spektrafilm has it, add its name to `STOCKS` in
[`tools/film-profiles/convert.py`](../../tools/film-profiles/convert.py) and
re-run it. Otherwise write the YAML by hand from the datasheet; the loader
validates the table lengths and says which file and field is wrong.
Either way, list it in `BUILT_IN` in [`src/lib.rs`](src/lib.rs) to compile it
in — or drop it in the profile directory at runtime, which is the path meant
for stocks that ship separately from the binary.
## Provenance
The shipped profiles are converted from
[spektrafilm](https://github.com/andreavolpato/spektrafilm) by Andrea Volpato,
licensed CC BY-SA 4.0. See [`profiles/LICENSE-PROFILES.txt`](profiles/LICENSE-PROFILES.txt)
for the licence and [`profiles/CHANGELOG.txt`](profiles/CHANGELOG.txt) for what
the conversion changed and what it deliberately did not.
The sRGB reflectance basis is Mallett & Yuksel (2019); the observer is the CIE
1931 2°.
-74
View File
@@ -1,74 +0,0 @@
Changes made to the spektrafilm profiles shipped in this directory
=================================================================
The profiles here are derived from spektrafilm by Andrea Volpato
(https://github.com/andreavolpato/spektrafilm), licensed CC BY-SA 4.0. The
full licence is in LICENSE-PROFILES.txt and is reproduced unchanged.
CC BY-SA 4.0 section 3(a)(1)(B) requires that a modified copy say it was
modified. It was. This file says how, and tools/film-profiles/convert.py
performs the modification, so it can be re-run against upstream and the result
compared rather than taken on trust.
What was changed
----------------
1. Format. Upstream ships JSON; these are YAML, so that adding or correcting a
stock is editing a legible file rather than a minified one. No value is
altered by the reformat.
2. Trimmed to the fields this renderer reads:
kept info.*, data.wavelengths (implicitly, as the fixed grid),
data.log_sensitivity, data.channel_density (renamed
dye_density), data.base_density, data.log_exposure (kept as its
two endpoints, since it is uniformly sampled),
data.density_curves
dropped data.density_curves_model - a 3-CDF fit of the curves; the
sampled curves are shipped
instead, and reproduce it to
0.004 density
data.density_curves_layers - per-sublayer curves, used for
grain, which is not implemented
yet. Worth restoring when it is:
real grain is per sublayer.
data.hanatos2025_adaptation_* - parameters for a spectral
upsampling method this renderer
does not use; see below
data.midscale_neutral_density - null in every profile shipped
Dropping fields loses nothing for the stocks shipped, but it does mean a
re-run of the converter is needed to pick up an upstream field later.
3. Numbers are written at 6 significant figures (5 for the density curves).
The inputs are digitised datasheet curves, so this is well inside their
measurement error; it is what takes a profile from 207 kB to 17 kB.
4. Nulls made explicit. Upstream uses null where a datasheet has no reading.
In log_sensitivity that means the layer is blind there, written here as the
sentinel -9; in the density tables it means no absorption, written as 0.
What was NOT changed
--------------------
No measured value has been rescaled, shifted, smoothed or refitted. The
renderer's own calibration conventions - mid-grey at 0.184, exposure
normalised on the green layer - are taken from spektrafilm's reference
implementation rather than invented, because the profile data is calibrated
against them.
Known deviation from upstream's rendering
-----------------------------------------
Upstream reconstructs a spectrum from an RGB triple with Hanatos (2025), which
needs a 4 MB coefficient table. This renderer uses the Mallett & Yuksel (2019)
sRGB basis instead, which is three curves and about 1 kB, at some cost in how
faithfully very saturated and out-of-gamut colours are handled. The
hanatos2025_adaptation_* parameters in the upstream profiles are therefore
unused here. This is a deliberate trade of accuracy at the gamut edge against
shipping four megabytes, and it is the first thing to revisit if saturated
colours look wrong.

Some files were not shown because too many files have changed in this diff Show More