Seven names are two or three live people on both devices: Ian (756 confirmed faces, and a second Ian with none), Jessie three times, Claudine, Mathias, Noemi, Pascal and PJ. Each was typed on its own device and carried across by sync, which keys people on their uuid and so keeps both. Each half of a person shows half their photographs. dedup_people::run, in one transaction: - Same-name people (trimmed, case-folded as the Identity screen folds them) merge into the one with the most confirmed faces, ties to the smaller uuid, through faces::merge_people_within, so confirmations, rejections and the survivor's name are kept. A person holding no faces at all merges: there is nothing to compare or to carry. Anyone else needs >= 2 confirmed faces per shared embedder on both sides and centroids at cosine >= 0.7 in each. A face confirmed as one and rejected as the other keeps them apart. Unnamed and set-aside people are never merged by name. - Faces held twice (one image, one embedder, IoU >= 0.5, cosine >= 0.7) keep the stronger detector's row (FaceDetector::outranks), then the confirmed one, then the older. The survivor takes the confirmed assignment and both rows' rejections. A pair confirmed as two different people is left and counted. - Judgements still on a merged-away person move to the person at the end of its redirects, and a redirect cycle (two devices merging one pair in opposite directions) is broken at the smaller uuid. Measured on copies of the desktop catalog and the tablet's server snapshot, w600k_mbf, confirmed faces only: - Centroids of differently named people: 2,699 pairs, median 0.02, 99.9th percentile 0.41. One pair reaches 0.70 (0.700 desktop, 0.705 tablet), "Michelle Casanonve" and "Michelle Casanova", one person typed two ways. Next is 0.62/0.64, "Boris Jost" and "Boris". The highest pair that is plainly two people is 0.43/0.44. - One person split in random halves: minimum 0.69, median 0.91 over 72 people. Four faces against twenty-two reach 0.7 in 97% of draws. One face against twenty of somebody else's reached 0.74 in 3,000 draws, and two faces reached 0.61, hence the two-face minimum. - Pascal (22 and 4 confirmed) is at 0.57 and PJ (14 and 7) at 0.50, under 0.7 on both devices, so both pairs stay apart and are logged. The desktop's second Ian holds 4 suggestions and no confirmations, at 0.38 against Ian's centroid, and stays apart. On the tablet it holds nothing and merges. Why a merge made here survives a peer on 0.17.0: the merged-away person stays as a merged_into redirect with a bumped revision, which the catalog merge has always taken on revision. The peer hides the duplicate and never sends it back as a live person. Its own confirmations of that person stay on the redirect, because a merge never overwrites a local confirmation. The manual merge has always left them there too. They follow the redirect when the peer runs this job. A test syncs two catalog files through the previous merge code and back, and the people converge and stay converged. Once a catalog is clean the job reads 80 redirects, the named people, and the face boxes from the covering faces_box index. That is ~10 ms on the reference library. There is no schema change. The index is created IF NOT EXISTS, as the merge already does.
Documentation
Two audiences, two folders. Most people want the first table and never the second.
Using DarkRoom
| The manual | Every feature, pictured from the application itself — opening a library, rating, labelling and filing, duplicate originals, developing, local masks, repair, film, presets, panoramas, export and albums. The application carries it and opens it from Help and from Settings |
| How it is driven | Every gesture and shortcut, by screen. Generated from the code, so it cannot describe one the application does not have. The same list is the in-app help sheet: Help or F1 in the grid, ? or F1 in develop |
The top-level README says what DarkRoom is, how to get it on each platform, and what is still missing.
Changing DarkRoom
Everything under dev/ is for someone working on the code. Start with
CONTRIBUTING.md, which says how to land a first change
without reading the rest.
The register and the record. What must be built, how it is built, and how far along it is.
| requirements.md | What the software must do — the numbered register, the decisions (D-numbers) and the spikes (S-numbers) |
| architecture.md | How it is built — crates, the GPU pipeline, the data model, sync |
| traceability.md | Generated: which requirement is claimed by which file. Never edited by hand |
| outstanding.md | What is specified and not built, and whether that is a decision or a gap |
| technical-debt.md | Compromises taken deliberately, each with the condition that retires it |
| code-health.md | What a contribution costs, per seam, measured |
Designs, one per subsystem. Each is the specification the code was built to, kept current as the code moved.
| catalog.md | The index, the library view, incremental scan, the job queue |
| storage.md | Storage backends: the seam a folder, a sync client and a Nextcloud account share |
| faces.md | Face detection, identity, clustering and the eye-state models |
| segmentation.md | How the application finds the regions a local mask snaps to |
| mask-editing.md | Painting, erasing and combining masks |
| spot-removal.md | Clone and heal as parameters in the edit graph |
| panorama.md | Alignment, projection, the chunked composite and the border fill |
| inference.md | The neural runtime and model chosen per device, with the measurements |
| display-and-extension.md | The display contract, and why the fused pipeline is already most of a plugin format |
| view-composition.md | A controller for the display layer |
| ui-navigation.md | Finding things in the interface once there are many |
Measurements. Numbers committed so a regression is a diff rather than a recollection.
| benchmarks.md | The per-commit suite: what it covers, what it does not, how to read a failure |
| bench-baseline.json | The committed numbers the suite checks against |
| frame-budget.md | What a frame costs on each device, and the decision those figures settled |
Platforms and distribution.
| distribution.md | Which channels v1 targets and what each one constrains |
| windows.md | The Windows installer, cross-built from the Linux CI |
| android-signing.md | Which key signs the APK, and keeping it |
Archive. Kept as the record of what was asked for, not as plans.
| milestone-v0.1.md | The first milestone, delivered 2026-08-30 and superseded |
| ui-refinement.md | How the interface should look; succeeded by ui-navigation.md |
Conventions
Three files here are generated and must not be edited by hand:
gestures.md, dev/traceability.md and manual/index.html, the page the
packages install, rendered from manual/README.md. All three come from
cargo run -p traceability, the pre-commit hook keeps them in step with the
tree, and CI fails when one is not what the tree generates. The manual's
pictures are recorded by tools/manual and live
in LFS.
A design document links to the requirements it satisfies and to the code
that satisfies them. When the code moves, the link moves with it; a document
that has stopped being true goes to dev/archive/ with a note saying what
replaced it, rather than being deleted.