Compare commits

...
39 Commits
Author SHA1 Message Date
dtourolle e22128c16b Release 0.18.2
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m34s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m0s
Build and test / Android (aarch64) (push) Successful in 30m28s
Build and test / android-image (push) Successful in 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 47m53s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 34s
Build and test / Windows (x86_64, cross) (push) Successful in 36m29s
Build and test / Publish the release (push) Successful in 1m10s
2026-09-27 08:23:04 -04:00
dtourolle 81ea9359bc Re-record the manual for 0.18.2
Every scene, recorded from this commit's desktop build. Since the last
recording (0.17.0) contrast flattens toward grey, a mask layer's
settings apply as offsets, and the film's settings are per-pixel, which
touch the develop, film, local and preset pictures; `--changed` could
not be trusted after the rebases, so nothing was left out.

Checked frame by frame: the film list still reaches Ilford HP5 Plus by
wheel, drag, scrollbar and keys (film_reach), and each picture shows
what its caption says. Settings shows version 0.18.1, the build's own
until the release commit bumps it.
2026-09-27 08:18:20 -04:00
dtourolle be37218696 Picture film inside a mask layer
The manual described film in a mask layer (6b99f67) with no picture of
it, and the paragraph named the control `Print exposure` where the
panel says `Print Exposure`.

A new scene, film_local, sets Kodak Ektar 100 on an upright alpine
frame — a negative, so it is printed and has a print exposure — then
adds a linear gradient, turns it by its rotate handle to fall from the
top, moves it onto the sky, shows the mask as its edge so the print
stays visible, and raises the layer's Print Exposure to burn the sky
in. Done Masking, then Before held. It puts the masks back to Tint
before undoing, as the other scenes expect.

The picture follows the film list's, under the paragraph it
illustrates. The bundled manual is regenerated to match.
2026-09-27 08:18:15 -04:00
dtourolle 5bff453d37 Add film in a mask and the hot-pixel repair to the README
The developing paragraph listed masks and film separately and said
nothing of photosites. It now says that a mask's sliders add to the
photograph's, the film's among them, so a sky can be burned in on the
print (0.18.1 and 6b99f67), and that hot and dead photosites are mended
before the demosaic with nothing to set (c50d96e).
2026-09-27 08:00:27 -04:00
dtourolle 61cd226719 Say that the runner's disk is the tighter budget
windows.md §7 weighed the Windows leg against the desktop leg in CPU
time only. What actually stopped a release was disk: the one 99 GB disk
is shared with the container images, v0.18.0's desktop run ended at
92 GB used, and v0.18.1's release build ran out, which is why that tag
has no release page. §7 now gives those figures, says what the desktop
leg deletes before its release build since 209ae361 and why that costs
the cache nothing, and names a second target's cache as the first thing
to check if a leg runs out again.
2026-09-27 08:00:27 -04:00
dtourolle 87d758bd81 Sweep outstanding.md for 0.18.2
Adds the release's line to the sweep notes at the head: FR-CULL-13's
write-path half and NFR-ARCH-1's executors, which now have entries, and
three things that closed without ever having had one — a mask layer's
film settings (FR-DEV-3f), hot and dead photosites repaired before the
demosaic (FR-RAW-3, with the defect-map reader still unwired), and the
tablet's scroll cue already described in §4a.
2026-09-27 08:00:19 -04:00
dtourolle 427a572aad Record where the named executors stand
NFR-ARCH-1's register note says what b1d1c472 and 7be1efff met and what
they left, but outstanding.md, which exists to show the distance between
the register and the binary, had no entry, and catalog.md §6 still said
interactive work runs "on the decode pool with the I/O pool behind it"
when there are no pools.

outstanding.md §4 gains the entry: named and guarded, not bounded — the
counts are a budget, the guard covers block_on only, the two mask
workers and the core crates' threads are outside the module. catalog.md
names the executors the thumbnail and metadata work starts on and says
the counts are not yet enforced.
2026-09-27 08:00:19 -04:00
dtourolle 01197ca37d Record FR-CULL-13's write-path half in outstanding.md
§2 still said FR-CULL-1 through -5 and -8 through -13 were built, while
the register's own status note (adbb9ac9) says only the write-path half
of -13 is met. The count now says -12, with -13 half built, and §2 gains
an entry for it: what verdicts.rs enumerates and holds to a reviewed
list, what fails the test, and the evidence that is not built — chips
for clipping, focus, burst membership and face counts, shown as absent
rather than zero, and a filter per signal.
2026-09-27 08:00:09 -04:00
dtourolle f9154ad12e Name the verdict check among the invariants the build defends
2cde2874 made `cargo test -p traceability` fail on any write of a
rating, flag, colour label or trash membership that is not on a
reviewed list, and CONTRIBUTING.md, which lists what CI will stop a
change for, did not mention it. It is now the third invariant beside
the ui-names-no-operation test and the operation schema: what it finds,
the kinds of reason a write can be listed with, that the workspace test
run is what runs it, and `traceability -- verdicts` to print the list.
2026-09-27 07:59:38 -04:00
dtourolle 1fdfb5990c Describe the film's tables as 0.18.2 bakes them
dr-film's README still described one 32³ lookup that took a negative
through the print and the paper, with the sliders' values baked into
it. Since 6b99f67 nothing a slider moves is baked: the curves are one
row per development time the datasheet measures and push interpolates
between them, a print is two lookups split at the paper's log exposure
with the enlarger's exposure added between them, and exposure, push,
print exposure and format reach the shader as uniforms. That is what
lets a mask layer hold film settings of its own.

The section now says so, and that the film's Exposure on the whole
photograph is the one setting that rebakes, because the enlarger's
filtration is solved against it.
2026-09-27 07:59:38 -04:00
dtourolle 7558053932 Say that a mask layer's settings add to the photograph's
fc54523 (0.18.1) stopped running a layer as a second chain after every
global operation and made its settings offsets applied at each
operation's own place, and nothing outside the code said so. The
manual still read as though a mask's slider were a setting of its own,
frame-budget.md described the masks as "a separate chain per layer",
and no document said how a layer is blended at all.

architecture.md §5.2 now says it: the offset, the blend by the layer's
weighted difference from the global result, what a photograph with no
layers composes to, and the film as the one operation whose settings
are averaged instead (6b99f67). FR-DEV-3 records the change as resolved
beside its local-adjustments bullet, frame-budget.md's note on what it
does not measure describes the cost as it now is, and the manual gives
the arithmetic in one sentence: -20 in a mask over -30 is -50 there.
The bundled manual is regenerated to match.
2026-09-27 07:59:29 -04:00
dtourolle 6bccc2db13 Say where hot and dead photosites are repaired
c50d96e added a repair pass ahead of the demosaic and no document said
so: FR-RAW-3's text names levels, the CFA and the colour matrices, and
architecture.md §5.2's stage diagram went from the upload straight to
black and white levels.

FR-RAW-3 now carries a status note: what counts as hot or dead, what
the photosite becomes, that Bayer and X-Trans share the pass, that
export and every other demosaicing path get it with no setting, the
test that holds it, and that the DNG defect map dr-decode can read is
still not used. §5.2 gains the stage and a paragraph on why it sits
before the demosaic, and its pointer to the raw histogram's tap names
the demosaic box rather than a row count the new stage would have made
wrong.
2026-09-27 07:59:00 -04:00
dtourolle 209ae36163 Free the desktop job's test binaries before its release build
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m18s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Successful in 47m36s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Layer separation (push) Successful in 32s
Traceability / Requirement traces (push) Successful in 1m37s
Build and test / Android (aarch64) (push) Successful in 30m39s
Build and test / Windows (x86_64, cross) (push) Successful in 35m42s
Build and test / Publish the release (push) Skipped
The single CI runner has one 99 GB disk shared with its container images.
The desktop job reached 92 GB used (2.1 GB free) on v0.18.0's run, and on
v0.18.1's the release build died with "No space left on device", so that
tag has no release page. At rest the runner holds about 23 GB; the
restored target cache, the models and the dependency build bring a job to
about 78 GB before a test runs, and tests plus the release build take the
rest.

The test executables and examples in target/debug are the largest part
of that, are relinked whenever a source changes, and are not what the
cache exists for — the dependency rlibs are. Deleting them after the Test
step, before the release build, frees several GB at the moment the job
is fullest without costing the next run anything the cache would have
saved. The step prints the disk afterwards, beside the existing Disk
before/after lines.
2026-09-27 07:25:49 -04:00
dtourolle 8071f7101a Say that a tablet's scrollers show a position cue
The manual said the lists on a tablet scroll by flick alone, and
outstanding.md §4a that Android had no scrollbars by design. Both now
describe the cue from #69: a thin line while the view moves, gone once
it stops, that a flick starting on it passes through; and §4a notes
DR_SCROLL_CUE for looking at it on a desktop, and that it is not yet
checked on the tablet. The bundled manual is regenerated to match.
2026-09-27 07:22:09 -04:00
dtourolle 502023c0f4 Show a thin scroll cue on Android instead of no scrollbar at all
On Android the desktop scrollbar is off, so every scroller that has one
on a desktop (the develop column, the grid, the collections sidebar,
Settings, the help sheet and the film list) gave no sign of how long it
was or where the view was in it. That is how the black-and-white film
stocks came to look deleted when the list could not scroll.

ScrollBar now has a second mode, chosen in the one Scrolling global:
where bars are off and `cue` is on, it draws the thumb alone, 3 px wide
against the edge, while the viewport moves, and fades it 500 ms after
the last move. It has no TouchArea, so a flick that starts on it
scrolls the content. Rust sets cue on touch-first builds; a desktop
build shows it when DR_SCROLL_CUE is set, to look at it without a
device. Desktop is otherwise unchanged.

Test: on the testing backend's help sheet, a press on the cue moves
nothing, a drag starting on it carries the list with the finger, the
cue is drawn while the list moves (drag, fling, wheel) and not once it
is idle; with bars on there is no cue and the thumb still takes a drag.
2026-09-27 07:22:09 -04:00
dtourolle adbb9ac9e6 Tag the judgement dispatch R7 and record which half of FR-CULL-13 is met
apply_judgement and apply_label carry TRACES: R7, the burst
representative callback FR-CULL-13 with a note that choosing it writes
the grouping and no verdict. The register's status for FR-CULL-13 says
the write-path test is met and the evidence chips are outstanding.
Traceability matrix regenerated.
2026-09-27 07:20:41 -04:00
dtourolle 2cde287440 Hold every verdict write to a reviewed list of user actions
FR-CULL-13 says evidence never writes a rating, flag, label or trash
membership, and nothing enforced it. tools/traceability/src/verdicts.rs
parses the shipped code with syn and enumerates every write: calls to
the catalog setters and trash recorders, SQL that assigns those columns,
sidecar Amendment::Judgement, and fields named rating/flag/label. Each
site must be in ALLOWED with a reason, as Input (inside a Slint on_*
closure, checked structurally), Relay (its callers are checked in
turn), Carried (a verdict made elsewhere: sidecar and XMP pulls, sync
merge, catalog mirrored to file, duplicates consolidation) or
NotAVerdict. Unlisted sites and stale entries both fail
`cargo test -p traceability`; `traces verdicts` prints the list.

syn and proc-macro2 were already in the lockfile as proc-macro
dependencies; this adds the edges, no new crate and no version change.
2026-09-27 07:20:38 -04:00
dtourolle 6e68f1fb3d Move a resaved test file's mtime ahead, so the scan cannot miss it
`a_resaved_file_owes_a_reread_and_loses_its_stale_hash` failed once in a
full dr-catalog run (updated 0, expected 1) and passed three times alone.
The incremental scan tells a changed file by its mtime at whole-second
resolution, and the `resave` helper wrote and renamed the file within
the same second as the scan before it, so on a fast enough pass the
resave looked like no change at all.

The helper now sets the file's and its folder's mtime two seconds ahead,
which is what a real resave some time after a scan looks like. Only the
test helper changes; the scan's rule is right for real files.
2026-09-27 07:19:04 -04:00
dtourolle 8df3dda9aa Regenerate the traceability matrix for the executors module 2026-09-27 07:08:40 -04:00
dtourolle 3912bc0d1d Point architecture §7.1 at the executors module and record NFR-ARCH-1
§7.1 now says where its table lives in code, how the UI thread is
marked and guarded, and that the counts are a budget rather than a
bound until work is pooled by executor. The register's status note says
what is met — named, spawned through one module, block_on guarded and
tested — and what is not: pooling, a guard for synchronous file reads
and catalog queries, the two mask workers, and threads the core crates
start.
2026-09-27 07:08:37 -04:00
dtourolle b1d1c47261 Start every worker thread through the executors module
Thirty-nine spawn sites in dr-ui, and one in the Android entry point,
called std::thread::spawn or a Builder of their own, and most of the
threads they started were <unnamed> in a panic message or a profiler.
Each now calls executors::spawn with its executor and a role, so the thread is
named <executor>:<role> — net:sync, decode:thumbs, io:catalog-open —
and knows which executor it is on. The three that already set a name
(automation, import, prefetch) keep their name as the role.

Behaviour is unchanged: each job still gets a thread of its own when it
starts, and spawn panics where std::thread::spawn did.

The module's documentation now says how a job is assigned: by what it
spends its time on, so a sweep that fetches bytes and then decodes them
is Decode, and a sidecar write that touches the catalog is Network.

Left as they were: the segmentation and refine workers in masks_ui.rs,
which another change is reworking, and test-only threads.
2026-09-27 07:08:37 -04:00
dtourolle 7be1efff32 Name the executors and fail a block_on on the UI thread
architecture.md §7.1 stated five executors and their thread counts, and
no code named them. dr_ui::executors now does: the Executor enum with
each one's thread name and the count §7.1 gives with its reason, and
spawn, which starts a thread named <executor>:<role> and marks it with
the executor it belongs to. The counts are the stated budget, not yet a
bound: a job still gets a thread of its own when it starts.

run marks its own thread as the UI executor before it builds the
window. net_runtime::build now returns a NetRuntime whose block_on
asserts, in debug and test builds, that the caller is not that thread;
everything else derefs to the tokio runtime. The login, folder-list and
remote-folder workers built the same runtime by hand and now take it
from net_runtime, so their block_on is guarded too.

Tests: a block_on on a thread marked as the UI executor panics naming
the UI thread; the same call on a worker returns; a spawned thread
carries its name and executor.
2026-09-27 07:08:37 -04:00
dtourolle c50d96e949 Repair hot and dead photosites before the demosaic
A hot photosite went into the demosaic as it was read, and came out as a
coloured cross three pixels wide that nothing later could take back
out. Night and long exposures showed them; the defect-map reader added
for FR-RAW-3 was never wired in, and a CR2 carries no map anyway.

A pass over the mosaic now runs ahead of the demosaic, into a second
buffer. A photosite is hot when it reads more than twice every
same-colour photosite in its 5x5 window plus 2% of the range, and more
than twice each of its eight immediate neighbours of any colour. The
second half keeps stars and glints: real light reaches the sensor
through a lens and an anti-aliasing filter and lights a patch, so the
photosites beside it are lit too, where a hot photosite's are dark. It
is replaced by its brightest same-colour neighbour, which invents
nothing. Dead photosites are the mirror case, judged only where the
neighbourhood is above 5%, so shadow noise clipped at black is left
alone.

The colour of each photosite comes from a 6x6 sensor-anchored tile, so
Bayer and X-Trans share the pass. Export and every other path that
demosaics get it too, and there is no setting: the repair only fires
where a single photosite disagrees with everything around it.

Cost, warm, on a Canon 6D frame (RTX 3050): 91-99 ms to demosaic
before, 94-98 ms after; the extra pass is inside the run-to-run noise.

Tests render a frame with and without the defect and compare the
finished pixels. Without the repair a hot photosite showed by 230 and a
dead one by 168; with it neither shows, and a 3x3 highlight at white
survives.
2026-09-27 06:20:23 -04:00
dtourolle 6b99f67f47 Develop a mask layer's film on its own settings
A layer offered the film's sliders and they moved nothing: its copy of
the node was never given the stock, so it stayed inactive. Film now
works in a layer the way the other adjustments do, as offsets to the
photograph's settings, but blended as settings rather than as results,
since a film is a rendering and cross-fading two developments is not
what a region on a pushed film looks like.

- dr-film bakes no slider. Exposure is a gain in the shader; push
  interpolates the stock's measured processes, one curve row each; the
  print is split at the paper's log exposure, so print exposure is an
  addition between two lookups and exact at any setting. The enlarger
  stays balanced at the photograph's exposure.
- film_sim reads all four settings as uniforms, format one-hot over a
  grain count per format, so every uniform is linear in what it does.
- Operation::blends_settings lets the composer average each overlapping
  layer's uniforms with the global ones by mask weight, the global
  setting taking whatever weight the layers leave, and run the fragment
  once. Three layers at full weight give the mean of their settings.
- The stock picker is hidden on a layer. Only the photograph's exposure
  re-solves the print balance; push, print exposure and format need no
  rebake at all now.
2026-09-26 23:29:13 -04:00
dtourolle 48c5e74fa8 Release 0.18.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m46s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 49s
Build and test / Android (aarch64) (push) Successful in 17m27s
Build and test / android-image (push) Successful in 3s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 25m32s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 29s
Build and test / Windows (x86_64, cross) (push) Successful in 37m19s
Build and test / Publish the release (push) Skipped
2026-09-26 21:29:26 -04:00
dtourolle fc54523093 Apply a mask layer's settings as offsets to the global ones
A local adjustment ran as a second chain after every global operation,
then blended by the mask. So global contrast -30 with -20 on a face was
contrast -30, the rest of the chain, and contrast -20 again on the
result, rather than -50 where contrast runs. The two edits compounded in
ways neither slider showed; a flattening applied to an already
flattened picture is how the shadows of a night shot went magenta.

A layer's setting is now an offset from its default, added to the
global setting (clamped to the parameter's range; a moved switch or
choice replaces it) and run at that operation's own place in the chain.
At each operation the global fragment and each touching layer's
combined fragment read the same input colour, and the pixel moves by
each layer's weighted difference: c_g + sum w_i (c_i - c_g). At full
weight that is the combined setting exactly, at zero the global result
exactly, and no setting is applied twice. An offset that brings an
operation back to neutral emits an empty version, which undoes the
global setting inside the mask.

Blending the colours rather than the uniforms is deliberate: the tone
curve and colour mixer emit code only for the channels and bands that
are touched, so the global and combined versions of one operation need
not share a uniform set.

A photograph with no masks compiles to the same shader byte for byte.

Test: global -30 with a whole-frame layer at -20 renders within one
count of global -50.
2026-09-26 20:43:02 -04:00
dtourolle 8392cf772e Flatten contrast toward grey instead of scaling shadows by a ratio
Reducing contrast turned every black in a night photograph pink. The
fragment lifted each pixel's luminance to its target by multiplying the
colour by target/luma. For a pixel at 0.001 on the way to 0.09 that is
a gain of ninety, and in the deepest shadows the channels are sensor
noise: after white balance the red and blue noise sits above the green,
their multipliers being nearly twice its, so ninety times that noise is
magenta.

Flattening now mixes the colour toward middle grey, which gives the
same luminance and adds the lift as a neutral. A black goes to grey and
its noise stays the size it was.

The same fragment clamped luma/0.36 into the curve's 0..1 domain, which
scaled every tone above twice middle grey down to 0.36 in either
direction: contrast +10 took a 230 grey to 162. Those tones are now left
where they are, which is continuous with the curve's top (value 1,
slope 0).
2026-09-26 20:43:02 -04:00
dtourolle 515d4eb59e Release 0.18.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m56s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 45s
Build and test / Android (aarch64) (push) Successful in 17m8s
Build and test / android-image (push) Successful in 2s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 48m1s
Build and test / windows-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / Layer separation (push) Successful in 36s
Build and test / Windows (x86_64, cross) (push) Successful in 21m35s
Build and test / Publish the release (push) Successful in 1m12s
2026-09-26 19:13:06 -04:00
dtourolle b2f3936a53 Say how synced faces and same-name people merge since #77 and #78
catalog.md §8.2 still said confirmed and rejected faces were matched to
local faces "by box", and that the merge reads only a remote face's box
and model; faces.md said `match_faces` "still matches by overlap alone
across devices". Since #77 the match falls back to embeddings, on the
photographs where a box leaves a remote face over, and since #78
`dedup_people` folds people of one name whose faces agree after every
sync. Both now say so, with the thresholds and the reason a less
decisive pair stays unmatched, taken from the code's own documentation.
2026-09-26 18:30:07 -04:00
dtourolle ec7a8c07ee Add a dedup_people example to measure the job on a catalog copy
`dedup_people COPY.sqlite` prints the listed people and faces before
and after, what the first run merged and kept apart and why, and the
time of three runs. The second and third runs are the cost the job
adds to every sync. `--peer PEER_COPY.sqlite` then plays two sync
round trips. The peer merges with the previous release's code path and
no job, this side merges back through sync::merge_remote, and the named
people each side lists are compared after every step.

On the reference pair: the desktop merges Claudine, Jessie x2, Mathias
and Noemi (80 -> 75 named). The tablet also merges its empty second
Ian (80 -> 74). The first run takes 1.2 s on the desktop, which is
building faces_box; the merge builds it first in practice. Later runs
take 8-15 ms.
2026-09-26 17:50:39 -04:00
dtourolle 78df4211b0 Run the people deduplication after every catalog merge (#78)
A merge is where two devices' people meet: the same name typed on each,
or a redirect one of them made. So dedup_people::run now follows every
successful sync::merge_remote. It runs on the sync worker, never the
UI thread, before the snapshot is pushed, so what it folds reaches the
server on the same pass.

It runs in its own transaction, and a failure is logged, not
returned. What the merge took is committed and valid either way, and
the next pass tries again. Once a catalog is clean it costs 8-15 ms on
the reference library (19k faces, 26k people rows). On copies of the
two real catalogs, a round trip with a peer running the previous merge
converges on 75 listed named people on both sides and stays there
over a second round. merge_remote with the job takes 75-90 ms there.
2026-09-26 17:50:38 -04:00
dtourolle c78b798cf0 Fold people of one name whose faces agree, and faces held twice (#78)
Seven names are two or three live people on both devices: Ian (756
confirmed faces, and a second Ian with none), Jessie three times,
Claudine, Mathias, Noemi, Pascal and PJ. Each was typed on its own
device and carried across by sync, which keys people on their uuid and
so keeps both. Each half of a person shows half their photographs.

dedup_people::run, in one transaction:

- Same-name people (trimmed, case-folded as the Identity screen folds
  them) merge into the one with the most confirmed faces, ties to the
  smaller uuid, through faces::merge_people_within, so confirmations,
  rejections and the survivor's name are kept. A person holding no
  faces at all merges: there is nothing to compare or to carry. Anyone
  else needs >= 2 confirmed faces per shared embedder on both sides and
  centroids at cosine >= 0.7 in each. A face confirmed as one and
  rejected as the other keeps them apart. Unnamed and set-aside people
  are never merged by name.
- Faces held twice (one image, one embedder, IoU >= 0.5, cosine >= 0.7)
  keep the stronger detector's row (FaceDetector::outranks), then the
  confirmed one, then the older. The survivor takes the confirmed
  assignment and both rows' rejections. A pair confirmed as two
  different people is left and counted.
- Judgements still on a merged-away person move to the person at the
  end of its redirects, and a redirect cycle (two devices merging one
  pair in opposite directions) is broken at the smaller uuid.

Measured on copies of the desktop catalog and the tablet's server
snapshot, w600k_mbf, confirmed faces only:
- Centroids of differently named people: 2,699 pairs, median 0.02,
  99.9th percentile 0.41. One pair reaches 0.70 (0.700 desktop, 0.705
  tablet), "Michelle Casanonve" and "Michelle Casanova", one person
  typed two ways. Next is 0.62/0.64, "Boris Jost" and "Boris". The
  highest pair that is plainly two people is 0.43/0.44.
- One person split in random halves: minimum 0.69, median 0.91 over 72
  people. Four faces against twenty-two reach 0.7 in 97% of draws.
  One face against twenty of somebody else's reached 0.74 in 3,000
  draws, and two faces reached 0.61, hence the two-face minimum.
- Pascal (22 and 4 confirmed) is at 0.57 and PJ (14 and 7) at 0.50,
  under 0.7 on both devices, so both pairs stay apart and are logged.
  The desktop's second Ian holds 4 suggestions and no confirmations,
  at 0.38 against Ian's centroid, and stays apart. On the tablet it
  holds nothing and merges.

Why a merge made here survives a peer on 0.17.0: the merged-away
person stays as a merged_into redirect with a bumped revision, which
the catalog merge has always taken on revision. The peer hides the
duplicate and never sends it back as a live person. Its own
confirmations of that person stay on the redirect, because a merge
never overwrites a local confirmation. The manual merge has always
left them there too. They follow the redirect when the peer runs this
job. A test syncs two catalog files through the previous merge code
and back, and the people converge and stay converged.

Once a catalog is clean the job reads 80 redirects, the named people,
and the face boxes from the covering faces_box index. That is ~10 ms
on the reference library. There is no schema change. The index is
created IF NOT EXISTS, as the merge already does.
2026-09-26 17:50:28 -04:00
dtourolle 6450f54199 Carry rejections when two people are merged
merge_people moved a person's faces onto the target and left the
"not this person" rejections on the redirect. A rejection there binds
nothing: once Annie is Anna, the grouping pass is free to suggest the
face the user pushed away from Annie as Anna, which is the behaviour
rejections exist to prevent. The Identity screen's merge has done this
since it was written, and the deduplication job for #78 merges through
the same function, so it would have done it for every same-name pair.

The source's rejections now move to the target (INSERT OR IGNORE, so
one the target already holds is not doubled). Where the two halves
disagree about one face, confirmed as one and rejected as the other,
the confirmation stands, as `confirm` already rules for one face; and a
moved rejection withdraws a suggestion of the same face, as `reject`
already does. Confirmations and names are unchanged.

The body is split into merge_people_within, taking the caller's
transaction, so a job that merges several pairs commits once
(unchecked_transaction cannot nest). merge_people keeps its signature
and its one transaction.
2026-09-26 17:41:55 -04:00
dtourolle 1479e45637 Keep one person to one face per photograph in the catalog merge
The grouping pass never puts two faces of one photograph in the same
group (the cannot-link in dr_face::cluster). The merge did not check
this. When the two devices disagree about which face in a frame is a
person, merge_people_within applied the remote's confirmation, or the
anchor of a set-aside group, to face X. This device already held the
same person on face Y of the same photograph, so the person ended up on
both faces.

The reference library has 80 such person/photograph pairs on the
desktop and 89 on the tablet: 79/88 unnamed set-aside groups and one
named person confirmed on two faces. There are no duplicate faces (no
pair of faces in one image and embedder with IoU >= 0.5).

An incoming assignment is now refused when another local face of the
same photograph already holds that person. The one exception is an
incoming confirmation against a local suggestion: the suggestion is
withdrawn and the confirmation is applied. Two confirmations stay as
this device has them, the same rule as a local confirmation outranking
a remote one. A face that already holds the person is not a rival to
itself, so a steady-state pass is unaffected. On the reference pair
this refuses 0 assignments and writes the same 8,954 as before; it only
changes what a future disagreement does. The refusals are counted in
MergeReport::faces_one_per_photograph.

Existing pairs are left alone. They are two different faces (cosine
0.31 for the named one), not one face twice, so there is nothing to
fuse, and which face is the wrong one is not the merge's to guess.
2026-09-26 16:54:05 -04:00
dtourolle b5b30e3750 Match synced faces by embedding where the boxes cannot (#77)
On the reference library, 631 of the faces in the tablet's snapshot
match no desktop face by box (IoU >= 0.5), so a name on them stays on
one device. Twenty of those are the same face with the box drawn
somewhere else. Whole photographs sit at IoU 0-0.48 with cosines of
0.72-0.96 between the two devices' vectors, and ten of them already
carry the same person on both sides. The merge never read the
1 KB embedding every face row carries.

match_faces now runs two passes. The box pass is unchanged except that
a pair must now be unique on both sides: a remote face with two
overlapping local faces, or a local face overlapped by two remote ones,
is no longer settled by whichever overlap is larger. Only for the
photographs where a remote face is left over, and a local face is still
free, does it read vectors: one json_each statement per side, keyed by
row id. That was 357 photographs on the reference library, not all
19 MB of vectors. A left-over face pairs with the local face it
resembles most when:
- the cosine is >= 0.7,
- the two are each other's best,
- each leads its runner-up by >= 0.2, and
- a box has not already claimed the local face.
Anything less decisive stays unmatched, so a new face stays new.

Why the threshold is safe, measured on both catalogs (w600k_mbf):
- Of 169,548 pairs of different faces in one photograph, 4 reach 0.7
  (lookalikes in one frame) and the maximum is 0.82.
- At 0.6 the rule would claim two pairs that carry different people on
  the two devices. At 0.7 it claims 20, none contradicted and 10
  corroborated, each leading its runner-up by more than 0.5.
- It only compares faces the boxes left unmatched on both sides: 737
  such pairs, so about 0.02 false pairs expected.
- A low cosine never overrules a box. About 150 box-matched pairs fall
  below 0.45, because two detectors cut the same tiny face differently.
  73 of them carry the same person on both devices.
- Faces are compared only within one file_id and one embedder, because
  the same person in another photograph reaches cosine 1.0.
- `Embedding::cosine` refuses a comparison across models.

Before -> after on the reference pair: matched by box 18,348 -> 18,348,
by embedding 0 -> 20, unmatched 631 -> 611, ambiguous 0 -> 0. The
report counts the embedding matches. No schema change.
2026-09-26 16:54:05 -04:00
dtourolle 884b681c21 Time a merge with another device's catalog in catalog_bench
The bench merged the catalog with a copy of itself. Every face in that
merge matches its own box, so the pass never reaches the faces the two
devices disagree about. On the reference library that is 631 of the
tablet's 19,052 faces, and it is the work #77 adds to.

`--remote PEER.sqlite` now also times `merge_remote_catalog` against a
copy of the peer file, and prints the first pass's report so two builds
can be checked for agreement. The first run writes what the peer
brought. The runs after it are the steady state, so compare builds from
two fresh copies of one catalog.
2026-09-26 16:54:05 -04:00
dtourolle 23abfd1827 Ship four presets for a bluer sky
Blue sky, Deep blue sky, Polariser, and Blue sky with golden land, in a
Skies section after Essentials. Each darkens the colour mixer's azure and
blue bands and adds chroma to them — what a polarising filter does to a
clear sky — and brings the highlights down with it, so a white cloud does
not read as a cut-out against the deeper blue. The stronger ones nudge
azure towards blue and add dehaze.

They are looks and work only on the hues a sky occupies, so an overcast
frame is left nearly alone: there is no blue for them to deepen, and
tinting grey cloud blue would be worse than doing nothing. Tuned by eye on
the demo library's alpine and Manhattan frames, with an overcast Étretat
frame as the control.
2026-09-26 16:46:46 -04:00
dtourolle 94ea2569ee Tag the SAF export path FR-EXP-10 only, not FR-PLAT-AND-1
saf.rs and the export path's SAF branch shipped tagged FR-PLAT-AND-1,
and the matrix counted the requirement as covered. Its subject is the
library — reached through SAF grants — and Android still reaches a
library over a Nextcloud account or a folder path. What the SAF code
does is give an album a folder on the tablet, which is FR-EXP-10.

outstanding.md said the figure overstated it and should be read with
this one subtracted; it now says the tags were narrowed, and coverage
reads 161 of 192.
2026-09-26 16:19:55 -04:00
dtourolle 7a56d16df1 Count the whole library in "All photographs" whatever is scoped (#76)
The sidebar's "All photographs" row was bound to library-total, which
is the scope's count: the header's "412 images" and the scrollbar's
size. Under a collection, and now under an album, the row read as the
album's size — 4 where the library holds 70.

It reads a separate library-whole-total now: the same number as the
scope's when nothing is scoped (no second count), and otherwise the
unscoped count under the same filter, read only when the view's facts
move, so scrolling inside an album does not recount the library.
2026-09-26 16:19:54 -04:00
123 changed files with 7944 additions and 883 deletions
+13
View File
@@ -157,6 +157,19 @@ jobs:
- name: Test - name: Test
run: cargo test --workspace run: cargo test --workspace
# The runner has one 99 GB disk shared with its images, and this job
# ended at 92 GB (2.1 GB free) on v0.18.0's run; v0.18.1's release
# build then died with "No space left on device". The test executables
# and examples in target/debug are the largest things in it, are never
# reused (a changed source relinks them) and are not what the cache is
# for — the dependency rlibs are — so they go before the release build
# rather than competing with it.
- name: Free the test binaries before the release build
run: |
find target/debug/deps -maxdepth 1 -type f -executable -delete
rm -rf target/debug/examples target/debug/incremental
df -h /workspace 2>/dev/null || df -h .
- name: Build - name: Build
run: cargo build --workspace --release run: cargo build --workspace --release
+12 -2
View File
@@ -148,9 +148,9 @@ screen looks like, [`tools/manual`](tools/manual/README.md) says how to record
it again. The pre-commit hook regenerates the matrix, the gesture book and the it again. The pre-commit hook regenerates the matrix, the gesture book and the
page; CI runs all three checks. page; CI runs all three checks.
## Two invariants the build defends ## Three invariants the build defends
Worth knowing before you trip one, because both failures name a requirement Worth knowing before you trip one, because each failure names a requirement
rather than a line: rather than a line:
- **No operation may be named in `ui/`** (FR-DEV-3a). Special-casing one - **No operation may be named in `ui/`** (FR-DEV-3a). Special-casing one
@@ -162,6 +162,16 @@ rather than a line:
`order:`, a filename disagreeing with its `id:`, a default outside its own `order:`, a filename disagreeing with its `id:`, a default outside its own
range, an expression naming something that is not a parameter. Each error range, an expression naming something that is not a parameter. Each error
names the key you got wrong and exits rather than panicking. names the key you got wrong and exits rather than panicking.
- **No verdict is written without a user action** (FR-CULL-13). A rating,
flag, colour label or trash membership is the photographer's to set, never a
signal's. `tools/traceability/src/verdicts.rs` finds every write of one in
the shipped code — the catalog setters, SQL that assigns those columns, the
sidecar's judgement amendment — and holds each to a reviewed list with its
reason: inside a Slint `on_*` callback, writing for callers that are checked
in turn, or carrying a verdict made elsewhere, such as a sidecar pull or the
sync merge. A new write fails `cargo test` (the `traceability` crate's tests,
part of the workspace run) until it is listed, and so does a listed one that
has gone; `cargo run -p traceability -- verdicts` prints the list.
## Commit messages ## Commit messages
Generated
+27 -25
View File
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]] [[package]]
name = "darkroom-android" name = "darkroom-android"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"dr-plat", "dr-plat",
@@ -1278,7 +1278,7 @@ dependencies = [
[[package]] [[package]]
name = "darkroom-desktop" name = "darkroom-desktop"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-plat", "dr-plat",
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]] [[package]]
name = "dr-bench" name = "dr-bench"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-catalog", "dr-catalog",
@@ -1471,7 +1471,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-catalog" name = "dr-catalog"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"dr-face", "dr-face",
"dr-plat", "dr-plat",
@@ -1486,7 +1486,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-decode" name = "dr-decode"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"env_logger", "env_logger",
@@ -1500,7 +1500,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-export" name = "dr-export"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-gpu", "dr-gpu",
@@ -1519,7 +1519,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-face" name = "dr-face"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1532,7 +1532,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-film" name = "dr-film"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -1541,7 +1541,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-gpu" name = "dr-gpu"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"dr-decode", "dr-decode",
@@ -1559,7 +1559,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-inference-engine" name = "dr-inference-engine"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"env_logger", "env_logger",
"libloading", "libloading",
@@ -1574,7 +1574,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ingest" name = "dr-ingest"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"dr-plat", "dr-plat",
"dr-types", "dr-types",
@@ -1586,7 +1586,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-lens" name = "dr-lens"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"lensfun", "lensfun",
"log", "log",
@@ -1594,7 +1594,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pano" name = "dr-pano"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-inference-engine", "dr-inference-engine",
@@ -1608,7 +1608,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pipeline" name = "dr-pipeline"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -1617,7 +1617,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-plat" name = "dr-plat"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"android-native-keyring-store", "android-native-keyring-store",
"dr-types", "dr-types",
@@ -1633,7 +1633,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-preset-xmp" name = "dr-preset-xmp"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"dr-pipeline", "dr-pipeline",
"log", "log",
@@ -1643,7 +1643,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-segment" name = "dr-segment"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1656,7 +1656,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync" name = "dr-sync"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-plat", "dr-plat",
@@ -1670,7 +1670,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-folder" name = "dr-sync-folder"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-sync", "dr-sync",
@@ -1682,7 +1682,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-nextcloud" name = "dr-sync-nextcloud"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-decode", "dr-decode",
@@ -1704,7 +1704,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-thumbs" name = "dr-thumbs"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"jpeg-encoder", "jpeg-encoder",
@@ -1716,7 +1716,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-types" name = "dr-types"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -1725,7 +1725,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ui" name = "dr-ui"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1773,7 +1773,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-xmp" name = "dr-xmp"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -7109,12 +7109,14 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]] [[package]]
name = "traceability" name = "traceability"
version = "0.17.0" version = "0.18.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"proc-macro2",
"pulldown-cmark", "pulldown-cmark",
"serde", "serde",
"serde_json", "serde_json",
"syn 2.0.119",
] ]
[[package]] [[package]]
+7 -1
View File
@@ -32,7 +32,7 @@ members = [
exclude = ["third_party"] exclude = ["third_party"]
[workspace.package] [workspace.package]
version = "0.17.0" version = "0.18.2"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
@@ -134,6 +134,12 @@ serde_json = "1"
# Slint's Markdown parser, so this adds a dependency edge and no crate; only # Slint's Markdown parser, so this adds a dependency edge and no crate; only
# the HTML writer is needed, not the command-line front end. # the HTML writer is needed, not the command-line front end.
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
# The verdict-writer check (tools/traceability, FR-CULL-13) reads Rust as Rust:
# a text scan cannot tell a call from a comment, a test module from shipped
# code, or which callback closure a call sits in. Both already in the tree as
# every proc macro's parser; `span-locations` gives a problem its line.
syn = { version = "2", default-features = false, features = ["full", "parsing", "visit", "printing"] }
proc-macro2 = { version = "1", default-features = false, features = ["span-locations"] }
base64 = "0.23" base64 = "0.23"
# Display-server clients, for FR-DSP-8's per-display profile acquisition. # Display-server clients, for FR-DSP-8's per-display profile acquisition.
+5 -2
View File
@@ -31,7 +31,10 @@ capture sharpening, noise reduction, lens correction, spectral film
simulation. Crop, straighten and correct converging verticals, spot repair, simulation. Crop, straighten and correct converging verticals, spot repair,
and local adjustments over masks the model draws — click a subject or a and local adjustments over masks the model draws — click a subject or a
category, then paint, subtract a gradient or keep only where two selections category, then paint, subtract a gradient or keep only where two selections
agree, grow or shrink the edge. Focus peaking and a raw histogram for judging agree, grow or shrink the edge. A mask's sliders add to the photograph's, the
film's among them, so a sky can be burned in on the print as a darkroom
printer would. Hot and dead photosites are mended before the demosaic, with
nothing to set. Focus peaking and a raw histogram for judging
what is recoverable. Presets, with a collection shipped in the application — what is recoverable. Presets, with a collection shipped in the application —
everyday corrections, and a look for each measured colour, cinema and everyday corrections, and a look for each measured colour, cinema and
black-and-white stock — and Lightroom presets imported as looks that leave a black-and-white stock — and Lightroom presets imported as looks that leave a
@@ -93,7 +96,7 @@ controls, its place in the chain and its tests.
## Where it stands ## Where it stands
**0.17.0**, twenty-five tagged releases in. 192 numbered requirements in **0.18.2**, twenty-eight tagged releases in. 192 numbered requirements in
scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md); scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md);
the rest are written down rather than merely absent. the rest are written down rather than merely absent.
+3 -1
View File
@@ -300,7 +300,9 @@ fn install_bundled_models(app: slint::android::AndroidApp) {
// on the worker because `AAssetManager` is thread-safe by contract and // on the worker because `AAssetManager` is thread-safe by contract and
// reading the pointer takes only the app's read lock, which `poll_events` // reading the pointer takes only the app's read lock, which `poll_events`
// also only ever holds shared. // also only ever holds shared.
std::thread::spawn(move || unpack_bundled_models(&app)); dr_ui::executors::spawn(dr_ui::executors::Executor::Io, "models", move || {
unpack_bundled_models(&app)
});
} }
/// The copy itself, on the worker [`install_bundled_models`] starts. /// The copy itself, on the worker [`install_bundled_models`] starts.
+30 -2
View File
@@ -1,6 +1,6 @@
//! What the catalog's routine reads cost on a real library, off the GUI. //! What the catalog's routine reads cost on a real library, off the GUI.
//! //!
//! cargo run --release -p dr-catalog --example catalog_bench -- CATALOG.sqlite [FACES_DIR] //! cargo run --release -p dr-catalog --example catalog_bench -- CATALOG.sqlite [FACES_DIR] [--remote PEER.sqlite]
//! //!
//! Times `Catalog::open` — which every worker thread pays, including the //! Times `Catalog::open` — which every worker thread pays, including the
//! develop view's fetch of each original and each neighbour it prefetches — //! develop view's fetch of each original and each neighbour it prefetches —
@@ -13,6 +13,13 @@
//! count), whose `library` module is private; their SQL is spelled here as //! count), whose `library` module is private; their SQL is spelled here as
//! it is spelled there, and has to be kept in step by hand. //! it is spelled there, and has to be kept in step by hand.
//! //!
//! `--remote` also times a merge with another device's catalog — the
//! server snapshot — which is the pass where the two disagree: faces one
//! side found and the other did not, boxes that moved. The merge with a copy
//! of itself matches every face by its box and never reaches that work. The
//! first of its runs writes what the peer brought; the rest are the steady
//! state, so compare two builds from two fresh copies of one catalog.
//!
//! The figures are for reading side by side before and after a change; they //! The figures are for reading side by side before and after a change; they
//! are not a gate. Compare the `cpu` column when the machine is busy. The //! are not a gate. Compare the `cpu` column when the machine is busy. The
//! answers are printed too, so two builds can be checked for agreeing. //! answers are printed too, so two builds can be checked for agreeing.
@@ -23,7 +30,15 @@ use std::time::{Duration, Instant};
use dr_catalog::{keywords, rating, schema, Catalog}; use dr_catalog::{keywords, rating, schema, Catalog};
fn main() { fn main() {
let args: Vec<String> = std::env::args().skip(1).collect(); let mut args: Vec<String> = std::env::args().skip(1).collect();
let peer = args.iter().position(|a| a == "--remote").map(|at| {
let path = args.get(at + 1).map(PathBuf::from).unwrap_or_else(|| {
eprintln!("--remote needs a catalog");
std::process::exit(2);
});
args.drain(at..at + 2);
path
});
let Some(path) = args.first().map(PathBuf::from) else { let Some(path) = args.first().map(PathBuf::from) else {
eprintln!("usage: catalog_bench CATALOG.sqlite"); eprintln!("usage: catalog_bench CATALOG.sqlite");
std::process::exit(2); std::process::exit(2);
@@ -117,6 +132,19 @@ fn main() {
let _ = std::fs::remove_file(&scratch); let _ = std::fs::remove_file(&scratch);
let _ = std::fs::remove_file(&remote); let _ = std::fs::remove_file(&remote);
if let Some(peer) = &peer {
// A copy, so nothing the merge does to its input reaches the file
// the caller named.
std::fs::copy(peer, &remote).unwrap();
let mut first = None;
time("merge_remote_catalog (--remote)", 5, || {
let report = catalog.merge_remote_catalog(&remote).unwrap();
first.get_or_insert(report);
});
println!(" first pass: {first:?}");
let _ = std::fs::remove_file(&remote);
}
// The face half of a sync pass, against a copy of the face store: both // The face half of a sync pass, against a copy of the face store: both
// directions in the steady state, where nothing is new either way. // directions in the steady state, where nothing is new either way.
if let Some(faces) = args.get(1).map(PathBuf::from) { if let Some(faces) = args.get(1).map(PathBuf::from) {
+141
View File
@@ -0,0 +1,141 @@
//! Run the people and face deduplication (#78) on a copy of a real catalog.
//!
//! cargo run --release -p dr-catalog --example dedup_people -- COPY.sqlite [--peer PEER_COPY.sqlite]
//!
//! It writes: run it against a *copy* (`sqlite3 catalog.sqlite ".backup
//! copy.sqlite"`), never the library's own file. Prints the live people and
//! faces before and after, what the first run merged and kept apart, and
//! how long the first and a second run took -- the second is the cost the
//! job adds to every sync once a catalog is clean.
//!
//! `--peer` then plays a sync round trip with another device's catalog (a
//! copy of the server snapshot, which it also writes): the peer merges this
//! one as the previous release would, with no job after it, then this one
//! merges the peer back through `sync::merge_remote`, twice. The named
//! people each side lists are printed after each step; they should agree.
use std::path::PathBuf;
use std::time::Instant;
use dr_catalog::{dedup_people, merge, schema, sync};
use rusqlite::Connection;
fn open(path: &std::path::Path) -> Connection {
let conn = Connection::open(path).expect("open the catalog copy");
schema::configure(&conn).expect("configure");
schema::migrate(&conn).expect("migrate");
conn
}
/// The named people a device lists, as `name (uuid prefix)`, sorted.
fn named(conn: &Connection) -> Vec<String> {
let mut v: Vec<String> = conn
.prepare(
"SELECT name, substr(uuid, 1, 8) FROM people
WHERE merged_into IS NULL AND trim(name) <> ''",
)
.unwrap()
.query_map([], |r| {
Ok(format!(
"{} ({})",
r.get::<_, String>(0)?,
r.get::<_, String>(1)?
))
})
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
v.sort();
v
}
fn main() {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let peer = args.iter().position(|a| a == "--peer").map(|at| {
let p = PathBuf::from(&args[at + 1]);
args.drain(at..at + 2);
p
});
let Some(path) = args.first().map(PathBuf::from) else {
eprintln!("usage: dedup_people COPY.sqlite [--peer PEER_COPY.sqlite]");
std::process::exit(2);
};
let conn = open(&path);
let counts = |label: &str| {
let q = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
println!(
"{label}: {} people listed ({} named), {} redirects, {} faces, {} confirmed",
q("SELECT COUNT(*) FROM people WHERE merged_into IS NULL"),
q("SELECT COUNT(*) FROM people WHERE merged_into IS NULL AND trim(name) <> ''"),
q("SELECT COUNT(*) FROM people WHERE merged_into IS NOT NULL"),
q("SELECT COUNT(*) FROM faces"),
q("SELECT COUNT(*) FROM face_person WHERE confirmed = 1"),
);
};
counts("before");
for pass in ["first", "second", "third"] {
let started = Instant::now();
let report = dedup_people::run(&conn).expect("dedup");
let took = started.elapsed();
println!("{pass} run: {took:?}, changed: {}", report.changed());
if pass == "first" {
println!(" merged: {:?}", report.merged);
for k in &report.kept_apart {
println!(
" kept apart: {:?} ({}) from {}: {:?}",
k.name, k.uuid, k.survivor, k.why
);
}
println!(
" redirects followed {}, cycles broken {}, faces fused {}, faces confirmed apart {}",
report.redirects_followed,
report.cycles_broken,
report.faces_fused,
report.faces_confirmed_apart
);
}
}
counts("after");
let Some(peer_path) = peer else { return };
let peer = open(&peer_path);
let show = |step: &str| {
let (ours, theirs) = (named(&conn), named(&peer));
println!(
"{step}: this device lists {} named, the peer {}; {}",
ours.len(),
theirs.len(),
if ours == theirs {
"the same".to_string()
} else {
format!("differ:\n here {ours:?}\n peer {theirs:?}")
}
);
};
show("before the round trip");
for round in 1..=2 {
peer.execute(
"ATTACH DATABASE ?1 AS remote_cat",
[path.to_string_lossy().as_ref()],
)
.unwrap();
let theirs = merge::merge_all(&peer).expect("the peer's merge");
peer.execute("DETACH DATABASE remote_cat", []).unwrap();
println!(
"round {round}: the peer took {} people updated, {} inserted",
theirs.people_updated, theirs.people_inserted
);
show(&format!("round {round}, after the peer's merge"));
let started = Instant::now();
let ours = sync::merge_remote(&conn, &peer_path).expect("our merge");
println!(
"round {round}: merge_remote with the job took {:?}; {} people updated, {} inserted",
started.elapsed(),
ours.people_updated,
ours.people_inserted
);
show(&format!("round {round}, after ours"));
}
}
File diff suppressed because it is too large Load Diff
+109 -7
View File
@@ -1029,7 +1029,8 @@ fn people_where(conn: &Connection, in_use: bool) -> Result<Vec<Person>, CatalogE
/// ///
/// Confirmations survive the move: a face the user confirmed as the source /// Confirmations survive the move: a face the user confirmed as the source
/// person is now a confirmed face of the target, which is what the user meant /// person is now a confirmed face of the target, which is what the user meant
/// by saying they are the same person. /// by saying they are the same person. So do rejections — see
/// [`merge_people_within`].
pub fn merge_people( pub fn merge_people(
conn: &Connection, conn: &Connection,
target: PersonId, target: PersonId,
@@ -1039,7 +1040,53 @@ pub fn merge_people(
return Ok(0); return Ok(0);
} }
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
let moved = merge_people_within(&tx, target, source)?;
tx.commit()?;
Ok(moved)
}
/// [`merge_people`] inside a transaction the caller holds, so a job that
/// merges several pairs commits once (`crate::dedup_people`).
///
/// **Rejections move with the faces.** "This face is not Annie" is a
/// judgement about the person, and once Annie is Anna it is one about Anna.
/// Left on the redirect it binds nothing, and the next grouping pass
/// suggests the face the user pushed away to the person it now belongs to. Where the
/// two halves disagree about one face — confirmed as one, rejected as the
/// other — the confirmation stands, which is the rule [`confirm`] applies
/// to one face; and a moved rejection takes a suggestion of the same face
/// with it, the rule [`reject`] applies.
pub(crate) fn merge_people_within(
tx: &Connection,
target: PersonId,
source: PersonId,
) -> Result<u64, CatalogError> {
if target == source {
return Ok(0);
}
let moved = move_judgements(tx, target, source)?;
tx.execute(
"UPDATE people SET merged_into = ?1, revision = revision + 1, modified = ?3
WHERE id = ?2",
rusqlite::params![target.0 as i64, source.0 as i64, now_secs()],
)?;
Ok(moved)
}
/// The half of [`merge_people_within`] that moves faces and rejections,
/// without touching either person's row.
///
/// Also what follows a redirect that arrived by sync
/// (`crate::dedup_people`): the other device merged the people, and this
/// one still holds judgements on the person merged away. Bumping the
/// person's revision there would be an edit of this device's own, sent back
/// on every pass, so the row is left as the merge wrote it.
pub(crate) fn move_judgements(
tx: &Connection,
target: PersonId,
source: PersonId,
) -> Result<u64, CatalogError> {
let (t, s) = (target.0 as i64, source.0 as i64);
// A face already assigned to the target must not gain a second row — // A face already assigned to the target must not gain a second row —
// `face_person` is keyed by face. Where both hold the same face, the // `face_person` is keyed by face. Where both hold the same face, the
// target's row wins and the source's is dropped. // target's row wins and the source's is dropped.
@@ -1047,18 +1094,31 @@ pub fn merge_people(
"DELETE FROM face_person "DELETE FROM face_person
WHERE person_id = ?2 WHERE person_id = ?2
AND face_id IN (SELECT face_id FROM face_person WHERE person_id = ?1)", AND face_id IN (SELECT face_id FROM face_person WHERE person_id = ?1)",
rusqlite::params![target.0 as i64, source.0 as i64], rusqlite::params![t, s],
)?; )?;
let moved = tx.execute( let moved = tx.execute(
"UPDATE face_person SET person_id = ?1 WHERE person_id = ?2", "UPDATE face_person SET person_id = ?1 WHERE person_id = ?2",
rusqlite::params![target.0 as i64, source.0 as i64], rusqlite::params![t, s],
)?; )?;
tx.execute( tx.execute(
"UPDATE people SET merged_into = ?1, revision = revision + 1, modified = ?3 "INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
WHERE id = ?2", SELECT face_id, ?1 FROM face_person_rejected WHERE person_id = ?2",
rusqlite::params![target.0 as i64, source.0 as i64, now_secs()], rusqlite::params![t, s],
)?;
tx.execute("DELETE FROM face_person_rejected WHERE person_id = ?1", [s])?;
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 = 1)",
[t],
)?;
tx.execute(
"DELETE FROM face_person
WHERE person_id = ?1 AND confirmed = 0
AND face_id IN (SELECT face_id FROM face_person_rejected WHERE person_id = ?1)",
[t],
)?; )?;
tx.commit()?;
Ok(moved as u64) Ok(moved as u64)
} }
@@ -2518,6 +2578,48 @@ mod tests {
assert_eq!(people(&c).unwrap().len(), 1); assert_eq!(people(&c).unwrap().len(), 1);
} }
/// A rejection left on the redirect bound nothing: the next grouping
/// pass suggested the face to the merged person, whom the user had told
/// it was somebody else.
#[test]
fn merging_moves_the_rejections_too() {
let c = db();
let ids: Vec<FaceId> = (1..=3)
.map(|n| {
let img = image(&c, n);
record_detections(&c, img, "w600k_mbf", 1024, &[face(n as u8)]).unwrap()[0]
})
.collect();
let anna = create_person(&c, "Anna").unwrap();
let annie = create_person(&c, "Annie").unwrap();
// Rejected as Annie, and nothing said about Anna.
reject(&c, ids[0], annie).unwrap();
// Rejected as Annie, suggested as Anna: the rejection now covers it.
suggest(&c, ids[1], anna, 0.8).unwrap();
reject(&c, ids[1], annie).unwrap();
// Rejected as Annie, confirmed as Anna: the confirmation stands.
confirm(&c, ids[2], anna).unwrap();
reject(&c, ids[2], annie).unwrap();
merge_people(&c, anna, annie).unwrap();
let rejected: Vec<(i64, i64)> = c
.prepare("SELECT face_id, person_id FROM face_person_rejected ORDER BY face_id")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
let anna_id = anna.0 as i64;
assert_eq!(
rejected,
[(ids[0].0 as i64, anna_id), (ids[1].0 as i64, anna_id)]
);
assert_eq!(for_image(&c, ImageId(2)).unwrap()[0].person, None);
let kept = &for_image(&c, ImageId(3)).unwrap()[0];
assert_eq!((kept.person, kept.confirmed), (Some(anna), true));
}
#[test] #[test]
fn merging_does_not_duplicate_a_face_both_people_hold() { fn merging_does_not_duplicate_a_face_both_people_hold() {
let c = db(); let c = db();
+1
View File
@@ -42,6 +42,7 @@ pub mod bursts;
pub mod cache; pub mod cache;
pub mod collections; pub mod collections;
pub mod dedup; pub mod dedup;
pub mod dedup_people;
pub mod duplicates; pub mod duplicates;
pub mod error; pub mod error;
pub mod face_shard; pub mod face_shard;
+533 -49
View File
@@ -113,6 +113,12 @@ pub struct MergeReport {
pub faces_kept_local: usize, pub faces_kept_local: usize,
/// "Not this person" judgements taken from the remote. /// "Not this person" judgements taken from the remote.
pub faces_rejected: usize, pub faces_rejected: usize,
/// Remote faces placed on a local one by their embedding, where the
/// boxes disagreed or were ambiguous (see `match_faces`).
pub faces_matched_by_embedding: usize,
/// Assignments from the remote refused because this device already has
/// that person on another face of the same photograph.
pub faces_one_per_photograph: usize,
/// Redundant identities for one word, retired by /// Redundant identities for one word, retired by
/// [`crate::keywords::fuse_duplicates`]. /// [`crate::keywords::fuse_duplicates`].
@@ -973,15 +979,17 @@ fn attached_has_table(conn: &Connection, schema: &str, table: &str) -> Result<bo
/// # Faces have no cross-device identity, so one is derived /// # Faces have no cross-device identity, so one is derived
/// ///
/// `faces.id` is a local row id and means nothing in another catalog; there is /// `faces.id` is a local row id and means nothing in another catalog; there is
/// no uuid to fall back on. What both devices *do* agree on is `oc:fileid` and /// no uuid to fall back on. What both devices *do* agree on is `oc:fileid`,
/// the box, so a remote face is matched to the local face on the same /// the box, and the embedding, so a remote face is matched to the local face
/// photograph whose box overlaps it most, above a floor of 0.5 IoU. /// on the same photograph whose box overlaps it, above a floor of 0.5 IoU —
/// or, where no box or two boxes do, whose vector it decisively resembles
/// ([`match_faces`]).
/// ///
/// That is not a new rule: it is the one /// That is not a new rule: it is the one
/// [`crate::faces::record_detections`] already uses to carry a confirmation /// [`crate::faces::record_detections`] already uses to carry a confirmation
/// across a re-index, and it is loose on purpose — the question is "is this the /// across a re-index, and it is loose on purpose — the question is "is this the
/// same face in the frame", not "is this the same rectangle", and a device /// same face in the frame", not "is this the same rectangle", and a device
/// running a newer detector is entitled to have moved the box a little. /// running a newer detector is entitled to have moved the box.
fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> { fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> {
// A remote written before faces existed has none of these tables, and one // A remote written before faces existed has none of these tables, and one
// written before V10 has no `ignored`. Both are ordinary — `remote_is_ // written before V10 has no `ignored`. Both are ordinary — `remote_is_
@@ -1076,7 +1084,14 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
} }
// ---- match the remote's faces onto this device's ---------------------- // ---- match the remote's faces onto this device's ----------------------
let face_map = match_faces(tx)?; let matched = match_faces(tx)?;
report.faces_matched_by_embedding += matched.by_embedding;
let FaceMatch {
map: face_map,
on_file,
file_of,
..
} = matched;
if face_map.is_empty() { if face_map.is_empty() {
return Ok(()); return Ok(());
} }
@@ -1122,6 +1137,8 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
probability = excluded.probability, probability = excluded.probability,
confirmed = excluded.confirmed", confirmed = excluded.confirmed",
)?; )?;
let mut withdraw =
tx.prepare_cached("DELETE FROM face_person WHERE face_id = ?1 AND confirmed = 0")?;
for (remote_face, person, probability, confirmed) in incoming { for (remote_face, person, probability, confirmed) in incoming {
let Some(&local_face) = face_map.get(&remote_face) else { let Some(&local_face) = face_map.get(&remote_face) else {
@@ -1148,6 +1165,37 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
continue; continue;
} }
// One person, one face per photograph -- the cannot-link the
// grouping pass already keeps (`dr_face::cluster`), which the
// merge did not. When the two devices disagree about *which*
// face in a frame is somebody, taking the remote's answer
// beside this device's own puts the person on both. The
// reference library holds 80 such pairs (79 set-aside
// strangers, one named person), the same on both devices. The
// face this device already gave the person keeps them, unless
// the remote's is a confirmation and this device's only a
// suggestion.
//
// A face that already holds the person adds nothing beside it,
// whatever else the photograph holds.
let adds_person = current.is_none_or(|(held_person, ..)| held_person != person);
let rival = file_of
.get(&local_face)
.filter(|_| adds_person)
.and_then(|file| {
on_file[file].iter().copied().find(|&other| {
other != local_face && held.get(&other).is_some_and(|h| h.0 == person)
})
});
if let Some(rival) = rival {
if !confirmed || held[&rival].2 == 1 {
report.faces_one_per_photograph += 1;
continue;
}
withdraw.execute([rival])?;
held.remove(&rival);
}
// Written only when it differs. Rewriting a row with the values it // Written only when it differs. Rewriting a row with the values it
// already holds dirtied a page per face, every pass, for nothing; // already holds dirtied a page per face, every pass, for nothing;
// the report still counts it, as it always has. // the report still counts it, as it always has.
@@ -1225,7 +1273,45 @@ fn remote_has_column(tx: &Connection, table: &str, column: &str) -> Result<bool,
Ok(stmt.exists(rusqlite::params![table, column])?) Ok(stmt.exists(rusqlite::params![table, column])?)
} }
/// Remote face row id to local face row id, by photograph and box overlap. /// The cosine above which two vectors from one embedder, on one
/// photograph, on two devices, are taken to be the same face when the boxes
/// do not say so.
///
/// Measured on the reference library against the tablet's snapshot
/// (2026-09-26, #77), both `w600k_mbf`: of the 169,548 pairs of *different*
/// faces in one photograph, four reach 0.7 and none 0.83 -- lookalikes in
/// one frame, a parent and child. Of the 18,348 pairs the boxes match, 94%
/// are above 0.9; the tail below is one face cut by two detectors, which is
/// why this never overrules a box that matches on its own. At 0.6 two of
/// the pairs it would claim carry different people on the two devices; at
/// 0.7 none of the twenty it claims does, and ten carry the same person on
/// both. Stricter than [`crate::faces::SAME_FACE_COSINE`] because that one
/// is only asked about boxes that overlap, and this one is asked about boxes
/// that do not.
const SAME_FACE_ACROSS_DEVICES: f32 = 0.7;
/// How far a face's best counterpart must lead its second best, on both
/// sides, for the embedding to decide. A face that two others resemble
/// almost equally is exactly the one a merge must not guess at; every pair
/// the rule claims on the reference library leads by more than 0.5.
const DECISIVE_MARGIN: f32 = 0.2;
/// What [`match_faces`] found.
#[derive(Default)]
struct FaceMatch {
/// Remote face row id to local face row id; one-to-one.
map: std::collections::HashMap<i64, i64>,
/// Every local face on a synced photograph, by the photograph's
/// cross-device id -- what "another face in the same photograph" means
/// to the merge.
on_file: std::collections::HashMap<i64, Vec<i64>>,
/// The inverse of `on_file`.
file_of: std::collections::HashMap<i64, i64>,
/// Pairs the boxes could not settle and the embeddings did.
by_embedding: usize,
}
/// Remote face row id to local face row id, by photograph, box and vector.
/// ///
/// See [`merge_people_within`] for why a face has no shared identity and this /// See [`merge_people_within`] for why a face has no shared identity and this
/// has to be derived. Faces are compared within an *embedder* /// has to be derived. Faces are compared within an *embedder*
@@ -1235,25 +1321,42 @@ fn remote_has_column(tx: &Connection, table: &str, column: &str) -> Result<bool,
/// rectangle — the same judgement `faces::record_detections` makes when it /// rectangle — the same judgement `faces::record_detections` makes when it
/// carries a confirmation across a re-detection. Keying on the exact id was /// carries a confirmation across a re-detection. Keying on the exact id was
/// what let a detector change strand every name on the device that made it. /// what let a detector change strand every name on the device that made it.
fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, CatalogError> { ///
/// # Two passes, and the second is rare
///
/// **By box.** A remote face and a local one on the same photograph are the
/// same face when their boxes overlap by at least 0.5 IoU and neither has
/// another such candidate. That settles 18,348 of the reference library's
/// 19,052 remote faces, reads no vector, and is the whole of a steady-state
/// pass.
///
/// **By embedding**, only on the photographs where a remote face is left
/// over -- no box overlapped it, or two did. Their vectors are read (a few
/// hundred photographs, not the library's 19 MB of them) and a remote face is
/// paired with the local face it resembles most when the cosine is at least
/// [`SAME_FACE_ACROSS_DEVICES`], the pair is each other's best, each leads
/// its runner-up by [`DECISIVE_MARGIN`], and the local face was not already
/// claimed by a box. That is the face whose box one device drew somewhere
/// else -- twenty on the reference library, boxes at IoU 0 with cosines of
/// 0.72 to 0.96 -- and the face between two overlapping boxes. Anything less
/// decisive stays unmatched, which is what a new face is: a name that fails
/// to cross can be given again, a name put on the wrong face is a false
/// merge the user has to find.
fn match_faces(tx: &Connection) -> Result<FaceMatch, CatalogError> {
use std::collections::HashMap;
/// Loose on purpose — "the same face in the frame", not "the same /// Loose on purpose — "the same face in the frame", not "the same
/// rectangle". The figure `record_detections` uses for the same job. /// rectangle". The figure `record_detections` uses for the same job.
const MIN_IOU: f32 = 0.5; const MIN_IOU: f32 = 0.5;
type Boxed = (i64, f32, f32, f32, f32); type Boxed = (i64, f32, f32, f32, f32);
type Key = (i64, String);
ensure_face_box_index(tx); ensure_face_box_index(tx);
// Local faces, grouped by the photograph's cross-device id. let read_boxes = |sql: &str| -> Result<HashMap<Key, Vec<Boxed>>, CatalogError> {
let mut local: std::collections::HashMap<(i64, String), Vec<Boxed>> = let mut out: HashMap<Key, Vec<Boxed>> = HashMap::new();
std::collections::HashMap::new(); let mut stmt = tx.prepare(sql)?;
{
let mut stmt = tx.prepare(
"SELECT f.id, r.file_id, f.model_id, f.x, f.y, f.w, f.h
FROM main.faces f
JOIN main.remote r ON r.image_id = f.image_id
WHERE r.file_id IS NOT NULL",
)?;
let rows = stmt.query_map([], |r| { let rows = stmt.query_map([], |r| {
Ok(( Ok((
r.get::<_, i64>(1)?, r.get::<_, i64>(1)?,
@@ -1270,50 +1373,187 @@ fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, C
for row in rows { for row in rows {
let (file_id, model, boxed) = row?; let (file_id, model, boxed) = row?;
let embedder = crate::faces::embedder_of(&model).to_string(); let embedder = crate::faces::embedder_of(&model).to_string();
local.entry((file_id, embedder)).or_default().push(boxed); out.entry((file_id, embedder)).or_default().push(boxed);
}
Ok(out)
};
// Local faces, grouped by the photograph's cross-device id.
let local = read_boxes(
"SELECT f.id, r.file_id, f.model_id, f.x, f.y, f.w, f.h
FROM main.faces f
JOIN main.remote r ON r.image_id = f.image_id
WHERE r.file_id IS NOT NULL",
)?;
if local.is_empty() {
return Ok(FaceMatch::default());
}
let mut out = FaceMatch::default();
for ((file_id, _), faces) in &local {
let on = out.on_file.entry(*file_id).or_default();
for &(id, ..) in faces {
on.push(id);
out.file_of.insert(id, *file_id);
} }
} }
if local.is_empty() {
return Ok(Default::default());
}
let mut map = std::collections::HashMap::new(); let remote = read_boxes(
let mut stmt = tx.prepare(
"SELECT f.id, r.file_id, f.model_id, f.x, f.y, f.w, f.h "SELECT f.id, r.file_id, f.model_id, f.x, f.y, f.w, f.h
FROM remote_cat.faces f FROM remote_cat.faces f
JOIN remote_cat.remote r ON r.image_id = f.image_id JOIN remote_cat.remote r ON r.image_id = f.image_id
WHERE r.file_id IS NOT NULL", WHERE r.file_id IS NOT NULL",
)?; )?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, String>(2)?,
(
r.get::<_, f64>(3)? as f32,
r.get::<_, f64>(4)? as f32,
r.get::<_, f64>(5)? as f32,
r.get::<_, f64>(6)? as f32,
),
))
})?;
for row in rows { // ---- by box -----------------------------------------------------------
let (remote_id, file_id, model, rbox) = row?; // Per group, which local face (by index) each remote face took.
let embedder = crate::faces::embedder_of(&model).to_string(); let mut left_over: Vec<(&Key, Vec<Option<usize>>)> = Vec::new();
let Some(candidates) = local.get(&(file_id, embedder)) else { for (key, theirs) in &remote {
let Some(ours) = local.get(key) else {
continue; continue;
}; };
let best = candidates let overlaps: Vec<Vec<bool>> = theirs
.iter() .iter()
.map(|&(id, x, y, w, h)| (id, iou(rbox, (x, y, w, h)))) .map(|&(_, x, y, w, h)| {
.filter(|&(_, score)| score >= MIN_IOU) ours.iter()
.max_by(|a, b| a.1.total_cmp(&b.1)); .map(|&(_, lx, ly, lw, lh)| iou((x, y, w, h), (lx, ly, lw, lh)) >= MIN_IOU)
if let Some((local_id, _)) = best { .collect()
map.insert(remote_id, local_id); })
.collect();
let mut taken: Vec<Option<usize>> = vec![None; theirs.len()];
for (i, row) in overlaps.iter().enumerate() {
let mut hits = row.iter().enumerate().filter(|(_, &hit)| hit);
let (Some((j, _)), None) = (hits.next(), hits.next()) else {
continue;
};
if overlaps.iter().filter(|other| other[j]).count() == 1 {
taken[i] = Some(j);
out.map.insert(theirs[i].0, ours[j].0);
}
}
// Worth reading vectors for only where a remote face is still
// unplaced and a local face is still free to be its counterpart.
let free = ours.len() > taken.iter().flatten().count();
if free && taken.iter().any(Option::is_none) {
left_over.push((key, taken));
} }
} }
Ok(map) if left_over.is_empty() {
return Ok(out);
}
// ---- by embedding, for what the boxes left --------------------------
let wanted = |side: &HashMap<Key, Vec<Boxed>>| -> String {
let ids: Vec<String> = left_over
.iter()
.flat_map(|(key, _)| side[*key].iter().map(|b| b.0.to_string()))
.collect();
format!("[{}]", ids.join(","))
};
// One statement per side, keyed by row id, for the faces of those
// photographs only.
let read_vectors = |schema: &str, ids: String| -> Result<HashMap<i64, Vec<u8>>, CatalogError> {
let mut stmt = tx.prepare(&format!(
"SELECT f.id, f.embedding
FROM json_each(?1) j
JOIN {schema}.faces f ON f.id = j.value"
))?;
let rows = stmt.query_map([ids], |r| Ok((r.get(0)?, r.get(1)?)))?;
Ok(rows.collect::<Result<_, _>>()?)
};
let our_vectors = read_vectors("main", wanted(&local))?;
let their_vectors = read_vectors("remote_cat", wanted(&remote))?;
for (key, taken) in left_over {
let model = dr_face::ModelId::new(key.1.as_str());
let decode =
|vectors: &HashMap<i64, Vec<u8>>, faces: &[Boxed]| -> Vec<Option<dr_face::Embedding>> {
faces
.iter()
.map(|b| {
let blob = vectors.get(&b.0)?;
dr_face::Embedding::from_f16_bytes(model.clone(), blob)
})
.collect()
};
let (theirs, ours) = (&remote[key], &local[key]);
let pairs = pair_by_embedding(
&decode(&their_vectors, theirs),
&decode(&our_vectors, ours),
&taken,
);
for (i, j) in pairs {
out.map.insert(theirs[i].0, ours[j].0);
out.by_embedding += 1;
}
}
Ok(out)
}
/// The pairs the embeddings decide, as `(remote index, local index)`, for the
/// remote faces the boxes left unplaced (`taken[i] == None`).
///
/// Both sides are compared in full -- a local face a box already claimed can
/// still be a remote face's best resemblance, and then that remote face is
/// not placed elsewhere, because its best counterpart is spoken for and its
/// second best is not decisive. Vectors that are missing or of another
/// embedder compare as nothing ([`dr_face::Embedding::cosine`]).
fn pair_by_embedding(
theirs: &[Option<dr_face::Embedding>],
ours: &[Option<dr_face::Embedding>],
taken: &[Option<usize>],
) -> Vec<(usize, usize)> {
let cos: Vec<Vec<f32>> = theirs
.iter()
.map(|t| {
ours.iter()
.map(|o| match (t, o) {
(Some(t), Some(o)) => t.cosine(o).unwrap_or(f32::NEG_INFINITY),
_ => f32::NEG_INFINITY,
})
.collect()
})
.collect();
/// The index of the largest value, and by how much it leads the next.
fn best(values: impl Iterator<Item = f32>) -> Option<(usize, f32, f32)> {
let mut first: Option<(usize, f32)> = None;
let mut second = f32::NEG_INFINITY;
for (at, v) in values.enumerate() {
match first {
Some((_, top)) if v <= top => second = second.max(v),
_ => {
if let Some((_, top)) = first {
second = top;
}
first = Some((at, v));
}
}
}
first.map(|(at, top)| (at, top, second))
}
let decisive =
|top: f32, second: f32| top >= SAME_FACE_ACROSS_DEVICES && top - second >= DECISIVE_MARGIN;
let claimed: std::collections::HashSet<usize> = taken.iter().flatten().copied().collect();
let mut pairs = Vec::new();
for (i, row) in cos.iter().enumerate() {
if taken[i].is_some() {
continue;
}
let Some((j, top, second)) = best(row.iter().copied()) else {
continue;
};
if claimed.contains(&j) || !decisive(top, second) {
continue;
}
let Some((back, top, second)) = best(cos.iter().map(|row| row[j])) else {
continue;
};
if back == i && decisive(top, second) {
pairs.push((i, j));
}
}
pairs
} }
/// The index the local half of [`match_faces`] is read from: every column /// The index the local half of [`match_faces`] is read from: every column
@@ -1332,7 +1572,7 @@ fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, C
/// extra index is invisible to them. The first merge after an upgrade pays /// extra index is invisible to them. The first merge after an upgrade pays
/// for building it, once. A failure is logged and the merge goes on reading /// for building it, once. A failure is logged and the merge goes on reading
/// rows, as it did before. /// rows, as it did before.
fn ensure_face_box_index(tx: &Connection) { pub(crate) fn ensure_face_box_index(tx: &Connection) {
if let Err(e) = tx.execute_batch( if let Err(e) = tx.execute_batch(
"CREATE INDEX IF NOT EXISTS main.faces_box ON faces(image_id, model_id, x, y, w, h);", "CREATE INDEX IF NOT EXISTS main.faces_box ON faces(image_id, model_id, x, y, w, h);",
) { ) {
@@ -1341,7 +1581,7 @@ fn ensure_face_box_index(tx: &Connection) {
} }
/// Intersection over union of two `(x, y, w, h)` boxes. /// Intersection over union of two `(x, y, w, h)` boxes.
fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 { pub(crate) fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 {
let x0 = a.0.max(b.0); let x0 = a.0.max(b.0);
let y0 = a.1.max(b.1); let y0 = a.1.max(b.1);
let x1 = (a.0 + a.2).min(b.0 + b.2); let x1 = (a.0 + a.2).min(b.0 + b.2);
@@ -2541,4 +2781,248 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(people, 1); assert_eq!(people, 1);
} }
// ── faces the boxes cannot place, and their vectors ───────────────────
/// A unit vector in the embedder's space, the same for the same seed.
/// Two seeds are near-orthogonal, as two strangers' faces are.
fn vector(seed: u32) -> Vec<f32> {
let mut s = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
let mut v: Vec<f32> = (0..dr_face::EMBEDDING_DIM)
.map(|_| {
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(s >> 8) as f32 / (1u32 << 23) as f32 - 0.5
})
.collect();
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
v.iter_mut().for_each(|x| *x /= norm);
v
}
/// Store `v` as `face`'s embedding, as the embedder would.
fn embed(c: &Connection, db: &str, face: i64, v: &[f32]) {
let e = dr_face::Embedding {
model: dr_face::ModelId::new("w600k_mbf"),
v: Box::new(v.try_into().unwrap()),
};
c.execute(
&format!("UPDATE {db}.faces SET embedding = ?2 WHERE id = ?1"),
rusqlite::params![face, e.to_f16_bytes()],
)
.unwrap();
}
/// Anna confirmed on the remote's face 42.
fn anna_on(c: &Connection, remote: i64) {
add_person(c, "remote_cat", 3, "u-anna", "Anna", false);
assign(c, "remote_cat", remote, 3, true);
}
/// The case #77 was opened for: one device drew the box somewhere else —
/// on the reference library, whole photographs whose boxes sit at IoU 0
/// with cosines above 0.9 — and the name stayed behind. The vector says
/// it is the same face.
#[test]
fn a_shifted_box_with_the_same_embedding_matches() {
let c = two_catalogs();
for db in ["main", "remote_cat"] {
add_synced_image(&c, db, 1, 5000);
}
let local = add_face(&c, "main", 7, 1, 0.10);
let remote = add_face(&c, "remote_cat", 42, 1, 0.60);
embed(&c, "main", local, &vector(1));
embed(&c, "remote_cat", remote, &vector(1));
anna_on(&c, remote);
let report = merge_all(&c).unwrap();
assert_eq!(report.faces_matched_by_embedding, 1);
assert_eq!(person_of(&c, local), Some(("Anna".to_string(), true)));
}
/// Two faces close enough that both boxes overlap the remote's by more
/// than half: the box cannot say which, and must not guess. The vector
/// can.
#[test]
fn two_overlapping_faces_are_told_apart_by_embedding() {
let c = two_catalogs();
for db in ["main", "remote_cat"] {
add_synced_image(&c, db, 1, 5000);
}
let front = add_face(&c, "main", 7, 1, 0.30);
let behind = add_face(&c, "main", 8, 1, 0.36);
let remote = add_face(&c, "remote_cat", 42, 1, 0.33);
embed(&c, "main", front, &vector(1));
embed(&c, "main", behind, &vector(2));
embed(&c, "remote_cat", remote, &vector(2));
anna_on(&c, remote);
merge_all(&c).unwrap();
assert_eq!(person_of(&c, behind), Some(("Anna".to_string(), true)));
assert_eq!(person_of(&c, front), None);
}
/// The same two overlapping boxes, and vectors that do not decide: the
/// face stays unmatched rather than going to the larger overlap.
#[test]
fn an_ambiguous_box_with_no_decisive_vector_stays_unmatched() {
let c = two_catalogs();
for db in ["main", "remote_cat"] {
add_synced_image(&c, db, 1, 5000);
}
let front = add_face(&c, "main", 7, 1, 0.30);
let behind = add_face(&c, "main", 8, 1, 0.35);
let remote = add_face(&c, "remote_cat", 42, 1, 0.33);
embed(&c, "main", front, &vector(1));
embed(&c, "main", behind, &vector(2));
// Equally like both: 0.71 each, no margin.
let between: Vec<f32> = vector(1)
.iter()
.zip(vector(2))
.map(|(a, b)| (a + b) / 2f32.sqrt())
.collect();
embed(&c, "remote_cat", remote, &between);
anna_on(&c, remote);
merge_all(&c).unwrap();
assert_eq!(person_of(&c, front), None);
assert_eq!(person_of(&c, behind), None);
}
/// The same person in another photograph has the same vector — the
/// worst lookalike there is — and is not the same face. Nor is a face
/// in the right photograph that neither box nor vector ties to it: that
/// is a face this device found and the other did not, and it stays new.
#[test]
fn a_similar_embedding_in_a_different_photograph_never_matches() {
let c = two_catalogs();
for db in ["main", "remote_cat"] {
add_synced_image(&c, db, 1, 5000);
add_synced_image(&c, db, 2, 6000);
}
let elsewhere = add_face(&c, "main", 7, 2, 0.10);
let stranger = add_face(&c, "main", 8, 1, 0.10);
let remote = add_face(&c, "remote_cat", 42, 1, 0.60);
embed(&c, "main", elsewhere, &vector(1));
embed(&c, "main", stranger, &vector(2));
embed(&c, "remote_cat", remote, &vector(1));
anna_on(&c, remote);
let report = merge_all(&c).unwrap();
assert_eq!(report.faces_matched_by_embedding, 0);
assert_eq!(person_of(&c, elsewhere), None, "matched across photographs");
assert_eq!(person_of(&c, stranger), None, "a new face was matched");
}
/// Vectors from two embedders live in two spaces; a cosine between
/// them is a number that means nothing.
#[test]
fn different_embedders_never_compare() {
let c = two_catalogs();
for db in ["main", "remote_cat"] {
add_synced_image(&c, db, 1, 5000);
}
let local = add_face(&c, "main", 7, 1, 0.10);
c.execute(
"UPDATE main.faces SET model_id = 'scrfd_10g+other_embedder' WHERE id = ?1",
[local],
)
.unwrap();
let remote = add_face(&c, "remote_cat", 42, 1, 0.60);
embed(&c, "main", local, &vector(1));
embed(&c, "remote_cat", remote, &vector(1));
anna_on(&c, remote);
merge_all(&c).unwrap();
assert_eq!(person_of(&c, local), None, "matched across embedders");
}
/// A local face its box already placed is not handed to a second remote
/// face because that one resembles it: one face, one counterpart.
#[test]
fn a_face_the_box_placed_is_not_taken_again_by_a_vector() {
let c = two_catalogs();
for db in ["main", "remote_cat"] {
add_synced_image(&c, db, 1, 5000);
}
let placed = add_face(&c, "main", 7, 1, 0.10);
let free = add_face(&c, "main", 8, 1, 0.70);
let by_box = add_face(&c, "remote_cat", 41, 1, 0.10);
let remote = add_face(&c, "remote_cat", 42, 1, 0.40);
embed(&c, "main", placed, &vector(1));
embed(&c, "main", free, &vector(3));
embed(&c, "remote_cat", by_box, &vector(2));
embed(&c, "remote_cat", remote, &vector(1));
anna_on(&c, remote);
merge_all(&c).unwrap();
assert_eq!(person_of(&c, placed), None);
assert_eq!(person_of(&c, free), None);
}
// ── one person, one face per photograph ───────────────────────────────
/// Two faces far apart in one photograph, one each side's remote
/// counterpart can be matched to by box: (local 7, local 8, remote 42
/// over 8).
fn two_faces_one_photograph(c: &Connection) -> (i64, i64, i64) {
for db in ["main", "remote_cat"] {
add_synced_image(c, db, 1, 5000);
}
let here = add_face(c, "main", 7, 1, 0.10);
let there = add_face(c, "main", 8, 1, 0.60);
let remote = add_face(c, "remote_cat", 42, 1, 0.60);
(here, there, remote)
}
/// The devices disagree about which stranger in a crowd a set-aside
/// group holds. Taking the remote's anchor beside this device's own put
/// one person on two faces of one frame.
#[test]
fn a_set_aside_anchor_does_not_land_beside_this_devices_own() {
let c = two_catalogs();
let (here, there, remote) = two_faces_one_photograph(&c);
add_person(&c, "main", 1, "u-stranger", "", true);
add_person(&c, "remote_cat", 3, "u-stranger", "", true);
assign(&c, "main", here, 1, false);
assign(&c, "remote_cat", remote, 3, false);
let report = merge_all(&c).unwrap();
assert_eq!(report.faces_one_per_photograph, 1);
assert_eq!(person_of(&c, here), Some((String::new(), false)));
assert_eq!(person_of(&c, there), None);
}
/// A confirmation from the other device outranks a suggestion here for
/// the same person on another face, which gives the person up.
#[test]
fn a_remote_confirmation_moves_a_local_suggestion_off_the_other_face() {
let c = two_catalogs();
let (here, there, remote) = two_faces_one_photograph(&c);
add_person(&c, "main", 1, "u-anna", "Anna", false);
add_person(&c, "remote_cat", 3, "u-anna", "Anna", false);
assign(&c, "main", here, 1, false);
assign(&c, "remote_cat", remote, 3, true);
merge_all(&c).unwrap();
assert_eq!(person_of(&c, there), Some(("Anna".to_string(), true)));
assert_eq!(person_of(&c, here), None);
}
/// Two confirmations of one person on two faces of one photograph is a
/// disagreement no merge can settle; this device's stands, and the
/// second is not added beside it.
#[test]
fn a_remote_confirmation_does_not_double_a_local_one() {
let c = two_catalogs();
let (here, there, remote) = two_faces_one_photograph(&c);
add_person(&c, "main", 1, "u-anna", "Anna", false);
add_person(&c, "remote_cat", 3, "u-anna", "Anna", false);
assign(&c, "main", here, 1, true);
assign(&c, "remote_cat", remote, 3, true);
let report = merge_all(&c).unwrap();
assert_eq!(report.faces_one_per_photograph, 1);
assert_eq!(person_of(&c, here), Some(("Anna".to_string(), true)));
assert_eq!(person_of(&c, there), None);
}
} }
+12
View File
@@ -344,6 +344,18 @@ pub fn merge_remote(conn: &Connection, remote: &Path) -> Result<MergeReport, Cat
log::warn!("failed to detach remote catalog: {e}"); log::warn!("failed to detach remote catalog: {e}");
} }
// After every merge, because a merge is where two devices' people meet:
// the same name typed on each, or a redirect one of them made. Its own
// transaction, and a failure is logged rather than returned -- what the
// merge took is committed and valid whether or not the duplicates were
// folded, and the next pass tries again. Runs on the sync worker, never
// the UI thread, and costs ~10 ms when there is nothing to do.
if result.is_ok() {
if let Err(e) = crate::dedup_people::run(conn) {
log::warn!("dedup after the catalog merge: {e}");
}
}
result result
} }
+11
View File
@@ -798,6 +798,17 @@ mod tests {
let tmp = target.with_extension("tmp"); let tmp = target.with_extension("tmp");
fs::write(&tmp, bytes).expect("write"); fs::write(&tmp, bytes).expect("write");
fs::rename(&tmp, &target).expect("rename"); fs::rename(&tmp, &target).expect("rename");
// A scan tells a changed file by its mtime, at whole-second
// resolution; a resave landing in the same second as the scan
// before it looks unchanged, and the test fails when the machine
// is fast enough. Two seconds ahead, on the file and its folder,
// is what a real resave some time later would look like.
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
for p in [target.as_path(), target.parent().expect("parent")] {
fs::File::open(p)
.and_then(|f| f.set_modified(later))
.expect("set mtime");
}
self self
} }
+22 -6
View File
@@ -41,18 +41,34 @@ matters — see [`src/bake.rs`](src/bake.rs) for the argument:
1. **A 3×3 matrix**, linear sRGB to the three layers' exposure. Exact, not an 1. **A 3×3 matrix**, linear sRGB to the three layers' exposure. Exact, not an
approximation: the reconstructed scene spectrum is linear in the sRGB approximation: the reconstructed scene spectrum is linear in the sRGB
triple, so the integral collapses into nine numbers. triple, so the integral collapses into nine numbers.
2. **Three 1D curves**, log exposure to density, sampled at 256 points. 2. **Three 1D curves**, log exposure to density, sampled at 256 points — one
3. **One 32³ lookup**, density to linear sRGB — dye absorption, the print row per development time the datasheet measures. Push picks between the
through the negative, the paper, the viewing illuminant and the chromatic rows, and interpolating them is exact, because density is linear in push
adaptation, all of which take exactly three numbers in. between two measured processes.
3. **One 32³ lookup**, density to linear sRGB — dye absorption, the viewing
illuminant and the chromatic adaptation, all of which take exactly three
numbers in. A printed negative is two: the film's cube ends at the paper's
log exposure through the negative, the enlarger's exposure is added there,
and the paper's own curve row and cube take it to linear sRGB.
Per pixel that is a matrix multiply, three curve taps and one texture fetch. Per pixel that is a matrix multiply, a handful of curve taps and one texture
Splitting 2 from 3, rather than baking one LUT over exposure, is measured fetch — two for a print. Splitting 2 from 3, rather than baking one LUT over exposure, is measured
rather than assumed: the curve carries all the sharp shape and the dye mixing rather than assumed: the curve carries all the sharp shape and the dye mixing
is smooth, so folding the curve into the 3D lookup would need it three times is smooth, so folding the curve into the 3D lookup would need it three times
larger for the same error. At 32³ the worst interpolation error is about 0.003 larger for the same error. At 32³ the worst interpolation error is about 0.003
in linear sRGB, below one 8-bit code value, and there is a test that says so. in linear sRGB, below one 8-bit code value, and there is a test that says so.
**No slider is baked.** Camera exposure is a gain before the matrix, push
chooses between curve rows, print exposure is the addition between the two
cubes, and format sets the grain; each reaches the shader as a uniform that is
linear in what it does. That is what lets a mask layer hold its own film
settings, and a pixel under several layers take the weighted average of them.
Only the enlarger's filtration is solved at bake time, against the
photograph's exposure — an enlarger has one filtration for the whole print —
so the film's Exposure, set on the whole photograph, is the one slider that
rebakes. The stock and its
paper are the photograph's; a layer has no picker.
## Adding a stock ## Adding a stock
If spektrafilm has it, add its name to `STOCKS` in If spektrafilm has it, add its name to `STOCKS` in
+443 -112
View File
@@ -21,14 +21,16 @@
//! curves and dyes, the viewing illuminant, the adaptation — all of it takes //! curves and dyes, the viewing illuminant, the adaptation — all of it takes
//! three numbers in and gives three numbers out. So it bakes into one small //! three numbers in and gives three numbers out. So it bakes into one small
//! 3D lookup, and the per-pixel cost is a matrix multiply, three curve taps //! 3D lookup, and the per-pixel cost is a matrix multiply, three curve taps
//! and one texture fetch. //! and one texture fetch. A print is two: the film's lookup ends at the
//! paper's log exposure, where the enlarger's exposure is an addition, and
//! the paper's curve and lookup take it from there — see [`Paper`].
//! //!
//! Splitting 2 from 3 rather than baking a single LUT over exposure is //! Splitting 2 from 3 rather than baking a single LUT over exposure is
//! deliberate and measured: the curve carries all of the sharp shape and the //! deliberate and measured: the curve carries all of the sharp shape and the
//! dye mixing is smooth, so putting the curve in the 3D LUT would force it //! dye mixing is smooth, so putting the curve in the 3D LUT would force it
//! three times larger for the same error. //! three times larger for the same error.
use crate::profile::Profile; use crate::profile::{Profile, CURVE_SAMPLES};
use crate::spectrum::{illuminant, Spectrum, Viewing}; use crate::spectrum::{illuminant, Spectrum, Viewing};
use crate::tables::{SPECTRUM, SRGB_BASIS}; use crate::tables::{SPECTRUM, SRGB_BASIS};
@@ -47,7 +49,19 @@ pub const MID_GREY: f32 = 0.184;
/// that on: the error is already under what the output can represent. /// that on: the error is already under what the output can represent.
pub const LUT_SIZE: usize = 32; pub const LUT_SIZE: usize = 32;
/// What to develop, and how. /// TRACES: FR-DEV-3f
/// The most development times a stock may measure: one curve row, and one
/// push station, each. Every stock shipped measures five; the ceiling is what
/// the shader's fixed uniform block can hold.
pub const MAX_CURVE_ROWS: usize = 8;
/// What to develop: the materials, and where the enlarger is balanced.
///
/// **Not how far, and not how bright.** Push, print exposure and camera
/// exposure are [`Settings`], evaluated per pixel against these tables, so
/// that a mask layer can hold its own and a pixel under it can take the
/// weighted average of everyone's (FR-DEV-3f). What is left here is what a
/// photograph has one of.
pub struct Recipe<'a> { pub struct Recipe<'a> {
/// The stock the picture was taken on. /// The stock the picture was taken on.
pub film: &'a Profile, pub film: &'a Profile,
@@ -55,17 +69,15 @@ pub struct Recipe<'a> {
/// what a reversal stock wants and what makes a negative come out orange /// what a reversal stock wants and what makes a negative come out orange
/// and inverted — that being what a negative actually looks like. /// and inverted — that being what a negative actually looks like.
pub print: Option<&'a Profile>, pub print: Option<&'a Profile>,
/// Camera exposure, in stops. /// The camera exposure the enlarger is balanced at, in stops. Ignored
pub exposure_ev: f32, /// without a `print`.
/// Enlarger exposure, in stops. Ignored without a `print`.
pub print_exposure_ev: f32,
/// TRACES: FR-DEV-3f
/// Development, in stops of push. Positive develops longer.
/// ///
/// Ignored by a stock measured at one process, of which there are many — /// The *photograph's* exposure, never a region's. An enlarger has one
/// see [`crate::profile::Profile::curves_at_push`], which returns the one /// filtration for the whole print: a negative exposed a stop brighter in
/// measured curve rather than inventing a pushed one. /// one corner prints a stop darker there, and that difference is the
pub push_stops: f32, /// picture — balancing it away per pixel would erase every local exposure
/// change a layer made.
pub exposure_ev: f32,
} }
impl<'a> Recipe<'a> { impl<'a> Recipe<'a> {
@@ -76,12 +88,27 @@ impl<'a> Recipe<'a> {
film, film,
print, print,
exposure_ev: 0.0, exposure_ev: 0.0,
print_exposure_ev: 0.0,
push_stops: 0.0,
} }
} }
} }
/// TRACES: FR-DEV-3f
/// What a pixel is developed with, against a [`Baked`] stock.
///
/// The shader's uniforms, as the CPU sees them: every field is linear in what
/// the tables are indexed by, which is what lets the composer blend several
/// layers' settings into one before the fragment runs.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Settings {
/// Camera exposure, in stops: a gain on the scene.
pub exposure_ev: f32,
/// Development, in stops of push. Positive develops longer. Nothing for a
/// stock measured at one process, of which there are many.
pub push_stops: f32,
/// Enlarger exposure, in stops. Nothing without a print.
pub print_exposure_ev: f32,
}
/// A recipe reduced to three tables. /// A recipe reduced to three tables.
/// ///
/// Plain `f32` with a documented layout, and no notion of a texture: what to /// Plain `f32` with a documented layout, and no notion of a texture: what to
@@ -89,16 +116,31 @@ impl<'a> Recipe<'a> {
/// the whole model be tested on the CPU. /// the whole model be tested on the CPU.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Baked { pub struct Baked {
/// Linear sRGB to the three layers' log₁₀ exposure, before the log — row /// Linear sRGB to the three layers' exposure, before the log — row `l`,
/// `l`, column `c` is layer `l`'s response to sRGB channel `c`. /// column `c` is layer `l`'s response to sRGB channel `c`. At unit gain:
/// [`Settings::exposure_ev`] is applied per pixel.
pub exposure_matrix: [[f32; 3]; 3], pub exposure_matrix: [[f32; 3]; 3],
/// The characteristic curves, `CURVE_SAMPLES` samples per layer, uniform /// The characteristic curves: `curve_rows` rows of `CURVE_SAMPLES`
/// over `[curve_log_min, curve_log_max]`. /// samples, row after row, each uniform over
/// `[curve_log_min, curve_log_max]`.
///
/// Row `r` is the stock as measured at its `r`th development time, which
/// is push [`Self::push_stations`]`[r]`. The rows are the measurements
/// themselves rather than a resampling: between two, density is linear in
/// push (development is interpolated in log time, and push is log time),
/// so interpolating the rows by push reproduces
/// [`Profile::curves_at_push`] exactly. A stock measured at one process
/// has one row.
pub curves: Vec<[f32; 3]>, pub curves: Vec<[f32; 3]>,
pub curve_rows: usize,
/// The push each row was developed to, ascending, one per row.
pub push_stations: Vec<f32>,
pub curve_log_min: f32, pub curve_log_min: f32,
pub curve_log_max: f32, pub curve_log_max: f32,
/// Density to linear sRGB, `LUT_SIZE³` entries uniform over /// Film density to what comes next, `LUT_SIZE³` entries uniform over
/// `[0, density_max]` on each axis. /// `[0, density_max]` on each axis: linear sRGB when the film is viewed
/// directly, and the paper's log₁₀ exposure through it, per layer, when it
/// is printed.
/// ///
/// **The red axis varies fastest**, then green, then blue — that is, /// **The red axis varies fastest**, then green, then blue — that is,
/// `lut[(b * size + g) * size + r]`. Stated because it is not the order /// `lut[(b * size + g) * size + r]`. Stated because it is not the order
@@ -108,60 +150,142 @@ pub struct Baked {
/// picture with red and blue transposed, which looks like a plausible /// picture with red and blue transposed, which looks like a plausible
/// photograph of the wrong colour. /// photograph of the wrong colour.
pub lut: Vec<[f32; 3]>, pub lut: Vec<[f32; 3]>,
/// The paper, when there is one. See [`Paper`].
pub paper: Option<Paper>,
/// The deepest density any row develops to, so one lookup covers every
/// push.
pub density_max: f32, pub density_max: f32,
pub lut_size: usize, pub lut_size: usize,
} }
/// TRACES: FR-DEV-3f
/// The print half of a baked stock: enlarger to paper to viewing.
///
/// Split from the film's lookup at the paper's log exposure, for the reason
/// the film is split from its own curve. The enlarger's exposure is a shift
/// *in that log exposure*, the same stops on all three layers, so a print
/// exposure is an addition between the two lookups — exact at any value and
/// free per pixel. Baking it into one lookup instead needs a slice per
/// setting, and interpolating between slices misses by several code values,
/// because the paper's curve is the sharpest thing in the print.
#[derive(Debug, Clone)]
pub struct Paper {
/// The enlarger's filtration, per layer, in log₁₀ exposure: what makes a
/// mid-grey scene print neutral at the photograph's exposure. See
/// [`Recipe::exposure_ev`].
pub balance: [f32; 3],
/// The paper's characteristic curves, `CURVE_SAMPLES` samples uniform
/// over `[log_min, log_max]`.
pub curves: Vec<[f32; 3]>,
pub log_min: f32,
pub log_max: f32,
/// Paper density to linear sRGB, laid out as [`Baked::lut`] is, uniform
/// over `[0, density_max]`.
pub lut: Vec<[f32; 3]>,
pub density_max: f32,
}
/// Where `push` falls among the rows: the lower row and the fraction toward
/// the next. Clamped at both ends, as `curves_at_push` clamps to the first and
/// last measured process.
fn push_row(stations: &[f32], push: f32) -> (usize, f32) {
if stations.len() < 2 {
return (0, 0.0);
}
let last = stations.len() - 1;
let hi = stations
.iter()
.position(|p| *p >= push)
.unwrap_or(last)
.max(1);
let lo = hi - 1;
let f = (push - stations[lo]) / (stations[hi] - stations[lo]).max(1e-6);
(lo, f.clamp(0.0, 1.0))
}
impl Baked { impl Baked {
/// Look a colour up the way the shader will, for tests and for previews. /// Look a colour up the way the shader will, at the stock's own settings.
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
self.apply_at(rgb, &Settings::default())
}
/// Look a colour up the way the shader will, for tests and for previews.
pub fn apply_at(&self, rgb: [f32; 3], settings: &Settings) -> [f32; 3] {
let gain = 2f32.powf(settings.exposure_ev);
let mut log_exposure = [0.0f32; 3]; let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() { for (l, slot) in log_exposure.iter_mut().enumerate() {
let m = self.exposure_matrix[l]; let m = self.exposure_matrix[l];
let e = m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]; let e = gain * (m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]);
*slot = (e.max(0.0) + 1e-10).log10(); *slot = (e.max(0.0) + 1e-10).log10();
} }
self.sample_lut(self.sample_curves(log_exposure)) let density = self.sample_curves(log_exposure, settings.push_stops);
let through = sample_cube(&self.lut, self.lut_size, density, self.density_max);
let Some(paper) = &self.paper else {
return through;
};
let shift = settings.print_exposure_ev * 2f32.log10();
let paper_log = [0, 1, 2].map(|l| through[l] + paper.balance[l] + shift);
let paper_density = sample_curve(&paper.curves, paper.log_min, paper.log_max, paper_log);
sample_cube(&paper.lut, self.lut_size, paper_density, paper.density_max)
} }
fn sample_curves(&self, log_exposure: [f32; 3]) -> [f32; 3] { fn sample_curves(&self, log_exposure: [f32; 3], push_stops: f32) -> [f32; 3] {
let last = self.curves.len() - 1; let (row, g) = push_row(&self.push_stations, push_stops);
let span = self.curve_log_max - self.curve_log_min; let lo = self.sample_curve_row(log_exposure, row);
let mut out = [0.0f32; 3]; if self.curve_rows < 2 {
for (c, slot) in out.iter_mut().enumerate() { return lo;
let t = ((log_exposure[c] - self.curve_log_min) / span).clamp(0.0, 1.0) * last as f32;
let i = (t.floor() as usize).min(last - 1);
let f = t - i as f32;
*slot = self.curves[i][c] * (1.0 - f) + self.curves[i + 1][c] * f;
} }
out let hi = self.sample_curve_row(log_exposure, row + 1);
[0, 1, 2].map(|c| lo[c] * (1.0 - g) + hi[c] * g)
} }
fn sample_lut(&self, density: [f32; 3]) -> [f32; 3] { fn sample_curve_row(&self, log_exposure: [f32; 3], row: usize) -> [f32; 3] {
let n = self.lut_size; let samples = self.curves.len() / self.curve_rows;
let mut base = [0usize; 3]; let curve = &self.curves[row * samples..(row + 1) * samples];
let mut frac = [0f32; 3]; sample_curve(curve, self.curve_log_min, self.curve_log_max, log_exposure)
for c in 0..3 { }
let t = (density[c] / self.density_max).clamp(0.0, 1.0) * (n - 1) as f32; }
base[c] = (t.floor() as usize).min(n - 2);
frac[c] = t - base[c] as f32; /// Three curves sampled uniformly over `[log_min, log_max]`, read at a log
} /// exposure per layer. Clamped at both ends, as the shader's is.
let mut out = [0.0f32; 3]; fn sample_curve(curve: &[[f32; 3]], log_min: f32, log_max: f32, at: [f32; 3]) -> [f32; 3] {
for dx in 0..2 { let last = curve.len() - 1;
for dy in 0..2 { let span = log_max - log_min;
for dz in 0..2 { let mut out = [0.0f32; 3];
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] } for (c, slot) in out.iter_mut().enumerate() {
* if dy == 0 { 1.0 - frac[1] } else { frac[1] } let t = ((at[c] - log_min) / span).clamp(0.0, 1.0) * last as f32;
* if dz == 0 { 1.0 - frac[2] } else { frac[2] }; let i = (t.floor() as usize).min(last - 1);
let e = self.lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx]; let f = t - i as f32;
for c in 0..3 { *slot = curve[i][c] * (1.0 - f) + curve[i + 1][c] * f;
out[c] += w * e[c]; }
} out
}
/// A cube of `n³` triples over `[0, max]` per axis, red fastest, read
/// trilinearly.
fn sample_cube(lut: &[[f32; 3]], n: usize, density: [f32; 3], max: f32) -> [f32; 3] {
let mut base = [0usize; 3];
let mut frac = [0f32; 3];
for c in 0..3 {
let t = (density[c] / max).clamp(0.0, 1.0) * (n - 1) as f32;
base[c] = (t.floor() as usize).min(n - 2);
frac[c] = t - base[c] as f32;
}
let mut out = [0.0f32; 3];
for dx in 0..2 {
for dy in 0..2 {
for dz in 0..2 {
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] }
* if dy == 0 { 1.0 - frac[1] } else { frac[1] }
* if dz == 0 { 1.0 - frac[2] } else { frac[2] };
let e = lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx];
for c in 0..3 {
out[c] += w * e[c];
} }
} }
} }
out
} }
out
} }
/// Linear sRGB to the three layers' exposure, mid-grey normalised. /// Linear sRGB to the three layers' exposure, mid-grey normalised.
@@ -204,12 +328,7 @@ pub fn exposure_matrix(film: &Profile) -> [[f32; 3]; 3] {
/// goes: the mask is a fixed density, so balancing mid-grey to neutral cancels /// goes: the mask is a fixed density, so balancing mid-grey to neutral cancels
/// it — which is why a printed negative looks like a photograph while a scanned /// it — which is why a printed negative looks like a photograph while a scanned
/// one looks orange. /// one looks orange.
fn print_balance( pub fn print_balance(film: &Profile, paper: &Profile, exposure_ev: f32) -> [f32; 3] {
film: &Profile,
paper: &Profile,
exposure_ev: f32,
print_exposure_ev: f32,
) -> [f32; 3] {
let matrix = exposure_matrix(film); let matrix = exposure_matrix(film);
let scene = MID_GREY * 2f32.powf(exposure_ev); let scene = MID_GREY * 2f32.powf(exposure_ev);
let mut log_exposure = [0.0f32; 3]; let mut log_exposure = [0.0f32; 3];
@@ -227,7 +346,7 @@ fn print_balance(
let mut offsets = [0.0f32; 3]; let mut offsets = [0.0f32; 3];
for (l, slot) in offsets.iter_mut().enumerate() { for (l, slot) in offsets.iter_mut().enumerate() {
*slot = target - (mid_raw[l] + 1e-10).log10() + print_exposure_ev * 2f32.log10(); *slot = target - (mid_raw[l] + 1e-10).log10();
} }
offsets offsets
} }
@@ -257,77 +376,117 @@ fn paper_exposure(film: &Profile, paper: &Profile, density: [f32; 3]) -> [f32; 3
/// Bake a recipe into the tables a shader runs. /// Bake a recipe into the tables a shader runs.
pub fn bake(recipe: &Recipe) -> Baked { pub fn bake(recipe: &Recipe) -> Baked {
let film = recipe.film; let film = recipe.film;
let mut matrix = exposure_matrix(film); // At unit gain. Camera exposure is a scalar on a linear quantity, so the
// Camera exposure rides in the matrix rather than in the shader: it is a // shader applies it for the price of one multiply — and has to, since a
// scalar on a linear quantity, and folding it in here costs nothing and // layer may hold its own.
// keeps the per-pixel work identical whether or not it has been moved. let matrix = exposure_matrix(film);
let gain = 2f32.powf(recipe.exposure_ev);
for row in &mut matrix {
for v in row.iter_mut() {
*v *= gain;
}
}
// TRACES: FR-DEV-3f // TRACES: FR-DEV-3f
// Developed to the requested push before anything else reads the curves: // Every measured process, not the one the slider is at: the shader
// the density ceiling, the print balance and the grain all depend on how // interpolates between rows per pixel, so a layer can push a region.
// far this film was taken, and a push that only reached one of them would // Resampled to one length because the rows share a texture.
// be a contrast change wearing a push's name. let measured = film.development_curves.len() >= 2
let curves = film.curves_at_push(recipe.push_stops); && film.development_times.len() == film.development_curves.len();
let density_max = curves let (curves, push_stations): (Vec<[f32; 3]>, Vec<f32>) = if measured {
.iter() let rows = film.development_curves.len().min(MAX_CURVE_ROWS);
.flat_map(|row| row.iter()) (
.fold(0.0f32, |a, &b| a.max(b)) film.development_curves[..rows]
.max(1e-3); .iter()
.flat_map(|c| resample(c))
let viewing = match recipe.print { .collect(),
Some(paper) => Viewing::new(&paper.viewing_illuminant), film.development_times[..rows]
None => Viewing::new(&film.viewing_illuminant), .iter()
.map(|t| 2.0 * (t / film.development_normal).log2())
.collect(),
)
} else {
(resample(&film.density_curves), vec![0.0])
}; };
let balance = recipe let curve_rows = push_stations.len();
.print // The ceiling of the deepest row, so one lookup covers every push.
.map(|paper| print_balance(film, paper, recipe.exposure_ev, recipe.print_exposure_ev)); let density_max = ceiling(&curves);
let n = LUT_SIZE; let n = LUT_SIZE;
let mut lut = Vec::with_capacity(n * n * n);
// Blue outermost and red innermost, so the red axis varies fastest. See // Blue outermost and red innermost, so the red axis varies fastest. See
// `Baked::lut`: this is the layout a 3D texture upload wants, and getting // `Baked::lut`: this is the layout a 3D texture upload wants, and getting
// it backwards transposes red and blue in the finished picture. // it backwards transposes red and blue in the finished picture.
for b in 0..n { let cube = |max: f32, f: &dyn Fn([f32; 3]) -> [f32; 3]| {
for g in 0..n { let mut out = Vec::with_capacity(n * n * n);
for r in 0..n { for b in 0..n {
let density = [ for g in 0..n {
density_max * r as f32 / (n - 1) as f32, for r in 0..n {
density_max * g as f32 / (n - 1) as f32, let step = max / (n - 1) as f32;
density_max * b as f32 / (n - 1) as f32, out.push(f([r as f32 * step, g as f32 * step, b as f32 * step]));
]; }
lut.push(match recipe.print.zip(balance) {
Some((paper, offsets)) => {
let raw = paper_exposure(film, paper, density);
let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() {
*slot = (raw[l] + 1e-10).log10() + offsets[l];
}
let paper_density = paper.density_at(log_exposure);
viewing.to_srgb(&paper.transmittance(paper_density))
}
None => viewing.to_srgb(&film.transmittance(density)),
});
} }
} }
} out
};
let (lut, paper) = match recipe.print {
None => {
let viewing = Viewing::new(&film.viewing_illuminant);
(
cube(density_max, &|d| viewing.to_srgb(&film.transmittance(d))),
None,
)
}
Some(paper) => {
let viewing = Viewing::new(&paper.viewing_illuminant);
let curves = resample(&paper.density_curves);
let paper_max = ceiling(&curves);
let lut = cube(density_max, &|d| {
paper_exposure(film, paper, d).map(|raw| (raw + 1e-10).log10())
});
let paper = Paper {
balance: print_balance(film, paper, recipe.exposure_ev),
log_min: paper.log_exposure_min,
log_max: paper.log_exposure_max,
lut: cube(paper_max, &|d| viewing.to_srgb(&paper.transmittance(d))),
density_max: paper_max,
curves,
};
(lut, Some(paper))
}
};
Baked { Baked {
exposure_matrix: matrix, exposure_matrix: matrix,
curves, curves,
curve_rows,
push_stations,
curve_log_min: film.log_exposure_min, curve_log_min: film.log_exposure_min,
curve_log_max: film.log_exposure_max, curve_log_max: film.log_exposure_max,
lut, lut,
paper,
density_max, density_max,
lut_size: n, lut_size: n,
} }
} }
/// A curve at `CURVE_SAMPLES`, uniform over the same domain it came in on.
fn resample(curve: &[[f32; 3]]) -> Vec<[f32; 3]> {
if curve.len() == CURVE_SAMPLES {
return curve.to_vec();
}
(0..CURVE_SAMPLES)
.map(|i| {
let at = i as f32 / (CURVE_SAMPLES - 1) as f32;
sample_curve(curve, 0.0, 1.0, [at; 3])
})
.collect()
}
/// The deepest density in a set of curves, floored so a lookup over it has
/// a width.
fn ceiling(curves: &[[f32; 3]]) -> f32 {
curves
.iter()
.flat_map(|row| row.iter())
.fold(0.0f32, |a, &b| a.max(b))
.max(1e-3)
}
fn mean(s: &Spectrum) -> f32 { fn mean(s: &Spectrum) -> f32 {
s.iter().sum::<f32>() / SPECTRUM as f32 s.iter().sum::<f32>() / SPECTRUM as f32
} }
@@ -467,6 +626,10 @@ mod tests {
#[test] #[test]
fn exposure_moves_the_print_the_way_it_moves_a_photograph() { fn exposure_moves_the_print_the_way_it_moves_a_photograph() {
// The photograph's exposure: the enlarger balanced at it, and the
// scene brighter by it. Mid-grey stays where the balance puts it —
// that is what the balance is for — so what a stop more does to a
// print is lift everything either side of it along the paper's curve.
let film = portra(); let film = portra();
let paper = endura(); let paper = endura();
let brighter = bake(&Recipe { let brighter = bake(&Recipe {
@@ -474,7 +637,155 @@ mod tests {
..Recipe::new(&film, Some(&paper)) ..Recipe::new(&film, Some(&paper))
}); });
let base = bake(&Recipe::new(&film, Some(&paper))); let base = bake(&Recipe::new(&film, Some(&paper)));
assert!(brighter.apply([MID_GREY; 3])[1] > base.apply([MID_GREY; 3])[1]); let one_stop = Settings {
exposure_ev: 1.0,
..Settings::default()
};
for v in [0.02f32, 0.6] {
assert!(
brighter.apply_at([v; 3], &one_stop)[1] > base.apply([v; 3])[1],
"{v} did not print brighter a stop up"
);
}
let (a, b) = (
brighter.apply_at([MID_GREY; 3], &one_stop)[1],
base.apply([MID_GREY; 3])[1],
);
assert!(
(a - b).abs() < 1.0 / 255.0,
"the balance let mid-grey move: {a} vs {b}"
);
}
#[test]
fn a_region_exposed_brighter_prints_brighter_than_the_enlarger_expects() {
// TRACES: FR-DEV-3f
// A layer's exposure is the scene's, not the enlarger's: the balance
// stays where the photograph put it, so the region prints lighter by
// more than the whole photograph would, which is what dodging at the
// camera is.
let film = portra();
let paper = endura();
let base = bake(&Recipe::new(&film, Some(&paper)));
let rebalanced = bake(&Recipe {
exposure_ev: 1.0,
..Recipe::new(&film, Some(&paper))
});
let one_stop = Settings {
exposure_ev: 1.0,
..Settings::default()
};
let local = base.apply_at([MID_GREY; 3], &one_stop)[1];
let global = rebalanced.apply_at([MID_GREY; 3], &one_stop)[1];
assert!(local > base.apply([MID_GREY; 3])[1], "not brighter at all");
assert!(
local > global,
"a region was rebalanced as though it were the whole print: {local} vs {global}"
);
}
#[test]
fn more_light_through_the_enlarger_darkens_the_print() {
// TRACES: FR-DEV-3f
// Paper is negative-working. Opening the enlarger a stop is burning
// in, and a slider that brightened would be the wrong way round for
// anyone who has printed.
let film = portra();
let paper = endura();
let baked = bake(&Recipe::new(&film, Some(&paper)));
let at = |stops: f32| {
baked.apply_at(
[MID_GREY; 3],
&Settings {
print_exposure_ev: stops,
..Settings::default()
},
)[1]
};
assert!(at(1.0) < at(0.0) && at(0.0) < at(-1.0));
}
#[test]
fn a_push_on_a_row_is_the_measured_curve() {
// TRACES: FR-DEV-3f
// The rows are the measured processes, so at a row the table must be
// that curve exactly, and between rows — density being linear in push
// there — it must be `curves_at_push` to rounding.
let film = profile(include_str!("../profiles/kodak_doublex.yaml"));
let baked = bake(&Recipe::new(&film, None));
assert_eq!(
baked.curve_rows, 5,
"Double-X measures five development times"
);
let span = film.log_exposure_max - film.log_exposure_min;
let mut worst = 0.0f32;
let stations = baked.push_stations.clone();
let mut pushes: Vec<(f32, bool)> = stations.iter().map(|p| (*p, true)).collect();
for k in 0..=16 {
pushes.push((-1.0 + 4.0 * k as f32 / 16.0, false));
}
for (push, on_row) in pushes {
let exact = film.curves_at_push(push);
for i in (0..exact.len()).step_by(7) {
let log = film.log_exposure_min + span * i as f32 / (exact.len() - 1) as f32;
let got = baked.sample_curves([log; 3], push);
for c in 0..3 {
let err = (got[c] - exact[i][c]).abs();
if on_row {
assert!(err < 1e-4, "push {push} is a row but misses it by {err}");
}
worst = worst.max(err);
}
}
}
assert!(worst < 1e-3, "between rows the density is off by {worst}");
}
#[test]
fn a_print_exposure_is_exact_at_any_setting() {
// TRACES: FR-DEV-3f
// The enlarger's exposure is added between the two lookups rather than
// baked into either, so no setting is nearer the tables than another.
// Compared against the chain evaluated spectrally, end to end, at
// settings chosen off every half and whole stop.
let film = portra();
let paper = endura();
let baked = bake(&Recipe::new(&film, Some(&paper)));
let offsets = print_balance(&film, &paper, 0.0);
let viewing = Viewing::new(&paper.viewing_illuminant);
let mut worst = 0.0f32;
for stops in [-2.3f32, -0.6, 0.0, 0.35, 1.7] {
for i in 0..14 {
let v = 0.004 * 2f32.powf(i as f32 * 0.6);
let rgb = [v, v * 0.8, v * 1.1];
let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() {
let m = baked.exposure_matrix[l];
*slot =
((m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]).max(0.0) + 1e-10).log10();
}
let raw = paper_exposure(&film, &paper, film.density_at(log_exposure));
let paper_log =
[0, 1, 2].map(|l| (raw[l] + 1e-10).log10() + offsets[l] + stops * 2f32.log10());
let exact = viewing.to_srgb(&paper.transmittance(paper.density_at(paper_log)));
let approx = baked.apply_at(
rgb,
&Settings {
print_exposure_ev: stops,
..Settings::default()
},
);
for c in 0..3 {
worst = worst.max((exact[c] - approx[c]).abs());
}
}
}
assert!(
worst < 1.0 / 255.0,
"the print misses the spectral chain by {worst}"
);
} }
#[test] #[test]
@@ -550,5 +861,25 @@ mod tests {
let baked = bake(&Recipe::new(&film, None)); let baked = bake(&Recipe::new(&film, None));
assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE); assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE);
assert_eq!(baked.curves.len(), CURVE_SAMPLES); assert_eq!(baked.curves.len(), CURVE_SAMPLES);
assert_eq!(baked.curve_rows, 1);
assert!(baked.paper.is_none());
// A print has a second lookup and a curve of its own; a development
// series a row per push. Neither is inferred from the other.
let negative = portra();
let paper = endura();
let printed = bake(&Recipe::new(&negative, Some(&paper)));
let print = printed
.paper
.as_ref()
.expect("a printed negative has a paper");
assert_eq!(print.lut.len(), LUT_SIZE.pow(3));
assert_eq!(print.curves.len(), CURVE_SAMPLES);
let pushable = profile(include_str!("../profiles/kodak_doublex.yaml"));
let rows = bake(&Recipe::new(&pushable, None));
assert_eq!(rows.curve_rows, pushable.development_times.len());
assert_eq!(rows.push_stations.len(), rows.curve_rows);
assert_eq!(rows.curves.len(), rows.curve_rows * CURVE_SAMPLES);
} }
} }
+4 -1
View File
@@ -680,15 +680,18 @@ fn film_tables() -> FilmTables {
FilmTables { FilmTables {
exposure_matrix: baked.exposure_matrix, exposure_matrix: baked.exposure_matrix,
curves: baked.curves.clone(), curves: baked.curves.clone(),
push_stations: baked.push_stations.clone(),
curve_log_min: baked.curve_log_min, curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max, curve_log_max: baked.curve_log_max,
lut: baked.lut.clone(), lut: baked.lut.clone(),
density_max: baked.density_max, density_max: baked.density_max,
lut_size: baked.lut_size, lut_size: baked.lut_size,
// Viewed directly: `STOCK` is baked without a paper above.
paper: None,
// Grain off. It is a per-pixel hash and would be measured; it is also // Grain off. It is a per-pixel hash and would be measured; it is also
// not part of every edit, and the chain being measured here is "every // not part of every edit, and the chain being measured here is "every
// operation active", not "every option of every operation". // operation active", not "every option of every operation".
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; dr_pipeline::ops::film_sim::FORMAT_COUNT],
grain_density_max: [baked.density_max; 3], grain_density_max: [baked.density_max; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
} }
+24 -4
View File
@@ -382,9 +382,23 @@ fn film_key(t: &dr_pipeline::ops::FilmTables) -> u64 {
t.curve_log_max, t.curve_log_max,
t.density_max, t.density_max,
t.lut_size as f32, t.lut_size as f32,
t.curves.len() as f32,
t.lut.len() as f32,
] { ] {
mix(v.to_bits()); mix(v.to_bits());
} }
for v in &t.push_stations {
mix(v.to_bits());
}
// The paper's balance is left out on purpose: it reaches the shader as
// uniforms, not texels, and it is what moves when the photograph's
// exposure does — keying on it would re-upload a megabyte per tick of a
// slider that changes three floats.
if let Some(p) = &t.paper {
for v in [p.log_min, p.log_max, p.density_max] {
mix(v.to_bits());
}
}
for e in t.lut.iter().step_by(8).chain(t.curves.iter().step_by(8)) { for e in t.lut.iter().step_by(8).chain(t.curves.iter().step_by(8)) {
mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22)); mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22));
} }
@@ -431,12 +445,16 @@ impl AdjustPass {
return; return;
} }
// One row per curve — the film's at each measured push, then the
// paper's — and one cube per stage stacked in depth. The shader reads
// the layout from `FilmTables`' uniforms, not from these sizes.
let samples = dr_pipeline::ops::film_sim::CURVE_SAMPLES as u32;
let curves = self.upload_film( let curves = self.upload_film(
"adjust-film-curves", "adjust-film-curves",
wgpu::TextureDimension::D2, wgpu::TextureDimension::D2,
wgpu::Extent3d { wgpu::Extent3d {
width: t.curves.len() as u32, width: samples,
height: 1, height: t.curves.len() as u32 / samples,
depth_or_array_layers: 1, depth_or_array_layers: 1,
}, },
&to_rgba(&t.curves), &to_rgba(&t.curves),
@@ -448,7 +466,7 @@ impl AdjustPass {
wgpu::Extent3d { wgpu::Extent3d {
width: n, width: n,
height: n, height: n,
depth_or_array_layers: n, depth_or_array_layers: t.lut.len() as u32 / (n * n),
}, },
&to_rgba(&t.lut), &to_rgba(&t.lut),
); );
@@ -2303,7 +2321,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; N * N * N], lut: vec![[0.5, 0.5, 0.5]; N * N * N],
density_max: 3.0, density_max: 3.0,
lut_size: N, lut_size: N,
grain_particles: [0.0; 3], push_stations: vec![0.0],
paper: None,
grain_particles: [[0.0; 3]; dr_pipeline::ops::film_sim::FORMAT_COUNT],
grain_density_max: [3.0; 3], grain_density_max: [3.0; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
} }
+255 -1
View File
@@ -57,6 +57,32 @@ struct XTransParams {
tile: [u32; 4], tile: [u32; 4],
} }
/// Uniform block for the hot-pixel repair. Layout must match
/// `hot_pixels.wgsl`.
///
/// One block for both colour filter arrays: the repair asks only "which
/// photosites share this one's colour", and a 6×6 tile answers that for a
/// Bayer cell as well as for X-Trans.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct HotPixelParams {
crop_x: u32,
crop_y: u32,
width: u32,
height: u32,
stride: u32,
words: u32,
row_invocations: u32,
samples: u32,
black: [f32; 4],
inv_range: [f32; 4],
tile: [u32; 4],
}
/// The repair's workgroup width. Must match `@workgroup_size` in
/// `hot_pixels.wgsl`.
const HOT_PIXEL_GROUP: u32 = 64;
/// A demosaiced image living on the GPU. /// A demosaiced image living on the GPU.
/// ///
/// RGBA16Float, scene-referred, camera colour space. This is the input every /// RGBA16Float, scene-referred, camera colour space. This is the input every
@@ -437,6 +463,8 @@ pub struct Demosaicer {
pipeline: wgpu::ComputePipeline, pipeline: wgpu::ComputePipeline,
xtrans_pipeline: wgpu::ComputePipeline, xtrans_pipeline: wgpu::ComputePipeline,
bind_group_layout: wgpu::BindGroupLayout, bind_group_layout: wgpu::BindGroupLayout,
hot_pixel_pipeline: wgpu::ComputePipeline,
hot_pixel_layout: wgpu::BindGroupLayout,
} }
impl Demosaicer { impl Demosaicer {
@@ -527,11 +555,15 @@ impl Demosaicer {
cache: None, cache: None,
}); });
let (hot_pixel_pipeline, hot_pixel_layout) = hot_pixel_pipeline(ctx);
Ok(Self { Ok(Self {
ctx: ctx.clone(), ctx: ctx.clone(),
pipeline, pipeline,
xtrans_pipeline, xtrans_pipeline,
bind_group_layout, bind_group_layout,
hot_pixel_pipeline,
hot_pixel_layout,
}) })
} }
@@ -558,8 +590,12 @@ impl Demosaicer {
// the buffer outlive the `if` that chose them. // the buffer outlive the `if` that chose them.
let bayer_params; let bayer_params;
let xtrans_params; let xtrans_params;
// Kept for the hot-pixel repair: finding the X-Trans phase reads the
// whole frame on the CPU, and once per photograph is enough.
let mut xtrans_tile = None;
let (pipeline, params_bytes) = if raw.cfa_pattern.is_xtrans() { let (pipeline, params_bytes) = if raw.cfa_pattern.is_xtrans() {
xtrans_params = xtrans_params_for(raw, width, height); xtrans_params = xtrans_params_for(raw, width, height);
xtrans_tile = Some(xtrans_params.tile);
(&self.xtrans_pipeline, bytemuck::bytes_of(&xtrans_params)) (&self.xtrans_pipeline, bytemuck::bytes_of(&xtrans_params))
} else { } else {
let pattern = match raw.cfa_pattern { let pattern = match raw.cfa_pattern {
@@ -598,6 +634,64 @@ impl Demosaicer {
usage: wgpu::BufferUsages::STORAGE, usage: wgpu::BufferUsages::STORAGE,
}); });
// TRACES: FR-RAW-3
// The mosaic the demosaic actually reads: the readout with its hot and
// dead photosites repaired. A second buffer rather than in place,
// because every photosite's verdict reads its neighbours' originals.
let repaired = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("raw-repaired"),
size: raw_buf.size(),
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
});
let words = packed.len() as u32;
let groups = words.div_ceil(HOT_PIXEL_GROUP).max(1);
// A 24 MP readout is 190,000 workgroups, past the 65,535 one
// dispatch dimension may hold, so the grid folds into rows.
let groups_x = groups.min(
self.ctx
.device
.limits()
.max_compute_workgroups_per_dimension,
);
let groups_y = groups.div_ceil(groups_x);
let hot_params = hot_pixel_params(
raw,
(width, height),
words,
groups_x * HOT_PIXEL_GROUP,
xtrans_tile,
);
let hot_params_buf =
self.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hot-pixel-params"),
contents: bytemuck::bytes_of(&hot_params),
usage: wgpu::BufferUsages::UNIFORM,
});
let hot_bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("hot-pixel-bg"),
layout: &self.hot_pixel_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: raw_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: hot_params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: repaired.as_entire_binding(),
},
],
});
let params_buf = self let params_buf = self
.ctx .ctx
.device .device
@@ -636,7 +730,7 @@ impl Demosaicer {
entries: &[ entries: &[
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 0, binding: 0,
resource: raw_buf.as_entire_binding(), resource: repaired.as_entire_binding(),
}, },
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 1, binding: 1,
@@ -655,6 +749,18 @@ impl Demosaicer {
.create_command_encoder(&wgpu::CommandEncoderDescriptor { .create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("demosaic-encoder"), label: Some("demosaic-encoder"),
}); });
// Two passes in one submission. wgpu orders a storage write in one
// pass before a read of the same buffer in the next, so the demosaic
// sees every repair.
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("hot-pixel-pass"),
timestamp_writes: None,
});
pass.set_pipeline(&self.hot_pixel_pipeline);
pass.set_bind_group(0, &hot_bind_group, &[]);
pass.dispatch_workgroups(groups_x, groups_y, 1);
}
{ {
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("demosaic-pass"), label: Some("demosaic-pass"),
@@ -960,6 +1066,136 @@ fn detect_xtrans_phase(raw: &RawImage) -> (u32, u32) {
/// TRACES: FR-RAW-5 /// TRACES: FR-RAW-5
/// Everything the X-Trans shader needs about one image. /// Everything the X-Trans shader needs about one image.
/// TRACES: FR-RAW-3
/// The hot-pixel repair's pipeline and its three bindings: the readout, the
/// uniform block, and the repaired copy it writes.
fn hot_pixel_pipeline(ctx: &GpuContext) -> (wgpu::ComputePipeline, wgpu::BindGroupLayout) {
let shader = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("hot-pixels"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/hot_pixels.wgsl").into()),
});
let storage = |binding, read_only| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let layout = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("hot-pixel-bgl"),
entries: &[
storage(0, true),
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
storage(2, false),
],
});
let pipeline_layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("hot-pixel-layout"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let pipeline = ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("hot-pixel-pipeline"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
(pipeline, layout)
}
/// The colour of each position of a Bayer cell, row-major, for the pattern
/// the decoder reported: 0=R, 1=G, 2=B. `None` for anything that is not a
/// 2×2 pattern.
fn bayer_cell(pattern: CfaPattern) -> Option<[u32; 4]> {
match pattern {
CfaPattern::Rggb => Some([0, 1, 1, 2]),
CfaPattern::Bggr => Some([2, 1, 1, 0]),
CfaPattern::Grbg => Some([1, 0, 2, 1]),
CfaPattern::Gbrg => Some([1, 2, 0, 1]),
_ => None,
}
}
/// A Bayer cell as the 6×6 sensor-anchored tile the repair indexes.
///
/// The decoder's pattern is phased for the *crop* origin, and the tile is
/// indexed by sensor coordinate, so each position is shifted by the crop.
/// Six is even, so a column's parity modulo 6 is its parity outright and the
/// cell repeats cleanly.
fn pack_bayer_tile(cell: [u32; 4], crop_x: u32, crop_y: u32) -> [u32; 4] {
let mut out = [0u32; 4];
for row in 0..6u32 {
for col in 0..6u32 {
let i = (((row + crop_y) & 1) * 2 + ((col + crop_x) & 1)) as usize;
out[(row >> 1) as usize] |= cell[i] << ((row & 1) * 12 + col * 2);
}
}
out
}
/// The repair's uniforms for one readout.
///
/// `xtrans_tile` is the tile the X-Trans demosaic was given, when it was one;
/// anything else must be a Bayer pattern, which `run` has already checked.
fn hot_pixel_params(
raw: &RawImage,
(width, height): (u32, u32),
words: u32,
row_invocations: u32,
xtrans_tile: Option<[u32; 4]>,
) -> HotPixelParams {
let (black, inv_range, tile) = match (xtrans_tile, bayer_cell(raw.cfa_pattern)) {
(Some(tile), _) => {
let (black, inv_range) = xtrans_levels(raw);
([black; 4], [inv_range; 4], tile)
}
(None, Some(cell)) => (
black_per_cell(raw),
inv_range_per_cell(raw),
pack_bayer_tile(cell, raw.crop.x, raw.crop.y),
),
// Not reached from `run`, which refuses any other pattern before
// this. A zero tile judges every photosite against all of its
// neighbours, which is right for a sensor with no colour filter.
(None, None) => (black_per_cell(raw), inv_range_per_cell(raw), [0; 4]),
};
HotPixelParams {
crop_x: raw.crop.x,
crop_y: raw.crop.y,
width,
height,
stride: raw.width,
words,
row_invocations,
samples: raw.data.len() as u32,
black,
inv_range,
tile,
}
}
fn xtrans_params_for(raw: &RawImage, width: u32, height: u32) -> XTransParams { fn xtrans_params_for(raw: &RawImage, width: u32, height: u32) -> XTransParams {
let (black, inv_range) = xtrans_levels(raw); let (black, inv_range) = xtrans_levels(raw);
let wb = wb_gains(raw); let wb = wb_gains(raw);
@@ -994,6 +1230,24 @@ mod tests {
} }
} }
/// The repair's tile is indexed by sensor coordinate, the decoder's
/// pattern by crop coordinate. A crop at an odd origin must shift one
/// into the other, or the repair compares red with green.
#[test]
fn the_bayer_tile_is_anchored_to_the_sensor_not_the_crop() {
let cell = bayer_cell(CfaPattern::Rggb).unwrap();
let colour = |tile: [u32; 4], x: u32, y: u32| {
(tile[((y % 6) >> 1) as usize] >> (((y % 6) & 1) * 12 + (x % 6) * 2)) & 3
};
for (cx, cy) in [(0, 0), (1, 0), (0, 1), (1, 1), (7, 4)] {
let tile = pack_bayer_tile(cell, cx, cy);
// Red is the crop's first photosite, wherever the crop starts.
assert_eq!(colour(tile, cx, cy), 0, "crop at ({cx}, {cy})");
assert_eq!(colour(tile, cx + 1, cy + 1), 2, "crop at ({cx}, {cy})");
assert_eq!(colour(tile, cx + 1, cy), 1, "crop at ({cx}, {cy})");
}
}
#[test] #[test]
fn unclamped_half_keeps_shadows_signs_and_highlights() { fn unclamped_half_keeps_shadows_signs_and_highlights() {
// A 14-bit LSB, normalised: subnormal in f16, and must not be zero. // A 14-bit LSB, normalised: subnormal in f16, and must not be zero.
+185
View File
@@ -0,0 +1,185 @@
// Hot and dead photosite repair, on the raw mosaic, before demosaic.
//
// A hot photosite reads far above anything the light put there — a leaky
// well, lit by its own dark current on a long or high-ISO exposure. Left in,
// the demosaic spreads it into its neighbours' interpolated channels and it
// becomes a coloured cross, three pixels wide, that no later stage can take
// back out: by then it is five pixels of plausible colour rather than one
// photosite of nonsense. So it is repaired here, where it is still one value.
//
// **What counts as hot.** A photosite far above *every* photosite of its own
// colour in its 5x5 window, and also far above every one of its eight
// immediate neighbours whatever their colour. The second half is what keeps a
// star or a glint: real light arrives through a lens and an anti-aliasing
// filter, so even the sharpest point lands on a patch of photosites, and the
// ones beside it are lit too. A hot photosite's neighbours are as dark as the
// rest of the frame. Dead photosites are the mirror image and are handled the
// same way.
//
// **What it becomes.** The brightest (for a hot photosite) or darkest (for a
// dead one) same-colour neighbour — the value nearest to what it read that
// the neighbourhood can vouch for. An average would soften the one case this
// gets wrong, a real highlight that happened to pass both tests; a clamp to
// the neighbourhood's range cannot invent anything.
//
// Written for either colour filter array: the colour of a photosite comes from
// a 6x6 tile anchored to the sensor, which holds the X-Trans pattern as it is
// and a Bayer 2x2 cell repeated nine times.
struct HotPixelParams {
// The cropped area, in sensor photosites. Only photosites inside it are
// judged, and only photosites inside it are asked as neighbours — the
// masked border sits at black and would make everything look hot.
crop_x: u32,
crop_y: u32,
width: u32,
height: u32,
// Row stride of the readout, in samples, and the number of u32 words.
stride: u32,
words: u32,
// How many invocations one row of the dispatch grid holds, so a frame
// wider than a dispatch dimension can be addressed as two.
row_invocations: u32,
// Samples in the readout. One less than twice `words` when the count is
// odd, and the padding half of the last word is never judged.
samples: u32,
// Per-position black levels and reciprocal ranges, indexed by the
// photosite's parity within the *crop*: (y&1)*2 + (x&1) counted from its
// origin, as the demosaic counts them.
black: vec4<f32>,
inv_range: vec4<f32>,
// The 6x6 colour tile, two bits per photosite, indexed by sensor
// coordinate modulo 6: word k holds row 2k in its low 12 bits and row
// 2k+1 in the next 12. The fourth word is padding.
tile: vec4<u32>,
}
@group(0) @binding(0) var<storage, read> raw: array<u32>;
@group(0) @binding(1) var<uniform> params: HotPixelParams;
@group(0) @binding(2) var<storage, read_write> repaired: array<u32>;
// How far above its brightest neighbour a photosite must read to be hot, as a
// ratio and a margin in normalised units. Twice the neighbourhood and two
// percent of the range above it: far enough that shot noise in a lit area
// never qualifies, near enough that a hot photosite in a night sky — reading
// a third of the range over a sky at one percent — always does.
const HOT_RATIO: f32 = 2.0;
const HOT_MARGIN: f32 = 0.02;
// A dead photosite reads under half its darkest neighbour, and only counts
// where that neighbour is at least this bright: in the shadows, a photosite
// at zero is noise that clipped at the black point, not a defect.
const DEAD_RATIO: f32 = 0.5;
const DEAD_FLOOR: f32 = 0.05;
fn value_at(index: u32) -> u32 {
let word = raw[index >> 1u];
return select(word & 0xFFFFu, word >> 16u, (index & 1u) == 1u);
}
fn colour_at(sx: u32, sy: u32) -> u32 {
let row = sy % 6u;
let col = sx % 6u;
let word = params.tile[row >> 1u];
return (word >> ((row & 1u) * 12u + col * 2u)) & 3u;
}
// A raw value against its own black level and range. Compared rather than
// stored, so it is left unclamped at the top: a hot photosite above white is
// still more above white than its neighbours are.
fn level(sx: u32, sy: u32, v: u32) -> f32 {
let cell = ((sy - params.crop_y) & 1u) * 2u + ((sx - params.crop_x) & 1u);
return max(f32(v) - params.black[cell], 0.0) * params.inv_range[cell];
}
// The value to store for the photosite at `index`.
fn repair(index: u32) -> u32 {
let v = value_at(index);
let sx = index % params.stride;
let sy = index / params.stride;
if (sx < params.crop_x || sy < params.crop_y
|| sx >= params.crop_x + params.width || sy >= params.crop_y + params.height) {
return v;
}
let centre = level(sx, sy, v);
let colour = colour_at(sx, sy);
var same_hi = -1.0;
var same_lo = 1.0e9;
var same_hi_raw = v;
var same_lo_raw = v;
var same_count = 0u;
var adjacent_hi = 0.0;
var adjacent_lo = 1.0e9;
for (var dy = -2; dy <= 2; dy++) {
for (var dx = -2; dx <= 2; dx++) {
if (dx == 0 && dy == 0) {
continue;
}
let nx = i32(sx) + dx;
let ny = i32(sy) + dy;
if (nx < i32(params.crop_x) || ny < i32(params.crop_y)
|| nx >= i32(params.crop_x + params.width)
|| ny >= i32(params.crop_y + params.height)) {
continue;
}
let nsx = u32(nx);
let nsy = u32(ny);
let nv = value_at(nsy * params.stride + nsx);
let n = level(nsx, nsy, nv);
if (abs(dx) <= 1 && abs(dy) <= 1) {
adjacent_hi = max(adjacent_hi, n);
adjacent_lo = min(adjacent_lo, n);
}
if (colour_at(nsx, nsy) == colour) {
same_count += 1u;
if (n > same_hi) {
same_hi = n;
same_hi_raw = nv;
}
if (n < same_lo) {
same_lo = n;
same_lo_raw = nv;
}
}
}
}
// A corner of the crop can leave a photosite with a single same-colour
// neighbour, and one witness is not a neighbourhood.
if (same_count < 2u) {
return v;
}
let hot_line_same = same_hi * HOT_RATIO + HOT_MARGIN;
let hot_line_adjacent = adjacent_hi * HOT_RATIO + HOT_MARGIN;
if (centre > hot_line_same && centre > hot_line_adjacent) {
return same_hi_raw;
}
if (same_lo >= DEAD_FLOOR && centre < same_lo * DEAD_RATIO
&& centre < adjacent_lo * DEAD_RATIO) {
return same_lo_raw;
}
return v;
}
// One invocation per u32 word: two photosites, packed as the demosaic reads
// them. A word may straddle two rows when the stride is odd, which `repair`
// does not mind — it addresses by sample index.
@compute @workgroup_size(64, 1, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let word = gid.y * params.row_invocations + gid.x;
if (word >= params.words) {
return;
}
let lo = repair(word * 2u);
// The padding half of an odd-length readout is copied, not judged: it is
// not a photosite, and the demosaic never addresses it.
var hi = raw[word] >> 16u;
if (word * 2u + 1u < params.samples) {
hi = repair(word * 2u + 1u);
}
repaired[word] = (lo & 0xFFFFu) | (hi << 16u);
}
+79
View File
@@ -0,0 +1,79 @@
//! Contrast, on a device, at the two ends of the tonal range.
//!
//! The descriptor tests check that the fragment says the right words; these
//! check what those words do to a pixel. Both failures here passed every
//! descriptor test for months, because each is a property of the arithmetic
//! at the extremes rather than of the shape of the code.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::operation::compose;
use dr_pipeline::ops;
const SIZE: u32 = 8;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat frame of one sRGB colour, contrast set to `amount`, rendered and
/// read back as the colour of one pixel.
fn render(ctx: &GpuContext, rgb: [u8; 3], amount: f32) -> [u8; 3] {
let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|_| [rgb[0], rgb[1], rgb[2], 255])
.collect();
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
let mut chain = ops::chain();
let op = chain
.iter_mut()
.find(|o| o.descriptor().id.0 == "contrast")
.expect("contrast is in the chain");
op.set_param(ParamId("contrast"), amount);
let shader = compose(&chain);
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
let pixels = adjust.export_pixels().expect("readback").0;
[pixels[0], pixels[1], pixels[2]]
}
/// **The pink-blacks bug.** A near-black pixel whose red and blue sit a count
/// above its green — what white-balanced sensor noise in a night shadow looks
/// like — must come out grey when contrast is reduced, not magenta.
///
/// The ratio form lifted it by a gain of well over a hundred, and a hundred
/// times a one-count cast is a saturated colour.
#[test]
fn reducing_contrast_lifts_a_black_to_grey_not_to_magenta() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let [r, g, b] = render(&ctx, [4, 1, 4], -50.0);
let spread = r.max(g).max(b) - r.min(g).min(b);
assert!(
g > 40,
"a black at half contrast should be lifted toward grey, got ({r}, {g}, {b})"
);
assert!(
spread <= 6,
"the lift must be neutral: ({r}, {g}, {b}) has a cast of {spread}"
);
}
/// **The pinned highlights.** A light tone, above twice middle grey, must not
/// be pulled down to the top of the curve by the smallest positive contrast.
#[test]
fn a_little_contrast_leaves_a_highlight_where_it_was() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let before = render(&ctx, [230, 230, 230], 0.0)[1];
let after = render(&ctx, [230, 230, 230], 10.0)[1];
assert!(
after >= before.saturating_sub(2),
"contrast +10 took a highlight from {before} to {after}"
);
}
+263 -6
View File
@@ -16,9 +16,12 @@
//! the CPU model. //! the CPU model.
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage}; use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_film::bake::{bake, Recipe}; use dr_film::bake::{bake, Recipe, Settings};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext}; use dr_gpu::{AdjustPass, Demosaicer, GpuContext, LabelField, MaskPass};
use dr_pipeline::ops::FilmTables; use dr_pipeline::mask::{MaskLayer, MaskSource};
use dr_pipeline::ops::film_sim;
use dr_pipeline::ops::film_sim::FORMAT_COUNT;
use dr_pipeline::ops::{FilmTables, PaperTables};
use dr_pipeline::EditGraph; use dr_pipeline::EditGraph;
const SIZE: u32 = 16; const SIZE: u32 = 16;
@@ -77,15 +80,31 @@ fn tables(baked: &dr_film::Baked) -> FilmTables {
/// split a grain that never left the CPU would look exactly like a passing /// split a grain that never left the CPU would look exactly like a passing
/// test suite. /// test suite.
fn tables_with_grain(baked: &dr_film::Baked, particles: [f32; 3]) -> FilmTables { fn tables_with_grain(baked: &dr_film::Baked, particles: [f32; 3]) -> FilmTables {
// The paper, when there is one, rides behind the film: its curve as one
// more row, its cube stacked after the film's.
let mut curves = baked.curves.clone();
let mut lut = baked.lut.clone();
let paper = baked.paper.as_ref().map(|p| {
curves.extend_from_slice(&p.curves);
lut.extend_from_slice(&p.lut);
PaperTables {
balance: p.balance,
log_min: p.log_min,
log_max: p.log_max,
density_max: p.density_max,
}
});
FilmTables { FilmTables {
exposure_matrix: baked.exposure_matrix, exposure_matrix: baked.exposure_matrix,
curves: baked.curves.clone(), curves,
push_stations: baked.push_stations.clone(),
curve_log_min: baked.curve_log_min, curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max, curve_log_max: baked.curve_log_max,
lut: baked.lut.clone(), lut,
density_max: baked.density_max, density_max: baked.density_max,
lut_size: baked.lut_size, lut_size: baked.lut_size,
grain_particles: particles, paper,
grain_particles: [particles; FORMAT_COUNT],
grain_density_max: [baked.density_max; 3], grain_density_max: [baked.density_max; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
} }
@@ -241,3 +260,241 @@ fn grain_reaches_the_shader_and_scales_with_the_pixel() {
"grain never reached the shader: the coarsest setting moved the pixel by {coarse_err}" "grain never reached the shader: the coarsest setting moved the pixel by {coarse_err}"
); );
} }
/// The same render, with the film's sliders set and mask layers laid over it.
///
/// Every layer is a `Regions` mask over a field splitting the frame down the
/// middle: region 0, the left half, at full weight, and the right half
/// untouched. Returns the left and right centre pixels, linear.
fn rendered_split(
ctx: &GpuContext,
level: u16,
tables: FilmTables,
global: Settings,
layers: Vec<MaskLayer>,
) -> ([f32; 3], [f32; 3]) {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(&flat_raw(level))
.expect("demosaic");
let mut graph = EditGraph::default_chain();
graph.set_film(Some(dr_pipeline::graph::Film {
stock: "under_test".to_string(),
print: None,
tables: tables.clone(),
}));
graph.set_param(film_sim::ID, film_sim::EXPOSURE, global.exposure_ev);
graph.set_param(film_sim::ID, film_sim::PUSH, global.push_stops);
graph.set_param(
film_sim::ID,
film_sim::PRINT_EXPOSURE,
global.print_exposure_ev,
);
for layer in layers {
graph.masks_mut().push(layer);
}
let shader = graph.compose();
let labels: Vec<u32> = (0..SIZE * SIZE)
.map(|i| u32::from(i % SIZE >= SIZE / 2))
.collect();
let field = LabelField::upload(ctx, &labels, SIZE, SIZE, 2).expect("label upload");
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks
.render(graph.masks(), Some(&field), None, None, SIZE, SIZE)
.expect("rasterise");
let mut adjust = AdjustPass::new(ctx);
adjust.set_film(Some(&tables));
adjust
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
.expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let at = |x: u32| {
let c = (((SIZE / 2) * SIZE + x) * 4) as usize;
[0, 1, 2].map(|i| srgb_to_linear(f32::from(pixels[c + i]) / 255.0))
};
(at(SIZE / 4), at(3 * SIZE / 4))
}
/// A layer over the left half holding these film offsets.
fn left_half(id: &str, offsets: &[(dr_pipeline::descriptor::ParamId, f32)]) -> MaskLayer {
let mut layer = MaskLayer::new(
id,
MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
},
);
for (param, v) in offsets {
layer.set_param(film_sim::ID.0, *param, *v);
}
layer
}
fn assert_close(got: [f32; 3], want: [f32; 3], what: &str) {
for c in 0..3 {
assert!(
(got[c] - want[c]).abs() < 0.02,
"{what}, channel {c}: GPU gave {got:?}, the model says {want:?}"
);
}
}
#[test]
fn a_print_renders_on_the_gpu_the_way_it_does_on_the_cpu_at_any_setting() {
// TRACES: FR-DEV-3f
// The print path — the film's lookup into the paper's log exposure, the
// enlarger added between, the paper's curve and its own lookup — is read
// from the same two textures as the film, at offsets. Every one of those
// offsets is a way to render a plausible print of the wrong thing.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_portra_400").expect("stock");
let paper = dr_film::default_print(film).expect("paper");
let baked = bake(&Recipe::new(film, Some(paper)));
for settings in [
Settings::default(),
Settings {
print_exposure_ev: -1.3,
..Settings::default()
},
Settings {
exposure_ev: 0.4,
print_exposure_ev: 0.8,
..Settings::default()
},
] {
for level in [6_000u16, 20_000] {
let input = f32::from(level) / f32::from(u16::MAX);
let (got, _) = rendered_split(&ctx, level, tables(&baked), settings, Vec::new());
assert_close(
got,
baked.apply_at([input; 3], &settings),
&format!("{settings:?} at {level}"),
);
}
}
}
#[test]
fn a_push_between_two_measured_processes_renders_as_the_model_does() {
// TRACES: FR-DEV-3f
// Double-X measures five processes; a push between two is a mix of two
// rows of the curve texture, found by searching the stations uniform.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_doublex").expect("stock");
let baked = bake(&Recipe::new(film, None));
assert!(baked.curve_rows > 2, "Double-X has a development series");
for push in [-0.8f32, 0.4, 1.3, 2.9] {
let settings = Settings {
push_stops: push,
..Settings::default()
};
let level = 12_000u16;
let input = f32::from(level) / f32::from(u16::MAX);
let (got, _) = rendered_split(&ctx, level, tables(&baked), settings, Vec::new());
assert_close(
got,
baked.apply_at([input; 3], &settings),
&format!("push {push}"),
);
}
}
#[test]
fn a_layer_develops_its_region_on_its_own_settings() {
// TRACES: FR-DEV-3f
// Offsets to the photograph's: print exposure +1 on a photograph at +0.5
// is +1.5 under the layer, and the rest of the print is untouched. Before
// film was blended as settings the layer's sliders moved and nothing
// happened, because the layer's copy of the node had no stock.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_portra_400").expect("stock");
let paper = dr_film::default_print(film).expect("paper");
let baked = bake(&Recipe::new(film, Some(paper)));
let level = 12_000u16;
let input = f32::from(level) / f32::from(u16::MAX);
let global = Settings {
print_exposure_ev: 0.5,
..Settings::default()
};
let layer = left_half(
"burn",
&[(film_sim::PRINT_EXPOSURE, 1.0), (film_sim::EXPOSURE, -0.5)],
);
let (left, right) = rendered_split(&ctx, level, tables(&baked), global, vec![layer]);
let under = Settings {
exposure_ev: -0.5,
print_exposure_ev: 1.5,
..Settings::default()
};
assert_close(left, baked.apply_at([input; 3], &under), "under the layer");
assert_close(right, baked.apply_at([input; 3], &global), "outside it");
assert!(
left[1] < right[1] - 0.01,
"the burn did not darken: {left:?} vs {right:?}"
);
}
#[test]
fn overlapping_layers_take_the_average_of_their_settings() {
// TRACES: FR-DEV-3f
// Three layers over the same pixels at full weight: the plain mean of what
// each asks for, and the global setting has no weight left. Summed, the
// offsets would be -2 stops of print exposure and +1 of exposure; the mean
// is (-1, -1, 0) / 3 and (0, 0, +1) / 3.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_portra_400").expect("stock");
let paper = dr_film::default_print(film).expect("paper");
let baked = bake(&Recipe::new(film, Some(paper)));
let level = 12_000u16;
let input = f32::from(level) / f32::from(u16::MAX);
let layers = vec![
left_half("a", &[(film_sim::PRINT_EXPOSURE, -1.0)]),
left_half("b", &[(film_sim::PRINT_EXPOSURE, -1.0)]),
left_half("c", &[(film_sim::EXPOSURE, 1.0)]),
];
let (left, right) = rendered_split(&ctx, level, tables(&baked), Settings::default(), layers);
let mean = Settings {
exposure_ev: 1.0 / 3.0,
print_exposure_ev: -2.0 / 3.0,
..Settings::default()
};
let summed = Settings {
exposure_ev: 1.0,
print_exposure_ev: -2.0,
..Settings::default()
};
let (m, s) = (
baked.apply_at([input; 3], &mean)[1],
baked.apply_at([input; 3], &summed)[1],
);
// Three times the tolerance the GPU is held to below, or a sum could pass
// for a mean.
assert!(
(m - s).abs() > 0.06,
"the mean and the sum render alike ({m} vs {s}), so this proves nothing"
);
assert_close(left, baked.apply_at([input; 3], &mean), "under all three");
assert_close(right, baked.apply([input; 3]), "outside them");
}
+142
View File
@@ -0,0 +1,142 @@
//! TRACES: FR-RAW-3
//! Hot and dead photosite repair, end to end on a device.
//!
//! Each test renders a frame twice — once with a defect, once without — and
//! compares the finished pixels. That is the only comparison that means
//! anything: the repair happens on the mosaic, and what a photographer would
//! see of a defect it missed is the coloured cross the demosaic makes of it.
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::EditGraph;
const SIZE: u32 = 36;
const WHITE: u16 = 4095;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat frame at `level`, with `set` applied to its photosites.
fn frame(pattern: CfaPattern, level: u16, set: &[(u32, u32, u16)]) -> RawImage {
let mut data = vec![level; (SIZE * SIZE) as usize];
for &(x, y, v) in set {
data[(y * SIZE + x) as usize] = v;
}
RawImage {
width: SIZE,
height: SIZE,
data,
cfa_pattern: pattern,
black_level: [0; 4],
white_level: WHITE,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
}
}
fn render(ctx: &GpuContext, raw: &RawImage) -> Vec<u8> {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(raw)
.expect("demosaic");
let shader = EditGraph::default_chain().compose();
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
adjust.export_pixels().expect("readback").0
}
/// The largest channel difference between two renders.
fn worst(a: &[u8], b: &[u8]) -> u8 {
a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap()
}
const MIDDLE: u32 = SIZE / 2;
/// **The feature.** A photosite at white in a dark frame — a hot pixel in a
/// night sky — leaves no trace in the rendered picture.
#[test]
fn a_hot_photosite_in_a_dark_frame_is_invisible() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let clean = render(&ctx, &frame(CfaPattern::Rggb, 40, &[]));
for (x, y) in [
(MIDDLE, MIDDLE),
(MIDDLE + 1, MIDDLE),
(MIDDLE + 1, MIDDLE + 1),
] {
let hot = render(&ctx, &frame(CfaPattern::Rggb, 40, &[(x, y, WHITE)]));
let diff = worst(&clean, &hot);
assert!(
diff <= 1,
"a hot photosite at ({x}, {y}) still shows, by {diff}"
);
}
}
/// The same for one stuck dark in a lit area.
#[test]
fn a_dead_photosite_in_a_lit_frame_is_invisible() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let clean = render(&ctx, &frame(CfaPattern::Rggb, 1600, &[]));
let dead = render(&ctx, &frame(CfaPattern::Rggb, 1600, &[(MIDDLE, MIDDLE, 0)]));
let diff = worst(&clean, &dead);
assert!(diff <= 1, "a dead photosite still shows, by {diff}");
}
/// **What it must not eat.** A point of real light lands on a patch of
/// photosites, not one — so a 3×3 highlight survives, even at its brightest.
#[test]
fn a_small_real_highlight_survives() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut star = Vec::new();
for dy in 0..3 {
for dx in 0..3 {
star.push((MIDDLE - 1 + dx, MIDDLE - 1 + dy, WHITE));
}
}
let clean = render(&ctx, &frame(CfaPattern::Rggb, 40, &[]));
let lit = render(&ctx, &frame(CfaPattern::Rggb, 40, &star));
let at = ((MIDDLE * SIZE + MIDDLE) * 4 + 1) as usize;
assert!(
lit[at] > clean[at] + 100,
"the highlight was repaired away: {} against a background of {}",
lit[at],
clean[at]
);
}
/// The Fujifilm path goes through the same repair, with its own tile.
#[test]
fn a_hot_photosite_on_x_trans_is_invisible() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let clean = render(&ctx, &frame(CfaPattern::XTrans, 40, &[]));
let hot = render(
&ctx,
&frame(CfaPattern::XTrans, 40, &[(MIDDLE, MIDDLE, WHITE)]),
);
let diff = worst(&clean, &hot);
assert!(diff <= 1, "a hot X-Trans photosite still shows, by {diff}");
}
+73
View File
@@ -1135,3 +1135,76 @@ fn two_shown_masks_are_drawn_each_in_its_own_colour() {
"between them, alpha shows black: ({r}, {g}, {b})" "between them, alpha shows black: ({r}, {g}, {b})"
); );
} }
/// Render a mid-grey-and-shadows frame through `chain` and `stack`.
fn render_chain(
ctx: &GpuContext,
chain: &[Box<dyn dr_pipeline::operation::Operation>],
stack: &MaskStack,
field: Option<&LabelField>,
) -> Vec<u8> {
// A ramp, so both ends of the tonal range are in the comparison.
let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|i| {
let v = ((i % SIZE) * 255 / (SIZE - 1)) as u8;
[v, v / 2, v, 255]
})
.collect();
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
let shader = compose_full(
chain,
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks
.render(stack, field, None, None, SIZE, SIZE)
.expect("rasterise");
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
.expect("render");
adjust.export_pixels().expect("readback").0
}
fn contrast_chain(v: f32) -> Vec<Box<dyn dr_pipeline::operation::Operation>> {
let mut chain = ops::chain();
chain
.iter_mut()
.find(|o| o.descriptor().id.0 == "contrast")
.expect("contrast")
.set_param(ParamId("contrast"), v);
chain
}
/// A layer's setting is an offset to the global one, applied once: global
/// −30 with a whole-frame layer at −20 is exactly global −50 — not −30 and
/// then −20 again on the result, which is what a layer used to do.
#[test]
fn a_whole_frame_layer_adds_its_setting_to_the_global_one() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut layer = MaskLayer::new("m1", whole_frame());
layer.set_param("contrast", ParamId("contrast"), -20.0);
let mut stack = MaskStack::new();
stack.push(layer);
let field = split_field(&ctx);
let offset = render_chain(&ctx, &contrast_chain(-30.0), &stack, Some(&field));
let direct = render_chain(&ctx, &contrast_chain(-50.0), &MaskStack::new(), None);
let worst = offset
.iter()
.zip(&direct)
.map(|(a, b)| a.abs_diff(*b))
.max()
.unwrap();
assert!(
worst <= 1,
"layer offset differs from the summed setting by {worst}"
);
}
+62 -28
View File
@@ -35,41 +35,62 @@ helpers: [luminance, apply_tone_gain]
define: define:
contrast_curve: | contrast_curve: |
// A symmetric S-curve on a 0..1 perceptual position. // The steepening S, on a 0..1 perceptual position.
// //
// `amount` above zero steepens, below zero flattens. The smoothstep form is // Blends toward a smoothstep, which has zero gradient at both ends, so the
// used for the steepening direction because it has zero gradient at both // curve cannot invert however hard it is pushed — the failure that makes
// ends, so the curve cannot invert however hard it is pushed — the failure // naive gain-about-a-pivot unusable past moderate settings. Only the
// that makes naive gain-about-a-pivot unusable past moderate settings. // positive direction comes here: flattening is not a curve at all (see
// the fragment).
fn contrast_curve(x: f32, amount: f32) -> f32 { fn contrast_curve(x: f32, amount: f32) -> f32 {
let clamped = clamp(x, 0.0, 1.0); let clamped = clamp(x, 0.0, 1.0);
if (amount >= 0.0) { let s = clamped * clamped * (3.0 - 2.0 * clamped);
// Blend toward a smoothstep, which is the S. return mix(clamped, s, amount);
let s = clamped * clamped * (3.0 - 2.0 * clamped);
return mix(clamped, s, amount);
}
// Flattening: pull toward the mid-point. At amount = -1 every tone
// collapses to 0.5, which is the meaningful limit of 'no contrast'.
return mix(clamped, 0.5, -amount);
} }
wgsl: | wgsl: |
let luma = luminance(c); if (amount < 0.0) {
if (luma > 0.0001) { // **Flattening mixes toward middle grey; it does not scale.**
// Work on luminance and rescale the colour by the ratio, rather than
// curving each channel independently. Per-channel contrast shifts hue
// wherever the channels differ — the classic symptom being skies going
// cyan as contrast rises.
// //
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone. The // Every tone moves the same fraction of the way to 0.18, which at -1
// curve operates on luma/(2*0.18) so that middle grey lands at the // collapses the picture to grey — the meaningful limit of 'no contrast'.
// curve's own 0.5 pivot. // In luminance this is exactly what the ratio form below would compute,
let pos = clamp(luma / 0.36, 0.0, 1.0); // but the ratio form reaches it by multiplying: a pixel at 0.001 has to
let curved = contrast_curve(pos, amount); // be lifted to 0.09, a gain of ninety, and in the deepest shadows the
// Not `target`: that is a WGSL reserved keyword, and using it produces a // channels are sensor noise, not a colour. After white balance the red
// parse error in generated code rather than anywhere a reader would look. // and blue noise sits above the green (their multipliers are nearly
let curved_luma = curved * 0.36; // twice its), so ninety times that noise is magenta — every black in the
c = apply_tone_gain(c, curved_luma / luma); // frame turned pink. Mixing adds the lift as a neutral, so a black goes
// to grey and its noise stays the size it was.
//
// The grey is (1, 1, 1) scaled, because this runs after white balance
// in the camera's space, where that is what neutral is.
c = mix(c, vec3<f32>(0.18), -amount);
} else {
let luma = luminance(c);
// Only up to twice middle grey, which is the curve's whole domain. Above
// it the curve's value is 1 and its slope 0, so leaving those tones
// alone is the continuous continuation — where scaling them to the
// curve's top, as this once did through a clamp, pinned every highlight
// in the photograph to 0.36 at the smallest touch of the slider.
if (luma > 0.0001 && luma < 0.36) {
// Work on luminance and rescale the colour by the ratio, rather than
// curving each channel independently. Per-channel contrast shifts hue
// wherever the channels differ — the classic symptom being skies going
// cyan as contrast rises. Safe here where it was not for flattening:
// the S only ever pulls a shadow down, so the gain is at most one
// below the pivot and noise is never amplified.
//
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone.
// The curve operates on luma/(2*0.18) so that middle grey lands at
// the curve's own 0.5 pivot.
let pos = luma / 0.36;
let curved = contrast_curve(pos, amount);
// Not `target`: that is a WGSL reserved keyword, and using it produces a
// parse error in generated code rather than anywhere a reader would look.
let curved_luma = curved * 0.36;
c = apply_tone_gain(c, curved_luma / luma);
}
} }
c = max(c, vec3<f32>(0.0)); c = max(c, vec3<f32>(0.0));
@@ -104,6 +125,19 @@ tests:
propagate through everything downstream. propagate through everything downstream.
expect_wgsl: ["luma > 0.0001"] expect_wgsl: ["luma > 0.0001"]
- name: flattening_mixes_toward_grey_rather_than_scaling
why: |
Lifting a shadow by a luminance ratio multiplies its noise by the same
ratio — ninety at the bottom of a night photograph — and after white
balance that noise is magenta. A mix adds the lift as a neutral.
expect_wgsl: ["mix(c, vec3<f32>(0.18), -amount)"]
- name: highlights_are_not_pinned_to_the_top_of_the_curve
why: |
The curve covers 0..0.36. A clamp into that range scaled every brighter
pixel down to 0.36; tones above it are left as they are.
expect_wgsl: ["luma < 0.36"]
- name: the_curve_cannot_invert - name: the_curve_cannot_invert
why: | why: |
A gain-about-a-pivot form produces a non-monotonic curve past moderate A gain-about-a-pivot form produces a non-monotonic curve past moderate
+52
View File
@@ -0,0 +1,52 @@
drpl 1
# Skies: a bluer, deeper sky without touching the rest of the picture.
#
# Written against this pipeline, not derived from anybody's preset. Each
# works the colour mixer's azure and blue bands — the hues a clear sky
# occupies, 210° and 240° — darkening them and adding chroma, which is what
# a polarising filter does to a sky and why it reads as "more blue" rather
# than "more saturated". A grey sky has no hue for the bands to find, so on
# an overcast frame these do little, by construction: they cannot invent a
# sky, and a preset that tinted grey clouds blue would be one nobody trusted.
#
# Highlights come down with the sky in the stronger ones, because a darker
# blue beside a clipped white cloud looks like a mask edge.
[preset Blue sky]
colour_mixer.azure_lum = -20
colour_mixer.azure_sat = 25
colour_mixer.blue_lum = -15
colour_mixer.blue_sat = 20
highlights_shadows.highlights = -15
[preset Deep blue sky]
colour_mixer.azure_hue = 10
colour_mixer.azure_lum = -30
colour_mixer.azure_sat = 35
colour_mixer.blue_lum = -25
colour_mixer.blue_sat = 30
colour_mixer.cyan_sat = 10
highlights_shadows.highlights = -30
[preset Polariser]
colour_mixer.azure_hue = 10
colour_mixer.azure_lum = -35
colour_mixer.azure_sat = 40
colour_mixer.blue_lum = -30
colour_mixer.blue_sat = 35
colour_mixer.cyan_lum = -10
colour_mixer.cyan_sat = 15
dehaze.amount = 20
highlights_shadows.highlights = -35
vibrance.vibrance = 10
[preset Blue sky, golden land]
colour_mixer.azure_lum = -20
colour_mixer.azure_sat = 25
colour_mixer.blue_lum = -15
colour_mixer.blue_sat = 20
colour_mixer.orange_sat = 12
colour_mixer.yellow_hue = -10
colour_mixer.yellow_sat = 15
highlights_shadows.highlights = -20
+1
View File
@@ -62,6 +62,7 @@ const SECTIONS: &[(&str, &str, &str)] = &[
"Essentials", "Essentials",
include_str!("../presets/essentials.drpl"), include_str!("../presets/essentials.drpl"),
), ),
("skies", "Skies", include_str!("../presets/skies.drpl")),
( (
"colour_film", "colour_film",
"Colour film", "Colour film",
+3 -1
View File
@@ -581,7 +581,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8], lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0, density_max: 2.0,
lut_size: 2, lut_size: 2,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [2.0; 3], grain_density_max: [2.0; 3],
grain_uniformity: 1.0, grain_uniformity: 1.0,
}, },
+3 -1
View File
@@ -132,7 +132,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0, density_max: 3.0,
lut_size: 32, lut_size: 32,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [3.0; 3], grain_density_max: [3.0; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
}, },
+407 -89
View File
@@ -73,7 +73,7 @@ use std::fmt::Write as _;
use std::sync::Arc; use std::sync::Arc;
use crate::coverage::Coverage; use crate::coverage::Coverage;
use crate::descriptor::{Attribute, OpDescriptor, ParamId}; use crate::descriptor::{Attribute, OpDescriptor, ParamId, ParamKind};
use crate::operation::Operation; use crate::operation::Operation;
use crate::ops; use crate::ops;
@@ -1368,17 +1368,19 @@ pub struct MaskLayer {
/// This layer's adjustments. /// This layer's adjustments.
/// ///
/// A full chain, the same one [`crate::EditGraph`] holds. That is the /// A full chain, the same one [`crate::EditGraph`] holds. That is the
/// whole reason local adjustments need no per-operation support: the /// whole reason local adjustments need no per-operation support: each
/// composer already knows how to turn a chain into WGSL, and a mask layer /// setting here is an offset from its default, added to the global chain's
/// is a chain that happens to be multiplied by a mask afterwards. /// setting and run where that operation runs, weighted by the mask (see
/// [`offset_onto`]).
pub ops: Vec<Box<dyn Operation>>, pub ops: Vec<Box<dyn Operation>>,
} }
/// The chain a mask layer holds: every point operation, and neither the /// The chain a mask layer holds: every point operation, and neither the
/// neighbourhood ones nor the optical corrections. /// neighbourhood ones nor the optical corrections.
/// ///
/// A layer's adjustments are fused into the colour dispatch and multiplied by /// A layer's adjustments are fused into the colour dispatch, each beside the
/// the mask afterwards, which is exactly why a layer needs no per-operation /// global operation it offsets and weighted by the mask, which is exactly why
/// a layer needs no per-operation
/// support — the composer already knows how to turn a chain into WGSL. A /// support — the composer already knows how to turn a chain into WGSL. A
/// neighbourhood operation cannot go through that path at all: it runs as its /// neighbourhood operation cannot go through that path at all: it runs as its
/// own dispatch in [`crate::detail`], after the fused pass and after the masks /// own dispatch in [`crate::detail`], after the fused pass and after the masks
@@ -1642,8 +1644,8 @@ impl MaskLayer {
/// The operations in this layer's chain that reach the shader. /// The operations in this layer's chain that reach the shader.
/// ///
/// Neighbourhood operations are excluded, and not as an oversight. A /// Neighbourhood operations are excluded, and not as an oversight. A
/// layer's chain is *fused into the point-operation pass* and multiplied /// layer's chain is *fused into the point-operation pass*, weighted by the
/// by the mask afterwards; the detail stage runs once, over the whole /// mask at each operation; the detail stage runs once, over the whole
/// frame, after that pass has finished (see [`crate::detail`]). There is /// frame, after that pass has finished (see [`crate::detail`]). There is
/// nowhere in that arrangement for a sharpening confined to one mask to /// nowhere in that arrangement for a sharpening confined to one mask to
/// happen, so a detail operation in a layer would contribute an empty /// happen, so a detail operation in a layer would contribute an empty
@@ -1654,7 +1656,7 @@ impl MaskLayer {
self.ops self.ops
.iter() .iter()
.map(|o| o.as_ref()) .map(|o| o.as_ref())
.filter(|o| o.is_active() && o.detail().is_none()) .filter(|o| moves(*o) && o.detail().is_none())
} }
/// Whether any part of this mask belongs to a different segmentation. /// Whether any part of this mask belongs to a different segmentation.
@@ -1975,7 +1977,9 @@ impl MaskStack {
Some(self.layers.remove(i)) Some(self.layers.remove(i))
} }
/// Reorder, since later layers composite over earlier ones. /// Reorder. Layers add their changes, so order no longer decides the
/// picture — but it is the order the panel lists them in and the order
/// their slots are assigned.
pub fn move_to(&mut self, id: &str, index: usize) { pub fn move_to(&mut self, id: &str, index: usize) {
let Some(from) = self.layers.iter().position(|l| l.id == id) else { let Some(from) = self.layers.iter().position(|l| l.id == id) else {
return; return;
@@ -2041,25 +2045,104 @@ impl MaskStack {
} }
} }
/// One layer's contribution to the generated shader. /// The layers' contribution to the generated shader.
///
/// Not a block of its own any more. A layer's adjustments are *offsets to the
/// global ones*, applied at each operation's own place in the chain, so what
/// this hands back is pieces the composer threads through its loop over the
/// global operations: the weights, sampled once before the first operation,
/// and one [`LocalOp`] per layer per operation the layer moved.
pub(crate) struct LayerShader { pub(crate) struct LayerShader {
pub uniform_fields: String, pub uniform_fields: String,
pub uniform_values: Vec<f32>, pub uniform_values: Vec<f32>,
pub body: String, /// Each layer's shaped mask, `mask_w{slot}`, sampled once ahead of the
/// operations that read it. Empty when no layer changes a pixel.
pub weights: String,
/// Every layer's version of every operation it moved, in layer order and
/// then chain order — see [`LocalOp`].
pub ops: Vec<LocalOp>,
pub helpers: Vec<crate::operation::Helper>, pub helpers: Vec<crate::operation::Helper>,
/// TRACES: FR-DEV-19c /// TRACES: FR-DEV-19c
/// The block that draws one layer's mask over the finished picture, empty /// The block that draws one layer's mask over the finished picture, empty
/// when nothing is being revealed. /// when nothing is being revealed.
/// ///
/// Kept apart from `body` because it belongs at the other end of the /// Kept apart from the rest because it belongs at the other end of the
/// shader. Everything in `body` runs on scene-referred colour in the /// shader. Everything else runs on scene-referred colour in the working
/// working space, where a flat tint would then be pushed through the base /// space, where a flat tint would then be pushed through the base curve
/// curve and the camera matrix and arrive as some other colour, and a /// and the camera matrix and arrive as some other colour, and a
/// white-on-black alpha would arrive as neither. This runs after the /// white-on-black alpha would arrive as neither. This runs after the
/// output transform, so what is written is what is seen. /// output transform, so what is written is what is seen.
pub reveal: String, pub reveal: String,
} }
/// One layer's version of one operation: the global settings with the layer's
/// offsets added, as a fragment reading this layer's own uniforms.
///
/// The composer runs it beside the global fragment on the same input colour,
/// and moves the pixel toward its result by the layer's weight — see
/// `operation::local_block`.
pub(crate) struct LocalOp {
pub op: &'static str,
pub slot: usize,
/// Empty when the offsets cancel the global setting back to neutral. That
/// is still an entry, because it still means something: inside the mask
/// this operation does nothing at all.
///
/// Empty, too, for an operation blended as settings: that entry stands
/// for this layer's uniforms, `mask{slot}_{op}_*`, which the composer
/// averages into the one fragment it runs.
pub fragment: String,
}
/// Whether a layer's copy of `op` holds an adjustment.
///
/// An operation's own answer, except for one blended as settings
/// ([`Operation::blends_settings`]): that one is active by what it *holds* —
/// a film is active when a stock is loaded — and a layer never holds a stock,
/// only offsets to the photograph's. So a layer's film counts as moved when
/// its sliders are, which is the question being asked.
fn moves(op: &dyn Operation) -> bool {
op.is_active()
|| (op.blends_settings()
&& op
.descriptor()
.params
.iter()
.any(|p| op.param(p.id) != p.default))
}
/// A layer's settings for one operation, applied as offsets to the global
/// operation's.
///
/// **This is what a local adjustment means**, and the reason it is not a
/// second chain run over the finished picture. A photographer who sets
/// contrast −30 on the whole frame and −20 on a face means −50 on the face,
/// at the place contrast sits in the chain — not −30, then everything after
/// contrast, then −20 applied again to the result. Stacked that way the two
/// edits compound in ways neither slider shows, and a flattening applied to an
/// already-flattened picture is how a shadow's noise ended up magenta.
///
/// Per parameter: one the layer left at its default takes the global value; a
/// moved scalar adds its distance from default to the global value, clamped to
/// the parameter's range; a moved switch or choice replaces it, since there is
/// no such thing as half a variant.
fn offset_onto(dst: &mut dyn Operation, local: &dyn Operation, global: Option<&dyn Operation>) {
let desc = local.descriptor();
for p in &desc.params {
let here = local.param(p.id);
let base = global.map_or(p.default, |g| g.param(p.id));
let value = if here == p.default {
base
} else {
match p.kind {
ParamKind::Scalar { .. } => p.clamp(base + (here - p.default)),
ParamKind::Bool | ParamKind::Enum { .. } => here,
}
};
dst.set_param(p.id, value);
}
}
/// Emit the WGSL for every layer that renders, and for the mask being looked /// Emit the WGSL for every layer that renders, and for the mask being looked
/// at. /// at.
/// ///
@@ -2068,11 +2151,18 @@ pub(crate) struct LayerShader {
/// an adjustment on it, which is why the two are one sequence and why every /// an adjustment on it, which is why the two are one sequence and why every
/// other half of the pipeline has to be given the same `reveal` for the slots /// other half of the pipeline has to be given the same `reveal` for the slots
/// to mean the same thing. /// to mean the same thing.
pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal>) -> LayerShader { ///
/// `global` is the chain the layers are offsets to.
pub(crate) fn compose_layers_revealing(
stack: &MaskStack,
reveal: Option<&Reveal>,
global: &[Box<dyn Operation>],
) -> LayerShader {
let mut out = LayerShader { let mut out = LayerShader {
uniform_fields: String::new(), uniform_fields: String::new(),
uniform_values: Vec::new(), uniform_values: Vec::new(),
body: String::new(), weights: String::new(),
ops: Vec::new(),
helpers: Vec::new(), helpers: Vec::new(),
reveal: String::new(), reveal: String::new(),
}; };
@@ -2104,13 +2194,14 @@ pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal
); );
out.uniform_values.extend_from_slice(&layer.uniforms()); out.uniform_values.extend_from_slice(&layer.uniforms());
let _ = writeln!( // A layer only being looked at moves no pixel, so it needs a slot for
out.body, // the reveal and no weight.
"\n // ======== mask {slot}: {} ({}) ========", if layer.active_ops().next().is_none() {
layer.display_name(), continue;
layer.base().source.kind() }
);
let _ = writeln!(out.body, " {{"); // The weight, once per pixel, ahead of every operation that reads it.
//
// **`uv_src`, not `gid.xy`.** The mask array is rasterised in *source* // **`uv_src`, not `gid.xy`.** The mask array is rasterised in *source*
// space, and `uv_src` is the source position this output pixel came // space, and `uv_src` is the source position this output pixel came
// from — after the crop, the zoom, the pan, the straightening and the // from — after the crop, the zoom, the pan, the straightening and the
@@ -2123,33 +2214,62 @@ pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal
// place. A second copy here would be a second thing to keep in step // place. A second copy here would be a second thing to keep in step
// with `Framing::wgsl_prologue`, and the failure would be a mask that // with `Framing::wgsl_prologue`, and the failure would be a mask that
// is subtly wrong only when straightened. // is subtly wrong only when straightened.
let _ = writeln!(out.body, " var m = sample_mask(uv_src, {slot});"); let w = format!("mask_w{slot}");
let _ = writeln!( let _ = writeln!(
out.body, out.weights,
" m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);" "\n // ======== mask {slot}: {} ({}) ========",
layer.display_name(),
layer.base().source.kind()
);
let _ = writeln!(out.weights, " var {w} = sample_mask(uv_src, {slot});");
let _ = writeln!(
out.weights,
" {w} = select({w}, 1.0 - {w}, u.{prefix}_invert > 0.5);"
); );
let _ = writeln!( let _ = writeln!(
out.body, out.weights,
" m = clamp(m * u.{prefix}_opacity, 0.0, 1.0);" " {w} = clamp({w} * u.{prefix}_opacity, 0.0, 1.0);"
); );
// Skipping the work where the mask is empty is most of the point of a
// local adjustment: a mask covering a tenth of the frame should cost
// about a tenth of the shader. Safe as non-uniform control flow —
// nothing inside samples with derivatives or synchronises.
let _ = writeln!(out.body, " if (m > 0.0) {{");
// `masked` is the outer-scope carrier: op fragments write to a `c`
// they expect to own, so the inner block shadows `c` and copies the
// result back out. Assigning the outer `c` from inside is not possible
// precisely because it is shadowed.
let _ = writeln!(out.body, " var masked = c;");
let _ = writeln!(out.body, " {{");
let _ = writeln!(out.body, " var c = masked;");
for op in layer.active_ops() { // A fresh chain to hold the combined settings: the layer's own ops
let id = op.descriptor().id.0; // are its offsets and must stay that way.
let mut combined = layer_chain();
for (dst, local) in combined.iter_mut().zip(&layer.ops) {
let local = local.as_ref();
if !moves(local) || local.detail().is_some() {
continue;
}
let id = local.descriptor().id.0;
let g = global
.iter()
.map(|o| o.as_ref())
.find(|o| o.descriptor().id.0 == id);
// The photograph's stock, lent to the layer's copy: a layer holds
// offsets to a film, never one of its own.
if let Some(g) = g {
dst.set_film_tables(g.film_tables());
}
offset_onto(dst.as_mut(), local, g);
if dst.blends_settings() {
// Its uniforms, and no fragment: the composer blends this
// layer's settings with the others' and runs the global
// fragment once. With no stock loaded there is nothing to
// blend, and the global side skips the operation too.
if !dst.is_active() {
continue;
}
} else if !dst.is_active() {
out.ops.push(LocalOp {
op: id,
slot,
fragment: String::new(),
});
continue;
}
let op_prefix = format!("{prefix}_{}", crate::operation::sanitise(id)); let op_prefix = format!("{prefix}_{}", crate::operation::sanitise(id));
let op_uniforms = dst.uniforms();
let op_uniforms = op.uniforms();
if !op_uniforms.is_empty() { if !op_uniforms.is_empty() {
let _ = writeln!(out.uniform_fields, " // mask {slot}: {id}"); let _ = writeln!(out.uniform_fields, " // mask {slot}: {id}");
} }
@@ -2158,34 +2278,29 @@ pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal
out.uniform_values.push(u.value); out.uniform_values.push(u.value);
} }
for h in op.helpers() { for h in dst.helpers() {
if !out.helpers.iter().any(|e| e.name == h.name) { if !out.helpers.iter().any(|e| e.name == h.name) {
out.helpers.push(*h); out.helpers.push(*h);
} }
} }
let mut fragment = op.wgsl_body(); let mut fragment = String::new();
for u in &op_uniforms { if !dst.blends_settings() {
fragment = crate::operation::rewrite_uniform( fragment = dst.wgsl_body();
&fragment, for u in &op_uniforms {
u.name, fragment = crate::operation::rewrite_uniform(
&format!("u.{op_prefix}_{}", u.name), &fragment,
); u.name,
&format!("u.{op_prefix}_{}", u.name),
);
}
} }
out.ops.push(LocalOp {
let _ = writeln!(out.body, " // ---- {id} ----"); op: id,
let _ = writeln!(out.body, " {{"); slot,
for line in fragment.lines() { fragment,
let _ = writeln!(out.body, " {line}"); });
}
let _ = writeln!(out.body, " }}");
} }
let _ = writeln!(out.body, " masked = c;");
let _ = writeln!(out.body, " }}");
let _ = writeln!(out.body, " c = mix(c, masked, m);");
let _ = writeln!(out.body, " }}");
let _ = writeln!(out.body, " }}");
} }
out out
@@ -2309,6 +2424,78 @@ mod tests {
layer layer
} }
/// A stock the shader can index, with values that are not a real one's.
fn film_fixture() -> crate::graph::Film {
use crate::ops::film_sim::{CURVE_SAMPLES, FORMAT_COUNT};
crate::graph::Film {
stock: "fixture".into(),
print: None,
tables: crate::ops::FilmTables {
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
curves: vec![[0.5; 3]; CURVE_SAMPLES],
push_stations: vec![0.0],
curve_log_min: -3.0,
curve_log_max: 1.0,
lut: vec![[0.5; 3]; 8],
density_max: 2.0,
lut_size: 2,
paper: None,
grain_particles: [[0.0; 3]; FORMAT_COUNT],
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
}
}
#[test]
fn a_layers_film_is_blended_as_settings_and_developed_once() {
// TRACES: FR-DEV-3f
// The film is a rendering: a layer's version of it run beside the
// global one and cross-faded would be the photograph developed twice.
// So the layer's uniforms are averaged into the global ones by weight
// and the fragment appears once.
use crate::ops::film_sim;
let mut graph = crate::EditGraph::default_chain();
graph.set_film(Some(film_fixture()));
let mut layer = MaskLayer::new("m1", regions(&[1]));
layer.set_param(film_sim::ID.0, film_sim::PRINT_EXPOSURE, 1.0);
assert!(layer.is_active(), "a film offset is an adjustment");
graph.masks_mut().push(layer);
let source = graph.compose().source;
assert!(source.contains("let set_w = mask_w0;"), "{source}");
assert!(source.contains("let set_g = max(1.0 - set_w, 0.0);"));
assert!(
source.contains("u.mask0_film_sim_pev"),
"the layer's setting is not read"
);
assert_eq!(
source.matches("let density = film_curve_pushed(").count(),
1,
"the film was developed more than once"
);
assert!(
!source.contains("let local_in"),
"the film was blended as a result"
);
}
#[test]
fn a_layers_film_without_a_stock_composes_to_nothing() {
// TRACES: FR-DEV-3f
// The offsets are kept — a stock chosen later brings them back — but
// with no film on the photograph there is nothing for them to offset.
use crate::ops::film_sim;
let mut graph = crate::EditGraph::default_chain();
let mut layer = MaskLayer::new("m1", regions(&[1]));
layer.set_param(film_sim::ID.0, film_sim::PUSH, 1.0);
graph.masks_mut().push(layer);
let source = graph.compose().source;
assert!(!source.contains("---- film_sim ----"), "{source}");
assert!(!source.contains("mask0_film_sim"));
}
#[test] #[test]
fn a_layer_with_no_adjustment_is_not_in_the_shader() { fn a_layer_with_no_adjustment_is_not_in_the_shader() {
let layer = MaskLayer::new("m1", regions(&[1])); let layer = MaskLayer::new("m1", regions(&[1]));
@@ -2317,7 +2504,10 @@ mod tests {
let mut stack = MaskStack::new(); let mut stack = MaskStack::new();
stack.push(layer); stack.push(layer);
assert!(stack.is_neutral()); assert!(stack.is_neutral());
assert_eq!(compose_layers_revealing(&stack, None).body, ""); assert_eq!(
compose_layers_revealing(&stack, None, &ops::chain()).weights,
""
);
} }
#[test] #[test]
@@ -2403,11 +2593,11 @@ mod tests {
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
stack.push(lit_layer("m2", -1.0)); stack.push(lit_layer("m2", -1.0));
let shader = compose_layers_revealing(&stack, None); let shader = compose_layers_revealing(&stack, None, &ops::chain());
assert!(shader.body.contains("sample_mask(uv_src, 0)")); assert!(shader.weights.contains("sample_mask(uv_src, 0)"));
assert!(shader.body.contains("sample_mask(uv_src, 1)")); assert!(shader.weights.contains("sample_mask(uv_src, 1)"));
assert!(shader.body.contains("u.mask0_opacity")); assert!(shader.weights.contains("u.mask0_opacity"));
assert!(shader.body.contains("u.mask1_opacity")); assert!(shader.weights.contains("u.mask1_opacity"));
} }
/// The slot a layer renders through must follow `active()`, not the raw /// The slot a layer renders through must follow `active()`, not the raw
@@ -2420,12 +2610,12 @@ mod tests {
stack.push(off); stack.push(off);
stack.push(lit_layer("m2", -1.0)); stack.push(lit_layer("m2", -1.0));
let shader = compose_layers_revealing(&stack, None); let shader = compose_layers_revealing(&stack, None, &ops::chain());
assert!( assert!(
shader.body.contains("sample_mask(uv_src, 0)"), shader.weights.contains("sample_mask(uv_src, 0)"),
"the one active layer must use slot 0, not slot 1" "the one active layer must use slot 0, not slot 1"
); );
assert!(!shader.body.contains("sample_mask(uv_src, 1)")); assert!(!shader.weights.contains("sample_mask(uv_src, 1)"));
} }
/// TRACES: FR-DEV-19c /// TRACES: FR-DEV-19c
@@ -2445,7 +2635,7 @@ mod tests {
stack.push(MaskLayer::new("m2", MaskSource::brush())); stack.push(MaskLayer::new("m2", MaskSource::brush()));
let reveal = Reveal::one("m2", RevealStyle::Alpha); let reveal = Reveal::one("m2", RevealStyle::Alpha);
let shader = compose_layers_revealing(&stack, Some(&reveal)); let shader = compose_layers_revealing(&stack, Some(&reveal), &ops::chain());
assert_eq!( assert_eq!(
stack.rendered_count(Some(&reveal)), stack.rendered_count(Some(&reveal)),
@@ -2483,7 +2673,7 @@ mod tests {
], ],
style: RevealStyle::Tint, style: RevealStyle::Tint,
}; };
let shader = compose_layers_revealing(&stack, Some(&reveal)); let shader = compose_layers_revealing(&stack, Some(&reveal), &ops::chain());
let sky = shader let sky = shader
.reveal .reveal
@@ -2509,7 +2699,9 @@ mod tests {
fn nothing_is_revealed_unless_it_was_asked_for() { fn nothing_is_revealed_unless_it_was_asked_for() {
let mut stack = MaskStack::new(); let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
assert!(compose_layers_revealing(&stack, None).reveal.is_empty()); assert!(compose_layers_revealing(&stack, None, &ops::chain())
.reveal
.is_empty());
} }
/// A reveal aimed at a layer that is not in the stack is not a slot, and /// A reveal aimed at a layer that is not in the stack is not a slot, and
@@ -2520,9 +2712,11 @@ mod tests {
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
let reveal = Reveal::one("gone", RevealStyle::Tint); let reveal = Reveal::one("gone", RevealStyle::Tint);
assert_eq!(stack.rendered_count(Some(&reveal)), 1); assert_eq!(stack.rendered_count(Some(&reveal)), 1);
assert!(compose_layers_revealing(&stack, Some(&reveal)) assert!(
.reveal compose_layers_revealing(&stack, Some(&reveal), &ops::chain())
.is_empty()); .reveal
.is_empty()
);
} }
#[test] #[test]
@@ -2531,7 +2725,7 @@ mod tests {
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
stack.push(lit_layer("m2", -1.0)); stack.push(lit_layer("m2", -1.0));
let shader = compose_layers_revealing(&stack, None); let shader = compose_layers_revealing(&stack, None, &ops::chain());
assert!(shader.uniform_fields.contains("mask0_exposure_")); assert!(shader.uniform_fields.contains("mask0_exposure_"));
assert!(shader.uniform_fields.contains("mask1_exposure_")); assert!(shader.uniform_fields.contains("mask1_exposure_"));
assert_eq!( assert_eq!(
@@ -2545,16 +2739,140 @@ mod tests {
); );
} }
/// The whole shader for `stack` over a global chain with `global`
/// applied to it.
fn composed_with(stack: &MaskStack, global: impl FnOnce(&mut [Box<dyn Operation>])) -> String {
let mut chain = ops::chain();
global(&mut chain);
crate::operation::compose_full(
&chain,
&crate::Framing::new(),
dr_types::ColourSpace::Srgb,
stack,
&crate::spot::SpotSet::new(),
&[],
)
.source
}
fn set(chain: &mut [Box<dyn Operation>], op: &str, param: &'static str, v: f32) {
chain
.iter_mut()
.find(|o| o.descriptor().id.0 == op)
.expect("op in chain")
.set_param(ParamId(param), v);
}
fn contrast_layer(v: f32) -> MaskLayer {
let mut layer = MaskLayer::new("m1", regions(&[1]));
layer.set_param("contrast", ParamId("contrast"), v);
layer
}
#[test] #[test]
fn the_inner_block_shadows_c_and_copies_back() { fn the_layer_version_shadows_c_and_blends_by_its_difference() {
let mut stack = MaskStack::new(); let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0)); stack.push(lit_layer("m1", 1.0));
let body = compose_layers_revealing(&stack, None).body; let src = composed_with(&stack, |_| {});
assert!(body.contains("var masked = c;")); assert!(src.contains("var c = local_in;"));
assert!(body.contains("var c = masked;")); assert!(src.contains("local_sum = local_sum + mask_w0 * (c - local_global);"));
assert!(body.contains("masked = c;")); assert!(src.contains("c = max(local_sum, vec3<f32>(0.0));"));
assert!(body.contains("c = mix(c, masked, m);")); }
/// **The bug this shape exists for.** A layer's contrast is added to the
/// global contrast, not run a second time on top of it.
#[test]
fn a_layer_setting_is_an_offset_to_the_global_one() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(-20.0));
let mut chain = ops::chain();
set(&mut chain, "contrast", "contrast", -30.0);
let shader = compose_layers_revealing(&stack, None, &chain);
let at = shader
.uniform_fields
.lines()
.filter(|l| l.trim_start().starts_with("mask"))
.position(|l| l.contains("mask0_contrast_amount"))
.expect("the layer carries its own contrast");
assert_eq!(
shader.uniform_values[at], -0.5,
"global -30 and local -20 is -50 inside the mask"
);
}
/// Where the operation runs is where the layer's version of it runs —
/// between the global operations either side, not after all of them.
#[test]
fn a_layer_runs_at_its_operations_place_in_the_chain() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(-20.0));
let src = composed_with(&stack, |c| set(c, "saturation", "saturation", 20.0));
let contrast = src.find("// ---- contrast ----").expect("contrast block");
let blend = src.find("mask_w0 * (c - local_global)").expect("blend");
let saturation = src
.find("// ---- saturation ----")
.expect("saturation block");
assert!(contrast < blend && blend < saturation);
}
/// **No setting is applied twice.** The layer's version of an operation
/// starts from the colour the operation was handed, not from the global
/// result, and reads only its own combined setting — so global −30 and
/// local −20 is one contrast of −50 inside the mask, never −30 and then
/// −50 again, and the operation does not run a second time after the
/// chain as it once did.
#[test]
fn a_setting_is_applied_once_not_stacked() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(-20.0));
let src = composed_with(&stack, |c| set(c, "contrast", "contrast", -30.0));
assert_eq!(
src.matches("// ---- contrast ----").count(),
1,
"contrast runs at one place in the chain"
);
let version = &src[src.find("if (mask_w0 > 0.0)").expect("layer version")..];
let version = &version[..version.find("local_sum = local_sum").unwrap()];
assert!(
version.contains("var c = local_in;"),
"starts from the operation's input"
);
assert!(version.contains("u.mask0_contrast_amount"));
assert!(
!version.contains("u.contrast_amount"),
"the global setting is already inside the combined one"
);
}
/// An offset that cancels the global setting is not nothing: inside the
/// mask the operation is back at neutral, so the layer's version is empty
/// and the blend pulls toward the colour the operation was handed.
#[test]
fn an_offset_back_to_neutral_undoes_the_global_setting() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(30.0));
let mut chain = ops::chain();
set(&mut chain, "contrast", "contrast", -30.0);
let shader = compose_layers_revealing(&stack, None, &chain);
let local: Vec<_> = shader.ops.iter().filter(|l| l.op == "contrast").collect();
assert_eq!(local.len(), 1);
assert!(local[0].fragment.is_empty());
}
/// A global chain with nothing moved still hands a layer's operation a
/// place to run: the global side of the blend is simply empty.
#[test]
fn an_operation_only_a_layer_moved_still_runs_in_its_place() {
let mut stack = MaskStack::new();
stack.push(contrast_layer(-20.0));
let src = composed_with(&stack, |_| {});
assert!(src.contains("// ---- contrast ----"));
assert!(!src.contains("(local only)"));
} }
#[test] #[test]
+222 -37
View File
@@ -307,6 +307,32 @@ pub trait Operation: Send + Sync {
/// shape it should take. /// shape it should take.
fn set_film_tables(&mut self, _tables: Option<&crate::ops::film_sim::FilmTables>) {} fn set_film_tables(&mut self, _tables: Option<&crate::ops::film_sim::FilmTables>) {}
/// TRACES: FR-DEV-3f
/// The stock's tables this operation holds, for a mask layer's copy of it.
///
/// The other half of [`Self::set_film_tables`]. A layer holds offsets,
/// never a stock of its own — the photograph is made on one film — so the
/// composer hands the global operation's tables to the layer's combined
/// copy through this pair.
fn film_tables(&self) -> Option<&crate::ops::film_sim::FilmTables> {
None
}
/// TRACES: FR-DEV-3 | FR-DEV-3f
/// Whether a mask layer's version of this operation is blended as
/// *settings* rather than as a result.
///
/// Default `false`: each layer's version runs on the same input as the
/// global one and the results are blended by weight, which is right for an
/// adjustment. An operation that answers `true` has every uniform linear
/// in what it controls, so the composer can blend the uniforms instead —
/// the weighted average of every overlapping layer's settings, the global
/// setting taking whatever weight the layers leave — and run the fragment
/// once. See `local_settings_block`.
fn blends_settings(&self) -> bool {
false
}
/// TRACES: FR-DEV-3e | FR-DEV-3f /// TRACES: FR-DEV-3e | FR-DEV-3f
/// Whether this operation *is* the rendering, rather than an adjustment to /// Whether this operation *is* the rendering, rather than an adjustment to
/// one. /// one.
@@ -597,10 +623,11 @@ pub fn compose_with_framing(
/// TRACES: FR-DEV-3 /// TRACES: FR-DEV-3
/// Compose the global chain, the framing, and the local adjustments. /// Compose the global chain, the framing, and the local adjustments.
/// ///
/// Mask layers are emitted **after** every global operation and before the /// A mask layer's settings are **offsets to the global ones**, applied at each
/// conversion out of camera space, so a local exposure acts on the tones the /// operation's own place in the chain: global contrast −30 and a face at −20
/// global chain settled on — which is what a photographer means by "and then /// is contrast −50 on the face, run where contrast runs. They once ran as a
/// lift the shadows on her face". /// second chain after every global operation, which compounded the two edits
/// in ways neither slider showed — see `mask::offset_onto`.
/// ///
/// The fused-dispatch property survives: three global adjustments and two /// The fused-dispatch property survives: three global adjustments and two
/// masked ones are still one shader, one read and one write. The masks /// masked ones are still one shader, one read and one write. The masks
@@ -858,48 +885,86 @@ fn compose_inner(
} }
} }
for op in &active { // TRACES: FR-DEV-3
// The local adjustments. Composed first because they are threaded through
// the loop below rather than appended after it: a layer's settings are
// offsets to the global ones, applied at each operation's own place in
// the chain (see `mask::offset_onto` for why). The weights are sampled
// here, once per pixel, ahead of every operation that reads them.
let layers = crate::mask::compose_layers_revealing(masks, reveal, ops);
body.push_str(&layers.weights);
// Every point operation that is active globally *or* in some layer. One
// that only a layer moved still runs here, at its place in the chain,
// with nothing on the global side of its blend.
for op in ops
.iter()
.map(|o| o.as_ref())
.filter(|o| o.detail().is_none())
{
let id = op.descriptor().id.0; let id = op.descriptor().id.0;
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
if !op.is_active() && local.is_empty() {
continue;
}
let prefix = sanitise(id); let prefix = sanitise(id);
// Each op's uniforms are prefixed, so two operations may both declare let mut fragment = String::new();
// a field called `amount` without colliding. let mut op_uniforms = Vec::new();
let op_uniforms = op.uniforms(); if op.is_active() {
if !op_uniforms.is_empty() { // Each op's uniforms are prefixed, so two operations may both
let _ = writeln!(uniform_fields, " // {id}"); // declare a field called `amount` without colliding.
} op_uniforms = op.uniforms();
for u in &op_uniforms { if !op_uniforms.is_empty() {
let _ = writeln!(uniform_fields, " {prefix}_{}: f32,", u.name); let _ = writeln!(uniform_fields, " // {id}");
uniform_values.push(u.value); }
} for u in &op_uniforms {
let _ = writeln!(uniform_fields, " {prefix}_{}: f32,", u.name);
for h in op.helpers() { uniform_values.push(u.value);
if !helpers.iter().any(|existing| existing.name == h.name) {
helpers.push(*h);
} }
}
// Rewrite bare uniform names to their prefixed struct fields, so a for h in op.helpers() {
// fragment is written without knowing about any other operation. if !helpers.iter().any(|existing| existing.name == h.name) {
let mut fragment = op.wgsl_body(); helpers.push(*h);
for u in &op_uniforms { }
fragment = rewrite_uniform(&fragment, u.name, &format!("u.{prefix}_{}", u.name)); }
fragment = op.wgsl_body();
} }
let _ = writeln!(body, "\n // ---- {id} ----"); let _ = writeln!(body, "\n // ---- {id} ----");
let _ = writeln!(body, " {{"); if op.blends_settings() && !local.is_empty() {
for line in fragment.lines() { // TRACES: FR-DEV-3f
let _ = writeln!(body, " {line}"); body.push_str(&local_settings_block(
&fragment,
&prefix,
&op_uniforms,
&local,
));
continue;
} }
let _ = writeln!(body, " }}"); // Rewrite bare uniform names to their prefixed struct fields, so a
// fragment is written without knowing about any other operation.
for u in &op_uniforms {
fragment = rewrite_uniform(&fragment, u.name, &format!("u.{prefix}_{}", u.name));
}
body.push_str(&local_block(&fragment, &local));
}
// A layer's operation the global chain does not hold at all. Not a case
// any editor produces — both chains come from `ops::chain` — but a layer
// must not lose an edit because a caller composed a shorter chain.
let mut orphans: Vec<&'static str> = Vec::new();
for l in &layers.ops {
if !orphans.contains(&l.op) && !ops.iter().any(|o| o.descriptor().id.0 == l.op) {
orphans.push(l.op);
}
}
for id in orphans {
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
let _ = writeln!(body, "\n // ---- {id} (local only) ----");
body.push_str(&local_block("", &local));
} }
// The local adjustments, after every global one: a masked exposure should
// act on the tones the global chain arrived at, not on the ones it started
// from. Their uniforms follow the global ops' in the block for the same
// reason those follow framing's — slot order is emission order, and
// nothing addresses a slot by number.
let layers = crate::mask::compose_layers_revealing(masks, reveal);
// TRACES: FR-DEV-19c // TRACES: FR-DEV-19c
// Held apart from the body, because it belongs after the output transform // Held apart from the body, because it belongs after the output transform
// rather than among the operations — see `mask::LayerShader::reveal`. // rather than among the operations — see `mask::LayerShader::reveal`.
@@ -908,7 +973,6 @@ fn compose_inner(
let reveal_block = layers.reveal.clone(); let reveal_block = layers.reveal.clone();
uniform_fields.push_str(&layers.uniform_fields); uniform_fields.push_str(&layers.uniform_fields);
uniform_values.extend_from_slice(&layers.uniform_values); uniform_values.extend_from_slice(&layers.uniform_values);
body.push_str(&layers.body);
for h in &layers.helpers { for h in &layers.helpers {
if !helpers.iter().any(|existing| existing.name == h.name) { if !helpers.iter().any(|existing| existing.name == h.name) {
helpers.push(*h); helpers.push(*h);
@@ -1255,6 +1319,125 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
} }
} }
/// TRACES: FR-DEV-3f
/// One operation's block when its layers are blended as *settings*: every
/// uniform the weighted average of the global value and each overlapping
/// layer's, and then the fragment once.
///
/// The weights: each layer's mask weight `w_i`, and the global setting
/// whatever the layers leave, `w_g = max(0, 1 − Σ w_i)`. So
///
/// ```text
/// p = (w_g·p_g + Σ w_i·p_i) / (w_g + Σ w_i)
/// ```
///
/// — one layer at weight `w` is `(1 − w)·p_g + w·p_1`, exactly the blend a
/// layer has always had, and three layers overlapping at full weight are the
/// plain mean of their three settings rather than one piled on another. A
/// layer's `p_i` is its combined setting (the global one plus its offset), so
/// the average is of what each layer *asks for*.
///
/// Only layers that move this operation take part. One that left it alone is
/// not voting for the global setting; it is not voting.
///
/// `fragment` is the operation's body with bare uniform names, as
/// `wgsl_body` wrote it: they are rewritten here to the blended values.
fn local_settings_block(
fragment: &str,
prefix: &str,
uniforms: &[Uniform],
local: &[&crate::mask::LocalOp],
) -> String {
let mut out = String::new();
let _ = writeln!(out, " {{");
let weights: Vec<String> = local.iter().map(|l| format!("mask_w{}", l.slot)).collect();
let _ = writeln!(out, " let set_w = {};", weights.join(" + "));
let _ = writeln!(out, " let set_g = max(1.0 - set_w, 0.0);");
// Never zero: the global weight is one wherever no layer reaches.
let _ = writeln!(out, " let set_n = max(set_g + set_w, 1e-6);");
let mut body = fragment.to_string();
for u in uniforms {
let mut sum = format!("set_g * u.{prefix}_{}", u.name);
for l in local {
let _ = write!(
sum,
" + mask_w{slot} * u.mask{slot}_{prefix}_{name}",
slot = l.slot,
name = u.name
);
}
let _ = writeln!(out, " let set_{} = ({sum}) / set_n;", u.name);
body = rewrite_uniform(&body, u.name, &format!("set_{}", u.name));
}
let _ = writeln!(out, " {{");
for line in body.lines() {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(out, " }}");
let _ = writeln!(out, " }}");
out
}
/// One operation's block: its global fragment, and each layer's version of it
/// blended in by that layer's weight.
///
/// Every version reads the same input — the colour as it arrived at this
/// operation — and the pixel moves from the global result by each layer's
/// difference from it: `c_g + Σ w_i (c_i − c_g)`. At full weight that is the
/// layer's combined setting exactly, at zero it is the global result exactly,
/// and two overlapping layers add their changes rather than one repainting
/// the other.
///
/// With no layer touching the operation this is the block the composer always
/// emitted, byte for byte: a photograph with no masks compiles to the shader
/// it did before layers were offsets.
fn local_block(global: &str, local: &[&crate::mask::LocalOp]) -> String {
let mut out = String::new();
let _ = writeln!(out, " {{");
if local.is_empty() {
for line in global.lines() {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(out, " }}");
return out;
}
let _ = writeln!(out, " let local_in = c;");
let _ = writeln!(out, " {{");
for line in global.lines() {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(out, " }}");
let _ = writeln!(out, " let local_global = c;");
let _ = writeln!(out, " var local_sum = c;");
for l in local {
let w = format!("mask_w{}", l.slot);
// Skipping where the mask is empty is most of the point of a local
// adjustment: a mask covering a tenth of the frame should cost about a
// tenth of the extra work. Safe as non-uniform control flow — nothing
// inside samples with derivatives or synchronises.
let _ = writeln!(out, " if ({w} > 0.0) {{");
// Fragments write to a `c` they expect to own, so the layer's version
// gets one of its own, shadowing the outer one and starting from what
// this operation was handed.
let _ = writeln!(out, " var c = local_in;");
let _ = writeln!(out, " {{");
for line in l.fragment.lines() {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(out, " }}");
let _ = writeln!(
out,
" local_sum = local_sum + {w} * (c - local_global);"
);
let _ = writeln!(out, " }}");
}
// Two layers pulling the same way can overshoot below zero, and a
// negative component poisons every operation after this one.
let _ = writeln!(out, " c = max(local_sum, vec3<f32>(0.0));");
let _ = writeln!(out, " }}");
out
}
/// The WGSL converting linear sRGB into the output space's primaries. /// The WGSL converting linear sRGB into the output space's primaries.
/// ///
/// A constant matrix rather than a uniform: the space is chosen when the /// A constant matrix rather than a uniform: the space is chosen when the
@@ -2070,7 +2253,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0, density_max: 3.0,
lut_size: 32, lut_size: 32,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [3.0; 3], grain_density_max: [3.0; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
})); }));
+234 -91
View File
@@ -22,9 +22,11 @@
//! //!
//! For the same reason [`crate::ops::vignetting`]'s coefficients are not: they //! For the same reason [`crate::ops::vignetting`]'s coefficients are not: they
//! are measurements of a physical thing, not something a slider moves. The //! are measurements of a physical thing, not something a slider moves. The
//! sliders here are exposure and print exposure, which are what a photographer //! sliders here are exposure, push, print exposure and format, which are what
//! and a printer actually control. `dr-film` turns a stock plus those two //! a photographer and a printer actually control. `dr-film` turns a stock into
//! numbers into [`FilmTables`]; this node knows only the layout. //! [`FilmTables`] that hold none of them; the shader applies all four per
//! pixel, which is what lets a mask layer hold its own (see
//! [`Operation::blends_settings`]). This node knows only the layout.
//! //!
//! Declared as a plain struct here rather than imported, so that dr-pipeline //! Declared as a plain struct here rather than imported, so that dr-pipeline
//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does //! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does
@@ -63,6 +65,15 @@ static FORMATS: [LocalizedKey; 6] = [
/// take; [`FilmTables::is_well_formed`] is what stops the two drifting. /// take; [`FilmTables::is_well_formed`] is what stops the two drifting.
pub const CURVE_SAMPLES: usize = 256; pub const CURVE_SAMPLES: usize = 256;
/// The most development times a stock may measure — a curve row and a push
/// station each. Must agree with `dr_film::bake::MAX_CURVE_ROWS`, for the
/// reason [`CURVE_SAMPLES`] must; the uniform block holds this many stations.
pub const MAX_CURVE_ROWS: usize = 8;
/// How many frames [`FORMATS`] offers, and so how many grain counts a stock
/// carries.
pub const FORMAT_COUNT: usize = 6;
/// The uniform field names the fragment reads the exposure matrix from. /// The uniform field names the fragment reads the exposure matrix from.
/// ///
/// A table rather than a formatted string, because a `Uniform`'s name is /// A table rather than a formatted string, because a `Uniform`'s name is
@@ -74,6 +85,24 @@ static MATRIX_FIELDS: [[&str; 3]; 3] = [
["m20", "m21", "m22"], ["m20", "m21", "m22"],
]; ];
/// Grains per pixel, per format and layer: `gn{format}{layer}`.
static GRAIN_FIELDS: [[&str; 3]; FORMAT_COUNT] = [
["gn00", "gn01", "gn02"],
["gn10", "gn11", "gn12"],
["gn20", "gn21", "gn22"],
["gn30", "gn31", "gn32"],
["gn40", "gn41", "gn42"],
["gn50", "gn51", "gn52"],
];
/// The push each curve row was developed to, padded with the last.
static PUSH_FIELDS: [&str; MAX_CURVE_ROWS] =
["ps0", "ps1", "ps2", "ps3", "ps4", "ps5", "ps6", "ps7"];
/// Which format this is, one-hot. See [`FilmSim::uniforms`] for why a choice
/// reaches the shader as six weights rather than an index.
static FORMAT_FIELDS: [&str; FORMAT_COUNT] = ["fmt0", "fmt1", "fmt2", "fmt3", "fmt4", "fmt5"];
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| { static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor { Arc::new(OpDescriptor {
// Tone and colour both, and not `Effect`: a stock is not something applied // Tone and colour both, and not `Effect`: a stock is not something applied
@@ -115,48 +144,86 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
/// Layout is the contract between the two crates, so it is written down here /// Layout is the contract between the two crates, so it is written down here
/// and checked rather than assumed: /// and checked rather than assumed:
/// ///
/// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel `c`. /// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel
/// - `curves` — `CURVE_SAMPLES` density triples, uniform over /// `c`, at unit gain: camera exposure is a per-pixel setting.
/// `[curve_log_min, curve_log_max]`. /// - `curves` — one row of `CURVE_SAMPLES` density triples per
/// - `lut` — `lut_size³` linear sRGB triples, uniform over `[0, density_max]` /// `push_stations` entry, uniform over `[curve_log_min, curve_log_max]`,
/// on each axis, with the **red axis varying fastest**: index /// and then, when printed, one more row: the paper's, uniform over
/// `(b * size + g) * size + r`. That is the order a 3D texture upload /// `[paper.log_min, paper.log_max]`.
/// expects, so the consumer hands the slice straight to the driver. Filling /// - `lut` — `lut_size³` triples uniform over `[0, density_max]` on each
/// it the other way round transposes red and blue in the finished picture — /// axis, with the **red axis varying fastest**: index
/// which is a plausible photograph of the wrong colour, and which the unit /// `(b * size + g) * size + r`. Linear sRGB when the film is viewed
/// tests on both sides of this seam happily pass, because each side is /// directly; the paper's log₁₀ exposure through the negative when it is
/// internally consistent. `dr-film` pins it; `dr-gpu`'s `film_sim` test /// printed, followed by a second cube, paper density over
/// catches it end to end. /// `[0, paper.density_max]` to linear sRGB. That is the order a 3D texture
/// upload expects with the cubes stacked in depth, so the consumer hands the
/// slice straight to the driver. Filling it the other way round transposes
/// red and blue in the finished picture — which is a plausible photograph
/// of the wrong colour, and which the unit tests on both sides of this seam
/// happily pass, because each side is internally consistent. `dr-film` pins
/// it; `dr-gpu`'s `film_sim` test catches it end to end.
///
/// Everything the sliders move — exposure, push, print exposure, format — is
/// absent. They are per-pixel settings the shader applies against these
/// tables, which is what lets a mask layer hold its own.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct FilmTables { pub struct FilmTables {
pub exposure_matrix: [[f32; 3]; 3], pub exposure_matrix: [[f32; 3]; 3],
pub curves: Vec<[f32; 3]>, pub curves: Vec<[f32; 3]>,
/// The push each film row was developed to, ascending: one entry for a
/// stock measured at a single process.
pub push_stations: Vec<f32>,
pub curve_log_min: f32, pub curve_log_min: f32,
pub curve_log_max: f32, pub curve_log_max: f32,
pub lut: Vec<[f32; 3]>, pub lut: Vec<[f32; 3]>,
pub density_max: f32, pub density_max: f32,
pub lut_size: usize, pub lut_size: usize,
/// The print, for a negative printed on paper.
pub paper: Option<PaperTables>,
/// TRACES: FR-DEV-3f /// TRACES: FR-DEV-3f
/// Grains in one pixel's patch of film, per layer, with the density /// Grains in one pixel's patch of film, per format and then per layer,
/// ceiling and uniformity the variance is taken against. Zero particles /// with the density ceiling and uniformity the variance is taken against.
/// means no grain, which is how the control is turned off. /// Zero particles means no grain, which is how the control is turned off.
pub grain_particles: [f32; 3], pub grain_particles: [[f32; 3]; FORMAT_COUNT],
pub grain_density_max: [f32; 3], pub grain_density_max: [f32; 3],
pub grain_uniformity: f32, pub grain_uniformity: f32,
} }
/// The print half of [`FilmTables`]: where the paper's row and cube are read.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaperTables {
/// The enlarger's filtration, per layer, in log₁₀ exposure.
pub balance: [f32; 3],
pub log_min: f32,
pub log_max: f32,
pub density_max: f32,
}
impl FilmTables { impl FilmTables {
/// Film rows, not counting the paper's.
pub fn curve_rows(&self) -> usize {
self.push_stations.len()
}
/// Whether these tables are the shape the shader will index them at. /// Whether these tables are the shape the shader will index them at.
/// ///
/// Checked on the way in, because the failure otherwise is a shader /// Checked on the way in, because the failure otherwise is a shader
/// sampling past the end of a texture: undefined, silent, and different on /// sampling past the end of a texture: undefined, silent, and different on
/// every driver. /// every driver.
pub fn is_well_formed(&self) -> bool { pub fn is_well_formed(&self) -> bool {
self.curves.len() == CURVE_SAMPLES let rows = self.curve_rows();
let printed = usize::from(self.paper.is_some());
let paper_ok = self
.paper
.is_none_or(|p| p.density_max > 0.0 && p.log_max > p.log_min);
(1..=MAX_CURVE_ROWS).contains(&rows)
&& self.push_stations.windows(2).all(|w| w[0] < w[1])
&& self.curves.len() == CURVE_SAMPLES * (rows + printed)
&& self.lut_size >= 2 && self.lut_size >= 2
&& self.lut.len() == self.lut_size.pow(3) && self.lut.len() == self.lut_size.pow(3) * (1 + printed)
&& self.density_max > 0.0 && self.density_max > 0.0
&& self.curve_log_max > self.curve_log_min && self.curve_log_max > self.curve_log_min
&& paper_ok
} }
} }
@@ -246,60 +313,80 @@ impl Operation for FilmSim {
self.set_tables(tables.cloned()); self.set_tables(tables.cloned());
} }
fn film_tables(&self) -> Option<&FilmTables> {
self.tables.as_ref()
}
/// TRACES: FR-DEV-3f
/// A layer's film is its settings, not its own picture blended over the
/// global one.
///
/// Blending outputs would be a photograph developed twice and cross-faded;
/// a region on a pushed film is not that. Every uniform below is linear
/// in what it controls, so the composer can take each layer's weighted
/// average of them and develop the pixel once.
fn blends_settings(&self) -> bool {
true
}
/// Every value here is linear in what the shader does with it, which is
/// what [`Self::blends_settings`] rests on. The format is the one that
/// needs arranging: an index averaged between layers is a format nobody
/// chose, so it goes out one-hot and the shader mixes the six grain
/// counts by it — two layers on 35 mm and 6x7 meet at the average grain.
fn uniforms(&self) -> Vec<Uniform> { fn uniforms(&self) -> Vec<Uniform> {
let Some(t) = &self.tables else { let Some(t) = &self.tables else {
return Vec::new(); return Vec::new();
}; };
let m = t.exposure_matrix; let mut out = Vec::with_capacity(64);
// Exposure rides in the matrix on the CPU when the stock is baked, so let mut push = |name: &'static str, value: f32| out.push(Uniform { name, value });
// what is left here is the *shader's* copy of the same nine numbers. for (l, row) in t.exposure_matrix.iter().enumerate() {
// Spelled out one at a time because a uniform is a named `f32` in this
// pipeline and a matrix would be a second kind of thing for one caller.
let mut out = Vec::with_capacity(MATRIX_FIELDS.len() + 5);
for (l, row) in m.iter().enumerate() {
for (c, v) in row.iter().enumerate() { for (c, v) in row.iter().enumerate() {
out.push(Uniform { push(MATRIX_FIELDS[l][c], *v);
name: MATRIX_FIELDS[l][c],
value: *v,
});
} }
} }
for (l, name) in ["gn0", "gn1", "gn2"].into_iter().enumerate() { for (f, per_layer) in t.grain_particles.iter().enumerate() {
out.push(Uniform { for (l, v) in per_layer.iter().enumerate() {
name, push(GRAIN_FIELDS[f][l], *v);
value: t.grain_particles[l], }
});
} }
for (l, name) in ["gd0", "gd1", "gd2"].into_iter().enumerate() { for (l, name) in ["gd0", "gd1", "gd2"].into_iter().enumerate() {
out.push(Uniform { push(name, t.grain_density_max[l]);
name,
value: t.grain_density_max[l],
});
} }
out.push(Uniform { push("grain_u", t.grain_uniformity);
name: "grain_u", push("log_min", t.curve_log_min);
value: t.grain_uniformity, push("log_max", t.curve_log_max);
}); push("density_max", t.density_max);
out.push(Uniform { push("lut_size", t.lut_size as f32);
name: "log_min",
value: t.curve_log_min, let last = *t.push_stations.last().unwrap_or(&0.0);
}); for (i, name) in PUSH_FIELDS.into_iter().enumerate() {
out.push(Uniform { push(name, t.push_stations.get(i).copied().unwrap_or(last));
name: "log_max", }
value: t.curve_log_max, push("rows", t.curve_rows() as f32);
});
out.push(Uniform { let paper = t.paper.unwrap_or(PaperTables {
name: "density_max", balance: [0.0; 3],
value: t.density_max, log_min: 0.0,
}); log_max: 1.0,
out.push(Uniform { density_max: 1.0,
name: "lut_size",
value: t.lut_size as f32,
});
out.push(Uniform {
name: "print_exposure",
value: self.print_exposure,
}); });
push("printed", if t.paper.is_some() { 1.0 } else { 0.0 });
for (l, name) in ["pb0", "pb1", "pb2"].into_iter().enumerate() {
push(name, paper.balance[l]);
}
push("plog_min", paper.log_min);
push("plog_max", paper.log_max);
push("pdmax", paper.density_max);
// The sliders.
push("ev", self.exposure);
push("push", self.push);
push("pev", self.print_exposure);
let chosen = (self.format.max(0.0).round() as usize).min(FORMAT_COUNT - 1);
for (f, name) in FORMAT_FIELDS.into_iter().enumerate() {
push(name, if f == chosen { 1.0 } else { 0.0 });
}
out out
} }
@@ -321,8 +408,9 @@ let scene = vec3<f32>(
// What each emulsion layer was exposed to. A matrix, exactly: the scene // What each emulsion layer was exposed to. A matrix, exactly: the scene
// spectrum reconstructed from an sRGB triple is linear in that triple, so the // spectrum reconstructed from an sRGB triple is linear in that triple, so the
// integral over wavelength collapsed into these nine numbers when the stock // integral over wavelength collapsed into these nine numbers when the stock
// was baked. // was baked. The camera's exposure is a gain on it, applied here rather than
let exposure = vec3<f32>( // baked in so that a layer can hold its own.
let exposure = exp2(ev) * vec3<f32>(
dot(vec3<f32>(m00, m01, m02), scene), dot(vec3<f32>(m00, m01, m02), scene),
dot(vec3<f32>(m10, m11, m12), scene), dot(vec3<f32>(m10, m11, m12), scene),
dot(vec3<f32>(m20, m21, m22), scene), dot(vec3<f32>(m20, m21, m22), scene),
@@ -331,12 +419,16 @@ let exposure = vec3<f32>(
// the curve, and the toe is where it belongs. // the curve, and the toe is where it belongs.
let log_exposure = log10(max(exposure, vec3<f32>(0.0)) + 1e-10); let log_exposure = log10(max(exposure, vec3<f32>(0.0)) + 1e-10);
// The characteristic curve: what density each layer develops to. Clamped, not // The characteristic curve: what density each layer develops to, at this
// extrapolated — past the shoulder a real emulsion stops responding, and // pixel's push. Clamped, not extrapolated — past the shoulder a real emulsion
// extrapolating would turn a blown highlight into a colour cast that grows the // stops responding, and extrapolating would turn a blown highlight into a
// more it is overexposed. // colour cast that grows the more it is overexposed.
let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min), let density = film_curve_pushed(
vec3<f32>(0.0), vec3<f32>(1.0))); clamp((log_exposure - log_min) / (log_max - log_min), vec3<f32>(0.0), vec3<f32>(1.0)),
push,
array<f32, 8>(ps0, ps1, ps2, ps3, ps4, ps5, ps6, ps7),
u32(rows),
);
// TRACES: FR-DEV-3f // TRACES: FR-DEV-3f
// Grain, on the density and before the dye. // Grain, on the density and before the dye.
@@ -346,16 +438,40 @@ let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min),
// through whatever density resulted. Adding noise to the finished colour -- // through whatever density resulted. Adding noise to the finished colour --
// which is what an effect does -- tints the highlights wrong, because that // which is what an effect does -- tints the highlights wrong, because that
// noise never passes through the dye at all. // noise never passes through the dye at all.
let grained = film_grain(density, source_px, //
vec3<f32>(gn0, gn1, gn2), // The format's grain count, mixed by the one-hot weights: exactly one format's
// on the whole photograph, and the weighted average under overlapping layers.
let particles = fmt0 * vec3<f32>(gn00, gn01, gn02)
+ fmt1 * vec3<f32>(gn10, gn11, gn12)
+ fmt2 * vec3<f32>(gn20, gn21, gn22)
+ fmt3 * vec3<f32>(gn30, gn31, gn32)
+ fmt4 * vec3<f32>(gn40, gn41, gn42)
+ fmt5 * vec3<f32>(gn50, gn51, gn52);
let grained = film_grain(density, source_px, particles,
vec3<f32>(gd0, gd1, gd2), vec3<f32>(gd0, gd1, gd2),
grain_u); grain_u);
// Dye absorption, the print through the negative, the paper, the viewing // Dye absorption through to what comes next — all of it takes exactly three
// illuminant and the chromatic adaptation — all of which take exactly three // numbers in, which is why it fits in one lookup. Viewed directly, that is
// numbers in, which is why they fit in one lookup. // the picture; printed, it is the light the paper receives through the
c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_size);" // negative, in log exposure.
.into() let through = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)),
lut_size, 0);
if (printed > 0.5) {
// The enlarger: its filtration, and then its exposure, the same stops on
// every layer — which is why print exposure is an addition here and not
// a table, and so exact at any setting.
let paper_log = through + vec3<f32>(pb0, pb1, pb2) + pev * 0.30103;
let paper_density = film_curve(
clamp((paper_log - plog_min) / (plog_max - plog_min), vec3<f32>(0.0), vec3<f32>(1.0)),
u32(rows),
);
c = film_lut(clamp(paper_density / pdmax, vec3<f32>(0.0), vec3<f32>(1.0)),
lut_size, i32(lut_size));
} else {
c = through;
}"
.into()
} }
fn helpers(&self) -> &'static [crate::operation::Helper] { fn helpers(&self) -> &'static [crate::operation::Helper] {
@@ -363,7 +479,7 @@ c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_s
} }
} }
static HELPERS: [crate::operation::Helper; 5] = [ static HELPERS: [crate::operation::Helper; 6] = [
crate::operation::Helper { crate::operation::Helper {
name: "film_hash", name: "film_hash",
source: "\ source: "\
@@ -453,9 +569,9 @@ fn log10(v: vec3<f32>) -> vec3<f32> {
crate::operation::Helper { crate::operation::Helper {
name: "film_curve", name: "film_curve",
source: "\ source: "\
// Three characteristic curves, sampled from a 256-wide texture and // Three characteristic curves, one row of a 256-wide texture, interpolated by
// interpolated by hand. `t` is already normalised to the curve's domain. // hand. `t` is already normalised to the curve's domain.
fn film_curve(t: vec3<f32>) -> vec3<f32> { fn film_curve(t: vec3<f32>, row: u32) -> vec3<f32> {
let samples = u32(textureDimensions(film_curves).x); let samples = u32(textureDimensions(film_curves).x);
let last = f32(samples - 1u); let last = f32(samples - 1u);
var out = vec3<f32>(0.0); var out = vec3<f32>(0.0);
@@ -463,20 +579,45 @@ fn film_curve(t: vec3<f32>) -> vec3<f32> {
let x = t[ch] * last; let x = t[ch] * last;
let i = min(u32(floor(x)), samples - 2u); let i = min(u32(floor(x)), samples - 2u);
let f = x - f32(i); let f = x - f32(i);
let a = textureLoad(film_curves, vec2<i32>(i32(i), 0), 0); let a = textureLoad(film_curves, vec2<i32>(i32(i), i32(row)), 0);
let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, 0), 0); let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, i32(row)), 0);
out[ch] = mix(a[ch], b[ch], f); out[ch] = mix(a[ch], b[ch], f);
} }
return out; return out;
}",
},
crate::operation::Helper {
name: "film_curve_pushed",
source: "\
// The curves at a push between two measured processes. Development is
// interpolated in log time and push *is* log time, so a straight line between
// the neighbouring rows is the stock's own interpolation, not an estimate of
// it. Clamped to the first and last process, as the stock is.
fn film_curve_pushed(t: vec3<f32>, push: f32, stations: array<f32, 8>, rows: u32) -> vec3<f32> {
if (rows < 2u) {
return film_curve(t, 0u);
}
var at = stations;
var hi = rows - 1u;
for (var i = 1u; i < rows; i = i + 1u) {
if (at[i] >= push) {
hi = i;
break;
}
}
let lo = hi - 1u;
let f = clamp((push - at[lo]) / max(at[hi] - at[lo], 1e-6), 0.0, 1.0);
return mix(film_curve(t, lo), film_curve(t, hi), f);
}", }",
}, },
crate::operation::Helper { crate::operation::Helper {
name: "film_lut", name: "film_lut",
source: "\ source: "\
// Trilinear interpolation of the density lookup, by hand for the same reason // Trilinear interpolation of one cube of the lookup, by hand for the same
// the curve above is: there is no sampler bound, and the eight loads are // reason the curve above is: there is no sampler bound, and the eight loads
// cache-neighbours. // are cache-neighbours. `z0` is where the cube starts in depth: the film's at
fn film_lut(t: vec3<f32>, size: f32) -> vec3<f32> { // zero, the paper's stacked after it.
fn film_lut(t: vec3<f32>, size: f32, z0: i32) -> vec3<f32> {
let n = i32(size); let n = i32(size);
let x = t * (size - 1.0); let x = t * (size - 1.0);
let base = min(vec3<i32>(floor(x)), vec3<i32>(n - 2)); let base = min(vec3<i32>(floor(x)), vec3<i32>(n - 2));
@@ -489,7 +630,7 @@ fn film_lut(t: vec3<f32>, size: f32) -> vec3<f32> {
let wy = select(1.0 - f.y, f.y, dy == 1); let wy = select(1.0 - f.y, f.y, dy == 1);
for (var dz = 0; dz < 2; dz = dz + 1) { for (var dz = 0; dz < 2; dz = dz + 1) {
let wz = select(1.0 - f.z, f.z, dz == 1); let wz = select(1.0 - f.z, f.z, dz == 1);
let p = base + vec3<i32>(dx, dy, dz); let p = base + vec3<i32>(dx, dy, dz + z0);
out = out + wx * wy * wz out = out + wx * wy * wz
* textureLoad(film_lut_texture, p, 0).rgb; * textureLoad(film_lut_texture, p, 0).rgb;
} }
@@ -513,7 +654,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0, density_max: 3.0,
lut_size: 32, lut_size: 32,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [3.0; 3], grain_density_max: [3.0; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
} }
+1 -1
View File
@@ -82,7 +82,7 @@ pub use colour_mixer::ColourMixer;
pub use curve::ToneCurve; pub use curve::ToneCurve;
pub use dehaze::Dehaze; pub use dehaze::Dehaze;
pub use distortion::Distortion; pub use distortion::Distortion;
pub use film_sim::{FilmSim, FilmTables}; pub use film_sim::{FilmSim, FilmTables, PaperTables};
// Clarity and texture are one implementation at two scales; see the module's // Clarity and texture are one implementation at two scales; see the module's
// documentation for why that is two nodes and not one. // documentation for why that is two nodes and not one.
pub use local_contrast::{Clarity, Texture}; pub use local_contrast::{Clarity, Texture};
+3 -1
View File
@@ -1280,7 +1280,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8], lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0, density_max: 2.0,
lut_size: 2, lut_size: 2,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [2.0; 3], grain_density_max: [2.0; 3],
grain_uniformity: 1.0, grain_uniformity: 1.0,
}, },
+3 -1
View File
@@ -2023,7 +2023,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8], lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0, density_max: 2.0,
lut_size: 2, lut_size: 2,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [2.0; 3], grain_density_max: [2.0; 3],
grain_uniformity: 1.0, grain_uniformity: 1.0,
}, },
+3 -1
View File
@@ -235,7 +235,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8], lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0, density_max: 2.0,
lut_size: 2, lut_size: 2,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [2.0; 3], grain_density_max: [2.0; 3],
grain_uniformity: 1.0, grain_uniformity: 1.0,
}, },
+31 -1
View File
@@ -347,6 +347,8 @@ RawImage (sensor data, CPU)
│ upload │ upload
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ hot/dead photosites │ repaired on the mosaic (FR-RAW-3)
├─────────────────────┤
│ black/white levels │ integer normalise │ black/white levels │ integer normalise
├─────────────────────┤ ├─────────────────────┤
│ demosaic │ Bayer or Markesteijn (X-Trans, FR-RAW-5) │ demosaic │ Bayer or Markesteijn (X-Trans, FR-RAW-5)
@@ -379,8 +381,29 @@ RawImage (sensor data, CPU)
Working precision is f16 in a linear wide-gamut space, quantising once at the output transform. Working precision is f16 in a linear wide-gamut space, quantising once at the output transform.
**A hot or dead photosite is repaired before the demosaic, not after.** Past it, one photosite of
nonsense is a coloured cross three pixels wide that no later stage can tell from detail. The pass
(`shaders/hot_pixels.wgsl`, run by `Demosaicer::run` into a second buffer) replaces a photosite that
stands apart from every same-colour photosite in its 5×5 window *and* from each of its eight
immediate neighbours with the nearest value that neighbourhood vouches for. The second test is what
keeps a star: real light arrives through a lens and lights a patch, so its neighbours are lit too. A
6×6 sensor-anchored colour tile serves Bayer and X-Trans alike, every path that demosaics gets it,
and there is no setting.
**A mask layer runs inside this chain, not after it.** Its settings are offsets to the global ones
(`mask::offset_onto`), and each operation a layer touches is composed at the operation's own place:
the global fragment and each layer's combined fragment read the same input, and the pixel moves by
each layer's weighted difference, `c_g + Σ wᵢ(cᵢ − c_g)`. At full weight that is the combined
setting exactly and at zero the global result exactly, so global contrast −30 under a layer at −20
is contrast −50 where contrast runs, never −30 now and −20 again later — which is what the layer
chain did before 0.18.1, and how the shadows of a night shot went magenta. A photograph with no
layers composes to the same shader byte for byte. The film is the one exception, because it is a
rendering rather than an adjustment and cross-fading two developments is not what a region of a
pushed negative looks like: an operation that `blends_settings` has its uniforms averaged by mask
weight instead, and runs once (FR-DEV-3f).
There is a second reduction that does not hang off the bottom of this chain. The raw histogram There is a second reduction that does not hang off the bottom of this chain. The raw histogram
(FR-CULL-3) taps the demosaiced scene-linear texture directly — the box four rows from the top — (FR-CULL-3) taps the demosaiced scene-linear texture directly — the demosaic box's output —
because what it measures is the file rather than the render. See §5.5. because what it measures is the file rather than the render. See §5.5.
### 5.3 Tiling and scheduling ### 5.3 Tiling and scheduling
@@ -593,6 +616,13 @@ does not silently become uploadable by existing.
The UI executor never blocks — this is the mechanism behind R4 and NFR-P9, which the requirements The UI executor never blocks — this is the mechanism behind R4 and NFR-P9, which the requirements
state as outcomes without saying how. state as outcomes without saying how.
**In code:** `ui/dr-ui/src/executors.rs`. `Executor` names the five and states each count with its
reason (`Executor::threads`); `executors::spawn(executor, role, f)` starts a worker named
`<executor>:<role>` and marks it with its executor. `run` marks its own thread as the UI executor
before the window exists, and `executors::assert_not_ui` — called by `net_runtime`'s `block_on` —
fails a debug or test build that blocks there. The counts are not yet enforced: a job still gets a
thread of its own, and pooling by executor is where NFR-ARCH-2's priority classes will live.
### 7.2 Cancellation ### 7.2 Cancellation
Cooperative, with tokens threaded through every long operation. Observed within 100 ms Cooperative, with tokens threaded through every long operation. Observed within 100 ms
+13 -4
View File
@@ -510,8 +510,11 @@ Failures increment `attempts` and set `not_before` to an exponential backoff. Af
count the job is marked failed and attached to its image as a typed error (NFR-ARCH-4) — one count the job is marked failed and attached to its image as a typed error (NFR-ARCH-4) — one
corrupt file does not stall the queue, and the user can see which files failed and why. corrupt file does not stall the queue, and the user can see which files failed and why.
**A job runner never touches the UI executor**, and `Interactive` work runs on the decode pool with **A job runner never touches the UI executor**, and `Interactive` work runs on the decode executor
the I/O pool behind it (ARCH §7.1). with the I/O executor behind it (ARCH §7.1). Those are named rather than pooled today: thumbnails
on demand start as `decode:thumbs` and the sweeps as `decode:thumb-sweep` and `decode:metadata`,
through `dr_ui::executors::spawn` and each on a thread of its own, and the thread counts §7.1 gives
are a budget nothing yet enforces (NFR-ARCH-1).
--- ---
@@ -723,7 +726,12 @@ merges reuses those rules or keys on the same identities, and each has no other
- **Collections and their membership** — by uuid and revision, membership as a set union. - **Collections and their membership** — by uuid and revision, membership as a set union.
- **Keywords** — the vocabulary by the same verdict, the assignments as a union. - **Keywords** — the vocabulary by the same verdict, the assignments as a union.
- **People and identity judgements** — people by uuid and revision, and the confirmed and rejected - **People and identity judgements** — people by uuid and revision, and the confirmed and rejected
face assignments matched to local faces by box (`merge::match_faces`). face assignments matched to local faces (`merge::match_faces`): by box first, and — since 0.18.0,
only on the photographs where a remote face is left over — by embedding, a pair being accepted
at cosine ≥ 0.7 when each is the other's best by a lead of ≥ 0.2 (#77; [faces.md §18.2](faces.md)). After every merge,
`dedup_people` folds people of one name whose confirmed faces agree, and a face held twice in
one photograph, through the ordinary `merged_into` redirect, which older builds already honour
(#78; [faces.md §19](faces.md)).
- **Albums** (FR-EXP-10, 0.17.0) — by uuid and revision with tombstones, and what went into each as - **Albums** (FR-EXP-10, 0.17.0) — by uuid and revision with tombstones, and what went into each as
a set union keyed on the server's file id (a content hash on a folder library). An album's a set union keyed on the server's file id (a content hash on a folder library). An album's
server folder is a column of its row and travels with it; a folder on *this device* is in server folder is a column of its row and travels with it; a folder on *this device* is in
@@ -754,7 +762,7 @@ it goes anywhere.
**The face crops stay out of the upload.** A crop is a ~5 KB JPEG on each `faces` row. On a 19k-face **The face crops stay out of the upload.** A crop is a ~5 KB JPEG on each `faces` row. On a 19k-face
library they are 96 MB of a 158 MB catalog. The face shards carry them to other devices, once each. library they are 96 MB of a 158 MB catalog. The face shards carry them to other devices, once each.
The merge reads a remote face's box and model to match it to a local one, never its pixels. No The merge reads a remote face's box and model to match it to a local one — and, where the boxes cannot decide, its embedding — never its pixels. No
device adopts a downloaded catalog as its own: a fresh device starts empty and takes faces, crops device adopts a downloaded catalog as its own: a fresh device starts empty and takes faces, crops
included, from the shards. So the snapshot's `crop` is NULL, and a merge never writes a local included, from the shards. So the snapshot's `crop` is NULL, and a merge never writes a local
crop. They were first stripped (2026-08) by copying the whole file with the backup API, setting crop. They were first stripped (2026-08) by copying the whole file with the backup API, setting
@@ -778,6 +786,7 @@ integer ids stay local and are never compared across catalogs.
| Deletion | Tombstone (`deleted = 1`) carrying a revision | Without it, merging against a device that still holds the collection resurrects it. With a revision, deletion competes on equal footing with a rename | | Deletion | Tombstone (`deleted = 1`) carrying a revision | Without it, merging against a device that still holds the collection resurrects it. With a revision, deletion competes on equal footing with a rename |
| An image the remote has and we do not | Skip the membership row | It joins on a later merge, once a scan has catalogued the file. Not an error | | An image the remote has and we do not | Skip the membership row | It joins on a later merge, once a scan has catalogued the file. Not an error |
| A remote from a newer schema | Decline before attaching | Attempting it would fail mid-transaction rather than declining cleanly | | A remote from a newer schema | Decline before attaching | Attempting it would fail mid-transaction rather than declining cleanly |
| People with the same name | Folded after each merge when their faces agree ([faces.md §19](faces.md)) | Names typed separately on two devices otherwise stay two people for ever |
Merging is idempotent: running it twice reports no changes the second time. That property is tested, Merging is idempotent: running it twice reports no changes the second time. That property is tested,
because a merge that oscillates would upload on every sync forever. because a merge that oscillates would upload on every sync forever.
+34 -2
View File
@@ -1575,5 +1575,37 @@ It is a match, not an update in place, and that is why the per-face repairs exis
detection: where nothing about a face but one field needs doing, `record_updates` keeps the id and detection: where nothing about a face but one field needs doing, `record_updates` keeps the id and
there is nothing to judge. there is nothing to judge.
The merge's `match_faces` still matches by overlap alone across devices. It is the same question, Since #77 (0.18.0) the merge's `match_faces` answers it too, within a photograph's `file_id` and
and the same answer would serve it; it is not changed here. one embedder: box IoU ≥ 0.5, unique on both sides, first; then, only for photographs where a remote
face is left over and a local face is free, embedding cosine ≥ 0.7, mutual best, with a lead of
≥ 0.2 over the runner-up on both sides. A box match is never overruled by a low cosine (about 150
genuine cross-device pairs of tiny faces score below 0.45). On the reference desktop/tablet pair this
recovers 20 of 631 unmatched faces with no false matches; the rest are faces one device alone found.
The merge also keeps one person to one face per photograph: an incoming assignment is refused when
another local face already holds that person, unless it is a remote confirmation over a local
suggestion, which moves the suggestion. Refusals are counted in `faces_one_per_photograph`.
The threshold differs from `SAME_FACE_COSINE` (0.45) above on purpose: re-detection additionally
requires the boxes to overlap, while the merge's embedding route exists for boxes that don't.
## 19. Deduplicating people · 2026-09-26
`dr_catalog::dedup_people::run` runs after every successful sync merge (`sync::merge_remote`, on the
sync worker), in one transaction, and logs one `dedup:` line (#78).
**People.** Named people with the same name, trimmed and case-folded, merge into the one with the
most confirmed faces (ties go to the smaller uuid) when every shared embedder's confirmed-face
centroids agree at cosine ≥ 0.7 (distance < 0.3). Each side needs at least two confirmed faces to
compare; a namesake holding no faces merges outright; a face confirmed as one and rejected as the
other keeps them apart; unnamed and set-aside people are never touched. On the reference library the
same-person centroid median is 0.91, and different named people have a 99.9th percentile of 0.41.
**Faces.** Two faces in the same image and embedder with IoU ≥ 0.5 and cosine ≥ 0.7 are one: the
job keeps the stronger detector's face (`FaceDetector::outranks`), then the confirmed one, then the
lower id, and it takes both faces' assignment and rejections.
**Propagation.** The merge is `faces::merge_people`, whose `merged_into` redirect a 0.17.0 peer
already honours, so an older device never resurrects the duplicate. The job also follows redirects
left by earlier manual merges, moving this device's own assignments onto the person kept, and
breaks a mutual redirect at the smaller uuid, which every device computes alike. A merge now also
carries the merged-away person's rejections to the person kept.
+6 -3
View File
@@ -325,9 +325,12 @@ without measuring them:
Stated because §7 of [display-and-extension.md](display-and-extension.md) asks Stated because §7 of [display-and-extension.md](display-and-extension.md) asks
for it, and because each of these could move the numbers. for it, and because each of these could move the numbers.
- **Local adjustments.** The mask stack is a separate chain per layer and is not - **Local adjustments.** Not in any row above. Since 0.18.1 a layer is no longer
in any row above. `render_masked` takes them and the fused shader addresses a separate chain after the global one: each operation a layer touches runs a
them per layer, so a heavily masked edit costs more than `all`. second fragment at its own place in the chain, blended by the layer's mask
([architecture.md §5.2](architecture.md)), and `render_masked` binds the mask
array the fused shader samples. A heavily masked edit therefore costs more
than `all`, by roughly one fragment per touched operation per layer.
- **Spot repairs.** These add detail passes, and their cost is per spot. - **Spot repairs.** These add detail passes, and their cost is per spot.
- **Lens corrections.** Not part of `EditGraph::default_chain` — they are built - **Lens corrections.** Not part of `EditGraph::default_chain` — they are built
from a matched profile — so the `point` row does not include the warp chain. from a matched profile — so the `point` row does not include the warp chain.
+43 -10
View File
@@ -38,6 +38,14 @@ work of 0.15.0 and 0.16.0 left open.
in §5 is rewritten around what the Flatpak has still not shown; albums brought the first SAF code, in §5 is rewritten around what the Flatpak has still not shown; albums brought the first SAF code,
which FR-PLAT-AND-1's entry now describes, along with why its new tag overstates it. which FR-PLAT-AND-1's entry now describes, along with why its new tag overstates it.
**And for 0.18.2.** FR-CULL-13's write-path half is held by a test now, and §2 records the evidence
half it leaves; NFR-ARCH-1's executors are named and guarded but not pooled, recorded in §4. Three
things closed without having had an entry: a mask layer's film settings, which the panel offered
and which moved nothing until 0.18.2 (FR-DEV-3f); hot and dead photosites, repaired on the mosaic
before the demosaic, for Bayer and X-Trans alike and with no setting (FR-RAW-3; the defect-map
reader in `dr-decode` is still not wired in, and a CR2 carries no map for it to read); and the
tablet's scrollers, which §4a now describes.
--- ---
## 1. Plugins — post-v1 since 2026-09-19 ## 1. Plugins — post-v1 since 2026-09-19
@@ -91,7 +99,7 @@ somebody reads the matrix.
## 2. Culling — the stated differentiator, half built ## 2. Culling — the stated differentiator, half built
[D11](requirements.md) names culling "the core differentiator". FR-CULL-1 through -5 and -8 through [D11](requirements.md) names culling "the core differentiator". FR-CULL-1 through -5 and -8 through
-13 are built. Two are not. -12 are built, and FR-CULL-13 is half built. Two are not built at all.
**FR-CULL-3 — Raw-truth overlays. Built, all three bullets.** Focus peaking is **FR-CULL-3 — Raw-truth overlays. Built, all three bullets.** Focus peaking is
`core/dr-gpu/src/focus.rs` and `ui/dr-ui/src/peaking.rs`; the raw histogram and the raw clipping `core/dr-gpu/src/focus.rs` and `ui/dr-ui/src/peaking.rs`; the raw histogram and the raw clipping
@@ -133,6 +141,17 @@ capture instant and size), `ui/dr-ui/src/duplicates.rs` proves each group the sa
compares their edits, and a group is folded onto one survivor with the others trashed in one compares their edits, and a group is folded onto one survivor with the others trashed in one
transaction, from the Duplicate originals review in the sidebar and in Settings. transaction, from the Duplicate originals review in the sidebar and in Settings.
**FR-CULL-13 — Evidence, never verdicts. The write-path half.** Every write of a rating, flag,
colour label or trash membership in the shipped code is enumerated by
`tools/traceability/src/verdicts.rs`, which parses the tree with `syn` and holds each site to a
reviewed list with its reason: a key, click or tap, a function writing for callers that are checked
in turn, or a verdict carried from elsewhere (a sidecar or `.xmp` pull, the sync merge, the catalog
mirrored to a file, duplicates consolidation). An unlisted write, or a listed one that is gone,
fails `cargo test -p traceability`. What is not built is the evidence itself: chips for clipping,
focus, burst membership and face counts on the grid cell and in FR-CULL-4's mode, shown as absent
rather than zero, with a filter per signal. Eye state is the one signal shown today, on People's
face cells and as a library filter.
**FR-CULL-6 — Compare and survey.** Absent. No side-by-side view, no synchronised zoom or pan. **FR-CULL-6 — Compare and survey.** Absent. No side-by-side view, no synchronised zoom or pan.
This is the one of the four with no adjacent machinery at all, and it is also the one that most This is the one of the four with no adjacent machinery at all, and it is also the one that most
directly distinguishes culling from browsing. directly distinguishes culling from browsing.
@@ -160,7 +179,7 @@ place.
--- ---
## 4. The render path — FR-DSP-2, FR-DSP-4, NFR-RES-2 ## 4. The render path — FR-DSP-2, FR-DSP-4, NFR-RES-2, NFR-ARCH-1
**FR-DSP-2 — Tiled computation. Unbuilt, and under challenge.** [architecture.md §6.2](architecture.md) **FR-DSP-2 — Tiled computation. Unbuilt, and under challenge.** [architecture.md §6.2](architecture.md)
calls for tiling "from day one" on the grounds that retrofitting it is a rewrite. It was not built, calls for tiling "from day one" on the grounds that retrofitting it is a rewrite. It was not built,
@@ -199,6 +218,17 @@ and no spill. Spike S6 — a tiled pipeline on a
mid-range Android device with an image larger than available GPU memory — is the one that would mid-range Android device with an image larger than available GPU memory — is the one that would
settle both this and FR-DSP-2, and there is no evidence it has run. settle both this and FR-DSP-2, and there is no evidence it has run.
**NFR-ARCH-1 — Named executors. Named and guarded, not bounded.** `dr_ui::executors` names the
five executors of [architecture.md §7.1](architecture.md) with their thread counts, every
long-lived worker in `dr-ui` and the Android entry point starts through its `spawn` as
`<executor>:<role>`, and `net_runtime`'s `block_on` fails a debug or test build on the UI thread.
The counts are a budget and not yet a limit: each job still gets a thread of its own, and a pool
sized from `Executor::threads` is where NFR-ARCH-2's priority classes would live. The guard covers
`block_on` only — not a synchronous file read or a catalog query on the UI thread, which the library
and People screens make on a click by design ([catalog.md §1](catalog.md)). The two mask workers in
`masks_ui.rs` still call `std::thread::spawn`, and the threads the core crates start are outside the
module.
--- ---
## 4a. Develop, masks and the keyboard — what the 0.15.0 and 0.16.0 work left open ## 4a. Develop, masks and the keyboard — what the 0.15.0 and 0.16.0 work left open
@@ -226,8 +256,11 @@ decoder behind a trait (FR-RAW-2), and duplicate originals (FR-CAT-11a, §2 abov
false alarm on the common path would teach the notice to be dismissed unread. false alarm on the common path would teach the notice to be dismissed unread.
The desktop's scrollbars (the develop column, the grid, the sidebar, Settings, the film list and The desktop's scrollbars (the develop column, the grid, the sidebar, Settings, the film list and
the help sheet) are drawn only where `dr_plat::is_touch_first()` is false; on Android the lists the help sheet) are drawn only where `dr_plat::is_touch_first()` is false. On Android the same
still scroll by flick alone, which is deliberate rather than outstanding. scrollers show instead a 3 px position cue (#69): `Scrolling.cue` in `widgets.slint`, the thumb
alone, drawn while the viewport moves and faded 500 ms after, with no touch target, so a flick that
starts on it scrolls the list (`tests/scroll_cue.rs`). A desktop build shows the cue when
`DR_SCROLL_CUE=1` is set, for checking it without a device. Not yet checked on the tablet.
--- ---
@@ -250,12 +283,12 @@ about.
0.17.0 brought the first real SAF code, for albums (FR-EXP-10): `FolderPicker.java` starts 0.17.0 brought the first real SAF code, for albums (FR-EXP-10): `FolderPicker.java` starts
`ACTION_OPEN_DOCUMENT_TREE` from a translucent activity of its own (the main activity is `ACTION_OPEN_DOCUMENT_TREE` from a translucent activity of its own (the main activity is
`NativeActivity`, whose results are not ours) and takes a persistable grant; `Saf.java` writes each `NativeActivity`, whose results are not ours) and takes a persistable grant; `Saf.java` writes each
export through `DocumentsContract`; `ui/dr-ui/src/saf.rs` is the JNI bridge. `saf.rs` and the export export through `DocumentsContract`; `ui/dr-ui/src/saf.rs` is the JNI bridge. They shipped tagged
path now carry `TRACES: FR-PLAT-AND-1`, and the matrix counts the requirement as covered. **That `TRACES: FR-PLAT-AND-1`, which made the matrix count the requirement as covered, and that overstated
overstates it.** The mechanism is the one the requirement names, but its subject is the library, and it: the mechanism is the one the requirement names, but its subject is the library, and Android
Android still reaches a library through a Nextcloud account or a folder, over paths, like the still reaches a library through a Nextcloud account or a folder, over paths, like the desktop. The
desktop. Either the tags narrow to FR-EXP-10 or the requirement is met for the library too; until tags now say FR-EXP-10 alone (0.17.1), so FR-PLAT-AND-1 reads as uncovered again until the library
one of those, read the coverage figure with this one subtracted. itself is reached through SAF.
That has a consequence for the rest of the cluster: **FR-PLAT-AND-2** — detecting the loss of a That has a consequence for the rest of the cluster: **FR-PLAT-AND-2** — detecting the loss of a
granted tree permission and marking images offline rather than deleting rows — is still blocked for granted tree permission and marking images offline rather than deleting rows — is still blocked for
+55
View File
@@ -269,6 +269,17 @@ identification, and camera-native colour matrices. Demosaic quality shall be sel
least a fast method for preview and a high-quality method for export (FR-EXP-9 requires export to least a fast method for preview and a high-quality method for export (FR-EXP-9 requires export to
use the latter). use the latter).
*Status (2026-09-27), defective photosites.* Hot and dead photosites are repaired on the mosaic,
before the demosaic, where each is still one wrong value rather than a coloured cross three pixels
wide (`core/dr-gpu/src/shaders/hot_pixels.wgsl`). A photosite is repaired only where it stands apart
from every same-colour photosite in its 5×5 window and from each of its eight immediate neighbours,
which leaves stars and glints alone, and it takes the value of its brightest (or, when dead,
darkest) same-colour neighbour, so nothing is invented. A 6×6 sensor-anchored colour tile serves
Bayer and X-Trans alike; export and every other path that demosaics get the repair, and there is no
setting. `core/dr-gpu/tests/hot_pixels.rs` renders a frame with and without a defect and compares
the finished pixels. The DNG defect map `dr_decode::defects` reads is not used: few files carry
one, and a CR2 none.
**FR-RAW-4 — Robustness.** A malformed or hostile RAW file shall not crash the application or **FR-RAW-4 — Robustness.** A malformed or hostile RAW file shall not crash the application or
compromise the process. Decode failures are reported per-file and do not abort a batch. compromise the process. Decode failures are reported per-file and do not abort a batch.
@@ -315,6 +326,13 @@ once, at the final export or display stage.
- Crop, straighten, rotate, flip - Crop, straighten, rotate, flip
- Local adjustments: linear gradient, radial gradient, and brush masks - Local adjustments: linear gradient, radial gradient, and brush masks
*Resolved 2026-09-26:* a mask layer's settings are **offsets to the photograph's**, applied at each
operation's own place in the chain — global contrast −30 under a layer at −20 is −50 inside the
mask, applied once. A moved switch or choice replaces the global one, and an offset that brings an
operation back to neutral undoes the global setting inside the mask. Layers used to run as a second
chain after every global operation, which compounded the two edits in ways neither slider showed
([architecture.md §5.2](architecture.md); `core/dr-gpu/tests/local_adjustments.rs`).
**FR-DEV-3a — Self-describing operations.** Every processing operation shall declare its own **FR-DEV-3a — Self-describing operations.** Every processing operation shall declare its own
parameters through a descriptor, so that adding an operation requires no changes to frontend code. parameters through a descriptor, so that adding an operation requires no changes to frontend code.
An operation declares *what* its parameters are; the frontend decides *how* to present them. An operation declares *what* its parameters are; the frontend decides *how* to present them.
@@ -434,6 +452,15 @@ reference implementation.
parameter — `core/dr-pipeline/src/sidecar.rs` records why an index was rejected (installing a parameter — `core/dr-pipeline/src/sidecar.rs` records why an index was rejected (installing a
profile would silently change which film every existing photograph was developed on). profile would silently change which film every existing photograph was developed on).
*Resolved 2026-09-26:* the film's settings — exposure, push, print exposure, format — are
**per-pixel**, evaluated by the shader against tables that hold none of them, so a mask layer can
hold its own. A layer's settings are offsets to the photograph's, and where layers overlap a pixel
takes the weighted average of what each asks for, the photograph's setting taking whatever weight
the layers leave (`operation::local_settings_block`). The stock and its paper stay
photograph-wide: a layer has no picker. The print is split at the paper's log exposure, so print
exposure is an addition between two lookups and exact at any setting; push interpolates the
stock's measured processes. Before this a layer offered the film's sliders and they moved nothing.
**FR-DEV-3g — AI denoise.** Learned denoising operating in the raw domain, ideally jointly with **FR-DEV-3g — AI denoise.** Learned denoising operating in the raw domain, ideally jointly with
demosaic. demosaic.
@@ -1309,6 +1336,21 @@ only from an input event. The evidence for a frame is visible in FR-CULL-4's mod
cell without opening it, and a filter on any one signal returns exactly the set whose chips show cell without opening it, and a filter on any one signal returns exactly the set whose chips show
it. it.
*Status (2026-09-26).* The write-path half is met; the evidence half is not. `cargo test -p
traceability` enumerates every write of a rating, flag, colour label or trash membership in the
shipped code — the catalog setters, any SQL that assigns those columns, the sidecar's judgement
amendment and the fields that carry one — and holds each to a hand-written list
(`tools/traceability/src/verdicts.rs`). Each listed write is a key, click or tap (a Slint `on_*`
callback, checked structurally), a function writing for its caller, whose callers are then checked
in turn, or a verdict carried from elsewhere: a sidecar or `.xmp` pull, the sync merge of two
devices' sidecars, the catalog mirrored out to a file, and duplicates consolidation, which moves
the copies' own verdicts onto the survivor and invents none. A new writer, including one in an
evidence producer, fails the test until it is listed with a reason; `traces verdicts` prints the
list. Choosing a burst's representative writes the grouping, not a verdict, and takes a press.
Outstanding: evidence chips (clipping, focus, burst membership, face counts) on the grid cell and in
FR-CULL-4's mode, shown as absent rather than zero, and a filter per signal. Eye state alone is
shown today, on People's face cells and as a library filter.
### 3.9.1 People ### 3.9.1 People
Face recognition was deferred in §7 through the 2026-08-08 calibration. It is undeferred here in a Face recognition was deferred in §7 through the 2026-08-08 calibration. It is undeferred here in a
@@ -2145,6 +2187,19 @@ pool, I/O pool, network — with stated thread counts and the invariant that **n
occurs on the UI executor**. This is the mechanism behind R4 and NFR-P9, which currently assert an occurs on the UI executor**. This is the mechanism behind R4 and NFR-P9, which currently assert an
outcome with no stated means. outcome with no stated means.
*Status (2026-09-26).* Named and guarded; not yet bounded. `dr_ui::executors` defines the five
executors with the thread counts of architecture.md §7.1 and the reason for each, and every
long-lived worker in `dr-ui` and the Android entry point is started through its `spawn`, which
names the thread `<executor>:<role>` (`net:sync`, `decode:thumbs`). `run` marks its thread as the
UI executor, and `net_runtime`'s `block_on` asserts in debug and test builds that it is not called
there; `executors`' tests show the panic on a thread marked as the UI one and the same call passing
on a worker. Outstanding: the counts are a stated budget, not a limit — each job still gets a
thread of its own, and a pool sized from `Executor::threads` is the change NFR-ARCH-2's priorities
need; the guard covers `block_on` only, not a synchronous file read or a catalog query on the UI
thread, several of which the library and People screens make on a click by design (catalog.md
§1); the two mask workers in `masks_ui.rs` still use `std::thread::spawn`; and threads the core
crates start (the inference engine's reaper and probe, already named) are outside the module.
**NFR-ARCH-2 — Scheduler priority.** The tiling scheduler assigns priority classes, with **NFR-ARCH-2 — Scheduler priority.** The tiling scheduler assigns priority classes, with
visible-tile work **strictly preempting** background export and thumbnail work. Without this, visible-tile work **strictly preempting** background export and thumbnail work. Without this,
NFR-P5's slider latency fails during a batch export — the common case, not an edge case. NFR-P5's slider latency fails during a batch export — the common case, not an edge case.
+110 -110
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -411,6 +411,16 @@ tract, Slint's compiler, wgpu — so with the cargo cache warm it is minutes and
better part of half an hour. Worth noting because the runner is one machine and the legs run in better part of half an hour. Worth noting because the runner is one machine and the legs run in
parallel on it; if it starts starving the desktop leg, `needs: desktop` serialises them. parallel on it; if it starts starving the desktop leg, `needs: desktop` serialises them.
**Disk is the tighter budget.** The runner has one 99 GB disk shared with its container images. At
rest it holds about 23 GB; the desktop leg's restored target cache, the models and the dependency
build bring it to about 78 GB before a test runs, and v0.18.0's run ended at 92 GB used. v0.18.1's
release build then died with "No space left on device", so that tag has no release page. Since
then the desktop leg deletes its test executables, `target/debug/examples` and
`target/debug/incremental` after the Test step and before the release build — they are relinked
whenever a source changes, and the cache exists for the dependency rlibs — and prints the disk
again beside its Disk before and after lines. A second target's cache on the same disk is the
first thing to look at if a leg runs out again.
--- ---
## 8. Requirements ## 8. Requirements
+27 -27
View File
@@ -354,7 +354,7 @@ One key for "up one", innermost first: a question before the sheet under it, a s
A list cut off at its edge looks, to a mouse, like a list that ends there — the film stocks past the tenth read as deleted. The bar says there is more and where the view is in it, and it is the one way to scroll that needs neither a wheel nor a drag on the content, which may be a row that would take the click. A list cut off at its edge looks, to a mouse, like a list that ends there — the film stocks past the tenth read as deleted. The bar says there is more and where the view is in it, and it is the one way to scroll that needs neither a wheel nor a drag on the content, which may be a row that would take the click.
<sub>`ui/dr-ui/ui/widgets.slint:1131`</sub> <sub>`ui/dr-ui/ui/widgets.slint:1138`</sub>
## Collections sidebar ## Collections sidebar
@@ -521,7 +521,7 @@ The right match confidence is a property of your library, not of the model. "Wha
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found. Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
<sub>`ui/dr-ui/ui/library.slint:1701`</sub> <sub>`ui/dr-ui/ui/library.slint:1706`</sub>
### Add or remove one photograph ### Add or remove one photograph
@@ -531,7 +531,7 @@ Touch has no ctrl, so without a mode there is no way to select a second photogra
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back. While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
<sub>`ui/dr-ui/ui/library.slint:1711`</sub> <sub>`ui/dr-ui/ui/library.slint:1716`</sub>
### Leave selecting ### Leave selecting
@@ -540,7 +540,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
- **Keyboard** — `Escape`, or `Back`; an open sheet closes first - **Keyboard** — `Escape`, or `Back`; an open sheet closes first
- **See it** — [in the manual](manual/README.md#selecting-several) - **See it** — [in the manual](manual/README.md#selecting-several)
<sub>`ui/dr-ui/ui/library.slint:1720`</sub> <sub>`ui/dr-ui/ui/library.slint:1725`</sub>
### Pick a photograph up to drag it ### Pick a photograph up to drag it
@@ -550,7 +550,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
A finger on a photograph might be starting a scroll, and for the first half-second the grid assumes it is. Holding says otherwise, and the ring is the grid saying it heard — from there the drag cannot be lost to a scroll. A mouse never waits: the cursor is precise enough that a sideways drag is unambiguous from the first pixel. A finger on a photograph might be starting a scroll, and for the first half-second the grid assumes it is. Holding says otherwise, and the ring is the grid saying it heard — from there the drag cannot be lost to a scroll. A mouse never waits: the cursor is precise enough that a sideways drag is unambiguous from the first pixel.
<sub>`ui/dr-ui/ui/library.slint:1751`</sub> <sub>`ui/dr-ui/ui/library.slint:1756`</sub>
### Select a range ### Select a range
@@ -561,7 +561,7 @@ A finger on a photograph might be starting a scroll, and for the first half-seco
This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out. This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
<sub>`ui/dr-ui/ui/library.slint:1817`</sub> <sub>`ui/dr-ui/ui/library.slint:1822`</sub>
### Take the blinks out of a burst ### Take the blinks out of a burst
@@ -571,7 +571,7 @@ This replaced a double tap, which had no visible state and could take forty phot
Face indexing reads each face's eyes. The chip drops frames where the chosen people are caught blinking, and leaves sunglasses and eyes it could not read alone. Face indexing reads each face's eyes. The chip drops frames where the chosen people are caught blinking, and leaves sunglasses and eyes it could not read alone.
<sub>`ui/dr-ui/ui/library.slint:2535`</sub> <sub>`ui/dr-ui/ui/library.slint:2540`</sub>
### Find photographs with two people in them ### Find photographs with two people in them
@@ -581,7 +581,7 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar. "Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
<sub>`ui/dr-ui/ui/library.slint:2565`</sub> <sub>`ui/dr-ui/ui/library.slint:2570`</sub>
### Show only photographs with one colour label ### Show only photographs with one colour label
@@ -591,7 +591,7 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
Each chip is the label's mark and its name, so the one you want is found by reading it; tap the lit chip again to show every label. Each chip is the label's mark and its name, so the one you want is found by reading it; tap the lit chip again to show every label.
<sub>`ui/dr-ui/ui/library.slint:2689`</sub> <sub>`ui/dr-ui/ui/library.slint:2694`</sub>
### Export the selection as the last export was ### Export the selection as the last export was
@@ -602,7 +602,7 @@ Each chip is the label's mark and its name, so the one you want is found by read
Lightroom's and darktable's chords. Every export runs on the saved defaults, so the plain chord opens them beside an Export button and the shifted one skips straight to exporting. Lightroom's and darktable's chords. Every export runs on the saved defaults, so the plain chord opens them beside an Export button and the shifted one skips straight to exporting.
<sub>`ui/dr-ui/ui/library.slint:3159`</sub> <sub>`ui/dr-ui/ui/library.slint:3164`</sub>
### Paste copied settings onto the selection ### Paste copied settings onto the selection
@@ -611,7 +611,7 @@ Lightroom's and darktable's chords. Every export runs on the saved defaults, so
- **Keyboard** — `Ctrl+V` - **Keyboard** — `Ctrl+V`
- **See it** — [in the manual](manual/README.md#copying-settings) - **See it** — [in the manual](manual/README.md#copying-settings)
<sub>`ui/dr-ui/ui/library.slint:3183`</sub> <sub>`ui/dr-ui/ui/library.slint:3188`</sub>
### Keyword the selection ### Keyword the selection
@@ -621,7 +621,7 @@ Lightroom's and darktable's chords. Every export runs on the saved defaults, so
Lightroom's keywording chord. The sheet opens with its field ready for typing, so the keys that judge in the grid are out of the way until it closes. Lightroom's keywording chord. The sheet opens with its field ready for typing, so the keys that judge in the grid are out of the way until it closes.
<sub>`ui/dr-ui/ui/library.slint:3212`</sub> <sub>`ui/dr-ui/ui/library.slint:3217`</sub>
### Show only photographs with some number of stars ### Show only photographs with some number of stars
@@ -632,7 +632,7 @@ Lightroom's keywording chord. The sheet opens with its field ready for typing, s
The chips say "this many or more". A range with a ceiling — the twos and threes still to be decided — is the keyboard's alone, and the bar says so in words while it holds. The chips say "this many or more". A range with a ceiling — the twos and threes still to be decided — is the keyboard's alone, and the bar says so in words while it holds.
<sub>`ui/dr-ui/ui/library.slint:3246`</sub> <sub>`ui/dr-ui/ui/library.slint:3251`</sub>
### Give photographs a colour label ### Give photographs a colour label
@@ -643,7 +643,7 @@ The chips say "this many or more". A range with a ceiling — the twos and three
Lightroom's keys, so hands that learned them there need not learn them again. Purple has no key there either, and is on the bar. Every mark carries its label's initial, so the label is read without telling the colours apart. Lightroom's keys, so hands that learned them there need not learn them again. Purple has no key there either, and is on the bar. Every mark carries its label's initial, so the label is read without telling the colours apart.
<sub>`ui/dr-ui/ui/library.slint:3296`</sub> <sub>`ui/dr-ui/ui/library.slint:3301`</sub>
### Pick or reject a photograph ### Pick or reject a photograph
@@ -653,7 +653,7 @@ Lightroom's keys, so hands that learned them there need not learn them again. Pu
The keys every culling tool uses, so muscle memory built elsewhere works here. The keys every culling tool uses, so muscle memory built elsewhere works here.
<sub>`ui/dr-ui/ui/library.slint:3320`</sub> <sub>`ui/dr-ui/ui/library.slint:3325`</sub>
### Move photographs to the trash ### Move photographs to the trash
@@ -663,7 +663,7 @@ The keys every culling tool uses, so muscle memory built elsewhere works here.
The bin acts on one photograph, so a stray click cannot trash a selection; the key acts on the selection because that is what every file manager's Delete does. Both are undone from the trash view. The bin acts on one photograph, so a stray click cannot trash a selection; the key acts on the selection because that is what every file manager's Delete does. Both are undone from the trash view.
<sub>`ui/dr-ui/ui/library.slint:3347`</sub> <sub>`ui/dr-ui/ui/library.slint:3352`</sub>
### Open this list ### Open this list
@@ -671,7 +671,7 @@ The bin acts on one photograph, so a stray click cannot trash a selection; the k
- **Pointer** — Press Help in the header, and Done to put it away - **Pointer** — Press Help in the header, and Done to put it away
- **Keyboard** — `F1`, and `Escape` to put it away - **Keyboard** — `F1`, and `Escape` to put it away
<sub>`ui/dr-ui/ui/library.slint:3372`</sub> <sub>`ui/dr-ui/ui/library.slint:3377`</sub>
### Rename the collection the grid is showing ### Rename the collection the grid is showing
@@ -679,7 +679,7 @@ The bin acts on one photograph, so a stray click cannot trash a selection; the k
- **Pointer** — Double-click it in the sidebar - **Pointer** — Double-click it in the sidebar
- **Keyboard** — `F2` - **Keyboard** — `F2`
<sub>`ui/dr-ui/ui/library.slint:3380`</sub> <sub>`ui/dr-ui/ui/library.slint:3385`</sub>
### Move through the grid ### Move through the grid
@@ -689,7 +689,7 @@ The bin acts on one photograph, so a stray click cannot trash a selection; the k
The cursor selects what it lands on, so walking and judging are one hand's work. The cursor selects what it lands on, so walking and judging are one hand's work.
<sub>`ui/dr-ui/ui/library.slint:3400`</sub> <sub>`ui/dr-ui/ui/library.slint:3405`</sub>
### Resize the thumbnails ### Resize the thumbnails
@@ -700,7 +700,7 @@ The cursor selects what it lands on, so walking and judging are one hand's work.
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:3531`</sub> <sub>`ui/dr-ui/ui/library.slint:3536`</sub>
### File photographs in a collection ### File photographs in a collection
@@ -710,7 +710,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:3730`</sub> <sub>`ui/dr-ui/ui/library.slint:3735`</sub>
### Open a photograph ### Open a photograph
@@ -721,7 +721,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:4035`</sub> <sub>`ui/dr-ui/ui/library.slint:4040`</sub>
### Rate a photograph without opening it ### Rate a photograph without opening it
@@ -732,7 +732,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:4158`</sub> <sub>`ui/dr-ui/ui/library.slint:4163`</sub>
### Choose the frame a folded burst shows ### Choose the frame a folded burst shows
@@ -742,7 +742,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:4291`</sub> <sub>`ui/dr-ui/ui/library.slint:4296`</sub>
### Drop the selection but keep selecting ### Drop the selection but keep selecting
@@ -753,7 +753,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:4982`</sub> <sub>`ui/dr-ui/ui/library.slint:4987`</sub>
### Select everything the grid is showing ### Select everything the grid is showing
@@ -764,7 +764,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:5001`</sub> <sub>`ui/dr-ui/ui/library.slint:5006`</sub>
### Take photographs out of a collection ### Take photographs out of a collection
@@ -774,7 +774,7 @@ 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:5182`</sub> <sub>`ui/dr-ui/ui/library.slint:5187`</sub>
## Settings ## Settings
+17 -3
View File
@@ -71,7 +71,9 @@ Drag the timeline to scrub through years; Ctrl and the wheel resize the
thumbnails. On a desktop a scrollbar beside the grid says how far through the thumbnails. On a desktop a scrollbar beside the grid says how far through the
library the view is — drag its thumb, or click the track to move a page — and library the view is — drag its thumb, or click the track to move a page — and
the sidebar, the develop column and Settings have one too whenever they run the sidebar, the develop column and Settings have one too whenever they run
past the window. On a tablet they scroll by flick alone. past the window. On a tablet they scroll by flick alone, and a thin line at
the right-hand edge shows where the view is while it moves, fading once it
stops; it is only a picture, and a flick that starts on it scrolls the list.
`Help` in the header, or `F1`, opens the controls and shortcuts: every key and `Help` in the header, or `F1`, opens the controls and shortcuts: every key and
gesture, screen by screen, with `See it` beside those this page shows, and gesture, screen by screen, with `See it` beside those this page shows, and
@@ -233,7 +235,10 @@ take the crop back or keep it.
`Local` in the rail turns the column into a mask stack. `Find subjects` runs a `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 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 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. mask — then every slider below edits only that region. A mask's slider adds to
the photograph's own rather than repeating it: contrast −20 in the mask over
−30 on the whole frame is −50 there, and +30 in the mask cancels the frame's
−30 inside it.
![What the model found in an urban scene: ground, architecture, sky, vegetation](media/local-categories.png) ![What the model found in an urban scene: ground, architecture, sky, vegetation](media/local-categories.png)
@@ -270,6 +275,15 @@ black-and-white stocks at its end.
![Opening the film list, scrolling it, choosing Velvia, then holding Before](media/film.gif) ![Opening the film list, scrolling it, choosing Velvia, then holding Before](media/film.gif)
The film's sliders work on a mask as they do on the whole photograph, the way
a printer dodges and burns: in a layer, `Print Exposure` darkens or lightens
that region of the print, `Push` develops it further, and the stock stays the
one the photograph was made on. Where layers overlap, the photograph takes the
average of what they ask for. Below, a negative stock on an alpine frame, then
a gradient over the sky whose `Print Exposure` burns it in.
![Kodak Ektar 100 on the whole frame, a gradient turned to cover the sky, its Print Exposure raised to burn the sky in, then holding Before](media/film-local.gif)
### History, snapshots, presets ### History, snapshots, presets
Every change is a step; `Undo` and the History panel walk them. `Snapshot` Every change is a step; `Undo` and the History panel walk them. `Snapshot`
@@ -278,7 +292,7 @@ apply elsewhere, and imports Lightroom presets — `Folder…` for a folder of
them, `.xmp file…` for one. them, `.xmp file…` for one.
The sheet lists your own presets first, then the ones DarkRoom ships — The sheet lists your own presets first, then the ones DarkRoom ships —
Essentials, and colour, cinema and black-and-white film, one measured stock Essentials, Skies, and colour, cinema and black-and-white film, one measured stock
each. A shipped preset is a look: it changes what it names and leaves the each. A shipped preset is a look: it changes what it names and leaves the
photograph's own corrections alone, as an imported Lightroom preset does. photograph's own corrections alone, as an imported Lightroom preset does.
Saving under a shipped preset's name makes your version the one that name Saving under a shipped preset's name makes your version the one that name
+15 -3
View File
@@ -191,7 +191,9 @@ and the same keys work there.</p>
thumbnails. On a desktop a scrollbar beside the grid says how far through the thumbnails. On a desktop a scrollbar beside the grid says how far through the
library the view is — drag its thumb, or click the track to move a page — and library the view is — drag its thumb, or click the track to move a page — and
the sidebar, the develop column and Settings have one too whenever they run the sidebar, the develop column and Settings have one too whenever they run
past the window. On a tablet they scroll by flick alone.</p> past the window. On a tablet they scroll by flick alone, and a thin line at
the right-hand edge shows where the view is while it moves, fading once it
stops; it is only a picture, and a flick that starts on it scrolls the list.</p>
<p><code>Help</code> in the header, or <code>F1</code>, opens the controls and shortcuts: every key and <p><code>Help</code> in the header, or <code>F1</code>, opens the controls and shortcuts: every key and
gesture, screen by screen, with <code>See it</code> beside those this page shows, and gesture, screen by screen, with <code>See it</code> beside those this page shows, and
<code>Manual</code> to open this page. In develop it is the <code>?</code> beside <code>Settings</code>.</p> <code>Manual</code> to open this page. In develop it is the <code>?</code> beside <code>Settings</code>.</p>
@@ -304,7 +306,10 @@ take the crop back or keep it.</p>
<p><code>Local</code> in the rail turns the column into a mask stack. <code>Find subjects</code> runs a <p><code>Local</code> in the rail turns the column into a mask stack. <code>Find subjects</code> runs a
segmentation model over the photograph; what it recognises appears as a list 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 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.</p> mask — then every slider below edits only that region. A mask's slider adds to
the photograph's own rather than repeating it: contrast −20 in the mask over
−30 on the whole frame is −50 there, and +30 in the mask cancels the frame's
−30 inside it.</p>
<figure><img loading="lazy" src="media/local-categories.png" alt="What the model found in an urban scene: ground, architecture, sky, vegetation"><figcaption>What the model found in an urban scene: ground, architecture, sky, vegetation</figcaption></figure> <figure><img loading="lazy" src="media/local-categories.png" alt="What the model found in an urban scene: ground, architecture, sky, vegetation"><figcaption>What the model found in an urban scene: ground, architecture, sky, vegetation</figcaption></figure>
<figure><img loading="lazy" src="media/local-segment.png" alt="The sky chosen: tinted on the photograph, and the column now scoped to it"><figcaption>The sky chosen: tinted on the photograph, and the column now scoped to it</figcaption></figure> <figure><img loading="lazy" src="media/local-segment.png" alt="The sky chosen: tinted on the photograph, and the column now scoped to it"><figcaption>The sky chosen: tinted on the photograph, and the column now scoped to it</figcaption></figure>
<p>A mask is a stack of parts. Paint into it, subtract a gradient from it, grow <p>A mask is a stack of parts. Paint into it, subtract a gradient from it, grow
@@ -328,13 +333,20 @@ sensor does not. The list opens over the column and scrolls on its own — by
wheel, drag or flick, or with <code>Up</code>, <code>Down</code> and <code>Enter</code> — down to the wheel, drag or flick, or with <code>Up</code>, <code>Down</code> and <code>Enter</code> — down to the
black-and-white stocks at its end.</p> black-and-white stocks at its end.</p>
<figure><img loading="lazy" src="media/film.gif" alt="Opening the film list, scrolling it, choosing Velvia, then holding Before"><figcaption>Opening the film list, scrolling it, choosing Velvia, then holding Before</figcaption></figure> <figure><img loading="lazy" src="media/film.gif" alt="Opening the film list, scrolling it, choosing Velvia, then holding Before"><figcaption>Opening the film list, scrolling it, choosing Velvia, then holding Before</figcaption></figure>
<p>The film's sliders work on a mask as they do on the whole photograph, the way
a printer dodges and burns: in a layer, <code>Print Exposure</code> darkens or lightens
that region of the print, <code>Push</code> develops it further, and the stock stays the
one the photograph was made on. Where layers overlap, the photograph takes the
average of what they ask for. Below, a negative stock on an alpine frame, then
a gradient over the sky whose <code>Print Exposure</code> burns it in.</p>
<figure><img loading="lazy" src="media/film-local.gif" alt="Kodak Ektar 100 on the whole frame, a gradient turned to cover the sky, its Print Exposure raised to burn the sky in, then holding Before"><figcaption>Kodak Ektar 100 on the whole frame, a gradient turned to cover the sky, its Print Exposure raised to burn the sky in, then holding Before</figcaption></figure>
<h3 id="history-snapshots-presets">History, snapshots, presets</h3> <h3 id="history-snapshots-presets">History, snapshots, presets</h3>
<p>Every change is a step; <code>Undo</code> and the History panel walk them. <code>Snapshot</code> <p>Every change is a step; <code>Undo</code> and the History panel walk them. <code>Snapshot</code>
keeps the current state under a name. <code>Presets…</code> saves the settings to keeps the current state under a name. <code>Presets…</code> saves the settings to
apply elsewhere, and imports Lightroom presets — <code>Folder…</code> for a folder of apply elsewhere, and imports Lightroom presets — <code>Folder…</code> for a folder of
them, <code>.xmp file…</code> for one.</p> them, <code>.xmp file…</code> for one.</p>
<p>The sheet lists your own presets first, then the ones DarkRoom ships — <p>The sheet lists your own presets first, then the ones DarkRoom ships —
Essentials, and colour, cinema and black-and-white film, one measured stock Essentials, Skies, and colour, cinema and black-and-white film, one measured stock
each. A shipped preset is a look: it changes what it names and leaves the each. A shipped preset is a look: it changes what it names and leaves the
photograph's own corrections alone, as an imported Lightroom preset does. photograph's own corrections alone, as an imported Lightroom preset does.
Saving under a shipped preset's name makes your version the one that name Saving under a shipped preset's name makes your version the one that name
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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.
+1 -1
View File
@@ -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.17.0 pkgver=0.18.2
# 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
+59
View File
@@ -57,6 +57,7 @@ TEAPOT = '_MG_4918'
PANO_FIRST, PANO_LAST = '_MG_8320', '_MG_8331' PANO_FIRST, PANO_LAST = '_MG_8320', '_MG_8331'
NY_FIRST, NY_LAST = '_MG_8393', '_MG_8397' NY_FIRST, NY_LAST = '_MG_8393', '_MG_8397'
ALPS_ROW = ['_MG_8322', '_MG_8328'] # the second row of the alps, first and last ALPS_ROW = ['_MG_8322', '_MG_8328'] # the second row of the alps, first and last
ALPS_SKY = '_MG_8330' # an upright alpine frame, its top third sky and cloud
TOWER = '_MG_8693' # towers shot looking up: verticals that converge TOWER = '_MG_8693' # towers shot looking up: verticals that converge
ROAD = '_MG_8672' # a road under a sky, with marks on it: masks and repair ROAD = '_MG_8672' # a road under a sky, with marks on it: masks and repair
ROAD_MARK = (0.305, 0.794) # a dark mark on its tarmac ROAD_MARK = (0.305, 0.794) # a dark mark on its tarmac
@@ -1005,6 +1006,64 @@ def film():
undo_all() undo_all()
FILM_LOCAL = 'Kodak Ektar 100' # a negative, so it is printed and has a print exposure
def choose_film(stock):
"""`stock` chosen from the film list, which is open at its top."""
px, py, _, _ = film_list_origin()
vx0, vy0, vx1, vy1 = dr.rect(f'{stock}@Text')
dr.click(px + (vx0 + vx1) // 2, py + (vy0 + vy1) // 2)
pause(3)
def gradient_over_top(to_fy):
"""The linear gradient just added, turned to fall from the top of the
frame and its middle moved to `to_fy`. A new one is laid across the
middle facing right, its rotate handle below the centre: that handle,
dragged round to the right, turns it a quarter to face up."""
cx, cy = dr.photo(0.5, 0.5)
handles = [(int(h['x'] + h['w'] / 2), int(h['y'] + h['h'] / 2))
for h in dr.matches('id:GradientHandles::drag')]
rx, ry = max(handles, key=lambda p: p[1])
dr.drag(rx, ry, cx + (ry - cy), cy, 25)
pause(1.0)
dr.drag(cx, cy, *dr.photo(0.5, to_fy), 25)
pause(1.2)
@scene(media=['film-local.gif'], sources=MASK_SRC + ['core/dr-film/src/**', 'core/dr-pipeline/src/ops/film_sim.rs'])
def film_local():
"""A negative stock on the whole photograph, then a gradient over the
sky whose Print Exposure burns it in, as a printer would under the
enlarger, the mask drawn as its edge so the print shows through; Done
Masking, then Before held."""
at_develop(ALPS_SKY)
in_column('Film@Button')
film_list_open()
choose_film(FILM_LOCAL)
tool('Local')
rec('film-local')
pause(0.6)
dr.click(*in_column('Linear@Button'))
pause(1.5)
gradient_over_top(0.36)
dr.click(*in_column('Edge@RadioButton')) # an outline, so the tint hides nothing
pause(1.2)
slide('Print Exposure', 28, 30)
pause(2.0)
dr.click_on('Done Masking@Button')
pause(2.0)
hold_before(1.6)
pause(1.0)
cut()
tool('Local') # masks shown tinted again, as the other scenes
dr.click(*in_column('Tint@RadioButton')) # expect; offered while one exists
pause(0.6)
tool('Photo')
undo_all()
FILM_LAST = 'Ilford HP5 Plus' # the last stock `DevelopSession::film_choices` lists FILM_LAST = 'Ilford HP5 Plus' # the last stock `DevelopSession::film_choices` lists
FILM_ROWS = 28 # None and the 27 camera stocks FILM_ROWS = 28 # None and the 27 camera stocks
FILM_ROW, FILM_LIST_MAX = 32, 320 FILM_ROW, FILM_LIST_MAX = 32, 320
+2
View File
@@ -19,3 +19,5 @@ anyhow.workspace = true
serde = { workspace = true } serde = { workspace = true }
serde_json.workspace = true serde_json.workspace = true
pulldown-cmark.workspace = true pulldown-cmark.workspace = true
syn.workspace = true
proc-macro2.workspace = true
+1
View File
@@ -42,6 +42,7 @@ pub mod chord;
pub mod gestures; pub mod gestures;
pub mod keymap; pub mod keymap;
pub mod manual; pub mod manual;
pub mod verdicts;
/// Requirement ID prefixes that participate in coverage. /// Requirement ID prefixes that participate in coverage.
/// ///
+36
View File
@@ -8,6 +8,7 @@
//! traces gestures-check # gate: non-zero exit if either has drifted //! traces gestures-check # gate: non-zero exit if either has drifted
//! traces manual # write docs/manual/index.html from its README //! traces manual # write docs/manual/index.html from its README
//! traces manual-check # gate: non-zero exit if the page has drifted //! traces manual-check # gate: non-zero exit if the page has drifted
//! traces verdicts # list every verdict write; non-zero exit on an unlisted one
//! ``` //! ```
//! //!
//! The gesture half scans a different tag out of the same files — see //! The gesture half scans a different tag out of the same files — see
@@ -79,6 +80,9 @@ fn main() -> Result<()> {
if mode.starts_with("manual") { if mode.starts_with("manual") {
return run_manual(&base, mode == "manual-check"); return run_manual(&base, mode == "manual-check");
} }
if mode == "verdicts" {
return run_verdicts(&base);
}
let req_path = base.join("docs/dev/requirements.md"); let req_path = base.join("docs/dev/requirements.md");
let markdown = std::fs::read_to_string(&req_path) let markdown = std::fs::read_to_string(&req_path)
@@ -248,6 +252,38 @@ fn run_gestures(base: &Path, check: bool) -> Result<()> {
Ok(()) Ok(())
} }
/// List every verdict write with the reason it is allowed, and fail on any
/// that has none — the same check `cargo test -p traceability` runs, in a
/// form a reviewer can read ([`traceability::verdicts`]).
fn run_verdicts(base: &Path) -> Result<()> {
let files = verdicts::sources(base);
let report = verdicts::check(&files, verdicts::ALLOWED);
println!("files scanned {}", files.len());
println!("writes found {}", report.sites.len());
for s in &report.sites {
let kind = verdicts::ALLOWED
.iter()
.find(|a| a.file == s.file && a.within == s.within && a.writes == s.writes)
.map(|a| format!("{:?}", a.kind))
.unwrap_or_else(|| "UNLISTED".into());
println!(
" {kind:<11} {}:{} {} {} {}",
s.file, s.line, s.within, s.writes, s.detail
);
}
if !report.problems.is_empty() {
for p in &report.problems {
println!(" {p}");
}
bail!(
"{} verdict write problem(s) (FR-CULL-13)",
report.problems.len()
);
}
println!("\nverdict gate: PASS");
Ok(())
}
/// Render the manual to its page, or check the committed page is that render. /// Render the manual to its page, or check the committed page is that render.
fn run_manual(base: &Path, check: bool) -> Result<()> { fn run_manual(base: &Path, check: bool) -> Result<()> {
let source = std::fs::read_to_string(base.join(MANUAL_SOURCE)) let source = std::fs::read_to_string(base.join(MANUAL_SOURCE))
File diff suppressed because it is too large Load Diff
+8 -9
View File
@@ -45,6 +45,7 @@
//! //!
//! Waiting is the client's job (poll `locate`), which keeps this stateless. //! Waiting is the client's job (poll `locate`), which keeps this stateless.
use crate::executors::{self, Executor};
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::net::{UnixListener, UnixStream};
use std::sync::mpsc; use std::sync::mpsc;
@@ -71,15 +72,13 @@ pub(crate) fn attach(window: &AppWindow) {
}; };
log::info!("automation: listening on {path:?}"); log::info!("automation: listening on {path:?}");
let weak = window.as_weak(); let weak = window.as_weak();
std::thread::Builder::new() executors::try_spawn(Executor::Io, "automation", move || {
.name("automation".into()) for stream in listener.incoming().flatten() {
.spawn(move || { let weak = weak.clone();
for stream in listener.incoming().flatten() { executors::spawn(Executor::Io, "auto-conn", move || serve(stream, weak));
let weak = weak.clone(); }
std::thread::spawn(move || serve(stream, weak)); })
} .ok();
})
.ok();
} }
fn serve(stream: UnixStream, weak: slint::Weak<AppWindow>) { fn serve(stream: UnixStream, weak: slint::Weak<AppWindow>) {
+2 -1
View File
@@ -36,6 +36,7 @@
//! abandoned half way leaves the signatures it did compute — they are permanent //! abandoned half way leaves the signatures it did compute — they are permanent
//! and correct — and the previous grouping intact. //! and correct — and the previous grouping intact.
use crate::executors::{self, Executor};
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
@@ -82,7 +83,7 @@ pub enum BurstMessage {
pub fn spawn_grouping(catalog_path: PathBuf, thumbs_dir: PathBuf) -> Receiver<BurstMessage> { pub fn spawn_grouping(catalog_path: PathBuf, thumbs_dir: PathBuf) -> Receiver<BurstMessage> {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { executors::spawn(Executor::Decode, "bursts", move || {
let catalog = match Catalog::open(&catalog_path) { let catalog = match Catalog::open(&catalog_path) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
+3 -2
View File
@@ -32,6 +32,7 @@
//! share boundary the account cannot write to. The scanner excludes it by the //! share boundary the account cannot write to. The scanner excludes it by the
//! same mechanism that excludes the trash. //! same mechanism that excludes the trash.
use crate::executors::{self, Executor};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use dr_sync::{Connection, RemoteBackend, RemoteError, RemoteId, RemotePath}; use dr_sync::{Connection, RemoteBackend, RemoteError, RemoteId, RemotePath};
@@ -132,7 +133,7 @@ pub fn spawn_sync(
) -> std::sync::mpsc::Receiver<SyncMessage> { ) -> std::sync::mpsc::Receiver<SyncMessage> {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { executors::spawn(Executor::Network, "sync", move || {
// A current-thread runtime here is what made the library scan itself // A current-thread runtime here is what made the library scan itself
// rather than adopt the shards the server already had; see net_runtime. // rather than adopt the shards the server already had; see net_runtime.
let rt = match crate::net_runtime::build() { let rt = match crate::net_runtime::build() {
@@ -1018,7 +1019,7 @@ pub fn spawn_place_fetch(
) -> std::sync::mpsc::Receiver<Result<Option<dr_types::Place>, String>> { ) -> std::sync::mpsc::Receiver<Result<Option<dr_types::Place>, String>> {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { executors::spawn(Executor::Network, "places", move || {
let rt = match crate::net_runtime::build() { let rt = match crate::net_runtime::build() {
Ok(rt) => rt, Ok(rt) => rt,
Err(e) => { Err(e) => {
+204 -76
View File
@@ -749,77 +749,103 @@ impl DevelopSession {
None None
}; };
// TRACES: FR-DEV-3f // TRACES: FR-DEV-3f
// Grain, at the scale this photograph is being sampled at. // The photograph's exposure, which is where the enlarger is balanced.
// // Nothing else a slider moves is baked: push, print exposure and the
// A digital frame has no film format, so simulating one means choosing // format are per-pixel settings the shader applies, so that a mask
// what it *would have been* — 35 mm, because that is the format every // layer can hold its own.
// published granularity figure and every intuition about how grainy a let exposure_ev = self
// stock looks comes from. The sensor's width in pixels then says how .graph
// much film one pixel covers, and the grain model needs nothing else .param(
// to be correct at any zoom. dr_pipeline::ops::film_sim::ID,
// TRACES: FR-DEV-3f dr_pipeline::ops::film_sim::EXPOSURE,
// The frame this is being simulated on, against the pixels it is being )
// rendered to: together they are the enlargement, and the enlargement .unwrap_or(0.0);
// is what decides how grainy the result looks. A crystal is a fixed
// size in micrometres — the same emulsion on a sheet averages far more
// of them into each pixel than it does on 35 mm.
let format = dr_film::Format::from_index(
self.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::FORMAT,
)
.unwrap_or(0.0)
.max(0.0) as usize,
);
let (source_width, _) = self.demosaiced.size();
let pixel_size_um = format.width_um() / source_width.max(1) as f32;
let grain = dr_film::Grain::for_pixel_size(profile, pixel_size_um);
let baked = dr_film::bake(&dr_film::Recipe { // Only the balance moves when the same stock and paper are chosen
film: profile, // again — which is what a moved exposure does — and it is three
print: paper, // numbers against a bake of two spectral lookups.
exposure_ev: self let same = self.graph.film().filter(|f| {
.graph f.stock == profile.stock && f.print.as_deref() == paper.map(|p| p.stock.as_str())
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::EXPOSURE,
)
.unwrap_or(0.0),
push_stops: self
.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::PUSH,
)
.unwrap_or(0.0),
print_exposure_ev: self
.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::PRINT_EXPOSURE,
)
.unwrap_or(0.0),
}); });
let tables = match same {
Some(f) => {
let mut tables = f.tables.clone();
if let (Some(held), Some(paper)) = (tables.paper.as_mut(), paper) {
held.balance = dr_film::bake::print_balance(profile, paper, exposure_ev);
}
tables
}
None => self.bake_film(profile, paper, exposure_ev),
};
self.set_film(Some(dr_pipeline::graph::Film { self.set_film(Some(dr_pipeline::graph::Film {
stock: profile.stock.clone(), stock: profile.stock.clone(),
print: paper.map(|p| p.stock.clone()), print: paper.map(|p| p.stock.clone()),
tables: dr_pipeline::ops::FilmTables { tables,
exposure_matrix: baked.exposure_matrix,
curves: baked.curves,
curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max,
lut: baked.lut,
density_max: baked.density_max,
lut_size: baked.lut_size,
grain_particles: grain.particles,
grain_density_max: grain.density_max,
grain_uniformity: grain.uniformity,
},
})); }));
} }
/// TRACES: FR-DEV-3f
/// A stock and its paper as the shader binds them.
///
/// The paper, when there is one, rides behind the film: its curve as one
/// more row, its lookup stacked after the film's. See `FilmTables`.
fn bake_film(
&self,
profile: &dr_film::Profile,
paper: Option<&dr_film::Profile>,
exposure_ev: f32,
) -> dr_pipeline::ops::FilmTables {
// TRACES: FR-DEV-3f
// Grain, at the scale this photograph is being sampled at, on every
// frame it could be simulated on.
//
// A digital frame has no film format, so simulating one means choosing
// what it *would have been*. A crystal is a fixed size in micrometres,
// so the sensor's width in pixels against the frame's width says how
// much film one pixel covers — and the same emulsion on a sheet
// averages far more of them into each pixel than it does on 35 mm.
// All six are baked because the format is a per-pixel setting: a
// layer may be on another one.
let (source_width, _) = self.demosaiced.size();
let grains = dr_film::Format::ALL.map(|format| {
let pixel_size_um = format.width_um() / source_width.max(1) as f32;
dr_film::Grain::for_pixel_size(profile, pixel_size_um)
});
let baked = dr_film::bake(&dr_film::Recipe {
film: profile,
print: paper,
exposure_ev,
});
let mut curves = baked.curves;
let mut lut = baked.lut;
let paper = baked.paper.map(|p| {
curves.extend_from_slice(&p.curves);
lut.extend_from_slice(&p.lut);
dr_pipeline::ops::PaperTables {
balance: p.balance,
log_min: p.log_min,
log_max: p.log_max,
density_max: p.density_max,
}
});
dr_pipeline::ops::FilmTables {
exposure_matrix: baked.exposure_matrix,
curves,
push_stations: baked.push_stations,
curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max,
lut,
density_max: baked.density_max,
lut_size: baked.lut_size,
paper,
grain_particles: grains.map(|g| g.particles),
grain_density_max: grains[0].density_max,
grain_uniformity: grains[0].uniformity,
}
}
/// The stock and paper currently chosen, by id. /// The stock and paper currently chosen, by id.
pub fn film(&self) -> Option<(&str, bool)> { pub fn film(&self) -> Option<(&str, bool)> {
self.graph self.graph
@@ -827,33 +853,51 @@ impl DevelopSession {
.map(|f| (f.stock.as_str(), f.print.is_some())) .map(|f| (f.stock.as_str(), f.print.is_some()))
} }
/// Re-bake if `op_index` names the film, and do nothing otherwise. /// TRACES: FR-DEV-3f
/// Re-balance the enlarger if `op_index`/`param_index` name the
/// photograph's film exposure, and do nothing otherwise.
/// ///
/// `op_index` counts over [`Self::scoped_capabilities`] — the same list /// The one film slider the tables depend on: the print balance is solved
/// [`Self::lookup`] resolves a slider through — so that is the only list to /// against the photograph's exposure, as an enlarger's filtration is set
/// ask. An earlier version also indexed `rows()`, which is one entry per /// for the negative in front of it. Everything else the film's sliders
/// *parameter* and filtered by the active tab: past its end the check /// move — push, print exposure, format, and exposure on a mask layer — is
/// a per-pixel setting the shader reads, and needs nothing from here.
///
/// Both indices count over [`Self::scoped_capabilities`] — the same list
/// [`Self::lookup`] resolves a slider through — so that is the only list
/// to ask. An earlier version also indexed `rows()`, which is one entry
/// per *parameter* and filtered by the active tab: past its end the check
/// short-circuited, the tables were never rebuilt, and the film's own /// short-circuited, the tables were never rebuilt, and the film's own
/// sliders moved nothing at all. /// sliders moved nothing at all.
/// ///
/// The test is here rather than at the call site so the callback in /// The test is here rather than at the call site so the callback in
/// `lib.rs` goes on naming no operation, which is the rule the whole panel /// `lib.rs` goes on naming no operation, which is the rule the whole panel
/// is built on (ARCH §4.3a). /// is built on (ARCH §4.3a).
pub fn rebake_film_if_affected(&mut self, op_index: i32) { pub fn rebake_film_if_affected(&mut self, op_index: i32, param_index: i32) {
let is_film = usize::try_from(op_index) if self.active_layer().is_some() {
return;
}
let caps = self.scoped_capabilities();
let Some(op) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
return;
};
let param = usize::try_from(param_index)
.ok() .ok()
.and_then(|i| self.scoped_capabilities().get(i).map(|c| c.id)) .and_then(|i| op.params.get(i))
.is_some_and(|id| id == dr_pipeline::ops::film_sim::ID); .map(|p| p.id);
if is_film { if op.id == dr_pipeline::ops::film_sim::ID
&& param == Some(dr_pipeline::ops::film_sim::EXPOSURE)
&& self.graph.film().is_some_and(|f| f.print.is_some())
{
self.rebake_film(); self.rebake_film();
} }
} }
/// The stock, the paper, and how far it was developed. /// The stock and the paper again, at the photograph's current exposure.
/// ///
/// Push rides with the other two through every path that re-bakes, because /// Only the enlarger's balance changes when they are the same stock and
/// it is the same kind of fact: a decision about the material rather than /// paper, so this is cheap on the path a slider takes; see
/// an adjustment to the picture it produced. /// [`Self::choose_film`].
pub fn rebake_film(&mut self) { pub fn rebake_film(&mut self) {
if let Some((stock, print)) = self.film().map(|(s, p)| (s.to_string(), p)) { if let Some((stock, print)) = self.film().map(|(s, p)| (s.to_string(), p)) {
self.choose_film(Some(&stock), print); self.choose_film(Some(&stock), print);
@@ -1249,6 +1293,90 @@ mod tests {
} }
} }
/// TRACES: FR-DEV-3f
/// A mask layer offers the film's settings and not the stock.
///
/// The layer holds offsets to the photograph's film, which the shader
/// blends per pixel, so its sliders belong on it. The stock does not: it
/// is what the whole photograph was made on, and a picker in the layer's
/// panel would change it for every pixel.
#[test]
fn a_mask_layer_offers_film_settings_but_not_a_stock() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
session.set_active_tab(-1);
assert!(
session.film_in_group(),
"the photograph has a stock to pick"
);
let id = session.add_gradient_mask(true).expect("radial");
session.set_active_mask(Some(&id));
assert!(!session.film_in_group(), "a layer has no stock of its own");
assert!(
session
.scoped_capabilities()
.iter()
.any(|c| c.id == dr_pipeline::ops::film_sim::ID),
"but it has the film's settings"
);
session.set_active_mask(None);
assert!(
session.film_in_group(),
"and the stock returns with the photograph"
);
}
/// TRACES: FR-DEV-3f
/// Only the photograph's exposure touches the tables, and only the
/// balance in them.
///
/// Everything else a film slider moves is a per-pixel setting. Re-baking
/// for those would cost two spectral lookups per tick of a slider for
/// nothing, and re-baking for a layer's exposure would rebalance the whole
/// print around one region.
#[test]
fn a_film_slider_rebalances_only_where_the_balance_depends_on_it() {
use dr_pipeline::ops::film_sim;
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
session.set_active_tab(-1);
session.choose_film(Some("kodak_portra_400"), true);
let balance = |s: &DevelopSession| {
s.graph
.film()
.and_then(|f| f.tables.paper)
.map(|p| p.balance)
};
let before = balance(&session).expect("a printed negative has a balance");
let caps = session.scoped_capabilities();
let op = caps
.iter()
.position(|c| c.id == film_sim::ID)
.expect("film");
let param = |id| caps[op].params.iter().position(|p| p.id == id).unwrap() as i32;
session.graph.set_param(film_sim::ID, film_sim::PUSH, 1.0);
session.rebake_film_if_affected(op as i32, param(film_sim::PUSH));
assert_eq!(
balance(&session),
Some(before),
"push is not the enlarger's business"
);
session
.graph
.set_param(film_sim::ID, film_sim::EXPOSURE, 1.0);
session.rebake_film_if_affected(op as i32, param(film_sim::EXPOSURE));
assert_ne!(
balance(&session),
Some(before),
"the balance follows the exposure"
);
}
/// A frame black on the left half and white on the right, at `size` /// A frame black on the left half and white on the right, at `size`
/// square. Both ends of the histogram are occupied and both clipping /// square. Both ends of the histogram are occupied and both clipping
/// counters are non-zero, and cropping to one half leaves exactly one of /// counters are non-zero, and cropping to one half leaves exactly one of
+8
View File
@@ -90,7 +90,15 @@ impl DevelopSession {
/// the operation — what is this control *about* — and the panel is not /// the operation — what is this control *about* — and the panel is not
/// allowed to know. It asks the descriptor, so a stock that were ever /// allowed to know. It asks the descriptor, so a stock that were ever
/// re-declared as something other than an effect would move on its own. /// re-declared as something other than an effect would move on its own.
///
/// Never on a mask layer. A layer holds the film's *settings* — a region
/// pushed further, or burned in under the enlarger — but the stock is what
/// the whole photograph was made on, and a picker in a layer's panel would
/// change it for every pixel while looking as though it changed some.
pub fn film_in_group(&self) -> bool { pub fn film_in_group(&self) -> bool {
if self.active_layer().is_some() {
return false;
}
let Some(active) = self.active_tab else { let Some(active) = self.active_tab else {
// "All" shows everything, the stock included. // "All" shows everything, the stock included.
return true; return true;
+6 -7
View File
@@ -445,12 +445,11 @@ fn wire_adjustments(window: &AppWindow, w: &DevelopWiring) {
if let Some(s) = session.borrow_mut().as_mut() { if let Some(s) = session.borrow_mut().as_mut() {
s.set_param(op, param, value); s.set_param(op, param, value);
// TRACES: FR-DEV-3f // TRACES: FR-DEV-3f
// The film's own exposures ride *inside* the baked tables // The print balance is solved against the photograph's
// rather than arriving as uniforms, because the print balance // exposure — an enlarger's filtration depends on how the
// is solved against them — an enlarger's filtration depends on // negative was exposed — so moving it re-solves the
// how the negative was exposed. So moving one has to rebuild // balance, which no other slider in the panel does.
// the lookup, which no other slider in the panel does. s.rebake_film_if_affected(op, param);
s.rebake_film_if_affected(op);
} }
sync_rows(&w, &rows, &session); sync_rows(&w, &rows, &session);
redraw(&w); redraw(&w);
@@ -506,7 +505,7 @@ fn wire_adjustments(window: &AppWindow, w: &DevelopWiring) {
(row.value + direction.signum() as f32 * step).clamp(row.minimum, row.maximum); (row.value + direction.signum() as f32 * step).clamp(row.minimum, row.maximum);
if let Some(s) = session.borrow_mut().as_mut() { if let Some(s) = session.borrow_mut().as_mut() {
s.set_param(op, param, value); s.set_param(op, param, value);
s.rebake_film_if_affected(op); s.rebake_film_if_affected(op, param);
} }
sync_rows(&w, &rows, &session); sync_rows(&w, &rows, &session);
redraw(&w); redraw(&w);
+3 -2
View File
@@ -38,6 +38,7 @@
//! is a function of the image id, so the next review finds each such file //! is a function of the image id, so the next review finds each such file
//! there, and consolidating again treats it as already moved. //! there, and consolidating again treats it as already moved.
use crate::executors::{self, Executor};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender}; use std::sync::mpsc::{Receiver, Sender};
@@ -492,7 +493,7 @@ pub fn spawn_check(
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
) -> Receiver<DupMessage> { ) -> Receiver<DupMessage> {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { executors::spawn(Executor::Network, "dupes", move || {
let stopped = run_check(&conn, &catalog_path, cache_dir, groups, &stop, &tx).err(); let stopped = run_check(&conn, &catalog_path, cache_dir, groups, &stop, &tx).err();
let _ = tx.send(DupMessage::Finished { stopped }); let _ = tx.send(DupMessage::Finished { stopped });
}); });
@@ -758,7 +759,7 @@ pub fn spawn_consolidate(
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
) -> Receiver<DupMessage> { ) -> Receiver<DupMessage> {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { executors::spawn(Executor::Network, "consolidate", move || {
let stopped = run_consolidate(&conn, &catalog_path, cache_dir, plans, &stop, &tx).err(); let stopped = run_consolidate(&conn, &catalog_path, cache_dir, plans, &stop, &tx).err();
let _ = tx.send(DupMessage::Finished { stopped }); let _ = tx.send(DupMessage::Finished { stopped });
}); });
+254
View File
@@ -0,0 +1,254 @@
//! TRACES: NFR-ARCH-1 | R4
//! The executors: the one place that names them, states their thread counts,
//! and starts the threads that belong to them.
//!
//! `architecture.md §7.1` is the design; this is where the code says the same
//! thing. There are five, and every long-lived thread the app starts belongs
//! to one of them:
//!
//! | Executor | Threads | Work |
//! |---|---|---|
//! | [`Executor::Ui`] | 1 | Slint's event loop. Never blocks. |
//! | [`Executor::GpuSubmit`] | 1 | Command encoding and queue submission |
//! | [`Executor::Decode`] | cores − 2 | RAW decode, previews, and the CPU passes over them |
//! | [`Executor::Io`] | 4 | Catalog, sidecar, cache, filesystem |
//! | [`Executor::Network`] | 2 | Nextcloud and other remote transfer |
//!
//! # What this module does and does not enforce
//!
//! It **names**: [`spawn`] gives every thread `<executor>:<role>` — `net:sync`,
//! `decode:thumbs` — which is what a panic message, `top -H` and a profiler
//! show. Before it, most workers were `<unnamed>`, and a panic on one said
//! nothing about which of thirty jobs it was.
//!
//! It **marks**: each thread knows which executor it belongs to
//! ([`current`]), and the UI thread is marked where the event loop starts
//! ([`mark_ui_thread`], called at the top of `run`). [`assert_not_ui`] is the
//! guard every blocking helper calls; see there.
//!
//! It does **not yet bound** the counts. A job still gets a thread of its own
//! when it starts, as it did before this module existed, so the counts in the
//! table are the budget the design states rather than a limit the code holds
//! — [`Executor::threads`] is what a pool will be sized from when one exists.
//! Pooling, and the priority between executors that NFR-ARCH-2 asks for, is a
//! change of behaviour; naming them first is what makes that change visible.
//!
//! # Which executor a job belongs to
//!
//! By what it spends its time on. One that decodes images or runs a CPU pass
//! over their pixels or embeddings — the thumbnail and metadata sweeps, face
//! indexing, grouping, repairs — is [`Executor::Decode`], even when it fetches
//! the bytes first. One that drives the GPU — batch export, a merge — is
//! [`Executor::GpuSubmit`]. One whose work is moving files or metadata to or
//! from a server — sync, sidecars, fetches, trash, login — is
//! [`Executor::Network`], even when it touches the catalog on the way. The
//! rest — opening the catalog, a backup, a local import, relaying a channel to
//! the log — is [`Executor::Io`].
use std::cell::Cell;
use std::thread::JoinHandle;
/// One of the app's executors. See the module documentation for the table.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Executor {
/// The Slint event loop. One thread, and it never blocks (NFR-P9).
Ui,
/// Command encoding and queue submission.
GpuSubmit,
/// RAW decode, preview extraction, and CPU-bound passes over the result.
Decode,
/// Catalog, sidecars, caches, the filesystem.
Io,
/// Transfer to and from a remote library.
Network,
}
impl Executor {
/// Every executor, in the order §7.1 lists them.
pub const ALL: [Executor; 5] = [
Executor::Ui,
Executor::GpuSubmit,
Executor::Decode,
Executor::Io,
Executor::Network,
];
/// The prefix of every thread name on this executor.
///
/// Short because Linux keeps fifteen bytes of a thread name, and the role
/// after the colon is the part worth reading.
pub const fn name(self) -> &'static str {
match self {
Executor::Ui => "ui",
Executor::GpuSubmit => "gpu",
Executor::Decode => "decode",
Executor::Io => "io",
Executor::Network => "net",
}
}
/// The thread count §7.1 states for this executor, on this machine.
///
/// - **UI, 1**: Slint has one event loop, on the thread that created the
/// window.
/// - **GPU submit, 1**: one queue; more submitters only contend for it.
/// - **Decode, cores − 2**: CPU-bound, so as many as there are cores,
/// less one for the UI and one for the GPU submitter, so neither is
/// starved by a sweep. Never fewer than one.
/// - **I/O, 4**: enough to overlap a catalog write with file reads; more
/// only queue on the same disk.
/// - **Network, 2**: one transfer and one metadata request in flight; a
/// WebDAV server serialises most of the rest per account.
pub fn threads(self) -> usize {
match self {
Executor::Ui | Executor::GpuSubmit => 1,
Executor::Decode => std::thread::available_parallelism()
.map_or(1, |n| n.get())
.saturating_sub(2)
.max(1),
Executor::Io => 4,
Executor::Network => 2,
}
}
}
thread_local! {
/// Which executor this thread belongs to; `None` for threads this module
/// did not start — the test harness, a library's own workers.
static CURRENT: Cell<Option<Executor>> = const { Cell::new(None) };
}
/// The executor the calling thread belongs to, if it was started here or is
/// the marked UI thread.
pub fn current() -> Option<Executor> {
CURRENT.with(Cell::get)
}
/// Mark the calling thread as the UI executor.
///
/// Called once, at the top of `run`, on the thread that goes on to create the
/// window and enter Slint's event loop — on desktop the main thread, on
/// Android the one `android_main` runs on.
pub fn mark_ui_thread() {
CURRENT.with(|c| c.set(Some(Executor::Ui)));
}
/// TRACES: NFR-ARCH-1 | NFR-P9
/// Fail loudly, in debug and test builds, if the calling thread is the UI
/// thread. `what` names the blocking call, for the message.
///
/// Every blocking helper calls this before it blocks: a `block_on` on the UI
/// thread is a frozen window for as long as the future takes, which for a
/// network call is unbounded. A debug assertion rather than a check in release
/// because the fault is in the code, not the input — a release build that met
/// it would still freeze rather than crash, which is no worse than before, and
/// every test and debug run that reaches the call finds it.
#[track_caller]
pub fn assert_not_ui(what: &str) {
debug_assert!(
current() != Some(Executor::Ui),
"{what} on the UI thread: it would block the event loop (NFR-ARCH-1). \
Move it onto a worker with executors::spawn."
);
}
/// Start a thread on `executor`, named `<executor>:<role>`.
///
/// A drop-in for `std::thread::spawn`, and like it panics if the OS will not
/// give a thread. `role` is what the job is — `scan`, `sync`, `thumbs` — and
/// should be short: the name is cut to fifteen bytes where the OS keeps it.
pub fn spawn<F, T>(executor: Executor, role: &str, f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
try_spawn(executor, role, f)
.unwrap_or_else(|e| panic!("spawning {}:{role}: {e}", executor.name()))
}
/// [`spawn`], for a caller that can carry on without the thread.
pub fn try_spawn<F, T>(executor: Executor, role: &str, f: F) -> std::io::Result<JoinHandle<T>>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
debug_assert!(
executor != Executor::Ui,
"the UI executor is the event loop's thread; it is marked, not spawned"
);
std::thread::Builder::new()
.name(format!("{}:{role}", executor.name()))
.spawn(move || {
CURRENT.with(|c| c.set(Some(executor)));
f()
})
}
#[cfg(test)]
mod tests {
use super::*;
/// TRACES: NFR-ARCH-1
/// Every executor has a name and at least one thread, and the fixed
/// counts are the ones §7.1 states.
#[test]
fn the_executors_and_their_counts_are_the_ones_the_design_states() {
let names: Vec<_> = Executor::ALL.iter().map(|e| e.name()).collect();
assert_eq!(names, ["ui", "gpu", "decode", "io", "net"]);
assert!(Executor::ALL.iter().all(|e| e.threads() >= 1));
assert_eq!(Executor::Ui.threads(), 1);
assert_eq!(Executor::GpuSubmit.threads(), 1);
assert_eq!(Executor::Io.threads(), 4);
assert_eq!(Executor::Network.threads(), 2);
}
/// TRACES: NFR-ARCH-1
/// A spawned thread carries its executor's name and knows which executor
/// it is on; the spawning thread is unaffected.
#[test]
fn a_spawned_thread_is_named_and_marked() {
let (name, on) = spawn(Executor::Network, "sync", || {
(std::thread::current().name().map(str::to_owned), current())
})
.join()
.unwrap();
assert_eq!(name.as_deref(), Some("net:sync"));
assert_eq!(on, Some(Executor::Network));
assert_ne!(current(), Some(Executor::Network));
}
/// TRACES: NFR-ARCH-1 | NFR-P9
/// A `block_on` on the thread marked as the UI executor panics, and says
/// why. On a thread of its own, because the mark is thread-local and with
/// `--test-threads=1` every test runs on the harness's main thread.
#[cfg(debug_assertions)]
#[test]
fn block_on_the_ui_thread_panics() {
let outcome = std::thread::spawn(|| {
mark_ui_thread();
let rt = crate::net_runtime::build().expect("runtime");
rt.block_on(async { 1 })
})
.join();
let payload = outcome.expect_err("a block_on on the UI thread must panic");
let message = payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_default();
assert!(message.contains("UI thread"), "message: {message}");
}
/// TRACES: NFR-ARCH-1
/// The same `block_on` on a worker is what the network runtime is for.
#[test]
fn block_on_a_worker_is_allowed() {
let answer = spawn(Executor::Network, "test", || {
let rt = crate::net_runtime::build().expect("runtime");
rt.block_on(async { 42 })
})
.join()
.expect("a worker may block");
assert_eq!(answer, 42);
}
}
+6 -3
View File
@@ -55,6 +55,7 @@
//! same file from the grid does — subject, both times, to what the settings //! same file from the grid does — subject, both times, to what the settings
//! allow (FR-EXP-8). //! allow (FR-EXP-8).
use crate::executors::{self, Executor};
use std::collections::HashSet; use std::collections::HashSet;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
@@ -213,7 +214,7 @@ pub fn place(
if destination.trim().is_empty() { if destination.trim().is_empty() {
return Err("No export folder is set. Choose an album to export to.".into()); return Err("No export folder is set. Choose an album to export to.".into());
} }
// TRACES: FR-EXP-10 | FR-PLAT-AND-1 // TRACES: FR-EXP-10
// A SAF tree on Android: written through the provider, which may // A SAF tree on Android: written through the provider, which may
// rename on a collision, so the name it reports is the one kept. // rename on a collision, so the name it reports is the one kept.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
@@ -414,7 +415,7 @@ pub fn spawn_upload(
) -> std::sync::mpsc::Receiver<UploadMessage> { ) -> std::sync::mpsc::Receiver<UploadMessage> {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { executors::spawn(Executor::Network, "upload", move || {
let rt = match crate::net_runtime::build() { let rt = match crate::net_runtime::build() {
Ok(rt) => rt, Ok(rt) => rt,
Err(e) => { Err(e) => {
@@ -665,7 +666,9 @@ pub enum BatchMessage {
/// Export a selection on a thread of its own. /// Export a selection on a thread of its own.
pub fn spawn_batch(request: BatchRequest, cancel: Cancel) -> Receiver<BatchMessage> { pub fn spawn_batch(request: BatchRequest, cancel: Cancel) -> Receiver<BatchMessage> {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || run(request, &cancel, &tx)); executors::spawn(Executor::GpuSubmit, "export", move || {
run(request, &cancel, &tx)
});
rx rx
} }
+4 -3
View File
@@ -23,6 +23,7 @@
//! on every one. It therefore runs debounced, when detection has been idle and //! on every one. It therefore runs debounced, when detection has been idle and
//! the face count has moved materially (catalog.md §10.2). //! the face count has moved materially (catalog.md §10.2).
use crate::executors::{self, Executor};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender}; use std::sync::mpsc::{Receiver, Sender};
@@ -816,7 +817,7 @@ pub fn spawn_store_face_sweep(
) -> Receiver<FaceSweepMessage> { ) -> Receiver<FaceSweepMessage> {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { executors::spawn(Executor::Decode, "faces", move || {
let finish_empty = |tx: &Sender<FaceSweepMessage>| { let finish_empty = |tx: &Sender<FaceSweepMessage>| {
let _ = tx.send(FaceSweepMessage::Finished { let _ = tx.send(FaceSweepMessage::Finished {
images: 0, images: 0,
@@ -1348,7 +1349,7 @@ pub fn spawn_grouping_preview(
) -> Receiver<PreviewMessage> { ) -> Receiver<PreviewMessage> {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { executors::spawn(Executor::Decode, "grouping", move || {
let msg = match Catalog::open(&catalog_path) { let msg = match Catalog::open(&catalog_path) {
Ok(catalog) => match preview_grouping(&catalog, &model_id, &grouping) { Ok(catalog) => match preview_grouping(&catalog, &model_id, &grouping) {
Ok(p) => PreviewMessage::Ready(p), Ok(p) => PreviewMessage::Ready(p),
@@ -1397,7 +1398,7 @@ pub fn spawn_recluster(
) -> Receiver<ReclusterMessage> { ) -> Receiver<ReclusterMessage> {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { executors::spawn(Executor::Decode, "recluster", move || {
let catalog = match Catalog::open(&catalog_path) { let catalog = match Catalog::open(&catalog_path) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
+7 -8
View File
@@ -48,6 +48,7 @@
//! record afterwards is each file's digest (`dr_catalog::dedup`), which the //! record afterwards is each file's digest (`dr_catalog::dedup`), which the
//! scan cannot know because it never reads a whole file. //! scan cannot know because it never reads a whole file.
use crate::executors::{self, Executor};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender}; use std::sync::mpsc::{Receiver, Sender};
@@ -228,14 +229,12 @@ pub struct Outcome {
/// does, and the caller holds it. /// does, and the caller holds it.
pub fn spawn(request: Request, cancel: Cancel) -> Receiver<Message> { pub fn spawn(request: Request, cancel: Cancel) -> Receiver<Message> {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new() executors::try_spawn(Executor::Io, "import", move || {
.name("import".into()) if let Err(e) = run(request, &cancel, &tx) {
.spawn(move || { let _ = tx.send(Message::Failed(e));
if let Err(e) = run(request, &cancel, &tx) { }
let _ = tx.send(Message::Failed(e)); })
} .expect("spawning the import worker");
})
.expect("spawning the import worker");
rx rx
} }
+8 -21
View File
@@ -4,6 +4,7 @@
//! the develop window's wiring. The model holds the state machine and is //! the develop window's wiring. The model holds the state machine and is
//! tested headless; this module only moves values across the boundary. //! tested headless; this module only moves values across the boundary.
use crate::executors::{self, Executor};
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@@ -464,7 +465,7 @@ fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server:
// the thread boundary. // the thread boundary.
let (tx, rx) = std::sync::mpsc::channel::<LoginMessage>(); let (tx, rx) = std::sync::mpsc::channel::<LoginMessage>();
std::thread::spawn(move || { executors::spawn(Executor::Network, "login", move || {
// A panic anywhere below would unwind the thread, drop `tx`, and leave // A panic anywhere below would unwind the thread, drop `tx`, and leave
// the UI with nothing but a closed channel — which it can only report // the UI with nothing but a closed channel — which it can only report
// as "failed unexpectedly", losing the one piece of information that // as "failed unexpectedly", losing the one piece of information that
@@ -491,12 +492,7 @@ fn spawn_login(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, server:
// Only IO and time are enabled; `enable_all()` would also start the // Only IO and time are enabled; `enable_all()` would also start the
// signal driver, which wants process-wide signal handling that an // signal driver, which wants process-wide signal handling that an
// Android app's runtime already owns. // Android app's runtime already owns.
let rt = match tokio::runtime::Builder::new_multi_thread() let rt = match crate::net_runtime::build() {
.worker_threads(1)
.enable_io()
.enable_time()
.build()
{
Ok(rt) => rt, Ok(rt) => rt,
Err(e) => { Err(e) => {
let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}"))); let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}")));
@@ -538,17 +534,12 @@ fn spawn_direct_login(
) { ) {
let (tx, rx) = std::sync::mpsc::channel::<LoginMessage>(); let (tx, rx) = std::sync::mpsc::channel::<LoginMessage>();
std::thread::spawn(move || { executors::spawn(Executor::Network, "login", move || {
let panic_tx = tx.clone(); let panic_tx = tx.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
// Multi-thread for the reason the browser flow is: a current-thread // Multi-thread for the reason the browser flow is: a current-thread
// runtime left reqwest's future unpolled on Android. // runtime left reqwest's future unpolled on Android.
let rt = match tokio::runtime::Builder::new_multi_thread() let rt = match crate::net_runtime::build() {
.worker_threads(1)
.enable_io()
.enable_time()
.build()
{
Ok(rt) => rt, Ok(rt) => rt,
Err(e) => { Err(e) => {
let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}"))); let _ = tx.send(LoginMessage::Failed(format!("tokio runtime: {e}")));
@@ -604,7 +595,7 @@ fn spawn_direct_login(
/// The body of the login flow, split out so the worker above can wrap it in /// The body of the login flow, split out so the worker above can wrap it in
/// `catch_unwind` without a deeply nested closure. /// `catch_unwind` without a deeply nested closure.
fn run_login_flow( fn run_login_flow(
rt: tokio::runtime::Runtime, rt: crate::net_runtime::NetRuntime,
tx: std::sync::mpsc::Sender<LoginMessage>, tx: std::sync::mpsc::Sender<LoginMessage>,
server: String, server: String,
step: &dyn Fn(&str), step: &dyn Fn(&str),
@@ -777,16 +768,12 @@ fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, pa
let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<String>, String>>(); let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<String>, String>>();
std::thread::spawn(move || { executors::spawn(Executor::Network, "folders", move || {
// Multi-thread for the same reason as the login worker: a // Multi-thread for the same reason as the login worker: a
// current-thread runtime left reqwest's connection future unpolled on // current-thread runtime left reqwest's connection future unpolled on
// Android, so the await never resolved and the thread stopped without // Android, so the await never resolved and the thread stopped without
// failing or returning. // failing or returning.
let rt = tokio::runtime::Builder::new_multi_thread() let rt = crate::net_runtime::build();
.worker_threads(1)
.enable_io()
.enable_time()
.build();
let Ok(rt) = rt else { let Ok(rt) = rt else {
let _ = tx.send(Err("runtime".into())); let _ = tx.send(Err("runtime".into()));
return; return;

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