Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
301e6f3828 | ||
|
|
a437363bd6 | ||
|
|
065872bec5 | ||
|
|
0ed38ada28 | ||
|
|
695d5ec304 | ||
|
|
ef1afc254d | ||
|
|
103c6e7fc0 | ||
|
|
e9398de9c1 | ||
|
|
b1c99b5796 | ||
|
|
3de109fbd1 | ||
|
|
388bda6af3 | ||
|
|
73845d8a77 | ||
|
|
b4821ee1ab | ||
|
|
9d1aa5735b | ||
|
|
97a854833d | ||
|
|
e0e193efb4 | ||
|
|
d790961b28 | ||
|
|
14ac41bee0 | ||
|
|
86410def88 | ||
|
|
df8be10c7d | ||
|
|
f176043632 | ||
|
|
9c2cd73337 | ||
|
|
e3acbdf4a3 | ||
|
|
eddaa44cd3 | ||
|
|
7fba28f7d8 | ||
|
|
0fa9003e54 | ||
|
|
e750bcdb8c | ||
|
|
48c4b403d2 |
@@ -20,3 +20,9 @@
|
|||||||
# reasoning as the models, with the opposite default: the model is not
|
# reasoning as the models, with the opposite default: the model is not
|
||||||
# optional and the fixtures are.
|
# optional and the fixtures are.
|
||||||
fixtures/** filter=lfs diff=lfs merge=lfs -text
|
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
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ jobs:
|
|||||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"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
|
- name: Cache cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ jobs:
|
|||||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"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/
|
ls -lR models/
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
@@ -213,7 +213,7 @@ jobs:
|
|||||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"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/
|
ls -lR models/
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
@@ -406,7 +406,7 @@ jobs:
|
|||||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||||
git config --local lfs.url \
|
git config --local lfs.url \
|
||||||
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
"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
|
ls -l models/face models/scene
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
|
|||||||
@@ -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
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-android"
|
name = "darkroom-android"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1234,7 +1234,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-desktop"
|
name = "darkroom-desktop"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1408,7 +1408,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-bench"
|
name = "dr-bench"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-catalog",
|
"dr-catalog",
|
||||||
@@ -1425,7 +1425,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-catalog"
|
name = "dr-catalog"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-face",
|
"dr-face",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1440,7 +1440,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-decode"
|
name = "dr-decode"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1454,7 +1454,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-export"
|
name = "dr-export"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
@@ -1473,7 +1473,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-face"
|
name = "dr-face"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-film"
|
name = "dr-film"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1495,7 +1495,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-gpu"
|
name = "dr-gpu"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1513,7 +1513,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-inference-engine"
|
name = "dr-inference-engine"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libloading",
|
"libloading",
|
||||||
"log",
|
"log",
|
||||||
@@ -1527,7 +1527,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ingest"
|
name = "dr-ingest"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1539,7 +1539,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-lens"
|
name = "dr-lens"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lensfun",
|
"lensfun",
|
||||||
"log",
|
"log",
|
||||||
@@ -1547,7 +1547,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pano"
|
name = "dr-pano"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
@@ -1561,7 +1561,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pipeline"
|
name = "dr-pipeline"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -1570,7 +1570,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-plat"
|
name = "dr-plat"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-native-keyring-store",
|
"android-native-keyring-store",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1586,7 +1586,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-preset-xmp"
|
name = "dr-preset-xmp"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-pipeline",
|
"dr-pipeline",
|
||||||
"log",
|
"log",
|
||||||
@@ -1596,7 +1596,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-segment"
|
name = "dr-segment"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1609,7 +1609,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync"
|
name = "dr-sync"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1623,7 +1623,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-folder"
|
name = "dr-sync-folder"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-sync",
|
"dr-sync",
|
||||||
@@ -1635,7 +1635,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-nextcloud"
|
name = "dr-sync-nextcloud"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1657,7 +1657,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-thumbs"
|
name = "dr-thumbs"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
@@ -1669,7 +1669,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-types"
|
name = "dr-types"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1678,7 +1678,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ui"
|
name = "dr-ui"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1720,7 +1720,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-xmp"
|
name = "dr-xmp"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -7021,7 +7021,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traceability"
|
name = "traceability"
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.13.1"
|
version = "0.13.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|||||||
@@ -1,84 +1,134 @@
|
|||||||
# DarkRoom
|
# 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
|
[](docs/manual/README.md)
|
||||||
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.
|
|
||||||
|
|
||||||
## 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 |
|
## What it does
|
||||||
|---|---|
|
|
||||||
| [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 |
|
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
|
[](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.
|
||||||
|
|
||||||
|
[](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
|
```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
|
```bash
|
||||||
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
|
./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 system packages, the four
|
||||||
[CONTRIBUTING.md](CONTRIBUTING.md) has the details and the four commands CI
|
commands CI runs against what you send, and the shortest useful
|
||||||
will run against what you send.
|
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
|
**0.13.3**, seventeen tagged releases in. 184 numbered requirements in
|
||||||
sync client keeps in virtual-files mode — where a placeholder is treated as the
|
scope, 84% of them claimed by code and [traced to it](docs/traceability.md);
|
||||||
photograph rather than as a one-byte file. A virtualised library grid with a
|
the rest are written down rather than merely absent.
|
||||||
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.
|
|
||||||
|
|
||||||
**The zero-copy display path works on desktop.** The compute pass writes a
|
**Not built:** plugins (post-v1, [D12](docs/requirements.md)), compare and
|
||||||
texture that Slint composites directly, which is what
|
survey culling, AI denoise, tiled and progressive rendering, HDR merge and
|
||||||
[ARCH §6.1](docs/architecture.md) requires; the readback it forbids costs 96%
|
focus stacking, most of the Android platform integration beyond running,
|
||||||
of frame time at 4K, and
|
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
|
**The one deliberate compromise worth knowing about before reading
|
||||||
cargo run -p dr-gpu --example bench --features readback
|
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
|
## Documentation
|
||||||
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).
|
|
||||||
|
|
||||||
**Not built.** Plugins, compare and survey culling, focus peaking, burst
|
For someone using it:
|
||||||
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.
|
| [manual](docs/manual/README.md) | Every feature, pictured |
|
||||||
[docs/outstanding.md](docs/outstanding.md) is the list, with the reasoning.
|
| [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
|
## 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).
|
||||||
|
|||||||
@@ -820,20 +820,22 @@ pub fn import_from_shards(
|
|||||||
let Some((faces, edge)) = store.get_image(file_id as u64, &held)? else {
|
let Some((faces, edge)) = store.get_image(file_id as u64, &held)? else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// A peer that embedded before the quality was kept has done work this
|
// A face the peer embedded before its quality was kept (schema V14)
|
||||||
// device cannot finish: the number exists only at embedding time, and
|
// is adopted with the reading missing, exactly as one without an eye
|
||||||
// adopting the faces would write the run marker that keeps them from
|
// reading is. The measuring passes find their work by the NULL
|
||||||
// ever being measured (schema V14). Left for this device's own pass —
|
// column, not by the run marker (`dr_ui::repairs`, `faces_needing`),
|
||||||
// or for the peer's, whose re-export replaces these.
|
// 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
|
// This used to refuse such faces, on the reasoning that the marker
|
||||||
// measuring pass finds those by the NULL, not by the marker, so
|
// would stop them ever being measured — true before the quality
|
||||||
// adopting the faces costs the reading nothing (schema V16) — and a
|
// repair existed, and wrong after. What it cost: V14 had dropped the
|
||||||
// peer that has no eye models may be the only one that has done the
|
// markers of every image holding such faces, so the peer never
|
||||||
// detection at all.
|
// re-exported them, and the only copies in the shards were the
|
||||||
if faces.iter().any(|f| f.quality.is_none()) {
|
// unmeasured ones. A tablet holding shards with 4,310 of the
|
||||||
continue;
|
// 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
|
let local: Vec<crate::faces::DetectedFace> = faces
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| crate::faces::DetectedFace {
|
.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
|
/// A face a peer embedded without measuring it is adopted all the same,
|
||||||
/// cannot finish, and adopting it would write the marker that stops it
|
/// and left on this device's quality pass by its missing reading. Refusing
|
||||||
/// ever being measured. The image stays outstanding instead.
|
/// 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]
|
#[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 b = device(&[(90, 5001), (91, 5002)]);
|
||||||
let mut store = FaceShardStore::open(&tempdir("unmeasured")).unwrap();
|
let mut store = FaceShardStore::open(&tempdir("unmeasured")).unwrap();
|
||||||
let shared = |file_id: u64, quality: Option<f32>| SharedFace {
|
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))])
|
.put_image(5002, "w600k_mbf", 2560, &[shared(5002, Some(19.0))])
|
||||||
.unwrap();
|
.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();
|
let cov = faces::coverage(&b, "w600k_mbf").unwrap();
|
||||||
assert_eq!(cov.indexed, 1);
|
assert_eq!(cov.indexed, 2);
|
||||||
assert_eq!(cov.outstanding(), 1, "the unmeasured image was adopted");
|
assert_eq!(cov.outstanding(), 0, "the unmeasured image was refused");
|
||||||
assert!(faces::for_image(&b, dr_types::ImageId(90))
|
let got = faces::for_image(&b, dr_types::ImageId(90)).unwrap();
|
||||||
.unwrap()
|
assert_eq!(got.len(), 1);
|
||||||
.is_empty());
|
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]
|
#[test]
|
||||||
|
|||||||
+229
-13
@@ -556,6 +556,19 @@ impl FaceUpdate {
|
|||||||
/// bookkeeping: `face_shard::export_to_shards` re-exports an image whose
|
/// 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
|
/// marker is newer than the store's copy, which is how what was written
|
||||||
/// here reaches the other devices.
|
/// 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(
|
pub fn record_updates(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
image_id: ImageId,
|
image_id: ImageId,
|
||||||
@@ -602,13 +615,25 @@ pub fn record_updates(
|
|||||||
for f in dropped {
|
for f in dropped {
|
||||||
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
|
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!(
|
&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")
|
embedder_sql("model_id")
|
||||||
),
|
),
|
||||||
rusqlite::params![image_id.0 as i64, embedder_of(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(
|
tx.execute(
|
||||||
"INSERT INTO face_index (image_id, model_id, indexed_at, faces_found, source_edge)
|
"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",
|
source_edge = excluded.source_edge",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
image_id.0 as i64,
|
image_id.0 as i64,
|
||||||
model_id,
|
marker,
|
||||||
now_secs(),
|
now_secs(),
|
||||||
remaining,
|
remaining,
|
||||||
source_edge as i64,
|
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)
|
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.
|
/// 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
|
/// 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
|
/// Merged-away people are excluded: they exist as redirects so a sync does not
|
||||||
/// resurrect them, not as entries in a list.
|
/// resurrect them, not as entries in a list.
|
||||||
pub fn people(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
|
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,
|
"SELECT p.id, p.uuid, p.name,
|
||||||
COALESCE(SUM(fp.confirmed = 1), 0),
|
COALESCE(c.confirmed, 0),
|
||||||
COALESCE(SUM(fp.confirmed = 0), 0),
|
COALESCE(c.suggested, 0),
|
||||||
p.ignored
|
p.ignored
|
||||||
FROM people p
|
FROM people p
|
||||||
LEFT JOIN face_person fp ON fp.person_id = p.id
|
LEFT JOIN (SELECT person_id,
|
||||||
WHERE p.merged_into IS NULL
|
SUM(confirmed = 1) AS confirmed,
|
||||||
GROUP BY p.id
|
SUM(confirmed = 0) AS suggested
|
||||||
ORDER BY 4 DESC, 5 DESC, p.name",
|
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| {
|
let rows = q.query_map([], |r| {
|
||||||
Ok(Person {
|
Ok(Person {
|
||||||
id: PersonId(r.get::<_, i64>(0)? as u64),
|
id: PersonId(r.get::<_, i64>(0)? as u64),
|
||||||
@@ -1057,6 +1127,72 @@ pub fn confirm(conn: &Connection, face: FaceId, person: PersonId) -> Result<(),
|
|||||||
Ok(())
|
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.
|
/// The user says this face is **not** this person.
|
||||||
///
|
///
|
||||||
/// Stored rather than implied by removal, so the next clustering pass does not
|
/// 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()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs() as i64)
|
.map(|d| d.as_secs() as i64)
|
||||||
@@ -1779,6 +1915,86 @@ mod tests {
|
|||||||
assert!(at >= marked_at, "the marker was not refreshed");
|
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
|
/// Re-detection is coalesced per image, so it must replace rather than
|
||||||
/// append — otherwise every re-index doubles the library's face count.
|
/// append — otherwise every re-index doubles the library's face count.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
|||||||
use crate::error::CatalogError;
|
use crate::error::CatalogError;
|
||||||
|
|
||||||
/// Schema version this build writes and understands.
|
/// 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`].
|
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||||
///
|
///
|
||||||
@@ -181,9 +181,105 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
|||||||
tx.commit()?;
|
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)
|
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.
|
/// 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,
|
/// 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
|
// V18 -- TRACES: FR-CULL-8a | FR-CULL-12
|
||||||
//
|
//
|
||||||
// The 106 dense landmarks the eye pass reads its eye boxes from, kept beside
|
// The 106 dense landmarks the eye pass reads its eye boxes from, kept beside
|
||||||
@@ -1809,6 +1943,90 @@ mod tests {
|
|||||||
assert_eq!(faces, 2);
|
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]
|
#[test]
|
||||||
fn job_uniqueness_coalesces_rather_than_duplicating() {
|
fn job_uniqueness_coalesces_rather_than_duplicating() {
|
||||||
let c = mem();
|
let c = mem();
|
||||||
|
|||||||
@@ -304,6 +304,26 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
|||||||
error::guarded("metadata", || metadata_unguarded(bytes))
|
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> {
|
fn metadata_unguarded(bytes: &[u8]) -> Result<Metadata, DecodeError> {
|
||||||
use rawler::rawsource::RawSource;
|
use rawler::rawsource::RawSource;
|
||||||
|
|
||||||
|
|||||||
@@ -183,22 +183,65 @@ struct Entry {
|
|||||||
value: u32,
|
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.
|
/// A minimal TIFF structure reader.
|
||||||
///
|
///
|
||||||
/// Deliberately not a general TIFF parser: it reads the IFD chain and entry
|
/// 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.
|
/// values and nothing else, because that is all locating a preview needs.
|
||||||
struct TiffReader<'a> {
|
struct TiffReader<'a> {
|
||||||
data: &'a [u8],
|
data: Src<'a>,
|
||||||
little_endian: bool,
|
little_endian: bool,
|
||||||
first_ifd: u32,
|
first_ifd: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> TiffReader<'a> {
|
impl<'a> TiffReader<'a> {
|
||||||
fn new(data: &'a [u8]) -> Option<Self> {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
let little_endian = match &data[0..2] {
|
let little_endian = match &head[0..2] {
|
||||||
b"II" => true,
|
b"II" => true,
|
||||||
b"MM" => false,
|
b"MM" => false,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
@@ -362,9 +405,7 @@ impl<'a> TiffReader<'a> {
|
|||||||
};
|
};
|
||||||
raw[..len.min(4)].to_vec()
|
raw[..len.min(4)].to_vec()
|
||||||
} else {
|
} else {
|
||||||
self.data
|
self.data.get(e.value as usize, len)?.to_vec()
|
||||||
.get(e.value as usize..e.value as usize + len)?
|
|
||||||
.to_vec()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = String::from_utf8_lossy(&bytes);
|
let s = String::from_utf8_lossy(&bytes);
|
||||||
@@ -396,8 +437,7 @@ impl<'a> TiffReader<'a> {
|
|||||||
// same way to recover the original byte order.
|
// same way to recover the original byte order.
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let start = e.value as usize;
|
self.data.get(e.value as usize, len)
|
||||||
self.data.get(start..start.checked_add(len)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn offsets(&self, e: &Entry) -> Vec<u32> {
|
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> {
|
fn read_u16(data: Src<'_>, at: usize, le: bool) -> Option<u16> {
|
||||||
let b = data.get(at..at + 2)?;
|
let b = data.get(at, 2)?;
|
||||||
Some(if le {
|
Some(if le {
|
||||||
u16::from_le_bytes([b[0], b[1]])
|
u16::from_le_bytes([b[0], b[1]])
|
||||||
} else {
|
} 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> {
|
fn read_u32(data: Src<'_>, at: usize, le: bool) -> Option<u32> {
|
||||||
let b = data.get(at..at + 4)?;
|
let b = data.get(at, 4)?;
|
||||||
Some(if le {
|
Some(if le {
|
||||||
u32::from_le_bytes([b[0], b[1], b[2], b[3]])
|
u32::from_le_bytes([b[0], b[1], b[2], b[3]])
|
||||||
} else {
|
} 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 —
|
/// no `DateTimeOriginal` for some DNGs whose tag sits plainly at byte 826 —
|
||||||
/// and without this fallback those images are silently undated.
|
/// and without this fallback those images are silently undated.
|
||||||
pub fn tiff_metadata(tiff_data: &[u8]) -> Result<crate::Metadata, crate::DecodeError> {
|
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()))?;
|
.ok_or_else(|| crate::DecodeError::Metadata("malformed EXIF header".into()))?;
|
||||||
|
|
||||||
let mut md = crate::Metadata::default();
|
let mut md = crate::Metadata::default();
|
||||||
|
|||||||
@@ -241,6 +241,8 @@ mod tests {
|
|||||||
let source = SourceMetadata {
|
let source = SourceMetadata {
|
||||||
make: Some("Canon".into()),
|
make: Some("Canon".into()),
|
||||||
model: Some("Canon EOS 6D".into()),
|
model: Some("Canon EOS 6D".into()),
|
||||||
|
captured_at: Some(1_754_398_664),
|
||||||
|
captured_offset: Some(120),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
write_linear_dng(
|
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));
|
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]
|
#[test]
|
||||||
fn a_strip_of_the_wrong_length_is_refused() {
|
fn a_strip_of_the_wrong_length_is_refused() {
|
||||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||||
|
|||||||
+86
-14
@@ -89,12 +89,19 @@ pub struct Params {
|
|||||||
pub coarse: usize,
|
pub coarse: usize,
|
||||||
/// The fine passes' band width.
|
/// The fine passes' band width.
|
||||||
pub band: usize,
|
pub band: usize,
|
||||||
/// How deep into the picture the mirrored context reaches. A plain
|
/// How deep into the picture the mirrored context reaches, or **zero
|
||||||
/// reflection of a deep hole pulls in whatever is that far from the
|
/// for no mirrored context at all**: the void is then shown to the
|
||||||
/// edge — a ridge, a peak — and the model, told that is what lies
|
/// model as it is — reaching the picture's edge with nothing beyond,
|
||||||
/// beyond, paints it upside down. Folding the reflection within this
|
/// and, beyond the band being filled, still unknown. That is what the
|
||||||
/// band keeps the ring looking like the edge it continues (sky beside
|
/// shipped model was trained on (a fine-tune of MI-GAN on voids cut
|
||||||
/// sky, grass beside grass) and nothing further away.
|
/// 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,
|
pub mirror_depth: usize,
|
||||||
/// How far inside the real edge the fill also regenerates, the two
|
/// How far inside the real edge the fill also regenerates, the two
|
||||||
/// blended by distance. A hard cut between real pixels and invented
|
/// blended by distance. A hard cut between real pixels and invented
|
||||||
@@ -108,9 +115,9 @@ pub struct Params {
|
|||||||
impl Default for Params {
|
impl Default for Params {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Params {
|
Params {
|
||||||
coarse: 4,
|
coarse: 1,
|
||||||
band: 96,
|
band: 192,
|
||||||
mirror_depth: 48,
|
mirror_depth: 0,
|
||||||
feather: 24,
|
feather: 24,
|
||||||
stride: 384,
|
stride: 384,
|
||||||
}
|
}
|
||||||
@@ -141,7 +148,10 @@ pub fn fill_border(
|
|||||||
} = params;
|
} = params;
|
||||||
let q = q.max(1);
|
let q = q.max(1);
|
||||||
let band = band.max(8);
|
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
|
if width == 0 || height == 0 || rgb.len() != width * height * 3 || known.len() != width * height
|
||||||
{
|
{
|
||||||
return Err(PanoError::Input("fill: buffer sizes disagree".into()));
|
return Err(PanoError::Input("fill: buffer sizes disagree".into()));
|
||||||
@@ -227,7 +237,11 @@ pub fn fill_border(
|
|||||||
let mut any = false;
|
let mut any = false;
|
||||||
for i in 0..width * height {
|
for i in 0..width * height {
|
||||||
let in_band = !known[i] && dist[i] > lo && dist[i] <= hi;
|
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;
|
any |= in_band;
|
||||||
}
|
}
|
||||||
if !any {
|
if !any {
|
||||||
@@ -369,7 +383,7 @@ fn fill_once(
|
|||||||
if known[i] {
|
if known[i] {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let p = (yy + RING) * pw + (xx + RING);
|
let p = (yy + ctx.ring) * pw + (xx + ctx.ring);
|
||||||
if wsum[p] > 0.0 {
|
if wsum[p] > 0.0 {
|
||||||
for ch in 0..3 {
|
for ch in 0..3 {
|
||||||
rgb[i * 3 + ch] = (acc[p * 3 + ch] / wsum[p]).clamp(0.0, 1.0);
|
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 {
|
struct MirroredContext {
|
||||||
width: usize,
|
width: usize,
|
||||||
height: usize,
|
height: usize,
|
||||||
|
/// The padding on every side: `RING` with mirrored context, 0 without.
|
||||||
|
ring: usize,
|
||||||
rgb: Vec<f32>,
|
rgb: Vec<f32>,
|
||||||
hole: Vec<bool>,
|
hole: Vec<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MirroredContext {
|
impl MirroredContext {
|
||||||
fn build(rgb: &[f32], width: usize, height: usize, known: &[bool], depth: usize) -> Self {
|
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 fold = |d: usize| fold(d, depth);
|
||||||
let (pw, ph) = (width + 2 * RING, height + 2 * RING);
|
let (pw, ph) = (width + 2 * RING, height + 2 * RING);
|
||||||
let mut canvas = vec![0.0f32; pw * ph * 3];
|
let mut canvas = vec![0.0f32; pw * ph * 3];
|
||||||
@@ -534,6 +561,7 @@ impl MirroredContext {
|
|||||||
MirroredContext {
|
MirroredContext {
|
||||||
width: pw,
|
width: pw,
|
||||||
height: ph,
|
height: ph,
|
||||||
|
ring: RING,
|
||||||
rgb: canvas,
|
rgb: canvas,
|
||||||
hole,
|
hole,
|
||||||
}
|
}
|
||||||
@@ -634,7 +662,10 @@ mod tests {
|
|||||||
200,
|
200,
|
||||||
&known,
|
&known,
|
||||||
&mut model,
|
&mut model,
|
||||||
test_params(0),
|
Params {
|
||||||
|
mirror_depth: 48,
|
||||||
|
..test_params(0)
|
||||||
|
},
|
||||||
&mut |_, _| {},
|
&mut |_, _| {},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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]
|
#[test]
|
||||||
fn the_fine_passes_run_in_bands_after_the_coarse_one() {
|
fn the_fine_passes_run_in_bands_after_the_coarse_one() {
|
||||||
// A 150-tall hole above and below a picture: the coarse pass sees
|
// A 150-tall hole above and below a picture: the coarse pass sees
|
||||||
@@ -663,7 +730,12 @@ mod tests {
|
|||||||
500,
|
500,
|
||||||
&known,
|
&known,
|
||||||
&mut model,
|
&mut model,
|
||||||
test_params(0),
|
Params {
|
||||||
|
coarse: 4,
|
||||||
|
band: 96,
|
||||||
|
mirror_depth: 48,
|
||||||
|
..test_params(0)
|
||||||
|
},
|
||||||
&mut |_, _| {},
|
&mut |_, _| {},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ pub struct NextcloudBackend {
|
|||||||
/// `/remote.php/dav/files/<user>/` — the prefix stripped from hrefs.
|
/// `/remote.php/dav/files/<user>/` — the prefix stripped from hrefs.
|
||||||
dav_base: String,
|
dav_base: String,
|
||||||
caps: Capabilities,
|
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 {
|
impl NextcloudBackend {
|
||||||
@@ -63,6 +77,7 @@ impl NextcloudBackend {
|
|||||||
login: creds.login_name.clone(),
|
login: creds.login_name.clone(),
|
||||||
password: creds.app_password.clone(),
|
password: creds.app_password.clone(),
|
||||||
dav_base,
|
dav_base,
|
||||||
|
known_dirs: Default::default(),
|
||||||
caps: Capabilities {
|
caps: Capabilities {
|
||||||
// The property that makes a no-op sync one request (ARCH §8.1).
|
// The property that makes a no-op sync one request (ARCH §8.1).
|
||||||
change_detection: ChangeDetection::PropagatingEtags,
|
change_detection: ChangeDetection::PropagatingEtags,
|
||||||
@@ -567,6 +582,16 @@ impl RemoteBackend for NextcloudBackend {
|
|||||||
chain.reverse();
|
chain.reverse();
|
||||||
|
|
||||||
for dir in chain {
|
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 url = self.url_for(&dir);
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -581,10 +606,12 @@ impl RemoteBackend for NextcloudBackend {
|
|||||||
|
|
||||||
// 405 is "already a collection here", which is exactly what the
|
// 405 is "already a collection here", which is exactly what the
|
||||||
// caller wanted. Anything else is reported.
|
// caller wanted. Anything else is reported.
|
||||||
if resp.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED {
|
if resp.status() != reqwest::StatusCode::METHOD_NOT_ALLOWED {
|
||||||
continue;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,19 @@ pub struct Account {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub root: String,
|
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).
|
/// Which formats the scan looks for (the tick-boxes).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub formats: Vec<String>,
|
pub formats: Vec<String>,
|
||||||
@@ -149,6 +162,7 @@ impl Account {
|
|||||||
login: String::new(),
|
login: String::new(),
|
||||||
user_id: String::new(),
|
user_id: String::new(),
|
||||||
root: String::new(),
|
root: String::new(),
|
||||||
|
root_chosen: false,
|
||||||
formats: Vec::new(),
|
formats: Vec::new(),
|
||||||
last_scan: None,
|
last_scan: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,6 +240,23 @@ impl ThumbStore {
|
|||||||
.collect()
|
.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.
|
/// 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
|
/// 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"))
|
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 {
|
pub fn index_path(&self) -> PathBuf {
|
||||||
self.dir.join("index.sqlite")
|
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, ©).unwrap();
|
||||||
|
let file_rows = Connection::open(©)
|
||||||
|
.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]
|
#[test]
|
||||||
fn a_forgotten_thumbnail_is_no_longer_served() {
|
fn a_forgotten_thumbnail_is_no_longer_served() {
|
||||||
// The point of the whole method: a purged photograph must not keep a
|
// The point of the whole method: a purged photograph must not keep a
|
||||||
|
|||||||
@@ -885,9 +885,14 @@ impl ExportSettings {
|
|||||||
///
|
///
|
||||||
/// The library root has to read as a place rather than as a blank field,
|
/// 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.
|
/// 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 {
|
pub fn destination_label(&self) -> &str {
|
||||||
match self.target {
|
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::Device => &self.destination,
|
||||||
ExportTarget::Remote if self.remote_destination.trim().is_empty() => "Library root",
|
ExportTarget::Remote if self.remote_destination.trim().is_empty() => "Library root",
|
||||||
ExportTarget::Remote => &self.remote_destination,
|
ExportTarget::Remote => &self.remote_destination,
|
||||||
@@ -1621,14 +1626,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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
|
// 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();
|
let mut s = Settings::default();
|
||||||
s.export.target = ExportTarget::Device;
|
s.export.target = ExportTarget::Device;
|
||||||
s.export.destination = String::new();
|
s.export.destination = String::new();
|
||||||
assert!(!s.export.destination_is_set());
|
assert!(!s.export.destination_is_set());
|
||||||
assert_eq!(s.export.destination_label(), "Ask each time");
|
assert_eq!(s.export.destination_label(), "Not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+19
-19
@@ -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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
### 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
|
- **Pointer** — Click it, or press Redo in the History header
|
||||||
- **Keyboard** — Ctrl+Shift+Z
|
- **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
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## The library
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Getting about
|
||||||
|
|
||||||
|
Drag the timeline to scrub through years; Ctrl and the wheel resize the
|
||||||
|
thumbnails.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Light
|
||||||
|
|
||||||
|
Exposure, contrast, highlights, shadows, blacks, whites and a tone curve.
|
||||||
|
Hold `Before` to see the photograph as it was.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Looking closer
|
||||||
|
|
||||||
|
Double-click for 1:1; drag to move about; double-click again to fit. The
|
||||||
|
wheel zooms to any amount in between.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### White balance from the photograph
|
||||||
|
|
||||||
|
Press `pick` in the White Balance group, then click something neutral.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Composing
|
||||||
|
|
||||||
|
Crop by dragging the frame's corners, straighten with the slider, lock a
|
||||||
|
ratio from the chips. `Done composing` returns to the photograph.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
BIN
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.
@@ -486,3 +486,61 @@ rectangle.
|
|||||||
`models/LICENCE.md`), installed by the PKGBUILD and unpacked by the APK
|
`models/LICENCE.md`), installed by the PKGBUILD and unpacked by the APK
|
||||||
beside the face and scene models; `tools/export-migan.sh` regenerates it
|
beside the face and scene models; `tools/export-migan.sh` regenerates it
|
||||||
from the upstream checkpoint.
|
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
File diff suppressed because one or more lines are too long
+9
-4
@@ -104,11 +104,16 @@ the dataset licence restricts models trained on it by name.
|
|||||||
|
|
||||||
| File | Source | Trained on | Used by |
|
| 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
|
The bare 512 generator at a fixed `1×4×512×512`, six operator types; the
|
||||||
`1×4×512×512`, six operator types. The tiling, the context and the blend are
|
tiling, the context and the blend are Rust (`dr_pano::fill`). Since
|
||||||
Rust (`dr_pano::fill`).
|
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
|
**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
|
repository, both read on 2026-09-19, both the plain MIT text with no further
|
||||||
|
|||||||
Binary file not shown.
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# makes `makepkg -si` in this directory install what you are actually working
|
# 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.
|
# on. Swap `source` for a tagged tarball when there is something to release.
|
||||||
pkgname=darkroom
|
pkgname=darkroom
|
||||||
pkgver=0.13.1
|
pkgver=0.13.3
|
||||||
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
# 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.
|
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
#
|
#
|
||||||
# ./tools/export-migan.sh # -> models/inpaint/migan-512.onnx
|
# ./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
|
# 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
|
# 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).
|
# Google Drive (models/LICENCE.md has the licence; it is MIT).
|
||||||
|
|||||||
@@ -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.
|
||||||
Executable
+182
@@ -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__)
|
||||||
Executable
+7
@@ -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"
|
||||||
Executable
+61
@@ -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
|
||||||
Executable
+323
@@ -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]()
|
||||||
@@ -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, ®istry).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()
|
||||||
|
}
|
||||||
@@ -286,9 +286,20 @@ async fn sync_shards(
|
|||||||
|
|
||||||
// ---- upload ----------------------------------------------------------
|
// ---- upload ----------------------------------------------------------
|
||||||
for shard in &local {
|
for shard in &local {
|
||||||
let path = store.shard_path(shard.id);
|
// A snapshot, never the live file — see `ThumbStore::snapshot_shard`
|
||||||
let Ok(bytes) = std::fs::read(&path) else {
|
// for the zero-byte upload that reading the file produced.
|
||||||
continue;
|
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);
|
let name = shard_name(&client, shard.id);
|
||||||
|
|
||||||
|
|||||||
@@ -2552,6 +2552,15 @@ impl DevelopSession {
|
|||||||
/// or reordered.
|
/// or reordered.
|
||||||
pub(crate) fn reveal(&self) -> Option<dr_pipeline::mask::Reveal> {
|
pub(crate) fn reveal(&self) -> Option<dr_pipeline::mask::Reveal> {
|
||||||
use dr_pipeline::mask::{Reveal, RevealedLayer};
|
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
|
let layers: Vec<RevealedLayer> = self
|
||||||
.graph
|
.graph
|
||||||
.masks()
|
.masks()
|
||||||
@@ -6986,6 +6995,27 @@ mod tests {
|
|||||||
/// — that the coverage is present, that it round-trips as bytes — would
|
/// — 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
|
/// still pass if the raster came back at the wrong scale, upside down, or
|
||||||
/// a threshold out.
|
/// 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]
|
#[test]
|
||||||
fn a_stored_mask_renders_exactly_what_the_model_rendered() {
|
fn a_stored_mask_renders_exactly_what_the_model_rendered() {
|
||||||
let Some(ctx) = headless() else { return };
|
let Some(ctx) = headless() else { return };
|
||||||
|
|||||||
+17
-2
@@ -116,6 +116,12 @@ pub fn faces_outstanding(
|
|||||||
ORDER BY i.id",
|
ORDER BY i.id",
|
||||||
faces::embedder_sql("fi.model_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
|
let rows = stmt
|
||||||
.query_map([faces::embedder_of(model_id)], |r| {
|
.query_map([faces::embedder_of(model_id)], |r| {
|
||||||
Ok(FaceRequest {
|
Ok(FaceRequest {
|
||||||
@@ -134,7 +140,7 @@ pub fn faces_outstanding(
|
|||||||
// a whole-library button that could only reach photographs the user had
|
// a whole-library button that could only reach photographs the user had
|
||||||
// personally zoomed into. `repairs::spawn` is that
|
// personally zoomed into. `repairs::spawn` is that
|
||||||
// requirement implemented; this one is the local-only variant.
|
// 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();
|
.collect();
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
@@ -270,12 +276,21 @@ pub fn audit(
|
|||||||
)",
|
)",
|
||||||
faces::embedder_sql("fi.model_id")
|
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);
|
let (mut ready, mut awaiting) = (0u64, 0u64);
|
||||||
for file_id in stmt
|
for file_id in stmt
|
||||||
.query_map([faces::embedder_of(model_id)], |r| r.get::<_, i64>(0))?
|
.query_map([faces::embedder_of(model_id)], |r| r.get::<_, i64>(0))?
|
||||||
.filter_map(Result::ok)
|
.filter_map(Result::ok)
|
||||||
{
|
{
|
||||||
if store.contains(file_id as u64, FACE_TIER) {
|
if held.contains(&(file_id as u64)) {
|
||||||
ready += 1;
|
ready += 1;
|
||||||
} else {
|
} else {
|
||||||
awaiting += 1;
|
awaiting += 1;
|
||||||
|
|||||||
+38
-28
@@ -216,7 +216,9 @@ pub struct IdentityView {
|
|||||||
/// Filtered rather than deleted, because this is a screen being drawn and not a
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// symptom of a real failure — a named person whose faces were regrouped out
|
||||||
@@ -228,11 +230,8 @@ pub fn load_people(
|
|||||||
model_id: &str,
|
model_id: &str,
|
||||||
) -> Result<IdentityView, dr_catalog::CatalogError> {
|
) -> Result<IdentityView, dr_catalog::CatalogError> {
|
||||||
let conn = catalog.connection();
|
let conn = catalog.connection();
|
||||||
let people = faces::people(conn)?
|
let people = faces::people_in_use(conn)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|p| {
|
|
||||||
p.confirmed_faces + p.suggested_faces > 0 || !p.name.trim().is_empty() || p.ignored
|
|
||||||
})
|
|
||||||
.map(|p| PersonRow {
|
.map(|p| PersonRow {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
@@ -247,7 +246,7 @@ pub fn load_people(
|
|||||||
people,
|
people,
|
||||||
selected: None,
|
selected: None,
|
||||||
faces: Vec::new(),
|
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),
|
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
|
/// 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
|
/// 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.
|
/// 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(
|
pub fn load_faces(
|
||||||
catalog: &Catalog,
|
catalog: &Catalog,
|
||||||
store: &ThumbStore,
|
store: &ThumbStore,
|
||||||
person: PersonId,
|
person: PersonId,
|
||||||
|
mut cut: std::collections::HashMap<FaceId, FaceCrop>,
|
||||||
) -> Result<Vec<FaceCell>, dr_catalog::CatalogError> {
|
) -> Result<Vec<FaceCell>, dr_catalog::CatalogError> {
|
||||||
let conn = catalog.connection();
|
let conn = catalog.connection();
|
||||||
let rows = faces::for_person(conn, person, true)?;
|
let rows = faces::for_person(conn, person, true)?;
|
||||||
|
|
||||||
// The crops kept at detection time, in one query. Where a face has one this
|
// The crops kept at detection time, in one query — but only when a face
|
||||||
// is the whole cost of drawing it — no proxy, no full-size JPEG decode, and
|
// is not already in hand. The common redraw has every face cached and
|
||||||
// no dependence on the thumbnail cache still holding the photograph.
|
// skips the blob read entirely; a face the cache lacks (a regroup, a
|
||||||
let stored = faces::crops_for_person(conn, person, true)?;
|
// 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
|
// 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
|
// 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());
|
let mut out = Vec::with_capacity(rows.len());
|
||||||
for f in rows {
|
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),
|
Some(c) => Some(c),
|
||||||
None => {
|
None => {
|
||||||
let entry = decoded
|
let entry = decoded
|
||||||
@@ -557,15 +579,7 @@ pub fn rename(
|
|||||||
/// right — the common case for a well-photographed person — should cost one
|
/// right — the common case for a well-photographed person — should cost one
|
||||||
/// click, not forty.
|
/// click, not forty.
|
||||||
pub fn confirm_all(catalog: &Catalog, person: PersonId) -> Result<usize, dr_catalog::CatalogError> {
|
pub fn confirm_all(catalog: &Catalog, person: PersonId) -> Result<usize, dr_catalog::CatalogError> {
|
||||||
let conn = catalog.connection();
|
faces::confirm_all(catalog.connection(), person).map(|n| n as usize)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user says this face is this person.
|
/// The user says this face is this person.
|
||||||
@@ -657,7 +671,7 @@ pub fn preview_split(
|
|||||||
.map(|(c, _)| c)
|
.map(|(c, _)| c)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let cells = load_faces(catalog, store, person)?;
|
let cells = load_faces(catalog, store, person, Default::default())?;
|
||||||
if cells.len() < 2 {
|
if cells.len() < 2 {
|
||||||
return Ok(vec![cells]);
|
return Ok(vec![cells]);
|
||||||
}
|
}
|
||||||
@@ -710,13 +724,9 @@ pub fn split_off(
|
|||||||
) -> Result<PersonId, dr_catalog::CatalogError> {
|
) -> Result<PersonId, dr_catalog::CatalogError> {
|
||||||
let conn = catalog.connection();
|
let conn = catalog.connection();
|
||||||
let new_person = faces::create_person(conn, name.trim())?;
|
let new_person = faces::create_person(conn, name.trim())?;
|
||||||
for &face in members {
|
// Rejected from `from` and confirmed onto the new person, in one
|
||||||
// Rejecting first is what stops the split being undone: without it the
|
// transaction rather than two per face: `faces::reassign` says why both.
|
||||||
// next pass sees a face that looks like `from` and suggests it straight
|
faces::reassign(conn, members, from, new_person)?;
|
||||||
// back, and the user's correction becomes an argument they keep having.
|
|
||||||
faces::reject(conn, face, from)?;
|
|
||||||
faces::confirm(conn, face, new_person)?;
|
|
||||||
}
|
|
||||||
Ok(new_person)
|
Ok(new_person)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-15
@@ -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.
|
/// Push the people rail and the face grid into the window.
|
||||||
///
|
///
|
||||||
/// **Draws with the portraits it already has and cuts the rest afterwards.**
|
/// **Draws with the portraits it already has and cuts the rest afterwards.**
|
||||||
@@ -154,6 +174,7 @@ pub fn refresh(
|
|||||||
store: Option<Rc<ThumbStore>>,
|
store: Option<Rc<ThumbStore>>,
|
||||||
model_id: &str,
|
model_id: &str,
|
||||||
eyes: bool,
|
eyes: bool,
|
||||||
|
changed: Changed,
|
||||||
) {
|
) {
|
||||||
let borrow = catalog.borrow();
|
let borrow = catalog.borrow();
|
||||||
let Some(cat) = borrow.as_ref() else {
|
let Some(cat) = borrow.as_ref() else {
|
||||||
@@ -239,7 +260,17 @@ pub fn refresh(
|
|||||||
|
|
||||||
match (ctl.selected.get(), store.as_deref()) {
|
match (ctl.selected.get(), store.as_deref()) {
|
||||||
(Some(person), Some(store)) => {
|
(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}");
|
log::warn!("identity: reading faces: {e}");
|
||||||
Vec::new()
|
Vec::new()
|
||||||
});
|
});
|
||||||
@@ -274,7 +305,9 @@ pub fn refresh(
|
|||||||
|
|
||||||
window.set_identity_picked(ctl.picked.borrow().len() as i32);
|
window.set_identity_picked(ctl.picked.borrow().len() as i32);
|
||||||
drop(borrow);
|
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.
|
// Last, so a portrait cannot delay anything above it.
|
||||||
if let Some(store) = store {
|
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
|
// 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
|
// body: macro hygiene would bind the name to this scope's `Rc`, which the
|
||||||
// first `move` closure would then take with it.
|
// 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 {
|
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(
|
refresh(
|
||||||
&$w,
|
&$w,
|
||||||
&$ctl,
|
&$ctl,
|
||||||
@@ -637,6 +672,7 @@ pub fn wire<S, M, P>(
|
|||||||
$store(),
|
$store(),
|
||||||
&model_id(&$settings),
|
&model_id(&$settings),
|
||||||
$eyes(),
|
$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
|
// 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.
|
// rather than cached: the user may have just put the models there.
|
||||||
w.set_identity_model_missing(models_present().is_none());
|
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
|
// The offer was about the person being navigated away from. Left
|
||||||
// up, its "Merge" would fold whoever is selected *now*.
|
// up, its "Merge" would fold whoever is selected *now*.
|
||||||
clear_merge_offer(&w, &ctl);
|
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);
|
reset_name_field(&w);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -748,7 +784,7 @@ pub fn wire<S, M, P>(
|
|||||||
Err(e) => log::warn!("identity: looking for a namesake: {e}"),
|
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
|
// `rename` trims; the field should show what was actually stored
|
||||||
// rather than the spacing the user happened to type.
|
// rather than the spacing the user happened to type.
|
||||||
reset_name_field(&w);
|
reset_name_field(&w);
|
||||||
@@ -786,7 +822,7 @@ pub fn wire<S, M, P>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
clear_merge_offer(&w, &ctl);
|
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);
|
reset_name_field(&w);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -832,7 +868,7 @@ pub fn wire<S, M, P>(
|
|||||||
log::warn!("identity: confirm: {e}");
|
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}");
|
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}"),
|
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();
|
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(),
|
store_tick(),
|
||||||
&model_id(&settings_tick),
|
&model_id(&settings_tick),
|
||||||
eyes_tick(),
|
eyes_tick(),
|
||||||
|
Changed::Identities,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1268,6 +1305,7 @@ pub fn wire<S, M, P>(
|
|||||||
store_tick(),
|
store_tick(),
|
||||||
&model_id(&settings_tick),
|
&model_id(&settings_tick),
|
||||||
eyes_tick(),
|
eyes_tick(),
|
||||||
|
Changed::Library,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1306,7 +1344,7 @@ pub fn wire<S, M, P>(
|
|||||||
a.finish("stopped");
|
a.finish("stopped");
|
||||||
}
|
}
|
||||||
w.set_identity_indexing(false);
|
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.selected.set(None);
|
||||||
ctl.clear_picks();
|
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 || {
|
window.on_identity_toggle_show_ignored(move || {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
ctl.show_ignored.set(!ctl.show_ignored.get());
|
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.selected.set(None);
|
||||||
ctl.clear_picks();
|
ctl.clear_picks();
|
||||||
ctl.covers.borrow_mut().clear();
|
ctl.covers.borrow_mut().clear();
|
||||||
reload!(w, ctl, catalog, store, settings, eyes_available);
|
reload!(w, ctl, catalog, store, settings, eyes_available, Library);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-10
@@ -218,6 +218,43 @@ impl LaunchModel {
|
|||||||
self.session().map(|s| s.root.clone()).unwrap_or_default()
|
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.
|
/// The login URL while approval is pending.
|
||||||
pub fn login_url(&self) -> String {
|
pub fn login_url(&self) -> String {
|
||||||
match &self.state {
|
match &self.state {
|
||||||
@@ -228,11 +265,13 @@ impl LaunchModel {
|
|||||||
|
|
||||||
/// Whether "Open library" should be clickable.
|
/// Whether "Open library" should be clickable.
|
||||||
///
|
///
|
||||||
/// Requires a signed-in account *and* a chosen folder: opening without one
|
/// A cloud account requires a signed-in session *and* a chosen folder:
|
||||||
/// would scan the whole account, which on a real library is thousands of
|
/// opening without one would scan the whole account, which on a real
|
||||||
/// directories the user did not ask for.
|
/// 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 {
|
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.
|
/// What the app should do on startup.
|
||||||
@@ -386,6 +425,9 @@ impl LaunchModel {
|
|||||||
let path = self.browser.as_ref()?.path.clone();
|
let path = self.browser.as_ref()?.path.clone();
|
||||||
let mut session = self.session()?.clone();
|
let mut session = self.session()?.clone();
|
||||||
session.root = path;
|
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.browser = None;
|
||||||
self.state = LaunchState::SignedIn {
|
self.state = LaunchState::SignedIn {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
@@ -547,17 +589,32 @@ mod tests {
|
|||||||
// Everything past sign-in is backend-neutral, and this is the check
|
// 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.
|
// that keeps it so: no branch on the account's connector below here.
|
||||||
let mut m = LaunchModel::default();
|
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"));
|
m.signed_in(folder_with_root("2026"));
|
||||||
|
assert!(m.is_signed_in());
|
||||||
assert!(m.can_open_library());
|
assert!(m.can_open_library());
|
||||||
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
|
assert_eq!(m.startup_action(false), Startup::OpenLibrary);
|
||||||
assert_eq!(m.account_label(), "/mnt/photos/2026");
|
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]
|
#[test]
|
||||||
fn format_toggles_apply_and_out_of_range_is_ignored() {
|
fn format_toggles_apply_and_out_of_range_is_ignored() {
|
||||||
let mut m = LaunchModel::default();
|
let mut m = LaunchModel::default();
|
||||||
@@ -665,13 +722,25 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn confirming_at_the_root_selects_the_whole_account() {
|
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();
|
let mut m = LaunchModel::default();
|
||||||
m.signed_in(session_with_root(""));
|
m.signed_in(session_with_root(""));
|
||||||
|
assert!(!m.can_open_library(), "nothing chosen yet");
|
||||||
|
assert_eq!(m.library_root_label(), "");
|
||||||
m.open_browser();
|
m.open_browser();
|
||||||
|
|
||||||
let session = m.choose_current_folder().expect("a session");
|
let session = m.choose_current_folder().expect("a session");
|
||||||
assert_eq!(session.root, "");
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ pub fn render(window: &AppWindow, controller: &LaunchController) {
|
|||||||
|
|
||||||
window.set_launch_signed_in(m.is_signed_in());
|
window.set_launch_signed_in(m.is_signed_in());
|
||||||
window.set_launch_account(m.account_label().into());
|
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_server(m.server_url.clone().into());
|
||||||
window.set_launch_folder(m.folder_path.clone().into());
|
window.set_launch_folder(m.folder_path.clone().into());
|
||||||
window.set_launch_busy(m.is_busy());
|
window.set_launch_busy(m.is_busy());
|
||||||
|
|||||||
+34
-5
@@ -3230,7 +3230,7 @@ async fn fetch_preview(
|
|||||||
// The same bytes carry EXIF. Reading it here is free — the alternative is
|
// The same bytes carry EXIF. Reading it here is free — the alternative is
|
||||||
// a second 256 KB fetch per image over the whole library.
|
// a second 256 KB fetch per image over the whole library.
|
||||||
if req.needs_metadata {
|
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`
|
// 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,
|
/// Shared by both paths — the thumbnail fetch, which gets the header anyway,
|
||||||
/// and the header-only pass for images whose pixels were already cached.
|
/// and the header-only pass for images whose pixels were already cached.
|
||||||
fn collect_metadata(header: &[u8], req: &ThumbnailRequest, out: &mut Vec<MetadataFound>) {
|
async fn collect_metadata(
|
||||||
let Ok(md) = dr_decode::metadata(header) else {
|
backend: &dyn RemoteBackend,
|
||||||
return;
|
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 {
|
out.push(MetadataFound {
|
||||||
image_id: req.image_id,
|
image_id: req.image_id,
|
||||||
@@ -3442,7 +3471,7 @@ async fn read_metadata_only(
|
|||||||
for attempt in 1..=ATTEMPTS {
|
for attempt in 1..=ATTEMPTS {
|
||||||
match backend.get(&id, Some(0..dr_decode::HEADER_BYTES)).await {
|
match backend.get(&id, Some(0..dr_decode::HEADER_BYTES)).await {
|
||||||
Ok(header) => {
|
Ok(header) => {
|
||||||
collect_metadata(&header, req, found);
|
collect_metadata(backend, &id, &header, req, found).await;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
Err(e) if e.is_transient() && attempt < ATTEMPTS => {
|
Err(e) if e.is_transient() && attempt < ATTEMPTS => {
|
||||||
|
|||||||
+129
-39
@@ -984,14 +984,7 @@ pub fn open(
|
|||||||
window.set_library_status("Starting…".into());
|
window.set_library_status("Starting…".into());
|
||||||
// Always visible: two folders one letter apart are easy to confuse, and a
|
// Always visible: two folders one letter apart are easy to confuse, and a
|
||||||
// scan of the wrong one is indistinguishable from a broken scan.
|
// scan of the wrong one is indistinguishable from a broken scan.
|
||||||
window.set_library_root_label(
|
window.set_library_root_label(library_root_label(&conn.account).into());
|
||||||
if conn.account.root.is_empty() {
|
|
||||||
format!("{} · whole account", conn.account.user_id)
|
|
||||||
} else {
|
|
||||||
format!("{}/{}", conn.account.user_id, conn.account.root)
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// An empty filter would walk the whole tree and match nothing, which looks
|
// An empty filter would walk the whole tree and match nothing, which looks
|
||||||
// exactly like a broken scan. Say so instead.
|
// 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
|
// 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
|
// wall of thumbnails gives no sense of *when* you are looking — the
|
||||||
// sidebar says it, but only if you consult it.
|
// 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 columns = window.get_library_columns().max(1) as usize;
|
||||||
let mut previous_month: Option<(i64, i64)> = None;
|
let headings = period_headings(
|
||||||
let headings: Vec<String> = cells
|
cells.iter().map(|c| {
|
||||||
.iter()
|
c.captured_at.map(|t| {
|
||||||
.enumerate()
|
let (y, m, _, _) = civil_from_unix(t);
|
||||||
.map(|(i, c)| {
|
(y, m)
|
||||||
let Some(t) = c.captured_at else {
|
})
|
||||||
return String::new();
|
}),
|
||||||
};
|
offset,
|
||||||
let (y, m, _, _) = civil_from_unix(t);
|
columns,
|
||||||
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();
|
|
||||||
|
|
||||||
// What the outgoing model is still holding — see [`hold_thumbnails`].
|
// What the outgoing model is still holding — see [`hold_thumbnails`].
|
||||||
let held = {
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn a_scrub_fraction_interpolates_within_the_span() {
|
fn a_scrub_fraction_interpolates_within_the_span() {
|
||||||
// The point of going fractional: a slow drag must advance
|
// The point of going fractional: a slow drag must advance
|
||||||
|
|||||||
+103
-10
@@ -181,7 +181,11 @@ impl FillSettings {
|
|||||||
params: dr_pano::FillParams {
|
params: dr_pano::FillParams {
|
||||||
coarse: self.params.coarse,
|
coarse: self.params.coarse,
|
||||||
band: px(self.params.band).max(8),
|
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 {
|
feather: if self.params.feather == 0 {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
@@ -218,6 +222,17 @@ pub enum MergeEvent {
|
|||||||
Cancelled,
|
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.
|
/// The alignment, described for a panel.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AlignmentReport {
|
pub struct AlignmentReport {
|
||||||
@@ -255,10 +270,29 @@ pub fn run(
|
|||||||
let send = |e: MergeEvent| {
|
let send = |e: MergeEvent| {
|
||||||
let _ = events.send(e);
|
let _ = events.send(e);
|
||||||
};
|
};
|
||||||
match run_inner(&ctx, &request, &events, &decision, &cancel) {
|
// Caught rather than allowed to unwind the thread: wgpu reports a device
|
||||||
Ok(Some(done)) => send(done),
|
// that has run out of memory by panicking, and a twelve-frame merge on a
|
||||||
Ok(None) => send(MergeEvent::Cancelled),
|
// GPU another process is using is exactly where that happens. Uncaught,
|
||||||
Err(e) => send(MergeEvent::Failed(e)),
|
// 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 black = first.raw.black_level[0];
|
||||||
let white_level = u32::from(first.raw.white_level.saturating_sub(black)).max(1);
|
let white_level = u32::from(first.raw.white_level.saturating_sub(black)).max(1);
|
||||||
let profile = dng_profile(first, white_level);
|
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 {
|
let output = MergeOutput {
|
||||||
projection,
|
projection,
|
||||||
@@ -1263,12 +1306,18 @@ fn unused_name(dir: &Path, name: &str) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Drain everything a job has said so far.
|
/// 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();
|
let mut out = Vec::new();
|
||||||
while let Ok(e) = rx.try_recv() {
|
loop {
|
||||||
out.push(e);
|
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
|
/// 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 }));
|
pgm.extend(known.iter().map(|&k| if k { 255u8 } else { 0 }));
|
||||||
std::fs::write(dir.join("known.pgm"), pgm)
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ pub fn wire<S, C, F>(
|
|||||||
"erosion" => f.erosion = v,
|
"erosion" => f.erosion = v,
|
||||||
"coarse" => f.params.coarse = v.max(1),
|
"coarse" => f.params.coarse = v.max(1),
|
||||||
"band" => f.params.band = v.max(8),
|
"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,
|
"feather" => f.params.feather = v,
|
||||||
_ => return,
|
_ => 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.
|
/// 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)>) {
|
fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&AppWindow)>) {
|
||||||
let events = {
|
let (events, gone) = {
|
||||||
let job = ctl.job.borrow();
|
let job = ctl.job.borrow();
|
||||||
let Some(job) = job.as_ref() else { return };
|
let Some(job) = job.as_ref() else { return };
|
||||||
merge::drain(&job.rx)
|
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() {
|
if events.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+186
-27
@@ -82,10 +82,28 @@ pub enum Needs {
|
|||||||
/// image still owes it. Evaluated for the list, for the count, and again
|
/// image still owes it. Evaluated for the list, for the count, and again
|
||||||
/// per image before the handler runs.
|
/// per image before the handler runs.
|
||||||
Sql(String),
|
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.
|
/// Evaluated once, at the start of the job.
|
||||||
Set(SetFn),
|
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.
|
/// A handler: fill one image, given what was fetched for it.
|
||||||
pub type ApplyFn = fn(&mut Toolkit, &Catalog, &Target, &mut Fetched) -> Result<usize, Failure>;
|
pub type ApplyFn = fn(&mut Toolkit, &Catalog, &Target, &mut Fetched) -> Result<usize, Failure>;
|
||||||
|
|
||||||
@@ -290,11 +308,10 @@ pub fn registry(
|
|||||||
.join(", ");
|
.join(", ");
|
||||||
format!("EXISTS (SELECT 1 FROM face_index fi WHERE fi.image_id = i.id AND fi.model_id IN ({list}))")
|
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| {
|
// The per-face fragment `Needs::Face` carries: this embedder's face,
|
||||||
format!(
|
// still owing the pass. Spelled as the partial indexes' WHERE clauses
|
||||||
"EXISTS (SELECT 1 FROM faces f WHERE f.image_id = i.id AND {f_embedder} = '{embedder}' AND ({pred}))"
|
// 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;
|
let can_detect = caps.gpu && caps.face_models;
|
||||||
|
|
||||||
@@ -340,7 +357,7 @@ pub fn registry(
|
|||||||
out.push(Repair {
|
out.push(Repair {
|
||||||
name: "face-quality",
|
name: "face-quality",
|
||||||
label: "images with faces to read for 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,
|
input: Input::NativeRender,
|
||||||
apply: quality,
|
apply: quality,
|
||||||
give_up: None,
|
give_up: None,
|
||||||
@@ -350,7 +367,7 @@ pub fn registry(
|
|||||||
out.push(Repair {
|
out.push(Repair {
|
||||||
name: "face-eyes",
|
name: "face-eyes",
|
||||||
label: "images with faces to read for eye state",
|
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,
|
input: Input::NativeRender,
|
||||||
apply: eyes,
|
apply: eyes,
|
||||||
give_up: None,
|
give_up: None,
|
||||||
@@ -360,7 +377,7 @@ pub fn registry(
|
|||||||
out.push(Repair {
|
out.push(Repair {
|
||||||
name: "face-crop",
|
name: "face-crop",
|
||||||
label: "images with faces without a 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,
|
input: Input::NativeRender,
|
||||||
apply: crop,
|
apply: crop,
|
||||||
give_up: None,
|
give_up: None,
|
||||||
@@ -732,12 +749,19 @@ fn faces_without_proxy(
|
|||||||
WHERE r.file_id IS NOT NULL AND {VISIBLE} AND {} = ?1",
|
WHERE r.file_id IS NOT NULL AND {VISIBLE} AND {} = ?1",
|
||||||
faces::embedder_sql("f.model_id"),
|
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
|
let rows = stmt
|
||||||
.query_map([embedder], |r| {
|
.query_map([embedder], |r| {
|
||||||
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
|
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
|
||||||
})?
|
})?
|
||||||
.filter_map(Result::ok)
|
.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)
|
.map(|(id, _)| id)
|
||||||
.collect();
|
.collect();
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
@@ -753,6 +777,7 @@ fn listed(
|
|||||||
) -> Result<Vec<Target>, dr_catalog::CatalogError> {
|
) -> Result<Vec<Target>, dr_catalog::CatalogError> {
|
||||||
let (predicate, set) = match &repair.needs {
|
let (predicate, set) = match &repair.needs {
|
||||||
Needs::Sql(sql) => (sql.clone(), None),
|
Needs::Sql(sql) => (sql.clone(), None),
|
||||||
|
Needs::Face(fragment) => (any_face(fragment), None),
|
||||||
Needs::Set(f) => ("1".to_string(), Some(f(catalog, store)?)),
|
Needs::Set(f) => ("1".to_string(), Some(f(catalog, store)?)),
|
||||||
};
|
};
|
||||||
let mut stmt = catalog.connection().prepare(&format!(
|
let mut stmt = catalog.connection().prepare(&format!(
|
||||||
@@ -785,24 +810,33 @@ fn still_owed(
|
|||||||
set: Option<&HashSet<i64>>,
|
set: Option<&HashSet<i64>>,
|
||||||
image: ImageId,
|
image: ImageId,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
match (&repair.needs, set) {
|
let sql = match (&repair.needs, set) {
|
||||||
(Needs::Set(_), Some(s)) => s.contains(&(image.0 as i64)),
|
(Needs::Set(_), Some(s)) => return s.contains(&(image.0 as i64)),
|
||||||
(Needs::Set(_), None) => false,
|
(Needs::Set(_), None) => return false,
|
||||||
(Needs::Sql(sql), _) => catalog
|
(Needs::Sql(sql), _) => sql.clone(),
|
||||||
.connection()
|
(Needs::Face(fragment), _) => any_face(fragment),
|
||||||
.query_row(
|
};
|
||||||
&format!(
|
catalog
|
||||||
"SELECT EXISTS (SELECT 1 FROM images i JOIN remote r ON r.image_id = i.id
|
.connection()
|
||||||
WHERE i.id = ?1 AND ({sql}))"
|
.query_row(
|
||||||
),
|
&format!(
|
||||||
[image.0 as i64],
|
"SELECT EXISTS (SELECT 1 FROM images i JOIN remote r ON r.image_id = i.id
|
||||||
|r| r.get::<_, bool>(0),
|
WHERE i.id = ?1 AND ({sql}))"
|
||||||
)
|
),
|
||||||
.unwrap_or(false),
|
[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(
|
pub fn counts(
|
||||||
catalog: &Catalog,
|
catalog: &Catalog,
|
||||||
store: &ThumbStore,
|
store: &ThumbStore,
|
||||||
@@ -810,10 +844,67 @@ pub fn counts(
|
|||||||
) -> Result<Vec<(&'static str, u64)>, dr_catalog::CatalogError> {
|
) -> Result<Vec<(&'static str, u64)>, dr_catalog::CatalogError> {
|
||||||
repairs
|
repairs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| Ok((r.label, listed(catalog, store, r)?.len() as u64)))
|
.map(|r| Ok((r.label, count(catalog, store, r)?)))
|
||||||
.collect()
|
.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
|
/// One image on the work list, with the most any repair claiming it asks
|
||||||
/// for.
|
/// for.
|
||||||
struct Planned {
|
struct Planned {
|
||||||
@@ -842,7 +933,7 @@ fn plan(
|
|||||||
for repair in repairs {
|
for repair in repairs {
|
||||||
let set = match &repair.needs {
|
let set = match &repair.needs {
|
||||||
Needs::Set(f) => Some(f(catalog, store)?),
|
Needs::Set(f) => Some(f(catalog, store)?),
|
||||||
Needs::Sql(_) => None,
|
Needs::Sql(_) | Needs::Face(_) => None,
|
||||||
};
|
};
|
||||||
let listed = listed(catalog, store, repair)?;
|
let listed = listed(catalog, store, repair)?;
|
||||||
if !listed.is_empty() {
|
if !listed.is_empty() {
|
||||||
@@ -1400,6 +1491,74 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(dir);
|
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
|
/// 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
|
/// 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
|
/// repair's -- a full re-detection of the same image would throw away
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ export component AppWindow inherits Window {
|
|||||||
in property <bool> launch-signed-in: false;
|
in property <bool> launch-signed-in: false;
|
||||||
in property <string> launch-account: "";
|
in property <string> launch-account: "";
|
||||||
in property <string> launch-root: "";
|
in property <string> launch-root: "";
|
||||||
|
in property <bool> launch-endpoint-is-library: false;
|
||||||
in property <string> launch-server: "";
|
in property <string> launch-server: "";
|
||||||
in property <string> launch-folder: "";
|
in property <string> launch-folder: "";
|
||||||
in property <bool> launch-busy: false;
|
in property <bool> launch-busy: false;
|
||||||
@@ -1546,6 +1547,7 @@ in property <bool> panel-visible: true;
|
|||||||
signed-in: root.launch-signed-in;
|
signed-in: root.launch-signed-in;
|
||||||
account: root.launch-account;
|
account: root.launch-account;
|
||||||
library-root: root.launch-root;
|
library-root: root.launch-root;
|
||||||
|
endpoint-is-library: root.launch-endpoint-is-library;
|
||||||
server-url: root.launch-server;
|
server-url: root.launch-server;
|
||||||
folder-path: root.launch-folder;
|
folder-path: root.launch-folder;
|
||||||
busy: root.launch-busy;
|
busy: root.launch-busy;
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ export component GestureSheet inherits Rectangle {
|
|||||||
spacing: Theme.gap;
|
spacing: Theme.gap;
|
||||||
|
|
||||||
Text {
|
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;
|
color: Theme.ink;
|
||||||
font-size: Theme.text-lg;
|
font-size: Theme.text-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
in property <bool> signed-in: false;
|
in property <bool> signed-in: false;
|
||||||
in property <string> account: "";
|
in property <string> account: "";
|
||||||
in property <string> library-root: "";
|
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: "";
|
in property <string> server-url: "";
|
||||||
// Two endpoints, shown together. Someone deciding between a server and a
|
// Two endpoints, shown together. Someone deciding between a server and a
|
||||||
// folder should not have to clear one field to try the other.
|
// 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 {
|
if root.signed-in && root.browsing: VerticalLayout {
|
||||||
spacing: Theme.gap;
|
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
|
// Current location, so it is always clear what
|
||||||
// "Use this folder" would select.
|
// "Use this folder" would select.
|
||||||
@@ -428,22 +434,31 @@ export component LaunchScreen inherits Rectangle {
|
|||||||
if root.signed-in && !root.browsing: VerticalLayout {
|
if root.signed-in && !root.browsing: VerticalLayout {
|
||||||
spacing: Theme.gap;
|
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; }
|
Value { text: root.account; }
|
||||||
|
|
||||||
Rectangle { height: Theme.gap-sm; }
|
Rectangle { height: Theme.gap-sm; }
|
||||||
|
|
||||||
PanelHeading { text: @tr("LIBRARY FOLDER"); }
|
PanelHeading {
|
||||||
|
text: root.endpoint-is-library ? @tr("SCAN") : @tr("LIBRARY FOLDER");
|
||||||
|
}
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
spacing: Theme.gap;
|
spacing: Theme.gap;
|
||||||
Value {
|
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 == "";
|
placeholder: root.library-root == "";
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
}
|
}
|
||||||
FormButton {
|
FormButton {
|
||||||
text: @tr("Choose…");
|
text: root.endpoint-is-library ? @tr("Subfolder…") : @tr("Choose…");
|
||||||
width: 110px;
|
width: 110px;
|
||||||
clicked => { root.choose-folder(); }
|
clicked => { root.choose-folder(); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2581,13 +2581,19 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// Now it is a worker, so it needs a sentence: a grid saying
|
// 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
|
// "Scanning…" while nothing is on the network is the same kind of
|
||||||
// lie the two answers below were separated to avoid.
|
// 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 {
|
if root.total == 0: EmptyState {
|
||||||
headline: root.opening
|
headline: root.opening
|
||||||
? "Opening the library…"
|
? "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)
|
detail: (root.opening || root.scanning)
|
||||||
? root.scan-status
|
? 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) -------------------------------
|
// --- 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.
|
// typed straight after — keywording a shoot is a run of them.
|
||||||
new-keyword := Field {
|
new-keyword := Field {
|
||||||
placeholder: "Type a keyword and press return";
|
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) => {
|
accepted(text) => {
|
||||||
root.assign-keyword(text);
|
root.assign-keyword(text);
|
||||||
self.text = "";
|
self.text = "";
|
||||||
|
|||||||
@@ -713,10 +713,17 @@ component MaskEntry inherits Rectangle {
|
|||||||
reset => { root.feather-changed(0.004); }
|
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 {
|
if root.data.shapeable: Segmented {
|
||||||
label: "Falloff";
|
label: "Falloff";
|
||||||
options: ["Hard", "Linear", "Smooth", "Gaussian", "Expo"];
|
options: ["Hard", "Linear", "Smooth", "Gaussian", "Expo"];
|
||||||
selected: root.data.falloff;
|
selected: root.data.falloff;
|
||||||
|
columns: 3;
|
||||||
picked(i) => { root.falloff-picked(i); }
|
picked(i) => { root.falloff-picked(i); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,6 +733,7 @@ component MaskEntry inherits Rectangle {
|
|||||||
+ "pinholes; open removes specks.";
|
+ "pinholes; open removes specks.";
|
||||||
options: ["None", "Grow", "Shrink", "Close", "Open"];
|
options: ["None", "Grow", "Shrink", "Close", "Open"];
|
||||||
selected: root.data.morphology;
|
selected: root.data.morphology;
|
||||||
|
columns: 3;
|
||||||
picked(i) => { root.morphology-picked(i); }
|
picked(i) => { root.morphology-picked(i); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -63,9 +63,9 @@ export component MergePage inherits Rectangle {
|
|||||||
/// seam. Working pixels, at the merge's working scale.
|
/// seam. Working pixels, at the merge's working scale.
|
||||||
in property <float> fill-scale: 2;
|
in property <float> fill-scale: 2;
|
||||||
in property <float> fill-erosion: 4;
|
in property <float> fill-erosion: 4;
|
||||||
in property <float> fill-coarse: 4;
|
in property <float> fill-coarse: 1;
|
||||||
in property <float> fill-band: 96;
|
in property <float> fill-band: 192;
|
||||||
in property <float> fill-mirror: 48;
|
in property <float> fill-mirror: 0;
|
||||||
in property <float> fill-feather: 48;
|
in property <float> fill-feather: 48;
|
||||||
|
|
||||||
/// The composite, once written: its name for the status line, and
|
/// The composite, once written: its name for the status line, and
|
||||||
@@ -240,26 +240,26 @@ export component MergePage inherits Rectangle {
|
|||||||
SliderRow {
|
SliderRow {
|
||||||
label: "Coarse pass";
|
label: "Coarse pass";
|
||||||
hint: "reduction of the structure pass; 1 skips it";
|
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;
|
enabled: !root.running && !root.done;
|
||||||
changed(v) => { root.fill-knob("coarse", v); }
|
changed(v) => { root.fill-knob("coarse", v); }
|
||||||
reset => { root.fill-knob("coarse", 4); }
|
reset => { root.fill-knob("coarse", 1); }
|
||||||
}
|
}
|
||||||
SliderRow {
|
SliderRow {
|
||||||
label: "Band width";
|
label: "Band width";
|
||||||
hint: "the fine passes' bands from the edge outward, working pixels";
|
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;
|
enabled: !root.running && !root.done;
|
||||||
changed(v) => { root.fill-knob("band", v); }
|
changed(v) => { root.fill-knob("band", v); }
|
||||||
reset => { root.fill-knob("band", 96); }
|
reset => { root.fill-knob("band", 192); }
|
||||||
}
|
}
|
||||||
SliderRow {
|
SliderRow {
|
||||||
label: "Mirror depth";
|
label: "Mirror depth";
|
||||||
hint: "how far into the picture the mirrored context reaches";
|
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: 48; minimum: 4; maximum: 512;
|
value: root.fill-mirror; default-value: 0; minimum: 0; maximum: 512;
|
||||||
enabled: !root.running && !root.done;
|
enabled: !root.running && !root.done;
|
||||||
changed(v) => { root.fill-knob("mirror", v); }
|
changed(v) => { root.fill-knob("mirror", v); }
|
||||||
reset => { root.fill-knob("mirror", 48); }
|
reset => { root.fill-knob("mirror", 0); }
|
||||||
}
|
}
|
||||||
SliderRow {
|
SliderRow {
|
||||||
label: "Seam feather";
|
label: "Seam feather";
|
||||||
|
|||||||
@@ -1161,7 +1161,10 @@ export component SettingsPage inherits Rectangle {
|
|||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
label: "Destination";
|
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;
|
text <=> root.destination;
|
||||||
field-width: 320px;
|
field-width: 320px;
|
||||||
placeholder: root.destination-hint;
|
placeholder: root.destination-hint;
|
||||||
|
|||||||
Reference in New Issue
Block a user