docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.
Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
`inference::init` listed the models from the user's shared directory
alone, while the app loads them from there or from the package's
`/usr/share/darkroom/models`. On a fresh package install the probe found
"no model to probe with", stayed on the CPU, and compiled nothing. Both
now resolve each file with the same search, `library::shared_model`.
The PKGBUILD names the ONNX Runtime packages as optional dependencies,
since the app loads one from /usr/lib if present.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sargsyan et al., ICCV 2023; MIT code and weights (models/LICENCE.md),
exported by tools/export-migan.sh at a fixed 1×4×512×512 from the
authors' checkpoint — six operator types, 28 MB, in LFS like the rest.
The package installs it beside the scene model and the APK unpacks it
with the others.
2d106det for the eye contours, OCEC for open or closed, SGC for
sunglasses — all three pinned to a batch of one by the same script as
the pair, and installed by every packager so the eyes-open filter works
out of the box. The two classifiers are MIT, code and weights; the
README records their provenance, SGC's undocumented training set, and
the hashes as fetched and as shipped.
faces.md §12.3 measured what the cheapest detector costs: the small
faces in every group shot, and a dog embedded a dozen times. Which
trade is right depends on the machine doing the sweep — a desktop left
overnight and a tablet on a battery want different answers — so the
detector is now a per-device setting, Fast / Balanced / Thorough on
the settings page beside the indexing button, persisted with the rest
of the settings file.
A detector is half of a model id. Every face, marker, shard and
calibration is keyed on faces.model_id precisely so that a model change
is a new id and a re-index rather than a silent change under existing
data, and a detector change is a model change: it decides which faces
exist and where the landmarks that align them land. So each choice
names its own pipeline. 500M keeps the bare "w600k_mbf" every existing
library was written under, so an upgrade disturbs nothing; the others
are qualified. Choosing one restarts coverage from zero under the new
id, the sweep re-detects, confirmed names carry across by box overlap,
and the sync shards are keyed by the same id so a peer on another
setting neither adopts nor pollutes them. The library controller
carries the id into the sync the same way it carries the cache budget,
because the sync starts from places that have no settings in reach.
All three shape-fixed exports ship — APK, Arch, Flatpak — since a
tablet has no other way to obtain the one it was not installed with;
the APK grows by twenty megabytes for the choice.
A bug fix release. Nothing here changes what DarkRoom is for since 0.10.0;
it changes how often it does the thing it already claimed to do.
The edits that went missing. A subject or category layer was stored as
identity alone, on the reasoning that the pixels were reproducible by
re-running the model — true, but nothing re-runs one except a photographer
pressing "find subjects". So a reopened photograph rendered without its
local adjustments and then saved that state back, and a batch export wrote
three hundred files without the edits their photographer had made, over a
log warning. The coverage now travels in the sidecar. Export also learned
to write the photograph rather than the canvas, and to carry the
photograph's header when the export is made from develop.
Face indexing was reading previews. It ran against a proxy and then
recorded the result as though it had seen the photograph, so faces smaller
than the proxy could resolve were not missed, they were *concluded absent*.
Indexing now runs on the native render, runs made against proxies too small
to find a face are forgotten rather than trusted, and a sweep that fails
everything says so instead of reporting a clean pass.
Where you were. The photo roll opens on the frame it opened with, develop
returns you to the photograph you were editing, the grid keeps its place
when another screen covers it, and the photographer's position now travels
between devices rather than being rediscovered on each.
Startup. The catalog opens on a worker and the bundled models unpack on
one, so a launch is no longer a page-by-page read on the way to the first
frame; the app says it is starting before there is anything to say it with.
The People rail builds the rows you can see, keeps portraits off the
blocking path, and withholds the empty groups that used to fill it.
Segmentation gained the half it was missing: the colour gate decided what
belonged to a category and had no way to decide where its edge fell, so a
refined sky kept the model's blocky outline no matter how the control was
set. A marker-based watershed now puts each contour onto a real edge, and
the refinement is a per-layer slider.
Coverage 70.4% -> 70.6% (127/180).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three waves of work since 0.9.0.
Culling gained the two instruments FR-CULL-3 asked for and never had:
focus peaking, and a histogram that reads the sensor rather than the
frame about to be displayed. Bursts group themselves. Masks can cover a
whole category rather than one instance.
The catalog now notices when it has been damaged and offers a way back --
restore a backup, or rebuild from the photographs, which were never at
risk. A crash leaves a record. A log survives the process, on a path a
tablet will hand back to adb, which is what makes the Android work
debuggable at all.
The APK compiles its own Java for the first time, so the app can receive
a photograph from another application and hand one back. Memory pressure
is answered in a stated order. A lost library root is reported rather
than reported as an empty library.
There is a benchmark suite now, so §8's promise that a regression fails
the build is a mechanism rather than a sentence. Its first run says the
catalog opens in 70ms against a 2s budget, and that thumbnail throughput
does not obviously reach its target.
Coverage 59.8% -> 69.8%, and it means more than it did: five requirements
that were tagged on code that did not implement them are no longer, and
the tool no longer counts its own test fixtures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The decoder can load from a path; nothing yet put a file at one. Four
packaging routes, and one lookup that finds the result.
## Not `include_bytes!`, unlike the instance model
The instance model is 11 MB and compiled in, which was the right call for
it: Android hands the app no filesystem path (ARCH §6.9) and 11 MB is
tolerable. The scene model is 24 MB, and 35 MB of constants in the binary
is paid by every install whether or not the tab is ever opened.
So it follows `models/face/` instead — carried as an APK asset, unpacked
once at first launch into the shared directory a desktop install already
uses, after which every lookup finds it where it finds a desktop user's.
Assets are stored rather than deflated in the APK, so unpacking is a copy
rather than an inflate.
`embedded-scene-model` exists for the desktop build with nowhere else to
read from, and for tests wanting the real graph. Off by default, which is
the asymmetry with `embedded-model` and the reason for a separate
feature.
## Three files, all or none
`scene_model` insists on the graph, its vocabulary and the category
descriptor together, for the reason `face_models` insists on its pair: a
graph alone decodes to 150 anonymous channels. Reporting the set missing
beats starting and failing at the first inference.
## The two model sets are not the same kind of thing
`install_bundled_models` now carries both, and the distinction is worth
keeping in view. Face weights are absent from the repository *by design*
— the InsightFace grant is research-only (docs/faces.md §2) — so a build
carrying none is ordinary. The scene model is committed, so a build
carrying none means a checkout without `git lfs pull`.
Neither is fatal. A photo editor that refuses to start over a missing
grading feature is worse than one that starts without it, so both report
themselves unavailable exactly as face indexing already did.
The LFS-pointer guards apply to the `.onnx` only. The vocabulary and the
descriptor are legitimately a few kilobytes, and a size check that fails
on them would be a guard against the wrong thing.
`scene_model` is exported ahead of the tab that will consume it so the
packaging added here has something to be verified against — assets
written where no lookup looks would be a silent mistake for as long as
the tab took to arrive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A desktop entry gives a software centre a name and a one-line Comment, and
nothing else — no description, no licence, no age rating, no statement of what
hardware the interface was laid out for. GNOME Software and Discover both show
an application with no metainfo as an unexplained icon, and both packages this
repository produces were in that position.
packaging/paris.tourolle.darkroom.metainfo.xml is the AppStream component, and
it is installed by the PKGBUILD as well as by the Flatpak manifest because the
description, the licence fields and the OARS rating are facts about the
application rather than about how it was packaged. Writing it twice is how the
two packages start disagreeing.
Two things in it are easy to get wrong and are commented in place. The two
licence fields differ on purpose: metadata_license covers the file itself and
has to permit the unconditional redistribution and reformatting a catalogue
does, which GPLv3 does not, so it is CC0-1.0; project_license is the
application's own and reads GPL-3.0-or-later to match D8. And the component id
is not a fifth name but the same string as the desktop basename, the Flatpak
application id, and the app_id dr_ui::run sets — a rename that misses one costs
the icon or the association, and neither failure announces itself.
D15's decision that the target devices are a tablet and a desktop is stated as
a display_length requirement rather than left implicit, so a software centre
does not offer this on hardware where the photograph and the parameter panel
cannot both be on screen.
Validates clean under appstream-util validate-relax; appstreamcli --pedantic
reports only that the gitea URLs are unreachable from a machine that cannot
see that host.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thirty-five commits since 0.8.0, and two of them are the reason this is a
minor rather than a patch.
**Storage became pluggable.** A folder backend now sits beside the
Nextcloud one behind the same seam, proved by a test rather than by a
trait, and the launch screen offers three routes into a library instead
of one.
**Faces gained a confidence that means something.** A suggestion is
scored against the people the user has actually named, the curve it came
from is stated rather than implied, and a regroup runs roughly three
times faster — the similarity scan and the merge engine both use the
machine's own SIMD kernel now, measured on the tablet where the NEON path
is the one that runs.
Alongside those, the framing tools got the quality-of-life pass this
release is named for: a crop can be held to a ratio, a straighten crops
away the corners it exposed, an export can be pinned to an exact
resolution with the common panel sizes offered as buttons, and dragging
the crop rectangle no longer tracks the pointer at half speed.
`pkgrel` returns to 1: a new `pkgver` is a new archive name, so there is
nothing left for a release number to disambiguate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A hundred commits since 0.7.0, and the face subsystem in them is not a set of
fixes — it is the difference between a feature that was shipped and one that
works.
Clustering finished at all for the first time: the old agglomeration rescanned
every live pair and recomputed average link from scratch after every merge, and
on a real library it did not return. A sparse above-threshold graph, connected
components and Lance-Williams sums put 1,813 faces at 0.28s, and the merge
threshold moved to 0.80 because the old 0.90 was measured — not guessed — to
leave a third of the library ungrouped.
Faces are no longer indexed at any size or any sharpness. Both floors were
measured over the real library with `face_index --quality`: 32 source pixels
across the aligned crop, and a contrast-invariant sharpness that catches the
large-but-blurred face whose confident, wrong embedding used to weld two people
together.
The crop is cut once and kept, so the People screen is no longer a derivative of
a thumbnail cache entitled to evict anything at any moment. The screen itself
became usable: the faces wrap into a grid instead of running off the edge, the
header fits a phone, a group can be set aside, and a person's photographs are a
button away — as a union or an intersection of several people.
And face sync now reaches the other device. Re-indexed images re-export, shards
written before the crop and index-time columns are repaired rather than failing
every insert, people and the user's judgements about them cross the wire at all,
and the pass says what it is doing while it does it.
The Android versionCode follows without being restated — package.sh packs
MAJOR*10000 + MINOR*100 + PATCH, so 0.8.0 is 800, above the 700 already on
devices and therefore an upgrade rather than a refusal. `pkgrel` returns to 1,
since this is a new version rather than a rebuild of the last one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`makepkg -si` reported "A package has already been built, installing existing
package" and installed 0.7.0-1 — the archive from before the models were added,
so the install still had no model and the app still said so. The version had not
changed because the application had not changed; only what the package contains
did, which is precisely what pkgrel exists to signal.
Verified: 0.7.0-2 carries both models at /usr/share/darkroom/models/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Android bundling landed the weights under that platform's asset directory,
which was the wrong home the moment a second packager wanted them. `makepkg -si`
produced a desktop install with no model at all — the same "no face model is
installed" the phone used to show, for the same reason: nothing put the files
anywhere the app looks.
So `models/face/` at the root is the one copy, and both packagers read it:
assemble-apk.sh bundles it as APK assets, and the PKGBUILD installs it to
/usr/share/darkroom/models. Both refuse an LFS pointer rather than shipping a
130-byte file that fails inside the graph loader on a user's machine.
`face_models` now searches three places, most specific first: the account's own
directory, the shared user directory, then $XDG_DATA_DIRS. So a packaged pair is
found automatically and a pair the user placed by hand still outranks it — which
is what keeps a deliberate choice of weights from being overridden by an
upgrade.
$XDG_DATA_DIRS rather than a hard-coded /usr/share: that is the variable a
distribution, a prefix install or a Nix-style store already sets to say where
its data went, and its documented default is exactly the two paths that would
otherwise have been hard-coded. Empty on Android, which has no such directories
— there the APK's copy is unpacked into the shared user directory instead,
because an asset inside a package is not a path anything can read from.
Verified: the APK still carries both models at assets/models/, the PKGBUILD
parses and installs from the new path, 467 tests pass.
Includes the pkgver 0.6.0 → 0.7.0 bump that was already sitting uncommitted in
the working tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Set by tools/set-version.sh, which is the only thing that should. The
workspace, the pacman package and — through Cargo.toml at link time — the
APK all state 0.6.0, so a bug report naming a version names one commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Set by tools/set-version.sh, which is the only thing that should. The
workspace, the pacman package and — through Cargo.toml at link time — the
APK all state 0.5.0, so a bug report naming a version names one commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workspace read 0.1.0 for two releases, so every desktop binary reported a
version two releases stale. The APK was worse: `AndroidManifest.xml` states no
version at all, so a device showed `versionName=null` and `versionCode=0` while
the library inside the APK knew exactly what it was. A version edited by hand
in several files is a version that is wrong in at least one of them.
`tools/set-version.sh` is now the only thing that sets one. It takes the
version from the latest git tag, or is told, and writes the two files that must
state it before anything is built: the workspace `Cargo.toml`, from which every
crate inherits, and `packaging/PKGBUILD`, which pacman reads before a build
exists. It refreshes `Cargo.lock`, because members appear there by version and
CI builds `--locked`. `--commit` commits the result.
Android is not in that list on purpose. `package.sh` reads the version out of
`Cargo.toml` and hands it to `aapt2 link`, so the APK cannot drift from the
binary it contains — there is no third file to forget. `versionCode` has to be
one increasing integer, which a semantic version is not, so it is packed as
MAJOR*10000 + MINOR*100 + PATCH: ordered the way Android requires, and readable
at a glance.
A version that is not MAJOR.MINOR.PATCH is refused rather than coerced. It is
a contract with whoever reads a bug report, and silently turning "0.4" into
something else is worse than being asked to type it again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The icon was already embedded and had been all along: `app.slint` sets it and
`build.rs` compiles it into the binary with `EmbedFiles`. That is not what a
Wayland compositor looks at. It ignores a client-set icon entirely, matches
the surface's `app_id` against installed `.desktop` files, and takes the icon
from the one whose basename agrees. Nothing set an app id and no desktop entry
existed, so GNOME had nothing to resolve and drew the placeholder.
Both halves are needed and neither substitutes for the other: the embedded
icon is what X11 and the window itself use, and the desktop entry is what the
overview, the dash and alt-tab use.
The app id, the `.desktop` basename and the installed icon's filename are one
string in three places — `paris.tourolle.darkroom`, the same reverse-DNS name
the Android manifest already uses, because it is one application. Change one
without the others and the icon disappears again with nothing logged, so each
file says so where the string appears.
The PKGBUILD builds from the checkout rather than a release tarball, so
`makepkg -si` installs what is being worked on. Install verified by inspection
of the built package: binary, desktop entry, and the icon under
hicolor/256x256 by the name the entry asks for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>