Compare commits

...
33 Commits
Author SHA1 Message Date
dtourolle 301e6f3828 Release 0.13.3
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m21s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 46s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 39s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 2m21s
Build and test / Windows (x86_64, cross) (push) Failing after 3m8s
2026-09-20 13:01:41 +02:00
dtourolle a437363bd6 Schema V20: put the mis-spelled run markers right
The markers the previous commit stops writing are already in the
catalogs — 2 on the desktop, 429 on the tablet — and in the shards
both have exchanged. Renaming them to the faces' own id with a fresh
time is what makes the export send each image again, under an entry
newer than the empty one `held_model` would otherwise pick. Where the
old write had inserted its marker beside the right one, the wrong one
goes and the right one is refreshed for the same reason: its entry in
the shards is older than the empty one.

Images V14 left with faces and no marker are not touched. That state is
the quality pass's cue, and the fixed write marks them correctly when
it reaches them.

Checked against copies of both real catalogs: the desktop renames 2,
the tablet deletes 429, both in under 200 ms.
2026-09-20 13:00:59 +02:00
dtourolle 065872bec5 Keep a run marker under the detector that found the faces
`record_updates` — the write behind the quality, eye and crop passes —
re-marked the image as indexed under the pipeline the pass ran as, and
left the faces it had updated under the id of the detector that found
them. On a desktop set to Thorough that put `scrfd_10g+w600k_mbf` over
faces spelled `w600k_mbf`; on the tablet, `scrfd_10g_i8+w600k_mbf` over
faces it had adopted from the desktop's thorough pass.

Every reader takes the marker and the faces to agree. `marker_under`
reads the marker as the detector having examined the image, so the
upgrade repair never revisits it. The shard store keys each face by
its pipeline id, so `export_to_shards` selects an image's faces by the
marker's id, finds none, and sends an entry that says the thorough
detector looked and found nothing — over photographs with named faces
on them. The desktop's shard index holds 54 such entries beside real
faces; the tablet's eye pass over the faces it had adopted made 430
more, and both devices have exchanged them. `held_model` takes the
newest entry for an image, which is the empty one. Nothing has been
lost yet only because the two spellings of the thorough detector rank
equal and neither side adopts the other's; a third device, or either
one after a reinstall, would adopt "nothing here" for 484 images. And
the desktop's eye pass is 4,739 images from doing the same to every
face from before V14 — which are the ones that only exist on the
desktop, and would then never reach anywhere.

The marker now takes the id the faces carry; the pass's own id is used
only when it dropped the last of them and there is no detector left to
name. A stale marker under another spelling of the same embedder is
removed in the same transaction, so one embedder has one marker.
2026-09-20 13:00:58 +02:00
dtourolle 0ed38ada28 Adopt a peer's unmeasured faces instead of refusing them
The tablet showed a fraction of each person: 681 of the desktop's 3,851
confirmations, and none of Ian's 746, Catherine's 626 or my own 480.
Every face that existed on both devices agreed on who it was, and the
people rows were identical — the merge was fine. The missing 3,170
confirmations were on faces the tablet did not hold at all: the
desktop's 16,080 faces from the original detector, on 4,310 images,
detected before schema V14 kept the quality reading.

Those faces were in shards the tablet had already downloaded, in
August's export. `import_from_shards` looked at them on every sync pass
and declined each one, because a face without a quality reading was
"work this device cannot finish": adopting it would write the run
marker, and the marker was what stopped an image being looked at again.
That was true when it was written and has not been since the quality
repair existed — that pass lists its work by `f.quality IS NULL`, not by
the marker, exactly as the eye pass does, and faces without an eye
reading were already adopted on that reasoning.

The refusal had no exit. V14 had deleted the markers of every image
holding such faces so the quality pass would find them, and
`export_to_shards` walks the markers, so the desktop never re-exported
them either; the unmeasured August copies were the only ones there
would ever be. The tablet's answer was to queue all 17,727 images for a
re-detection of its own, a fetch of the whole library, while holding
the faces on disk.

Adopt them. The receiving device's quality pass measures them when it
reaches them, and the desktop's confirmations match onto them by box
overlap on the next catalog merge. The test that asserted the refusal
now asserts the adoption and that the image is still owed to the pass.
2026-09-20 12:58:41 +02:00
dtourolle 695d5ec304 Correct four claims in the README against the tree
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m26s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 46s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 40s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / windows-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 2m21s
Build and test / Windows (x86_64, cross) (push) Failing after 3m5s
Eighteen declared operations, not fifteen; JPEG XL is an export format;
the grid does not filter by keyword, only the catalog's query can; and a
panorama's provenance is a sidecar beside the composite, not a history
step in it.
2026-09-20 11:57:17 +02:00
dtourolle ef1afc254d Release 0.13.2
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m36s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 46s
Build and test / Layer separation (push) Successful in 34s
Traceability / Requirement traces (push) Failing after 40s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / windows-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 2m26s
Build and test / Windows (x86_64, cross) (push) Failing after 3m7s
2026-09-20 11:06:54 +02:00
dtourolle 103c6e7fc0 Rewrite the README for someone arriving, not someone already here
Benchmarks / CPU and I/O (per commit) (push) Failing after 30s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 53s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 41s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 2m25s
Build and test / Windows (x86_64, cross) (push) Failing after 3m11s
It said 0.9.0 against a 0.13.1 tree, listed focus peaking and burst
grouping as unbuilt when both have shipped, and opened with a page of
prose about the display path before saying what the application does.
Lead with what it is and a picture of it, say how to get it on each
platform and what state each channel is in, keep the honest account of
what is missing, and put the manual first in the documentation table.
2026-09-20 11:06:10 +02:00
dtourolle e9398de9c1 Ship the fine-tuned border filler: MI-GAN 512 trained on projection-shaped voids from the maintainer's library 2026-09-20 10:56:38 +02:00
dtourolle b1c99b5796 Let the fill show the model an open void: mirror depth 0 means no ring and nothing known beyond the band 2026-09-20 10:56:38 +02:00
dtourolle 3de109fbd1 Write down the catalog and screen-refresh patterns the Identity fixes exposed
A CLAUDE.md at the root, for anyone changing this code: the ways a redraw
and a catalog read came to cost half a second per click, what each fix
looked like, and how to measure the next one against a copy of a real
catalog.
2026-09-20 10:56:37 +02:00
dtourolle 388bda6af3 Count the outstanding repairs from the faces, on partial indexes
"How many images still owe a quality reading" was a correlated EXISTS per
image over `faces`, and the face row is 8 KB of embedding and crop before
the column it looks at, so each count opened every row. Six such counts
run on every open of the Identity screen and at the end of every sweep:
160 ms on the reference library.

V19 adds three partial indexes holding only the faces still owing each
pass, keyed on the image and carrying the model id the predicate reads,
and replaces `faces_image` with `(image_id, model_id)` so "does this image
hold this embedder's faces" is answered from the index too. The planner
takes a partial index when the count is driven from `faces` and ignores it
inside the EXISTS, so `Needs::Face` carries the per-face fragment and
`repairs::count` spells the query from the faces' side; the list and the
per-image check keep the EXISTS. A test holds the two spellings to the
same answer for every repair.
2026-09-20 10:56:37 +02:00
dtourolle 73845d8a77 Ask the server about a collection once per job, not once per file
Every `move_to` guaranteed its destination's parent with a `MKCOL` for
each ancestor down from the account root, and a trash folder under a
library root several levels deep meant three round trips answering
`405 Method Not Allowed` before the one `MOVE` that did anything — for
every image of a delete, on a connection built for that job.

The backend now records the collections it has confirmed exist and asks
about each once. It lives for one job, so a folder another client removes
mid-batch is the one case this misses, and the `MOVE` then reports the
`409` rather than hiding it.
2026-09-20 10:56:37 +02:00
dtourolle b4821ee1ab Filter the people rail in the query, and count the unassigned faces
`faces::people` grouped `face_person` after a LEFT JOIN over every person
and sorted the lot by name; the rail then discarded the empty, unnamed
groups a regrouping pass leaves behind — 17,000 of 19,000 rows on the
reference library. `people_in_use` filters them in the WHERE and joins
`people` to face counts aggregated first (2,000 groups), so the sort sees
only the rows that will be drawn. `count_unassigned` replaces fetching
2,400 ids to take their length. `load_people` 22 ms → 10 ms.
2026-09-20 10:56:36 +02:00
dtourolle 9d1aa5735b Confirm a group, and split one, in one transaction
`confirm_all` called `faces::confirm` per face, and `split_off` called
`reject` then `confirm` per face: each opens and commits its own
transaction, so a click on a group of several hundred was several hundred
commits. `faces::confirm_all` is two statements — clear the rejections the
confirmations override, then flip the rows — and `faces::reassign` does a
split's reject-and-confirm for every face under one commit. 16 ms → 2 ms
and 22 ms → 4 ms on the largest group.
2026-09-20 10:56:36 +02:00
dtourolle 97a854833d Reuse the face grid's decoded crops across a redraw
A confirm or a reject changes one row and redraws the whole grid, and the
redraw re-read every crop blob of the selected person (4 MB for the
largest) and decoded every one — 316 ms per click on the reference
library's 754-face person, to arrive at the pixels already on screen.

`load_faces` now takes the crops the previous load decoded, keyed by face,
and moves each into its new cell; the blob read is skipped when every face
is already in hand. `refresh` drains the old cells into it rather than
cloning them. The redraw is 2.6 ms.
2026-09-20 10:56:36 +02:00
dtourolle e0e193efb4 Do not recount face coverage on every confirm, and count it without listing
Every click on the Identity screen's face grid — confirm, reject, split,
rename, merge — redrew the whole screen, and the redraw recomputed the
coverage line. That line lists every repair's outstanding images to count
them: six scans of the images table with a correlated EXISTS over the
8 KB face rows, an ORDER BY the job's visiting order, a Target with its
path per row, and a thumbnail-index query per image with faces. On the
reference library (24k images, 19k faces) that was ~200 ms of the
~540 ms each click cost, spent computing a figure a confirm cannot change.

`refresh` now takes what changed: `Changed::Identities` re-reads the rail
and the grid and leaves the coverage line alone; `Changed::Library` — an
open, a sweep ending or stopped, the face data deleted — re-reads it too.

For the times it does run, `repairs::counts` counts instead of building
and dropping the lists, and the thumbnail store is read once
(`ThumbStore::held`) rather than probed once per image in the audit, the
outstanding list and the proxy repair.

`identity_bench` is the measurement: the reads a click performs and the
batch writes, timed against a copy of a real catalog.
2026-09-20 10:56:36 +02:00
dtourolle d790961b28 Add the manual: every feature pictured from the application itself
Benchmarks / CPU and I/O (per commit) (push) Failing after 30s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 47s
Build and test / Layer separation (push) Successful in 27s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 4s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Traceability / Requirement traces (push) Failing after 38s
Build and test / Android (aarch64) (push) Failing after 2m24s
Build and test / Windows (x86_64, cross) (push) Failing after 3m21s
docs/manual/README.md is a tour for a photographer opening DarkRoom for
the first time — one picture per thing, moving where movement is the
point. tools/manual/ is how the pictures are made: drive.py puppeteers the
desktop build on a private Xvfb (launch, click, drag, type, screenshot,
record), scenes.py is each picture as a script, and record.sh runs them
all over a folder and writes the results into docs/manual/media/.

The media is in LFS, with the CI pulls excluding it as they exclude the
fixtures; a screenshot changes wholesale when the interface does.

Nothing in the pictures shows a person, by design: the demo library is
seventy urban and alpine frames, chosen from the catalog's rows that face
detection found nobody in.

The traceability matrix is regenerated here after the rebase that
brought this branch up to master.
2026-09-20 00:26:00 +02:00
dtourolle 14ac41bee0 Give the keyword sheet's field focus, and the empty trash its own words
Without focus the keys typed into the keywords sheet went to the grid
behind the scrim, and the Return meant for the keyword opened a
photograph. The naming sheet already takes focus on open; do the same.

An empty trash said "No images found — check the library folder and which
formats are ticked", which sends someone off to fix a library that is
fine.
2026-09-20 00:21:14 +02:00
dtourolle 86410def88 Title the gesture book for the whole application, not the grid 2026-09-20 00:21:14 +02:00
dtourolle df8be10c7d Stop promising to ask for an export folder
An empty device destination read "Ask each time" on the settings page,
and nothing asks: an export made with the field blank is refused with
"no export folder is set". Say what will happen.
2026-09-20 00:21:14 +02:00
dtourolle f176043632 Report a merge worker that dies rather than leaving the page on Stop
wgpu reports a device out of memory by panicking, and a twelve-frame
merge on a GPU another process is using is where that happens. The panic
unwound the worker, the sender went with it, and the page sat on "Stop"
with every control disabled and nothing to say why — the crash record on
disk was the only sign. Catch the panic and send it as a failure, and
treat a closed channel with no final event as a dead worker too.
2026-09-20 00:21:14 +02:00
dtourolle 9c2cd73337 Show a mask's tint only while masking
The eyes are per layer and outlive the mode, so a photographer coming
back finds the layers they were looking at still lit. But the tint is a
way of looking at a mask, and outside Local there is no mask being looked
at: the sky stayed red through Repair and back in Photo, a mode that had
been left leaving its overlay behind — the fault ui-navigation.md D-N1
exists to prevent.
2026-09-20 00:21:14 +02:00
dtourolle e3acbdf4a3 Wrap the falloff and edge chips so a category mask cannot widen the column
Five chips in one row declare 440px, and the develop column takes the
widest panel's request — so selecting a category mask levered the sidebar
past the window's edge, clipping the histogram, the group strip and the
subject list. The same trap ChipGrid's comment records for film formats.
2026-09-20 00:21:14 +02:00
dtourolle eddaa44cd3 Announce a month at the next row it opens, not only if it begins one
A heading was drawn only on a cell that both began a month and began a
row, so at seven columns most months were never named, and the one
heading on screen — always on the window's first cell — was wrong about
every row below it. Worse, two headings drawn on the same cell overprinted
each other. Now a row carries a heading whenever its first cell's month is
not the one last announced: a month starting mid-row is named on the next
row it opens, one row late and right about everything under it.
2026-09-20 00:21:14 +02:00
dtourolle 7fba28f7d8 Upload a snapshot of a thumbnail shard, never the live file
Every shard is in WAL mode and every put opens its own connection, so
while thumbnails are being generated on several threads — which is when
the first sync pass runs — the log is never checkpointed and the main
file holds whatever the last quiet moment left in it. For a shard created
seconds earlier that is nothing: a zero-byte file with the schema still
in the log. The sync read that file and uploaded it, and every other
device merging it failed with "no such table: thumbs" on every pass.

Copy the shard through SQLite's backup API into scratch first, which
serialises against writers and carries the log, and upload that.
2026-09-20 00:21:14 +02:00
dtourolle 0fa9003e54 Let the top level be chosen as the library root
Confirming "/" in the folder picker set an empty root, which the launch
model read as no root at all: "Open library" stayed disabled after the
question had plainly been answered, and a folder library — whose folder
is the whole library — could never be opened without first descending
into a subfolder of it. The empty string was carrying two meanings.

Record the choice as its own fact on the account (`root_chosen`, defaulted
so existing configuration loads unchanged), treat a folder endpoint as
chosen by definition, and let the launch screen say so: a folder is shown
as a LIBRARY rather than an ACCOUNT, the second question becomes an
optional "scan only a subfolder", and the library header names the folder
instead of calling it "· whole account".
2026-09-20 00:21:13 +02:00
dtourolle e750bcdb8c Date the composite at the mean of its frames' capture times
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 31m52s
Build and test / Layer separation (push) Successful in 47s
Traceability / Requirement traces (push) Failing after 55s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Benchmarks / CPU and I/O (per commit) (push) Failing after 32s
Build and test / Android (aarch64) (push) Failing after 2m37s
Build and test / Windows (x86_64, cross) (push) Failing after 3m10s
It carried the first frame's, so it sorted before the sweep it was made
from. The middle of the sweep puts it among them.
2026-09-19 22:36:09 +02:00
dtourolle 48c4b403d2 Date a DNG whose IFDs follow its pixels: read the head and the tail
The scan reads the first 256 KB of a file for its metadata. A camera
writes its IFDs at the front, so that is the whole structure; the linear
DNG a merge writes puts its first IFD after the pixels, and rawler,
given the head alone, finds no decoder in it. The composite was
catalogued without a date and sorted to the very end of the grid, after
every dated photograph — which is where a panorama merged on the tablet
went unfound.

dr-decode's own TIFF reader now reads through a head and a tail at a
known offset; trailing_ifd says where the tail starts and
metadata_split reads the two together. The scan, when the head fails
and points beyond itself, fetches from the IFD to the end — kilobytes —
and dates the file from both. Tested against the writer's own output.
2026-09-19 22:35:58 +02:00
dtourolle 9d04ff2154 Release 0.13.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 12m24s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
Build and test / Desktop (Linux) (push) Failing after 31m25s
🐳 Windows image / Build and push (push) Successful in 4s
Build and test / windows-image (push) Successful in 4s
Build and test / Layer separation (push) Successful in 46s
Build and test / Android (aarch64) (push) Failing after 6h25m37s
Build and test / Windows (x86_64, cross) (push) Failing after 3m17s
Traceability / Requirement traces (push) Failing after 48s
2026-09-19 22:06:06 +02:00
dtourolle c6cfb2a02a Put the -1 on the greens along the chroma axis, not across it
The Malvar "R at green in R row" kernel weights the two greens two
sites away along the row at -1 and the pair up and down the column at
+1/2. The shader had the two swapped, in the comment as well as the
code, so the transcription checked against itself. Both sum to zero
and reconstruct a flat patch exactly, which is all the tests fed it.

On an edge the correction at green sites is half strength and the
false colour doubles: 0.375 against 0.19 on a grey step, and a
blue/yellow zipper around every clipped highlight at 1:1. The other
three kernels and the CFA tables were right.

A grey vertical step now runs through the pass; the transposed kernel
fails it at 0.375.
2026-09-19 22:05:39 +02:00
dtourolle b83f192847 Package release 2 of 0.13.0: the inference engine and the user runtime directory 2026-09-19 21:20:53 +02:00
dtourolle ecb648818b Search the user's own runtime directory before the system library
The reference desktop's only system ONNX Runtime is Arch's
onnxruntime-opt-cuda: 1.29, built without TensorRT and against cuDNN 8
on a cuDNN 9 machine. The probe rejects both providers correctly and
the app runs on the CPU provider, which is right and not what anyone
wants. runtime/ beside the models is now searched ahead of /usr/lib,
tools/fetch-desktop-runtime.sh fills it with the four libraries from
the current onnxruntime-gpu wheel (cuDNN 9, TensorRT 10), and the
About caption lists every rung that lost and why, not only the first.
Verified: the app selects TensorRT from that directory with no
environment variable set.
2026-09-19 21:15:55 +02:00
dtourolle 5fbf8944d7 Count the filler in the APK's bundled-model array
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m35s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 31m59s
Build and test / Layer separation (push) Successful in 40s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / windows-image (push) Successful in 2s
Traceability / Requirement traces (push) Failing after 57s
Build and test / Android (aarch64) (push) Failing after 54m6s
Build and test / Windows (x86_64, cross) (push) Failing after 1h4m55s
The unpack list gained migan-512.onnx without its length following;
nothing on the desktop compiles that crate, and the first Android build
of 0.13.0 stopped there.
2026-09-19 20:55:38 +02:00
84 changed files with 3201 additions and 445 deletions
+6
View File
@@ -20,3 +20,9 @@
# reasoning as the models, with the opposite default: the model is not
# optional and the fixtures are.
fixtures/** filter=lfs diff=lfs merge=lfs -text
# The manual's pictures live in LFS for the same reason the models do: a
# screenshot or a GIF changes wholesale when the interface it shows changes,
# and every re-recording would otherwise stay in every clone for good. CI's
# pulls exclude the directory; nothing built or tested reads it.
docs/manual/media/** filter=lfs diff=lfs merge=lfs -text
+1 -1
View File
@@ -148,7 +148,7 @@ jobs:
| while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull --exclude="fixtures/**"
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
- name: Cache cargo
uses: actions/cache@v4
+3 -3
View File
@@ -96,7 +96,7 @@ jobs:
| while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull --exclude="fixtures/**"
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
ls -lR models/
- name: Cache cargo
@@ -213,7 +213,7 @@ jobs:
| while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull --exclude="fixtures/**"
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
ls -lR models/
- name: Cache cargo
@@ -406,7 +406,7 @@ jobs:
| while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull --exclude="fixtures/**"
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
ls -l models/face models/scene
- name: Cache cargo
+108
View File
@@ -0,0 +1,108 @@
# Working in this repository
Notes for anyone — person or agent — changing this code. They record what
went wrong once and what the fix looked like, so the same shape is not
written again. Requirements live in `docs/requirements.md`; this file is
about habits, not features.
## Catalog reads: work is proportional to what changed, never to library size
`docs/catalog.md §1` states the rule. These are the ways it was broken on
the Identity screen, found when every confirm click cost half a second on a
24k-image library (2026-09-19), and what each fix looked like.
**A redraw must know what changed.** A click handler that calls "refresh
everything" pays for everything. `identity_ui::refresh` takes a `Changed`:
a confirm re-reads the rail and the grid and *not* the coverage line,
because moving a face between people cannot alter how many images are
indexed. Before adding a read to a shared refresh, ask which events can
change its answer, and gate it on those.
**Count with `COUNT(*)`, never with `.len()` on a list you then drop.**
`repairs::counts` used to build every repair's work list — a `Target` with
its path per row, sorted into visiting order — to report its length. Six
repairs, 350 ms, nothing kept. If the caller wants a number, the query
returns a number.
**One query, not one per row.** `ThumbStore::contains` in a filter over
5,000 rows is 5,000 prepared statements; `ThumbStore::held(size)` reads the
index once into a set. The same applies to any `query_row` inside a loop
over a result set — including `deep_count` per sidebar row, which is fine
at sidebar scale and would not be at grid scale. Aggregate in one
statement and look up in memory.
**Filter and aggregate in SQL, and aggregate the small side first.**
`faces::people` read 19,000 rows, grouped, sorted them by name, and the
screen threw 17,000 away (empty unnamed groups). `people_in_use` filters in
the `WHERE`, and joins `people` to a pre-aggregated `face_person` (2,000
groups) rather than grouping after a `LEFT JOIN` over every person. The
sort then sees only the rows that will be drawn.
**Wide rows make "just check one column" a table scan.** A `faces` row is
~8 KB (a 1 KB embedding and a ~5 KB crop, then the columns added later).
Any predicate that reads `quality`, `crop` or an eye column for every face
reads every row. V17 learned this for the eye filter; V19 applies it to the
repair counts with partial indexes (`faces_owed_*`) that hold only the rows
still owing, keyed on what the predicate joins on and carrying `model_id`
because the predicate reads it. Two things to know about them:
- **Drive the count from the small side.** SQLite uses a partial index
when the query starts from `faces` (`repairs::count`, `Needs::Face`) and
ignores it inside a correlated `EXISTS (... WHERE f.image_id = i.id ...)`.
That is why `Needs::Face` carries the per-face fragment and spells it two
ways.
- **Spell the predicate as the index's `WHERE` is spelled.** `NEEDS_EYES`
is `(f.eye_right IS NULL OR f.landmarks_dense IS NULL)` because
`faces_owed_eyes` is `WHERE eye_right IS NULL OR landmarks_dense IS NULL`.
Change one, change both, and `counts_are_the_sizes_of_the_lists` will
tell you if they drift.
Check a query's plan with `EXPLAIN QUERY PLAN` against a copy of a real
catalog before trusting an index exists for it: "SEARCH ... USING COVERING
INDEX" is the answer you want, "SEARCH f USING INDEX faces_image" on a wide
table means every probe opens a row.
## Catalog writes: one transaction per user action
`faces::confirm` opens a transaction. Calling it in a loop over a group is
a commit per face; `faces::confirm_all` is two statements and one commit,
`faces::reassign` one transaction for a whole split. When a UI action
touches N rows, give the catalog a function that takes the N, not a loop
that calls the one-row function N times — `unchecked_transaction` cannot
nest, so this has to be designed in at the catalog layer, not wrapped
from above.
## Screens: keep what is already decoded
`identity::load_faces` takes the crops the grid is currently showing and
hands them back into the new cells. Before that, a click re-read 4 MB of
crop blobs and decoded 700 JPEGs to produce the pixels already on screen.
When a redraw replaces a model, the expensive parts of the old model — a
decoded image, a cut portrait — are the first thing to reuse; only the row
that changed needs new work. Drain the old cells rather than cloning them.
## Remote calls: one round trip per file, not one per ancestor
`NextcloudBackend::move_to` guaranteed its destination's parent with a
`MKCOL` per ancestor from the account root, on every file of a batch —
three `405`s before each `MOVE`. The backend now remembers the collections
it has confirmed (`known_dirs`) for its lifetime, which is one job. When a
per-file operation has a per-batch precondition, satisfy it once.
## Measuring
`cargo run --release -p dr-ui --example identity_bench -- CATALOG THUMBS`
times what one click on the Identity screen reads and what the batch
operations write. Run it against a **copy** of a real catalog (it writes),
never the library's own file; `sqlite3 catalog.sqlite ".backup copy.sqlite"`
takes a consistent one while the app runs. Compare the `cpu` column when
other builds are running on the machine — the wall clock doubles under
load, the CPU figure does not. Keep the binary from before the change and
run both back to back rather than trusting numbers taken an hour apart.
Reference figures from the 2026-09-19 fixes, largest person (754 faces),
24k images, 19k faces, before → after. What one click read: `load_people`
22 ms → 12 ms, `load_faces` 316 ms → 2.4 ms, `audit` 190 ms → not run
(66 ms when it is, on open and at the end of a sweep). What one click
wrote: `confirm_all` 16 ms → 2 ms, `split_off` 23 ms → 4.5 ms. A click on
the face grid went from ~530 ms of catalog work to ~15 ms.
Generated
+25 -25
View File
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "darkroom-android"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"android_logger",
"dr-plat",
@@ -1234,7 +1234,7 @@ dependencies = [
[[package]]
name = "darkroom-desktop"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"anyhow",
"dr-plat",
@@ -1408,7 +1408,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dr-bench"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"anyhow",
"dr-catalog",
@@ -1425,7 +1425,7 @@ dependencies = [
[[package]]
name = "dr-catalog"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"dr-face",
"dr-plat",
@@ -1440,7 +1440,7 @@ dependencies = [
[[package]]
name = "dr-decode"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"dr-types",
"env_logger",
@@ -1454,7 +1454,7 @@ dependencies = [
[[package]]
name = "dr-export"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"dr-decode",
"dr-gpu",
@@ -1473,7 +1473,7 @@ dependencies = [
[[package]]
name = "dr-face"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"dr-inference-engine",
"env_logger",
@@ -1486,7 +1486,7 @@ dependencies = [
[[package]]
name = "dr-film"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"log",
"serde",
@@ -1495,7 +1495,7 @@ dependencies = [
[[package]]
name = "dr-gpu"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"bytemuck",
"dr-decode",
@@ -1513,7 +1513,7 @@ dependencies = [
[[package]]
name = "dr-inference-engine"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"libloading",
"log",
@@ -1527,7 +1527,7 @@ dependencies = [
[[package]]
name = "dr-ingest"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"dr-plat",
"dr-types",
@@ -1539,7 +1539,7 @@ dependencies = [
[[package]]
name = "dr-lens"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"lensfun",
"log",
@@ -1547,7 +1547,7 @@ dependencies = [
[[package]]
name = "dr-pano"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"dr-decode",
"dr-inference-engine",
@@ -1561,7 +1561,7 @@ dependencies = [
[[package]]
name = "dr-pipeline"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"dr-types",
"log",
@@ -1570,7 +1570,7 @@ dependencies = [
[[package]]
name = "dr-plat"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"android-native-keyring-store",
"dr-types",
@@ -1586,7 +1586,7 @@ dependencies = [
[[package]]
name = "dr-preset-xmp"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"dr-pipeline",
"log",
@@ -1596,7 +1596,7 @@ dependencies = [
[[package]]
name = "dr-segment"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"dr-inference-engine",
"env_logger",
@@ -1609,7 +1609,7 @@ dependencies = [
[[package]]
name = "dr-sync"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"async-trait",
"dr-plat",
@@ -1623,7 +1623,7 @@ dependencies = [
[[package]]
name = "dr-sync-folder"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"async-trait",
"dr-sync",
@@ -1635,7 +1635,7 @@ dependencies = [
[[package]]
name = "dr-sync-nextcloud"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"async-trait",
"dr-decode",
@@ -1657,7 +1657,7 @@ dependencies = [
[[package]]
name = "dr-thumbs"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"dr-types",
"jpeg-encoder",
@@ -1669,7 +1669,7 @@ dependencies = [
[[package]]
name = "dr-types"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"serde",
"serde_json",
@@ -1678,7 +1678,7 @@ dependencies = [
[[package]]
name = "dr-ui"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"anyhow",
"async-trait",
@@ -1720,7 +1720,7 @@ dependencies = [
[[package]]
name = "dr-xmp"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"dr-types",
"log",
@@ -7021,7 +7021,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "traceability"
version = "0.13.0"
version = "0.13.3"
dependencies = [
"anyhow",
"serde",
+1 -1
View File
@@ -29,7 +29,7 @@ members = [
]
[workspace.package]
version = "0.13.0"
version = "0.13.3"
edition = "2021"
rust-version = "1.92"
license = "GPL-3.0-or-later"
+109 -59
View File
@@ -1,84 +1,134 @@
# DarkRoom
A cross-platform, non-destructive RAW photo editor for Linux and Android.
A non-destructive RAW photo editor and library for Linux and Android, with a
GPU develop pipeline, a catalog that syncs between devices, and no account,
no telemetry and no cloud of its own.
**Status:** 0.9.0, and no longer a spike. A library opens, culls, develops and
exports on both platforms, across eight tagged releases. What is *not*
built is written down rather than merely absent — see
[docs/outstanding.md](docs/outstanding.md) for the requirements that have no
implementation and why, and [docs/technical-debt.md](docs/technical-debt.md)
for the compromises that were chosen.
[![The library: seventy frames, the timeline beside them, the filter bar above](docs/manual/media/library.png)](docs/manual/README.md)
## Documentation
**[The manual](docs/manual/README.md)** shows every feature, pictured from
the application itself. This page says what it is, how to get it, and what
is still missing.
| Document | Contents |
|---|---|
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
| [requirements.md](docs/requirements.md) | What the software must do — 179 numbered requirements |
| [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync |
| [technical-debt.md](docs/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
| [outstanding.md](docs/outstanding.md) | What is not built, and whether that is a decision or a gap |
| [code-health.md](docs/code-health.md) | What a contribution costs, per seam, measured |
| [traceability.md](docs/traceability.md) | Generated: which requirement is claimed by which file |
| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measured |
## What it does
## Building
**A library.** Point it at a folder — on this machine, on a network mount,
or one a Nextcloud client keeps in virtual-files mode, where a placeholder
is treated as the photograph rather than as a one-byte file — or at a
Nextcloud account directly. The grid is virtualised, ordered by capture
time with a timeline beside it, and filtered by rating, flag, person and
whether the file is here. Ratings, keywords, collections and a
trash that survives a crash mid-operation. Card ingest. Bursts fold. Face
detection and identity, with the index syncing between devices.
Desktop:
**Developing.** Eighteen declared operations fused into one compute
dispatch, plus the neighbourhood work that cannot be: clarity, texture,
capture sharpening, noise reduction, lens correction, spectral film
simulation. Crop and straighten, spot repair, and local adjustments over
masks the model draws — click a subject or a category, then paint, subtract
a gradient, grow or shrink the edge. Focus peaking and a raw histogram for
judging what is recoverable. Named presets; XMP sidecars other editors read.
[![Segmenting an urban scene and choosing the sky as a mask](docs/manual/media/local-segment.png)](docs/manual/README.md#local-adjustments)
**Panoramas.** Select the frames, align, choose a projection, fill the
ragged border rather than crop it, and the composite lands beside its
sources as a DNG, with a sidecar recording what it was merged from.
[![Twelve hand-held frames aligned on a cylinder](docs/manual/media/panorama-aligned.png)](docs/manual/README.md#merging-a-panorama)
**Export.** JPEG, PNG, AVIF, JPEG XL, 8- and 16-bit TIFF, with resize, output
sharpening, a naming template and a colour space — to a folder here or back
into the library.
**On both platforms.** The same core runs on a desktop and a 12-inch
tablet; the interface is one layout, tuned for a wide viewport with touch
targets throughout. On desktop the develop view draws the compute pass's
texture directly — no readback between the GPU and the screen.
## Getting it
| Platform | How | State |
|---|---|---|
| Arch Linux | [`packaging/PKGBUILD`](packaging/PKGBUILD) — `makepkg -si` | Built from every release |
| Android | The APK from each CI run, or `./docker/android/package.sh --install` | Runs on a tablet; F-Droid not yet submitted |
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/windows.md)) | Verified under Wine only; unsigned |
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; choosing a library does not yet work in the sandbox |
Or build it. Git LFS is required for the model weights, and the toolchain
pins itself to 1.92.0:
```bash
cargo run -p darkroom-desktop
git lfs install && git lfs pull
cargo run --release -p darkroom-desktop
```
Android (containerised toolchain, see [docker/android](docker/android/README.md)):
Android, through the containerised toolchain ([docker/android](docker/android/README.md)):
```bash
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
```
Git LFS is required for the model weights, and the toolchain pins itself.
[CONTRIBUTING.md](CONTRIBUTING.md) has the details and the four commands CI
will run against what you send.
[CONTRIBUTING.md](CONTRIBUTING.md) has the system packages, the four
commands CI runs against what you send, and the shortest useful
contribution — a develop operation is one YAML file, and it arrives with its
controls, its place in the chain and its tests.
## Current state
## Where it stands
**Working.** A catalog over a local folder, a Nextcloud account, or a folder a
sync client keeps in virtual-files mode — where a placeholder is treated as the
photograph rather than as a one-byte file. A virtualised library grid with a
capture-time timeline, ratings, labels, keywords, collections and a trash that
survives a crash mid-operation. Card ingest. Face detection and identity, with
the index syncing between devices. A develop pipeline of fifteen declared
operations fused into a single compute dispatch, plus the neighbourhood
operations that cannot be — clarity, texture, capture sharpening, noise
reduction, lens correction, spectral film simulation. Crop, straighten, spot
removal, gradient and subject-segmentation masks, named presets, and a
generated panel that no operation in `ui/` is allowed to name. Export to JPEG,
PNG and 8- or 16-bit TIFF with resize and output sharpening.
**0.13.3**, seventeen tagged releases in. 184 numbered requirements in
scope, 84% of them claimed by code and [traced to it](docs/traceability.md);
the rest are written down rather than merely absent.
**The zero-copy display path works on desktop.** The compute pass writes a
texture that Slint composites directly, which is what
[ARCH §6.1](docs/architecture.md) requires; the readback it forbids costs 96%
of frame time at 4K, and
**Not built:** plugins (post-v1, [D12](docs/requirements.md)), compare and
survey culling, AI denoise, tiled and progressive rendering, HDR merge and
focus stacking, most of the Android platform integration beyond running,
and the Flatpak's library chooser. The performance targets are half
verified: the per-commit benchmark suite §8 requires exists for everything
that does not need a frame — the catalog, the scan, the thumbnails — and
not yet for the render path, so a regression there fails nothing.
[outstanding.md](docs/outstanding.md) is the list, with the reasoning for
each.
```bash
cargo run -p dr-gpu --example bench --features readback
```
**The one deliberate compromise worth knowing about before reading
anything else:** the Android develop view reads its frame back through the
CPU, because zero-copy there needs wgpu's Vulkan swapchain and that tears a
portrait window on a tablet whose panel is mounted landscape. It is debt,
not a revision of the rule — [technical-debt.md TD-1](docs/technical-debt.md)
has the measurements and the three things any one of which would remove it.
still reproduces that measurement. **The one exception is the Android develop
view**, which reads the frame back through the CPU because zero-copy there
needs wgpu's Vulkan swapchain, and that tears a portrait window on a tablet
whose panel is mounted landscape. It is debt, not a revision of the rule: the
reasoning, the on-device measurements that forced it, and the three separate
things any one of which would remove it are in
[technical-debt.md TD-1](docs/technical-debt.md).
## Documentation
**Not built.** Plugins, compare and survey culling, focus peaking, burst
grouping, AI denoise, tiled and progressive rendering, and most of the Android
platform integration beyond running. The performance targets in §4.1 are
unverified rather than unmet — the per-commit benchmark suite §8 requires does
not exist, so nothing fails a build on a regression.
[docs/outstanding.md](docs/outstanding.md) is the list, with the reasoning.
For someone using it:
| | |
|---|---|
| [manual](docs/manual/README.md) | Every feature, pictured |
| [gestures.md](docs/gestures.md) | How it is driven — generated from the code, so it cannot describe a gesture that does not exist |
For someone changing it:
| | |
|---|---|
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest |
| [requirements.md](docs/requirements.md) | What the software must do — the numbered register, and the decisions |
| [architecture.md](docs/architecture.md) | How it is built — crates, the GPU pipeline, the data model, sync |
| [technical-debt.md](docs/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it |
| [outstanding.md](docs/outstanding.md) | What is not built, and whether that is a decision or a gap |
| [code-health.md](docs/code-health.md) | What a contribution costs, per seam, measured |
| [traceability.md](docs/traceability.md) | Generated: which requirement is claimed by which file |
Designs, one per subsystem:
[segmentation](docs/segmentation.md) and [mask editing](docs/mask-editing.md) ·
[spot removal](docs/spot-removal.md) · [panorama](docs/panorama.md) ·
[faces](docs/faces.md) · [inference](docs/inference.md) ·
[storage and sync](docs/storage.md) · [catalog](docs/catalog.md) ·
[display and extension](docs/display-and-extension.md) ·
[navigation](docs/ui-navigation.md) · [distribution](docs/distribution.md) ·
[windows](docs/windows.md) · [benchmarks](docs/benchmarks.md).
## Licence
GPL-3.0-or-later.
GPL-3.0-or-later. The photographs in the manual and the test fixtures are
the author's and are there to show and test this project, nothing else.
The model weights carry their own licences — [models/LICENCE.md](models/LICENCE.md).
+1 -1
View File
@@ -333,7 +333,7 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
// The int8 forms beside the three detectors are what the Hexagon runs
// (docs/inference.md §5); the engine loads the sibling when the probe
// chose that rung and ignores it otherwise.
const BUNDLED: [(&std::ffi::CStr, &str); 13] = [
const BUNDLED: [(&std::ffi::CStr, &str); 14] = [
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
(
c"models/scrfd_500m_640.int8.onnx",
+9 -4
View File
@@ -83,10 +83,14 @@ fn main() -> anyhow::Result<()> {
/// `DARKROOM_ORT_DIR` is for a developer pointing at a runtime that is not
/// installed — the wheel's `capi` directory, say. Then beside the executable
/// and in the package's private library directory, for a package that
/// bundles its own; then the Flatpak prefix; then the system library
/// directory, for a distribution that ships ONNX Runtime as a package of its
/// own. A system copy whose GPU providers do not load is not a problem: the
/// probe builds a real session before believing a provider.
/// bundles its own; then the user's own `runtime/` beside the models, where
/// `tools/fetch-desktop-runtime.sh` puts one; then the Flatpak prefix; then
/// the system library directory, for a distribution that ships ONNX Runtime
/// as a package of its own. The user's copy outranks the system's because
/// the system's is the one most likely to be built without the GPU
/// providers, or against the wrong cuDNN — and a system copy whose providers
/// do not load is not a problem, only a slower app: the probe builds a real
/// session before believing a provider.
fn runtime_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(dir) = std::env::var_os("DARKROOM_ORT_DIR") {
@@ -98,6 +102,7 @@ fn runtime_dirs() -> Vec<PathBuf> {
dirs.push(bin.join("../lib/darkroom"));
}
}
dirs.push(dr_ui::inference::user_runtime_dir());
#[cfg(target_os = "linux")]
dirs.extend([
PathBuf::from("/app/lib/darkroom"),
+31 -23
View File
@@ -820,20 +820,22 @@ pub fn import_from_shards(
let Some((faces, edge)) = store.get_image(file_id as u64, &held)? else {
continue;
};
// A peer that embedded before the quality was kept has done work this
// device cannot finish: the number exists only at embedding time, and
// adopting the faces would write the run marker that keeps them from
// ever being measured (schema V14). Left for this device's own pass —
// or for the peer's, whose re-export replaces these.
// A face the peer embedded before its quality was kept (schema V14)
// is adopted with the reading missing, exactly as one without an eye
// reading is. The measuring passes find their work by the NULL
// column, not by the run marker (`dr_ui::repairs`, `faces_needing`),
// so adopting costs the reading nothing and this device's own pass
// fills it.
//
// A missing *eye* reading is not the same case and is adopted. The
// measuring pass finds those by the NULL, not by the marker, so
// adopting the faces costs the reading nothing (schema V16) — and a
// peer that has no eye models may be the only one that has done the
// detection at all.
if faces.iter().any(|f| f.quality.is_none()) {
continue;
}
// This used to refuse such faces, on the reasoning that the marker
// would stop them ever being measured — true before the quality
// repair existed, and wrong after. What it cost: V14 had dropped the
// markers of every image holding such faces, so the peer never
// re-exported them, and the only copies in the shards were the
// unmeasured ones. A tablet holding shards with 4,310 of the
// desktop's images and 3,170 of its confirmations declined every one
// of them, showed a fraction of each person, and queued the whole
// library for a re-detection of its own instead.
let local: Vec<crate::faces::DetectedFace> = faces
.into_iter()
.map(|f| crate::faces::DetectedFace {
@@ -1365,11 +1367,12 @@ mod catalog_round_trip {
);
}
/// A face a peer embedded without measuring it is work this device
/// cannot finish, and adopting it would write the marker that stops it
/// ever being measured. The image stays outstanding instead.
/// A face a peer embedded without measuring it is adopted all the same,
/// and left on this device's quality pass by its missing reading. Refusing
/// it was what stranded every confirmation the desktop had made on faces
/// from before V14: the tablet held the shards and would not use them.
#[test]
fn a_peers_unmeasured_faces_are_left_for_this_device_to_index() {
fn a_peers_unmeasured_faces_are_adopted_and_left_for_the_quality_pass() {
let b = device(&[(90, 5001), (91, 5002)]);
let mut store = FaceShardStore::open(&tempdir("unmeasured")).unwrap();
let shared = |file_id: u64, quality: Option<f32>| SharedFace {
@@ -1395,13 +1398,18 @@ mod catalog_round_trip {
.put_image(5002, "w600k_mbf", 2560, &[shared(5002, Some(19.0))])
.unwrap();
assert_eq!(import_from_shards(&b, &store, "w600k_mbf").unwrap(), 1);
assert_eq!(import_from_shards(&b, &store, "w600k_mbf").unwrap(), 2);
let cov = faces::coverage(&b, "w600k_mbf").unwrap();
assert_eq!(cov.indexed, 1);
assert_eq!(cov.outstanding(), 1, "the unmeasured image was adopted");
assert!(faces::for_image(&b, dr_types::ImageId(90))
.unwrap()
.is_empty());
assert_eq!(cov.indexed, 2);
assert_eq!(cov.outstanding(), 0, "the unmeasured image was refused");
let got = faces::for_image(&b, dr_types::ImageId(90)).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].quality, None, "a reading was invented");
// Still owed to the measuring pass, which lists by the column.
assert_eq!(
faces::count_needing(&b, "w600k_mbf", "f.quality IS NULL").unwrap(),
1
);
}
#[test]
+229 -13
View File
@@ -556,6 +556,19 @@ impl FaceUpdate {
/// bookkeeping: `face_shard::export_to_shards` re-exports an image whose
/// marker is newer than the store's copy, which is how what was written
/// here reaches the other devices.
///
/// It is re-written under the pipeline id the **faces carry**, not the one
/// this pass ran as. `model_id` names the pass only through its embedder;
/// the detector half of a marker is a statement about who drew the boxes,
/// and this pass drew none. Every reader takes the two to agree: the export
/// selects an image's faces by the marker's id, `marker_under` takes a
/// marker as proof the detector has been over the image, and the shard
/// store keys each face by it. When the marker was written as
/// `scrfd_10g+w600k_mbf` over faces still spelled `w600k_mbf`, the export
/// found no faces under it and sent the other devices an entry saying the
/// thorough detector had looked and found nothing — over photographs with
/// named faces on them. With no faces left, the pass's own id is the only
/// one there is, and the marker says so.
pub fn record_updates(
conn: &Connection,
image_id: ImageId,
@@ -602,13 +615,25 @@ pub fn record_updates(
for f in dropped {
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
}
let remaining: i64 = tx.query_row(
let (remaining, found_by): (i64, Option<String>) = tx.query_row(
&format!(
"SELECT COUNT(*) FROM faces WHERE image_id = ?1 AND {} = ?2",
"SELECT COUNT(*), MIN(model_id) FROM faces WHERE image_id = ?1 AND {} = ?2",
embedder_sql("model_id")
),
rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|r| r.get(0),
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
let marker = found_by.as_deref().unwrap_or(model_id);
// One marker per embedder: a stale one under another spelling would
// keep saying that detector had been here, which is the claim the
// faces' own id is now making in its place.
tx.execute(
&format!(
"DELETE FROM face_index
WHERE image_id = ?1 AND model_id != ?2 AND {} = ?3",
embedder_sql("model_id")
),
rusqlite::params![image_id.0 as i64, marker, embedder_of(model_id)],
)?;
tx.execute(
"INSERT INTO face_index (image_id, model_id, indexed_at, faces_found, source_edge)
@@ -619,7 +644,7 @@ pub fn record_updates(
source_edge = excluded.source_edge",
rusqlite::params![
image_id.0 as i64,
model_id,
marker,
now_secs(),
remaining,
source_edge as i64,
@@ -838,6 +863,22 @@ pub fn unassigned(conn: &Connection, model_id: &str) -> Result<Vec<FaceId>, Cata
rows.collect::<Result<_, _>>().map_err(Into::into)
}
/// How many faces have no identity yet — [`unassigned`] counted rather than
/// listed, for a screen that only shows the number.
pub fn count_unassigned(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
let n: i64 = conn.query_row(
&format!(
"SELECT COUNT(*) FROM faces f
LEFT JOIN face_person fp ON fp.face_id = f.id
WHERE fp.face_id IS NULL AND {} = ?1",
embedder_sql("f.model_id")
),
[embedder_of(model_id)],
|r| r.get(0),
)?;
Ok(n as u64)
}
/// One face's stored embedding, as the clustering pass consumes it.
///
/// A struct rather than a tuple because it crosses a crate boundary and "the
@@ -910,17 +951,46 @@ pub fn rename_person(conn: &Connection, person: PersonId, name: &str) -> Result<
/// Merged-away people are excluded: they exist as redirects so a sync does not
/// resurrect them, not as entries in a list.
pub fn people(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
let mut q = conn.prepare(
people_where(conn, false)
}
/// Everyone who holds a face, carries a name, or was set aside — the people a
/// screen has a row for.
///
/// The rest are the empty, unnamed groups a regrouping pass leaves behind
/// (`prune_empty_unnamed`), and on the reference library they were 17,000 of
/// 19,000 rows: read, counted, sorted by name and then thrown away by the
/// caller on every redraw. Filtered here, in the query, they are never
/// sorted. The filter is SQL's `trim`, which strips spaces and not every
/// whitespace character, so a name that is only a tab is listed rather than
/// hidden — the safe direction for a row the user typed something into.
pub fn people_in_use(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
people_where(conn, true)
}
/// [`people`], with face counts aggregated once per person *before* the join
/// rather than grouped after it: the face table is joined to the 2,000
/// people it names, not the 19,000 rows of the people table.
fn people_where(conn: &Connection, in_use: bool) -> Result<Vec<Person>, CatalogError> {
let filter = if in_use {
"AND (c.person_id IS NOT NULL OR trim(p.name) <> '' OR p.ignored)"
} else {
""
};
let mut q = conn.prepare(&format!(
"SELECT p.id, p.uuid, p.name,
COALESCE(SUM(fp.confirmed = 1), 0),
COALESCE(SUM(fp.confirmed = 0), 0),
COALESCE(c.confirmed, 0),
COALESCE(c.suggested, 0),
p.ignored
FROM people p
LEFT JOIN face_person fp ON fp.person_id = p.id
WHERE p.merged_into IS NULL
GROUP BY p.id
ORDER BY 4 DESC, 5 DESC, p.name",
)?;
LEFT JOIN (SELECT person_id,
SUM(confirmed = 1) AS confirmed,
SUM(confirmed = 0) AS suggested
FROM face_person
GROUP BY person_id) c ON c.person_id = p.id
WHERE p.merged_into IS NULL {filter}
ORDER BY 4 DESC, 5 DESC, p.name"
))?;
let rows = q.query_map([], |r| {
Ok(Person {
id: PersonId(r.get::<_, i64>(0)? as u64),
@@ -1057,6 +1127,72 @@ pub fn confirm(conn: &Connection, face: FaceId, person: PersonId) -> Result<(),
Ok(())
}
/// The user says every suggested face of this person is right.
///
/// What "Confirm all" runs, and the reason it is not a loop over [`confirm`]:
/// that is a transaction per face, and on a group of several hundred it was
/// several hundred commits for one click. Two statements, one commit, and the
/// same two rules `confirm` applies face by face — an earlier rejection of
/// the pair is overridden, and a face already confirmed is left alone.
///
/// Returns how many suggestions became confirmations.
pub fn confirm_all(conn: &Connection, person: PersonId) -> Result<u64, CatalogError> {
let tx = conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM face_person_rejected
WHERE person_id = ?1
AND face_id IN (SELECT face_id FROM face_person
WHERE person_id = ?1 AND confirmed = 0)",
[person.0 as i64],
)?;
let n = tx.execute(
"UPDATE face_person SET confirmed = 1, probability = 1.0
WHERE person_id = ?1 AND confirmed = 0",
[person.0 as i64],
)?;
tx.commit()?;
Ok(n as u64)
}
/// The user says these faces are `to`, not `from`.
///
/// [`reject`] from one and [`confirm`] onto the other, for every face, in one
/// transaction — what a split commits. Rejecting first is what stops the
/// split being undone: without it the next pass sees a face that looks like
/// `from` and suggests it straight back. Confirmed rather than suggested on
/// `to`, because the user has just asserted these belong together.
pub fn reassign(
conn: &Connection,
faces: &[FaceId],
from: PersonId,
to: PersonId,
) -> Result<(), CatalogError> {
let tx = conn.unchecked_transaction()?;
{
let mut reject = tx.prepare(
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
VALUES (?1, ?2)",
)?;
let mut unrejected =
tx.prepare("DELETE FROM face_person_rejected WHERE face_id = ?1 AND person_id = ?2")?;
let mut confirm = tx.prepare(
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
VALUES (?1, ?2, 1.0, 1)
ON CONFLICT(face_id) DO UPDATE SET
person_id = excluded.person_id,
probability = 1.0,
confirmed = 1",
)?;
for face in faces {
reject.execute(rusqlite::params![face.0 as i64, from.0 as i64])?;
unrejected.execute(rusqlite::params![face.0 as i64, to.0 as i64])?;
confirm.execute(rusqlite::params![face.0 as i64, to.0 as i64])?;
}
}
tx.commit()?;
Ok(())
}
/// The user says this face is **not** this person.
///
/// Stored rather than implied by removal, so the next clustering pass does not
@@ -1446,7 +1582,7 @@ fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 {
}
}
fn now_secs() -> i64 {
pub(crate) fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
@@ -1779,6 +1915,86 @@ mod tests {
assert!(at >= marked_at, "the marker was not refreshed");
}
/// The marker a per-face pass leaves names the detector that drew the
/// boxes, whatever pipeline the pass itself ran as. A marker under the
/// pass's id over faces spelled another way is one the export finds no
/// faces under — and it sent every other device "nothing here".
#[test]
fn an_update_keeps_the_marker_under_the_detector_that_found_the_faces() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(
&c,
img,
"w600k_mbf",
1024,
&[DetectedFace {
quality: None,
..face(1)
}],
)
.unwrap();
// The state V14 leaves: the faces, and no marker at all.
c.execute("DELETE FROM face_index", []).unwrap();
record_updates(
&c,
img,
"scrfd_10g+w600k_mbf",
6000,
&[FaceUpdate {
embedding: Some((vec![9; 1024], 21.5)),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
let markers: Vec<(String, i64)> = c
.prepare("SELECT model_id, faces_found FROM face_index")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.map(Result::unwrap)
.collect();
assert_eq!(markers, vec![("w600k_mbf".to_string(), 1)]);
// A marker already there under the pass's own id is replaced, not
// kept beside the right one.
c.execute(
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
VALUES (1, 'scrfd_10g+w600k_mbf', 0, 0, 6000)",
[],
)
.unwrap();
record_updates(
&c,
img,
"scrfd_10g+w600k_mbf",
6000,
&[FaceUpdate {
crop: Some(vec![1, 2, 3]),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
let n: i64 = c
.query_row("SELECT COUNT(*) FROM face_index", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1, "a second marker survived");
// With every face dropped there is no detector left to name, and
// the pass's own id records that it looked.
record_updates(&c, img, "scrfd_10g+w600k_mbf", 6000, &[], &ids).unwrap();
let marker: (String, i64) = c
.query_row("SELECT model_id, faces_found FROM face_index", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert_eq!(marker, ("scrfd_10g+w600k_mbf".to_string(), 0));
}
/// Re-detection is coalesced per image, so it must replace rather than
/// append — otherwise every re-index doubles the library's face count.
#[test]
+219 -1
View File
@@ -15,7 +15,7 @@ use rusqlite::Connection;
use crate::error::CatalogError;
/// Schema version this build writes and understands.
pub const SCHEMA_VERSION: i64 = 18;
pub const SCHEMA_VERSION: i64 = 20;
/// Apply migrations up to [`SCHEMA_VERSION`].
///
@@ -181,9 +181,105 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
tx.commit()?;
}
if from < 19 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V19)?;
tx.pragma_update(None, "user_version", 19)?;
tx.commit()?;
}
if from < 20 {
let tx = conn.unchecked_transaction()?;
v20_markers_name_the_detector_that_found_the_faces(&tx)?;
tx.pragma_update(None, "user_version", 20)?;
tx.commit()?;
}
Ok(from)
}
// V20 -- TRACES: FR-CAT-7
//
// Run markers that named the wrong detector, put right.
//
// `faces::record_updates` -- the write behind the quality, eye and crop
// passes -- re-marked an image under the pipeline the pass ran as, while
// the faces it had updated kept the id of the detector that found them.
// A marker of `scrfd_10g+w600k_mbf` over faces spelled `w600k_mbf` reads,
// to every consumer, as the thorough detector having examined the image:
// the upgrade repair skips it, and `face_shard::export_to_shards` selects
// its faces by the marker's id, finds none, and tells every other device
// that the thorough detector found nothing there. The desktop's shard index
// held 54 such entries over photographs with named faces, and the tablet's
// eye pass over faces it had adopted from the desktop had made 430 more.
//
// The write is fixed to keep the marker under the faces' own id. This puts
// the markers already written right, with a fresh time so the export sends
// each image again under an entry newer than the empty one -- which is what
// `held_model` orders by. Where the right marker is still there beside the
// wrong one (the old write inserted rather than replaced), the wrong one
// goes and the right one is refreshed for the same reason: its entry in
// the shards is older than the empty one, and a device that has neither
// would take the empty one. An image V14 left with faces and no marker at
// all is not touched: that state is the quality pass's cue, and the fixed
// write marks it correctly when the pass reaches it.
//
// Restated in Rust rather than SQL because the embedder half of a pipeline
// id is `faces::embedder_sql`, which this must agree with.
fn v20_markers_name_the_detector_that_found_the_faces(tx: &Connection) -> Result<(), CatalogError> {
let fi = crate::faces::embedder_sql("face_index.model_id");
let f = crate::faces::embedder_sql("f.model_id");
// A marker is wrong when the image holds faces of its embedder under
// another id. First the wrong ones that sit beside a right one -- the
// update below would collide with it -- then the rest are renamed.
let wrong = format!(
"EXISTS (SELECT 1 FROM faces f
WHERE f.image_id = face_index.image_id
AND {f} = {fi}
AND f.model_id != face_index.model_id)"
);
let found_by = format!(
"(SELECT MIN(f.model_id) FROM faces f
WHERE f.image_id = face_index.image_id AND {f} = {fi})"
);
let now = crate::faces::now_secs();
tx.execute(
&format!(
"UPDATE face_index
SET indexed_at = ?1
WHERE model_id = {found_by}
AND EXISTS (SELECT 1 FROM face_index w
WHERE w.image_id = face_index.image_id
AND w.model_id != face_index.model_id
AND {} = {fi})",
crate::faces::embedder_sql("w.model_id")
),
[now],
)?;
tx.execute(
&format!(
"DELETE FROM face_index
WHERE {wrong}
AND EXISTS (SELECT 1 FROM face_index o
WHERE o.image_id = face_index.image_id
AND o.model_id = {found_by})"
),
[],
)?;
tx.execute(
&format!(
"UPDATE face_index
SET model_id = {found_by},
faces_found = (SELECT COUNT(*) FROM faces f
WHERE f.image_id = face_index.image_id AND {f} = {fi}),
indexed_at = ?1
WHERE {wrong}"
),
[now],
)?;
Ok(())
}
/// The seven columns V16 adds to `faces`, in the order the readers name them.
///
/// Named once because three places have to agree on them: this migration,
@@ -766,6 +862,44 @@ CREATE TABLE IF NOT EXISTS xmp_conflicts (
);
"#;
// V19 -- TRACES: NFR-P9
//
// The indexes the repair counts are served from, and V17's lesson applied
// to the rest of the face columns.
//
// "How many images still owe a quality reading" was answered per image: a
// correlated EXISTS over `faces` that had to open each face's row to look
// at one nullable column -- the row being eight kilobytes of embedding and
// crop. Six such counts run every time the Identity screen opens and every
// time a sweep ends, 160 ms of them on the reference library. Three
// partial indexes hold only the faces still owing each pass, keyed by the
// image and carrying the model id the predicate also reads, so the count
// walks a few thousand index entries and touches no row at all -- and each
// index shrinks to nothing as its pass completes. The planner takes them
// when the count is driven from `faces` (`repairs::count`) and ignores
// them inside the per-image EXISTS, which is why that function has two
// spellings of the same predicate.
//
// `faces_image_model` replaces `faces_image`: the same key with the model
// id beside it, so "does this image hold this embedder's faces" -- asked in
// the audit, the proxy repair and the outstanding-detection count -- is an
// index-only probe where it used to read the row for the model id. Every
// lookup that used `faces_image` is served by its prefix.
//
// Not applied to attached catalogs, like V7 and V17: an index is a local
// concern, and a merge never runs these queries across an attachment.
const V19: &str = r#"
CREATE INDEX IF NOT EXISTS faces_image_model ON faces(image_id, model_id);
DROP INDEX IF EXISTS faces_image;
CREATE INDEX IF NOT EXISTS faces_owed_quality ON faces(image_id, model_id)
WHERE quality IS NULL;
CREATE INDEX IF NOT EXISTS faces_owed_crop ON faces(image_id, model_id)
WHERE crop IS NULL;
CREATE INDEX IF NOT EXISTS faces_owed_eyes ON faces(image_id, model_id)
WHERE eye_right IS NULL OR landmarks_dense IS NULL;
"#;
// V18 -- TRACES: FR-CULL-8a | FR-CULL-12
//
// The 106 dense landmarks the eye pass reads its eye boxes from, kept beside
@@ -1809,6 +1943,90 @@ mod tests {
assert_eq!(faces, 2);
}
#[test]
fn v20_renames_markers_to_the_detector_that_found_the_faces() {
let c = mem();
c.pragma_update(None, "user_version", 0).unwrap();
migrate(&c).unwrap();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'test')",
[],
)
.unwrap();
c.execute(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES (1,1,'a',0),(2,1,'b',0),(3,1,'c',0),(4,1,'d',0),(5,1,'e',0)",
[],
)
.unwrap();
// 1: the desktop's case -- old faces, re-marked as thorough.
// 2: the tablet's case -- adopted thorough faces, re-marked int8,
// and the right marker still beside it (refreshed, so it is
// exported again over the empty entry).
// 3: right already. 4: examined and empty. 5: V14's state, faces
// and no marker.
for (image, model) in [
(1, "scrfd_10g+w600k_mbf"),
(2, "scrfd_10g_i8+w600k_mbf"),
(2, "scrfd_10g+w600k_mbf"),
(3, "scrfd_10g+w600k_mbf"),
(4, "scrfd_10g+w600k_mbf"),
] {
c.execute(
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
VALUES (?1, ?2, 100, 0, 6000)",
rusqlite::params![image, model],
)
.unwrap();
}
for (image, model) in [
(1, "w600k_mbf"),
(1, "w600k_mbf"),
(2, "scrfd_10g+w600k_mbf"),
(3, "scrfd_10g+w600k_mbf"),
(5, "w600k_mbf"),
] {
c.execute(
"INSERT INTO faces
(image_id, x, y, w, h, landmarks, detector_confidence, embedding,
crop_px, model_id, detected_at)
VALUES (?1, 0.1, 0.1, 0.2, 0.2, X'00', 0.9, X'00', 180.0, ?2, 0)",
rusqlite::params![image, model],
)
.unwrap();
}
c.pragma_update(None, "user_version", 19).unwrap();
migrate(&c).unwrap();
let markers: Vec<(i64, String, i64, bool)> = c
.prepare(
"SELECT image_id, model_id, faces_found, indexed_at > 100
FROM face_index ORDER BY image_id, model_id",
)
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
.unwrap()
.map(Result::unwrap)
.collect();
assert_eq!(
markers,
vec![
(1, "w600k_mbf".to_string(), 2, true),
(2, "scrfd_10g+w600k_mbf".to_string(), 0, true),
(3, "scrfd_10g+w600k_mbf".to_string(), 0, false),
(4, "scrfd_10g+w600k_mbf".to_string(), 0, false),
]
);
// Re-enterable: nothing left to rename.
c.pragma_update(None, "user_version", 19).unwrap();
migrate(&c).unwrap();
let n: i64 = c
.query_row("SELECT count(*) FROM face_index", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 4);
}
#[test]
fn job_uniqueness_coalesces_rather_than_duplicating() {
let c = mem();
+20
View File
@@ -304,6 +304,26 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
error::guarded("metadata", || metadata_unguarded(bytes))
}
/// TRACES: FR-CAT-5
/// Where a TIFF-shaped file keeps its first IFD, when the head handed to
/// [`metadata`] does not reach it — the linear DNG a merge writes puts its
/// IFDs after the pixels, and rawler, given the head alone, finds no
/// decoder in it. The caller fetches from this offset to the end and
/// reads the two ranges with [`metadata_split`].
pub fn trailing_ifd(head: &[u8]) -> Option<u64> {
locate::trailing_ifd(head)
}
/// TRACES: FR-CAT-5
/// [`metadata`] for a file read in two ranges: `head` from offset 0 and
/// `tail` from `tail_at`. The EXIF sub-IFD such a file wrote before its
/// pixels is in the head; the first IFD and its values are in the tail.
pub fn metadata_split(head: &[u8], tail: &[u8], tail_at: u64) -> Result<Metadata, DecodeError> {
error::guarded("metadata", || {
locate::tiff_metadata_split(head, tail, tail_at)
})
}
fn metadata_unguarded(bytes: &[u8]) -> Result<Metadata, DecodeError> {
use rawler::rawsource::RawSource;
+82 -13
View File
@@ -183,22 +183,65 @@ struct Entry {
value: u32,
}
/// The bytes a [`TiffReader`] reads: the head of a file, and optionally a
/// second range from further in, at a known offset.
///
/// A camera writes its IFDs at the front, so the first 256 KB of a file
/// is the whole structure. A file written strip by strip — the linear
/// DNG a merge produces — has its first IFD at the *end*, after the
/// pixels, and a reader that only has the head sees a pointer into
/// nothing. Rather than fetch 800 MB to read a date, the caller fetches
/// the head, asks [`crate::trailing_ifd`] where the IFD is, fetches that
/// tail, and reads through both. Offsets are the file's own throughout;
/// a read that falls in neither range is simply absent.
#[derive(Clone, Copy)]
struct Src<'a> {
head: &'a [u8],
tail: &'a [u8],
/// Where `tail` starts in the file.
tail_at: usize,
}
impl<'a> Src<'a> {
fn whole(data: &'a [u8]) -> Self {
Src {
head: data,
tail: &[],
tail_at: 0,
}
}
fn get(&self, start: usize, len: usize) -> Option<&'a [u8]> {
let end = start.checked_add(len)?;
if let Some(b) = self.head.get(start..end) {
return Some(b);
}
let s = start.checked_sub(self.tail_at)?;
self.tail.get(s..s.checked_add(len)?)
}
}
/// A minimal TIFF structure reader.
///
/// Deliberately not a general TIFF parser: it reads the IFD chain and entry
/// values and nothing else, because that is all locating a preview needs.
struct TiffReader<'a> {
data: &'a [u8],
data: Src<'a>,
little_endian: bool,
first_ifd: u32,
}
impl<'a> TiffReader<'a> {
fn new(data: &'a [u8]) -> Option<Self> {
if data.len() < 8 {
Self::over(Src::whole(data))
}
fn over(data: Src<'a>) -> Option<Self> {
let head = data.head;
if head.len() < 8 {
return None;
}
let little_endian = match &data[0..2] {
let little_endian = match &head[0..2] {
b"II" => true,
b"MM" => false,
_ => return None,
@@ -362,9 +405,7 @@ impl<'a> TiffReader<'a> {
};
raw[..len.min(4)].to_vec()
} else {
self.data
.get(e.value as usize..e.value as usize + len)?
.to_vec()
self.data.get(e.value as usize, len)?.to_vec()
};
let s = String::from_utf8_lossy(&bytes);
@@ -396,8 +437,7 @@ impl<'a> TiffReader<'a> {
// same way to recover the original byte order.
return None;
}
let start = e.value as usize;
self.data.get(start..start.checked_add(len)?)
self.data.get(e.value as usize, len)
}
fn offsets(&self, e: &Entry) -> Vec<u32> {
@@ -420,8 +460,8 @@ impl<'a> TiffReader<'a> {
}
}
fn read_u16(data: &[u8], at: usize, le: bool) -> Option<u16> {
let b = data.get(at..at + 2)?;
fn read_u16(data: Src<'_>, at: usize, le: bool) -> Option<u16> {
let b = data.get(at, 2)?;
Some(if le {
u16::from_le_bytes([b[0], b[1]])
} else {
@@ -429,8 +469,8 @@ fn read_u16(data: &[u8], at: usize, le: bool) -> Option<u16> {
})
}
fn read_u32(data: &[u8], at: usize, le: bool) -> Option<u32> {
let b = data.get(at..at + 4)?;
fn read_u32(data: Src<'_>, at: usize, le: bool) -> Option<u32> {
let b = data.get(at, 4)?;
Some(if le {
u32::from_le_bytes([b[0], b[1], b[2], b[3]])
} else {
@@ -460,7 +500,36 @@ pub fn jpeg_metadata(bytes: &[u8]) -> Result<crate::Metadata, crate::DecodeError
/// no `DateTimeOriginal` for some DNGs whose tag sits plainly at byte 826 —
/// and without this fallback those images are silently undated.
pub fn tiff_metadata(tiff_data: &[u8]) -> Result<crate::Metadata, crate::DecodeError> {
let reader = TiffReader::new(tiff_data)
tiff_metadata_over(Src::whole(tiff_data))
}
/// Where a TIFF-shaped file's first IFD is, when the head does not reach
/// it: the offset to fetch from, so [`tiff_metadata_split`] can read it.
/// `None` for a file that is not TIFF, or whose IFD the head already holds.
pub fn trailing_ifd(head: &[u8]) -> Option<u64> {
let r = TiffReader::new(head)?;
let at = r.first_ifd as u64;
(at >= head.len() as u64).then_some(at)
}
/// [`tiff_metadata`] over a head and a tail fetched separately: the head
/// from offset 0, the tail from `tail_at`. For the file whose IFDs follow
/// its pixels.
pub fn tiff_metadata_split(
head: &[u8],
tail: &[u8],
tail_at: u64,
) -> Result<crate::Metadata, crate::DecodeError> {
tiff_metadata_over(Src {
head,
tail,
tail_at: usize::try_from(tail_at)
.map_err(|_| crate::DecodeError::Metadata("tail offset out of range".into()))?,
})
}
fn tiff_metadata_over(src: Src<'_>) -> Result<crate::Metadata, crate::DecodeError> {
let reader = TiffReader::over(src)
.ok_or_else(|| crate::DecodeError::Metadata("malformed EXIF header".into()))?;
let mut md = crate::Metadata::default();
+28
View File
@@ -241,6 +241,8 @@ mod tests {
let source = SourceMetadata {
make: Some("Canon".into()),
model: Some("Canon EOS 6D".into()),
captured_at: Some(1_754_398_664),
captured_offset: Some(120),
..Default::default()
};
write_linear_dng(
@@ -301,6 +303,32 @@ mod tests {
assert_eq!((crop.p.x, crop.p.y, crop.d.w, crop.d.h), (2, 1, 15, 10));
}
#[test]
fn the_catalog_reads_the_date_from_a_head_and_a_tail() {
// TRACES: FR-CAT-5
// The IFDs follow the pixels, so a scan that has the first bytes of
// the file has a pointer into nothing; rawler finds no decoder in
// that, and the composite would sit undated at the end of the grid.
// The scan's second range — from the first IFD to the end — with
// the head is enough to date it, and to name the camera.
let bytes = write(640, 400, 64);
let head = &bytes[..4096];
assert!(
dr_decode::metadata(head).is_err(),
"the head alone must not read"
);
let at = dr_decode::trailing_ifd(head).expect("the IFD is beyond the head");
assert!(at as usize > head.len());
let tail = &bytes[at as usize..];
assert!(tail.len() < 4096, "the tail is the IFD, not the pixels");
let md = dr_decode::metadata_split(head, tail, at).expect("read from two ranges");
assert_eq!(md.captured_at, Some(1_754_398_664));
assert_eq!(md.captured_offset, Some(120));
assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"));
// A head that holds everything is not a trailing-IFD file.
assert_eq!(dr_decode::trailing_ifd(&bytes), None);
}
#[test]
fn a_strip_of_the_wrong_length_is_refused() {
let mut bytes = std::io::Cursor::new(Vec::new());
+47
View File
@@ -1347,6 +1347,53 @@ mod tests {
}
}
#[test]
fn a_grey_step_edge_stays_grey() {
// A flat patch cannot tell the Malvar kernels from any other set of
// weights that sum to zero. An edge can. A grey vertical step, so
// every photosite records the same profile, must come back with the
// three channels close together on both sides; any spread is false
// colour from interpolating across the edge.
//
// The bound is set by the paper's kernels, which peak at 0.19 here.
// With the ±2 terms of the green-site kernels transposed — the bug
// this test was written against — the peak is 0.375.
let Some(ctx) = ctx() else { return };
let d = Demosaicer::new(&ctx).expect("demosaicer");
let size = 32u32;
let white = 16383u16;
let mut raw = flat_cfa(CfaPattern::Rggb, size, [0, 0, 0], 0, white);
for y in 0..size {
for x in size / 2..size {
raw.data[(y * size + x) as usize] = white;
}
}
let img = d.run(&raw).expect("demosaic");
let px = read_rgba(&ctx, &img);
let (w, _) = img.size();
let mut worst = (0.0f32, 0u32, 0u32);
for y in 2..size - 2 {
for x in 2..size - 2 {
let p = px[(y * w + x) as usize];
let spread = (p[0] - p[1]).abs().max((p[2] - p[1]).abs());
if spread > worst.0 {
worst = (spread, x, y);
}
}
}
assert!(
worst.0 < 0.25,
"false colour of {} at ({}, {}) on a grey edge — the green-site \
kernels are interpolating across the edge",
worst.0,
worst.1,
worst.2
);
}
#[test]
fn output_is_free_of_nan_and_negatives() {
// f16 NaN propagates silently through every later stage; a negative
+10 -4
View File
@@ -160,12 +160,18 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
// Green is measured. Red and blue are interpolated from their own
// axis, with a correction from the green Laplacian.
//
// Malvar "G at R/B locations" kernels, transposed per axis:
// chroma along the row: (5c + 4(w1+e1) - (nw+ne+sw+se) - (n2+s2) + 0.5(w2+e2)) / 8
// Malvar "R at green in R row" kernel, and its transpose:
// chroma along the row: (5c + 4(w1+e1) - (nw+ne+sw+se) - (w2+e2) + 0.5(n2+s2)) / 8
//
// The -1 goes on the two greens *along* the chroma axis and the +0.5
// on the pair across it. Transposed, both kernels still sum to zero
// and reconstruct a flat patch exactly, but on an edge the correction
// at green sites is half strength and the false colour doubles: a
// blue/yellow zipper around every clipped highlight.
let along_row =
(5.0 * c + 4.0 * (w1 + e1) - diag1 - vert2 + 0.5 * horiz2) * 0.125;
(5.0 * c + 4.0 * (w1 + e1) - diag1 - horiz2 + 0.5 * vert2) * 0.125;
let along_col =
(5.0 * c + 4.0 * (n1 + s1) - diag1 - horiz2 + 0.5 * vert2) * 0.125;
(5.0 * c + 4.0 * (n1 + s1) - diag1 - vert2 + 0.5 * horiz2) * 0.125;
let red_horizontal = red_is_horizontal(gid.x, gid.y);
let r = select(along_col, along_row, red_horizontal);
+3
View File
@@ -155,6 +155,8 @@ pub struct Status {
/// Engines compiled and engines wanted, for a compiling rung; `(0, 0)`
/// otherwise.
pub engines: (usize, usize),
/// Every rung above the selected one that was tried, and why it lost.
pub failed: Vec<(Rung, String)>,
}
impl Status {
@@ -399,6 +401,7 @@ pub fn status() -> Status {
runtime: api::runtime(),
rung,
reason: s.cache.reason.clone(),
failed: s.cache.failed.clone(),
probing: s.probing,
engines: if rung.compiles() {
(s.cache.compiled.len(), s.wanted)
+86 -14
View File
@@ -89,12 +89,19 @@ pub struct Params {
pub coarse: usize,
/// The fine passes' band width.
pub band: usize,
/// How deep into the picture the mirrored context reaches. A plain
/// reflection of a deep hole pulls in whatever is that far from the
/// edge — a ridge, a peak — and the model, told that is what lies
/// beyond, paints it upside down. Folding the reflection within this
/// band keeps the ring looking like the edge it continues (sky beside
/// sky, grass beside grass) and nothing further away.
/// How deep into the picture the mirrored context reaches, or **zero
/// for no mirrored context at all**: the void is then shown to the
/// model as it is — reaching the picture's edge with nothing beyond,
/// and, beyond the band being filled, still unknown. That is what the
/// shipped model was trained on (a fine-tune of MI-GAN on voids cut
/// from photographs the way a cylindrical merge cuts them, see
/// `docs/panorama.md` §14); a ring would give it a fold to continue.
///
/// Non-zero is the stock model's crutch: a plain reflection of a deep
/// hole pulls in whatever is that far from the edge — a ridge, a peak —
/// and the model, told that is what lies beyond, paints it upside down.
/// Folding the reflection within this band keeps the ring looking like
/// the edge it continues and nothing further away.
pub mirror_depth: usize,
/// How far inside the real edge the fill also regenerates, the two
/// blended by distance. A hard cut between real pixels and invented
@@ -108,9 +115,9 @@ pub struct Params {
impl Default for Params {
fn default() -> Self {
Params {
coarse: 4,
band: 96,
mirror_depth: 48,
coarse: 1,
band: 192,
mirror_depth: 0,
feather: 24,
stride: 384,
}
@@ -141,7 +148,10 @@ pub fn fill_border(
} = params;
let q = q.max(1);
let band = band.max(8);
let mirror_depth = mirror_depth.max(1);
// No ring: the void beyond the band stays unknown, as in the model's
// training; with a ring the far side is the coarse fill, presented as
// known, which the stock model needed to see something there.
let open = mirror_depth == 0;
if width == 0 || height == 0 || rgb.len() != width * height * 3 || known.len() != width * height
{
return Err(PanoError::Input("fill: buffer sizes disagree".into()));
@@ -227,7 +237,11 @@ pub fn fill_border(
let mut any = false;
for i in 0..width * height {
let in_band = !known[i] && dist[i] > lo && dist[i] <= hi;
band_known[i] = !in_band;
band_known[i] = if open {
known[i] || dist[i] <= lo
} else {
!in_band
};
any |= in_band;
}
if !any {
@@ -369,7 +383,7 @@ fn fill_once(
if known[i] {
continue;
}
let p = (yy + RING) * pw + (xx + RING);
let p = (yy + ctx.ring) * pw + (xx + ctx.ring);
if wsum[p] > 0.0 {
for ch in 0..3 {
rgb[i * 3 + ch] = (acc[p * 3 + ch] / wsum[p]).clamp(0.0, 1.0);
@@ -478,12 +492,25 @@ fn fold(d: usize, depth: usize) -> usize {
struct MirroredContext {
width: usize,
height: usize,
/// The padding on every side: `RING` with mirrored context, 0 without.
ring: usize,
rgb: Vec<f32>,
hole: Vec<bool>,
}
impl MirroredContext {
fn build(rgb: &[f32], width: usize, height: usize, known: &[bool], depth: usize) -> Self {
if depth == 0 {
// Open: the picture as it is, the hole as it is. What the hole
// holds does not matter — the model masks it out.
return MirroredContext {
width,
height,
ring: 0,
rgb: rgb.to_vec(),
hole: known.iter().map(|&k| !k).collect(),
};
}
let fold = |d: usize| fold(d, depth);
let (pw, ph) = (width + 2 * RING, height + 2 * RING);
let mut canvas = vec![0.0f32; pw * ph * 3];
@@ -534,6 +561,7 @@ impl MirroredContext {
MirroredContext {
width: pw,
height: ph,
ring: RING,
rgb: canvas,
hole,
}
@@ -634,7 +662,10 @@ mod tests {
200,
&known,
&mut model,
test_params(0),
Params {
mirror_depth: 48,
..test_params(0)
},
&mut |_, _| {},
)
.unwrap();
@@ -648,6 +679,42 @@ mod tests {
}
}
#[test]
fn an_open_void_reaches_the_tile_edge_and_stays_unknown_beyond_the_band() {
// A 150-tall hole above and below; bands of 96. With no ring the
// first band's tiles sit at the picture's edge, so a tile's top
// row is unknown, and the rows deeper than the band are unknown
// too — not "known" coarse fill — exactly as the model was trained.
let (mut rgb, known) = picture(200, 500, 150);
let mut model = Flat {
tile: 64,
seen: Vec::new(),
};
fill_border(
&mut rgb,
200,
500,
&known,
&mut model,
Params {
band: 96,
..test_params(0)
},
&mut |_, _| {},
)
.unwrap();
// The first pass's tile at the picture's top edge is unknown
// through and through: the hole is 150 deep, the tile 64, and
// nothing beyond the band was presented as known. With a ring, or
// with the far side shown as coarse fill, no tile is ever all hole.
assert!(model.seen.iter().any(|(_, k)| k.iter().all(|&v| !v)));
for i in 0..200 * 500 {
if !known[i] {
assert!((rgb[i * 3] - 0.5).abs() < 1e-4, "pixel {i}");
}
}
}
#[test]
fn the_fine_passes_run_in_bands_after_the_coarse_one() {
// A 150-tall hole above and below a picture: the coarse pass sees
@@ -663,7 +730,12 @@ mod tests {
500,
&known,
&mut model,
test_params(0),
Params {
coarse: 4,
band: 96,
mirror_depth: 48,
..test_params(0)
},
&mut |_, _| {},
)
.unwrap();
+30 -3
View File
@@ -47,6 +47,20 @@ pub struct NextcloudBackend {
/// `/remote.php/dav/files/<user>/` — the prefix stripped from hrefs.
dav_base: String,
caps: Capabilities,
/// Collections this backend has seen exist, so [`create_dir`] asks the
/// server about each one once.
///
/// Every `MOVE` guarantees its destination's parent, and did so with a
/// `MKCOL` for each ancestor down from the account root — for a trash
/// folder three levels deep that was three round trips of `405 Method
/// Not Allowed` before the one request that moved anything, on every
/// image of a batch. A backend lives for one job (`dr_ui::remote::
/// connect` builds one per worker), so a folder deleted by another
/// client mid-job is the one case this can get wrong, and it is reported
/// as the `409` the `MOVE` then earns rather than hidden.
///
/// [`create_dir`]: RemoteBackend::create_dir
known_dirs: std::sync::Mutex<std::collections::HashSet<String>>,
}
impl NextcloudBackend {
@@ -63,6 +77,7 @@ impl NextcloudBackend {
login: creds.login_name.clone(),
password: creds.app_password.clone(),
dav_base,
known_dirs: Default::default(),
caps: Capabilities {
// The property that makes a no-op sync one request (ARCH §8.1).
change_detection: ChangeDetection::PropagatingEtags,
@@ -567,6 +582,16 @@ impl RemoteBackend for NextcloudBackend {
chain.reverse();
for dir in chain {
// Asked once per backend — see `known_dirs`. The lock is held
// across no await: it is taken to look, and again to record.
let known = self
.known_dirs
.lock()
.map(|k| k.contains(dir.as_str()))
.unwrap_or(false);
if known {
continue;
}
let url = self.url_for(&dir);
let resp = self
.client
@@ -581,10 +606,12 @@ impl RemoteBackend for NextcloudBackend {
// 405 is "already a collection here", which is exactly what the
// caller wanted. Anything else is reported.
if resp.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED {
continue;
if resp.status() != reqwest::StatusCode::METHOD_NOT_ALLOWED {
map_status(resp.status(), &url)?;
}
if let Ok(mut k) = self.known_dirs.lock() {
k.insert(dir.as_str().to_string());
}
map_status(resp.status(), &url)?;
}
Ok(())
}
+14
View File
@@ -127,6 +127,19 @@ pub struct Account {
#[serde(default)]
pub root: String,
/// Whether [`root`](Self::root) has been chosen at all.
///
/// An empty `root` is two different things: nothing picked yet, and the
/// endpoint itself picked on purpose — a user who keeps everything at
/// the top level, or a folder library, which is its own root. The string
/// cannot tell them apart, and reading empty as "not chosen" meant the
/// top level could be confirmed in the picker and still not open. So the
/// fact is recorded separately. Defaulted, so an account written before
/// it existed loads as it always did: a non-empty root is chosen by
/// virtue of being there, and an empty one asks again.
#[serde(default)]
pub root_chosen: bool,
/// Which formats the scan looks for (the tick-boxes).
#[serde(default)]
pub formats: Vec<String>,
@@ -149,6 +162,7 @@ impl Account {
login: String::new(),
user_id: String::new(),
root: String::new(),
root_chosen: false,
formats: Vec::new(),
last_scan: None,
}
+80
View File
@@ -240,6 +240,23 @@ impl ThumbStore {
.collect()
}
/// Every file id stored at one size, read from the index in one query.
///
/// For a pass that asks about thousands of images at once — the face
/// audit, the repair lists. Each [`contains`](Self::contains) is a
/// prepared statement and a b-tree probe; asked ten thousand times over a
/// scan it costs more than the scan does, where one walk of the index is
/// a few milliseconds and answers every row.
pub fn held(&self, size: ThumbSize) -> Result<std::collections::HashSet<u64>, ThumbError> {
let mut stmt = self
.index
.prepare("SELECT file_id FROM entries WHERE size = ?1")?;
let ids = stmt
.query_map([size as i64], |r| r.get::<_, i64>(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(ids.into_iter().map(|id| id as u64).collect())
}
/// Store a thumbnail, opening a new shard if the active one is full.
///
/// Re-storing an existing id overwrites in place rather than migrating it
@@ -481,6 +498,30 @@ impl ThumbStore {
self.dir.join(format!("shard-{shard:04}.sqlite"))
}
/// Write a coherent copy of one shard to `dest`, ready to upload.
///
/// Not a file copy. Every shard is in WAL mode and every `put` opens its
/// own connection, so while thumbnails are being generated on several
/// threads at once — which is exactly when the first sync pass runs —
/// there is nearly always a connection open and the log is never
/// checkpointed. The main file then holds whatever the *last* quiet
/// moment left in it, which for a shard created seconds ago is nothing:
/// zero bytes, the schema still in the log. Reading it uploaded an empty
/// file, and every other device merging it failed with "no such table:
/// thumbs". The backup API serialises against writers and copies the
/// database as it is, log included.
pub fn snapshot_shard(&self, shard: u32, dest: &Path) -> Result<(), ThumbError> {
let source = self.open_shard(shard, false)?;
let _ = std::fs::remove_file(dest);
let mut out = Connection::open(dest)?;
let backup = rusqlite::backup::Backup::new(&source, &mut out)?;
// rusqlite asserts a positive page count where SQLite would take -1
// for "everything"; a shard is capped well under this many pages.
backup.run_to_completion(i32::MAX, std::time::Duration::ZERO, None)?;
drop(backup);
Ok(())
}
pub fn index_path(&self) -> PathBuf {
self.dir.join("index.sqlite")
}
@@ -726,6 +767,45 @@ mod tests {
}
}
#[test]
fn a_snapshot_carries_what_the_shard_file_does_not_yet() {
// A thumbnail worker holding the shard open keeps the log from being
// checkpointed; the file on disk is then not the database. The
// upload used to read that file.
let (mut s, _d) = store();
s.put(1, ThumbSize::Grid, &thumb(1024)).unwrap();
let path = s.shard_path(0);
let worker = Connection::open(&path).unwrap();
worker.pragma_update(None, "journal_mode", "WAL").unwrap();
s.put(2, ThumbSize::Grid, &thumb(2048)).unwrap();
s.put(3, ThumbSize::Grid, &thumb(2048)).unwrap();
// What a byte-for-byte reader sees is at most what the last
// checkpoint left; the log is the part a copy misses.
let copy = _d.join("copied.sqlite");
std::fs::copy(&path, &copy).unwrap();
let file_rows = Connection::open(&copy)
.ok()
.and_then(|c| {
c.query_row("SELECT count(*) FROM thumbs", [], |r| r.get::<_, i64>(0))
.ok()
})
.unwrap_or(0);
let snap = _d.join("upload.sqlite");
s.snapshot_shard(0, &snap).unwrap();
let snapshot = Connection::open(&snap).unwrap();
let rows: i64 = snapshot
.query_row("SELECT count(*) FROM thumbs", [], |r| r.get(0))
.unwrap();
assert_eq!(rows, 3, "the snapshot is the whole shard");
assert!(
file_rows < 3,
"the file alone lagged ({file_rows} rows), which is what the snapshot exists for"
);
drop(worker);
}
#[test]
fn a_forgotten_thumbnail_is_no_longer_served() {
// The point of the whole method: a purged photograph must not keep a
+10 -4
View File
@@ -885,9 +885,14 @@ impl ExportSettings {
///
/// The library root has to read as a place rather than as a blank field,
/// or confirming the picker where it opens looks like it did nothing.
///
/// An empty device folder used to read "Ask each time", which nothing
/// does: no platform asks, and an export made with the field blank is
/// refused with "no export folder is set". The label now says what will
/// happen rather than what was once meant to.
pub fn destination_label(&self) -> &str {
match self.target {
ExportTarget::Device if self.destination.trim().is_empty() => "Ask each time",
ExportTarget::Device if self.destination.trim().is_empty() => "Not set",
ExportTarget::Device => &self.destination,
ExportTarget::Remote if self.remote_destination.trim().is_empty() => "Library root",
ExportTarget::Remote => &self.remote_destination,
@@ -1621,14 +1626,15 @@ mod tests {
}
#[test]
fn an_empty_device_destination_still_means_ask() {
fn an_empty_device_destination_is_not_set_and_says_so() {
// The asymmetry is deliberate: a filesystem has no folder worth
// assuming, so empty there is a question rather than an answer.
// assuming, so empty there is a gap rather than an answer — and the
// label must not promise a question nobody will be asked.
let mut s = Settings::default();
s.export.target = ExportTarget::Device;
s.export.destination = String::new();
assert!(!s.export.destination_is_set());
assert_eq!(s.export.destination_label(), "Ask each time");
assert_eq!(s.export.destination_label(), "Not set");
}
#[test]
+19 -19
View File
@@ -25,7 +25,7 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between.
<sub>`ui/dr-ui/ui/app.slint:2068`</sub>
<sub>`ui/dr-ui/ui/app.slint:2070`</sub>
### Move a magnified photograph about
@@ -34,7 +34,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
<sub>`ui/dr-ui/ui/app.slint:2159`</sub>
<sub>`ui/dr-ui/ui/app.slint:2161`</sub>
### Paint a mask by hand
@@ -43,7 +43,7 @@ Only once there is something outside the viewport to reach, which is why the cur
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
<sub>`ui/dr-ui/ui/app.slint:2246`</sub>
<sub>`ui/dr-ui/ui/app.slint:2248`</sub>
### Take back the last change
@@ -53,7 +53,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
<sub>`ui/dr-ui/ui/app.slint:2467`</sub>
<sub>`ui/dr-ui/ui/app.slint:2469`</sub>
### Do it again after taking it back
@@ -61,7 +61,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Pointer** — Click it, or press Redo in the History header
- **Keyboard** — Ctrl+Shift+Z
<sub>`ui/dr-ui/ui/app.slint:2480`</sub>
<sub>`ui/dr-ui/ui/app.slint:2482`</sub>
### Copy the settings from this photograph
@@ -71,7 +71,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
<sub>`ui/dr-ui/ui/app.slint:2513`</sub>
<sub>`ui/dr-ui/ui/app.slint:2515`</sub>
### Paste the settings onto this photograph
@@ -81,7 +81,7 @@ The panel is the copy that has to work: a tablet has no modifier key to hold and
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
<sub>`ui/dr-ui/ui/app.slint:2525`</sub>
<sub>`ui/dr-ui/ui/app.slint:2527`</sub>
### Change which group of adjustments is on screen
@@ -91,7 +91,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
<sub>`ui/dr-ui/ui/app.slint:2553`</sub>
<sub>`ui/dr-ui/ui/app.slint:2555`</sub>
### Look at the photograph at 1:1
@@ -101,7 +101,7 @@ The groups are whatever the operation set declares itself to be about, so there
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans.
<sub>`ui/dr-ui/ui/app.slint:2588`</sub>
<sub>`ui/dr-ui/ui/app.slint:2590`</sub>
### Move to the next or previous photograph
@@ -111,7 +111,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing.
<sub>`ui/dr-ui/ui/app.slint:2640`</sub>
<sub>`ui/dr-ui/ui/app.slint:2642`</sub>
### See the photograph before you edited it
@@ -121,7 +121,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
<sub>`ui/dr-ui/ui/app.slint:2764`</sub>
<sub>`ui/dr-ui/ui/app.slint:2766`</sub>
### Put one control back to its default
@@ -326,7 +326,7 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
<sub>`ui/dr-ui/ui/library.slint:2789`</sub>
<sub>`ui/dr-ui/ui/library.slint:2795`</sub>
### File photographs in a collection
@@ -335,7 +335,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
<sub>`ui/dr-ui/ui/library.slint:2986`</sub>
<sub>`ui/dr-ui/ui/library.slint:2992`</sub>
### Open a photograph
@@ -344,7 +344,7 @@ The selection is what the drag carries, which is why selecting several is worth
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
<sub>`ui/dr-ui/ui/library.slint:3254`</sub>
<sub>`ui/dr-ui/ui/library.slint:3260`</sub>
### Rate a photograph without opening it
@@ -354,7 +354,7 @@ A tap opens; a tap that *moved* does not. Travel is what separates a deliberate
A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
<sub>`ui/dr-ui/ui/library.slint:3374`</sub>
<sub>`ui/dr-ui/ui/library.slint:3380`</sub>
### Choose the frame a folded burst shows
@@ -363,7 +363,7 @@ A star has to take the press without it also reaching the cell, or every rating
A folded burst draws its earliest frame, which is a fact about the clock and not a judgement about the photograph — nothing in this application ranks a frame (FR-CULL-5). But the point of a burst is that one of the twelve is better than the other eleven, and the photographer is the only one who knows which. So the choice is offered on the frames themselves, while they are open and side by side, which is the one moment the alternatives are on screen to be compared.
<sub>`ui/dr-ui/ui/library.slint:3505`</sub>
<sub>`ui/dr-ui/ui/library.slint:3511`</sub>
### Drop the selection but keep selecting
@@ -372,7 +372,7 @@ A folded burst draws its earliest frame, which is a fact about the clock and not
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
<sub>`ui/dr-ui/ui/library.slint:4166`</sub>
<sub>`ui/dr-ui/ui/library.slint:4177`</sub>
### Select everything the grid is showing
@@ -381,7 +381,7 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
<sub>`ui/dr-ui/ui/library.slint:4183`</sub>
<sub>`ui/dr-ui/ui/library.slint:4194`</sub>
### Take photographs out of a collection
@@ -390,4 +390,4 @@ A scoped grid of two hundred frames is two hundred taps otherwise, and "all of t
The badge on a cell says a photograph is filed in three collections and never which. This is the sheet that names them, and the only way out of one the grid is not currently scoped to.
<sub>`ui/dr-ui/ui/library.slint:4307`</sub>
<sub>`ui/dr-ui/ui/library.slint:4318`</sub>
+224
View File
@@ -0,0 +1,224 @@
# DarkRoom, shown
A tour of what the application does, one picture per thing. Every image on
this page was captured from the desktop build driving itself — nothing is a
mock-up, and nothing has been retouched outside DarkRoom. Where a feature is
better seen moving, it moves.
The requirements behind each feature are in [requirements.md](../requirements.md);
the reasoning is in the design documents linked from each section. This page
is only about what you see.
The photographs are the author's. None show a person.
## Opening a library
DarkRoom opens on a library: a folder on this machine, a folder a sync
client keeps, or a Nextcloud account. A folder needs no password and uploads
nothing.
![The launch screen: a server field, a folder field, and which formats to scan for](media/launch.png)
Once a folder is named, it is the library — you are not asked for it again,
and `Open library` opens it whole. `Subfolder…` narrows the scan to part of
it. The formats ticked are what the scan looks for; RAW is on and JPEG off
by default, because a RAW editor's sensible default is the file the camera
wrote first.
![A folder chosen: the library, whether to scan a subfolder, and the formats](media/launch-folder.png)
## The library
![The grid: collections on the left, the timeline beside it, the roll of thumbnails, and the filter bar above](media/library.png)
Photographs are ordered by capture time, with a month heading where each
begins. The strip on the left is the timeline — drag it to jump to a year.
The bar above the grid filters by rating, flag and where the file is (on this
device, or only on the server).
### Rating and flagging
Hover a cell and the stars appear; click one. The filter chips above the grid
count what each rating holds, and clicking `3+` shows only those.
![Rating two photographs, then filtering the grid to three stars and more](media/library-rating.gif)
### Getting about
Drag the timeline to scrub through years; Ctrl and the wheel resize the
thumbnails.
![Scrubbing the timeline](media/library-timeline.gif)
![Resizing the thumbnails with Ctrl and the wheel](media/library-thumbsize.gif)
### Selecting several
`Select` in the header — or Ctrl-click — starts a selection. Shift-click picks
a range. The bar at the foot of the grid is everything a selection can be done
to: collections, keywords, presets, export, and merging to a panorama.
![Twelve photographs selected, with the selection bar along the foot of the grid](media/library-selection.png)
`Keywords` on that bar opens a sheet; type a word and press return, and it
is on every photograph selected. The list below the field is every keyword
the library has, ticked where the selection carries it.
![Keywording twelve frames](media/library-keywords.gif)
### Collections
`+` at the head of the sidebar makes one. Drag a photograph — or the whole
selection — onto its row to file it there; click the row to see it. A
photograph can be in several, and the badge on its cell counts them.
![Filing photographs in a collection by dragging them onto it](media/library-collections.gif)
## Developing a photograph
Click a thumbnail to open it. The column on the right is every adjustment;
the strip at its head narrows it to one group.
![The develop view: the photograph, the histogram, and the adjustment column](media/develop.png)
![Switching between the Optics, Light, Colour, Effects and Detail groups](media/develop-groups.gif)
### Light
Exposure, contrast, highlights, shadows, blacks, whites and a tone curve.
Hold `Before` to see the photograph as it was.
![Raising exposure, pulling the highlights, lifting the shadows, then holding Before](media/develop-light.gif)
### Looking closer
Double-click for 1:1; drag to move about; double-click again to fit. The
wheel zooms to any amount in between.
![Zooming to 1:1, panning, and back](media/develop-zoom.gif)
### White balance from the photograph
Press `pick` in the White Balance group, then click something neutral.
![Picking a neutral wall to set the white balance](media/develop-wb.gif)
### Composing
Crop by dragging the frame's corners, straighten with the slider, lock a
ratio from the chips. `Done composing` returns to the photograph.
![Cropping, straightening and choosing a ratio](media/compose.gif)
### Local adjustments
`Local` in the rail turns the column into a mask stack. `Find subjects` runs a
segmentation model over the photograph; what it recognises appears as a list
of categories with how much of the frame each covers. Click one and it is a
mask — then every slider below edits only that region.
![What the model found in an urban scene: ground, architecture, sky, vegetation](media/local-categories.png)
![The sky chosen: tinted on the photograph, and the column now scoped to it](media/local-segment.png)
A mask is a stack of parts. Paint into it, subtract a gradient from it, grow
or shrink its edge, choose how it falls off. `Show masks as` draws the mask
tinted, as alpha, or as an outline; the eye on its row switches it off.
![Looking at the mask three ways, painting into it, then growing its edge](media/local-paint.gif)
Linear and radial gradients, a tone range and a colour range are the other
ways to make one; each can be combined with any other.
### Repair
`Repair` in the rail: click a mark and it is covered from a source DarkRoom
chooses beside it. Drag either circle to move it; the size, feather and
opacity are in the panel. Heal blends; clone copies.
![Covering marks on a road](media/repair.gif)
### Film
The `Film` chooser at the head of Adjust applies a spectral simulation of a
named stock; below it, the print exposure and push controls a film has and a
sensor does not.
![Choosing Velvia, then holding Before](media/film.gif)
### History, snapshots, presets
Every change is a step; `Undo` and the History panel walk them. `Snapshot`
keeps the current state under a name. `Presets…` saves the settings to
apply elsewhere, and imports `.xmp` from other applications.
![The presets sheet](media/presets.png)
## Merging a panorama
Select the frames, then `Merge to panorama` from the selection bar. The
frames are read, aligned, and drawn on the suggested projection with each one
outlined where it landed — twelve hand-held portrait frames across an alpine
valley, here. Change the projection (a 150° sweep on a flat perspective is
what the middle of the film shows, and why cylindrical is suggested), ask for
the border to be filled rather than cropped, then `Merge`. The composite is
written beside its sources as a DNG and appears in the grid with the merge
as the first step in its history.
![Twelve frames aligned, the projections tried, and the border filled](media/panorama.gif)
![The alignment on a cylinder, each frame outlined where it landed](media/panorama-aligned.png)
![The same, with the ragged border filled by the model rather than cropped away](media/panorama-filled.png)
## Export
`Export` in the develop header, or `Export N` from a selection. Format,
size, colour space, sharpening, naming and where the file goes are in
Settings, and apply to every export until changed. An export with no folder
set is refused, and the header says so.
![Export defaults in Settings](media/settings-export.png)
## Settings
![The settings page: background activity, indexing, storage, display](media/settings.png)
Background activity with progress, thumbnail and face indexing, storage on
this device, display and colour, export defaults, what is written to XMP
sidecars, and a diagnostics bundle for a bug report.
## People
Face detection and identity run over the library and group faces by person;
the `Identity` page is where suggestions are confirmed, rejected and split,
and `People` on the filter bar narrows the grid to someone. Not pictured
here, for the obvious reason — [faces.md](../faces.md) has the design.
## Where things are written down
| Feature | Design |
|---|---|
| Local masks and segmentation | [segmentation.md](../segmentation.md), [mask-editing.md](../mask-editing.md) |
| Repair | [spot-removal.md](../spot-removal.md) |
| Panorama | [panorama.md](../panorama.md) |
| Faces and identity | [faces.md](../faces.md) |
| Gestures, generated from the code | [gestures.md](../gestures.md) |
| Navigation and layout | [ui-navigation.md](../ui-navigation.md) |
| Sync and storage | [storage.md](../storage.md) |
## How this page is made
[`tools/manual/`](../../tools/manual/README.md) drives the desktop build on
a private X server and records each scene; `record.sh <library>` re-makes
every picture here. Run it after a change to the interface and commit what
changed. The pictures are in LFS.
Making it the first time turned up nine faults, each fixed in its own
commit before the pictures were taken: the folder picker could not choose
the top level, month headings overprinted each other, a category mask
widened the column off the window, the mask tint outlived its mode, the
first sync uploaded an empty thumbnail shard, a merge that ran out of GPU
memory left the page on Stop for ever, the export settings promised to ask
for a folder and did not, the keyword sheet sent its keys to the grid, and
an empty trash told you to check your library folder.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+58
View File
@@ -486,3 +486,61 @@ rectangle.
`models/LICENCE.md`), installed by the PKGBUILD and unpacked by the APK
beside the face and scene models; `tools/export-migan.sh` regenerates it
from the upstream checkpoint.
## 14. The fill, trained — 2026-09-20
§13.5 named the remaining fault: in a deep corner the stock model puts its
Places2 prior — clouds, peaks, a water line — into a hole, because it was
trained on holes *inside* pictures and a panorama's border is a hole with
the picture on one side and nothing on the other. Every ring (mirror,
replicate, detrend) treated the symptom. The fix is a model that has seen
the real thing: **MI-GAN's 512 generator fine-tuned on border-shaped voids
cut from the user's own photographs**, in a separate repository
(`darkroom-infill`, beside this one), so the truth beyond the void is known
and the model learns one-sided extrapolation.
**What it was trained on.** Voids made the way this merge makes them:
frames with a yaw, a common pitch and per-frame roll, projected onto the
cylinder and rasterised, the canvas their union's bounding box, the void
the canvas outside the union — arcs where straight edges bent, cusps where
frames meet, the bow-tie wedge at a corner (a third of tiles are cut at a
canvas corner). Voids to 256 px deep at a 512 tile. Half the deep tiles
train the *second pass*: a no-grad first pass fills the tile, its nearest
band (64–256 px) is marked known, and the remaining void is the example —
so the model continues its own output without drift, which is how `fill`
runs deep voids. Data: ~7 400 pictures — the 1024-px proxy tier of the
library and ~2 000 raws sampled evenly across every year, developed at
half size. Loss: hole-weighted L1, VGG16 perceptual, a hinge PatchGAN.
One night on the reference desktop's RTX 3050.
**What changed here.** `FillParams::mirror_depth` **0** is now "no ring":
the void reaches the tile's edge with nothing beyond, and beyond the band
being filled the void stays *unknown* rather than presented as known coarse
fill — the two conditions the model was trained under. Defaults: mirror 0,
coarse 1 (the coarse pass seeds nothing the model is allowed to see), band
192. The ring remains on the page for the stock model's sake, at any depth
above zero. The model file is a drop-in (`models/inpaint/migan-512.onnx`,
same six operators, same tensors) and the engine loads it unchanged.
**Measured, 240 held-out tiles with projection-shaped voids (PSNR in the
hole, dB / LPIPS on the composite), stock → shipped (step 3 607):** edge
16.9 → 18.5 / 0.121 → 0.136; corner 14.6 → 16.3 / 0.183 → 0.205; interior
18.2 → 19.3 / 0.051 → 0.056. Read both columns: the fine-tune gains ~2 dB
on edges and corners because it stops inventing objects, and *loses* on
LPIPS because what it paints in a deep void is smoother than the stock
model's confident wrong texture — LPIPS rewards texture, right or wrong.
On the fixture's dump at half resolution (the merge's working size) the
sky corners are sky, with no structure and a faint tone step at worst;
the ground bands carry a fine texture at the right tone, softer than the
real scree above them. The stock model's top-left corner on the same
dump is a glowing invented structure. The training's own record — what
each loss weighting did, and the two runs abandoned (blur under L1 in
the hole; a brick pattern under a strong adversarial term against a
discriminator that had not learned) — is `runs/` in `darkroom-infill`.
**What is still wrong.** The ground fill is softer than its context —
texture, not structure, is what a night on a laptop GPU could not finish.
The levers, in order: a discriminator that learns (a pretrained one —
MI-GAN's own from the unfused checkpoint — instead of a PatchGAN from
scratch), feature matching, and more steps at 512. FR-MRG-4's
*experimental* stays.
+88 -88
View File
File diff suppressed because one or more lines are too long
+9 -4
View File
@@ -104,11 +104,16 @@ the dataset licence restricts models trained on it by name.
| File | Source | Trained on | Used by |
|---|---|---|---|
| `inpaint/migan-512.onnx` | `migan_512_places2.pt` from `https://github.com/Picsart-AI-Research/MI-GAN` (Sargsyan et al., ICCV 2023) | Places2, by the authors | the panorama border fill (FR-MRG-4) |
| `inpaint/migan-512.onnx` | `migan_512_places2.pt` from `https://github.com/Picsart-AI-Research/MI-GAN` (Sargsyan et al., ICCV 2023), **fine-tuned** in the `darkroom-infill` repository (2026-09-20) | Places2 by the authors, then ~7 400 of the maintainer's own photographs with border-shaped voids | the panorama border fill (FR-MRG-4) |
Exported by `tools/export-migan.sh`: the bare 512 generator at a fixed
`1×4×512×512`, six operator types. The tiling, the context and the blend are
Rust (`dr_pano::fill`).
The bare 512 generator at a fixed `1×4×512×512`, six operator types; the
tiling, the context and the blend are Rust (`dr_pano::fill`). Since
2026-09-20 the shipped file is the fine-tune (`docs/panorama.md` §14),
exported by `python -m infill.export` in `darkroom-infill`;
`tools/export-migan.sh` still produces the stock generator from the upstream
checkpoint, which the fine-tune starts from. The fine-tuned weights are a
derivative of the MIT weights trained on photographs the maintainer owns,
and carry the same MIT grant.
**MIT, code and weights alike** — `LICENSE` and `LICENSE-WEIGHTS` in the
repository, both read on 2026-09-19, both the plain MIT text with no further
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@
# makes `makepkg -si` in this directory install what you are actually working
# on. Swap `source` for a tagged tarball when there is something to release.
pkgname=darkroom
pkgver=0.13.0
pkgver=0.13.3
# Back to 1 with the version: a new pkgver is a new archive name, so there is
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
pkgrel=1
+6
View File
@@ -8,6 +8,12 @@
#
# ./tools/export-migan.sh # -> models/inpaint/migan-512.onnx
#
# Since 2026-09-20 the file that ships is not this export but a fine-tune of
# it on panorama-border voids (docs/panorama.md §14), made in the
# `darkroom-infill` repository with `python -m infill.export`. This script
# still yields the stock generator — the fine-tune's starting point, and the
# model the page's "mirror depth" knob above zero was built around.
#
# Requires `uv`. Everything else is fetched into a throwaway venv, including
# a CPU-only torch and `gdown` for the checkpoint, which the authors keep on
# Google Drive (models/LICENCE.md has the licence; it is MIT).
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Put a GPU-capable ONNX Runtime where the desktop app looks for one
# (docs/inference.md §3): `runtime/` beside the models in the user data
# directory, ahead of the system library.
#
# ./tools/fetch-desktop-runtime.sh [DEST]
#
# The source is the `onnxruntime-gpu` wheel: the one build that carries the
# CUDA *and* TensorRT providers against the cuDNN and TensorRT majors current
# on this machine. Distribution packages tend to have neither — Arch's
# `onnxruntime-opt-cuda` is built without TensorRT and against cuDNN 8 — and
# the probe rejects them correctly and leaves the app on the CPU provider,
# which is what this script exists to fix. Nothing NVIDIA is bundled here:
# the providers load CUDA, cuDNN and TensorRT from the system, and if those
# are missing the probe says so and the app stays on the CPU.
set -euo pipefail
DEST="${1:-${XDG_DATA_HOME:-${HOME}/.local/share}/darkroom/runtime}"
WORK="$(mktemp -d -p /var/tmp fetch-desktop-runtime.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
echo "==> downloading the onnxruntime-gpu wheel"
uv venv --python 3.12 "${WORK}/venv" >/dev/null
VIRTUAL_ENV="${WORK}/venv" uv pip install --quiet onnxruntime-gpu
CAPI="$(find "${WORK}/venv" -type d -path '*/onnxruntime/capi' | head -1)"
[[ -n "${CAPI}" ]] || { echo "error: no capi directory in the wheel" >&2; exit 1; }
mkdir -p "${DEST}"
# The runtime and its provider libraries; not the Python binding.
cp "${CAPI}"/libonnxruntime.so* "${CAPI}"/libonnxruntime_providers_*.so "${DEST}/"
echo "==> runtime in ${DEST}:"
ls -1 "${DEST}" | sed 's/^/ /'
echo " (the app finds it on its next launch; Settings › About › Inference says what it chose)"
+22
View File
@@ -0,0 +1,22 @@
# tools/manual — the pictures in docs/manual
`record.sh <library>` drives the desktop build on a private X server and
records every scene in `scenes.py` into `docs/manual/media/`. `drive.py` is
the puppeteer underneath — launch, click, drag, type, screenshot, record —
and is usable on its own to look at a panel after changing it:
```bash
DR_HOME=/var/tmp/x tools/manual/drive.py launch /some/folder
tools/manual/drive.py click 1543 22 # Settings
tools/manual/drive.py shot /tmp/settings.png
tools/manual/drive.py stop
```
The demo library is the author's: seventy frames with no people in them,
the `fixtures/pano` set among them. The scenes' cell coordinates are for
that grid, at 1600×1100; the panel coordinates hold for any library.
Two things the scripts know that are not obvious: a Slint `TouchArea` wants
a held press, not xdotool's `click`; and the app is driven on Xvfb rather
than the desktop because under XWayland at scale 2 a pointer warp lands at
twice the coordinate asked for.
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Drive the desktop build from outside, for the manual's screenshots.
drive.py launch [args...] start the app on the private X server
drive.py stop
drive.py shot OUT.png
drive.py click X Y [button]
drive.py move X Y
drive.py drag X1 Y1 X2 Y2 [steps]
drive.py type TEXT
drive.py key KEYSYM...
drive.py rec OUT.mp4 / cut start and stop a recording of the window
drive.py where the pointer, in window coordinates
Everything is in *window* pixels, at scale 1, with the window at the origin
of a 1920×1200 Xvfb on `DR_DISPLAY` (`:7`). The app gets its own XDG
profile under `DR_HOME` (`/var/tmp/dr-manual`), so nothing here touches the
library or settings of whoever is logged in; `DR_BIN` names the binary.
Two things that cost an afternoon, kept here so they cost nobody else one:
a Slint `TouchArea` wants a *held* press (`mousedown`, a beat, `mouseup`) —
xdotool's `click` is sometimes dropped; and under XWayland at scale 2 a
pointer warp lands at twice the coordinate asked for, which is why this runs
on Xvfb rather than the desktop.
"""
import os
import subprocess
import sys
import time
HOME = os.environ.get('DR_HOME', '/var/tmp/dr-manual')
DISPLAY = os.environ.get('DR_DISPLAY', ':7')
BIN = os.environ.get('DR_BIN', 'target/release/darkroom-desktop')
ENV = dict(
os.environ,
XDG_CONFIG_HOME=f'{HOME}/xdg/config',
XDG_DATA_HOME=f'{HOME}/xdg/data',
XDG_STATE_HOME=f'{HOME}/xdg/state',
RUST_LOG='info',
WINIT_X11_SCALE_FACTOR='1',
DISPLAY=DISPLAY,
)
ENV.pop('WAYLAND_DISPLAY', None)
os.environ['DISPLAY'] = DISPLAY
def x(*args, check=True):
return subprocess.run(
['xdotool', *map(str, args)], capture_output=True, text=True, check=check
).stdout.strip()
def win():
return open(f'{HOME}/app.win').read().strip()
def geometry():
out = x('getwindowgeometry', win())
pos = out.split('Position: ')[1].split(' ')[0]
px, py = map(int, pos.split(','))
return px, py
def launch(args):
os.makedirs(HOME, exist_ok=True)
log = open(f'{HOME}/app.log', 'w')
p = subprocess.Popen([BIN, *args], env=ENV, stdout=log, stderr=subprocess.STDOUT)
open(f'{HOME}/app.pid', 'w').write(str(p.pid))
w = ''
for _ in range(120):
w = x('search', '--pid', p.pid, '--name', 'DarkRoom', check=False).split('\n')[-1]
if w:
break
time.sleep(0.5)
time.sleep(1.5)
W, H = os.environ.get('WIDTH', '1600'), os.environ.get('HEIGHT', '1100')
x('windowsize', '--sync', w, W, H)
time.sleep(0.5)
x('windowmove', '--sync', w, 0, 0)
x('windowfocus', '--sync', w, check=False)
open(f'{HOME}/app.win', 'w').write(w)
print(f'pid {p.pid} win {w}')
def stop():
try:
os.kill(int(open(f'{HOME}/app.pid').read()), 15)
except (OSError, ValueError) as e:
print(e)
time.sleep(1)
def shot(out):
subprocess.run(['import', '-window', win(), out], check=True)
def move(px, py):
ox, oy = geometry()
x('mousemove', '--sync', ox + int(px), oy + int(py))
def click(px, py, button=1):
x('windowfocus', '--sync', win(), check=False)
move(px, py)
time.sleep(0.15)
x('mousedown', button)
time.sleep(0.12)
x('mouseup', button)
def drag(x1, y1, x2, y2, steps=20):
x('windowfocus', '--sync', win(), check=False)
move(x1, y1)
time.sleep(0.15)
x('mousedown', 1)
time.sleep(0.15)
for i in range(1, int(steps) + 1):
t = i / int(steps)
move(int(x1) + (int(x2) - int(x1)) * t, int(y1) + (int(y2) - int(y1)) * t)
time.sleep(0.03)
time.sleep(0.15)
x('mouseup', 1)
def rec_start(out):
size = x('getwindowgeometry', win()).split('Geometry: ')[1].strip()
ox, oy = geometry()
p = subprocess.Popen([
'ffmpeg', '-hide_banner', '-loglevel', 'error', '-f', 'x11grab',
'-framerate', '15', '-video_size', size, '-i', f'{DISPLAY}+{ox},{oy}',
'-c:v', 'libx264', '-preset', 'ultrafast', '-qp', '0', '-y', out,
])
open(f'{HOME}/rec.pid', 'w').write(str(p.pid))
time.sleep(0.5)
def rec_stop():
pid = int(open(f'{HOME}/rec.pid').read())
os.kill(pid, 2)
for _ in range(50):
try:
os.kill(pid, 0)
time.sleep(0.1)
except OSError:
break
def where():
out = x('getmouselocation')
mx, my = [int(v.split(':')[1]) for v in out.split()[:2]]
ox, oy = geometry()
print(mx - ox, my - oy)
if __name__ == '__main__':
cmd, *a = sys.argv[1:]
if cmd == 'launch':
launch(a)
elif cmd == 'stop':
stop()
elif cmd == 'shot':
shot(a[0])
elif cmd == 'click':
click(*a)
elif cmd == 'move':
move(*a)
elif cmd == 'drag':
drag(*a)
elif cmd == 'type':
x('windowfocus', '--sync', win(), check=False)
x('type', '--delay', '120', a[0])
elif cmd == 'key':
x('windowfocus', '--sync', win(), check=False)
x('key', '--delay', '80', *a)
elif cmd == 'rec':
rec_start(a[0])
elif cmd == 'cut':
rec_stop()
elif cmd == 'where':
where()
else:
sys.exit(__doc__)
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# gif.sh in.mp4 out.gif [width] [fps] — a palette-optimised GIF of a recording.
in=$1; out=$2; w=${3:-960}; fps=${4:-10}
ffmpeg -hide_banner -loglevel error -y -i "$in" \
-vf "fps=$fps,scale=$w:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=192:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle" \
"$out"
ls -la "$out"
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Re-record the manual: docs/manual/README.md's pictures, from the app itself.
#
# tools/manual/record.sh <library-folder> [scene...]
#
# Needs Xvfb, xdotool, ImageMagick's `import` and ffmpeg. Builds the desktop
# binary if it is not there, starts a private X server, opens the folder as
# a library in a throwaway profile, runs the scenes (all of them by default)
# and writes PNGs and GIFs into docs/manual/media/.
#
# The library folder is the author's demo set — seventy face-free frames,
# the twelve-frame panorama from fixtures/pano among them — and scenes.py's
# cell coordinates assume its grid. Another folder needs those looked at.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
library="${1:?library folder}"
shift || true
export DR_HOME="${DR_HOME:-/var/tmp/dr-manual}"
export DR_DISPLAY="${DR_DISPLAY:-:7}"
export DR_BIN="${DR_BIN:-$repo/target/release/darkroom-desktop}"
media="$repo/docs/manual/media"
mkdir -p "$media" "$DR_HOME/xdg/config/darkroom" "$DR_HOME/xdg/data/darkroom"
[ -x "$DR_BIN" ] || (cd "$repo" && cargo build --release -p darkroom-desktop)
# The models and the runtime are shared with the real profile: a segmenter
# that is not there makes "Find subjects" a picture of nothing.
for d in models runtime; do
src="${XDG_DATA_HOME:-$HOME/.local/share}/darkroom/$d"
[ -e "$src" ] && ln -sfn "$src" "$DR_HOME/xdg/data/darkroom/$d"
done
# A folder library, chosen: no launch screen on the way in.
cat > "$DR_HOME/xdg/config/darkroom/sessions.json" <<JSON
{ "version": 0, "sessions": [ { "backend": "folder", "server": "$library",
"login": "", "user_id": "", "root": "", "root_chosen": true,
"formats": [], "last_scan": null } ] }
JSON
if ! DISPLAY="$DR_DISPLAY" xdpyinfo >/dev/null 2>&1; then
Xvfb "$DR_DISPLAY" -screen 0 1920x1200x24 +extension GLX +render -noreset \
> "$DR_HOME/xvfb.log" 2>&1 &
echo $! > "$DR_HOME/xvfb.pid"
sleep 2
fi
python3 "$here/drive.py" launch
sleep 12
python3 "$here/scenes.py" "$media" "${@:-all}"
python3 "$here/drive.py" stop
# GIFs for the page; the MP4s are working files and are not kept.
for mp4 in "$media"/*.mp4; do
[ -e "$mp4" ] || continue
"$here/gif.sh" "$mp4" "${mp4%.mp4}.gif" 960 10
rm "$mp4"
done
optipng -quiet -o2 "$media"/*.png || true
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env python3
"""The manual's scenes: `scenes.py OUT_DIR scene [scene...]`, or `all`.
Each scene drives the running app through `drive.py` and leaves a PNG or an
MP4 in OUT_DIR, named after itself. `record.sh` runs them in order and turns
the MP4s into GIFs.
Coordinates are window pixels for a 1600×1100 window over the manual's own
library (see record.sh): which cell holds which photograph is part of the
scene, so a different library needs the numbers looked at again. Panel
coordinates hold for any library.
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import drive as dr # noqa: E402
OUT = sys.argv[1] if len(sys.argv) > 1 else '.'
def shot(name):
dr.shot(f'{OUT}/{name}.png')
def rec(name):
dr.rec_start(f'{OUT}/{name}.mp4')
def cut():
dr.rec_stop()
def pause(s):
time.sleep(s)
def hold(px, py, seconds):
dr.move(px, py)
pause(0.15)
dr.x('mousedown', 1)
pause(seconds)
dr.x('mouseup', 1)
def wheel(n, px, py):
dr.move(px, py)
dr.x('click', '--repeat', abs(n), '--delay', 40, 5 if n > 0 else 4)
def ctrl_wheel(n, px, py):
dr.move(px, py)
dr.x('keydown', 'ctrl')
for _ in range(abs(n)):
dr.x('click', 5 if n > 0 else 4)
pause(0.35)
dr.x('keyup', 'ctrl')
# --- places on the screen ---------------------------------------------------
LIBRARY = (55, 22) # ‹ Library, in the develop header
SELECT = (942, 22) # Select / Done, in the library header
SETTINGS_LIB = (1543, 22)
SETTINGS_DEV = (1343, 22)
EXPORT = (720, 22)
BACK = (60, 22) # ‹ Back, on the settings page
RAIL = {'photo': (30, 75), 'compose': (30, 130), 'local': (30, 185), 'repair': (30, 240)}
GROUPS = {'all': 1269, 'optics': 1313, 'light': 1359, 'colour': 1406, 'effects': 1457, 'detail': 1506}
BEFORE = (210, 1045)
RESET_ADJUST = (1565, 741) # "reset" in the Adjust heading, All group, scrolled to top
CELL_TEAPOT = (424, 165)
CELL_PANO_FIRST = (1264, 165)
CELL_CHINATOWN = (963, 525)
CELL_NY_LAST = (1502, 525)
COLLECTION_ROW = (60, 90)
def group(name):
dr.click(GROUPS[name], 67)
pause(0.9)
def to_library():
dr.click(*LIBRARY)
pause(2)
def open_chinatown():
dr.click(*CELL_CHINATOWN)
pause(6)
def reset_edit():
group('all')
wheel(-60, 1420, 700)
dr.click(*RESET_ADJUST)
pause(1)
# --- library ----------------------------------------------------------------
def library():
shot('library')
def library_rating():
rec('library-rating')
dr.move(424, 900); pause(1.0)
dr.click(444, 939); pause(1.2)
dr.move(604, 900); pause(0.8)
dr.click(644, 939); pause(1.2)
dr.move(800, 700); pause(0.6)
dr.click(665, 61); pause(1.5) # 3+
dr.click(384, 61); pause(1.2) # All
cut()
def library_timeline():
rec('library-timeline')
dr.drag(300, 200, 300, 900, 40); pause(1.0)
dr.drag(300, 900, 300, 150, 40); pause(1.0)
cut()
def library_thumbsize():
rec('library-thumbsize')
ctrl_wheel(-4, 900, 500); pause(0.8)
ctrl_wheel(4, 900, 500); pause(0.8)
cut()
def library_selection():
dr.click(*SELECT); pause(0.5)
dr.click(*CELL_PANO_FIRST); pause(0.3)
dr.x('keydown', 'shift'); dr.click(*CELL_NY_LAST); dr.x('keyup', 'shift'); pause(0.8)
for x in (963, 1143, 1323, 1502):
dr.x('keydown', 'ctrl'); dr.click(x, 525); dr.x('keyup', 'ctrl'); pause(0.3)
pause(0.5)
shot('library-selection')
def library_keywords():
# Continues from library_selection: twelve frames selected.
rec('library-keywords')
dr.click(1117, 1079); pause(1.2)
for word in ('alps', 'panorama', 'summer'):
dr.x('type', '--delay', 90, word); dr.x('key', 'Return'); pause(0.7)
pause(0.8)
dr.click(800, 673); pause(1.0) # Done
cut()
dr.click(*SELECT); pause(0.5) # leave selecting
def library_collections():
dr.click(212, 22); pause(0.3) # + in the collections header
dr.x('type', '--delay', 90, 'Alps'); dr.x('key', 'Return'); pause(1.2)
rec('library-collections')
dr.drag(*CELL_PANO_FIRST, *COLLECTION_ROW, 40); pause(1.5)
dr.click(*SELECT); pause(0.5)
dr.click(424, 350); pause(0.3)
dr.x('keydown', 'shift'); dr.click(1502, 350); dr.x('keyup', 'shift'); pause(0.8)
dr.drag(963, 350, *COLLECTION_ROW, 40); pause(1.5)
dr.click(*SELECT); pause(0.5)
dr.click(*COLLECTION_ROW); pause(1.5)
cut()
shot('library-collection')
dr.click(60, 48); pause(1.2) # All photographs
# --- develop ----------------------------------------------------------------
def develop():
open_chinatown()
group('all')
shot('develop')
def develop_groups():
rec('develop-groups')
for g in ['optics', 'light', 'colour', 'effects', 'detail', 'all']:
group(g); pause(0.6)
cut()
def develop_light():
group('light')
rec('develop-light')
pause(0.5)
dr.drag(1398, 792, 1422, 792, 25); pause(0.8) # exposure up
dr.drag(1398, 918, 1320, 918, 25); pause(0.8) # highlights down
dr.drag(1398, 964, 1450, 964, 25); pause(0.8) # shadows up
hold(*BEFORE, 1.6); pause(1.0)
cut()
reset_edit()
def develop_zoom():
rec('develop-zoom')
dr.move(650, 500); pause(0.3)
dr.x('click', '--repeat', 2, '--delay', 80, 1); pause(1.5)
dr.drag(650, 500, 900, 700, 30); pause(0.8)
dr.drag(900, 700, 500, 450, 30); pause(0.8)
dr.x('click', '--repeat', 2, '--delay', 80, 1); pause(1.2)
cut()
def develop_wb():
group('colour')
rec('develop-wb')
pause(0.4)
dr.click(1542, 767); pause(0.8) # pick
dr.click(1000, 300); pause(1.5) # a neutral wall
hold(*BEFORE, 1.4); pause(0.8)
cut()
reset_edit()
def compose():
dr.click(*RAIL['compose']); pause(1.2)
rec('compose')
pause(0.4)
dr.drag(66, 182, 260, 330, 30); pause(0.8)
dr.drag(1236, 964, 1100, 900, 25); pause(0.8)
dr.drag(1420, 593, 1448, 593, 20); pause(1.0) # straighten
dr.click(1466, 647); pause(1.2) # 1:1
dr.click(1378, 647); pause(1.0) # Original
dr.click(132, 1045); pause(1.2) # Done composing
cut()
shot('compose-done')
dr.click(1567, 510); pause(1.0) # reset compose
dr.click(115, 1045); pause(1.0) # refit
def local_segment():
dr.click(*RAIL['local']); pause(1.2)
dr.click(1420, 435); pause(14) # Find subjects
shot('local-categories')
dr.click(1266, 646); pause(2.5) # Sky
shot('local-segment')
def local_paint():
# Continues from local_segment: the sky mask selected and shown.
rec('local-paint')
wheel(12, 1420, 800); pause(0.8)
shot('local-mask-row')
cut()
def local_done():
dr.click(62, 1045); pause(1.0) # Done masking
reset_edit()
def repair():
dr.click(*RAIL['repair']); pause(1.2)
rec('repair')
pause(0.4)
dr.click(700, 620); pause(3)
dr.drag(1264, 460, 1330, 460, 20); pause(1.5) # size
hold(*BEFORE, 1.4); pause(0.8)
cut()
dr.click(125, 1045); pause(1.0) # Done repairing
reset_edit()
def film():
group('all')
rec('film')
dr.click(1420, 779); pause(1.5) # Film ▸
dr.click(1420, 953); pause(3) # Velvia 100
hold(*BEFORE, 1.4); pause(0.8)
cut()
reset_edit()
def presets():
dr.click(1420, 692); pause(1.5)
shot('presets')
dr.click(800, 783); pause(1.0) # Done
def develop_export():
dr.click(*EXPORT); pause(8)
shot('export-done')
# --- panorama ---------------------------------------------------------------
def panorama():
to_library()
library_selection()
rec('panorama')
dr.click(1325, 1079); pause(40) # Merge to panorama
dr.click(184, 637); pause(45) # Fill the border
dr.click(1543, 22); pause(60) # Merge
cut()
shot('panorama-done')
dr.click(*BACK); pause(2)
# --- settings ---------------------------------------------------------------
def settings():
dr.click(*SETTINGS_LIB); pause(2)
shot('settings')
wheel(30, 700, 600); pause(1)
shot('settings-export')
dr.click(*BACK); pause(1.5)
ALL = [
'library', 'library_rating', 'library_timeline', 'library_thumbsize',
'library_selection', 'library_keywords', 'library_collections',
'develop', 'develop_groups', 'develop_light', 'develop_zoom', 'develop_wb',
'compose', 'local_segment', 'local_paint', 'local_done', 'repair', 'film',
'presets', 'panorama', 'settings',
]
if __name__ == '__main__':
names = sys.argv[2:]
if names == ['all']:
names = ALL
for name in names:
print(f'-- {name}', flush=True)
globals()[name]()
+156
View File
@@ -0,0 +1,156 @@
//! What one click on the Identity screen costs, off the GUI.
//!
//! cargo run --release -p dr-ui --example identity_bench -- CATALOG.sqlite THUMBS_DIR
//!
//! Times each read the screen performs after a confirm, a reject or a
//! split — the people rail, the selected person's faces with their crops,
//! the coverage line — and the two batch writes, against a *copy* of a real
//! catalog. It writes to the catalog it is given (the batch operations are
//! the point), so never hand it the library's own file.
//!
//! The figures are wall-clock on this machine and this library, for reading
//! side by side before and after a change; they are not a gate.
use std::path::PathBuf;
use std::time::{Duration, Instant};
use dr_catalog::faces::{self, PersonId};
use dr_catalog::Catalog;
use dr_thumbs::ThumbStore;
use dr_ui::identity;
use dr_ui::repairs::{self, Capabilities, Scope};
const MODEL_ID: &str = "scrfd_10g+w600k_mbf";
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() < 2 {
eprintln!("usage: identity_bench CATALOG.sqlite THUMBS_DIR");
std::process::exit(2);
}
let catalog = Catalog::open(&PathBuf::from(&args[0])).expect("catalog");
let store = ThumbStore::open(&PathBuf::from(&args[1])).expect("thumbs");
let conn = catalog.connection();
// The person with the most faces: the worst case for the face grid, and
// the one a user is likeliest to be confirming through.
let (biggest, n_faces): (i64, i64) = conn
.query_row(
"SELECT person_id, COUNT(*) c FROM face_person GROUP BY 1 ORDER BY c DESC LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("a person");
let biggest = PersonId(biggest as u64);
println!("largest person {biggest:?} holds {n_faces} faces\n");
// ── the reads a click triggers ────────────────────────────────────
let detector = dr_types::FaceDetector::for_model_id(MODEL_ID).unwrap_or_default();
let registry = repairs::registry(
Scope::Outstanding,
MODEL_ID,
detector,
Capabilities {
gpu: true,
face_models: true,
eye_models: true,
},
);
time("load_people", 5, || {
identity::load_people(&catalog, MODEL_ID).unwrap();
});
time("load_faces (largest person, cold)", 5, || {
identity::load_faces(&catalog, &store, biggest, Default::default()).unwrap();
});
// What a confirm click costs: the grid's own crops handed back in.
let mut cells = identity::load_faces(&catalog, &store, biggest, Default::default()).unwrap();
time("load_faces (largest person, redraw)", 5, || {
let cut = cells
.drain(..)
.filter_map(|c| Some((c.face, c.crop?)))
.collect();
cells = identity::load_faces(&catalog, &store, biggest, cut).unwrap();
});
time("audit (coverage line)", 5, || {
dr_ui::faces::audit(&catalog, &store, MODEL_ID, &registry).unwrap();
});
// ── the batch writes ──────────────────────────────────────────────
// Every face of the largest person is demoted to a suggestion, then
// confirmed back in one call, so the measurement covers the whole group.
let ids: Vec<i64> = {
let mut q = conn
.prepare("SELECT face_id FROM face_person WHERE person_id = ?1")
.unwrap();
q.query_map([biggest.0 as i64], |r| r.get(0))
.unwrap()
.collect::<Result<_, _>>()
.unwrap()
};
let demote = |conn: &rusqlite::Connection| {
conn.execute(
"UPDATE face_person SET confirmed = 0, probability = 0.5 WHERE person_id = ?1",
[biggest.0 as i64],
)
.unwrap();
};
demote(conn);
time("confirm_all (largest person)", 3, || {
demote(conn);
identity::confirm_all(&catalog, biggest).unwrap();
});
// Split the group onto a new person and fold it straight back, so the
// catalog ends where it started apart from the redirect rows.
let members: Vec<faces::FaceId> = ids.iter().map(|&i| faces::FaceId(i as u64)).collect();
time("split_off (largest person, all faces)", 3, || {
let new = identity::split_off(&catalog, biggest, &members, "").unwrap();
faces::merge_people(conn, biggest, new).unwrap();
});
// The split rejects every face from `biggest`; a merge back does not
// clear that, so clear it here or a later run measures a different table.
conn.execute(
"DELETE FROM face_person_rejected WHERE person_id = ?1",
[biggest.0 as i64],
)
.unwrap();
}
/// Run `f` a few times and print the best wall-clock, the median, and the
/// best CPU time. The best is what the code costs, the median is what the
/// user waits — and the CPU figure is the one to compare across runs, since
/// this path is single-threaded and a build running on the same machine
/// doubles the wall clock without touching it.
fn time(label: &str, runs: usize, mut f: impl FnMut()) {
let mut wall: Vec<Duration> = Vec::with_capacity(runs);
let mut cpu: Vec<Duration> = Vec::with_capacity(runs);
for _ in 0..runs {
let c = cpu_now();
let t = Instant::now();
f();
wall.push(t.elapsed());
cpu.push(cpu_now().saturating_sub(c));
}
wall.sort();
cpu.sort();
println!(
"{label:42} best {:8.1} ms median {:8.1} ms cpu {:8.1} ms",
wall[0].as_secs_f64() * 1e3,
wall[runs / 2].as_secs_f64() * 1e3,
cpu[0].as_secs_f64() * 1e3
);
}
/// This thread's time on a CPU so far, from the scheduler's own account.
///
/// `/proc/self/schedstat` is the main thread's; the bench runs everything on
/// it. Zero where the file is missing, which only makes the CPU column
/// useless rather than the run.
fn cpu_now() -> Duration {
std::fs::read_to_string("/proc/self/schedstat")
.ok()
.and_then(|s| s.split_whitespace().next()?.parse::<u64>().ok())
.map(Duration::from_nanos)
.unwrap_or_default()
}
+14 -3
View File
@@ -286,9 +286,20 @@ async fn sync_shards(
// ---- upload ----------------------------------------------------------
for shard in &local {
let path = store.shard_path(shard.id);
let Ok(bytes) = std::fs::read(&path) else {
continue;
// A snapshot, never the live file — see `ThumbStore::snapshot_shard`
// for the zero-byte upload that reading the file produced.
let snapshot = scratch.join(format!("thumb-shard-{:04}-upload.sqlite", shard.id));
let bytes = match store.snapshot_shard(shard.id, &snapshot) {
Ok(()) => std::fs::read(&snapshot),
Err(e) => Err(std::io::Error::other(e.to_string())),
};
let _ = std::fs::remove_file(&snapshot);
let bytes = match bytes {
Ok(b) => b,
Err(e) => {
log::warn!("snapshotting thumbnail shard {}: {e}", shard.id);
continue;
}
};
let name = shard_name(&client, shard.id);
+30
View File
@@ -2552,6 +2552,15 @@ impl DevelopSession {
/// or reordered.
pub(crate) fn reveal(&self) -> Option<dr_pipeline::mask::Reveal> {
use dr_pipeline::mask::{Reveal, RevealedLayer};
// Only while masking. The eyes are per layer and outlive the mode,
// so a photographer coming back finds the layers they were looking
// at still lit — but a tint is a way of looking at a *mask*, and
// outside Local there is no mask being looked at. Without this the
// sky stayed red through Repair and back in Photo, a mode that had
// been left leaving its overlay behind (ui-navigation.md D-N1).
if !self.show_overlay {
return None;
}
let layers: Vec<RevealedLayer> = self
.graph
.masks()
@@ -6986,6 +6995,27 @@ mod tests {
/// — that the coverage is present, that it round-trips as bytes — would
/// still pass if the raster came back at the wrong scale, upside down, or
/// a threshold out.
#[test]
fn a_shown_mask_is_only_shown_while_masking() {
let Some(ctx) = headless() else { return };
let mut s = session_with_a_left_half_subject(&ctx);
let id = s.add_subject_mask(0).expect("a subject layer");
s.set_overlay(true);
s.set_mask_shown(&id, true);
assert!(s.any_mask_shown(), "lit, in Local mode");
// Leaving the mode — what `on_mode_picked` does for Photo and Spots.
s.set_overlay(false);
assert!(
!s.any_mask_shown(),
"the tint belongs to the mode, not to the photograph"
);
assert!(s.mask_shown(&id), "the eye itself is remembered");
s.set_overlay(true);
assert!(s.any_mask_shown(), "and is lit again on return");
}
#[test]
fn a_stored_mask_renders_exactly_what_the_model_rendered() {
let Some(ctx) = headless() else { return };
+17 -2
View File
@@ -116,6 +116,12 @@ pub fn faces_outstanding(
ORDER BY i.id",
faces::embedder_sql("fi.model_id")
))?;
// The store's index in one read rather than a probe per image; see
// `audit`, which splits the same list the same way.
let held = store.held(FACE_TIER).unwrap_or_else(|e| {
log::warn!("faces: reading the thumbnail index: {e}");
Default::default()
});
let rows = stmt
.query_map([faces::embedder_of(model_id)], |r| {
Ok(FaceRequest {
@@ -134,7 +140,7 @@ pub fn faces_outstanding(
// a whole-library button that could only reach photographs the user had
// personally zoomed into. `repairs::spawn` is that
// requirement implemented; this one is the local-only variant.
.filter(|req| store.contains(req.file_id, FACE_TIER))
.filter(|req| held.contains(&req.file_id))
.collect();
Ok(rows)
}
@@ -270,12 +276,21 @@ pub fn audit(
)",
faces::embedder_sql("fi.model_id")
))?;
// One read of the store's index, not one probe per outstanding image:
// `contains` answers the same question, and asked four thousand times
// it cost more than every query above put together. A store whose index
// cannot be read is treated as holding nothing, which is what `contains`
// reports for it too.
let held = store.held(FACE_TIER).unwrap_or_else(|e| {
log::warn!("faces: reading the thumbnail index: {e}");
Default::default()
});
let (mut ready, mut awaiting) = (0u64, 0u64);
for file_id in stmt
.query_map([faces::embedder_of(model_id)], |r| r.get::<_, i64>(0))?
.filter_map(Result::ok)
{
if store.contains(file_id as u64, FACE_TIER) {
if held.contains(&(file_id as u64)) {
ready += 1;
} else {
awaiting += 1;
+38 -28
View File
@@ -216,7 +216,9 @@ pub struct IdentityView {
/// Filtered rather than deleted, because this is a screen being drawn and not a
/// catalog being repaired. A row is withheld; nothing is lost, a sync cannot
/// resurrect what was never removed, and the prune stays the one place that
/// decides these are disposable.
/// decides these are disposable. The filter is the catalog's
/// (`faces::people_in_use`), in the query, so the rows withheld are never
/// read or sorted either.
///
/// An empty group with a *name* still shows. That one is not debris, it is the
/// symptom of a real failure — a named person whose faces were regrouped out
@@ -228,11 +230,8 @@ pub fn load_people(
model_id: &str,
) -> Result<IdentityView, dr_catalog::CatalogError> {
let conn = catalog.connection();
let people = faces::people(conn)?
let people = faces::people_in_use(conn)?
.into_iter()
.filter(|p| {
p.confirmed_faces + p.suggested_faces > 0 || !p.name.trim().is_empty() || p.ignored
})
.map(|p| PersonRow {
id: p.id,
name: p.name,
@@ -247,7 +246,7 @@ pub fn load_people(
people,
selected: None,
faces: Vec::new(),
unassigned: faces::unassigned(conn, model_id)?.len(),
unassigned: faces::count_unassigned(conn, model_id)? as usize,
calibrated: faces::calibration(conn, model_id)?.is_some_and(|(c, _)| c.valid),
})
}
@@ -261,18 +260,38 @@ pub fn load_people(
/// Crops come from the proxy the grid already built. An image whose proxy has
/// been evicted yields a cell with no crop rather than being dropped — the face
/// is still real, still counted, and still confirmable from its filename.
///
/// `cut` is whatever the previous load of this grid had already decoded,
/// keyed by face, and is consumed: a crop found there is moved into the new
/// cell and neither read from the catalog nor decoded again. A confirm or a
/// reject changes one face's row and redraws the whole grid, and without this
/// the redraw re-read four megabytes of JPEG and decoded seven hundred of
/// them — 300 ms on the reference library's largest person, per click, to
/// arrive at pixels the screen was already showing. Face ids are global, so
/// a map from another person's grid is merely useless, never wrong.
pub fn load_faces(
catalog: &Catalog,
store: &ThumbStore,
person: PersonId,
mut cut: std::collections::HashMap<FaceId, FaceCrop>,
) -> Result<Vec<FaceCell>, dr_catalog::CatalogError> {
let conn = catalog.connection();
let rows = faces::for_person(conn, person, true)?;
// The crops kept at detection time, in one query. Where a face has one this
// is the whole cost of drawing it — no proxy, no full-size JPEG decode, and
// no dependence on the thumbnail cache still holding the photograph.
let stored = faces::crops_for_person(conn, person, true)?;
// The crops kept at detection time, in one query — but only when a face
// is not already in hand. The common redraw has every face cached and
// skips the blob read entirely; a face the cache lacks (a regroup, a
// fresh sweep) costs the read for the whole person once, and it is
// cached from then on.
//
// Where a face has a stored crop this is the whole cost of drawing it —
// no proxy, no full-size JPEG decode, and no dependence on the thumbnail
// cache still holding the photograph.
let stored = if rows.iter().all(|f| cut.contains_key(&f.id)) {
Default::default()
} else {
faces::crops_for_person(conn, person, true)?
};
// The fallback path, for faces indexed before crops were kept. One decode
// per *image*, not per face: a group photograph holding six faces of one
@@ -283,7 +302,10 @@ pub fn load_faces(
let mut out = Vec::with_capacity(rows.len());
for f in rows {
let crop = match stored.get(&f.id).and_then(|b| decode_crop(b)) {
let crop = match cut
.remove(&f.id)
.or_else(|| stored.get(&f.id).and_then(|b| decode_crop(b)))
{
Some(c) => Some(c),
None => {
let entry = decoded
@@ -557,15 +579,7 @@ pub fn rename(
/// right — the common case for a well-photographed person — should cost one
/// click, not forty.
pub fn confirm_all(catalog: &Catalog, person: PersonId) -> Result<usize, dr_catalog::CatalogError> {
let conn = catalog.connection();
let mut n = 0;
for f in faces::for_person(conn, person, true)? {
if !f.confirmed {
faces::confirm(conn, f.id, person)?;
n += 1;
}
}
Ok(n)
faces::confirm_all(catalog.connection(), person).map(|n| n as usize)
}
/// The user says this face is this person.
@@ -657,7 +671,7 @@ pub fn preview_split(
.map(|(c, _)| c)
.unwrap_or_default();
let cells = load_faces(catalog, store, person)?;
let cells = load_faces(catalog, store, person, Default::default())?;
if cells.len() < 2 {
return Ok(vec![cells]);
}
@@ -710,13 +724,9 @@ pub fn split_off(
) -> Result<PersonId, dr_catalog::CatalogError> {
let conn = catalog.connection();
let new_person = faces::create_person(conn, name.trim())?;
for &face in members {
// Rejecting first is what stops the split being undone: without it the
// next pass sees a face that looks like `from` and suggests it straight
// back, and the user's correction becomes an argument they keep having.
faces::reject(conn, face, from)?;
faces::confirm(conn, face, new_person)?;
}
// Rejected from `from` and confirmed onto the new person, in one
// transaction rather than two per face: `faces::reassign` says why both.
faces::reassign(conn, members, from, new_person)?;
Ok(new_person)
}
+53 -15
View File
@@ -138,6 +138,26 @@ impl IdentityController {
}
}
/// What a reload has to re-read, named by what just changed.
///
/// The coverage line is the expensive half of a redraw: it lists every
/// repair's outstanding images to count them, which is several scans of the
/// whole `images` table (`crate::repairs::counts`). A confirm, a reject, a
/// rename or a merge moves faces between people and cannot change how many
/// images have been indexed, so a redraw for one of those must not pay for
/// it — that was 200 ms of the half-second every click on the face grid used
/// to cost on the reference library.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Changed {
/// Who the faces belong to. The rail and the grid are re-read; the
/// coverage line is left as it was.
Identities,
/// Which faces exist: a sweep finished or was stopped, the face data was
/// deleted, the screen was opened onto a catalog another device may have
/// indexed. Everything is re-read, the coverage line included.
Library,
}
/// Push the people rail and the face grid into the window.
///
/// **Draws with the portraits it already has and cuts the rest afterwards.**
@@ -154,6 +174,7 @@ pub fn refresh(
store: Option<Rc<ThumbStore>>,
model_id: &str,
eyes: bool,
changed: Changed,
) {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
@@ -239,7 +260,17 @@ pub fn refresh(
match (ctl.selected.get(), store.as_deref()) {
(Some(person), Some(store)) => {
let cells = identity::load_faces(cat, store, person).unwrap_or_else(|e| {
// The crops the grid is showing now, handed over to be reused
// rather than decoded again — see `load_faces`. Drained, not
// cloned: a crop is 64 KB of pixels and the largest groups hold
// hundreds.
let cut = ctl
.faces
.borrow_mut()
.drain(..)
.filter_map(|c| Some((c.face, c.crop?)))
.collect();
let cells = identity::load_faces(cat, store, person, cut).unwrap_or_else(|e| {
log::warn!("identity: reading faces: {e}");
Vec::new()
});
@@ -274,7 +305,9 @@ pub fn refresh(
window.set_identity_picked(ctl.picked.borrow().len() as i32);
drop(borrow);
refresh_coverage(window, catalog, store.as_deref(), model_id, eyes);
if changed == Changed::Library {
refresh_coverage(window, catalog, store.as_deref(), model_id, eyes);
}
// Last, so a portrait cannot delay anything above it.
if let Some(store) = store {
@@ -628,8 +661,10 @@ pub fn wire<S, M, P>(
// The availability closure is an argument rather than named in the
// body: macro hygiene would bind the name to this scope's `Rc`, which the
// first `move` closure would then take with it.
// The last argument is a `Changed` variant, named bare so the call
// stays on one line.
macro_rules! reload {
($w:expr, $ctl:expr, $catalog:expr, $store:expr, $settings:expr, $eyes:expr) => {
($w:expr, $ctl:expr, $catalog:expr, $store:expr, $settings:expr, $eyes:expr, $changed:ident) => {
refresh(
&$w,
&$ctl,
@@ -637,6 +672,7 @@ pub fn wire<S, M, P>(
$store(),
&model_id(&$settings),
$eyes(),
Changed::$changed,
)
};
}
@@ -662,7 +698,7 @@ pub fn wire<S, M, P>(
// A fact about the filesystem, so it is re-checked on every open
// rather than cached: the user may have just put the models there.
w.set_identity_model_missing(models_present().is_none());
reload!(w, ctl, catalog, store, settings, eyes_available);
reload!(w, ctl, catalog, store, settings, eyes_available, Library);
});
}
@@ -702,7 +738,7 @@ pub fn wire<S, M, P>(
// The offer was about the person being navigated away from. Left
// up, its "Merge" would fold whoever is selected *now*.
clear_merge_offer(&w, &ctl);
reload!(w, ctl, catalog, store, settings, eyes_available);
reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
reset_name_field(&w);
});
}
@@ -748,7 +784,7 @@ pub fn wire<S, M, P>(
Err(e) => log::warn!("identity: looking for a namesake: {e}"),
}
}
reload!(w, ctl, catalog, store, settings, eyes_available);
reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
// `rename` trims; the field should show what was actually stored
// rather than the spacing the user happened to type.
reset_name_field(&w);
@@ -786,7 +822,7 @@ pub fn wire<S, M, P>(
}
}
clear_merge_offer(&w, &ctl);
reload!(w, ctl, catalog, store, settings, eyes_available);
reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
reset_name_field(&w);
});
}
@@ -832,7 +868,7 @@ pub fn wire<S, M, P>(
log::warn!("identity: confirm: {e}");
}
}
reload!(w, ctl, catalog, store, settings, eyes_available);
reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
});
}
@@ -853,7 +889,7 @@ pub fn wire<S, M, P>(
log::warn!("identity: reject: {e}");
}
}
reload!(w, ctl, catalog, store, settings, eyes_available);
reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
});
}
@@ -897,7 +933,7 @@ pub fn wire<S, M, P>(
Err(e) => log::warn!("identity: confirm all: {e}"),
}
}
reload!(w, ctl, catalog, store, settings, eyes_available);
reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
});
}
@@ -931,7 +967,7 @@ pub fn wire<S, M, P>(
}
}
ctl.clear_picks();
reload!(w, ctl, catalog, store, settings, eyes_available);
reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
});
}
@@ -1091,6 +1127,7 @@ pub fn wire<S, M, P>(
store_tick(),
&model_id(&settings_tick),
eyes_tick(),
Changed::Identities,
);
}
},
@@ -1268,6 +1305,7 @@ pub fn wire<S, M, P>(
store_tick(),
&model_id(&settings_tick),
eyes_tick(),
Changed::Library,
);
}
},
@@ -1306,7 +1344,7 @@ pub fn wire<S, M, P>(
a.finish("stopped");
}
w.set_identity_indexing(false);
reload!(w, ctl, catalog, store, settings, eyes_available);
reload!(w, ctl, catalog, store, settings, eyes_available, Library);
});
}
@@ -1351,7 +1389,7 @@ pub fn wire<S, M, P>(
ctl.selected.set(None);
ctl.clear_picks();
}
reload!(w, ctl, catalog, store, settings, eyes_available);
reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
});
}
@@ -1365,7 +1403,7 @@ pub fn wire<S, M, P>(
window.on_identity_toggle_show_ignored(move || {
let Some(w) = weak.upgrade() else { return };
ctl.show_ignored.set(!ctl.show_ignored.get());
reload!(w, ctl, catalog, store, settings, eyes_available);
reload!(w, ctl, catalog, store, settings, eyes_available, Identities);
});
}
@@ -1387,7 +1425,7 @@ pub fn wire<S, M, P>(
ctl.selected.set(None);
ctl.clear_picks();
ctl.covers.borrow_mut().clear();
reload!(w, ctl, catalog, store, settings, eyes_available);
reload!(w, ctl, catalog, store, settings, eyes_available, Library);
});
}
}
+23 -1
View File
@@ -60,6 +60,18 @@ pub fn init(runtime_dirs: Vec<PathBuf>) {
crate::memory::evict_at(crate::memory::Tier::Gpu, dr_inference_engine::release_all);
}
/// Where a person can put a runtime by hand: `runtime/` beside the models,
/// searched before any system library. The system copy on the reference
/// desktop is built without TensorRT and against the wrong cuDNN, and a
/// working one is four files from the `onnxruntime-gpu` wheel; this is
/// where they go, and `tools/fetch-desktop-runtime.sh` puts them there.
pub fn user_runtime_dir() -> PathBuf {
crate::library::shared_face_models_dir()
.parent()
.map(|p| p.join("runtime"))
.unwrap_or_else(|| PathBuf::from("runtime"))
}
/// Which form the current backend loads `detector` in, given the files on
/// this device — the fact `faces.model_id` has to carry (§7).
///
@@ -94,8 +106,18 @@ pub fn about_lines() -> (String, String) {
status.engines.0,
status.engines.1
)
} else {
} else if status.failed.is_empty() {
status.reason
} else {
// Every rung that was tried and why it lost, not only the first:
// "TensorRT: not enabled in this build" says nothing about why CUDA
// was not taken instead.
status
.failed
.iter()
.map(|(rung, why)| format!("{}: {why}", rung.label()))
.collect::<Vec<_>>()
.join(" · ")
};
(line, detail)
}
+79 -10
View File
@@ -218,6 +218,43 @@ impl LaunchModel {
self.session().map(|s| s.root.clone()).unwrap_or_default()
}
/// Whether a root has been settled on, including the top level itself.
///
/// Three ways to have one: a subfolder is named; the picker confirmed
/// the top level, which `Account::root_chosen` records because the
/// empty string cannot; or the endpoint is a folder, whose top level is
/// the library by definition.
pub fn root_is_chosen(&self) -> bool {
self.session()
.is_some_and(|s| !s.root.is_empty() || s.root_chosen)
|| self.endpoint_is_library()
}
/// What the signed-in section shows for the root: empty when nothing is
/// chosen, `/` for the top level, else the path.
pub fn library_root_label(&self) -> String {
let root = self.library_root();
if root.is_empty() && self.root_is_chosen() {
"/".to_string()
} else {
root
}
}
/// Whether the endpoint itself is the library.
///
/// A folder account has no sub-root to choose: the directory the user
/// typed is the whole library, and asking them to pick a "library
/// folder" inside it a second time reads as though the first answer was
/// not taken. A cloud account is the opposite — its endpoint is an
/// entire server-side tree, and a root inside it is required (see
/// [`can_open_library`](Self::can_open_library)). Keyed on the absence
/// of a login, the same fact [`prefill`] keys on, rather than on the
/// connector's id.
pub fn endpoint_is_library(&self) -> bool {
self.session().is_some_and(|s| s.login.is_empty())
}
/// The login URL while approval is pending.
pub fn login_url(&self) -> String {
match &self.state {
@@ -228,11 +265,13 @@ impl LaunchModel {
/// Whether "Open library" should be clickable.
///
/// Requires a signed-in account *and* a chosen folder: opening without one
/// would scan the whole account, which on a real library is thousands of
/// directories the user did not ask for.
/// A cloud account requires a signed-in session *and* a chosen folder:
/// opening without one would scan the whole account, which on a real
/// library is thousands of directories the user did not ask for. Chosen
/// is the operative word, not non-empty — the top level is a legitimate
/// choice, and a folder library is the whole folder by definition.
pub fn can_open_library(&self) -> bool {
self.is_signed_in() && !self.library_root().is_empty()
self.is_signed_in() && self.root_is_chosen()
}
/// What the app should do on startup.
@@ -386,6 +425,9 @@ impl LaunchModel {
let path = self.browser.as_ref()?.path.clone();
let mut session = self.session()?.clone();
session.root = path;
// Even when `path` is the top level: that is a choice, and the
// empty string alone would read as none having been made.
session.root_chosen = true;
self.browser = None;
self.state = LaunchState::SignedIn {
session: session.clone(),
@@ -547,17 +589,32 @@ mod tests {
// Everything past sign-in is backend-neutral, and this is the check
// that keeps it so: no branch on the account's connector below here.
let mut m = LaunchModel::default();
m.signed_in(folder_with_root(""));
assert!(m.is_signed_in());
assert!(!m.can_open_library(), "no root chosen yet");
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
m.signed_in(folder_with_root("2026"));
assert!(m.is_signed_in());
assert!(m.can_open_library());
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
assert_eq!(m.account_label(), "/mnt/photos/2026");
}
#[test]
fn a_folder_is_the_whole_library_without_choosing_a_root() {
// The directory typed on the launch screen is the answer to "which
// folder"; a second question with the same name is what confused
// the first person to open one. A server account still needs a
// root, because its endpoint is the entire tree.
let mut m = LaunchModel::default();
m.signed_in(folder_with_root(""));
assert!(m.endpoint_is_library());
assert!(m.can_open_library(), "the folder itself is the library");
assert_eq!(m.library_root_label(), "/");
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
m.signed_in(session_with_root(""));
assert!(!m.endpoint_is_library());
assert!(!m.can_open_library(), "a server account needs a root");
assert_eq!(m.startup_action(false), Startup::ShowLaunchScreen);
}
#[test]
fn format_toggles_apply_and_out_of_range_is_ignored() {
let mut m = LaunchModel::default();
@@ -665,13 +722,25 @@ mod tests {
#[test]
fn confirming_at_the_root_selects_the_whole_account() {
// Legitimate: a user may keep everything at the top level.
// Legitimate: a user may keep everything at the top level — and it
// must then open. It used to be recorded as an empty root, which
// is indistinguishable from no root, so "Open library" stayed
// disabled after the picker had plainly been answered.
let mut m = LaunchModel::default();
m.signed_in(session_with_root(""));
assert!(!m.can_open_library(), "nothing chosen yet");
assert_eq!(m.library_root_label(), "");
m.open_browser();
let session = m.choose_current_folder().expect("a session");
assert_eq!(session.root, "");
assert!(
session.root_chosen,
"the choice survives to the config file"
);
assert!(m.can_open_library());
assert_eq!(m.library_root_label(), "/");
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
}
#[test]
+2 -1
View File
@@ -44,7 +44,8 @@ pub fn render(window: &AppWindow, controller: &LaunchController) {
window.set_launch_signed_in(m.is_signed_in());
window.set_launch_account(m.account_label().into());
window.set_launch_root(m.library_root().into());
window.set_launch_root(m.library_root_label().into());
window.set_launch_endpoint_is_library(m.endpoint_is_library());
window.set_launch_server(m.server_url.clone().into());
window.set_launch_folder(m.folder_path.clone().into());
window.set_launch_busy(m.is_busy());
+34 -5
View File
@@ -3230,7 +3230,7 @@ async fn fetch_preview(
// The same bytes carry EXIF. Reading it here is free — the alternative is
// a second 256 KB fetch per image over the whole library.
if req.needs_metadata {
collect_metadata(&header, req, found_metadata);
collect_metadata(backend, &id, &header, req, found_metadata).await;
}
// Read unconditionally, unlike the rest of the EXIF above: `needs_metadata`
@@ -3350,9 +3350,38 @@ pub(crate) fn store_thumbnail(
///
/// Shared by both paths — the thumbnail fetch, which gets the header anyway,
/// and the header-only pass for images whose pixels were already cached.
fn collect_metadata(header: &[u8], req: &ThumbnailRequest, out: &mut Vec<MetadataFound>) {
let Ok(md) = dr_decode::metadata(header) else {
return;
async fn collect_metadata(
backend: &dyn RemoteBackend,
id: &RemoteId,
header: &[u8],
req: &ThumbnailRequest,
out: &mut Vec<MetadataFound>,
) {
let md = match dr_decode::metadata(header) {
Ok(md) => md,
Err(first) => {
// A file whose IFDs follow its pixels — the linear DNG a merge
// writes — has nothing for the decoder in its head but a
// pointer. Its structure is a few kilobytes at the end; fetch
// that and read the two ranges together, rather than leave the
// composite undated at the end of the grid.
let Some(at) = dr_decode::trailing_ifd(header).filter(|at| *at < req.size) else {
return;
};
match backend.get(id, Some(at..req.size)).await {
Ok(tail) => match dr_decode::metadata_split(header, &tail, at) {
Ok(md) => md,
Err(e) => {
log::debug!("metadata: {}: head {first}; head and tail {e}", req.path);
return;
}
},
Err(e) => {
log::debug!("metadata: {}: tail not fetched: {e}", req.path);
return;
}
}
}
};
out.push(MetadataFound {
image_id: req.image_id,
@@ -3442,7 +3471,7 @@ async fn read_metadata_only(
for attempt in 1..=ATTEMPTS {
match backend.get(&id, Some(0..dr_decode::HEADER_BYTES)).await {
Ok(header) => {
collect_metadata(&header, req, found);
collect_metadata(backend, &id, &header, req, found).await;
return true;
}
Err(e) if e.is_transient() && attempt < ATTEMPTS => {
+129 -39
View File
@@ -984,14 +984,7 @@ pub fn open(
window.set_library_status("Starting…".into());
// Always visible: two folders one letter apart are easy to confuse, and a
// scan of the wrong one is indistinguishable from a broken scan.
window.set_library_root_label(
if conn.account.root.is_empty() {
format!("{} · whole account", conn.account.user_id)
} else {
format!("{}/{}", conn.account.user_id, conn.account.root)
}
.into(),
);
window.set_library_root_label(library_root_label(&conn.account).into());
// An empty filter would walk the whole tree and match nothing, which looks
// exactly like a broken scan. Say so instead.
@@ -2831,38 +2824,17 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// Month headings. The grid is ordered by capture time, so without these a
// wall of thumbnails gives no sense of *when* you are looking — the
// sidebar says it, but only if you consult it.
//
// Marked on the cell that both begins a month and begins a row: a heading
// stranded mid-row would label the cells to its left, which belong to the
// previous month.
let columns = window.get_library_columns().max(1) as usize;
let mut previous_month: Option<(i64, i64)> = None;
let headings: Vec<String> = cells
.iter()
.enumerate()
.map(|(i, c)| {
let Some(t) = c.captured_at else {
return String::new();
};
let (y, m, _, _) = civil_from_unix(t);
let is_new = previous_month != Some((y, m));
previous_month = Some((y, m));
// A heading is drawn above its row, so it can only sit on a cell
// that begins one — a heading stranded mid-row would appear to
// label the cells to its left, which belong to the month before.
//
// The first cell of the window always carries one, whichever
// column it lands in: a scrolled window would otherwise show no
// date at all until the next month began.
let begins_row = (i + offset) % columns == 0;
if i == 0 || (is_new && begins_row) {
format!("{} {y}", month_name(m))
} else {
String::new()
}
})
.collect();
let headings = period_headings(
cells.iter().map(|c| {
c.captured_at.map(|t| {
let (y, m, _, _) = civil_from_unix(t);
(y, m)
})
}),
offset,
columns,
);
// What the outgoing model is still holding — see [`hold_thumbnails`].
let held = {
@@ -7158,10 +7130,128 @@ fn stop(slot: &RefCell<Option<slint::Timer>>) {
}
}
/// The heading each cell of a window carries: a month name on some, nothing
/// on most.
///
/// A heading is drawn above its row, so it can only sit on a cell that
/// begins one — stranded mid-row it would appear to label the cells to its
/// left, which belong to the month before. So a row carries a heading when
/// its first cell's month is not the one last announced. A month that begins
/// mid-row is therefore announced at the *next* row it opens, which is one
/// row late and still right about every cell under it. It used to be
/// announced only if it happened to begin a row, which at seven columns
/// meant most months were never named at all and the one heading on screen
/// was wrong about everything below its first row.
///
/// The first cell of the window always carries one, whichever column it
/// lands in: a scrolled window would otherwise show no date at all until
/// the next month began.
fn period_headings(
months: impl Iterator<Item = Option<(i64, i64)>>,
offset: usize,
columns: usize,
) -> Vec<String> {
let columns = columns.max(1);
let mut announced: Option<(i64, i64)> = None;
months
.enumerate()
.map(|(i, month)| {
let Some((y, m)) = month else {
return String::new();
};
let begins_row = (i + offset).is_multiple_of(columns);
if i == 0 || (begins_row && announced != Some((y, m))) {
announced = Some((y, m));
format!("{} {y}", month_name(m))
} else {
String::new()
}
})
.collect()
}
/// The line in the library header that says what is being catalogued.
///
/// For an account with a user, the user and the chosen subtree, or the
/// whole account when none was chosen. For a folder there is no user and
/// the endpoint is the library, so the folder's own name: `library`, or
/// `library/2026` when narrowed — never " · whole account", which is a
/// sentence about a server said of a directory.
fn library_root_label(account: &Account) -> String {
if account.login.is_empty() {
let folder = std::path::Path::new(&account.endpoint)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| account.endpoint.clone());
if account.root.is_empty() {
folder
} else {
format!("{folder}/{}", account.root)
}
} else if account.root.is_empty() {
format!("{} · whole account", account.user_id)
} else {
format!("{}/{}", account.user_id, account.root)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_month_that_begins_mid_row_is_still_announced() {
// Four columns; August starts in the second column of the second
// row and October in the third of the third. Neither begins a row,
// and both must still be named — on the next row each opens.
let mar = Some((2024, 3));
let aug = Some((2024, 8));
let oct = Some((2025, 10));
let months = [
mar, mar, mar, mar, //
mar, aug, aug, aug, //
aug, aug, oct, oct, //
oct,
];
let h = period_headings(months.into_iter(), 0, 4);
assert_eq!(h[0], "March 2024");
assert_eq!(h[4], "", "still March at the top of row two");
assert_eq!(h[5], "", "never mid-row");
assert_eq!(h[8], "August 2024");
assert_eq!(h[12], "October 2025");
assert_eq!(h.iter().filter(|s| !s.is_empty()).count(), 3);
}
#[test]
fn a_scrolled_window_names_its_first_cell_wherever_it_lands() {
let jan = Some((2026, 1));
let feb = Some((2026, 2));
// Offset 2 into a 4-column grid: the window's first cell is in the
// third column, and the row after it begins a new month.
let h = period_headings([jan, jan, feb, feb, feb].into_iter(), 2, 4);
assert_eq!(h[0], "January 2026");
assert_eq!(h[2], "February 2026", "cell 2 begins a row at offset 2");
assert_eq!(h[3], "");
// Undated cells carry nothing and announce nothing.
let h = period_headings([None, jan, None, None, jan].into_iter(), 0, 4);
assert_eq!(h[0], "");
assert_eq!(h[4], "January 2026");
}
#[test]
fn the_header_names_a_folder_library_by_its_folder() {
let mut folder = Account::new("folder", "/var/tmp/dr-demo/library");
assert_eq!(library_root_label(&folder), "library");
folder.root = "2026".into();
assert_eq!(library_root_label(&folder), "library/2026");
let mut cloud =
Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan");
assert_eq!(library_root_label(&cloud), "duncan · whole account");
cloud.root = "PhotosRaw".into();
assert_eq!(library_root_label(&cloud), "duncan/PhotosRaw");
}
#[test]
fn a_scrub_fraction_interpolates_within_the_span() {
// The point of going fractional: a slow drag must advance
+103 -10
View File
@@ -181,7 +181,11 @@ impl FillSettings {
params: dr_pano::FillParams {
coarse: self.params.coarse,
band: px(self.params.band).max(8),
mirror_depth: px(self.params.mirror_depth),
mirror_depth: if self.params.mirror_depth == 0 {
0
} else {
px(self.params.mirror_depth)
},
feather: if self.params.feather == 0 {
0
} else {
@@ -218,6 +222,17 @@ pub enum MergeEvent {
Cancelled,
}
impl MergeEvent {
/// Whether this is the job's last word: after one of these the worker
/// has nothing more to say, so its channel closing is expected.
pub fn is_final(&self) -> bool {
matches!(
self,
MergeEvent::Done { .. } | MergeEvent::Failed(_) | MergeEvent::Cancelled
)
}
}
/// The alignment, described for a panel.
#[derive(Debug, Clone)]
pub struct AlignmentReport {
@@ -255,10 +270,29 @@ pub fn run(
let send = |e: MergeEvent| {
let _ = events.send(e);
};
match run_inner(&ctx, &request, &events, &decision, &cancel) {
Ok(Some(done)) => send(done),
Ok(None) => send(MergeEvent::Cancelled),
Err(e) => send(MergeEvent::Failed(e)),
// Caught rather than allowed to unwind the thread: wgpu reports a device
// that has run out of memory by panicking, and a twelve-frame merge on a
// GPU another process is using is exactly where that happens. Uncaught,
// the thread died, the sender went with it, and the page sat on "Stop"
// with every control disabled and nothing to say why — the crash record
// on disk was the only sign. The panic hook still writes that record;
// this is what puts the reason on the page.
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run_inner(&ctx, &request, &events, &decision, &cancel)
}));
match result {
Ok(Ok(Some(done))) => send(done),
Ok(Ok(None)) => send(MergeEvent::Cancelled),
Ok(Err(e)) => send(MergeEvent::Failed(e)),
Err(panic) => {
let detail = panic
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panicked with a non-string payload".to_string());
log::error!("merge worker panicked: {detail}");
send(MergeEvent::Failed(format!("internal error: {detail}")));
}
}
}
@@ -518,7 +552,16 @@ fn run_inner(
let black = first.raw.black_level[0];
let white_level = u32::from(first.raw.white_level.saturating_sub(black)).max(1);
let profile = dng_profile(first, white_level);
let (_, carried) = crate::export::header_for_file(&first.meta);
let (_, mut carried) = crate::export::header_for_file(&first.meta);
// The composite is dated at the middle of its sweep — the mean of the
// frames' capture times — so it sorts among the frames it was made
// from, not at the first of them and not at the moment of the merge.
// The zone is the first frame's; a sweep does not cross one.
let stamps: Vec<i64> = frames.iter().filter_map(|f| f.meta.captured_at).collect();
if !stamps.is_empty() {
let sum: i128 = stamps.iter().map(|&t| t as i128).sum();
carried.captured_at = Some((sum / stamps.len() as i128) as i64);
}
let output = MergeOutput {
projection,
@@ -1263,12 +1306,18 @@ fn unused_name(dir: &Path, name: &str) -> PathBuf {
}
/// Drain everything a job has said so far.
pub fn drain(rx: &Receiver<MergeEvent>) -> Vec<MergeEvent> {
/// Everything the worker has said since the last call, and whether it has
/// hung up — a closed channel after nothing [`MergeEvent::is_final`] is a
/// worker that died mid-job.
pub fn drain(rx: &Receiver<MergeEvent>) -> (Vec<MergeEvent>, bool) {
let mut out = Vec::new();
while let Ok(e) = rx.try_recv() {
out.push(e);
loop {
match rx.try_recv() {
Ok(e) => out.push(e),
Err(std::sync::mpsc::TryRecvError::Empty) => return (out, false),
Err(std::sync::mpsc::TryRecvError::Disconnected) => return (out, true),
}
}
out
}
/// The filler's input as the debugging example reads it: `input.ppm`, the
@@ -1291,3 +1340,47 @@ fn dump_fill_input(
pgm.extend(known.iter().map(|&k| if k { 255u8 } else { 0 }));
std::fs::write(dir.join("known.pgm"), pgm)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_worker_that_hangs_up_mid_job_is_reported_as_gone() {
// The failure the page could not see: a panic drops the sender with
// no final event, and `try_recv`'s Disconnected used to be folded
// into "nothing new".
let (tx, rx) = std::sync::mpsc::channel();
tx.send(MergeEvent::Progress {
stage: "Reading",
done: 1,
total: 12,
})
.unwrap();
let (events, gone) = drain(&rx);
assert_eq!(events.len(), 1);
assert!(!gone, "the sender is still alive");
drop(tx);
let (events, gone) = drain(&rx);
assert!(events.is_empty());
assert!(gone, "and now it is not");
}
#[test]
fn a_job_that_said_its_last_word_is_not_a_dead_worker() {
let (tx, rx) = std::sync::mpsc::channel();
tx.send(MergeEvent::Failed("out of memory".into())).unwrap();
drop(tx);
let (events, gone) = drain(&rx);
assert!(gone);
assert!(events.iter().any(MergeEvent::is_final), "Failed is final");
assert!(MergeEvent::Cancelled.is_final());
assert!(!MergeEvent::Progress {
stage: "x",
done: 0,
total: 0
}
.is_final());
}
}
+12 -2
View File
@@ -339,7 +339,7 @@ pub fn wire<S, C, F>(
"erosion" => f.erosion = v,
"coarse" => f.params.coarse = v.max(1),
"band" => f.params.band = v.max(8),
"mirror" => f.params.mirror_depth = v.max(1),
"mirror" => f.params.mirror_depth = v,
"feather" => f.params.feather = v,
_ => return,
}
@@ -487,11 +487,21 @@ fn chip_projection(i: i32) -> Option<dr_pano::Projection> {
/// Take everything the job has said and reflect it on the page.
fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&AppWindow)>) {
let events = {
let (events, gone) = {
let job = ctl.job.borrow();
let Some(job) = job.as_ref() else { return };
merge::drain(&job.rx)
};
// The worker is the only sender, so a closed channel with nothing final
// said is a worker that died without saying anything — the one way
// `merge::run` cannot report, since reporting is what it was doing.
// Without this the page stayed on "Stop" for ever.
let mut events = events;
if gone && !events.iter().any(MergeEvent::is_final) {
events.push(MergeEvent::Failed(
"the merge stopped unexpectedly; the log has the reason".into(),
));
}
if events.is_empty() {
return;
}
+186 -27
View File
@@ -82,10 +82,28 @@ pub enum Needs {
/// image still owes it. Evaluated for the list, for the count, and again
/// per image before the handler runs.
Sql(String),
/// SQL over `faces f`, true where the face still owes it; the image owes
/// the repair if any of its faces does.
///
/// Kept as the per-face fragment rather than folded into an image
/// predicate, because the two questions asked of it want opposite
/// shapes. The list and the per-image check want `EXISTS (... WHERE
/// f.image_id = i.id AND fragment)`, one probe per image. The count
/// wants to start from the faces, where the partial indexes V19 keeps
/// for exactly these fragments make it a walk over the few thousand
/// still owing rather than a probe into eight-kilobyte rows for every
/// image in the library -- and the planner will not use those indexes
/// from inside the EXISTS.
Face(String),
/// Evaluated once, at the start of the job.
Set(SetFn),
}
/// [`Needs::Face`] as an image predicate: the image holds a face owing it.
fn any_face(fragment: &str) -> String {
format!("EXISTS (SELECT 1 FROM faces f WHERE f.image_id = i.id AND {fragment})")
}
/// A handler: fill one image, given what was fetched for it.
pub type ApplyFn = fn(&mut Toolkit, &Catalog, &Target, &mut Fetched) -> Result<usize, Failure>;
@@ -290,11 +308,10 @@ pub fn registry(
.join(", ");
format!("EXISTS (SELECT 1 FROM face_index fi WHERE fi.image_id = i.id AND fi.model_id IN ({list}))")
};
let face_needing = |pred: &str| {
format!(
"EXISTS (SELECT 1 FROM faces f WHERE f.image_id = i.id AND {f_embedder} = '{embedder}' AND ({pred}))"
)
};
// The per-face fragment `Needs::Face` carries: this embedder's face,
// still owing the pass. Spelled as the partial indexes' WHERE clauses
// are (V19), which is what lets the count be served from them.
let face_needing = |pred: &str| format!("{f_embedder} = '{embedder}' AND ({pred})");
let can_detect = caps.gpu && caps.face_models;
@@ -340,7 +357,7 @@ pub fn registry(
out.push(Repair {
name: "face-quality",
label: "images with faces to read for quality",
needs: Needs::Sql(face_needing(NEEDS_QUALITY)),
needs: Needs::Face(face_needing(NEEDS_QUALITY)),
input: Input::NativeRender,
apply: quality,
give_up: None,
@@ -350,7 +367,7 @@ pub fn registry(
out.push(Repair {
name: "face-eyes",
label: "images with faces to read for eye state",
needs: Needs::Sql(face_needing(NEEDS_EYES)),
needs: Needs::Face(face_needing(NEEDS_EYES)),
input: Input::NativeRender,
apply: eyes,
give_up: None,
@@ -360,7 +377,7 @@ pub fn registry(
out.push(Repair {
name: "face-crop",
label: "images with faces without a crop",
needs: Needs::Sql(face_needing(NEEDS_CROP)),
needs: Needs::Face(face_needing(NEEDS_CROP)),
input: Input::NativeRender,
apply: crop,
give_up: None,
@@ -732,12 +749,19 @@ fn faces_without_proxy(
WHERE r.file_id IS NOT NULL AND {VISIBLE} AND {} = ?1",
faces::embedder_sql("f.model_id"),
))?;
// The index once, not a probe per image with faces — see
// `ThumbStore::held`. An unreadable index reads as empty, as `contains`
// would have reported it.
let held = store.held(dr_thumbs::ThumbSize::Large).unwrap_or_else(|e| {
log::warn!("repairs: reading the thumbnail index: {e}");
Default::default()
});
let rows = stmt
.query_map([embedder], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
})?
.filter_map(Result::ok)
.filter(|(_, file_id)| !store.contains(*file_id as u64, dr_thumbs::ThumbSize::Large))
.filter(|(_, file_id)| !held.contains(&(*file_id as u64)))
.map(|(id, _)| id)
.collect();
Ok(rows)
@@ -753,6 +777,7 @@ fn listed(
) -> Result<Vec<Target>, dr_catalog::CatalogError> {
let (predicate, set) = match &repair.needs {
Needs::Sql(sql) => (sql.clone(), None),
Needs::Face(fragment) => (any_face(fragment), None),
Needs::Set(f) => ("1".to_string(), Some(f(catalog, store)?)),
};
let mut stmt = catalog.connection().prepare(&format!(
@@ -785,24 +810,33 @@ fn still_owed(
set: Option<&HashSet<i64>>,
image: ImageId,
) -> bool {
match (&repair.needs, set) {
(Needs::Set(_), Some(s)) => s.contains(&(image.0 as i64)),
(Needs::Set(_), None) => false,
(Needs::Sql(sql), _) => catalog
.connection()
.query_row(
&format!(
"SELECT EXISTS (SELECT 1 FROM images i JOIN remote r ON r.image_id = i.id
WHERE i.id = ?1 AND ({sql}))"
),
[image.0 as i64],
|r| r.get::<_, bool>(0),
)
.unwrap_or(false),
}
let sql = match (&repair.needs, set) {
(Needs::Set(_), Some(s)) => return s.contains(&(image.0 as i64)),
(Needs::Set(_), None) => return false,
(Needs::Sql(sql), _) => sql.clone(),
(Needs::Face(fragment), _) => any_face(fragment),
};
catalog
.connection()
.query_row(
&format!(
"SELECT EXISTS (SELECT 1 FROM images i JOIN remote r ON r.image_id = i.id
WHERE i.id = ?1 AND ({sql}))"
),
[image.0 as i64],
|r| r.get::<_, bool>(0),
)
.unwrap_or(false)
}
/// How many images each repair still lists, for the settings line.
/// How many images each repair still lists, for the settings line and the
/// coverage line.
///
/// Counted, not listed. [`listed`] builds a `Target` per image — its path,
/// its size — and sorts the faces-first order the job visits them in, none of
/// which a count reads; asked for six repairs on a 24,000-image library that
/// was 350 ms of `source_ref` strings built to be dropped. A `COUNT(*)` over
/// the same predicate is the same number in a tenth of the time.
pub fn counts(
catalog: &Catalog,
store: &ThumbStore,
@@ -810,10 +844,67 @@ pub fn counts(
) -> Result<Vec<(&'static str, u64)>, dr_catalog::CatalogError> {
repairs
.iter()
.map(|r| Ok((r.label, listed(catalog, store, r)?.len() as u64)))
.map(|r| Ok((r.label, count(catalog, store, r)?)))
.collect()
}
/// How many images one repair lists — the size of [`listed`]'s answer,
/// without building it.
fn count(
catalog: &Catalog,
store: &ThumbStore,
repair: &Repair,
) -> Result<u64, dr_catalog::CatalogError> {
match &repair.needs {
Needs::Sql(sql) => {
let n: i64 = catalog.connection().query_row(
&format!(
"SELECT COUNT(*)
FROM images i
JOIN remote r ON r.image_id = i.id
WHERE r.file_id IS NOT NULL AND {VISIBLE} AND ({sql})"
),
[],
|r| r.get(0),
)?;
Ok(n as u64)
}
// From the faces, not the images: see `Needs::Face`.
Needs::Face(fragment) => {
let n: i64 = catalog.connection().query_row(
&format!(
"SELECT COUNT(DISTINCT f.image_id)
FROM faces f
JOIN images i ON i.id = f.image_id
JOIN remote r ON r.image_id = i.id
WHERE {fragment} AND r.file_id IS NOT NULL AND {VISIBLE}"
),
[],
|r| r.get(0),
)?;
Ok(n as u64)
}
// The set is built from its own query and may name images `listed`
// would not visit, so it is intersected with the same base rather
// than trusted for its size.
Needs::Set(f) => {
let set = f(catalog, store)?;
let mut stmt = catalog.connection().prepare(&format!(
"SELECT i.id
FROM images i
JOIN remote r ON r.image_id = i.id
WHERE r.file_id IS NOT NULL AND {VISIBLE}"
))?;
let n = stmt
.query_map([], |r| r.get::<_, i64>(0))?
.filter_map(Result::ok)
.filter(|id| set.contains(id))
.count();
Ok(n as u64)
}
}
}
/// One image on the work list, with the most any repair claiming it asks
/// for.
struct Planned {
@@ -842,7 +933,7 @@ fn plan(
for repair in repairs {
let set = match &repair.needs {
Needs::Set(f) => Some(f(catalog, store)?),
Needs::Sql(_) => None,
Needs::Sql(_) | Needs::Face(_) => None,
};
let listed = listed(catalog, store, repair)?;
if !listed.is_empty() {
@@ -1400,6 +1491,74 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
/// `counts` answers from the faces, `listed` from the images (see
/// `Needs::Face`), and the two spellings of each predicate have to
/// agree -- for every repair, on a library where each has something to
/// do and something already done.
#[test]
fn counts_are_the_sizes_of_the_lists() {
let catalog = with_images(5);
let ids = image_ids(&catalog);
let (store, dir) = store();
let conn = catalog.connection();
// 0: two faces, one measured, neither read for eyes, one without a
// crop -- the per-face repairs disagree about it face by face.
faces::record_detections(
conn,
ids[0],
"w600k_mbf",
4000,
&[
face("w600k_mbf", None, vec![1]),
face("w600k_mbf", Some(18.0), Vec::new()),
],
)
.unwrap();
// 1: done, under the chosen detector.
faces::record_detections(
conn,
ids[1],
"scrfd_10g+w600k_mbf",
4000,
&[complete("scrfd_10g+w600k_mbf")],
)
.unwrap();
// 2: examined by a weaker detector, nothing found.
faces::record_detections(conn, ids[2], "w600k_mbf", 4000, &[]).unwrap();
// 3, 4: never examined.
let repairs = registry(
Scope::Outstanding,
"scrfd_10g+w600k_mbf",
FaceDetector::Scrfd10g,
ALL,
);
let counted = counts(&catalog, &store, &repairs).unwrap();
for (repair, (label, n)) in repairs.iter().zip(counted) {
assert_eq!(label, repair.label);
let list = listed(&catalog, &store, repair).unwrap();
assert_eq!(n as usize, list.len(), "{}", repair.name);
}
// And the fixture exercised what it claims to.
let names: Vec<&str> = repairs.iter().map(|r| r.name).collect();
for name in [
"face-quality",
"face-eyes",
"face-crop",
"face-detection",
"face-upgrade",
] {
assert!(names.contains(&name), "{name} missing from the registry");
assert!(
!listed(&catalog, &store, by_name(&repairs, name))
.unwrap()
.is_empty(),
"{name} has nothing to do"
);
}
let _ = std::fs::remove_dir_all(dir);
}
/// The state schema V14 leaves: a face with no quality and an image
/// with no marker. It is the quality repair's work, and *only* that
/// repair's -- a full re-detection of the same image would throw away
+2
View File
@@ -192,6 +192,7 @@ export component AppWindow inherits Window {
in property <bool> launch-signed-in: false;
in property <string> launch-account: "";
in property <string> launch-root: "";
in property <bool> launch-endpoint-is-library: false;
in property <string> launch-server: "";
in property <string> launch-folder: "";
in property <bool> launch-busy: false;
@@ -1546,6 +1547,7 @@ in property <bool> panel-visible: true;
signed-in: root.launch-signed-in;
account: root.launch-account;
library-root: root.launch-root;
endpoint-is-library: root.launch-endpoint-is-library;
server-url: root.launch-server;
folder-path: root.launch-folder;
busy: root.launch-busy;
+4 -1
View File
@@ -100,7 +100,10 @@ export component GestureSheet inherits Rectangle {
spacing: Theme.gap;
Text {
text: "How to drive the grid";
// The book covers every place, and opens on Develop — a
// title naming the grid was wrong about the first thing
// under it.
text: "How to drive DarkRoom";
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
+20 -5
View File
@@ -77,6 +77,10 @@ export component LaunchScreen inherits Rectangle {
in property <bool> signed-in: false;
in property <string> account: "";
in property <string> library-root: "";
/// The endpoint is the whole library — a folder — so there is no root
/// to choose inside it, only, optionally, a subfolder to narrow to. A
/// server account's endpoint is the entire tree and a root is required.
in property <bool> endpoint-is-library: false;
in property <string> server-url: "";
// Two endpoints, shown together. Someone deciding between a server and a
// folder should not have to clear one field to try the other.
@@ -349,7 +353,9 @@ export component LaunchScreen inherits Rectangle {
if root.signed-in && root.browsing: VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: @tr("CHOOSE LIBRARY FOLDER"); }
PanelHeading {
text: root.endpoint-is-library ? @tr("SCAN ONLY A SUBFOLDER") : @tr("CHOOSE LIBRARY FOLDER");
}
// Current location, so it is always clear what
// "Use this folder" would select.
@@ -428,22 +434,31 @@ export component LaunchScreen inherits Rectangle {
if root.signed-in && !root.browsing: VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: @tr("ACCOUNT"); }
// A folder *is* the library, and saying "account" of a
// directory, then asking for a "library folder" a second
// time, reads as though the path just typed was not
// taken. A server account genuinely has both: who it is,
// and which subtree of it to catalogue.
PanelHeading { text: root.endpoint-is-library ? @tr("LIBRARY") : @tr("ACCOUNT"); }
Value { text: root.account; }
Rectangle { height: Theme.gap-sm; }
PanelHeading { text: @tr("LIBRARY FOLDER"); }
PanelHeading {
text: root.endpoint-is-library ? @tr("SCAN") : @tr("LIBRARY FOLDER");
}
HorizontalLayout {
spacing: Theme.gap;
Value {
text: root.library-root == "" ? @tr("(not chosen)") : root.library-root;
text: root.library-root == "" ? @tr("(not chosen)")
: root.library-root == "/" && root.endpoint-is-library ? @tr("The whole folder")
: root.library-root;
placeholder: root.library-root == "";
horizontal-stretch: 1;
overflow: elide;
}
FormButton {
text: @tr("Choose…");
text: root.endpoint-is-library ? @tr("Subfolder…") : @tr("Choose…");
width: 110px;
clicked => { root.choose-folder(); }
}
+13 -2
View File
@@ -2581,13 +2581,19 @@ export component LibraryGrid inherits Rectangle {
// Now it is a worker, so it needs a sentence: a grid saying
// "Scanning…" while nothing is on the network is the same kind of
// lie the two answers below were separated to avoid.
// An empty trash is the ordinary state of a trash, and telling
// someone who opened it to check their folder and formats sends
// them off to fix a library that is fine.
if root.total == 0: EmptyState {
headline: root.opening
? "Opening the library…"
: (root.scanning ? "Scanning…" : "No images found");
: (root.scanning ? "Scanning…"
: (root.viewing-trash ? "The trash is empty" : "No images found"));
detail: (root.opening || root.scanning)
? root.scan-status
: "Check the library folder and which formats are ticked.";
: (root.viewing-trash
? "Photographs you delete wait here until the trash is emptied."
: "Check the library folder and which formats are ticked.");
}
// --- keyboard judgement (FR-CULL-4) -------------------------------
@@ -3897,6 +3903,11 @@ export component LibraryGrid inherits Rectangle {
// typed straight after — keywording a shoot is a run of them.
new-keyword := Field {
placeholder: "Type a keyword and press return";
// As the naming sheet below does, and for the same
// reason: the field is the only thing to do here. Without
// it the keys went to the grid behind the scrim, and the
// Return meant for the keyword opened a photograph.
init => { self.take-focus(); }
accepted(text) => {
root.assign-keyword(text);
self.text = "";
+8
View File
@@ -713,10 +713,17 @@ component MaskEntry inherits Rectangle {
reset => { root.feather-changed(0.004); }
}
// Wrapped, both of them: five chips in one row is 440px of
// declared width, and in this column the widest panel's request
// is what every panel gets — selecting a category mask levered
// the whole sidebar open past the window's edge and clipped the
// histogram, the groups strip and the subject list along with
// it. See `ChipGrid`.
if root.data.shapeable: Segmented {
label: "Falloff";
options: ["Hard", "Linear", "Smooth", "Gaussian", "Expo"];
selected: root.data.falloff;
columns: 3;
picked(i) => { root.falloff-picked(i); }
}
@@ -726,6 +733,7 @@ component MaskEntry inherits Rectangle {
+ "pinholes; open removes specks.";
options: ["None", "Grow", "Shrink", "Close", "Open"];
selected: root.data.morphology;
columns: 3;
picked(i) => { root.morphology-picked(i); }
}
+10 -10
View File
@@ -63,9 +63,9 @@ export component MergePage inherits Rectangle {
/// seam. Working pixels, at the merge's working scale.
in property <float> fill-scale: 2;
in property <float> fill-erosion: 4;
in property <float> fill-coarse: 4;
in property <float> fill-band: 96;
in property <float> fill-mirror: 48;
in property <float> fill-coarse: 1;
in property <float> fill-band: 192;
in property <float> fill-mirror: 0;
in property <float> fill-feather: 48;
/// The composite, once written: its name for the status line, and
@@ -240,26 +240,26 @@ export component MergePage inherits Rectangle {
SliderRow {
label: "Coarse pass";
hint: "reduction of the structure pass; 1 skips it";
value: root.fill-coarse; default-value: 4; minimum: 1; maximum: 8;
value: root.fill-coarse; default-value: 1; minimum: 1; maximum: 8;
enabled: !root.running && !root.done;
changed(v) => { root.fill-knob("coarse", v); }
reset => { root.fill-knob("coarse", 4); }
reset => { root.fill-knob("coarse", 1); }
}
SliderRow {
label: "Band width";
hint: "the fine passes' bands from the edge outward, working pixels";
value: root.fill-band; default-value: 96; minimum: 16; maximum: 384;
value: root.fill-band; default-value: 192; minimum: 16; maximum: 384;
enabled: !root.running && !root.done;
changed(v) => { root.fill-knob("band", v); }
reset => { root.fill-knob("band", 96); }
reset => { root.fill-knob("band", 192); }
}
SliderRow {
label: "Mirror depth";
hint: "how far into the picture the mirrored context reaches";
value: root.fill-mirror; default-value: 48; minimum: 4; maximum: 512;
hint: "0: the void as it is, what the model was trained on; otherwise how far into the picture a mirrored context reaches";
value: root.fill-mirror; default-value: 0; minimum: 0; maximum: 512;
enabled: !root.running && !root.done;
changed(v) => { root.fill-knob("mirror", v); }
reset => { root.fill-knob("mirror", 48); }
reset => { root.fill-knob("mirror", 0); }
}
SliderRow {
label: "Seam feather";
+4 -1
View File
@@ -1161,7 +1161,10 @@ export component SettingsPage inherits Rectangle {
TextRow {
label: "Destination";
hint: "empty asks each time";
// What an empty field does, not what it was once
// going to do: nothing asks, and an export with
// no folder is refused and says so in the header.
hint: "a folder on this device; exports are refused until one is set";
text <=> root.destination;
field-width: 320px;
placeholder: root.destination-hint;