Compare commits

...
89 Commits
Author SHA1 Message Date
dtourolle 2c947430e6 Release 0.19.3
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m26s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m9s
Build and test / Android (aarch64) (push) Successful in 30m44s
Build and test / android-image (push) Successful in 3s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 49m45s
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 35s
Build and test / Windows (x86_64, cross) (push) Successful in 36m12s
Build and test / Publish the release (push) Successful in 1m12s
2026-09-30 22:46:17 -04:00
dtourolle 36556729f1 Let Back close the presets menu instead of the application
The presets menu at the foot of the tool rail is a PopupWindow, and
showing a popup takes focus off the develop view until it closes. The
menu held nothing focusable, so Android's Back gesture, pressed to
dismiss it, found no focus item, went unanswered, and the platform
closed the application. Slint closes a popup on Escape by itself but
not on Back.

The menu now holds a key scope, as the film list does, that closes it
on Back or Escape. The next Back leaves develop for the grid.
2026-09-30 22:11:51 -04:00
dtourolle 6f33517b35 Cut panorama overlaps along seams instead of averaging them
The merge weighted every overlap pixel by its distance from each frame's
edge, a 200 px linear cross-fade. Anything the frames disagreed on —
parallax in the near foreground, grass in the wind, a walker — came out
twice at half strength: a soft double edge at 1:1.

dr_pano::seam picks, per output texel at proxy resolution, which frame a
pixel comes from. Where a new frame overlaps the composite the cost is the
gain-corrected difference plus local detail plus nearness to either
frame's edge, taken as the worst over a small window, and the cut is a
dynamic-programming path across the overlap. merge.wgsl weights each frame
by its tent-filtered share of that map, a 64 px blend that follows the
seam, with the edge feather kept as the fallback. The page's preview uses
the same map, and examples/merge.rs takes --feather-only for comparison.
2026-09-30 21:57:44 -04:00
dtourolle 1d7115437b Date a photograph from its name when its header has none
WhatsApp strips every EXIF tag and names the file "WhatsApp Image
2023-06-15 at 07.00.42.jpeg"; Windows Phone, Android cameras and
darktable's import put the date in the name too. Those images sorted
after everything else and were absent from the timeline.

name_dates reads a date (and a time, when one follows) from the file
name, then from the innermost folder that states one. A sequence number
after a date is not read as a time, and a bare year folder is not a date.
EXIF always wins: only examined rows still undated are filled.

The sweep and the metadata repair fill as they mark an image examined,
and the open backfill fills catalogs examined by earlier builds. On the
reference library that takes 274 undated images to 10; the no-op case is
a seek on images_captured, 0.6 ms an open.
2026-09-30 21:45:54 -04:00
dtourolle caae65c78d Import from an SD card or card reader on Android
Import was switched off on Android: `imports_supported` was true only for
`target_os = "linux"`, and its comment said Android has no path to read a
card by and nowhere to write the copies. Neither holds. With "all files
access" (MANAGE_EXTERNAL_STORAGE, API 30) an app reads the root of an SD
card or a USB card reader by path, `/storage/9C33-6BBD`, and the importer
only ever writes into its own staging directory, which is a plain
directory on Android too. So the engine runs unchanged; what was missing
was finding the card and the permission.

- The manifest declares MANAGE_EXTERNAL_STORAGE, and
  READ_EXTERNAL_STORAGE up to API 29 with requestLegacyExternalStorage,
  which is the same access on 28 and 29.
- Cards.java lists the mounted non-primary volumes through
  StorageManager and opens the system "All files access" page for this
  app. dr_ui::cards is the JNI bridge, through saf's helpers.
- The import page on Android asks for the permission with an "Allow
  access" button until it has it, rather than showing an empty list that
  reads as "no card", and watches for the grant so the list fills in when
  the user comes back from settings.

Google Play restricts this permission to file managers and the like;
DarkRoom is sideloaded, so that does not apply.
2026-09-30 21:30:09 -04:00
dtourolle fcccc2c2e0 Release 0.19.2
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m29s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m4s
Build and test / Android (aarch64) (push) Successful in 31m8s
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 50m33s
Build and test / windows-image (push) Successful in 3s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / Layer separation (push) Successful in 33s
Build and test / Windows (x86_64, cross) (push) Successful in 36m27s
Build and test / Publish the release (push) Successful in 59s
2026-09-29 21:31:58 -04:00
dtourolle 1bc04870c3 Let a crop be trimmed from one edge
Four bar handles at the midpoints of the sides, each moving only its
own side along the axis across it. The overlay reports an edge as 0.5
on the axis it does not move, so the anchor Rust takes is the middle
of the far side.

Under a ratio lock the dragged axis leads. with_aspect grew the short
axis onto the ratio, which for an edge pulled inward made the untouched
axis the leader and pushed the edge straight back out.
2026-09-29 21:31:27 -04:00
dtourolle 4da2ec39b3 Fit the photograph clear of the roll and the floating row while composing
A crop handle straddles the picture's edge. With the photograph fitted
to the whole canvas, the bottom handles sat in the photo roll's band,
whose swipe handler takes every press inside it, and the bottom-left
one under the "Done Composing" row, which is declared after the overlay
and whose buttons press 44px tall. Both were drawn and could not be
grabbed: on a laptop-shaped window, one or the other for nearly any
photograph.

While composing, the canvas box is inset 16px from its clip and stops
above the band, the row's margin and the row's hit height. The band is
measured once, on the canvas, and the row reads it from there.
2026-09-29 21:31:27 -04:00
dtourolle 9558b77759 Say how to build and install from source, not only how to run it
The README gave cargo run and nothing else. A binary run from target/
finds no models and, in a release build, no manual, because both are
looked up only where an install puts them. Spell out the Arch package,
a /usr/local install mirroring the PKGBUILD, the Windows installer
cross-build and where it lands, and the Android APK.
2026-09-29 21:29:17 -04:00
dtourolle 5ae742816d Release 0.19.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m29s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m8s
Build and test / Android (aarch64) (push) Successful in 30m34s
Build and test / android-image (push) Successful in 3s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / Desktop (Linux) (push) Successful in 48m59s
Build and test / windows-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 48s
Build and test / Windows (x86_64, cross) (push) Successful in 35m51s
Build and test / Publish the release (push) Successful in 51s
2026-09-28 19:58:04 -04:00
dtourolle 8d66fa9be5 Show the composite in the grid in the manual, and say how it gets there
The panorama scene now ends in the grid, on the composite in its wide
cell beside the twelve frames it was made from, with the thumbnail the
merge made: the picture is taken straight after the merge, before any
rescan could have found the file. The manual's library section says how
a panorama's cell is sized and packed, and its panorama section that the
composite is in the grid the moment it is written. panorama.md §9 records
the race that kept it out, the catalogue-then-register path that
replaced it, how the thumbnail is developed, and the layout's classes.
2026-09-28 19:57:37 -04:00
dtourolle 34630ff752 Name the folder lookup the merge page is handed, for clippy 2026-09-28 19:57:37 -04:00
dtourolle 3b7d7129ff Learn a photograph's shape from its header
A panorama the merge did not make, a stitch from another program or a
phone's sweep, never had a width or height in the catalog, so it could
never be given a wide cell. The header read that dates a photograph now
records its size as it is seen, orientation applied, alongside the date.
2026-09-28 19:57:37 -04:00
dtourolle 6050a8e703 Size a panorama's cell and thumbnail by class: two, three or four columns
One lookup, natural_span, maps a photograph's aspect to the columns its
cell spans, and the same number names its thumbnail class, Wide2, Wide3
or Wide4, 512 pixels of long edge per column, so a 4:1 panorama is as
sharp across four columns as a frame is in one. The boundaries are
where the two neighbouring cells would leave the same share of
themselves undrawn, sqrt(s(s+1)): 2.45 and 3.46, with the first at 1.9
so a 3:2 frame stays a frame. A grid too narrow for the class falls
back to the widest that fits, the tablet gives the whole row, and a
cell asks for the class it is actually drawn at: its span, but never
more than its own class. The merge renders each wide class up to the
composite's own, which covers every fallback.
2026-09-28 19:57:37 -04:00
dtourolle ae4e1a0f07 Give a panorama a wide cell in the grid
A 4:1 composite drawn in one square cell is a strip a few pixels high.
A photograph about twice as wide as it is tall (1.9 and up) now spans
two columns, three from 2.9, with a thumbnail class of its own whose
long edge is sized for that width; on the tablet, or where the columns
are too few to put it beside anything, it takes the whole row.

Rows are computed in one place, library_ui::layout. The grid is a
lattice of slots: each cell is drawn at the slot Rust gives it, and a
wide cell that would not fit in what is left of a row starts the next
one, leaving the gap empty so the grid still reads in capture order.
The scrollbar spans the slots, a scroll reports a slot that the layout
turns back into an ordinal, and scrubs, restores and the cursor go
through the same conversion. Up and down step by rows through the
layout rather than by a row's worth of ordinals; left and right, a
shift-click's run, burst folding and the timeline are ordinal-based
and unchanged.

The window's own read carries each photograph's w and h, so the cells
know their shape with no query per cell. Where the wide ones sit in
the whole list is one query, run when what the grid lists changes or
when a window finds the layout out of date, and a library with no
panorama answers it from a partial index created on first use
(images_wide), not a schema bump. The merge makes the wide thumbnail
for a wide composite along with the others.
2026-09-28 19:57:37 -04:00
dtourolle ce5b7d72e3 Thumbnail a composite during the merge, as develop first shows it
The DNG a merge writes has no embedded preview, and an embedded preview
is all the grid's thumbnail path reads, so a composite stood in the grid
as a blank cell until it was opened. Reading an 800 MB file back to make
one would cost what the merge already has in hand.

The bands are box-reduced as they are written, after the border fill,
into a copy 4096 pixels long. That copy is written as a linear DNG in
memory with the composite's own profile, header and crop, and opened
through open_session, the function develop opens every file with: the
same decode, the default graph and view transform, the as-shot white
balance and the conversion to the display's space. The grid and large
thumbnails are rendered from that session, staged beside the payload
before the rename releases it to a drain, and put in the store under the
file id once the upload has learned it. Until then the grid draws them
from memory, so the cell is not blank while the file is uploading.

A test develops a synthetic composite both ways, the whole file as
develop opens it and the merge's reduced copy, and holds the thumbnail's
mean, 95th and 99.5th luma percentiles to within 3-4 levels of develop's
render; the naive balanced-and-gamma picture the merge's preview draws
misses by 13.
2026-09-28 19:57:37 -04:00
dtourolle 98a67393d9 Catalogue a merged panorama the moment it is written
A finished merge drained the outbox and started a rescan beside it. The
scan raced the upload: on a folder library the 800 MB copy was still
running when the folder was listed, on Nextcloud the upload takes
minutes, and either way the listing lacked the composite, recorded the
folder's validator, and nothing looked again until the next sync pass.

The job now reports what the catalog needs (the name, the size of the
picture it opens on, the capture time it wrote into the DNG) and the
library writes the row at once, in one transaction, keyed where the scan
will list the file; a composite with no time of its own takes its
sources' earliest. The grid reloads and shows it beside its sources.

The upload then gives the row what only the server knows: after sending
a file the catalog already has a row for, it lists the folder once, takes
the file id the server assigned, and records it (and, once the merge
makes them, the thumbnails waiting beside the payload) under that id.
Every drain now rescans when something landed in the library, after the
upload rather than beside it. A second merge of the same frames is no
longer named over the first: the name is checked against the catalog's
names in that folder, which is all that knows it once the outbox is empty.
2026-09-28 19:57:37 -04:00
dtourolle 37136f7377 Let one outbox drain run at a time
A finished merge drained the outbox and the sync pass that followed
drained it again beside it: each read the 800 MB composite into memory
and sent it, and the later one found its record cleared underneath it
and logged the file as missing. Drains now take a lock and the one that
waited finds the queue empty.
2026-09-28 19:57:37 -04:00
dtourolle e2e2181469 Write the composite under a .part name until it is whole
The merge writes its outbox record before the DNG, which takes minutes,
and a drain that ran in the meantime took whatever lay at the record's
name: a sync pass that fired mid-merge uploaded the first part of the
composite and cleared the record. The file is now written as x.dng.part
and renamed into place once the last strip is in; the drain skips a
record whose payload does not exist yet.
2026-09-28 19:57:37 -04:00
dtourolle ad27369cdc Draft the design for learned denoise (FR-DEV-3g)
A design draft for the learned stage outstanding.md §3 lists as missing:
a joint demosaic-and-denoise network that runs on the mosaic, in the
slot architecture.md §5.2 reserves for it, with its result kept as a
cache rather than a new file in the library. It covers the model and
tiling, synthetic training pairs from the library and a per-body noise
calibration, evaluation on real pairs, the Amount control, speed on the
tablet, X-Trans, and the decisions still open. Nothing in it is built;
figures marked "estimate" wait for the measurements that replace them.
2026-09-28 07:31:35 -04:00
dtourolle 883b4aca10 Release 0.19.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m27s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m2s
Build and test / Android (aarch64) (push) Successful in 29m53s
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 50m11s
Build and test / windows-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 38s
Build and test / Windows (x86_64, cross) (push) Successful in 35m28s
Build and test / Publish the release (push) Successful in 1m10s
2026-09-27 19:50:46 -04:00
dtourolle 8102234e68 Re-record the film preset on the preset folders
presets-film.gif came from the tree before preset-tree landed, so it
opened a flat list; the scene now walks Film › Black and white under
the folders, on the scene-referred pipeline. presets.png and
presets-menu.png re-recorded identical: preset-tree recorded them on
D19 already.
2026-09-27 19:50:28 -04:00
dtourolle 757133d2a8 Picture a panorama too large for one texture being navigated
A 22 927 × 8966 Lightroom panorama of a glacier, opened in develop:
the wheel in from fit to the peaks, a slow pan along the ridge at that
zoom, back out, then 1:1 with a double-click and fit with another. The
scene borrows the file from outside the demo library, copies it into a
panorama folder, restarts so the scan finds it, and deletes it and the
sidecar the app writes beside it before restarting again; it is
registered last so no other scene sees it.

The film is fine ice and rock in every frame and was 22 MB at the
usual 960 px and ten frames a second, so a scene can now name its own
GIF size (GIF_SIZE, read by record.sh through `scenes.py gif`); this
one is 640 px at eight, 8.7 MB.
2026-09-27 19:43:09 -04:00
dtourolle 5e863fa718 Re-record the manual under the scene-referred pipeline
D19 changed how every raw photograph renders, so every picture was
stale; all forty are recorded again from this tree. Beside the ones
already in the page there is now one for Tone Mapping: an alpine frame
whose clouds sit near white, its White Point raised to bring them back
and lowered for a brighter picture, its Contrast raised, then Before.

The panorama film unticks the first frame and ticks it back, so the
Frames list and the re-solve show; it no longer films minutes of
"Reading the frames", cutting from the first seconds of the job to the
alignment. The manual's presets menu opens beside the rail, not beside
the photograph.
2026-09-27 19:43:09 -04:00
dtourolle e600dae3df Regenerate the traceability matrix after the doc-comment sweep
The D19 sweep reflowed comments in detail.rs, adjust.rs and demosaic.rs,
which moves the line numbers the matrix records.
2026-09-27 19:42:51 -04:00
dtourolle 330ece0abe Bring the README's features and standing up to 0.19.0
The develop paragraph counted eighteen operations fused into one
dispatch, before the view transform made nineteen and a detail stage
made the fusion two passes, and said nothing of editing on the scene
with Tone Mapping last. Presets are now a menu on the tool rail, a
panorama frame can be left out, and a linear DNG larger than one
texture develops. The requirement count and coverage are
traceability.md's (193, 85.5%), and tiled rendering is no longer
wholly unbuilt. outstanding.md gains its sweep note for 0.19.0 and
says in §11 what the frame choice changed.
2026-09-27 19:42:42 -04:00
dtourolle 4bd8d86c00 Record where a linear DNG too large for one texture goes
ARCH §5.3 still described only a tile cache nobody built; it now says
what 0.19.0 tiles and what it does not: a linear DNG past PROXY_EDGE
opens on a reduced copy, a finer render samples a full-resolution
window, and the export is cut into halo-grown tiles. display-and-
extension.md's FR-DSP-2 row said absent, outstanding.md said the halo
had nothing to read it and that such a file fell to the embedded
preview.

rawler now builds from third_party, which ARCH's stack table and §3.2,
the root Cargo.toml's comment ("two upstream crates ... for Android")
and third_party/README.md's bump procedure did not know; the README
also names each vendored crate's licence. panorama.md and the manual
say a composite this wide develops and exports.
2026-09-27 19:42:42 -04:00
dtourolle 6c749b469d Describe leaving a panorama frame out, and its white clouds
panorama.md still had a frame that did not fit end the job, and FR-MRG-5
still said the merge stops; since 621a6b83 the frame is named on its
row and unticked, and the rest are solved again from the pairs already
matched. §15 records the split of align into match_pairs and solve and
why re-aligning a subset from scratch moved every RANSAC seed, and the
rule e98b1def gave a blown sample, which §8's "nothing about being a
RAW is lost" now mentions. The manual says where the boxes are and
what an unplaceable frame does to Merge.
2026-09-27 19:42:42 -04:00
dtourolle affdaecaee Stop describing a base curve the pipeline no longer has
D19 retired the per-body base curve, moved the matrix ahead of the
edits and the film into the view transform's place, but a dozen doc
comments still listed the curve among what a pixel passes through, or
said the film skipped it. The detail stage's module doc still drew the
matrix after the edits and the last detail pass encoding, which the
view pass took over. The film crate's README gave the base curves as
its reason for being data, and the ops README's list of hand-written
nodes had neither the view transform nor three of the five kernels.

FR-MRG-2 gave the base curve as why the merge cuts below the profile;
the view transform is why now. The decision table still said colour
defaults were a per-body curve, and FR-DEV-3j said only the default
view transform skips a JPEG, where the node skips one whatever its
sliders say. frame-budget.md records the view pass as unmeasured.
2026-09-27 19:42:41 -04:00
dtourolle 31bcc3a462 File presets in folders that open and close, as collections do
The presets menu and sheet listed every preset under flat section
headings, seventy rows to scroll past. They now list folders, closed
until opened, with how many presets each holds; opening one shows what
is inside it, folders and presets indented beneath.

A category is spelled in the name: "Portraits/Warm skin" is Warm skin
in a Portraits folder under Yours. The file format does not change, so
an older build lists the whole path as the name; renaming a preset is
how it moves, and saving or renaming into a folder opens the way to it.
A Lightroom import names what it reads after the folders below the one
chosen, and a "/" in a displayed name becomes "∕" so it files nothing.
The shipped film sections become Film › Colour, Cinema and Black and
white.

The tree is built and flattened in Rust (PresetTree), each row carrying
its depth, and which folders are open is remembered for the session.

A PopupWindow keeps the size it was shown at, so a folder opened in the
menu pushed its contents under "Save or manage…"; the menu is shown
again after each toggle to take its new height. That is a function on
the rail because Slint 1.17 generates Rust that does not compile for a
popup's close() reached from inside the popup. The sheet's list takes a
preferred height of up to 400px, since a Flickable reports next to
nothing and an opened folder showed three rows.

The manual describes the folders and naming. Its pictures show the menu
with Film › Colour open, and a black-and-white stock applied from Film
› Black and white; the scenes aim popup rows from the rail's entry, and
pick the menu's "Film" over the develop column's film chooser.
2026-09-27 18:37:56 -04:00
dtourolle 89b464db0c Accept a tile halo that the frame's edge cuts short
The test demanded a full halo on every side except the frame's own
edge, but a tile one step in from it is grown to the edge and no
further, exactly where the untiled render stops too.
2026-09-27 17:37:06 -04:00
dtourolle 87b2599740 Record that exports of oversized linear DNGs now tile
FR-DSP-2 and NFR-RES-2 said tiling was unbuilt; the export half of it
now exists for a linear DNG past one texture. The interactive path and
spike S6 are unchanged.
2026-09-27 17:37:06 -04:00
dtourolle 885864b6a0 Open and export a linear DNG too large for one texture
A 22927×8966 Lightroom panorama opened as its embedded preview with
develop withheld, because no texture could hold it. DevelopSession now
opens a linear DNG past PROXY_EDGE (8192) on a box-reduced copy, and
keeps the full resolution on the CPU. The canvas at fit, the thumbnail,
the histograms and the masks work from the copy; a render finer than
it — the canvas zoomed in, a tile of the export — samples a window cut
from the full resolution, kept while the view stays inside it. The
export renders in halo-grown tiles of 4096 and assembles them.

On the panorama: decode 1.6 s, open 210 ms, canvas 37 ms, a zoomed
window 130 ms, the full-size export 5.9 s.
2026-09-27 17:37:06 -04:00
dtourolle 0007fa459f Develop a linear DNG from windows and reduced copies of it
DemosaicedImage::linear_rgb16_window uploads part of a linear DNG, or a
box-reduced copy of it, and says where it sits in the frame; size() now
reports the frame and texture_size() the texels, and the fused pass
writes the window into the shader's uniforms. EditGraph::source_region
finds the part of the source a view reads, and tiles::plan cuts a render
too large for one texture into halo-grown, grid-aligned tiles.

The GPU test renders frames a tile at a time from their own windows and
compares them with the whole: identical for point operations, within one
code value when straightened with clarity on.
2026-09-27 17:35:16 -04:00
dtourolle 1eab723c90 Say how far a detail chain reaches, for a tile's halo
radius() is the widest single pass. The chain's passes run in sequence,
so what a tile has to be grown by is their sum.
2026-09-27 17:34:16 -04:00
dtourolle e601bd806c Keep clarity's radius when the develop view zooms in
A frame fraction was taken of the render's short edge, and render_scale
folds the zoom into the region the render stands for. Zoomed to 1:1 on a
corner, clarity, texture and dehaze drew a quarter of the halo the export
gets, though the docs promise they preview honestly at any zoom.
RenderScale now carries the whole framed frame (within), and a frame
fraction is measured against it; at fit nothing changes.
2026-09-27 17:34:16 -04:00
dtourolle cfc1fea25a Let the fused shader sample a window of a larger source
A photograph larger than one texture has to be developed from a part of
it or from a reduced copy of it. The generated prologue measured the frame
by the bound texture, so either would have moved every crop, warp and
grain seed. Two uniform vec4s now say which part of the frame the texture
holds and how large the frame is; the sampler maps into the texture
through to_window, and a texture holding the whole frame samples exactly
as before ((uv - 0) / 1 is uv to the bit).
2026-09-27 17:34:00 -04:00
dtourolle 72aa7e98bf Let rawler decode a linear DNG wider than 16 700 pixels
rawler's allocation guard is sized in samples but worded in pixels, and
a linear DNG passes width × 3. A 22927 × 8966 Lightroom panorama was
refused as ">50000 px wide", and develop fell back silently to the
embedded preview. Route rawler through third_party with the guard at
1.5 G samples and 200 000 per axis.
2026-09-27 17:33:19 -04:00
dtourolle 77a1925bac Vendor rawler 0.7.2 unmodified
The crate as crates.io publishes it, minus .cargo-ok, its Cargo.lock and
data/testdata (13 MB of sample files only its own tests read). Not yet
routed through [patch.crates-io]; the next commit is the patch.
2026-09-27 17:33:19 -04:00
dtourolle 0d9feb0556 Describe Tone Mapping and the film's new place in the manual
The Light group gains `Tone Mapping`, and a film stock now takes its
place at the end of the chain rather than sitting after exposure (D19).
Say what the two sliders do and why every adjustment above them works on
the scene, highlights beyond white included. The pictures still show the
old rendering and are re-recorded separately.
2026-09-27 16:52:56 -04:00
dtourolle 0682d05f95 Let the tone curve continue past 1.0, and test that nothing clips
The tone curve clamped its input to [0, 1] and its output back to 1.0
around a 2.2 gamma, mid-chain — master and per-channel both. So any
edit with a curve made every scene value above 1.0 the same number
before the view transform ever saw it: D19's fourth finding. Beyond its
last point the curve now continues along its last span, whose secant
is the tangent the spline already gives that point, so the join is
smooth and an identity curve stays the identity to any height. The only
clamp left is the floor at zero, where there is no light to curve.

FR-DEV-2's acceptance test is how the rule stays true.
`scene_referred_until_the_view` wraps every point operation, at
non-neutral settings, between a gain of sixteen and one of a
sixty-fourth, with an identity in the view transform's place, and
renders a ramp from 0.4 to 15.2 times sensor saturation through it. The
output must still increase with the input and still separate the top of
the ramp. Run against the previous commit's tone curve it fails with
[21, 29, 33, 33, 33, 33, 33, 33]; every other operation already passed.
It renders on a device because dr-pipeline has none, and the spec's
pointer to it says so.
2026-09-27 16:52:55 -04:00
dtourolle 657f8f19ff Develop the film last, in the view transform's place
A film stock ran at order 25, after white balance and exposure, and
everything after it — contrast, the curves, the colour mixer, the
grading, every mask layer's tone — acted on the film's display-referred
output, as though the frame had been scanned and then worked on. That
was a display-referred rendering in the middle of the chain, which D19
removes (FR-DEV-3f, FR-DEV-3j).

`film_sim` is now in `Stage::View` beside `view_transform`. While a
stock is loaded the composer emits it in the view transform's place and
not the sigmoid; otherwise the sigmoid. So every edit is a decision
about the exposure the negative receives, and the film is the last
thing that happens to the picture — after the detail stage too, in the
view pass, which already binds the film's tables, the mask array and
the grain's source position. Its per-layer settings blend there as they
did in the fused pass.

`op_renders` goes: a rendering is chosen, not suppressed, and the only
render with no view transform is the camera-space tap. The YAML order
moves to 190 so the panel reads in pipeline order; the stage, not the
number, is what places it.

Existing edits that combine a stock with tone or colour operations now
render differently: those operations used to act on the print, and now
act on the scene.
2026-09-27 16:52:55 -04:00
dtourolle c07f81edcb Run the view transform after the detail stage, in a pass of its own
The fused pass stops at "linear working values" when a sharpener, a
blur or a repair follows, and the detail passes convolve what it hands
on. Until now it handed on the rendering: the base curve, and since the
last commit the view transform, ran before the store. So every kernel
worked on display-referred values while its comments promised the
opposite — D19's second finding.

A fused pass composed for a detail stage now stops before the view
transform, and carries a second shader, `ComposedShader::view`, composed
from the same inputs. It runs the same prologue, for the positions a
fragment reads (a film's grain seeds from `source_px`) and the corners
it blacks out, takes its colour from the detail stage's result bound
where the sample cache would be, and runs the view transform, the
output transform and the mask reveal. `render_detailed` dispatches it
after the last detail pass, in the same encoder.

So no detail pass encodes any more. Every pass writes an intermediate,
the last one included, which retires three things that existed only to
make the last pass encode: `writes_output` and the runner's second
layout, the body-less resolve pass for an active kernel with nothing to
draw at this scale, and capture sharpening's pass-through, which now
emits no pass at all. An empty chain is a whole render: the view pass
reads the fused result directly. The detail stage no longer takes an
output space either, so `compose_detail_for` folds into
`compose_detail` and the space is named once, on the fused half.

The cost is one full-render read and write per frame when a detail
stage exists, and a third intermediate for a one-pass chain.
2026-09-27 16:52:54 -04:00
dtourolle 92afaebd34 Make the view transform an operation the photographer can set
FR-DEV-3j gives the view transform two controls, contrast and the white
point in stops above middle grey, persisted and held per mask layer like
any other setting. A scene-referred pipeline whose white point cannot be
moved hands the photographer a shoulder they cannot place.

`view_transform` is a hand-written node in `Stage::View`, a new stage
the composer emits last and emits whatever the node's state: a neutral
operation is otherwise left out of the shader, but a photograph with no
view transform is a scan. "Active" keeps meaning "moved from the
defaults", so an untouched photograph writes nothing for it and
`every_node_starts_neutral` still holds. A caller whose chain holds no
view operation gets a default one.

The composer's loop becomes an ordered list of steps — camera nodes, the
matrix, scene nodes, layer-only nodes, the view — so each is emitted in
exactly one place. The base curve could not be a node because it
belonged to the camera; the ops README records why that argument went
with it (D19). The panel shows it in the Light group as "Tone Mapping".

Tests that counted the blocks of a neutral graph now count one, the
view transform, and the two chain-wide tests that load a film expect the
view transform to be absent, since a stock replaces it.
2026-09-27 16:52:54 -04:00
dtourolle 37a6d99dc4 Replace the per-body base curve with a scene-referred view transform
The base curve was a five-point spline on the unit square, flat past its
last point: every value above 1.0 left it as the same number, per
channel. Exposure and highlight recovery put values up there, and the
curve threw them away, then handed the result on as though it were
still scene-linear. The six per-body curves were also, by their own
file's account, hand-tuned shapes rather than measurements, and not
enough is known about where they came from to keep them (D19).

In their place, one view transform for every body (FR-DEV-3j): a
log-logistic sigmoid per channel, with the middle channel put back
between the other two so a hue survives the shoulder. Its two free
constants are solved from two conditions rather than set: scene grey
0.13, where the retired default curve put it, lands on display 0.18,
and the scene white four stops above grey lands on 1.0. So a highlight
a stop past sensor saturation still rolls into white, and the midtones
stay within 0.26 EV of the retired default between scene 0.03 and 1.0.
`dr_pipeline::view` holds the CPU reference and the WGSL, and the tests
there are FR-DEV-3j's acceptance criteria.

It is still fixed and still in the fused pass's tail, so a detail stage
still sees rendered values; the next commits make it an operation and
move it after the detail stage. It is skipped for a JPEG, as the base
curve was, and absent from the camera-space tap.

The base curve's database, its lookup and its twelve uniform slots go.
`RawImage` and `DemosaicedImage` lose the field, and the GPU test that
proved a curve reached the shader is replaced by one that renders the
view transform against the CPU reference and shows two highlights above
1.0 still render apart. The JPEG-and-sensor test now asserts the two
differ by exactly the view transform, where before an identity fixture
curve had made them match.
2026-09-27 16:52:53 -04:00
dtourolle db7b84795c Convert to the working space before the edits, not after
Every point operation ran in camera RGB and the camera matrix came after
them all, against the order ARCH §5.2 draws. So `luminance()` applied
Rec.709 weights to a body's own primaries, a band in the colour mixer
was a different hue on every make of sensor, and the vibrance skin guard
tested channel order in a space where skin does not have one.

Operations now declare a `Stage`. White balance is the only camera-stage
node — its multipliers scale the sensor's channels, and after a matrix
that mixes them the same numbers are a different correction — and says
so with `stage: camera` in its YAML, a key both the build-time generator
and the load-time declared op read. The composer emits the camera nodes,
then the matrix, then the rest, each group in graph order; an empty
chain still gets the matrix.

Film simulation stops converting out of camera space itself, since it is
now handed working-space colour like every other scene node. The base
curve stays where it was, after the operations, and so now acts on
working-space colour; the next commit replaces it (D19).
2026-09-27 16:52:53 -04:00
dtourolle e8898a5c38 Specify a scene-referred pipeline (D19)
The spec missed Ansel, and with it Aurélien Pierre's argument that a
display-referred curve early in the pipeline throws away what every
later stage needs. Read against that argument, the code broke it four
ways: the base curve was flat past its last point and so clipped every
recovered highlight; the detail stage was handed curved, clipped values
while its comments promised linear ones; the edits ran in camera RGB
because the matrix had drifted to after them, against ARCH §5.2; and the
tone curve clamped to [0, 1] around a 2.2 gamma mid-chain.

D19 records the decision: unbounded scene-linear colour from the matrix
to one view transform, last, after the detail stage. FR-DEV-3j specifies
that view transform as an adjustable operation (contrast, white) with a
default fitted to where the retired default curve put middle grey.
FR-DEV-3e retires the per-body base curves, whose own file called them
hand-tuned shapes of unknown provenance. FR-DEV-3f makes a film stock
the view transform when one is chosen, instead of a rendering at order
25 that the later edits acted on. FR-DEV-2 gains the acceptance test
that holds the rule, and ARCH gains §6.14 and the corrected §5.2 order.
2026-09-27 16:52:52 -04:00
dtourolle 621a6b8313 Leave a panorama frame out without leaving the page
A frame that did not fit ended the job with its name, and the only way
on was Back, a smaller selection and every frame read, demosaiced and
searched for keypoints again. Each row on the page now has a box. An
unticked frame is left out and the rest are solved again from what the
first pass measured.

dr_pano::align is split for it: match_pairs does the matching and the
pairwise RANSAC once over every frame (about 5 s for twelve), and solve
takes a subset of the frames and uses only the links among them (about
0.1 s). Solving a subset by re-aligning it also moved every RANSAC seed,
which are keyed on frame position, and on the fixture set that was
enough to lose a marginal link and strand a neighbour of the frame left
out.

A frame that cannot be placed no longer stops the job either. Its row
names it and Merge stays off until it is unticked; the headless example
leaves such frames out the same way, and takes --leave-out N to try it.
2026-09-27 16:29:39 -04:00
dtourolle e98b1def98 Keep blown highlights grey in a panorama
A clipped photosite reaches the merge as (1, 1, 1), which the as-shot
balance turns magenta. The preview balanced it with no highlight rule at
all, so every blown cloud was pink on the alignment page. The DNG had the
quieter form of the same fault: a frame's gain below one moved a blown
sample off the white level, and a feather mixed it into a neighbour's
real sky, so the develop's own desaturation no longer recognised it.

The merge shader and the preview now write a blown sample, before the
gain, as the camera value the composite's balance calls grey — the
develop pipeline's neutral, fading in from CLIP_ONSET.
2026-09-27 16:29:28 -04:00
dtourolle f52c4cb8b6 Open presets from a menu at the foot of the tool rail
Presets were a "Presets…" button in develop's top bar that opened a
sheet over the photograph, so applying one was two clicks with the
picture covered. They are now an entry pinned to the bottom of the tool
rail, apart from the tools because a preset arms nothing, and it opens a
menu beside the rail: the same sectioned rows the sheet lists, one click
to apply to the open photograph. The menu's last row, "Save or manage…",
opens the sheet, which keeps saving, renaming, reverting and importing,
since those take a name or a path.

The menu is a PopupWindow for the film list's reasons, with the list in
a clipped box of its own; the Flickable alone let its last row draw over
the manage row. Choosing from it zeroes preset-apply-count before
applying, since the grid may have left its selection count there.

The manual says where presets are now and gains a picture of the open
menu. The scenes reach the sheet through the menu and aim the manage
row from the rail's entry, as the film list's rows are aimed, because
the automation reports a popup's elements in the popup's coordinates.
2026-09-27 16:08:33 -04:00
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
1154 changed files with 85801 additions and 3717 deletions
+13
View File
@@ -157,6 +157,19 @@ jobs:
- name: Test
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
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
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:
- **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
range, an expression naming something that is not a parameter. Each error
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
Generated
+27 -27
View File
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "darkroom-android"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"android_logger",
"dr-plat",
@@ -1278,7 +1278,7 @@ dependencies = [
[[package]]
name = "darkroom-desktop"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"anyhow",
"dr-plat",
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dr-bench"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"anyhow",
"dr-catalog",
@@ -1471,7 +1471,7 @@ dependencies = [
[[package]]
name = "dr-catalog"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"dr-face",
"dr-plat",
@@ -1486,7 +1486,7 @@ dependencies = [
[[package]]
name = "dr-decode"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"dr-types",
"env_logger",
@@ -1500,7 +1500,7 @@ dependencies = [
[[package]]
name = "dr-export"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"dr-decode",
"dr-gpu",
@@ -1519,7 +1519,7 @@ dependencies = [
[[package]]
name = "dr-face"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"dr-inference-engine",
"env_logger",
@@ -1532,7 +1532,7 @@ dependencies = [
[[package]]
name = "dr-film"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"log",
"serde",
@@ -1541,7 +1541,7 @@ dependencies = [
[[package]]
name = "dr-gpu"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"bytemuck",
"dr-decode",
@@ -1559,7 +1559,7 @@ dependencies = [
[[package]]
name = "dr-inference-engine"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"env_logger",
"libloading",
@@ -1574,7 +1574,7 @@ dependencies = [
[[package]]
name = "dr-ingest"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"dr-plat",
"dr-types",
@@ -1586,7 +1586,7 @@ dependencies = [
[[package]]
name = "dr-lens"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"lensfun",
"log",
@@ -1594,7 +1594,7 @@ dependencies = [
[[package]]
name = "dr-pano"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"dr-decode",
"dr-inference-engine",
@@ -1608,7 +1608,7 @@ dependencies = [
[[package]]
name = "dr-pipeline"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"dr-types",
"log",
@@ -1617,7 +1617,7 @@ dependencies = [
[[package]]
name = "dr-plat"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"android-native-keyring-store",
"dr-types",
@@ -1633,7 +1633,7 @@ dependencies = [
[[package]]
name = "dr-preset-xmp"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"dr-pipeline",
"log",
@@ -1643,7 +1643,7 @@ dependencies = [
[[package]]
name = "dr-segment"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"dr-inference-engine",
"env_logger",
@@ -1656,7 +1656,7 @@ dependencies = [
[[package]]
name = "dr-sync"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"async-trait",
"dr-plat",
@@ -1670,7 +1670,7 @@ dependencies = [
[[package]]
name = "dr-sync-folder"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"async-trait",
"dr-sync",
@@ -1682,7 +1682,7 @@ dependencies = [
[[package]]
name = "dr-sync-nextcloud"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"async-trait",
"dr-decode",
@@ -1704,7 +1704,7 @@ dependencies = [
[[package]]
name = "dr-thumbs"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"dr-types",
"jpeg-encoder",
@@ -1716,7 +1716,7 @@ dependencies = [
[[package]]
name = "dr-types"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"serde",
"serde_json",
@@ -1725,7 +1725,7 @@ dependencies = [
[[package]]
name = "dr-ui"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"anyhow",
"async-trait",
@@ -1773,7 +1773,7 @@ dependencies = [
[[package]]
name = "dr-xmp"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"dr-types",
"log",
@@ -5513,8 +5513,6 @@ dependencies = [
[[package]]
name = "rawler"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04f4cc35c23969a4a834e0b117c7da41ace812eb9053b5effc3fc5c77d114677"
dependencies = [
"backtrace",
"bitstream-io",
@@ -7109,12 +7107,14 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "traceability"
version = "0.17.0"
version = "0.19.3"
dependencies = [
"anyhow",
"proc-macro2",
"pulldown-cmark",
"serde",
"serde_json",
"syn 2.0.119",
]
[[package]]
+14 -6
View File
@@ -32,7 +32,7 @@ members = [
exclude = ["third_party"]
[workspace.package]
version = "0.17.0"
version = "0.19.3"
edition = "2021"
rust-version = "1.92"
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
# the HTML writer is needed, not the command-line front end.
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"
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
@@ -270,11 +276,13 @@ opt-level = 0
lto = "thin"
codegen-units = 1
# Two upstream crates carry a local patch so that the Android build can draw
# with wgpu on a rotated display (technical-debt.md TD-1). Both are exact
# copies of the version the lockfile already resolves, plus that patch;
# third_party/README.md says what was changed and how to carry it forward
# when Slint or wgpu moves.
# Three upstream crates carry a local patch: wgpu-hal and Slint's Skia
# renderer so that the Android build can draw with wgpu on a rotated display
# (technical-debt.md TD-1), and rawler so that a linear DNG wider than 16 700
# pixels decodes. Each is an exact copy of the version the lockfile already
# resolves, plus its patch; third_party/README.md says what was changed and
# how to carry it forward when Slint, wgpu or rawler moves.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
rawler = { path = "third_party/rawler-0.7.2" }
+135 -27
View File
@@ -25,23 +25,34 @@ dated folder and a backup beside it — is found, proved the same, and folded
onto one copy with the spares in the trash. Face detection and identity,
with the index syncing between devices.
**Developing.** Eighteen declared operations fused into one compute
dispatch, plus the neighbourhood work that cannot be: clarity, texture,
capture sharpening, noise reduction, lens correction, spectral film
simulation. Crop, straighten and correct converging verticals, spot repair,
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
agree, grow or shrink the edge. Focus peaking and a raw histogram for judging
what is recoverable. Presets, with a collection shipped in the application —
**Developing.** Nineteen declared operations, those that read one pixel
fused into a generated shader rather than run a pass each, plus the
neighbourhood work that cannot be: clarity, texture, dehaze, capture
sharpening, noise reduction, lens correction. Every edit works on the scene
as the camera recorded it — linear, highlights beyond white included — and
one `Tone Mapping` step, last, after sharpening and noise reduction, fits it
to the screen, with a contrast and a white point of its own; a spectral film
stock takes its place when one is chosen. Crop, straighten and correct
converging verticals, spot repair, 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 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, a click away in a menu
at the foot of the tool rail, with a collection shipped in the application —
everyday corrections, and a look for each measured colour, cinema and
black-and-white stock — and Lightroom presets imported as looks that leave a
photograph's own corrections alone. XMP sidecars other editors read.
photograph's own corrections alone. XMP sidecars other editors read. A
linear DNG larger than one GPU texture — a stitched panorama twenty thousand
pixels wide — opens, develops and exports at full size.
[![Segmenting an urban scene and choosing the sky as a mask](docs/manual/media/local-segment.png)](docs/manual/README.md#local-adjustments)
**Panoramas.** Select the frames, align, choose a projection, fill the
ragged border rather than crop it, and the composite lands beside its
sources as a DNG, with a sidecar recording what it was merged from.
**Panoramas.** Select the frames, align, untick any frame to leave it out
and the rest re-align at once, choose a projection, fill the ragged border
rather than crop it, and the composite lands beside its sources as a DNG,
with a sidecar recording what it was merged from.
[![Twelve hand-held frames aligned on a cylinder](docs/manual/media/panorama-aligned.png)](docs/manual/README.md#merging-a-panorama)
@@ -72,40 +83,137 @@ texture directly — no readback between the GPU and the screen.
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned |
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; folders are chosen through the portal, but no Flatpak has been built to prove it |
Or build it. Git LFS is required for the model weights, and the toolchain
pins itself to 1.92.0:
## Building from source
**Before anything.** Git LFS holds the model weights and the manual's
pictures; a clone without it has ~130-byte pointers in their place, and every
packager below refuses to ship one. The Rust toolchain pins itself to 1.92.0
through `rust-toolchain.toml`, so rustup is all you install. Slint needs a few
system headers, and the app needs a Vulkan driver at runtime:
```bash
git clone https://gitea.tourolle.paris/dtourolle/DarkRoom.git && cd DarkRoom
git lfs install && git lfs pull
# Debian / Ubuntu
sudo apt-get install pkg-config libfontconfig1-dev libxkbcommon-dev libvulkan1
# Arch
sudo pacman -S --needed pkgconf fontconfig libxkbcommon vulkan-icd-loader
```
**To try it** from the checkout, without installing anything:
```bash
cargo run --release -p darkroom-desktop
```
Android, through the containerised toolchain ([docker/android](docker/android/README.md)):
This is for development. The binary under `target/` finds no face, scene or
panorama-fill models, and a release build does not find the manual either:
it looks for all of them in the system data directories an install creates
(`$XDG_DATA_DIRS/darkroom`, by default `/usr/local/share/darkroom` and
`/usr/share/darkroom`), never in the checkout. Those features show as
unavailable until it is installed.
### Linux: build and install
**On Arch**, build a package from the checkout and install it with pacman,
so it can be upgraded and removed like any other:
```bash
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
cd packaging && makepkg -si
```
[CONTRIBUTING.md](CONTRIBUTING.md) has the system packages, the four
**Elsewhere**, build the release binary and install it under `/usr/local`
by hand. These are the same files, in the same places, as the Arch package
([`packaging/PKGBUILD`](packaging/PKGBUILD)'s `package()` is the reference):
```bash
cargo build --release --locked -p darkroom-desktop
# -> target/release/darkroom-desktop
P=/usr/local
sudo install -Dm755 target/release/darkroom-desktop $P/bin/darkroom-desktop
# The models: faces and eye state, scene categories, panorama border fill
sudo install -d $P/share/darkroom/models
sudo install -m644 models/face/*.onnx models/scene/* models/inpaint/*.onnx \
$P/share/darkroom/models/
# The offline manual the Help menu opens
sudo install -Dm644 docs/manual/index.html $P/share/darkroom/manual/index.html
sudo install -Dm644 -t $P/share/darkroom/manual/media docs/manual/media/*
# Launcher entry, icon and software-centre description
sudo install -Dm644 packaging/paris.tourolle.darkroom.desktop \
$P/share/applications/paris.tourolle.darkroom.desktop
sudo install -Dm644 ui/dr-ui/ui/app-icon.png \
$P/share/icons/hicolor/256x256/apps/paris.tourolle.darkroom.png
sudo install -Dm644 packaging/paris.tourolle.darkroom.metainfo.xml \
$P/share/metainfo/paris.tourolle.darkroom.metainfo.xml
```
Then run `darkroom-desktop`, or open it from the application menu. To
uninstall, remove those files and `/usr/local/share/darkroom`. Your catalog,
settings and thumbnails live in `darkroom/` under your own XDG data, config
and cache directories (`~/.local/share`, `~/.config`, `~/.cache`) and are
not touched by either.
Optional at runtime: `gnome-keyring` or `kwallet` to remember Nextcloud
credentials, and an ONNX Runtime in `/usr/lib` (CPU, or ROCm on an AMD GPU) to
run the models on every core rather than on the built-in engine.
### Windows: build the installer
The `.exe` is cross-built from Linux in a container (podman or docker), with
no Windows machine involved. Two steps — the executable, then the NSIS
installer that carries it with its models and manual:
```bash
./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
./docker/windows/build.sh docker/windows/package.sh
```
Both land in the container's cache on the host, `~/.cache/darkroom-windows/target/`:
the bare executable under `x86_64-pc-windows-gnu/release/darkroom-desktop.exe`,
the installer under `installer/DarkRoom-<version>-x86_64-setup.exe`.
Copy that to the Windows machine and run it — it installs per user, needs no
administrator rights, and adds an uninstaller. Run on its own, the bare
`.exe` looks for `models\` and `manual\` beside itself, so use the installer.
[docker/windows](docker/windows/README.md) has the details.
### Android: build the APK
Also containerised ([docker/android](docker/android/README.md)). This
builds, packages and debug-signs the APK, and with `--install` puts it on a
device connected over adb:
```bash
./docker/android/package.sh --install
```
A debug-signed APK cannot replace one installed from a release; uninstall
that first.
[CONTRIBUTING.md](CONTRIBUTING.md) has the four
commands CI runs against what you send, and the shortest useful
contribution — a develop operation is one YAML file, and it arrives with its
controls, its place in the chain and its tests.
## Where it stands
**0.17.0**, twenty-five tagged releases in. 192 numbered requirements in
scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md);
**0.19.3**, thirty-two tagged releases in. 193 numbered requirements in
scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
the rest are written down rather than merely absent.
**Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
survey culling, AI denoise, tiled rendering, HDR merge and
focus stacking, importing a Lightroom or darktable catalog, translations
beyond the launch screen, most of the Android platform integration beyond
running, and a Flatpak actually built and run in its sandbox. The
performance targets are half verified: the per-commit benchmark suite §8
requires exists for everything that does not need a frame — the catalog,
the scan, the thumbnails — and not yet for the render path, so a regression
there fails nothing.
survey culling, AI denoise, tiled rendering beyond the export of an oversized
DNG, HDR merge and focus stacking, importing a Lightroom or darktable catalog,
translations beyond the launch screen, most of the Android platform
integration beyond running, and a Flatpak actually built and run in its
sandbox. The performance targets are half verified: the per-commit benchmark
suite §8 requires exists for everything that does not need a frame — the
catalog, the scan, the thumbnails — and not yet for the render path, so a
regression there fails nothing.
[outstanding.md](docs/dev/outstanding.md) is the list, with the reasoning for
each.
@@ -4,9 +4,10 @@
Deliberately minimal: this packages the viewer for on-device testing (spike
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
here is a distribution manifest yet. Only network access is declared: file
access needs no manifest permission because the library grid reads through
SAF, which grants per-tree at runtime (ARCH §6.9).
here is a distribution manifest yet. The library grid needs no storage
permission, because it reads through SAF, which grants per-tree at runtime
(ARCH §6.9); the one storage permission declared is for importing from a
camera card, which is read by path.
Minimal is not the same as empty, and the entries below that are not the
activity are the difference. A manifest is the only place a component can be
@@ -21,13 +22,29 @@
WebDAV listing, thumbnail and image fetches. Without it Android refuses
socket creation outright, and the failure is invisible — no panic to
catch, no log line, just a worker thread that stops. Storage is the
separate case that genuinely needs no permission here, because SAF
grants per-tree at runtime (ARCH §6.9). -->
separate case: the library and album folders need no permission
here, because SAF grants per-tree at runtime (ARCH §6.9). -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
work on unmetered-and-charging, which means knowing the network type. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- FR-CAT-10: importing from a camera card. The importer reads the card
as files, and "all files access" is what makes an SD card or a USB
card reader readable by path on API 30 and up (see Cards.java). It is
granted on a system settings page, not a dialog; the import page
sends the user there when it is missing. READ_EXTERNAL_STORAGE is the
same thing for API 28 and 29, and means nothing above them; on 29 it
reads by path only with requestLegacyExternalStorage, which is why
<application> carries that flag.
Google Play limits MANAGE_EXTERNAL_STORAGE to a short list of app
kinds. DarkRoom is not distributed through Play. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
dependable (NFR-COMPAT-1). Marked required so an unsupported device
fails at install rather than at first frame. -->
@@ -53,6 +70,7 @@
android:icon="@mipmap/ic_launcher"
android:hasCode="true"
android:allowBackup="false"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true">
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
@@ -0,0 +1,150 @@
package paris.tourolle.darkroom;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.provider.Settings;
import android.util.Log;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* Finding a camera card, and the permission that makes it readable (FR-CAT-10).
*
* <p>An import reads the card as files: the survey walks it, the probe reads
* each header and the copy streams each original, all through the same
* {@code std::fs} code the desktop uses. Android hands out such paths —
* {@code /storage/9C33-6BBD/DCIM} — to an app holding "all files access"
* ({@code MANAGE_EXTERNAL_STORAGE}, API 30), which covers the root of an SD
* card and of a USB card reader. Below API 30 the same paths are readable
* with {@code READ_EXTERNAL_STORAGE}.
*
* <p>Not the folder picker {@link FolderPicker} uses for albums. A tree
* granted through SAF is {@code content://} URIs, not paths, and since API 30
* the picker refuses the root of a card outright; reading a card through it
* would mean a second storage implementation under the importer, where this
* needs none.
*
* <p>Google Play restricts this permission to file managers and the like.
* DarkRoom is not distributed through Play, so the restriction does not
* apply; it would need revisiting if that changed.
*/
public final class Cards {
private static final String TAG = "DarkRoom";
private Cards() {
}
/** Whether this app may read a card's files by path. */
public static boolean hasAccess(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return Environment.isExternalStorageManager();
}
return context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
== PackageManager.PERMISSION_GRANTED;
}
/**
* Open the system page where the user grants it.
*
* <p>A settings page rather than a permission dialog because there is no
* dialog for this one on API 30 and up: the user flips "Allow access to
* manage all files" for this app. Below 30 the context is the application
* context, which cannot raise a runtime permission request (that needs an
* Activity's result), so the app's own settings page is the route there
* too. Either way the app learns of the grant by asking
* {@link #hasAccess} again.
*/
public static void requestAccess(Context context) {
Uri self = Uri.parse("package:" + context.getPackageName());
Intent intent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, self);
} else {
intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, self);
}
// The context is not an Activity; see FolderPicker.start.
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
context.startActivity(intent);
} catch (RuntimeException e) {
// Some builds ship without the per-app page; the list of every
// app holding the permission is the fallback that always exists.
Log.w(TAG, "no per-app all-files page; opening the list", e);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent list = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
list.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(list);
}
}
}
/**
* Every mounted volume other than the device's own storage.
*
* <p>One string per volume, {@code path \t description \t removable},
* where removable is {@code 1} or {@code 0}: the reason {@link Intents}
* gives for keeping the JNI surface to strings. The primary volume is left
* out — it is the device's internal storage, never a card — and so is
* anything not mounted, which is a card being ejected or one the system
* could not read.
*/
public static String[] volumes(Context context) {
List<String> out = new ArrayList<String>();
StorageManager manager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
if (manager == null) {
return new String[0];
}
for (StorageVolume volume : manager.getStorageVolumes()) {
if (volume.isPrimary()) {
continue;
}
String state = volume.getState();
if (!Environment.MEDIA_MOUNTED.equals(state)
&& !Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
continue;
}
String path = path(volume);
if (path == null) {
Log.w(TAG, "a mounted volume with no path: " + volume);
continue;
}
String description = volume.getDescription(context);
if (description == null) {
description = new File(path).getName();
}
out.add(path + "\t" + description.replace('\t', ' ') + "\t"
+ (volume.isRemovable() ? "1" : "0"));
}
return out.toArray(new String[0]);
}
/**
* Where the volume is mounted.
*
* <p>{@code getDirectory} is API 30. Below it the same answer is the
* hidden {@code getPath}, which every release from 24 to 29 has, reached by
* reflection because android.jar does not declare it.
*/
private static String path(StorageVolume volume) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
File dir = volume.getDirectory();
return dir == null ? null : dir.getPath();
}
try {
Object path = StorageVolume.class.getMethod("getPath").invoke(volume);
return path == null ? null : path.toString();
} catch (ReflectiveOperationException e) {
Log.w(TAG, "StorageVolume.getPath", e);
return null;
}
}
}
+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
// reading the pointer takes only the app's read lock, which `poll_events`
// 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.
+34 -3
View File
@@ -1,6 +1,6 @@
//! 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
//! 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
//! 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
//! 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.
@@ -20,10 +27,18 @@
use std::path::PathBuf;
use std::time::{Duration, Instant};
use dr_catalog::{keywords, rating, schema, Catalog};
use dr_catalog::{keywords, name_dates, rating, schema, Catalog};
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 {
eprintln!("usage: catalog_bench CATALOG.sqlite");
std::process::exit(2);
@@ -93,6 +108,9 @@ fn main() {
time(" keywords::adopt_orphan_terms", 20, || {
keywords::adopt_orphan_terms(conn).unwrap();
});
time(" name_dates::fill", 20, || {
name_dates::fill(conn, None).unwrap();
});
interactive(conn);
@@ -117,6 +135,19 @@ fn main() {
let _ = std::fs::remove_file(&scratch);
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
// directions in the steady state, where nothing is new either way.
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
/// 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(
conn: &Connection,
target: PersonId,
@@ -1039,7 +1040,53 @@ pub fn merge_people(
return Ok(0);
}
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 —
// `face_person` is keyed by face. Where both hold the same face, the
// target's row wins and the source's is dropped.
@@ -1047,18 +1094,31 @@ pub fn merge_people(
"DELETE FROM face_person
WHERE person_id = ?2
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(
"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(
"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()],
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
SELECT face_id, ?1 FROM face_person_rejected WHERE person_id = ?2",
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)
}
@@ -2518,6 +2578,48 @@ mod tests {
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]
fn merging_does_not_duplicate_a_face_both_people_hold() {
let c = db();
+2
View File
@@ -42,6 +42,7 @@ pub mod bursts;
pub mod cache;
pub mod collections;
pub mod dedup;
pub mod dedup_people;
pub mod duplicates;
pub mod error;
pub mod face_shard;
@@ -49,6 +50,7 @@ pub mod faces;
pub mod jobs;
pub mod keywords;
pub mod merge;
pub mod name_dates;
pub mod query;
pub mod rating;
pub mod recovery;
+533 -49
View File
@@ -113,6 +113,12 @@ pub struct MergeReport {
pub faces_kept_local: usize,
/// "Not this person" judgements taken from the remote.
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
/// [`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.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
/// the box, so a remote face is matched to the local face on the same
/// photograph whose box overlaps it most, above a floor of 0.5 IoU.
/// no uuid to fall back on. What both devices *do* agree on is `oc:fileid`,
/// the box, and the embedding, so a remote face is matched to the local face
/// 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
/// [`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
/// 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> {
// A remote written before faces existed has none of these tables, and one
// 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 ----------------------
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() {
return Ok(());
}
@@ -1122,6 +1137,8 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
probability = excluded.probability,
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 {
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;
}
// 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
// already holds dirtied a page per face, every pass, for nothing;
// 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])?)
}
/// 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
/// 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
/// 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.
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
/// rectangle". The figure `record_detections` uses for the same job.
const MIN_IOU: f32 = 0.5;
type Boxed = (i64, f32, f32, f32, f32);
type Key = (i64, String);
ensure_face_box_index(tx);
// Local faces, grouped by the photograph's cross-device id.
let mut local: std::collections::HashMap<(i64, String), Vec<Boxed>> =
std::collections::HashMap::new();
{
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 read_boxes = |sql: &str| -> Result<HashMap<Key, Vec<Boxed>>, CatalogError> {
let mut out: HashMap<Key, Vec<Boxed>> = HashMap::new();
let mut stmt = tx.prepare(sql)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(1)?,
@@ -1270,50 +1373,187 @@ fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, C
for row in rows {
let (file_id, model, boxed) = row?;
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 mut stmt = tx.prepare(
let remote = read_boxes(
"SELECT f.id, r.file_id, f.model_id, f.x, f.y, f.w, f.h
FROM remote_cat.faces f
JOIN remote_cat.remote r ON r.image_id = f.image_id
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 {
let (remote_id, file_id, model, rbox) = row?;
let embedder = crate::faces::embedder_of(&model).to_string();
let Some(candidates) = local.get(&(file_id, embedder)) else {
// ---- by box -----------------------------------------------------------
// Per group, which local face (by index) each remote face took.
let mut left_over: Vec<(&Key, Vec<Option<usize>>)> = Vec::new();
for (key, theirs) in &remote {
let Some(ours) = local.get(key) else {
continue;
};
let best = candidates
let overlaps: Vec<Vec<bool>> = theirs
.iter()
.map(|&(id, x, y, w, h)| (id, iou(rbox, (x, y, w, h))))
.filter(|&(_, score)| score >= MIN_IOU)
.max_by(|a, b| a.1.total_cmp(&b.1));
if let Some((local_id, _)) = best {
map.insert(remote_id, local_id);
.map(|&(_, x, y, w, h)| {
ours.iter()
.map(|&(_, lx, ly, lw, lh)| iou((x, y, w, h), (lx, ly, lw, lh)) >= MIN_IOU)
.collect()
})
.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
@@ -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
/// for building it, once. A failure is logged and the merge goes on reading
/// 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(
"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.
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 y0 = a.1.max(b.1);
let x1 = (a.0 + a.2).min(b.0 + b.2);
@@ -2541,4 +2781,248 @@ mod tests {
.unwrap();
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);
}
}
+387
View File
@@ -0,0 +1,387 @@
//! TRACES: FR-CAT-5
//! A capture time read from the file's name, for an image whose header has
//! none.
//!
//! # Why
//!
//! A photograph with no EXIF date sorts after everything else, so it is lost
//! at the end of the grid and absent from the timeline. The files that end up
//! there are rarely without a date — they are without *EXIF*: WhatsApp strips
//! every tag and names the file `WhatsApp Image 2023-06-15 at 07.00.42.jpeg`,
//! a Windows Phone wrote `WP_20140922_14_16_27_Pro.jpg`, a phone camera
//! `IMG_20190812_153012.jpg`, and darktable's import renames to
//! `20230629_0001.jpeg`. On the reference library 250 of 274 undated images
//! carried their date in the name or in the folder above it.
//!
//! # What is accepted
//!
//! A date is `YYYYMMDD` as a whole run of digits, or `YYYY`, `MM` and `DD`
//! joined by `-`, `_` or `.`. A time may follow it — `HHMMSS` as one run (or
//! nine digits, milliseconds appended), or three two-digit runs joined by
//! `-`, `_`, `.` or `:` — after `_`, `-`, `.`, `T`, a space or ` at `.
//! Anything else after the date leaves it at midnight: `_0059` in
//! `20230628_0059` is a sequence number, not 00:59, and reading it as a time
//! would invent one.
//!
//! The name is tried first and then each folder above it, innermost first —
//! `2016/2016-11-11/IMG_7910.jpg` is dated by its folder. A bare year folder
//! is not a date: putting a photograph at 1 January is a wrong answer, and an
//! undated one at least says it does not know.
//!
//! The reading is wall-clock time with no zone, stored as EXIF's is
//! (`dr_decode::parse_exif_datetime`), and EXIF always wins: this only fills
//! rows whose `captured_at` is still empty.
use rusqlite::Connection;
use crate::CatalogError;
/// The capture time a path's name states, as wall-clock Unix seconds.
pub fn date_from_path(source_ref: &str) -> Option<i64> {
let mut parts = source_ref.rsplit(['/', '\\']);
let name = parts.next()?;
let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
date_in(stem).or_else(|| parts.find_map(date_in))
}
/// Date every examined, undated image whose name states one.
///
/// `only` limits the pass to the images just examined — what the sweep hands
/// in — and `None` visits every undated image, which is the backfill's case.
/// Both read the undated side alone (`images_captured` answers
/// `captured_at IS NULL` with a seek), never the library.
///
/// Returns how many images were dated.
pub fn fill(conn: &Connection, only: Option<&[i64]>) -> Result<usize, CatalogError> {
let rows: Vec<(i64, String)> = match only {
None => {
let mut stmt = conn.prepare(
"SELECT id, source_ref FROM images
WHERE captured_at IS NULL AND metadata_state >= 2",
)?;
let rows = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<Result<_, _>>()?;
rows
}
Some(ids) => {
let mut stmt = conn.prepare_cached(
"SELECT source_ref FROM images
WHERE id = ?1 AND captured_at IS NULL AND metadata_state >= 2",
)?;
let mut rows = Vec::new();
for &id in ids {
let mut q = stmt.query([id])?;
if let Some(r) = q.next()? {
rows.push((id, r.get(0)?));
}
}
rows
}
};
let dated: Vec<(i64, i64)> = rows
.iter()
.filter_map(|(id, path)| date_from_path(path).map(|at| (*id, at)))
.collect();
if dated.is_empty() {
return Ok(0);
}
// A savepoint rather than a transaction, so a caller already inside one
// can still call this: the backfill's 250 rows are one commit, not 250.
conn.execute_batch("SAVEPOINT name_dates")?;
let written = (|| {
let mut stmt = conn.prepare_cached(
"UPDATE images SET captured_at = ?2 WHERE id = ?1 AND captured_at IS NULL",
)?;
let mut n = 0;
for (id, at) in &dated {
n += stmt.execute(rusqlite::params![id, at])?;
}
Ok::<_, CatalogError>(n)
})();
match written {
Ok(n) => {
conn.execute_batch("RELEASE name_dates")?;
Ok(n)
}
Err(e) => {
let _ = conn.execute_batch("ROLLBACK TO name_dates; RELEASE name_dates");
Err(e)
}
}
}
/// The first date, with its time if one follows, in one name component.
fn date_in(s: &str) -> Option<i64> {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() {
// Only at the start of a run of digits: a date inside a longer number
// is a coincidence, not a date.
if b[i].is_ascii_digit() && (i == 0 || !b[i - 1].is_ascii_digit()) {
if let Some(at) = date_at(b, i) {
return Some(at);
}
}
i += 1;
}
None
}
/// A date starting at `i`, and the time after it if there is one.
fn date_at(b: &[u8], i: usize) -> Option<i64> {
let run = digits(b, i);
let ((y, mo, d), after) = match run.len() {
// YYYYMMDD, or YYYYMMDDHHMMSS written as one number.
8 | 14 => ((num(&run[..4]), num(&run[4..6]), num(&run[6..8])), i + 8),
4 => {
let sep = |at: usize| matches!(b.get(at), Some(b'-' | b'_' | b'.'));
let mo_at = i + 4 + 1;
let d_at = mo_at + 2 + 1;
if !(sep(i + 4) && digits(b, mo_at).len() == 2 && sep(mo_at + 2))
|| digits(b, d_at).len() != 2
{
return None;
}
(
(num(run), num(&b[mo_at..mo_at + 2]), num(&b[d_at..d_at + 2])),
d_at + 2,
)
}
_ => return None,
};
let day = civil_days(y, mo, d)?;
let time = if run.len() == 14 {
hms(num(&run[8..10]), num(&run[10..12]), num(&run[12..14]))
} else {
time_at(b, after)
};
Some(day * 86_400 + time.unwrap_or(0))
}
/// The time following a date that ends at `i`, as seconds into the day.
fn time_at(b: &[u8], i: usize) -> Option<i64> {
let rest = &b[i..];
let start = if rest.starts_with(b" at ") {
i + 4
} else if matches!(rest.first(), Some(b'_' | b'-' | b'.' | b'T' | b' ')) {
i + 1
} else {
return None;
};
let run = digits(b, start);
match run.len() {
// HHMMSS, or with milliseconds appended (Pixel's PXL_…_123456789).
6 | 9 => hms(num(&run[..2]), num(&run[2..4]), num(&run[4..6])),
2 => {
let sep = |at: usize| matches!(b.get(at), Some(b'-' | b'_' | b'.' | b':'));
let (m_at, s_at) = (start + 3, start + 6);
if !(sep(start + 2) && digits(b, m_at).len() == 2 && sep(m_at + 2))
|| digits(b, s_at).len() != 2
{
return None;
}
hms(num(run), num(&b[m_at..m_at + 2]), num(&b[s_at..s_at + 2]))
}
_ => None,
}
}
/// The run of ASCII digits starting at `i`.
fn digits(b: &[u8], i: usize) -> &[u8] {
let rest = b.get(i..).unwrap_or(&[]);
let n = rest.iter().take_while(|c| c.is_ascii_digit()).count();
&rest[..n]
}
fn num(d: &[u8]) -> i64 {
d.iter().fold(0, |n, c| n * 10 + i64::from(c - b'0'))
}
fn hms(h: i64, m: i64, s: i64) -> Option<i64> {
((0..24).contains(&h) && (0..60).contains(&m) && (0..61).contains(&s))
.then_some(h * 3_600 + m * 60 + s)
}
/// Days since 1970-01-01 for a valid civil date, `None` for anything else.
///
/// The year range is EXIF's (`parse_exif_datetime`): wide enough for scanned
/// film, narrow enough that a counter such as `12345678` is not a date.
fn civil_days(y: i64, mo: i64, d: i64) -> Option<i64> {
let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
let month_len = match mo {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => return None,
};
if !(1900..=2200).contains(&y) || !(1..=month_len).contains(&d) {
return None;
}
let y_adj = if mo <= 2 { y - 1 } else { y };
let era = y_adj.div_euclid(400);
let yoe = y_adj - era * 400;
let mp = (mo + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
Some(era * 146_097 + doe - 719_468)
}
#[cfg(test)]
mod tests {
use super::*;
/// Wall-clock seconds for a date and time, the expected side of each case.
fn at(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64) -> Option<i64> {
Some(civil_days(y, mo, d).unwrap() * 86_400 + h * 3_600 + mi * 60 + s)
}
#[test]
fn the_names_in_the_reference_library_are_read() {
// Every shape here is a file that sat undated at the end of the grid.
for (path, want) in [
(
"PhotosRaw/alps trip/alps whatsapp/WhatsApp Image 2023-06-15 at 07.00.42.jpeg",
at(2023, 6, 15, 7, 0, 42),
),
(
"PhotosRaw/alps trip/alps whatsapp/WhatsApp Image 2023-06-17 at 12.45.52 (1).jpeg",
at(2023, 6, 17, 12, 45, 52),
),
(
"PhotosRaw/WP_20140922_14_16_27_Pro.jpg",
at(2014, 9, 22, 14, 16, 27),
),
// A sequence number after the date is not a time.
(
"PhotosRaw/Darktable/20230629_no_name/20230629_0001.jpeg",
at(2023, 6, 29, 0, 0, 0),
),
("PhotosRaw/20230628_0059.jpg", at(2023, 6, 28, 0, 0, 0)),
(
"PhotosRaw/backdrops/IMG_20130625_0021.jpg",
at(2013, 6, 25, 0, 0, 0),
),
(
"PhotosRaw/alps trip/20230628_0641 - 20230628_0661.jpg",
at(2023, 6, 28, 0, 0, 0),
),
] {
assert_eq!(date_from_path(path), want, "{path}");
}
}
#[test]
fn common_camera_and_app_names_are_read() {
for (path, want) in [
("IMG_20190812_153012.jpg", at(2019, 8, 12, 15, 30, 12)),
("PXL_20210101_123456789.jpg", at(2021, 1, 1, 12, 34, 56)),
(
"Screenshot_2021-03-04-12-30-45.png",
at(2021, 3, 4, 12, 30, 45),
),
(
"Screenshot from 2021-03-04 12-30-45.png",
at(2021, 3, 4, 12, 30, 45),
),
("IMG-20210304-WA0001.jpg", at(2021, 3, 4, 0, 0, 0)),
("20210304143012.jpg", at(2021, 3, 4, 14, 30, 12)),
("2019.12.25 party.jpg", at(2019, 12, 25, 0, 0, 0)),
("signal-2022-01-02-101112.jpg", at(2022, 1, 2, 10, 11, 12)),
("2022-01-02T10:11:12.jpg", at(2022, 1, 2, 10, 11, 12)),
] {
assert_eq!(date_from_path(path), want, "{path}");
}
}
#[test]
fn a_folder_dates_a_name_that_does_not() {
assert_eq!(
date_from_path("PhotosRaw/2016/2016-11-11/IMG_7910.jpg"),
at(2016, 11, 11, 0, 0, 0)
);
// The innermost folder that states a date wins.
assert_eq!(
date_from_path("2016-01-01 trip/2016-01-03/_MG_1.jpg"),
at(2016, 1, 3, 0, 0, 0)
);
// The name beats its folder.
assert_eq!(
date_from_path("2016-11-11/IMG_20161112_080000.jpg"),
at(2016, 11, 12, 8, 0, 0)
);
}
#[test]
fn numbers_that_are_not_dates_are_left_alone() {
for path in [
"PhotosRaw/_MG_9002.jpg",
"PhotosRaw/scanning/fau_2.jpg",
// A year folder is not a day.
"PhotosRaw/2016/_MG_1.jpg",
"IMG_1999.jpg",
"DSC_12345678.jpg", // month 56
"20230230_0001.jpg", // 30 February
"120230615.jpg", // the date is inside a longer number
"1612345678901.jpg", // a millisecond epoch, not a civil date
"2023-6-15.jpg", // a one-digit month is too loose to trust
] {
assert_eq!(date_from_path(path), None, "{path}");
}
}
#[test]
fn a_time_that_cannot_be_is_dropped_and_the_date_kept() {
assert_eq!(
date_from_path("20230615_256199.jpg"),
at(2023, 6, 15, 0, 0, 0)
);
}
#[test]
fn fill_dates_only_examined_undated_rows_and_never_overrides_exif() {
let c = Connection::open_in_memory().unwrap();
crate::schema::migrate(&c).unwrap();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.unwrap();
// (id, name, captured_at, metadata_state)
for (id, name, captured, state) in [
(1i64, "IMG_20190812_153012.jpg", None, 2i64),
// EXIF already answered; the name disagrees and loses.
(2, "IMG_20190812_153012b.jpg", Some(42i64), 2),
// Not yet examined: EXIF may still come, so the name waits.
(3, "IMG_20190813_000000.jpg", None, 1),
(4, "_MG_9002.jpg", None, 2),
] {
c.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, metadata_state, added_at)
VALUES (?1, 1, ?2, ?3, ?4, 0)",
rusqlite::params![id, name, captured, state],
)
.unwrap();
}
let captured = |id: i64| -> Option<i64> {
c.query_row("SELECT captured_at FROM images WHERE id = ?1", [id], |r| {
r.get(0)
})
.unwrap()
};
assert_eq!(fill(&c, Some(&[2, 3, 4])).unwrap(), 0);
assert_eq!(fill(&c, None).unwrap(), 1);
assert_eq!(captured(1), at(2019, 8, 12, 15, 30, 12));
assert_eq!(captured(2), Some(42));
assert_eq!(captured(3), None);
assert_eq!(captured(4), None);
// Nothing left to do is a no-op, not a rewrite.
assert_eq!(fill(&c, None).unwrap(), 0);
}
}
+9
View File
@@ -356,6 +356,15 @@ pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, Catalog
out.push(("keyword_terms", n));
}
// TRACES: FR-CAT-5
// A date from the file's name for every examined image EXIF left undated.
// The sweep does this as it examines each image; this is for the images
// examined by a build that did not, and reads the undated side alone.
let n = crate::name_dates::fill(conn, None)?;
if n > 0 {
out.push(("dates_from_names", n));
}
Ok(out)
}
+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}");
}
// 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
}
+11
View File
@@ -798,6 +798,17 @@ mod tests {
let tmp = target.with_extension("tmp");
fs::write(&tmp, bytes).expect("write");
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
}
-160
View File
@@ -1,160 +0,0 @@
# DarkRoom camera base curves (FR-DEV-3e).
#
# ---------------------------------------------------------------------------
# Adding a body is editing this file. It is not a code change.
# ---------------------------------------------------------------------------
#
# The copy you are reading is compiled into the binary as a floor. At startup
# `dr_decode::base_curve::load` also looks for `base_curves.yaml` in:
#
# 1. $DARKROOM_PROFILES/ (set it while you are tuning)
# 2. $XDG_DATA_HOME/darkroom/profiles/
# or $HOME/.local/share/darkroom/profiles/
#
# and uses the first one it finds *whose `version:` is higher than this one's*.
# So: bump `version`, drop the file in that directory, restart. A body added
# this afternoon renders correctly this afternoon, with no release and no
# rebuild — which is what the requirement asks for, and what makes these
# contributable under the GPL.
#
# The version check runs both ways on purpose. A file older than the built-in
# copy is ignored with a log line, so upgrading DarkRoom cannot silently lose
# curves to a pack somebody downloaded a year ago.
#
# ---------------------------------------------------------------------------
# What the numbers mean
# ---------------------------------------------------------------------------
#
# Five `[x, y]` control points on a monotone spline (Fritsch-Carlson, the same
# one the tone curve widget draws). Both axes are **linear**:
#
# x scene-referred camera RGB after white balance, 1.0 = sensor saturation
# y display-referred linear; the sRGB transfer function is applied later,
# at the end of the shader, so do not pre-apply a gamma here
#
# The identity is y = x, and it is what an unrecognised body gets if `default:`
# is removed. It is also the wrong answer for almost every photograph: linear
# scene data has middle grey at about 13% and a camera JPEG puts it near 18%,
# so an uncurved render is roughly half a stop dark through the midtones and
# has no highlight rolloff at all.
#
# A curve that works has three parts, and it is worth naming them because they
# are what you are actually tuning:
#
# the toe the first span, slope near or below 1. Deep shadows stay
# deep. Lift it and blacks go milky; crush it and shadow
# detail the sensor recorded disappears.
# the midtones the middle spans, slope well above 1. This is the contrast
# and the brightness people read as "the camera's look".
# the shoulder the last span, slope well below 1. Highlights compress
# toward white instead of arriving there and clipping. It is
# the difference between a rolled-off sky and a white hole.
#
# Two invariants are enforced in code and tested, so a mistake here fails the
# build rather than the photograph: x must strictly increase, y must not
# decrease, and everything must lie inside the unit square.
#
# ---------------------------------------------------------------------------
# Honesty about these values
# ---------------------------------------------------------------------------
#
# These are hand-tuned shapes, not measurements. They encode what every camera
# JPEG rendering has in common — the toe/midtone/shoulder structure above —
# plus each maker's well-known house differences: Canon's gentler shoulder and
# warmer-reading midtones, Nikon's slightly higher midtone contrast, Sony's
# flatter and more conservative default, Fujifilm's markedly contrastier
# Provia-derived rendering.
#
# FR-DEV-3e's acceptance criterion is subjective comparison against each body's
# own JPEG, and meeting it properly needs a frame from that body in front of
# you. Where that has not been done, the entry is still much closer to right
# than the identity — which is the bar these have to clear, and do.
version: 1
# The rendering for a body with no entry of its own.
#
# **Deliberately not the identity.** The failure this requirement exists to fix
# is the flat render, and a conservative curve is far closer to right for every
# body than no curve is for any of them. It is gentler than the per-body
# entries below — a shallower midtone and an earlier, softer shoulder — because
# it has to be safe on a sensor nobody has looked at, and the cost of being too
# tame is a photograph that wants a little contrast rather than one that has
# lost its highlights.
default:
points:
- [0.00, 0.000]
- [0.04, 0.043]
- [0.13, 0.175]
- [0.45, 0.690]
- [1.00, 1.000]
bodies:
# Canon. A soft toe and a long, gradual shoulder — the reason Canon files
# are described as forgiving in highlights and a little low in contrast
# straight out of camera.
- make: Canon
model: EOS 6D
points:
- [0.00, 0.000]
- [0.04, 0.045]
- [0.13, 0.190]
- [0.45, 0.720]
- [1.00, 1.000]
- make: Canon
model: EOS R6
points:
- [0.00, 0.000]
- [0.04, 0.044]
- [0.13, 0.195]
- [0.45, 0.730]
- [1.00, 1.000]
# Nikon. A slightly deeper toe and more midtone slope than Canon, which is
# the "punchier out of camera" difference people describe between the two.
- make: Nikon
model: Z 6
points:
- [0.00, 0.000]
- [0.04, 0.038]
- [0.13, 0.200]
- [0.46, 0.750]
- [1.00, 1.000]
- make: Nikon
model: D750
points:
- [0.00, 0.000]
- [0.04, 0.039]
- [0.13, 0.198]
- [0.46, 0.745]
- [1.00, 1.000]
# Sony. The flattest default of the four, and intentionally so — Sony's own
# rendering leaves more headroom than it uses, which is why Sony files are
# the ones people describe as needing the most work.
- make: Sony
model: ILCE-7M3
points:
- [0.00, 0.000]
- [0.04, 0.048]
- [0.13, 0.185]
- [0.44, 0.700]
- [1.00, 1.000]
# Fujifilm. Provia, the default film simulation: a firm toe, the steepest
# midtones here, and a hard shoulder. It is the most distinctive rendering of
# the four and the one where a flat render looks most obviously wrong.
#
# This entry does *not* read the in-RAF film simulation tag — that is
# FR-DEV-3f, and until it lands every Fujifilm file gets the Provia shape
# whatever the camera was set to.
- make: Fujifilm
model: X-T3
points:
- [0.00, 0.000]
- [0.045, 0.040]
- [0.14, 0.215]
- [0.47, 0.775]
- [1.00, 1.000]
-752
View File
@@ -1,752 +0,0 @@
//! TRACES: FR-DEV-3e
//! Base curves — the per-body rendering that turns a correct exposure into a
//! photograph.
//!
//! # What this is for
//!
//! A camera matrix gets the *colours* right and leaves the picture flat. Sensor
//! data is scene-referred and very nearly linear; a print, a screen and a
//! camera's own JPEG are none of those things. Rendering linear data straight
//! out is the dcraw default, and FR-DEV-3e names it precisely: "the flat,
//! poor-skin-tone rendering characteristic of dcraw defaults, which is the
//! documented reason people abandon darktable in the first hour."
//!
//! The fix is a tone curve applied as part of *reading* the file rather than as
//! an edit — a toe, a steep midtone, and a shoulder that rolls highlights off
//! instead of clipping them. Every raw converter has one. Adobe calls it the
//! camera profile's tone curve, darktable calls it the base curve, and the name
//! here follows darktable's because the placement does too: it runs in camera
//! RGB, after white balance and the user's adjustments, immediately before the
//! conversion out to a working space.
//!
//! # Why it is not an edit
//!
//! It never reaches the sidecar and there is no slider for it, for the same
//! reason the EXIF orientation is not an edit (FR-DEV-3h): it is a property of
//! the body that took the frame, not of what anyone decided about the frame.
//! Sidecars are shared between devices and bodies (FR-NC-9), and one camera's
//! rendering must not follow an edit onto another camera's file.
//!
//! # Why it is data
//!
//! FR-DEV-3e requires the profile database to be "versioned independently of
//! the app binary so bodies and curves can be added without a release — and,
//! under D8's GPLv3, contributed by users". So the curves live in
//! `profiles/base_curves.yaml`, a file that is compiled in as a floor and
//! *overridden* by a copy on disk carrying a higher `version:`. Adding a body
//! is adding ten numbers to a YAML file; shipping that body to users is
//! publishing the file. Neither is a code change and neither needs a release.
//!
//! See [`load`] for the search path and [`Curves::body`] for the matching.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// How many control points a base curve has.
///
/// Five, which is not a coincidence: it is what the tone curve widget uses
/// (`dr_pipeline::ops::curve::POINTS`), so the shader evaluates a profile's
/// curve and a photographer's curve through exactly the same spline. A profile
/// author and a photographer dragging a point mean the same thing by it, and
/// the generated shader carries one implementation rather than two that could
/// disagree.
pub const POINTS: usize = 5;
/// TRACES: FR-DEV-3e
/// A base curve: five points on a monotone spline through the unit square.
///
/// `xs` is scene-linear camera RGB, normalised so that 1.0 is the sensor's
/// saturation point. `ys` is display-referred linear — *not* gamma-encoded,
/// because the sRGB transfer function is applied at the very end of the
/// generated shader and applying it twice would wash the image out.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BaseCurve {
pub xs: [f32; POINTS],
pub ys: [f32; POINTS],
}
impl BaseCurve {
/// The curve that does nothing — the identity diagonal.
///
/// What an unrecognised body gets if the database carries no default, and
/// what a JPEG gets always: an already-rendered image must not be rendered
/// a second time.
pub const IDENTITY: Self = Self {
xs: [0.0, 0.25, 0.5, 0.75, 1.0],
ys: [0.0, 0.25, 0.5, 0.75, 1.0],
};
/// Whether this curve would leave the image alone.
///
/// The shader is told to skip the stage entirely when it would, so an
/// unprofiled body costs a branch that is uniform across the dispatch
/// rather than a spline evaluation per channel per pixel.
pub fn is_identity(&self) -> bool {
self.xs
.iter()
.zip(self.ys.iter())
.all(|(x, y)| (x - y).abs() < 1e-6)
}
/// Build from raw pairs, rejecting anything that is not a curve.
///
/// A profile file is data a user may have edited, so this is the boundary
/// where "ten numbers" becomes "a curve": the x coordinates must increase,
/// the y coordinates must not decrease, and both must lie in the unit
/// square. A non-monotone x sends the spline's span search backwards and
/// divides by a negative width; a decreasing y inverts tones locally,
/// which reads as a dark halo through smooth gradients rather than as a
/// bad profile.
///
/// Endpoints are not forced to (0,0) and (1,1). A curve that lifts black
/// slightly, or that places the shoulder below white, is a legitimate
/// rendering choice and several bodies make it.
pub fn from_points(points: &[[f32; 2]]) -> Option<Self> {
if points.len() != POINTS {
return None;
}
let mut xs = [0.0f32; POINTS];
let mut ys = [0.0f32; POINTS];
for (i, p) in points.iter().enumerate() {
if !p[0].is_finite() || !p[1].is_finite() {
return None;
}
if !(0.0..=1.0).contains(&p[0]) || !(0.0..=1.0).contains(&p[1]) {
return None;
}
xs[i] = p[0];
ys[i] = p[1];
}
for i in 1..POINTS {
// Strictly increasing in x — the spline divides by the span width.
if xs[i] <= xs[i - 1] {
return None;
}
// Non-decreasing in y. Flat is allowed: a curve that holds a
// highlight range at white is clipping deliberately.
if ys[i] < ys[i - 1] {
return None;
}
}
Some(Self { xs, ys })
}
}
/// One body's entry in the database.
#[derive(Debug, Clone, PartialEq)]
pub struct BodyCurve {
/// The manufacturer, as the file writes it — "Canon", "NIKON CORPORATION".
pub make: String,
/// The model, as the file writes it — "EOS 6D", "ILCE-7M3".
pub model: String,
pub curve: BaseCurve,
}
/// TRACES: FR-DEV-3e
/// The base curve database.
///
/// Versioned as a whole rather than per body, because that is the unit a user
/// downloads and the unit that has to beat the built-in copy. See [`load`].
#[derive(Debug, Clone, PartialEq)]
pub struct Curves {
version: u32,
default: Option<BaseCurve>,
bodies: Vec<BodyCurve>,
}
impl Curves {
/// TRACES: FR-DEV-3e
/// The curve to render a frame from this body with.
///
/// Falls back, in order, to the database's `default:` and then to the
/// identity. **The default is deliberately not the identity**: an
/// unrecognised body rendered flat is the failure this requirement exists
/// to prevent, and a gentle, conservative curve is much closer to right for
/// every body than no curve is for any of them. A body with its own entry
/// gets that instead.
///
/// # What "this body" has to survive
///
/// The same camera names itself three ways depending on which program last
/// touched the file. A native NEF says make "NIKON CORPORATION", model
/// "NIKON Z 6"; rawler's own database cleans that to "Nikon" and "Z 6"; an
/// Adobe-converted DNG keeps the uncleaned pair. A database that had to
/// spell every variant would go stale the first time a maker changed its
/// mind about its own name, so the matching does the folding instead:
///
/// - Case, punctuation and runs of whitespace are flattened, so
/// "ILCE-7M3", "ILCE 7M3" and "ilce-7m3" are one body.
/// - The make is compared on its **first word only**. Every maker's
/// trailing corporate boilerplate — "CORPORATION", "IMAGING CORP" — is
/// noise, and no two camera manufacturers share a first word.
/// - The model is tried both as written and with a leading copy of the
/// make removed, which is what lets one "Canon"/"EOS 6D" entry cover
/// "Canon EOS 6D" as well.
pub fn body(&self, make: &str, model: &str) -> BaseCurve {
let (make, model) = (make_key(make), normalise(model));
// The model with a leading copy of the maker's name removed.
let bare = model.strip_prefix(&format!("{make} ")).unwrap_or(&model);
self.bodies
.iter()
.find(|b| {
let entry_model = normalise(&b.model);
make_key(&b.make) == make && (entry_model == model || entry_model == bare)
})
.map(|b| b.curve)
.or(self.default)
.unwrap_or(BaseCurve::IDENTITY)
}
/// The database version. Higher wins; see [`load`].
pub fn version(&self) -> u32 {
self.version
}
/// How many bodies have their own curve, excluding the default.
pub fn len(&self) -> usize {
self.bodies.len()
}
pub fn is_empty(&self) -> bool {
self.bodies.is_empty()
}
/// Parse a database from YAML.
///
/// Entries that are not curves are dropped with a warning rather than
/// failing the parse. A user-contributed file with one bad body should
/// cost that body's rendering, not every body's — and the alternative is an
/// application that will not open a photograph because somebody typed a
/// comma.
pub fn parse(yaml: &str) -> Result<Self, String> {
let file: File = serde_norway::from_str(yaml).map_err(|e| e.to_string())?;
let default = file.default.and_then(|d| {
BaseCurve::from_points(&d.points).or_else(|| {
log::warn!("base curves: the default entry is not a monotone curve; ignoring it");
None
})
});
let bodies = file
.bodies
.into_iter()
.filter_map(|b| match BaseCurve::from_points(&b.points) {
Some(curve) => Some(BodyCurve {
make: b.make,
model: b.model,
curve,
}),
None => {
log::warn!(
"base curves: {} {} is not a monotone curve; ignoring it",
b.make,
b.model
);
None
}
})
.collect();
Ok(Self {
version: file.version,
default,
bodies,
})
}
}
/// The copy that ships inside the binary.
///
/// A floor, not the answer: [`load`] prefers a newer file on disk. Compiled in
/// so that a fresh install with no profile directory — and every Android build,
/// where there is no such directory to speak of — still renders properly.
const BUILT_IN: &str = include_str!("../profiles/base_curves.yaml");
/// TRACES: FR-DEV-3e
/// The base curve database, loaded once.
///
/// # The search path, and why it is a version comparison
///
/// 1. `$DARKROOM_PROFILES`, a directory, when set. The escape hatch: a profile
/// author iterating on a curve points this at their working copy and does
/// not have to install anything.
/// 2. `$XDG_DATA_HOME/darkroom/profiles/`, else `$HOME/.local/share/darkroom/profiles/`.
/// The same base directory the catalog uses, chosen there for the same
/// reason — it is data, not cache, and must survive a storage sweep.
/// 3. The copy compiled into the binary.
///
/// The first file that parses *and carries a higher `version:` than the
/// built-in copy* wins. The version check is the whole mechanism the
/// requirement asks for, and it runs in both directions:
///
/// - A downloaded pack at version 7 supersedes a binary shipping version 3, so
/// a body added after the release renders correctly with no release.
/// - A stale pack at version 2 does **not** supersede a binary shipping version
/// 3, so upgrading the application cannot silently lose curves to a file
/// somebody downloaded a year ago and forgot.
///
/// Failures are warnings, never errors. A malformed profile file must cost the
/// user their curves, not their photographs.
pub fn load() -> &'static Curves {
static LOADED: OnceLock<Curves> = OnceLock::new();
LOADED.get_or_init(|| {
let built_in = Curves::parse(BUILT_IN).unwrap_or_else(|e| {
// Unreachable in a build that ran its tests — `the_shipped_database_parses`
// asserts exactly this — but a panic here would mean an
// application that cannot open a photograph because of a typo in a
// data file, which is never the right trade.
log::error!("base curves: the built-in database does not parse: {e}");
Curves {
version: 0,
default: None,
bodies: Vec::new(),
}
});
choose(built_in, &search_path())
})
}
/// The version comparison, separated from where the directories come from.
///
/// Split out so it can be tested against real files in a real directory
/// without the process-wide `OnceLock` and the environment `load` reads. The
/// rule this implements is the whole of what FR-DEV-3e asks for, so it is
/// worth being able to state it as a test rather than as a comment.
fn choose(built_in: Curves, dirs: &[PathBuf]) -> Curves {
for dir in dirs {
let path = dir.join("base_curves.yaml");
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
match Curves::parse(&text) {
Ok(external) if external.version > built_in.version => {
log::info!(
"base curves: using {} (version {}, {} bodies) over the built-in version {}",
path.display(),
external.version,
external.len(),
built_in.version
);
return external;
}
Ok(external) => log::info!(
"base curves: ignoring {} at version {}; the built-in database is version {}",
path.display(),
external.version,
built_in.version
),
Err(e) => log::warn!("base curves: {} does not parse: {e}", path.display()),
}
}
built_in
}
/// TRACES: FR-DEV-3e
/// The curve for a body, from the loaded database.
///
/// The one call site the decoder needs; everything above is reachable for
/// tests and for a future profile editor.
pub fn for_body(make: &str, model: &str) -> BaseCurve {
load().body(make, model)
}
/// Directories that may hold a `base_curves.yaml`, most specific first.
fn search_path() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(explicit) = std::env::var_os("DARKROOM_PROFILES") {
dirs.push(PathBuf::from(explicit));
}
// The same resolution `dr_ui::library::catalog_path` uses, and for the
// same reason: this is data a user may have installed, not a cache. It is
// duplicated rather than shared because `dr-decode` sits far below the UI
// and must not acquire a dependency on it to find a directory.
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".local/share")));
if let Some(base) = base {
dirs.push(base.join("darkroom").join("profiles"));
}
dirs
}
/// A manufacturer's first word, folded.
///
/// "NIKON CORPORATION", "Nikon" and "nikon" all become `NIKON`. The corporate
/// suffixes are not information — they appear or not depending on whether the
/// file went through a DNG converter — and no two camera manufacturers share a
/// first word, so nothing is lost by dropping them.
fn make_key(s: &str) -> String {
normalise(s)
.split(' ')
.next()
.unwrap_or_default()
.to_string()
}
/// Fold a make or model into something two files can agree on.
///
/// Upper-cased, with every run of non-alphanumeric characters collapsed to one
/// space and the ends trimmed, so that "ILCE-7M3", "ILCE 7M3" and "ilce-7m3"
/// become one.
fn normalise(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut pending_space = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
if pending_space && !out.is_empty() {
out.push(' ');
}
pending_space = false;
out.push(c.to_ascii_uppercase());
} else {
pending_space = true;
}
}
out
}
// ---- The on-disk shape, kept apart from the in-memory one ----------------
//
// Deliberately separate types. The file is data a user edits and is allowed to
// be wrong; `Curves` is a parsed database whose every entry is known to be a
// monotone curve. Deriving `Deserialize` on `BaseCurve` directly would delete
// that boundary and let an unchecked five-point array reach the shader.
//
// Unknown fields are **accepted**, which is not laziness. The database is
// versioned independently of the binary and moves in both directions: a pack
// published after this release may carry keys this build has never heard of —
// a hue twist, a look table (FR-DEV-3f) — and it must still deliver its curves
// to an older DarkRoom rather than failing to parse and leaving every body
// flat. `deny_unknown_fields` would trade that for a diagnostic nobody needs.
#[derive(serde::Deserialize)]
struct File {
version: u32,
#[serde(default)]
default: Option<Entry>,
#[serde(default)]
bodies: Vec<BodyEntry>,
}
#[derive(serde::Deserialize)]
struct Entry {
points: Vec<[f32; 2]>,
}
#[derive(serde::Deserialize)]
struct BodyEntry {
make: String,
model: String,
points: Vec<[f32; 2]>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shipped_database_parses_and_carries_a_default() {
// The one test that must never be allowed to fail quietly: `load`
// degrades to an empty database rather than panicking, so without this
// a typo in the YAML would ship as "every photograph renders flat"
// rather than as a build failure.
let curves = Curves::parse(BUILT_IN).expect("the shipped database parses");
assert!(curves.version() >= 1);
assert!(!curves.is_empty(), "the database ships bodies");
assert!(
!curves.body("Nobody", "Nothing").is_identity(),
"an unknown body must still get the default rendering"
);
}
#[test]
fn every_shipped_curve_lifts_the_midtones_and_rolls_the_highlights() {
// What makes a base curve a base curve rather than a decoration. If a
// shipped curve failed either half it would be a worse rendering than
// the flat one it replaced, which is the one outcome forbidden.
let curves = Curves::parse(BUILT_IN).expect("parses");
let all = curves
.bodies
.iter()
.map(|b| (format!("{} {}", b.make, b.model), b.curve))
.chain(curves.default.map(|c| ("default".to_string(), c)));
for (name, curve) in all {
// The midtone point sits above the diagonal: a linear midtone is
// roughly a stop and a half darker than any camera renders it.
let mid = 2;
assert!(
curve.ys[mid] > curve.xs[mid],
"{name} does not lift its midtones ({} -> {})",
curve.xs[mid],
curve.ys[mid]
);
// And the last span is shallower than the one before it, which is
// what a shoulder *is*. Without one the curve clips highlights
// harder than the linear rendering did.
let slope =
|i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]);
assert!(
slope(POINTS - 2) < slope(POINTS - 3),
"{name} has no highlight shoulder"
);
}
}
#[test]
fn a_curve_that_is_not_monotone_is_refused() {
// The profile file is user-editable, so this is a real boundary and
// not a formality. A decreasing y inverts tones locally and shows up
// as a dark halo in a gradient, which reads as a rendering fault
// rather than as a bad profile.
assert_eq!(
BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.4], [0.5, 0.3], [0.75, 0.8], [1.0, 1.0]]),
None
);
}
#[test]
fn a_curve_whose_x_does_not_advance_is_refused() {
// The spline divides by the span width; a repeated x is a division by
// zero in the shader, which is a NaN pixel rather than an error.
assert_eq!(
BaseCurve::from_points(&[
[0.0, 0.0],
[0.25, 0.3],
[0.25, 0.5],
[0.75, 0.8],
[1.0, 1.0]
]),
None
);
}
#[test]
fn a_curve_of_the_wrong_length_is_refused() {
assert_eq!(BaseCurve::from_points(&[[0.0, 0.0], [1.0, 1.0]]), None);
}
#[test]
fn values_outside_the_unit_square_are_refused() {
// The shader clamps its output at the very end anyway, but a control
// point above 1.0 would put the shoulder outside the range the curve
// is defined over and silently flatten everything below it.
assert_eq!(
BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.3], [0.5, 1.4], [0.75, 1.5], [1.0, 1.6]]),
None
);
}
#[test]
fn a_body_with_its_own_entry_beats_the_default() {
let curves = Curves::parse(
"version: 2
default:
points: [[0.0, 0.0], [0.25, 0.3], [0.5, 0.6], [0.75, 0.85], [1.0, 1.0]]
bodies:
- make: Canon
model: EOS 6D
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
assert_eq!(curves.body("Canon", "EOS 5D").ys[1], 0.30);
}
#[test]
fn the_make_may_be_repeated_in_the_model() {
// Canon writes "Canon" as the make and "Canon EOS 6D" as the model;
// rawler's cleaned strings drop the repetition and both reach here.
// One entry has to cover both or half the files on a card miss.
let curves = Curves::parse(
"version: 1
bodies:
- make: Canon
model: EOS 6D
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.body("Canon", "Canon EOS 6D").ys[1], 0.35);
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
assert_eq!(curves.body("CANON", "eos 6d").ys[1], 0.35);
}
#[test]
fn a_corporate_suffix_does_not_hide_a_body() {
// The same Z 6 arrives as "Nikon"/"Z 6" from rawler's camera database
// and as "NIKON CORPORATION"/"NIKON Z 6" from a DNG converted out of
// the same file. Both must find the entry, or converting a file to
// DNG would silently change how it renders.
let curves = Curves::parse(
"version: 1
bodies:
- make: Nikon
model: Z 6
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.body("Nikon", "Z 6").ys[1], 0.35);
assert_eq!(curves.body("NIKON CORPORATION", "NIKON Z 6").ys[1], 0.35);
}
#[test]
fn punctuation_and_spacing_do_not_decide_whether_a_body_is_known() {
let curves = Curves::parse(
"version: 1
bodies:
- make: Sony
model: ILCE-7M3
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.body("SONY", "ILCE 7M3").ys[1], 0.35);
assert_eq!(curves.body("sony", "ilce-7m3").ys[1], 0.35);
}
#[test]
fn one_bad_entry_does_not_cost_the_rest() {
// A user-contributed file with one typo should cost that body's
// rendering, not every body's.
let curves = Curves::parse(
"version: 1
bodies:
- make: Broken
model: Body
points: [[0.0, 0.0], [0.25, 0.9], [0.5, 0.1], [0.75, 0.9], [1.0, 1.0]]
- make: Canon
model: EOS 6D
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("parses");
assert_eq!(curves.len(), 1);
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
assert!(curves.body("Broken", "Body").is_identity());
}
#[test]
fn a_pack_from_the_future_still_delivers_its_curves() {
// The database is versioned independently of the binary, so a pack
// published after this build may carry keys this build has never heard
// of. It must still hand over the curves it does understand — failing
// the parse would leave every body flat, which is the exact failure
// FR-DEV-3e exists to prevent, delivered by the mechanism meant to
// prevent it.
let curves = Curves::parse(
"version: 9
look_table: ambitious
bodies:
- make: Canon
model: EOS 6D
hue_twist: [1, 2, 3]
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
",
)
.expect("an unfamiliar key must not fail the parse");
assert_eq!(curves.version(), 9);
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
}
#[test]
fn an_unknown_body_with_no_default_gets_the_identity() {
// Graceful fallback, stated as a property: never worse than a flat
// render, and never a curve tuned for somebody else's sensor when the
// database declines to offer one.
let curves = Curves::parse("version: 1\nbodies: []\n").expect("parses");
assert!(curves.body("Nobody", "Nothing").is_identity());
}
/// A directory holding one `base_curves.yaml`, unique to the caller.
fn a_pack_dir(name: &str, yaml: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("darkroom-base-curves-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a writable temp directory");
std::fs::write(dir.join("base_curves.yaml"), yaml).expect("write");
dir
}
const A_CANON_ENTRY: &str = "bodies:
- make: Canon
model: EOS 6D
points: [[0.0, 0.0], [0.25, 0.42], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
";
#[test]
fn a_newer_pack_on_disk_supersedes_the_built_in_database() {
// **This is the requirement.** FR-DEV-3e asks for a profile database
// versioned independently of the app binary "so bodies and curves can
// be added without a release". A file with a higher version, dropped
// in the profile directory, is what that means in practice.
let built_in = Curves::parse(BUILT_IN).expect("parses");
let newer = format!("version: {}\n{A_CANON_ENTRY}", built_in.version() + 1);
let dir = a_pack_dir("newer", &newer);
let chosen = choose(built_in.clone(), &[dir]);
assert_eq!(chosen.version(), built_in.version() + 1);
assert_eq!(chosen.body("Canon", "EOS 6D").ys[1], 0.42);
}
#[test]
fn a_stale_pack_does_not_survive_an_upgrade() {
// The other direction, and the one that protects the user. Somebody
// downloads a pack, a release later ships better curves for the same
// bodies, and the forgotten file must not quietly hold the application
// back at last year's rendering.
let built_in = Curves::parse(BUILT_IN).expect("parses");
let stale = format!("version: {}\n{A_CANON_ENTRY}", built_in.version());
let dir = a_pack_dir("stale", &stale);
let chosen = choose(built_in.clone(), &[dir]);
assert_eq!(chosen.version(), built_in.version());
assert_ne!(
chosen.body("Canon", "EOS 6D").ys[1],
0.42,
"an equal version must not displace the built-in database"
);
}
#[test]
fn a_broken_pack_costs_the_curves_and_not_the_photographs() {
// A malformed profile file must degrade to the built-in database, not
// to an error. The user came here to look at a photograph.
let built_in = Curves::parse(BUILT_IN).expect("parses");
let dir = a_pack_dir("broken", "version: [this is not a number\n");
let chosen = choose(built_in.clone(), &[dir]);
assert_eq!(chosen.version(), built_in.version());
assert_eq!(chosen.len(), built_in.len());
}
#[test]
fn a_directory_with_no_pack_in_it_is_simply_skipped() {
// The ordinary case on every machine: the search path exists, the file
// does not. It must not be a warning, an error, or a slow path.
let built_in = Curves::parse(BUILT_IN).expect("parses");
let missing = std::env::temp_dir().join("darkroom-base-curves-nothing-here");
let _ = std::fs::remove_dir_all(&missing);
assert_eq!(choose(built_in.clone(), &[missing]), built_in);
}
#[test]
fn the_identity_is_recognised_as_doing_nothing() {
assert!(BaseCurve::IDENTITY.is_identity());
assert!(!Curves::parse(BUILT_IN)
.expect("parses")
.body("Canon", "EOS 6D")
.is_identity());
}
}
-25
View File
@@ -16,14 +16,12 @@
//! second decoder can be put behind them without changing any of them
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
pub mod base_curve;
mod decoder;
mod error;
mod locate;
mod preview;
pub mod profile;
pub use base_curve::BaseCurve;
pub use decoder::{default, Decoder, Rawler};
pub use error::DecodeError;
pub use locate::{
@@ -125,19 +123,6 @@ pub struct RawImage {
/// for the light the frame was shot under; see [`profile::CameraProfile`].
pub color_matrix: Option<[f32; 9]>,
/// TRACES: FR-DEV-3e
/// The per-body rendering curve, the other half of the camera profile.
///
/// The matrix above decides what the colours *are*; this decides what the
/// picture looks like. Carried on the decoded image rather than looked up
/// downstream because this is the only point in the system that knows
/// which body took the frame, and because it is not an edit: it belongs to
/// the file in the same way the masked-photosite crop does, and must never
/// reach a sidecar (FR-NC-9).
///
/// [`BaseCurve::IDENTITY`] for an unknown body with no default in the
/// database, which renders exactly as this decoder did before profiles
/// existed.
pub base_curve: BaseCurve,
/// The usable region of `data`, excluding masked and border photosites.
pub crop: CropRect,
/// TRACES: FR-MRG-3
@@ -592,15 +577,6 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
profile.as_ref().map(|p| p.xyz_to_cam()).as_ref(),
);
// The rendering half of the profile (FR-DEV-3e). rawler's cleaned strings
// are preferred where it has them — they are what the shipped database is
// written against — and the matching folds the variants either way, so a
// DNG naming the same body differently still finds its curve.
let base_curve = base_curve::for_body(
image.camera.clean_make.as_str(),
image.camera.clean_model.as_str(),
);
// TRACES: FR-MRG-3
// A linear DNG — three samples per pixel, no colour filter array — is a
// composite this application wrote (or any other demosaiced DNG). It
@@ -683,7 +659,6 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
.unwrap_or(u16::MAX),
wb_coeffs,
color_matrix,
base_curve,
samples_per_pixel,
profile,
make: image.camera.clean_make.clone(),
+6 -4
View File
@@ -6,8 +6,10 @@
//! colour needs two things the file cannot supply on its own: a **matrix**
//! saying how this sensor's three responses relate to the CIE observer, and a
//! **rendering** saying what to do with the resulting scene-referred values so
//! that a photograph looks like a photograph. This module supplies the first
//! and looks up the second ([`crate::base_curve`]).
//! that a photograph looks like a photograph. This module supplies the first.
//! The second is not the body's: since D19 it is the pipeline's view
//! transform (FR-DEV-3j), one for every camera, and the per-body base curves
//! that used to be looked up here are retired.
//!
//! # What is extracted, and from where
//!
@@ -65,8 +67,8 @@
//! FR-DEV-3e defers full `.dcp` support — `HueSatDeltas` and
//! `ProfileLookTable` — and requires that they arrive as *additions* rather
//! than as a pipeline reordering. They would: both are lookups applied to a
//! colour after this matrix and before, or alongside, the base curve, so they
//! extend [`CameraProfile`] with more calibration data and extend the shader's
//! colour at this matrix, before any edit reaches it, so they extend
//! [`CameraProfile`] with more calibration data and extend the shader's
//! camera-profile stage with more work. Nothing above would move.
use crate::{cam_to_srgb_from, invert3};
+31 -9
View File
@@ -1,9 +1,8 @@
# Film stocks
One file per stock in [`profiles/`](profiles/). Adding a stock is adding a
file — no code change, no shader, no new operation — for the same reason
`dr-decode`'s base curves work that way: under the GPLv3 a stock should be
contributable without a release.
file — no code change, no shader, no new operation — because under the GPLv3
a stock should be contributable without a release.
## What a profile is
@@ -41,18 +40,41 @@ 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
approximation: the reconstructed scene spectrum is linear in the sRGB
triple, so the integral collapses into nine numbers.
2. **Three 1D curves**, log exposure to density, sampled at 256 points.
3. **One 32³ lookup**, density to linear sRGB — dye absorption, the print
through the negative, the paper, the viewing illuminant and the chromatic
adaptation, all of which take exactly three numbers in.
2. **Three 1D curves**, log exposure to density, sampled at 256 points — one
row per development time the datasheet measures. Push picks between the
rows, and interpolating them is exact, because density is linear in push
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.
Splitting 2 from 3, rather than baking one LUT over exposure, is measured
The stock is the last thing that happens to the picture. It runs in the view
transform's place (D19): handed linear sRGB, scene-referred, after every other
adjustment and after sharpening and noise reduction, and handing back the
rendering the output transform encodes. So every other slider decides the
exposure the negative receives, and the default tone mapping is not applied
on top.
Per pixel that is a matrix multiply, a handful of curve taps and one texture
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
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
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
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
//! 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
//! 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
//! 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
//! 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::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.
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> {
/// The stock the picture was taken on.
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
/// and inverted — that being what a negative actually looks like.
pub print: Option<&'a Profile>,
/// Camera exposure, in stops.
pub exposure_ev: f32,
/// 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.
/// The camera exposure the enlarger is balanced at, in stops. Ignored
/// without a `print`.
///
/// Ignored by a stock measured at one process, of which there are many —
/// see [`crate::profile::Profile::curves_at_push`], which returns the one
/// measured curve rather than inventing a pushed one.
pub push_stops: f32,
/// The *photograph's* exposure, never a region's. An enlarger has one
/// filtration for the whole print: a negative exposed a stop brighter in
/// one corner prints a stop darker there, and that difference is the
/// picture — balancing it away per pixel would erase every local exposure
/// change a layer made.
pub exposure_ev: f32,
}
impl<'a> Recipe<'a> {
@@ -76,12 +88,27 @@ impl<'a> Recipe<'a> {
film,
print,
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.
///
/// 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.
#[derive(Debug, Clone)]
pub struct Baked {
/// Linear sRGB to the three layers' log₁₀ exposure, before the log — row
/// `l`, column `c` is layer `l`'s response to sRGB channel `c`.
/// Linear sRGB to the three layers' exposure, before the log — row `l`,
/// 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],
/// The characteristic curves, `CURVE_SAMPLES` samples per layer, uniform
/// over `[curve_log_min, curve_log_max]`.
/// The characteristic curves: `curve_rows` rows of `CURVE_SAMPLES`
/// 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 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_max: f32,
/// Density to linear sRGB, `LUT_SIZE³` entries uniform over
/// `[0, density_max]` on each axis.
/// Film density to what comes next, `LUT_SIZE³` entries uniform over
/// `[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,
/// `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
/// photograph of the wrong colour.
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 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 {
/// 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] {
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];
for (l, slot) in log_exposure.iter_mut().enumerate() {
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();
}
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] {
let last = self.curves.len() - 1;
let span = self.curve_log_max - self.curve_log_min;
let mut out = [0.0f32; 3];
for (c, slot) in out.iter_mut().enumerate() {
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;
fn sample_curves(&self, log_exposure: [f32; 3], push_stops: f32) -> [f32; 3] {
let (row, g) = push_row(&self.push_stations, push_stops);
let lo = self.sample_curve_row(log_exposure, row);
if self.curve_rows < 2 {
return lo;
}
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] {
let n = self.lut_size;
let mut base = [0usize; 3];
let mut frac = [0f32; 3];
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;
}
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 = self.lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx];
for c in 0..3 {
out[c] += w * e[c];
}
fn sample_curve_row(&self, log_exposure: [f32; 3], row: usize) -> [f32; 3] {
let samples = self.curves.len() / self.curve_rows;
let curve = &self.curves[row * samples..(row + 1) * samples];
sample_curve(curve, self.curve_log_min, self.curve_log_max, log_exposure)
}
}
/// 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.
fn sample_curve(curve: &[[f32; 3]], log_min: f32, log_max: f32, at: [f32; 3]) -> [f32; 3] {
let last = curve.len() - 1;
let span = log_max - log_min;
let mut out = [0.0f32; 3];
for (c, slot) in out.iter_mut().enumerate() {
let t = ((at[c] - 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 = curve[i][c] * (1.0 - f) + curve[i + 1][c] * f;
}
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.
@@ -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
/// it — which is why a printed negative looks like a photograph while a scanned
/// one looks orange.
fn print_balance(
film: &Profile,
paper: &Profile,
exposure_ev: f32,
print_exposure_ev: f32,
) -> [f32; 3] {
pub fn print_balance(film: &Profile, paper: &Profile, exposure_ev: f32) -> [f32; 3] {
let matrix = exposure_matrix(film);
let scene = MID_GREY * 2f32.powf(exposure_ev);
let mut log_exposure = [0.0f32; 3];
@@ -227,7 +346,7 @@ fn print_balance(
let mut offsets = [0.0f32; 3];
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
}
@@ -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.
pub fn bake(recipe: &Recipe) -> Baked {
let film = recipe.film;
let mut matrix = exposure_matrix(film);
// Camera exposure rides in the matrix rather than in the shader: it is a
// scalar on a linear quantity, and folding it in here costs nothing and
// keeps the per-pixel work identical whether or not it has been moved.
let gain = 2f32.powf(recipe.exposure_ev);
for row in &mut matrix {
for v in row.iter_mut() {
*v *= gain;
}
}
// At unit gain. Camera exposure is a scalar on a linear quantity, so the
// shader applies it for the price of one multiply — and has to, since a
// layer may hold its own.
let matrix = exposure_matrix(film);
// TRACES: FR-DEV-3f
// Developed to the requested push before anything else reads the curves:
// the density ceiling, the print balance and the grain all depend on how
// far this film was taken, and a push that only reached one of them would
// be a contrast change wearing a push's name.
let curves = film.curves_at_push(recipe.push_stops);
let density_max = curves
.iter()
.flat_map(|row| row.iter())
.fold(0.0f32, |a, &b| a.max(b))
.max(1e-3);
let viewing = match recipe.print {
Some(paper) => Viewing::new(&paper.viewing_illuminant),
None => Viewing::new(&film.viewing_illuminant),
// Every measured process, not the one the slider is at: the shader
// interpolates between rows per pixel, so a layer can push a region.
// Resampled to one length because the rows share a texture.
let measured = film.development_curves.len() >= 2
&& film.development_times.len() == film.development_curves.len();
let (curves, push_stations): (Vec<[f32; 3]>, Vec<f32>) = if measured {
let rows = film.development_curves.len().min(MAX_CURVE_ROWS);
(
film.development_curves[..rows]
.iter()
.flat_map(|c| resample(c))
.collect(),
film.development_times[..rows]
.iter()
.map(|t| 2.0 * (t / film.development_normal).log2())
.collect(),
)
} else {
(resample(&film.density_curves), vec![0.0])
};
let balance = recipe
.print
.map(|paper| print_balance(film, paper, recipe.exposure_ev, recipe.print_exposure_ev));
let curve_rows = push_stations.len();
// The ceiling of the deepest row, so one lookup covers every push.
let density_max = ceiling(&curves);
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
// `Baked::lut`: this is the layout a 3D texture upload wants, and getting
// it backwards transposes red and blue in the finished picture.
for b in 0..n {
for g in 0..n {
for r in 0..n {
let density = [
density_max * r as f32 / (n - 1) as f32,
density_max * g as f32 / (n - 1) as f32,
density_max * b as f32 / (n - 1) as f32,
];
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)),
});
let cube = |max: f32, f: &dyn Fn([f32; 3]) -> [f32; 3]| {
let mut out = Vec::with_capacity(n * n * n);
for b in 0..n {
for g in 0..n {
for r in 0..n {
let step = max / (n - 1) as f32;
out.push(f([r as f32 * step, g as f32 * step, b as f32 * step]));
}
}
}
}
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 {
exposure_matrix: matrix,
curves,
curve_rows,
push_stations,
curve_log_min: film.log_exposure_min,
curve_log_max: film.log_exposure_max,
lut,
paper,
density_max,
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 {
s.iter().sum::<f32>() / SPECTRUM as f32
}
@@ -467,6 +626,10 @@ mod tests {
#[test]
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 paper = endura();
let brighter = bake(&Recipe {
@@ -474,7 +637,155 @@ mod tests {
..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]
@@ -550,5 +861,25 @@ mod tests {
let baked = bake(&Recipe::new(&film, None));
assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE);
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);
}
}
+3 -3
View File
@@ -2,9 +2,9 @@
//!
//! # Why it is data
//!
//! The same argument `dr_decode::base_curve` makes for camera bodies, and for
//! the same requirement: under the GPLv3 a stock should be contributable
//! without a release. A profile is three tables and a handful of facts, all of
//! Under the GPLv3 a stock should be contributable without a release (the
//! argument the retired per-body base curves made for camera bodies, before
//! D19). A profile is three tables and a handful of facts, all of
//! them published in the manufacturer's datasheet, so adding a stock is adding
//! a file — not a code change, not a shader, and not a new operation.
//!
+4 -1
View File
@@ -680,15 +680,18 @@ fn film_tables() -> FilmTables {
FilmTables {
exposure_matrix: baked.exposure_matrix,
curves: baked.curves.clone(),
push_stations: baked.push_stations.clone(),
curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max,
lut: baked.lut.clone(),
density_max: baked.density_max,
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
// not part of every edit, and the chain being measured here is "every
// 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_uniformity: 0.97,
}
+1 -2
View File
@@ -182,8 +182,7 @@ fn render_to(
// and the example never has to know which it was handed.
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(image.size(), (w, h));
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
let key = graph.invalidation().through(Affects::Colour);
adjust
.render_detailed(image, &shader, w, h, None, &detail, key)
+199 -80
View File
@@ -34,16 +34,6 @@ use crate::{DemosaicedImage, GpuContext, GpuError};
/// reads them.
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
/// TRACES: FR-DEV-3e
/// The two crates must agree on how many points a base curve has.
///
/// `dr-decode` reads them from the profile database and `dr-pipeline` declares
/// the uniform slots; this file is the only place the two meet, and it packs
/// them by index. A disagreement would not fail to compile — it would upload a
/// curve with a point missing or a stale float in it, which renders as a
/// plausible-looking wrong tone response. Cheaper to catch here, at build time.
const _: () = assert!(dr_decode::base_curve::POINTS == dr_pipeline::BASE_CURVE_POINTS);
/// Runs composed operation chains against demosaiced images.
pub struct AdjustPass {
ctx: GpuContext,
@@ -132,6 +122,8 @@ pub struct AdjustPass {
colour_dispatches: usize,
/// Detail dispatches encoded.
detail_dispatches: usize,
/// View passes encoded — one per render with a detail stage (D19).
view_dispatches: usize,
}
struct Target {
@@ -382,9 +374,23 @@ fn film_key(t: &dr_pipeline::ops::FilmTables) -> u64 {
t.curve_log_max,
t.density_max,
t.lut_size as f32,
t.curves.len() as f32,
t.lut.len() as f32,
] {
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)) {
mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22));
}
@@ -431,12 +437,16 @@ impl AdjustPass {
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(
"adjust-film-curves",
wgpu::TextureDimension::D2,
wgpu::Extent3d {
width: t.curves.len() as u32,
height: 1,
width: samples,
height: t.curves.len() as u32 / samples,
depth_or_array_layers: 1,
},
&to_rgba(&t.curves),
@@ -448,7 +458,7 @@ impl AdjustPass {
wgpu::Extent3d {
width: n,
height: n,
depth_or_array_layers: n,
depth_or_array_layers: t.lut.len() as u32 / (n * n),
},
&to_rgba(&t.lut),
);
@@ -645,6 +655,7 @@ impl AdjustPass {
sample: SampleCache::new(ctx),
colour_dispatches: 0,
detail_dispatches: 0,
view_dispatches: 0,
}
}
@@ -1040,13 +1051,14 @@ impl AdjustPass {
/// Render one frame with a neighbourhood stage.
///
/// `shader` and `detail` must be the two halves of **one** composition —
/// `EditGraph::compose_for` and `EditGraph::compose_detail_for` on the same
/// graph, at the same output space. The fused pass stops at linear working
/// values when a detail stage exists and the last detail pass performs the
/// output transform, so a mismatched pair either encodes twice or not at
/// all.
/// `EditGraph::compose_for` and `EditGraph::compose_detail` on the same
/// graph. The fused pass stops at linear working values when a detail
/// stage exists, and its view pass ([`ComposedShader::view`]) performs the
/// view transform and the output transform after the last detail pass
/// (D19), so a mismatched pair either encodes twice or not at all.
///
/// An empty `detail` falls through to [`Self::render_masked`], which is
/// An encoded shader with an empty `detail` falls through to
/// [`Self::render_masked`], which is
/// the honest thing to do rather than an optimisation: an edit with no
/// active sharpening *is* an ordinary edit, and it should cost exactly
/// what one costs.
@@ -1086,17 +1098,25 @@ impl AdjustPass {
detail: &ComposedDetail,
colour_key: u64,
) -> Result<&wgpu::Texture, GpuError> {
if detail.is_empty() {
// An edit with no detail stage encodes in the fused pass, and is an
// ordinary render. Decided from the shader rather than from the chain:
// an active kernel too fine for this render emits no pass, and its
// fused pass has still stopped at linear values for the view pass.
if shader.output_mode == OutputMode::Encoded && detail.is_empty() {
return self.render_masked(source, shader, width, height, masks);
}
if shader.output_mode != OutputMode::LinearWorking {
return Err(GpuError::ShaderCompilation(
"this detail chain expects a fused pass composed to hand on \
linear working values, but the shader given encodes its own \
output; compose both halves from the same graph"
.into(),
));
}
let view = match (shader.output_mode, shader.view.as_deref()) {
(OutputMode::LinearWorking, Some(view)) => view,
_ => {
return Err(GpuError::ShaderCompilation(
"this detail chain expects a fused pass composed to hand on \
linear working values, with its view pass, but the shader \
given encodes its own output; compose both halves from the \
same graph"
.into(),
));
}
};
let (width, height) = (width.max(1), height.max(1));
self.ensure_target(width, height);
@@ -1109,6 +1129,7 @@ impl AdjustPass {
// both want `&mut self`, and the second holds its borrow across the
// encode below.
self.pipeline(shader)?;
self.pipeline(view)?;
let colour_view = self
.detail
.colour_target(detail.len(), width, height)
@@ -1199,20 +1220,12 @@ impl AdjustPass {
self.colour_dispatches += 1;
}
// One encoder for the colour pass and every detail pass, submitted
// once — the shape `MaskPass::render` established. Submission order is
// the whole of the synchronisation: each pass reads what the previous
// one wrote, through the same queue.
let target_view = self.targets[self.current]
.as_ref()
.expect("ensured above")
.view
.clone();
let ran = match self
.detail
.encode(&mut enc, detail, &target_view, width, height)
{
Ok(ran) => ran,
// One encoder for the colour pass, every detail pass and the view pass,
// submitted once — the shape `MaskPass::render` established.
// Submission order is the whole of the synchronisation: each pass
// reads what the previous one wrote, through the same queue.
let (ran, result) = match self.detail.encode(&mut enc, detail, width, height) {
Ok(done) => done,
Err(e) => {
// Nothing is submitted, so a cache this frame was to write
// holds nothing, and must not be read as though it did.
@@ -1222,6 +1235,86 @@ impl AdjustPass {
return Err(e);
}
};
// TRACES: FR-DEV-3j
// The view pass: the view transform and the output transform, after
// every kernel (D19). Its own uniform block, filled from the source
// like the fused pass's — it reads the non-linear flag and the film
// settings there — with the sample cache off, because the colour it
// reads is the detail stage's result, bound where the cache would be.
let view_uniforms = Self::fused_uniforms(source, view);
let view_params = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-view-params"),
contents: bytemuck::cast_slice(&view_uniforms),
usage: wgpu::BufferUsages::UNIFORM,
});
let (_, no_sample_out) = self.sample.views(SampleUse::Direct);
let target_view = self.targets[self.current]
.as_ref()
.expect("ensured above")
.view
.clone();
let view_bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("adjust-view-bg"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source.view()),
},
wgpu::BindGroupEntry {
binding: 1,
resource: view_params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&target_view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(
masks.map_or(&self.empty_masks, |m| m.view()),
),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::TextureView(&film_curves),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(&film_lut),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&result),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&no_sample_out),
},
],
});
{
let pipeline = self
.cache
.get(&view.structure_hash)
.expect("compiled above");
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("adjust-view-pass"),
timestamp_writes: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &view_bind_group, &[]);
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
}
self.view_dispatches += 1;
self.ctx.queue.submit(Some(enc.finish()));
self.detail_dispatches += ran;
self.colour_key = Some((key, width, height));
@@ -1257,22 +1350,13 @@ impl AdjustPass {
// runs. See `DemosaicedImage::is_non_linear`.
let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 };
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]);
// TRACES: FR-DEV-3e
// The camera profile's base curve, packed the way the generated block
// declares it: four x, four y, then the fifth point and the flag. The
// flag is what lets one compiled shader serve a profiled body and an
// unprofiled one, so the pipeline cache is not split in two by which
// camera took the frame.
//
// Written here rather than at the call site so that *both* callers —
// the plain render and the masked one — carry the profile. Filling it
// at one of them was how the two halves of this merge each had it.
let curve = source.base_curve();
let on = if curve.is_identity() { 0.0 } else { 1.0 };
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
uniforms[b..b + 4].copy_from_slice(&curve.xs[0..4]);
uniforms[b + 4..b + 8].copy_from_slice(&curve.ys[0..4]);
uniforms[b + 8..b + 12].copy_from_slice(&[curve.xs[4], curve.ys[4], on, 0.0]);
// TRACES: FR-DSP-2 | NFR-RES-2
// Which part of the photograph the texture holds. The whole of it for
// every source that fits in one texture, which writes back exactly
// what the composer put there.
let w = dr_pipeline::SOURCE_WINDOW_UNIFORM_OFFSET;
uniforms[w..w + dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS]
.copy_from_slice(&source.window_uniforms());
uniforms
}
@@ -1386,6 +1470,13 @@ impl AdjustPass {
self.detail_dispatches
}
/// TRACES: FR-DEV-3j
/// View passes encoded since this pass was created: one for every render
/// that had a detail stage, since the view transform follows it.
pub fn view_dispatches(&self) -> usize {
self.view_dispatches
}
/// How many linear intermediates have been allocated. For tests: see
/// [`crate::MaskPass::allocations`] for the regression this catches.
pub fn detail_allocations(&self) -> usize {
@@ -1429,9 +1520,9 @@ impl AdjustPass {
/// one: the storage format is in the layout. The profile uniforms are
/// filled neutral here rather than from the source, which is the whole
/// point of the mode (`OutputMode::CameraLinear`): unit white balance,
/// identity matrix, base curve off. The non-linear flag is kept, so a
/// JPEG source is still linearised — camera space for a JPEG is the
/// decoded values made linear, which is the best that exists.
/// identity matrix, and no view transform composed. The non-linear flag
/// is kept, so a JPEG source is still linearised — camera space for a
/// JPEG is the decoded values made linear, which is the best that exists.
///
/// The texture stays on the device for a merge's warp to sample; see
/// [`Self::camera_texture`] and [`Self::read_camera_linear`].
@@ -1460,8 +1551,6 @@ impl AdjustPass {
uniforms[4..8].copy_from_slice(&[0.0, 1.0, 0.0, 0.0]);
uniforms[8..12].copy_from_slice(&[0.0, 0.0, 1.0, 0.0]);
uniforms[12..16].copy_from_slice(&[1.0, 1.0, 1.0, non_linear]);
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
uniforms[b + 10] = 0.0;
let params_buf = self
.ctx
@@ -1689,7 +1778,7 @@ pub(crate) fn numbered(src: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_pipeline::ops::{colour_mixer, exposure, saturation};
use dr_pipeline::EditGraph;
// For `Operation::detail`, which is how `the_whole_chain_at_once_compiles`
@@ -1727,7 +1816,6 @@ mod tests {
// Identity, so the test reasons about the operations alone
// rather than about a camera's colour response.
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(),
@@ -1931,7 +2019,6 @@ mod tests {
white_level: 16383,
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(),
@@ -2303,7 +2390,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; N * N * N],
density_max: 3.0,
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_uniformity: 0.97,
}
@@ -2361,7 +2450,7 @@ mod tests {
// find those operations in neither stage and fail for a reason that is
// not a defect. Shadows the smaller size deliberately.
let (w, h) = g.output_size(512, 512);
let detail = g.compose_detail_for((512, 512), (w, h), dr_types::ColourSpace::Srgb);
let detail = g.compose_detail((512, 512), (w, h));
assert!(
!detail.is_empty(),
"the detail half composed nothing, so nothing of it was compiled"
@@ -2381,7 +2470,20 @@ mod tests {
let mut fused_blocks = 0;
for desc in g.descriptors() {
let id = desc.id.0;
let point = shader.source.contains(&format!("---- {id} ----"));
// A stock is loaded here, and a stock is a rendering: the view
// transform it replaces is correctly in neither half (FR-DEV-3j).
if id == dr_pipeline::ops::view_transform::ID.0 {
assert!(!shader.source.contains("---- view_transform ----"));
continue;
}
// A view operation is in the view pass when a detail stage
// follows, which it does here (D19).
let block = format!("---- {id} ----");
let point = shader.source.contains(&block)
|| shader
.view
.as_ref()
.is_some_and(|v| v.source.contains(&block));
let neighbourhood = detail
.passes
.iter()
@@ -2415,8 +2517,15 @@ mod tests {
// because the two catch different faults: the XOR catches an operation
// in the wrong stage, this catches a block in the shader that nothing
// in the chain asked for.
//
// The view pass repeats the prologue — framing and the warps — for
// the positions it publishes, so only its operation blocks count.
let view_blocks = shader
.view
.as_ref()
.map_or(0, |v| v.source.matches("---- ").count() - (warp_blocks + 1));
assert_eq!(
shader.source.matches("---- ").count(),
shader.source.matches("---- ").count() + view_blocks,
fused_blocks + warp_blocks + 1,
"the fused shader carries a block nothing in the chain asked for"
);
@@ -2518,7 +2627,6 @@ mod tests {
white_level: 16383,
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(),
@@ -2622,7 +2730,6 @@ mod tests {
white_level: 16383,
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(),
@@ -3207,11 +3314,16 @@ mod tests {
}
#[test]
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
// The two producers must be interchangeable. A mid-grey that is
// linearly 0.216 (sRGB 128) arriving as sensor data and as a JPEG
// must render the same, or an edit would mean different things
// depending on which decoder opened the file.
fn a_jpeg_and_sensor_data_differ_by_exactly_the_view_transform() {
// TRACES: FR-DEV-3j
// The two producers must be interchangeable up to the rendering. A
// mid-grey that is linearly 0.216 (sRGB 128) arriving as sensor data
// is scene-referred and goes through the view transform; arriving as
// a JPEG it is already a rendering and must come out as it went in.
// Before D19 the fixture's identity base curve made both unrendered
// and this asserted they matched; what it guards is unchanged — the
// linearisation of each agrees — but the rendering between them is
// now always there for sensor data.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
@@ -3230,11 +3342,18 @@ mod tests {
read_centre(&ctx, t)
};
let delta = (i32::from(from_sensor[0]) - i32::from(from_jpeg[0])).abs();
let scene = 3537.0 / 16383.0;
let viewed = dr_pipeline::view::Sigmoid::default_curve().channel(scene);
let expected = (dr_types::Transfer::Srgb.encode(viewed) * 255.0).round() as i32;
let delta = (i32::from(from_sensor[0]) - expected).abs();
assert!(
delta <= 3,
"the same scene value rendered {from_sensor:?} from sensor data \
and {from_jpeg:?} from a JPEG"
"sensor data rendered {from_sensor:?}, expected about {expected}"
);
let delta = (i32::from(from_jpeg[0]) - 128).abs();
assert!(
delta <= 3,
"a JPEG was rendered again: {from_jpeg:?} from sRGB 128"
);
}
}
+407 -44
View File
@@ -10,7 +10,7 @@
//! pass over this texture; it does not re-demosaic, which is what keeps the
//! interaction budget (NFR-P9) reachable on a 24 MP file.
use dr_decode::{BaseCurve, CfaPattern, RawImage};
use dr_decode::{CfaPattern, RawImage};
use wgpu::util::DeviceExt;
use crate::{GpuContext, GpuError};
@@ -57,6 +57,32 @@ struct XTransParams {
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.
///
/// RGBA16Float, scene-referred, camera colour space. This is the input every
@@ -83,23 +109,25 @@ pub struct DemosaicedImage {
color_matrix: [f32; 9],
/// As-shot white balance, the neutral starting point for the WB control.
as_shot_wb: [f32; 3],
/// TRACES: FR-DEV-3e
/// The camera profile's rendering curve, carried through for the adjust
/// pass exactly as `color_matrix` is.
///
/// It rides on the image rather than on the edit graph because it is not
/// an edit: it belongs to the body that took the frame, the way the
/// masked-photosite crop and the EXIF orientation do, and a sidecar shared
/// between two bodies must never carry one body's rendering onto the
/// other's file (FR-NC-9).
base_curve: BaseCurve,
/// Whether the texture holds gamma-encoded rather than linear values.
non_linear: bool,
/// Which upload this is, unique for the life of the process. See
/// [`Self::id`].
id: u64,
/// TRACES: FR-DSP-2 | NFR-RES-2
/// The whole frame's size in pixels — what [`Self::size`] reports.
/// The texture's own size when it holds the whole frame at full
/// resolution, which is every photograph that fits in one.
frame: (u32, u32),
/// Which part of the frame the texture holds, as origin and extent in
/// normalised frame coordinates. `[0, 0, 1, 1]` for the whole frame,
/// reduced or not. See [`Self::window_uniforms`].
window: [f32; 4],
}
/// The window of a texture that holds the whole frame.
const WHOLE_FRAME: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
/// The next [`DemosaicedImage::id`].
fn next_image_id() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -117,10 +145,50 @@ impl DemosaicedImage {
&self.view
}
/// The size of the photograph this stands for, in its own pixels.
///
/// **Not necessarily the texture's.** For a photograph larger than one
/// texture this is a reduced copy of it or a window cut from it, and
/// everything that sizes a render, a crop or a kernel has to go on
/// measuring the photograph. What indexes the texture's texels asks
/// [`Self::texture_size`] instead.
pub fn size(&self) -> (u32, u32) {
self.frame
}
/// The texture's own size in texels.
pub fn texture_size(&self) -> (u32, u32) {
(self.width, self.height)
}
/// TRACES: FR-DSP-2 | NFR-RES-2
/// The source window uniforms the fused shader reads, in the order
/// `dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS` declares them.
///
/// The second `vec4` is zero for a texture that holds the whole frame at
/// full resolution, so the shader measures the texture itself exactly as
/// it did before windows existed.
pub fn window_uniforms(&self) -> [f32; dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS] {
let [x, y, w, h] = self.window;
let (fw, fh) = if self.is_whole() {
(0.0, 0.0)
} else {
(self.frame.0 as f32, self.frame.1 as f32)
};
[x, y, w, h, fw, fh, 0.0, 0.0]
}
/// Whether the texture is the whole frame at full resolution.
pub fn is_whole(&self) -> bool {
self.window == WHOLE_FRAME && self.frame == (self.width, self.height)
}
/// The window this texture holds, as origin and extent in normalised
/// frame coordinates.
pub fn window(&self) -> [f32; 4] {
self.window
}
/// Which texture this is, as a number that is never reused.
///
/// For a cache that has to know it is still looking at the same pixels
@@ -139,15 +207,6 @@ impl DemosaicedImage {
self.color_matrix
}
/// TRACES: FR-DEV-3e
/// The camera profile's base curve, as five `(x, y)` points.
///
/// [`BaseCurve::IDENTITY`] where the body is unprofiled or the source was
/// never raw, in which case the adjust pass skips the stage entirely.
pub fn base_curve(&self) -> BaseCurve {
self.base_curve
}
/// As-shot white balance multipliers, green-normalised.
///
/// The white balance control is expressed *relative* to these, so its
@@ -261,13 +320,15 @@ impl DemosaicedImage {
color_matrix: IDENTITY_3X3,
as_shot_wb: [1.0, 1.0, 1.0],
// **The identity, and this is the whole reason the field is here
// rather than resolved further down.** A JPEG has already had its
// camera's base curve baked in by the camera; applying one again
// would render the rendering, crushing the shadows and flattening
// the highlights of an image that was already finished.
base_curve: BaseCurve::IDENTITY,
// rather than resolved further down.** A JPEG has already been
// rendered by the camera; the view transform skips a source
// flagged non-linear, since rendering the rendering would crush
// the shadows and flatten the highlights of an image that was
// already finished.
non_linear: true,
id: next_image_id(),
frame: (width, height),
window: WHOLE_FRAME,
})
}
}
@@ -278,11 +339,46 @@ impl DemosaicedImage {
/// what a merge writes. No demosaic; the samples are normalised by the
/// file's black and white levels exactly as the demosaic kernel would
/// normalise a photosite, and everything else — the matrix, the
/// balance, the body's base curve — is carried through as for a CFA
/// balance, the view transform — is carried through as for a CFA
/// file, because the composite is developed as one photograph from the
/// body that took its sources.
pub fn from_linear_rgb16(ctx: &GpuContext, raw: &RawImage) -> Result<Self, GpuError> {
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
Self::linear_rgb16_window(ctx, raw, [0, 0, width, height], 1)
}
/// TRACES: FR-DSP-2 | NFR-RES-2
/// Part of a linear DNG, or a reduced copy of it, for a photograph too
/// large to hold in one texture.
///
/// `region` is `[x, y, width, height]` in pixels of the frame (the
/// file's crop), clamped to it. `reduce` averages `reduce × reduce`
/// blocks into one texel — a box filter, which is what a reduced copy
/// that is only ever displayed smaller than itself needs, and which keeps
/// the samples in scene-linear light where an average means something.
///
/// The texture then knows where it sits ([`Self::window`]) and how large
/// the photograph is ([`Self::size`]), and the fused shader maps each
/// output pixel's position in the *photograph* into it. So a crop, a
/// rotation or a mask drawn on the reduced copy lands on the same pixels
/// of a full-resolution window, and an export in tiles is the same
/// picture as one that fitted.
///
/// Refused only if the result itself does not fit the device.
pub fn linear_rgb16_window(
ctx: &GpuContext,
raw: &RawImage,
region: [u32; 4],
reduce: u32,
) -> Result<Self, GpuError> {
let frame = (raw.crop.width.max(1), raw.crop.height.max(1));
let k = reduce.max(1);
let x0 = region[0].min(frame.0 - 1);
let y0 = region[1].min(frame.1 - 1);
let rw = region[2].clamp(1, frame.0 - x0);
let rh = region[3].clamp(1, frame.1 - y0);
let (width, height) = (rw.div_ceil(k), rh.div_ceil(k));
let limits = ctx.device.limits();
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
return Err(GpuError::TooLarge(format!(
@@ -302,19 +398,49 @@ impl DemosaicedImage {
}
let black = black_per_cell(raw);
let inv = inv_range_per_cell(raw);
// Per channel rather than per CFA cell: R, G, B are the first three.
let mut half: Vec<u16> = Vec::with_capacity((width * height * 4) as usize);
for y in 0..height as usize {
let row = (raw.crop.y as usize + y) * stride + raw.crop.x as usize * 3;
for x in 0..width as usize {
let p = &raw.data[row + x * 3..row + x * 3 + 3];
for c in 0..3 {
let v = (f32::from(p[c]) - black[c]) * inv[c];
half.push(f32_to_f16_bits_unclamped(v));
// One output row per task: a 200-megapixel reduction is a second of
// one core, and the rows are independent.
let row_texels = width as usize * 4;
let mut half = vec![0u16; row_texels * height as usize];
let fill_row = |ty: usize, out: &mut [u16]| {
let sy0 = y0 as usize + ty * k as usize;
let sy1 = (sy0 + k as usize).min((y0 + rh) as usize);
for tx in 0..width as usize {
let sx0 = x0 as usize + tx * k as usize;
let sx1 = (sx0 + k as usize).min((x0 + rw) as usize);
let mut acc = [0f32; 3];
for sy in sy0..sy1 {
let row = (raw.crop.y as usize + sy) * stride + raw.crop.x as usize * 3;
for sx in sx0..sx1 {
let p = &raw.data[row + sx * 3..row + sx * 3 + 3];
for c in 0..3 {
acc[c] += f32::from(p[c]);
}
}
}
half.push(f32_to_f16_bits(1.0));
let n = ((sy1 - sy0) * (sx1 - sx0)).max(1) as f32;
let texel = &mut out[tx * 4..tx * 4 + 4];
for c in 0..3 {
let v = (acc[c] / n - black[c]) * inv[c];
texel[c] = f32_to_f16_bits_unclamped(v);
}
texel[3] = f32_to_f16_bits(1.0);
}
}
};
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
let rows_per = (height as usize).div_ceil(threads).max(1);
std::thread::scope(|scope| {
for (chunk, rows) in half.chunks_mut(rows_per * row_texels).enumerate() {
let fill_row = &fill_row;
scope.spawn(move || {
for (i, out) in rows.chunks_mut(row_texels).enumerate() {
fill_row(chunk * rows_per + i, out);
}
});
}
});
let texture = ctx.device.create_texture_with_data(
&ctx.queue,
&wgpu::TextureDescriptor {
@@ -335,6 +461,17 @@ impl DemosaicedImage {
bytemuck::cast_slice(&half),
);
let view = texture.create_view(&Default::default());
// The extent is the texels' own, `width × k`, not the region's: the
// last block of a reduction may run past the frame's edge, and
// stretching it to fit would put every texel slightly off the
// pixels it averaged. The shader's bounds test is on the frame, so
// nothing past the edge is ever read.
let window = [
x0 as f32 / frame.0 as f32,
y0 as f32 / frame.1 as f32,
(width * k) as f32 / frame.0 as f32,
(height * k) as f32 / frame.1 as f32,
];
Ok(Self {
texture,
view,
@@ -342,9 +479,10 @@ impl DemosaicedImage {
height,
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
base_curve: raw.base_curve,
non_linear: false,
id: next_image_id(),
frame,
window,
})
}
}
@@ -437,6 +575,8 @@ pub struct Demosaicer {
pipeline: wgpu::ComputePipeline,
xtrans_pipeline: wgpu::ComputePipeline,
bind_group_layout: wgpu::BindGroupLayout,
hot_pixel_pipeline: wgpu::ComputePipeline,
hot_pixel_layout: wgpu::BindGroupLayout,
}
impl Demosaicer {
@@ -527,11 +667,15 @@ impl Demosaicer {
cache: None,
});
let (hot_pixel_pipeline, hot_pixel_layout) = hot_pixel_pipeline(ctx);
Ok(Self {
ctx: ctx.clone(),
pipeline,
xtrans_pipeline,
bind_group_layout,
hot_pixel_pipeline,
hot_pixel_layout,
})
}
@@ -558,8 +702,12 @@ impl Demosaicer {
// the buffer outlive the `if` that chose them.
let bayer_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() {
xtrans_params = xtrans_params_for(raw, width, height);
xtrans_tile = Some(xtrans_params.tile);
(&self.xtrans_pipeline, bytemuck::bytes_of(&xtrans_params))
} else {
let pattern = match raw.cfa_pattern {
@@ -598,6 +746,64 @@ impl Demosaicer {
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
.ctx
.device
@@ -636,7 +842,7 @@ impl Demosaicer {
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: raw_buf.as_entire_binding(),
resource: repaired.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
@@ -655,6 +861,18 @@ impl Demosaicer {
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
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 {
label: Some("demosaic-pass"),
@@ -678,12 +896,13 @@ impl Demosaicer {
// Whatever the profile database had for this body (FR-DEV-3e),
// resolved at decode because that is the only place the make and
// model are known.
base_curve: raw.base_curve,
// Sensor data is linear by construction — the demosaic shader
// normalises against black and white levels and applies no
// transfer function.
non_linear: false,
id: next_image_id(),
frame: (width, height),
window: WHOLE_FRAME,
})
}
}
@@ -960,6 +1179,136 @@ fn detect_xtrans_phase(raw: &RawImage) -> (u32, u32) {
/// TRACES: FR-RAW-5
/// 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 {
let (black, inv_range) = xtrans_levels(raw);
let wb = wb_gains(raw);
@@ -994,6 +1343,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]
fn unclamped_half_keeps_shadows_signs_and_highlights() {
// A 14-bit LSB, normalised: subnormal in f16, and must not be zero.
@@ -1030,7 +1397,6 @@ mod tests {
white_level: white,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None,
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1,
profile: None,
make: String::new(),
@@ -1146,7 +1512,6 @@ mod tests {
white_level: white,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None,
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1,
profile: None,
make: String::new(),
@@ -1443,7 +1808,6 @@ mod tests {
white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None,
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1,
profile: None,
make: String::new(),
@@ -1528,7 +1892,6 @@ mod tests {
1.0,
],
color_matrix: None,
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1,
profile: None,
make: String::new(),
+32 -46
View File
@@ -43,12 +43,12 @@
//! dispatch is skipped, and dragging a sharpening slider costs the detail
//! passes alone (FR-DEV-3d).
//!
//! The remaining passes alternate between slots 1 and 2, and the last one
//! writes the display texture directly rather than an intermediate — so a
//! chain of *N* passes costs *N* dispatches and not *N* + 1, and there is no
//! resolve pass to pay for. That leaves the allocation at `1 + min(N-1, 2)`
//! textures: one for a single-pass operation, two for a separable blur, three
//! however long the chain gets after that.
//! The passes alternate between slots 1 and 2, the last one included: since
//! D19 it hands its result to the adjust pass's **view pass**, which performs
//! the view transform and the output transform after every kernel, so no
//! detail pass writes the display texture. A chain of *N* passes costs *N*
//! dispatches plus that one, and the allocation is `1 + min(N, 2)` textures.
//! An empty chain costs the view pass alone, reading slot 0.
//!
//! # The reduced chain, and why a second one was needed
//!
@@ -186,10 +186,9 @@ impl Intermediates {
/// intermediate against a fresh colour result and never be told.
pub(crate) struct DetailRunner {
ctx: GpuContext,
/// Layout for a pass writing another linear intermediate.
/// Layout for every pass: each writes a linear intermediate, the last
/// one included, and the adjust pass's view pass reads the last (D19).
to_linear: Layout,
/// Layout for the last pass, which writes the display texture.
to_output: Layout,
/// Compiled pipelines by pass structure hash.
cache: HashMap<u64, wgpu::ComputePipeline>,
pool: Intermediates,
@@ -255,7 +254,6 @@ impl DetailRunner {
Self {
ctx: ctx.clone(),
to_linear: Layout::new(ctx, INTERMEDIATE_FORMAT, "detail-linear"),
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
cache: HashMap::new(),
pool: Intermediates::new(),
reduced: Intermediates::new(),
@@ -274,27 +272,30 @@ impl DetailRunner {
width: u32,
height: u32,
) -> &wgpu::TextureView {
// One for the colour pass's result, then one per hand-off between
// detail passes, capped at two because a ping-pong needs no more: the
// last pass writes the display texture rather than an intermediate.
let needed = 1 + passes.saturating_sub(1).min(2);
// One for the colour pass's result, then one per pass, capped at two
// because a ping-pong needs no more. The last pass writes an
// intermediate like the others since D19 — the view pass reads it —
// so a one-pass chain needs two slots where it used to need one.
let needed = 1 + passes.min(2);
self.pool.ensure(&self.ctx, needed, width, height);
&self.pool.slots[0].view
}
/// Encode every pass of `chain`, the last one writing `output`.
/// Encode every pass of `chain`, and return how many ran and the view
/// the last one wrote — slot 0, the colour pass's own result, for an
/// empty chain.
///
/// The caller must already have run the fused colour pass into
/// [`Self::colour_target`] — or established that a previous frame's is
/// still valid, which is the whole point of keeping slot 0.
/// still valid, which is the whole point of keeping slot 0 — and reads the
/// returned view in the view pass that finishes the render (D19).
pub(crate) fn encode(
&mut self,
encoder: &mut wgpu::CommandEncoder,
chain: &ComposedDetail,
output: &wgpu::TextureView,
width: u32,
height: u32,
) -> Result<usize, GpuError> {
) -> Result<(usize, wgpu::TextureView), GpuError> {
for pass in &chain.passes {
self.compile(pass)?;
}
@@ -340,17 +341,15 @@ impl DetailRunner {
for pass in chain.passes.iter() {
let scaled = pass.output_scale > 1;
// The last pass carries the output transform into the display
// texture, which is the render size by definition. A scaled pass
// there would bind a shader dispatching over a quarter-size grid
// to a full-size target and write a quarter of the picture — a
// wrong image rather than a validation failure, so it is caught
// here and named.
if scaled && pass.writes_output {
// The last pass hands the view pass its input, which is read at
// the render size by definition. A scaled pass there would leave
// the result in the reduced chain and the view pass would read the
// full-size slot before it — a wrong image rather than a
// validation failure, so it is caught here and named.
if scaled && std::ptr::eq(pass, chain.passes.last().expect("iterating")) {
return Err(GpuError::ShaderCompilation(format!(
"detail pass {} declares output_scale {} and is last in \
the chain; the output transform is written at the render \
size",
the chain; the view pass reads the render size",
pass.label, pass.output_scale
)));
}
@@ -365,17 +364,14 @@ impl DetailRunner {
};
// Read what the previous pass in *this pass's own chain* wrote;
// write the next slot of it, or the display texture if this is the
// last pass. Alternating slots is what stops a pass reading the
// write the next slot of it. Alternating slots is what stops a pass reading the
// texture it is writing — on a compute pass that is not an error
// the driver reports, merely a picture that depends on scheduling.
let source = match (scaled, carried) {
(true, Some(slot)) => &self.reduced.slots[slot].view,
_ => &self.pool.slots[full].view,
};
let destination = if pass.writes_output {
output
} else if scaled {
let destination = if scaled {
&self.reduced.slots[reduced_writes % 2].view
} else {
&self.pool.slots[1 + (full_writes % 2)].view
@@ -387,11 +383,7 @@ impl DetailRunner {
Some(slot) => &self.reduced.slots[slot].view,
None => &self.no_reduced,
};
let layout = if pass.writes_output {
&self.to_output
} else {
&self.to_linear
};
let layout = &self.to_linear;
let params = self
.ctx
@@ -464,9 +456,7 @@ impl DetailRunner {
compute.dispatch_workgroups(dispatch_w.div_ceil(8), dispatch_h.div_ceil(8), 1);
drop(compute);
if pass.writes_output {
// Nothing downstream to hand anything to.
} else if scaled {
if scaled {
carried = Some(reduced_writes % 2);
reduced_writes += 1;
} else {
@@ -480,7 +470,7 @@ impl DetailRunner {
}
}
Ok(chain.passes.len())
Ok((chain.passes.len(), self.pool.slots[full].view.clone()))
}
/// Compile one pass, or leave the cached pipeline in place.
@@ -508,11 +498,7 @@ impl DetailRunner {
source: wgpu::ShaderSource::Wgsl(pass.source.as_str().into()),
});
let layout = if pass.writes_output {
&self.to_output
} else {
&self.to_linear
};
let layout = &self.to_linear;
let pipeline = self
.ctx
+1 -1
View File
@@ -928,7 +928,7 @@ impl MaskPass {
// the only readers and they are skipped in that case.
let source_step = match source {
Some(image) => {
let (sw, sh) = image.size();
let (sw, sh) = image.texture_size();
[
sw as f32 / width.max(1) as f32,
sh as f32 / height.max(1) as f32,
+122 -10
View File
@@ -30,18 +30,27 @@
//! are the caller's to provide and cache — `source` is asked for frame `k`
//! as it is needed, and a caller short of memory may demosaic on demand.
//!
//! # The blend
//!
//! With a seam map (`dr_pano::seam`), a frame's weight at a pixel is its
//! share of the map about that pixel — whole on its own side of a seam,
//! nothing on the other, and a ramp across a window `seam_blend` pixels
//! wide that follows the seam. Without one, or where the map has nothing
//! to say, the weight is the distance to the frame's edge over `feather`,
//! which hides exposure steps and does not hide parallax: the average draws
//! anything the frames disagree on twice.
//!
//! # What is not here yet
//!
//! A feathered blend, not seams and a Laplacian pyramid: the weight is the
//! distance to the frame's edge, which hides exposure steps and small
//! misalignments and does not hide parallax. Gain is a scalar per frame
//! the caller supplies. Both are panorama.md §10's step 5, after the path
//! writes a file end to end.
//! A Laplacian pyramid, which would let the seam's blend be narrow for
//! detail and wide for exposure at once. Gain is a scalar per frame the
//! caller supplies.
use std::sync::Arc;
use dr_pano::bundle::Cameras;
use dr_pano::projection::{Bounds, Projection};
use dr_pano::seam::SeamMap;
use wgpu::util::DeviceExt;
use crate::readback::await_mapping;
@@ -58,7 +67,7 @@ pub struct MergeFrame {
}
/// The output the merge produces.
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, PartialEq)]
pub struct MergeOutput {
pub projection: Projection,
/// The projection's scale in output pixels: the cylinder's radius, the
@@ -69,10 +78,19 @@ pub struct MergeOutput {
pub bounds: Bounds,
/// Pixels over which a frame's weight ramps up from its edge.
pub feather: f32,
/// Which frame each part of the output is taken from, laid out at the
/// proxies' scale; `None` for the feathered average everywhere.
pub seams: Option<Arc<SeamMap>>,
/// The width, in output pixels, of the blend across a seam.
pub seam_blend: f32,
/// Chunk size: the unit of GPU work and of memory.
pub chunk: (u32, u32),
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
pub sample_scale: f32,
/// The white balance the composite will be developed with — the
/// inverse of its `AsShotNeutral` — so that a blown sample can be
/// written as the camera value that balance calls grey.
pub balance: [f32; 3],
}
impl MergeOutput {
@@ -109,7 +127,14 @@ struct WarpParams {
tile_origin: [f32; 2],
tile_size: [u32; 2],
feather: f32,
_pad: f32,
clip_onset: f32,
balance: [f32; 4],
seam_origin: [f32; 2],
seam_size: [u32; 2],
seam_px: f32,
seam_radius: f32,
frame_index: u32,
seam_on: u32,
}
#[repr(C)]
@@ -177,6 +202,16 @@ impl MergePass {
count: None,
},
storage(2, false),
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Uint,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
let resolve_layout =
@@ -274,6 +309,32 @@ impl MergePass {
let mut band_cov = vec![false; (out_w * ch) as usize];
let mut chunk_px: Vec<u32> = Vec::new();
// The seam map, once for the whole output, and where it sits in
// this output's coordinates. A one-texel stand-in when there is
// none, because the binding is not optional.
let (seam_tex, seam_origin, seam_px, seam_radius, seam_size) = match &output.seams {
Some(m) => {
let ((ou, ov), px) = m.at_scale(output.scale);
let radius = m.blend_radius(output.scale, f64::from(output.seam_blend));
(
self.label_texture(m.width as u32, m.height as u32, &m.labels),
[ou as f32, ov as f32],
px as f32,
radius as f32,
[m.width as u32, m.height as u32],
)
}
None => (
self.label_texture(1, 1, &[dr_pano::seam::NONE]),
[0.0; 2],
1.0,
1.0,
[1, 1],
),
};
let seam_view = seam_tex.create_view(&Default::default());
let seam_on = u32::from(output.seams.is_some());
let mut y = 0u32;
while y < out_h {
let rows = ch.min(out_h - y);
@@ -334,9 +395,21 @@ impl MergePass {
tile_origin: [rect.0 as f32, rect.1 as f32],
tile_size: [rect.2, rect.3],
feather: output.feather,
_pad: 0.0,
clip_onset: dr_pipeline::CLIP_ONSET,
balance: [
output.balance[0].max(1e-3),
output.balance[1].max(1e-3),
output.balance[2].max(1e-3),
0.0,
],
seam_origin,
seam_size,
seam_px,
seam_radius,
frame_index: k as u32,
seam_on,
};
self.accumulate(&params, tile);
self.accumulate(&params, tile, &seam_view);
}
self.resolve_chunk((cols, rows), output.sample_scale, &mut chunk_px)?;
@@ -374,7 +447,42 @@ impl MergePass {
self.ctx.queue.submit(Some(enc.finish()));
}
fn accumulate(&mut self, params: &WarpParams, tile: &wgpu::Texture) {
/// The seam map's labels as an `r8uint` texture.
fn label_texture(&self, width: u32, height: u32, labels: &[u8]) -> wgpu::Texture {
let size = wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
};
let tex = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("merge-seams"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Uint,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
self.ctx.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
labels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(width),
rows_per_image: Some(height),
},
size,
);
tex
}
fn accumulate(&mut self, params: &WarpParams, tile: &wgpu::Texture, seams: &wgpu::TextureView) {
let chunk = (params.chunk_size[0], params.chunk_size[1]);
let uniforms = self
.ctx
@@ -406,6 +514,10 @@ impl MergePass {
binding: 2,
resource: acc.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(seams),
},
],
});
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
+2 -2
View File
@@ -28,8 +28,8 @@
//! than leaving the specification and the code silently disagreeing.
//!
//! That texture is the right one on the merits. It is camera-native: no white
//! balance has been applied, no camera matrix, no base curve, no tone curve,
//! no output transform. It is normalised by the sensor's own black and white
//! balance has been applied, no camera matrix, no tone curve, no view
//! transform, no output transform. It is normalised by the sensor's own black and white
//! levels, so 1.0 is saturation by construction and the distribution below it
//! *is* the headroom question, with no calibration to carry and no origin to
//! choose.
+1 -1
View File
@@ -208,7 +208,7 @@ impl SegmentPass {
source: &DemosaicedImage,
opts: SegmentOptions,
) -> Result<Segmentation, GpuError> {
let (src_w, src_h) = source.size();
let (src_w, src_h) = source.texture_size();
let (width, height) = proxy_size(src_w, src_h, opts.max_edge);
let n = (width * height) as u64;
+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);
}
+102 -5
View File
@@ -5,8 +5,9 @@
// pixel it asks which direction that pixel looks along, turns the
// direction into the frame's camera, projects it to a source pixel, and
// if that pixel is inside the tile that was rendered for this chunk,
// samples it and adds it — weighted by its distance from the frame's edge
// — into the accumulator. `resolve` runs once per chunk after every frame
// samples it and adds it — weighted by the frame's share of the seam map
// there, or by its distance from the frame's edge where there is no map —
// into the accumulator. `resolve` runs once per chunk after every frame
// has been added: divides the sums by the weights and packs the result as
// sixteen-bit samples at the sensor's scale (FR-MRG-3).
//
@@ -46,13 +47,87 @@ struct Params {
tile_size: vec2<u32>,
// Pixels over which the weight ramps from the edge to full.
feather: f32,
_pad: f32,
// Where a sample starts to count as blown (`CLIP_ONSET`), and the
// white balance the composite will be developed with.
clip_onset: f32,
balance: vec4<f32>,
// The seam map (`dr_pano::seam`): where its texel (0, 0)'s corner sits
// in this output's centred coordinates, its size, output pixels per
// texel, the blend's radius in texels, which frame this dispatch is,
// and whether there is a map at all.
seam_origin: vec2<f32>,
seam_size: vec2<u32>,
seam_px: f32,
seam_radius: f32,
frame_index: u32,
seam_on: u32,
};
@group(0) @binding(0) var<uniform> p: Params;
@group(0) @binding(1) var tile: texture_2d<f32>;
// rgb·w summed, then w: four floats per chunk pixel.
@group(0) @binding(2) var<storage, read_write> acc: array<vec4<f32>>;
// One frame index per texel, 255 for none.
@group(0) @binding(3) var seams: texture_2d<u32>;
const NO_FRAME: u32 = 255u;
fn label(i: i32, j: i32) -> u32 {
if (i < 0 || j < 0 || i >= i32(p.seam_size.x) || j >= i32(p.seam_size.y)) {
return NO_FRAME;
}
return textureLoad(seams, vec2<i32>(i, j), 0).r;
}
// This frame's share of the seam map about output point (u, v): the
// tent-weighted fraction of the texels within the radius that it owns, and
// the weight of the texels owned by anyone (zero where the map has nothing
// to say). `SeamMap::share` verbatim.
fn seam_share(u: f32, v: f32) -> vec2<f32> {
let x = (u - p.seam_origin.x) / p.seam_px - 0.5;
let y = (v - p.seam_origin.y) / p.seam_px - 0.5;
let r = max(p.seam_radius, 1.0);
let x0 = i32(ceil(x - r));
let x1 = i32(floor(x + r));
let y0 = i32(ceil(y - r));
let y1 = i32(floor(y + r));
// Most pixels are nowhere near a seam: if the window's corners, edge
// midpoints and centre agree, so does the window. A seam crossing it
// has to cross its border, between two of those.
let xm = i32(round(x));
let ym = i32(round(y));
let c = label(xm, ym);
if (label(x0, y0) == c && label(x1, y0) == c && label(x0, y1) == c && label(x1, y1) == c
&& label(xm, y0) == c && label(xm, y1) == c && label(x0, ym) == c && label(x1, ym) == c) {
if (c == NO_FRAME) {
return vec2<f32>(0.0, 0.0);
}
return vec2<f32>(select(0.0, 1.0, c == p.frame_index), 1.0);
}
var mine = 0.0;
var owned = 0.0;
for (var j = y0; j <= y1; j = j + 1) {
let wy = 1.0 - abs(y - f32(j)) / r;
if (wy <= 0.0) {
continue;
}
for (var i = x0; i <= x1; i = i + 1) {
let wx = 1.0 - abs(x - f32(i)) / r;
let l = label(i, j);
if (wx <= 0.0 || l == NO_FRAME) {
continue;
}
owned = owned + wx * wy;
if (l == p.frame_index) {
mine = mine + wx * wy;
}
}
}
if (owned <= 0.0) {
return vec2<f32>(0.0, 0.0);
}
return vec2<f32>(mine / owned, 1.0);
}
fn to_direction(u: f32, v: f32) -> vec3<f32> {
let s = p.proj_scale;
@@ -95,7 +170,17 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
if (edge <= 0.0) {
return;
}
let w = clamp(edge / max(p.feather, 1.0), 0.0, 1.0);
var w = clamp(edge / max(p.feather, 1.0), 0.0, 1.0);
// With seams, the share of the map scales it. The small floor keeps
// the feather underneath as the answer wherever no frame that reaches
// this pixel owns it — the map is coarser than the output, so at the
// frames' outer edges it can name a frame that falls just short.
if (p.seam_on != 0u) {
let s = seam_share(u, v);
if (s.y > 0.0) {
w = w * (s.x + 1e-4);
}
}
// Into the tile.
let tx = sx - p.tile_origin.x;
let ty = sy - p.tile_origin.y;
@@ -125,7 +210,19 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
return;
}
// Colour is the alpha-weighted mean of the texels that exist.
let rgb = s.rgb / s.a * p.gain;
let cam = s.rgb / s.a;
// **A blown sample is written as grey, before the gain.** A clipped
// photosite arrives as (1, 1, 1), which is not a colour: balanced, it
// is magenta, and the develop's highlight desaturation only rescues it
// while it is still at the white level. A gain below one moved it off
// that level, and a feather mixed it into a neighbour's real sky, so
// the composite's blown clouds came out pink. Written instead as the
// camera value the balance maps to grey — the develop pipeline's own
// neutral, the brightest balanced channel — it survives both.
let clipped = smoothstep(p.clip_onset, 1.0, max(cam.r, max(cam.g, cam.b)));
let balanced = cam * p.balance.rgb;
let grey = vec3<f32>(max(balanced.r, max(balanced.g, balanced.b))) / p.balance.rgb;
let rgb = mix(cam, grey, clipped) * p.gain;
let wa = w * s.a;
let i = gid.y * p.chunk_size.x + gid.x;
acc[i] = acc[i] + vec4<f32>(rgb * wa, wa);
+1 -1
View File
@@ -3,7 +3,7 @@
//
// The shader beside this one, `histogram.wgsl`, counts the frame the display
// is about to show: an 8-bit code value, after white balance, the camera
// matrix, the base curve, the tone curve and the output transform. This one
// matrix, the tone curve, the view transform and the output transform. This one
// counts the texture the demosaic wrote, before any of that. The two differ in
// exactly one place — the axis — and everything else here is deliberately the
// same construction, because the two reductions have the same shape and any
-181
View File
@@ -1,181 +0,0 @@
//! TRACES: FR-DEV-3e
//! The camera profile's base curve, end to end on a device.
//!
//! The unit tests either side of this one check halves. `dr-decode` asserts
//! that the shipped database parses and that every curve in it lifts its
//! midtones; `dr-pipeline` asserts that the generated WGSL evaluates a curve
//! in the right place. Neither would notice if the two agreed with each other
//! and both were wrong — a curve packed into the wrong uniform slots, or a
//! flag read from the wrong component, satisfies both and renders nothing.
//!
//! So this renders real pixels twice, once with a profiled body's curve and
//! once with the identity, and asserts the difference is the one a base curve
//! is for: midtones lifted, black still black, white still white.
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::EditGraph;
const SIZE: u32 = 16;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat RGGB frame at `level` out of 65535, carrying `curve`.
///
/// Every photosite the same value, so the demosaic result is a uniform grey
/// and the only thing that can move a pixel is the curve. The colour matrix is
/// the identity and the balance is neutral for the same reason: this test is
/// about one stage, and a real body's matrix would make every assertion below
/// a statement about that body instead.
fn flat_raw(level: u16, curve: BaseCurve) -> RawImage {
RawImage {
width: SIZE,
height: SIZE,
data: vec![level; (SIZE * SIZE) as usize],
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: u16::MAX,
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: curve,
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
}
}
/// Render a neutral edit over a flat frame and return the centre pixel's red.
///
/// The centre rather than a corner: a demosaic has to invent its edges, and
/// the interpolated border of a 16×16 frame is not where anyone should be
/// reading a tone off.
fn rendered_level(ctx: &GpuContext, level: u16, curve: BaseCurve) -> u8 {
let raw = flat_raw(level, curve);
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");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
pixels[centre as usize]
}
/// The Canon EOS 6D's curve, from the shipped profile database.
///
/// Looked up by name rather than written out, so this also asserts the thing
/// no other test can: that a curve travels from the YAML, through the body
/// match, onto the decoded image and into the uniform block that the shader
/// actually reads.
fn six_d() -> BaseCurve {
let curve = dr_decode::base_curve::for_body("Canon", "EOS 6D");
assert!(
!curve.is_identity(),
"the shipped database must have a curve for the EOS 6D"
);
curve
}
#[test]
fn a_profiled_body_renders_brighter_midtones_than_a_flat_one() {
// **The whole requirement, in one assertion.** A linear midtone renders
// roughly half a stop dark, which is the flat, lifeless look FR-DEV-3e
// exists to get away from. If the curve did not reach the shader — wrong
// slot, wrong flag, wrong stage — this is the only test that would fail.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
// 13% of full scale: roughly where a camera places middle grey, leaving
// about two and a half stops of highlight headroom above it.
let level = (0.13 * 65535.0) as u16;
let flat = rendered_level(&ctx, level, BaseCurve::IDENTITY);
let profiled = rendered_level(&ctx, level, six_d());
assert!(
profiled > flat + 8,
"the profile lifted middle grey from {flat} only to {profiled}"
);
}
#[test]
fn the_curve_leaves_black_black_and_white_white() {
// A base curve renders the range between the endpoints; it must not move
// the endpoints themselves. A curve that lifted black would put a grey
// veil over every night photograph, and one that pulled white down would
// make a correctly exposed frame look underexposed.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let curve = six_d();
assert_eq!(rendered_level(&ctx, 0, curve), 0, "black moved");
assert_eq!(rendered_level(&ctx, u16::MAX, curve), 255, "white moved");
}
#[test]
fn an_unprofiled_body_renders_exactly_as_it_did_before_profiles_existed() {
// The graceful fallback, asserted as a number rather than as a promise.
// With no curve the pipeline must still be a pass-through: black level
// out, white level in, sRGB encoding on the way to the screen and nothing
// else. "Never worse than today" is the one property this change was not
// allowed to trade away, and the way it would break is silently — a flag
// read from the wrong component would apply a curve nobody asked for.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
for level in [0u16, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
let scene = f32::from(level) / f32::from(u16::MAX);
let expected = (dr_types::Transfer::Srgb.encode(scene) * 255.0).round() as i32;
let got = i32::from(rendered_level(&ctx, level, BaseCurve::IDENTITY));
// Two 8-bit steps: the texture holding the demosaiced frame is
// `Rgba16Float`, so a value round-trips through eleven mantissa bits
// before it is encoded. That is well under one step at any level, and
// the tolerance is for the rounding either side of it rather than for
// the transform being approximate.
assert!(
(got - expected).abs() <= 2,
"raw {level} rendered as {got}, expected about {expected}"
);
}
}
#[test]
fn the_curve_is_monotone_through_the_whole_range() {
// The property the spline's tangent limiting exists to guarantee, checked
// where it actually matters: on the device, through the real uniform
// packing. A curve that dipped anywhere would put a dark band across a
// smooth gradient — a sky, most visibly — and it would read as a
// rendering fault rather than as a bad profile.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let curve = six_d();
let mut previous = 0u8;
for step in 0..=16u32 {
let level = (step * 65535 / 16) as u16;
let value = rendered_level(&ctx, level, curve);
assert!(
value >= previous,
"the curve fell from {previous} to {value} at raw level {level}"
);
previous = value;
}
}
+18 -11
View File
@@ -87,8 +87,7 @@ fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render");
@@ -413,7 +412,7 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
let pipelines = pass.cached_detail_pipelines();
let allocations = pass.detail_allocations();
assert_eq!(pipelines, 2, "one per axis of the separable mask");
assert_eq!(allocations, 2, "the colour result, and one hand-off");
assert_eq!(allocations, 3, "the colour result, and the ping-pong pair");
assert_eq!(pass.detail_dispatches(), 2);
assert_eq!(pass.colour_dispatches(), 1);
@@ -453,13 +452,12 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
#[test]
fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
// The failure mode that the pass-through exists to prevent, proved on a
// device rather than argued about. With the radius finer than a render
// pixel the operation declines to sharpen — but it is still active, so the
// fused pass has already been composed to hand on unclipped linear values,
// and something must still perform the output transform. An empty chain
// here would not be a soft preview: it would be a hard error out of
// `render_detailed`, on the most ordinary develop view there is.
// Proved on a device rather than argued about. With the radius finer than
// a render pixel the operation declines to sharpen — but it is still
// active, so the fused pass has already been composed to hand on
// unclipped linear values, and something must still perform the output
// transform. Since D19 that is the view pass, whatever the chain holds:
// the chain is empty and the frame is still whole.
let Some(ctx) = ctx() else { return };
const SOURCE: u32 = 128;
const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame
@@ -471,7 +469,16 @@ fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
let mut pass = AdjustPass::new(&ctx);
let sharp = render(&mut pass, &graph, &source, RENDER);
assert_eq!(pass.detail_dispatches(), 1, "one pass, and it only encodes");
assert_eq!(
pass.detail_dispatches(),
0,
"nothing to sharpen at this scale"
);
assert_eq!(
pass.view_dispatches(),
1,
"and the view pass finishes the frame"
);
// And what reaches the screen is the unsharpened picture, not a black
// frame, a linear one, or a guess.
+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}"
);
}
+22 -10
View File
@@ -38,15 +38,17 @@ fn grey(ctx: &GpuContext) -> DemosaicedImage {
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
}
/// A pass that sums the instance list into the red channel and writes the
/// output. Deliberately trivial: the value on screen is then a direct readout
/// of what arrived in the buffer.
/// A pass that sums the instance list into the red channel and writes a
/// linear intermediate, which the view pass then encodes (D19). Deliberately
/// trivial: the value on screen is then a direct readout of what arrived in the
/// buffer, through the sRGB encode — the source is an 8-bit upload, so the
/// view transform is skipped for it and the encode is the only thing between.
fn summing_pass(storage: Vec<[f32; 4]>, structure: u64) -> ComposedDetailPass {
let source = "
@group(0) @binding(0) var source: texture_2d<f32>;
struct Params { detail_base: vec4<f32> }
@group(0) @binding(1) var<uniform> u: Params;
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(2) var output: texture_storage_2d<rgba16float, write>;
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
@compute @workgroup_size(8, 8, 1)
@@ -61,7 +63,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
for (var i = 0u; i < n; i = i + 1u) {
total = total + instances[i].x * f32(i + 1u);
}
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(total, f32(n) / 255.0, 0.0, 1.0));
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(total, f32(n) * 0.1, 0.0, 1.0));
}
"
.to_string();
@@ -73,13 +75,17 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0],
storage,
radius: 0,
writes_output: true,
// Any distinct number: the hash is a cache key, and these tests are
// what decide whether two chains share a pipeline.
structure_hash: structure,
}
}
/// A linear value as the view pass leaves it in the 8-bit output.
fn encoded(linear: f32) -> u8 {
(dr_types::Transfer::Srgb.encode(linear) * 255.0).round() as u8
}
fn render(pass: &mut AdjustPass, source: &DemosaicedImage, chain: &ComposedDetail) -> Vec<u8> {
// The fused half has to be composed knowing a detail stage follows it, or
// it encodes its own output and the chain would quantise twice — a mismatch
@@ -118,12 +124,15 @@ fn a_pass_reads_the_list_it_was_given() {
let pixels = render(&mut pass, &source, &chain);
let (red, green) = (pixels[0], pixels[1]);
// 0.05·1 + 0.1·2 = 0.25, written straight to an rgba8 target.
// 0.05·1 + 0.1·2 = 0.25.
assert!(
red.abs_diff((0.25 * 255.0) as u8) <= 1,
red.abs_diff(encoded(0.25)) <= 1,
"the shader summed {red}, not the list it was handed"
);
assert_eq!(green, 2, "arrayLength saw both entries");
assert!(
green.abs_diff(encoded(0.2)) <= 1,
"arrayLength saw both entries"
);
}
/// A convolution declares no list and must still run: it is bound to the
@@ -142,7 +151,10 @@ fn a_pass_with_no_list_still_runs() {
let pixels = render(&mut pass, &source, &chain);
assert_eq!(pixels[0], 0, "the placeholder is zeroed");
assert_eq!(pixels[1], 1, "and is exactly one element long");
assert!(
pixels[1].abs_diff(encoded(0.1)) <= 1,
"and is exactly one element long"
);
}
/// The property that makes placing the tenth spot as cheap as moving a slider:
+3 -5
View File
@@ -101,8 +101,7 @@ fn render(
let _ = ctx;
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render");
@@ -301,7 +300,7 @@ fn dragging_a_slider_recompiles_nothing_and_reallocates_nothing() {
let pipelines = pass.cached_detail_pipelines();
let allocations = pass.detail_allocations();
assert_eq!(pipelines, 2, "one per pass of the separable blur");
assert_eq!(allocations, 2, "the colour result, and one hand-off");
assert_eq!(allocations, 3, "the colour result, and the ping-pong pair");
for radius in [0.06, 0.07, 0.08, 0.09] {
graph.set_param(PROBE, RADIUS, radius);
@@ -403,8 +402,7 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
assert!(detail.is_empty());
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
+264 -8
View File
@@ -15,10 +15,13 @@
//! model is checked against the reference, and the shader is checked against
//! the CPU model.
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_film::bake::{bake, Recipe};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::ops::FilmTables;
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_film::bake::{bake, Recipe, Settings};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext, LabelField, MaskPass};
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;
const SIZE: u32 = 16;
@@ -45,7 +48,6 @@ fn flat_raw(level: u16) -> RawImage {
// Off deliberately: a film replaces the camera's rendering, and
// leaving a curve here would test the suppression rather than the
// film. `dr-pipeline` asserts the suppression on the generated source.
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1,
profile: None,
make: String::new(),
@@ -77,15 +79,31 @@ fn tables(baked: &dr_film::Baked) -> FilmTables {
/// split a grain that never left the CPU would look exactly like a passing
/// test suite.
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 {
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_max: baked.curve_log_max,
lut: baked.lut.clone(),
lut,
density_max: baked.density_max,
lut_size: baked.lut_size,
grain_particles: particles,
paper,
grain_particles: [particles; FORMAT_COUNT],
grain_density_max: [baked.density_max; 3],
grain_uniformity: 0.97,
}
@@ -241,3 +259,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}"
);
}
/// 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");
}
+141
View File
@@ -0,0 +1,141 @@
//! 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::{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]),
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})"
);
}
/// 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}"
);
}
+13 -21
View File
@@ -109,8 +109,7 @@ fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> {
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render");
@@ -683,28 +682,21 @@ fn texture_contributes_nothing_where_its_scale_does_not_exist() {
// `render_masked`, and was rejected for handing a linear-working shader
// to the plain path — so texture alone on a thumbnail did not render.
//
// The seam was closed where that note said it would have to be, at the
// composition boundary: `compose_detail` now emits a bodyless
// `detail/resolve` pass in exactly this case, which reads only the pixel
// it writes and performs the output transform the fused pass declined to
// do. So the chain is no longer empty — it carries precisely the one pass
// that finishes the render and no kernel at all, which is the honest
// description of "a two-pixel surface structure is not present in a
// 128-pixel rendering".
// The seam was closed at the composition boundary, and closed again,
// more simply, by D19: no detail pass encodes any more, the fused pass's
// view pass performs the output transform whatever the chain holds, and
// so the empty chain is a whole render. That is the honest description of
// "a two-pixel surface structure is not present in a 128-pixel
// rendering".
let scale = graph.render_scale(source.size(), (128, 128));
let composed =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
assert_eq!(
composed.len(),
1,
"the chain must carry the resolve pass and nothing else"
);
assert_eq!(composed.passes[0].label, "detail/resolve");
assert_eq!(
composed.radius(),
0,
let composed = graph.compose_detail(scale.full_size(), scale.render_size());
assert!(
composed.is_empty(),
"texture claimed a kernel it cannot draw"
);
let mut pass = AdjustPass::new(&ctx);
render(&mut pass, &graph, &source, 128);
assert_eq!(pass.view_dispatches(), 1, "the view pass still finishes it");
// With clarity on as well the edit is renderable again, and the dispatch
// count says what the assertion above says: two passes, not four. Texture
+1 -2
View File
@@ -73,8 +73,7 @@ fn render_at(
scale: RenderScale,
) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let detail =
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
.expect("render");
+196
View File
@@ -0,0 +1,196 @@
//! TRACES: FR-DEV-2 | FR-DEV-3j
//! Scene-referred until the view transform (D19, ARCH §6.14), on a device.
//!
//! The rule is about every operation between the camera matrix and the view
//! transform, so this runs each of them over a ramp that reaches sixteen
//! times sensor saturation and asserts the two things a clip or an early
//! encode would break: the output still increases with the input, and values
//! above 1.0 still differ from one another.
//!
//! A clip above 1.0 cannot be seen through an 8-bit display encode on its
//! own, so each operation is wrapped: a gain of sixteen ahead of it puts the
//! ramp into the range the rule is about, a gain of one sixty-fourth after it
//! brings the result back under 1.0 — with two stops to spare, for the
//! operations that brighten — and an identity in the view transform's
//! place stops the sigmoid compressing what is being measured. A fragment
//! that clamps, or encodes and decodes through a clamped range, flattens the
//! top of the ramp, and the last few steps come out equal.
//!
//! The view stage and the detail stage are excluded. The view transform and
//! film simulation clip into a display range because that is their job, and
//! a neighbourhood operation is a pass of its own that a flat frame cannot
//! exercise.
use std::sync::Arc;
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind};
use dr_pipeline::operation::{Operation, Stage, Uniform};
const SIZE: u32 = 16;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A gain, as a scene-stage operation, or an identity in the view stage.
struct Probe {
id: &'static str,
gain: f32,
stage: Stage,
}
impl Operation for Probe {
fn descriptor(&self) -> Arc<OpDescriptor> {
Arc::new(OpDescriptor {
id: OpId(self.id),
label: LocalizedKey(self.id),
params: Vec::new(),
attributes: vec![Attribute::Tone],
})
}
fn set_param(&mut self, _: ParamId, _: f32) {}
fn param(&self, _: ParamId) -> f32 {
0.0
}
fn is_active(&self) -> bool {
true
}
fn stage(&self) -> Stage {
self.stage
}
/// The identity view claims the view transform's place: while it is
/// active the composer emits it rather than the sigmoid.
fn renders(&self) -> bool {
self.stage == Stage::View
}
fn wgsl_body(&self) -> String {
"c = c * gain;".into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![Uniform {
name: "gain",
value: self.gain,
}]
}
}
fn probe(id: &'static str, gain: f32, stage: Stage) -> Box<dyn Operation> {
Box::new(Probe { id, gain, stage })
}
/// A flat frame at `level` of sensor saturation, identity matrix, neutral
/// balance.
fn flat(ctx: &GpuContext, level: f32) -> dr_gpu::DemosaicedImage {
let raw = RawImage {
width: SIZE,
height: SIZE,
data: vec![(level * f32::from(u16::MAX)).round() as u16; (SIZE * SIZE) as usize],
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: u16::MAX,
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]),
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
};
Demosaicer::new(ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic")
}
/// Every parameter moved off its default, a third of the way toward its
/// maximum — or toward its minimum where the default is the maximum.
///
/// The tone curve is the exception, because its neutral is a relationship:
/// its parameters are point coordinates, and moving every x and y the same
/// fraction leaves the points on the diagonal. It gets a lifted midpoint on
/// the master and on the red curve instead — the two helpers that clamped.
fn non_neutral(op: &mut dyn Operation) {
use dr_pipeline::ops::curve::{coordinate, Axis, Channel};
if op.descriptor().id == dr_pipeline::ops::curve::ID {
op.set_param(coordinate(Channel::Master, 2, Axis::Y), 0.65);
op.set_param(coordinate(Channel::Red, 2, Axis::Y), 0.6);
return;
}
for p in &op.descriptor().params {
if let ParamKind::Scalar { min, max, .. } = p.kind {
let toward = if p.default < max { max } else { min };
op.set_param(p.id, p.default + (toward - p.default) / 3.0);
}
}
}
/// The ramp, as scene values after the sixteenfold gain: 0.4 to 16.
///
/// Kept below 1.0 at the sensor, and away from its last 1.5%, because the
/// prologue's highlight desaturation fades a photosite toward neutral there —
/// a sensor fact, not an operation's, and flat grey is neutral already.
const LEVELS: [f32; 8] = [0.025, 0.05, 0.1, 0.2, 0.4, 0.6, 0.8, 0.95];
#[test]
fn scene_referred_until_the_view() {
// TRACES: FR-DEV-2 | FR-DEV-3j
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let sources: Vec<_> = LEVELS.iter().map(|&l| flat(&ctx, l)).collect();
let mut adjust = AdjustPass::new(&ctx);
let mut checked = 0;
for mut op in dr_pipeline::ops::chain() {
if op.detail().is_some() || op.stage() == Stage::View {
continue;
}
let id = op.descriptor().id.0;
non_neutral(op.as_mut());
assert!(op.is_active(), "{id}: the edit above left it neutral");
let ops = vec![
probe("probe_up", 16.0, Stage::Scene),
op,
probe("probe_down", 1.0 / 64.0, Stage::Scene),
probe("probe_view", 1.0, Stage::View),
];
let shader = dr_pipeline::compose(&ops);
assert!(
!shader.source.contains("view_sigmoid"),
"the identity must take the view transform's place"
);
let mut out = Vec::new();
for source in &sources {
adjust.render(source, &shader, SIZE, SIZE).expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let centre = (((SIZE / 2) * SIZE + SIZE / 2) * 4) as usize;
out.push([pixels[centre], pixels[centre + 1], pixels[centre + 2]]);
}
for channel in 0..3 {
let ramp: Vec<u8> = out.iter().map(|p| p[channel]).collect();
assert!(
ramp.windows(2).all(|w| w[1] >= w[0]),
"{id} is not monotone in channel {channel}: {ramp:?}"
);
// The top three levels are scene 9.6, 12.8 and 15.2: all far
// above 1.0, and a clip anywhere below them makes them equal.
let top = &ramp[LEVELS.len() - 3..];
assert!(
top[0] < top[1] && top[1] < top[2],
"{id} flattens values above 1.0 in channel {channel}: {ramp:?}"
);
}
checked += 1;
}
assert!(checked >= 10, "only {checked} operations were checked");
}
+208
View File
@@ -0,0 +1,208 @@
//! TRACES: FR-DSP-2 | NFR-RES-2
//! A photograph larger than one texture, developed from windows of it.
//!
//! The claim under test is that the window is invisible: a frame rendered a
//! tile at a time, each tile from only the part of the source it reads, is the
//! frame rendered whole. `dr-pipeline` can check the plan — the tiles cover
//! the frame once, each is grown by the reach — but not that the shader's
//! mapping into a window lands on the texel the whole texture would have
//! given, which only a device answers.
//!
//! The frames here are small and the "device limit" is a number passed in,
//! so the tiling is exercised on any adapter, including one whose real limit
//! a test image could never approach.
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::descriptor::{OpId, ParamId};
use dr_pipeline::framing::ANGLE;
use dr_pipeline::{tiles, Affects, EditGraph};
use dr_types::ColourSpace;
fn ctx() -> Option<GpuContext> {
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
/// A linear RGB frame with detail at every scale: a slow gradient for the
/// tone controls and a hash for the kernels, so a tile that read one pixel
/// off would show.
fn linear_frame(w: u32, h: u32, noise: bool) -> RawImage {
let mut data = Vec::with_capacity((w * h * 3) as usize);
for y in 0..h {
for x in 0..w {
let base = 4000.0 + 30000.0 * (x as f32 / w as f32) + 12000.0 * (y as f32 / h as f32);
let hash = if noise {
((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 8000) as f32
} else {
0.0
};
for c in 0..3 {
data.push((base * (0.7 + 0.15 * c as f32) + hash) as u16);
}
}
}
RawImage {
width: w,
height: h,
data,
cfa_pattern: CfaPattern::Unknown,
black_level: [512; 4],
white_level: 65535,
wb_coeffs: [2.0, 1.0, 1.5, 1.0],
color_matrix: Some([1.6, -0.5, -0.1, -0.2, 1.4, -0.2, 0.0, -0.4, 1.4]),
samples_per_pixel: 3,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: w,
height: h,
},
}
}
/// Render `graph` over `source` at `size` and read it back.
fn render(
pass: &mut AdjustPass,
graph: &EditGraph,
source: &DemosaicedImage,
size: (u32, u32),
) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let detail = graph.compose_detail(source.size(), size);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, size.0, size.1, None, &detail, key)
.expect("render");
pass.export_pixels().expect("readback").0
}
/// The frame at full resolution, a tile at a time, each from its own window.
fn render_tiled(
ctx: &GpuContext,
pass: &mut AdjustPass,
graph: &mut EditGraph,
raw: &RawImage,
max_edge: u32,
) -> (Vec<u8>, usize) {
let frame = (raw.crop.width, raw.crop.height);
let out = graph.output_size(frame.0, frame.1);
let reach = graph.compose_detail(frame, out).reach();
let plan = tiles::plan(out, max_edge, reach).expect("a plan");
let mut pixels = vec![0u8; (out.0 * out.1 * 4) as usize];
for t in &plan {
graph.framing_mut().set_view(t.view(out));
let r = graph.source_region(frame, 0);
let x0 = (r.x * frame.0 as f32).floor() as u32;
let y0 = (r.y * frame.1 as f32).floor() as u32;
let x1 = ((r.x + r.width) * frame.0 as f32).ceil() as u32;
let y1 = ((r.y + r.height) * frame.1 as f32).ceil() as u32;
let window = DemosaicedImage::linear_rgb16_window(ctx, raw, [x0, y0, x1 - x0, y1 - y0], 1)
.expect("window");
assert_eq!(window.size(), frame, "a window measures the frame");
let tile = render(pass, graph, &window, (t.grown[2], t.grown[3]));
let (ox, oy) = t.keep_offset();
for row in 0..t.keep[3] {
let src = (((oy + row) * t.grown[2] + ox) * 4) as usize;
let dst = (((t.keep[1] + row) * out.0 + t.keep[0]) * 4) as usize;
let n = (t.keep[2] * 4) as usize;
pixels[dst..dst + n].copy_from_slice(&tile[src..src + n]);
}
}
graph
.framing_mut()
.set_view(dr_pipeline::CropRect::default());
(pixels, plan.len())
}
fn largest_difference(a: &[u8], b: &[u8]) -> u8 {
a.iter()
.zip(b)
.map(|(x, y)| x.abs_diff(*y))
.max()
.unwrap_or(0)
}
#[test]
fn tiles_of_windows_are_the_whole_frame() {
// Point operations only, unrotated: every output pixel is an exact load
// of one source texel, so the tiled frame has to be the whole one to
// the bit.
let Some(ctx) = ctx() else { return };
let raw = linear_frame(200, 120, true);
let mut graph = EditGraph::default_chain();
graph.set_param(OpId("exposure"), ParamId("exposure"), 0.7);
let mut pass = AdjustPass::new(&ctx);
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
assert!(whole.is_whole());
let reference = render(&mut pass, &graph, &whole, (200, 120));
let (tiled, n) = render_tiled(&ctx, &mut pass, &mut graph, &raw, 64);
assert!(n > 4, "the frame should have been cut, got {n} tile(s)");
assert_eq!(largest_difference(&reference, &tiled), 0);
}
#[test]
fn a_straightened_frame_with_clarity_tiles_without_seams() {
// The hard case: a free angle samples between texels, and clarity reads
// a wide neighbourhood on a reduced grid. The halo and the grid
// alignment are what keep the tiles' edges out of the picture; a code
// value of rounding is all that may differ.
let Some(ctx) = ctx() else { return };
let raw = linear_frame(320, 208, true);
let mut graph = EditGraph::default_chain();
graph.set_param(OpId("clarity"), ParamId("amount"), 60.0);
graph.framing_mut().set_param(ANGLE, 3.0);
let mut pass = AdjustPass::new(&ctx);
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
let out = graph.output_size(320, 208);
let reference = render(&mut pass, &graph, &whole, out);
let (tiled, n) = render_tiled(&ctx, &mut pass, &mut graph, &raw, 160);
assert!(n > 1, "the frame should have been cut, got {n} tile(s)");
let worst = largest_difference(&reference, &tiled);
assert!(
worst <= 1,
"tiles differ from the whole frame by {worst} code values"
);
}
#[test]
fn a_reduced_copy_stands_for_the_whole_frame() {
// The canvas at fit renders from a copy reduced to fit the device. It
// must measure the photograph, not itself, or a crop drawn on it lands
// somewhere else in the export; and rendered small it must look like the
// full frame rendered small.
let Some(ctx) = ctx() else { return };
let raw = linear_frame(400, 240, false);
let mut graph = EditGraph::default_chain();
graph.set_crop(dr_pipeline::CropRect {
x: 0.25,
y: 0.1,
width: 0.5,
height: 0.6,
});
let mut pass = AdjustPass::new(&ctx);
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
let reduced = DemosaicedImage::linear_rgb16_window(&ctx, &raw, [0, 0, 400, 240], 3).unwrap();
assert_eq!(reduced.size(), (400, 240));
assert_eq!(reduced.texture_size(), (134, 80));
assert!(!reduced.is_whole());
let size = (50, 36);
let a = render(&mut pass, &graph, &whole, size);
let b = render(&mut pass, &graph, &reduced, size);
let worst = largest_difference(&a, &b);
assert!(
worst <= 3,
"the reduced copy renders {worst} code values away"
);
}
+1 -1
View File
@@ -72,7 +72,7 @@ fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, ou
let shader = graph.compose_for(ColourSpace::Srgb);
let (w, h) = graph.output_size(source.size().0, source.size().1);
let (w, h) = (w.min(out), h.min(out));
let detail = graph.compose_detail_for(source.size(), (w, h), ColourSpace::Srgb);
let detail = graph.compose_detail(source.size(), (w, h));
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, w, h, None, &detail, key)
.expect("render");
+178
View File
@@ -0,0 +1,178 @@
//! TRACES: FR-DEV-3j | FR-DEV-2
//! The view transform, end to end on a device.
//!
//! `dr-pipeline` checks the curve on the CPU and that the composer emits it in
//! the right place. Neither would notice a shader that disagreed with the CPU
//! reference, or a clamp somewhere upstream that made two highlights the same
//! number before the curve ever saw them — which is exactly what the retired
//! base curve did, and why D19 exists. So this renders real pixels.
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::view::Sigmoid;
use dr_pipeline::EditGraph;
const SIZE: u32 = 16;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat RGGB frame at `level` out of 65535, with an identity matrix and a
/// neutral balance, so the only things that move a pixel are the edit and the
/// view transform.
fn flat_raw(level: u16) -> RawImage {
RawImage {
width: SIZE,
height: SIZE,
data: vec![level; (SIZE * SIZE) as usize],
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: u16::MAX,
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]),
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
}
}
/// Render `graph` over a flat frame and return the centre pixel's red.
///
/// The centre rather than a corner: a demosaic has to invent its edges.
fn rendered(ctx: &GpuContext, level: u16, graph: &EditGraph) -> u8 {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(&flat_raw(level))
.expect("demosaic");
let shader = graph.compose();
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
pixels[centre as usize]
}
#[test]
fn the_shader_agrees_with_the_cpu_reference() {
// TRACES: FR-DEV-3j
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let curve = Sigmoid::default_curve();
let graph = EditGraph::default_chain();
for level in [0u16, 500, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
let scene = f32::from(level) / f32::from(u16::MAX);
let display = curve.channel(scene).min(1.0);
let expected = (dr_types::Transfer::Srgb.encode(display) * 255.0).round() as i32;
let got = i32::from(rendered(&ctx, level, &graph));
// Two 8-bit steps, for the `Rgba16Float` intermediate and the
// rounding either side of the encode.
assert!(
(got - expected).abs() <= 2,
"raw {level} rendered as {got}, expected about {expected}"
);
}
}
#[test]
fn highlights_above_one_stay_distinct() {
// TRACES: FR-DEV-2 | FR-DEV-3j
// The failure D19 names first. Two stops of exposure put these two
// frames at 1.0 and 1.5 of sensor saturation. The base curve was flat
// past 1.0, so both rendered as the same white; the view transform's
// shoulder still separates them.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let mut graph = EditGraph::default_chain();
graph.set_param(
dr_pipeline::ops::exposure::ID,
dr_pipeline::ops::exposure::EXPOSURE,
2.0,
);
let lower = rendered(&ctx, u16::MAX / 4, &graph);
let upper = rendered(&ctx, (u16::MAX / 8) * 3, &graph);
assert!(
upper > lower,
"scene 1.0 rendered {lower} and scene 1.5 rendered {upper}"
);
assert!(upper < 255, "scene 1.5 is below the default white point");
}
#[test]
fn the_rendering_is_monotone_through_the_whole_range() {
// TRACES: FR-DEV-3j
// A dip anywhere puts a dark band across a smooth gradient — a sky, most
// visibly.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let graph = EditGraph::default_chain();
let mut last = 0u8;
for step in 0..=32u32 {
let level = (step * u32::from(u16::MAX) / 32) as u16;
let got = rendered(&ctx, level, &graph);
assert!(got >= last, "raw {level} rendered {got}, below {last}");
last = got;
}
}
/// Render `graph` over a flat frame through `render_detailed`, the path every
/// frontend takes, and return the centre pixel's red.
fn rendered_detailed(ctx: &GpuContext, level: u16, graph: &EditGraph) -> (u8, AdjustPass) {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(&flat_raw(level))
.expect("demosaic");
let shader = graph.compose_for(dr_types::ColourSpace::Srgb);
let detail = graph.compose_detail(source.size(), (SIZE, SIZE));
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
let mut adjust = AdjustPass::new(ctx);
adjust
.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, key)
.expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
(pixels[centre as usize], adjust)
}
#[test]
fn a_detail_stage_renders_through_the_view_pass_unchanged() {
// TRACES: FR-DEV-3j | FR-DEV-2
// With a detail stage the view transform is a dispatch of its own after
// it (D19). Sharpening a flat field changes nothing, so the same frame
// with and without it must render the same: the view pass read the detail
// stage's result, applied the view transform once, and encoded once.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let plain = rendered(&ctx, 8_520, &EditGraph::default_chain());
let mut sharpened = EditGraph::default_chain();
let id = dr_pipeline::ops::capture_sharpen::ID;
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::AMOUNT, 100.0);
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::RADIUS, 1.0);
let (detailed, pass) = rendered_detailed(&ctx, 8_520, &sharpened);
assert!(
pass.detail_dispatches() > 0,
"the premise: a detail stage ran"
);
assert_eq!(pass.view_dispatches(), 1);
assert!(
detailed.abs_diff(plain) <= 1,
"with a detail stage {detailed}, without {plain}"
);
}
+142 -6
View File
@@ -12,6 +12,11 @@
//! best-connected frame; rotations chained along it.
//! 5. Bundle adjustment over every link's inliers (`bundle`).
//!
//! Steps 1 and 2 are [`match_pairs`] and most of the time; 3 to 5 are
//! [`solve`], which takes a subset of the frames. Leaving a frame out is
//! then a solve over the pairs already measured — the same links, not a
//! fresh RANSAC whose seeds would move with the frames' positions.
//!
//! What it refuses to do is guess. A frame the tree does not reach is
//! reported by index with the reason (FR-MRG-5) and left out of the
//! cameras; the caller decides whether a set with a hole is worth
@@ -125,13 +130,45 @@ impl Alignment {
}
}
/// Align a set of frames from their features.
/// Every pair of a set measured: steps 1 and 2, the expensive part, kept
/// so that a solve over a subset reuses it.
#[derive(Debug, Clone, PartialEq)]
pub struct Pairs {
/// Each frame's long edge, for the focal length's clamp.
long_edges: Vec<f64>,
/// Pairs with enough matches to try a geometry, whether or not it held.
matched: Vec<(usize, usize)>,
links: Vec<Link>,
/// Every link's inliers, in pixels, centred.
observations: Vec<Observation>,
}
impl Pairs {
/// How many frames were measured.
pub fn len(&self) -> usize {
self.long_edges.len()
}
pub fn is_empty(&self) -> bool {
self.long_edges.is_empty()
}
}
/// Align a set of frames from their features: [`match_pairs`], then
/// [`solve`] over all of them.
///
/// Every `Features` must be in its own frame's pixel coordinates with the
/// image size filled in; points are centred on the image centre here. The
/// frames must all come from the same lens at the same focal length, which
/// is the panorama assumption and not checked — the caller has the EXIF.
pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, PanoError> {
let pairs = match_pairs(frames, opts)?;
solve(&pairs, &vec![true; frames.len()], opts)
}
/// Steps 1 and 2: every pair matched, and a robust homography for each
/// pair with enough matches.
pub fn match_pairs(frames: &[Features], opts: &AlignOptions) -> Result<Pairs, PanoError> {
let n = frames.len();
if n < 2 {
return Err(PanoError::Input(
@@ -156,7 +193,7 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
// 1 + 2: every pair.
let mut links = Vec::new();
let mut observations: Vec<Observation> = Vec::new();
let mut matched_any = vec![false; n];
let mut matched = Vec::new();
let t_match = std::time::Instant::now();
for i in 0..n {
for j in i + 1..n {
@@ -165,8 +202,7 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
if matches.len() < 4 {
continue;
}
matched_any[i] = true;
matched_any[j] = true;
matched.push((i, j));
let pairs: Vec<((f64, f64), (f64, f64))> = matches
.iter()
.map(|m| {
@@ -214,6 +250,69 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
}
log::debug!("matching and pairwise geometry in {:?}", t_match.elapsed());
Ok(Pairs {
long_edges: frames
.iter()
.map(|f| f.width.max(f.height) as f64)
.collect(),
matched,
links,
observations,
})
}
/// Steps 3 to 5 over the frames `keep` marks, from pairs already measured.
///
/// The result is indexed by the kept frames in order: its frame `k` is the
/// `k`-th frame `keep` marks. Only pairs whose frames are both kept take
/// part, so a frame whose only overlap was with one left out is reported
/// as unaligned, as it would be had it never been measured with it.
pub fn solve(pairs: &Pairs, keep: &[bool], opts: &AlignOptions) -> Result<Alignment, PanoError> {
if keep.len() != pairs.len() {
return Err(PanoError::Input(format!(
"{} flags for {} frames",
keep.len(),
pairs.len()
)));
}
// Input index to the solve's.
let mut slot = vec![None; keep.len()];
let mut n = 0usize;
for (k, &kept) in keep.iter().enumerate() {
if kept {
slot[k] = Some(n);
n += 1;
}
}
if n < 2 {
return Err(PanoError::Input(
"a panorama needs at least two frames".into(),
));
}
let both = |i: usize, j: usize| Some((slot[i]?, slot[j]?));
let mut matched_any = vec![false; n];
for &(i, j) in &pairs.matched {
if let Some((i, j)) = both(i, j) {
matched_any[i] = true;
matched_any[j] = true;
}
}
let links: Vec<Link> = pairs
.links
.iter()
.filter_map(|l| {
let (i, j) = both(l.i, l.j)?;
Some(Link { i, j, ..l.clone() })
})
.collect();
let observations: Vec<Observation> = pairs
.observations
.iter()
.filter_map(|o| {
let (i, j) = both(o.i, o.j)?;
Some(Observation { i, j, ..*o })
})
.collect();
// 3: the focal length.
let mut estimates: Vec<f64> = links
@@ -221,9 +320,12 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
.filter_map(|l| homography::focal_from_homography(&l.h))
.filter(|f| f.is_finite() && *f > 0.0)
.collect();
let longest = frames
let longest = pairs
.long_edges
.iter()
.map(|f| f.width.max(f.height) as f64)
.zip(keep)
.filter(|(_, &kept)| kept)
.map(|(&e, _)| e)
.fold(0.0, f64::max);
let focal = if !estimates.is_empty() {
estimates.sort_by(f64::total_cmp);
@@ -448,6 +550,40 @@ mod tests {
assert!(out.rotations[..3].iter().all(Option::is_some));
}
#[test]
fn a_frame_left_out_is_solved_without_measuring_again() {
let (frames, truth) = synthetic_sweep(6, 0.3, 1400.0, 1024, 768);
let opts = AlignOptions::default();
let pairs = match_pairs(&frames, &opts).expect("measured");
// The first frame left out: five cameras, indexed as the kept
// frames, and the links among them only.
let keep = [false, true, true, true, true, true];
let out = solve(&pairs, &keep, &opts).expect("solved");
assert!(out.is_complete(), "unaligned: {:?}", out.unaligned);
assert_eq!(out.rotations.len(), 5);
assert_eq!(out.links.len(), 4 + 3, "links: {}", out.links.len());
let root = out
.rotations
.iter()
.position(|r| *r == Some(Mat3::IDENTITY))
.unwrap();
for k in 0..5 {
let rel_truth = truth.rotations[root + 1].transpose() * truth.rotations[k + 1];
let err = angle_between(rel_truth, out.rotations[k].unwrap());
assert!(err < 2e-3, "frame {k} off by {err} rad");
}
// A frame in the middle left out splits the sweep only if nothing
// spans the gap; at 0.3 rad steps its neighbours still overlap.
let keep = [true, true, false, true, true, true];
let out = solve(&pairs, &keep, &opts).expect("solved");
assert!(out.is_complete(), "unaligned: {:?}", out.unaligned);
// And the whole set solved from the pairs is `align`'s answer.
assert_eq!(
solve(&pairs, &[true; 6], &opts).expect("solved"),
align(&frames, &opts).expect("aligned")
);
}
#[test]
fn one_frame_is_refused() {
let (frames, _) = synthetic_sweep(1, 0.3, 1400.0, 640, 480);
+4 -1
View File
@@ -22,6 +22,7 @@
//! - [`align`] — the whole thing, from features to cameras, honest about
//! what it could not place.
//! - [`projection`] — perspective, cylindrical, spherical.
//! - [`seam`] — which frame each output pixel is taken from.
//! - [`linalg`] — the small dense algebra all of it uses.
//!
//! # What it depends on
@@ -43,15 +44,17 @@ pub mod matching;
#[cfg(feature = "xfeat")]
pub mod migan;
pub mod projection;
pub mod seam;
#[cfg(feature = "xfeat")]
pub mod xfeat;
pub use align::{align, AlignOptions, Alignment, Link, Unaligned};
pub use align::{align, match_pairs, solve, AlignOptions, Alignment, Link, Pairs, Unaligned};
pub use bundle::Cameras;
pub use features::{Features, Keypoint};
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
pub use image::Gray;
pub use projection::Projection;
pub use seam::{SeamMap, SeamOptions};
#[derive(Debug, thiserror::Error)]
pub enum PanoError {
+691
View File
@@ -0,0 +1,691 @@
//! TRACES: FR-MRG-10
//! Where each frame gives way to the next.
//!
//! The first merges averaged every overlap: each frame weighted by its
//! distance from its own edge, so that across two hundred pixels one frame
//! faded into the other. That hides an exposure step and does not hide
//! anything that differs between the frames — parallax on a near slope, a
//! walker, a branch in the wind — which the average draws twice, half as
//! bright, a soft double edge at 1:1.
//!
//! A seam answers it the way every stitcher does: in an overlap, each output
//! pixel is taken from *one* frame, and the line where the choice changes is
//! put where the frames agree and the picture is smooth — through sky,
//! along a shadow, round the walker rather than through him — and away from
//! either frame's edge, where vignetting and the lens correction's fringe
//! live. The blend is then narrow and only across that line.
//!
//! # How
//!
//! At proxy resolution, on the output surface, which fits (panorama.md §5:
//! "it is a mask, not an image"):
//!
//! 1. Frames are laid down one at a time, each next to one already placed.
//! The composite so far is a label per texel and the value its owner saw.
//! 2. Where a new frame overlaps the composite, a cost per texel: the
//! difference between the two (after the gains), how much detail either
//! has there, and how near either frame's edge it is — smoothed over a
//! few texels, because "agree" means locally, not at one pixel.
//! 3. The cut is a path across the overlap, perpendicular to the line from
//! the composite's frames to the new one, found by dynamic programming
//! one row at a time: the per-column seam panorama.md §4 chose over a
//! graph cut because it is the GPU-friendly shape. Texels on the new
//! frame's side of the path become its own.
//!
//! What the merge reads is [`SeamMap::share`]: the fraction of a small
//! window about a point that is labelled with a frame, tent-weighted, which
//! is a narrow blend that follows the seam. `merge.wgsl` computes the same
//! thing on the GPU from the same labels.
use crate::bundle::Cameras;
use crate::image::Gray;
use crate::projection::{self, Projection};
/// No frame owns this texel.
pub const NONE: u8 = 255;
/// The most frames a map can label: one less than [`NONE`].
pub const MAX_FRAMES: usize = NONE as usize;
/// Which frame each texel of the output takes its pixels from.
#[derive(Debug, Clone, PartialEq)]
pub struct SeamMap {
pub width: usize,
pub height: usize,
/// The projection scale the map was laid out at: the proxies' focal
/// length. Output coordinates at any other scale are this times the
/// ratio of the scales.
pub scale: f64,
/// Centred output coordinates, at `scale`, of texel (0, 0)'s top-left
/// corner.
pub origin: (f64, f64),
/// Output units per texel, at `scale`.
pub px: f64,
/// Row-major, one per texel: the frame's index, or [`NONE`].
pub labels: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SeamOptions {
/// The widest the map is laid out, in texels. Wider than the proxies'
/// own resolution buys nothing.
pub max_width: usize,
/// How much detail costs against disagreement: a seam through texture
/// shows even where the frames agree, because the blend across it
/// softens it.
pub detail: f32,
/// How much a frame's edge costs, and how far in from it the cost
/// reaches, in proxy pixels. Frame edges are where vignetting is
/// darkest and the lens correction ran out of sensor.
pub edge: f32,
pub edge_margin: f32,
/// The radius, in texels, a texel's cost looks about it for the worst
/// of its neighbours: at least the radius the merge blends across.
pub smoothing: usize,
}
impl Default for SeamOptions {
fn default() -> Self {
SeamOptions {
max_width: 2048,
detail: 0.5,
edge: 0.5,
edge_margin: 24.0,
smoothing: 4,
}
}
}
/// The most texels a blend reaches either side of a seam. The merge's
/// shader loads the square of twice this per pixel per frame near a seam.
pub const MAX_BLEND_RADIUS: f64 = 4.0;
/// Cost of a texel outside the overlap: high enough that the path keeps to
/// the overlap wherever there is one, finite so that a row with a gap in it
/// still has an answer.
const OUTSIDE: f32 = 1.0e3;
impl SeamMap {
/// The map's origin and texel size in the coordinates of an output
/// laid out at `scale` (the full-resolution focal length, or a fraction
/// of it).
pub fn at_scale(&self, scale: f64) -> ((f64, f64), f64) {
let r = scale / self.scale;
((self.origin.0 * r, self.origin.1 * r), self.px * r)
}
/// The radius, in texels, of a blend `blend_px` output pixels wide in an
/// output laid out at `scale`: what [`Self::share`] and the shader are
/// given, so that the preview and the merge blend alike.
pub fn blend_radius(&self, scale: f64, blend_px: f64) -> f64 {
let (_, px) = self.at_scale(scale);
(blend_px / 2.0 / px).clamp(1.0, MAX_BLEND_RADIUS)
}
/// The share frame `k` has of output point `(u, v)` given at `scale`:
/// the tent-weighted fraction of the texels within `radius` (in texels)
/// that it owns. `None` where no texel in reach is owned at all — the
/// map has nothing to say there, and the caller falls back to its
/// feather.
///
/// This is the function `merge.wgsl`'s `seam_share` repeats; the two
/// must agree.
pub fn share(&self, k: usize, u: f64, v: f64, scale: f64, radius: f64) -> Option<f32> {
let ((ou, ov), px) = self.at_scale(scale);
let x = (u - ou) / px - 0.5;
let y = (v - ov) / px - 0.5;
let r = radius.max(1.0);
let (x0, x1) = ((x - r).ceil() as i64, (x + r).floor() as i64);
let (y0, y1) = ((y - r).ceil() as i64, (y + r).floor() as i64);
let (mut mine, mut all) = (0.0f64, 0.0f64);
for j in y0.max(0)..=y1.min(self.height as i64 - 1) {
let wy = 1.0 - (y - j as f64).abs() / r;
if wy <= 0.0 {
continue;
}
for i in x0.max(0)..=x1.min(self.width as i64 - 1) {
let wx = 1.0 - (x - i as f64).abs() / r;
if wx <= 0.0 {
continue;
}
let l = self.labels[j as usize * self.width + i as usize];
if l == NONE {
continue;
}
all += wx * wy;
if usize::from(l) == k {
mine += wx * wy;
}
}
}
(all > 0.0).then(|| (mine / all) as f32)
}
}
/// One frame warped onto the map: its gain-corrected value and its distance
/// from its own edge (in proxy pixels) per texel, NaN where it does not
/// reach.
struct Warped {
value: Vec<f32>,
edge: Vec<f32>,
}
/// Lay seams across the overlaps of `proxies`, aligned by `cameras` (at the
/// proxies' scale), with `gains` the linear multipliers the merge will
/// apply. `None` if the frames project nowhere or there are more than
/// [`MAX_FRAMES`].
pub fn find(
proxies: &[&Gray],
cameras: &Cameras,
gains: &[f32],
projection: Projection,
opts: &SeamOptions,
) -> Option<SeamMap> {
let n = proxies.len();
if n == 0 || n > MAX_FRAMES || cameras.rotations.len() != n || gains.len() != n {
return None;
}
let (fw, fh) = (proxies[0].width as f64, proxies[0].height as f64);
let scale = cameras.focal;
let bounds = projection::bounds(projection, scale, cameras, (fw, fh))?;
let width = opts.max_width.min(bounds.width().ceil() as usize).max(1);
let px = bounds.width() / width as f64;
let height = ((bounds.height() / px).ceil() as usize).max(1);
let mut map = SeamMap {
width,
height,
scale,
origin: (bounds.min_u, bounds.min_v),
px,
labels: vec![NONE; width * height],
};
// Where each frame's centre lands, in texels: what orders the frames
// and orients each cut.
let centres: Vec<(f64, f64)> = (0..n)
.map(|k| {
let d = cameras.bearing(k, (0.0, 0.0));
projection
.from_direction(scale, d)
.map(|(u, v)| ((u - bounds.min_u) / px, (v - bounds.min_v) / px))
.unwrap_or((width as f64 / 2.0, height as f64 / 2.0))
})
.collect();
// The composite so far: what its owner saw, and how far from the
// owner's edge.
let mut value = vec![f32::NAN; width * height];
let mut edge = vec![f32::NAN; width * height];
for k in order(&centres, (width as f64 / 2.0, height as f64 / 2.0)) {
let w = warp(&map, proxies[k], cameras, k, gains[k], projection);
let overlap: Vec<usize> = (0..width * height)
.filter(|&i| map.labels[i] != NONE && !w.value[i].is_nan())
.collect();
// Texels nobody owns yet are the new frame's without a cut.
let mut take: Vec<bool> = map
.labels
.iter()
.zip(&w.value)
.map(|(&l, v)| l == NONE && !v.is_nan())
.collect();
if !overlap.is_empty() {
cut(
&map, &value, &edge, &w, &overlap, &centres, k, opts, &mut take,
);
}
for i in 0..width * height {
if take[i] {
map.labels[i] = k as u8;
value[i] = w.value[i];
edge[i] = w.edge[i];
}
}
}
Some(map)
}
/// The order frames are laid down in: the one nearest the middle first,
/// then always the unplaced frame nearest any placed one, so that each new
/// frame meets the composite along an overlap rather than across a gap.
fn order(centres: &[(f64, f64)], middle: (f64, f64)) -> Vec<usize> {
let d2 = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2);
let n = centres.len();
let mut placed = vec![false; n];
let mut out = Vec::with_capacity(n);
let first = (0..n)
.min_by(|&a, &b| d2(centres[a], middle).total_cmp(&d2(centres[b], middle)))
.expect("at least one frame");
placed[first] = true;
out.push(first);
while out.len() < n {
let next = (0..n)
.filter(|&k| !placed[k])
.min_by(|&a, &b| {
let near = |k: usize| {
out.iter()
.map(|&p| d2(centres[k], centres[p]))
.fold(f64::MAX, f64::min)
};
near(a).total_cmp(&near(b))
})
.expect("an unplaced frame");
placed[next] = true;
out.push(next);
}
out
}
/// Frame `k` sampled at every texel's centre, bilinearly. The proxy is
/// gamma-encoded grey, so the gain (linear) becomes `gain^(1/2.2)` on it.
fn warp(
map: &SeamMap,
g: &Gray,
cameras: &Cameras,
k: usize,
gain: f32,
projection: Projection,
) -> Warped {
let (fw, fh) = (g.width as f64, g.height as f64);
let gain = gain.max(1e-6).powf(1.0 / 2.2);
let mut value = vec![f32::NAN; map.width * map.height];
let mut edge = vec![f32::NAN; map.width * map.height];
for ty in 0..map.height {
let v = map.origin.1 + (ty as f64 + 0.5) * map.px;
for tx in 0..map.width {
let u = map.origin.0 + (tx as f64 + 0.5) * map.px;
let d = projection.to_direction(map.scale, u, v);
let Some((x, y)) = cameras.project(k, d) else {
continue;
};
let (x, y) = (x + fw / 2.0 - 0.5, y + fh / 2.0 - 0.5);
let e = x.min(fw - 1.0 - x).min(y).min(fh - 1.0 - y);
if e < 0.0 {
continue;
}
let (x0, y0) = (x.floor() as usize, y.floor() as usize);
let (x1, y1) = ((x0 + 1).min(g.width - 1), (y0 + 1).min(g.height - 1));
let (ax, ay) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
let at = |xx: usize, yy: usize| g.data[yy * g.width + xx];
let top = at(x0, y0) * (1.0 - ax) + at(x1, y0) * ax;
let bot = at(x0, y1) * (1.0 - ax) + at(x1, y1) * ax;
let i = ty * map.width + tx;
value[i] = (top * (1.0 - ay) + bot * ay) * gain;
edge[i] = e as f32;
}
}
Warped { value, edge }
}
/// Central-difference gradient magnitude of `plane` at texel `i`, from the
/// neighbours that exist.
fn detail(plane: &[f32], width: usize, height: usize, i: usize) -> f32 {
let (x, y) = (i % width, i / width);
let c = plane[i];
let mut g = 0.0f32;
let mut diff = |j: usize| {
let n = plane[j];
if !n.is_nan() {
g = g.max((n - c).abs());
}
};
if x > 0 {
diff(i - 1);
}
if x + 1 < width {
diff(i + 1);
}
if y > 0 {
diff(i - width);
}
if y + 1 < height {
diff(i + width);
}
g
}
/// Cut the overlap between the composite and frame `k`, marking in `take`
/// the overlap texels that go to `k`.
#[allow(clippy::too_many_arguments)]
fn cut(
map: &SeamMap,
value: &[f32],
edge: &[f32],
new: &Warped,
overlap: &[usize],
centres: &[(f64, f64)],
k: usize,
opts: &SeamOptions,
take: &mut [bool],
) {
let (w, h) = (map.width, map.height);
// The raw cost per overlap texel.
let mut raw = vec![f32::NAN; w * h];
let margin = opts.edge_margin.max(1.0);
for &i in overlap {
let differ = (value[i] - new.value[i]).abs();
let detail = detail(value, w, h, i).max(detail(&new.value, w, h, i));
let near = (1.0 - edge[i].min(new.edge[i]) / margin).max(0.0);
raw[i] = differ + opts.detail * detail + opts.edge * near * near + 1e-3;
}
// The worst over a small window: a texel is only cheap if its whole
// neighbourhood agrees, so the path keeps at least the blend's radius
// clear of a difference rather than threading the one lucky texel
// beside it — the blend straddles the path by that much and would
// otherwise reach the difference anyway.
let r = opts.smoothing as isize;
let mut cost = vec![OUTSIDE; w * h];
for &i in overlap {
let (x, y) = ((i % w) as isize, (i / w) as isize);
let mut worst = 0.0f32;
for dy in -r..=r {
for dx in -r..=r {
let (xx, yy) = (x + dx, y + dy);
if xx < 0 || yy < 0 || xx >= w as isize || yy >= h as isize {
continue;
}
let c = raw[yy as usize * w + xx as usize];
if !c.is_nan() {
worst = worst.max(c);
}
}
}
cost[i] = worst;
}
// The axis the cut crosses: from the composite's frames, weighted by how
// much of the overlap each owns, to the new frame.
let mut from = (0.0f64, 0.0f64);
for &i in overlap {
let c = centres[usize::from(map.labels[i])];
from = (from.0 + c.0, from.1 + c.1);
}
let m = overlap.len() as f64;
from = (from.0 / m, from.1 / m);
let to = centres[k];
let (mut ax, mut ay) = (to.0 - from.0, to.1 - from.1);
let len = (ax * ax + ay * ay).sqrt();
if len < 1e-6 {
(ax, ay) = (1.0, 0.0);
} else {
(ax, ay) = (ax / len, ay / len);
}
// Along the cut: perpendicular to the axis.
let (bx, by) = (-ay, ax);
// The overlap's extent in (s along the cut, t across it).
let st = |i: usize| {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
(x * bx + y * by, x * ax + y * ay)
};
let (mut s0, mut s1, mut t0, mut t1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
for &i in overlap {
let (s, t) = st(i);
s0 = s0.min(s);
s1 = s1.max(s);
t0 = t0.min(t);
t1 = t1.max(t);
}
let rows = (s1 - s0).round() as usize + 1;
let cols = (t1 - t0).round() as usize + 1;
// The grid in (s, t), each cell sampled from the texel it falls in, so
// that a rotated overlap has no holes.
let mut grid = vec![OUTSIDE; rows * cols];
let mut any = vec![false; rows];
for si in 0..rows {
for ti in 0..cols {
let (s, t) = (s0 + si as f64, t0 + ti as f64);
let x = s * bx + t * ax;
let y = s * by + t * ay;
if x < 0.0 || y < 0.0 {
continue;
}
let (x, y) = (x as usize, y as usize);
if x >= w || y >= h {
continue;
}
let c = cost[y * w + x];
if c < OUTSIDE {
grid[si * cols + ti] = c;
any[si] = true;
}
}
}
// Dynamic programming down the rows: the path moves at most one column
// per row, and starts afresh after a row with no overlap in it.
let mut acc = grid.clone();
let mut from_col = vec![0u32; rows * cols];
for si in 1..rows {
if !any[si] {
continue;
}
let prev = &acc[(si - 1) * cols..si * cols].to_vec();
if !any[si - 1] {
continue;
}
for ti in 0..cols {
let mut best = (prev[ti], ti);
if ti > 0 && prev[ti - 1] < best.0 {
best = (prev[ti - 1], ti - 1);
}
if ti + 1 < cols && prev[ti + 1] < best.0 {
best = (prev[ti + 1], ti + 1);
}
acc[si * cols + ti] += best.0;
from_col[si * cols + ti] = best.1 as u32;
}
}
// Back up from the end of each run of rows with overlap.
let mut seam = vec![usize::MAX; rows];
let mut si = rows;
while si > 0 {
si -= 1;
if !any[si] {
continue;
}
let row = &acc[si * cols..(si + 1) * cols];
let mut t = (0..cols)
.min_by(|&a, &b| row[a].total_cmp(&row[b]))
.unwrap_or(0);
loop {
seam[si] = t;
if si == 0 || !any[si - 1] {
break;
}
t = from_col[si * cols + t] as usize;
si -= 1;
}
}
// The new frame takes the side of the path its centre is on.
for &i in overlap {
let (s, t) = st(i);
let si = ((s - s0).round() as usize).min(rows - 1);
let ti = (t - t0).round();
if seam[si] != usize::MAX && ti >= seam[si] as f64 {
take[i] = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linalg::{Mat3, Vec3};
/// A scene as a function of direction, and frames of it rendered by the
/// same cameras the seam reads.
fn render(
cameras: &Cameras,
k: usize,
size: (usize, usize),
scene: impl Fn(Vec3) -> f32,
) -> Gray {
let (w, h) = size;
let mut data = vec![0.0; w * h];
for y in 0..h {
for x in 0..w {
let p = (
x as f64 + 0.5 - w as f64 / 2.0,
y as f64 + 0.5 - h as f64 / 2.0,
);
data[y * w + x] = scene(cameras.bearing(k, p));
}
}
Gray {
width: w,
height: h,
data,
}
}
fn yaw(a: f64) -> Mat3 {
let (s, c) = a.sin_cos();
Mat3([[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]])
}
/// Smooth, with a little texture: what a sky over a slope looks like to
/// the cost.
fn landscape(d: Vec3) -> f32 {
let (x, y) = (d.x() / d.z(), d.y() / d.z());
let texture = if y > 0.1 { 0.1 * (y * 40.0).sin() } else { 0.0 };
(0.5 + 0.2 * (x * 3.0).sin() + texture).clamp(0.0, 1.0) as f32
}
fn pair() -> Cameras {
Cameras {
rotations: vec![Mat3::IDENTITY, yaw(0.35)],
focal: 300.0,
}
}
#[test]
fn one_frame_owns_everything_it_reaches() {
let cameras = Cameras {
rotations: vec![Mat3::IDENTITY],
focal: 300.0,
};
let g = render(&cameras, 0, (320, 240), landscape);
let map = find(
&[&g],
&cameras,
&[1.0],
Projection::Perspective,
&Default::default(),
)
.unwrap();
let owned = map.labels.iter().filter(|&&l| l == 0).count();
assert!(owned as f64 > 0.95 * (map.width * map.height) as f64);
}
#[test]
fn each_frame_keeps_its_own_side() {
let cameras = pair();
let frames: Vec<Gray> = (0..2)
.map(|k| render(&cameras, k, (320, 240), landscape))
.collect();
let refs: Vec<&Gray> = frames.iter().collect();
let map = find(
&refs,
&cameras,
&[1.0, 1.0],
Projection::Cylindrical,
&Default::default(),
)
.unwrap();
let mid = map.height / 2 * map.width;
assert_eq!(map.labels[mid + 2], 0, "the left edge is frame 0's alone");
assert_eq!(
map.labels[mid + map.width - 3],
1,
"the right edge is frame 1's"
);
// One change of owner along every row that both frames cross.
for y in 0..map.height {
let row = &map.labels[y * map.width..(y + 1) * map.width];
let owned: Vec<u8> = row.iter().copied().filter(|&l| l != NONE).collect();
let changes = owned.windows(2).filter(|p| p[0] != p[1]).count();
assert!(changes <= 1, "row {y} changes owner {changes} times");
}
}
#[test]
fn the_seam_goes_round_what_only_one_frame_saw() {
// Frame 1 saw something frame 0 did not — a figure that walked into
// the overlap — in the middle of where the two meet.
let cameras = pair();
let figure = Vec3::new(0.175f64.sin(), 0.0, 0.175f64.cos());
let walker = |d: Vec3| {
let near = (d.x() - figure.x()).abs() < 0.04 && (d.y() - figure.y()).abs() < 0.15;
if near {
0.95
} else {
landscape(d)
}
};
let frames = [
render(&cameras, 0, (320, 240), landscape),
render(&cameras, 1, (320, 240), walker),
];
let refs: Vec<&Gray> = frames.iter().collect();
let map = find(
&refs,
&cameras,
&[1.0, 1.0],
Projection::Cylindrical,
&Default::default(),
)
.unwrap();
// Every texel of the figure is taken from the same frame, with a
// blend radius of room to spare, so it is either all there or not at
// all — never half.
let (u, v) = Projection::Cylindrical
.from_direction(map.scale, figure)
.unwrap();
let mut owners = std::collections::HashSet::new();
// The figure's extent on the surface, plus the blend's radius.
let radius = 3.0;
let reach = |half: f64| half * map.scale + radius * map.px;
let (ru, rv) = (reach(0.04), reach(0.15));
let mut dv = -rv;
while dv <= rv {
let mut du = -ru;
while du <= ru {
let s = map.share(1, u + du, v + dv, map.scale, radius);
owners.insert((s.unwrap() * 100.0).round() as i32);
du += map.px;
}
dv += map.px;
}
assert_eq!(owners.len(), 1, "the figure is split: shares {owners:?}");
}
#[test]
fn share_is_a_blend_across_the_seam_and_whole_away_from_it() {
let map = SeamMap {
width: 8,
height: 1,
scale: 1.0,
origin: (0.0, 0.0),
px: 1.0,
labels: vec![0, 0, 0, 0, 1, 1, 1, 1],
};
assert_eq!(map.share(0, 1.5, 0.5, 1.0, 2.0), Some(1.0));
assert_eq!(map.share(1, 6.5, 0.5, 1.0, 2.0), Some(1.0));
let at_seam = map.share(0, 4.0, 0.5, 1.0, 2.0).unwrap();
assert!((at_seam - 0.5).abs() < 1e-6, "{at_seam}");
// And at twice the scale, the same point is twice as far out.
assert_eq!(
map.share(0, 8.0, 1.0, 2.0, 2.0),
map.share(0, 4.0, 0.5, 1.0, 2.0)
);
let empty = SeamMap {
labels: vec![NONE; 8],
..map
};
assert_eq!(empty.share(0, 4.0, 0.5, 1.0, 2.0), None);
}
}
+10
View File
@@ -405,6 +405,7 @@ fn emit_node(out: &mut String, node: &Declaration) {
active,
tests,
presentation,
camera_stage,
..
} = node;
@@ -569,6 +570,15 @@ fn emit_node(out: &mut String, node: &Declaration) {
" fn is_active(&self) -> bool {{\n {active_expr}\n }}\n"
);
// Only a camera-stage node says anything: the trait's default is the
// scene, which is every other node (D19).
if *camera_stage {
out.push_str(
" fn stage(&self) -> crate::operation::Stage {\n \
crate::operation::Stage::Camera\n }\n\n",
);
}
let _ = writeln!(
out,
" fn wgsl_body(&self) -> String {{\n {}.into()\n }}\n",
+45 -47
View File
@@ -228,9 +228,11 @@ interpolated points — master, red, green, blue — each reaching the shader on
when it has been moved), `colour_mixer` (thirty-six faceted parameters from
twelve computed hue bands), `film_sim` (a stock's measured tables, which are
not parameters, and the one node that declares `Operation::renders` — see
below), `capture_sharpen` (a separable convolution) and `noise_reduction` (a
kernel, and one that decides how many dispatches to emit at each resolution) —
the last two for the reason the next section gives. `vignetting` is
below), `view_transform` (composed at its defaults, which a declaration cannot
say — see [What is not a node](#what-is-not-a-node-and-why)), and the five
kernels — `capture_sharpen` (a separable convolution), `noise_reduction` (one
that decides how many dispatches to emit at each resolution), `clarity`,
`texture` and `dehaze` — for the reason the next section gives. `vignetting` is
hand-written too but is not in the develop chain — it carries lens-profile
coefficients that are not parameters.
@@ -240,18 +242,16 @@ coefficients that are not parameters.
`Operation::renders`, and it is worth knowing why before writing a second one.
Every other node *adjusts* a picture. That one *makes* it: a film stock's
characteristic curve does the camera profile's base curve's job, from
measurements rather than from a curve somebody drew. Running both renders the
scene twice — the camera's rendering, and then a film's rendering of *that* —
which looks like neither and reads as a colour-management bug with no
colour-management bug to find.
characteristic curve does the view transform's job, from measurements rather
than from a curve somebody chose. Running both renders the scene twice — the
default rendering, and then a film's rendering of *that* — which looks like
neither and reads as a colour-management bug with no colour-management bug to
find.
So a node declaring `renders` takes camera RGB and hands back linear sRGB, and
in exchange the composer emits neither the base curve nor the conversion out of
camera space. Both halves move to the node, together: the base curve is defined
in camera RGB and the matrix is what leaves it, so a node replacing one has
necessarily replaced the other. `compose_full` keeps them as a single string
for exactly that reason — it is what makes getting half of it right impossible. `distortion` and
So `film_sim` is in `Stage::View` beside `view_transform`, and while a stock
is loaded the composer emits it in the view transform's place, last, after the
detail stage, and not the sigmoid (D19). It is handed working-space colour and
hands back display-referred linear sRGB for the output transform. `distortion` and
`aberration` are `Warp`s rather than operations: they rewrite coordinates
before sampling rather than transforming a colour after it.
@@ -264,7 +264,8 @@ clarity, texture, dehaze and spot removal are all defined by what the
of `c` at any price.
They go in the **detail stage**, which runs after the fused pass, in linear
light, at render resolution, before the output transform — see
light, at render resolution, before the view transform and the output
transform — see
[`../src/detail.rs`](../src/detail.rs) for why each of those is a decision
rather than a convenience. A node of this kind:
@@ -294,41 +295,38 @@ in raw pixels is a different photograph on screen and in the exported file.
## What is not a node, and why
Three things act on every pixel and are deliberately not in this directory:
the as-shot white balance, the camera matrix, and the **base curve**
(FR-DEV-3e). They are emitted by [`../src/operation.rs`](../src/operation.rs)
into the composed shader's fixed preamble, around the block of nodes.
Two things act on every pixel and are deliberately not in this directory: the
as-shot white balance and the camera matrix. They are emitted by
[`../src/operation.rs`](../src/operation.rs) into the composed shader around the
block of nodes. They are properties of the *file*, at the same standing as the
masked-photosite crop (FR-RAW-3) and the stored orientation (FR-DEV-3h): nobody
chose the sensor's green sensitivity, and reading the file correctly means
undoing it.
The test is not "does it transform a colour" — all three do. It is **whose
decision is it**. A node is something a photographer chose: it has parameters,
it moves off a neutral, it lands in the sidecar, it can be undone. These three
are properties of the *file*, at the same standing as the masked-photosite crop
(FR-RAW-3) and the stored orientation (FR-DEV-3h). Nobody chose the sensor's
green sensitivity or the body's rendering; they are what reading the file
correctly means.
The **view transform** (FR-DEV-3j) *is* a node — `view_transform.yaml`, a
`rust:` one — and that is a change of mind worth knowing about. It replaced the
per-body base curve, which was kept out of this directory because it belonged
to the camera: as a node it would have carried one body's rendering onto
another body's file through a shared sidecar. D19 retired the per-body curves,
and with them the argument. One view transform serves every body, so its
settings are a decision about the picture like any other. What is still
special about it is `Stage::View`: the composer emits it at the end of the
chain *whatever its state*, because a photograph with no view transform is a
scan and not a picture. Its neutral is its defaults, like every other node's,
so an untouched photograph writes nothing for it.
Making the base curve a node would have said the opposite in four places at
once. It would have appeared in the develop panel as a control, so an
unprofiled body would show a slider that does nothing. Its values would have
gone into the sidecar, and sidecars are shared between devices and bodies
(FR-NC-9) — one camera's rendering would follow an edit onto another camera's
file. Its neutral would have had to be "the identity", so a profiled body would
open reporting itself modified. And there is no seam through which a node could
learn which camera took the frame: the profile arrives on the decoded image,
travels through `DemosaicedImage` beside the matrix it belongs with, and is
written into the uniform block by the same three lines in `dr-gpu` — which is
exactly the path the matrix already took, because it is exactly the same kind
of thing.
## Stages
What it *does* share with the tone curve node is the spline. The composer asks
`ToneCurve` for its `curve_span`/`curve_eval` helpers rather than emitting a
second copy, so a profile author placing a control point and a photographer
dragging one mean the same thing by it.
The order still reads correctly from this directory: the base curve runs after
every node in the chain and before the conversion out of camera space. That is
the same reasoning `exposure` records under `placement:` — corrections to
capture are only meaningful on linear values, so the rendering goes last.
`stage: camera` puts a node in camera RGB, ahead of the camera matrix; the
default, `stage: scene`, hands it working-space colour — linear sRGB
primaries, scene-referred and unbounded. White balance is the only camera
node, because its multipliers scale the sensor's own channels. Everything else
belongs in the scene, where a hue or a luminance weight means the same thing
whichever body took the frame (D19). The composer emits the camera nodes, then
the matrix, then the scene nodes, each group in `order:`, and the view
transform last. `stage: view` is not offered to a declaration: a node that
maps into a display range is exactly what ARCH §6.14 forbids of everything
before the end, and the one that is allowed to is hand-written.
## Errors
+63 -28
View File
@@ -35,41 +35,63 @@ helpers: [luminance, apply_tone_gain]
define:
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
// used for the steepening direction because it has zero gradient at both
// ends, so the curve cannot invert however hard it is pushed — the failure
// that makes naive gain-about-a-pivot unusable past moderate settings.
// Blends toward a smoothstep, which has zero gradient at both ends, so the
// curve cannot invert however hard it is pushed — the failure that makes
// naive gain-about-a-pivot unusable past moderate settings. Only the
// positive direction comes here: flattening is not a curve at all (see
// the fragment).
fn contrast_curve(x: f32, amount: f32) -> f32 {
let clamped = clamp(x, 0.0, 1.0);
if (amount >= 0.0) {
// Blend toward a smoothstep, which is the S.
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);
let s = clamped * clamped * (3.0 - 2.0 * clamped);
return mix(clamped, s, amount);
}
wgsl: |
let luma = luminance(c);
if (luma > 0.0001) {
// 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.
if (amount < 0.0) {
// **Flattening mixes toward middle grey; it does not scale.**
//
// 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 = clamp(luma / 0.36, 0.0, 1.0);
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);
// Every tone moves the same fraction of the way to 0.18, which at -1
// collapses the picture to grey — the meaningful limit of 'no contrast'.
// In luminance this is exactly what the ratio form below would compute,
// but the ratio form reaches it by multiplying: a pixel at 0.001 has to
// be lifted to 0.09, a gain of ninety, and in the deepest shadows the
// channels are sensor noise, not a colour. After white balance the red
// and blue noise sits above the green (their multipliers are nearly
// twice its), so ninety times that noise is magenta — every black in the
// 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
// and the camera matrix, which carries a balanced neutral to equal
// channels.
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));
@@ -104,6 +126,19 @@ tests:
propagate through everything downstream.
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
why: |
A gain-about-a-pivot form produces a non-monotonic curve past moderate
+12 -10
View File
@@ -1,5 +1,5 @@
id: film_sim
order: 25
order: 190
# What this node is *about* is not written here, and cannot be: a `rust:` node
# publishes its own descriptor, so `attributes:` in this file would be read,
# validated and then ignored. See `Attribute::Effect` on `FilmSim`'s descriptor
@@ -11,15 +11,17 @@ why_rust: |
characteristic curves and a density lookup — which are not parameters and
which no `uniforms:` expression could produce. Its neutral is "no stock
loaded" rather than a set of values, and it is the one node that declares
`Operation::renders`, so the composer omits the camera profile's base curve
and the conversion out of camera space on its behalf.
`Operation::renders`, so while a stock is loaded the composer emits it in
the view transform's place instead of the default sigmoid.
placement: |
After white balance and exposure, and before everything else.
Last, in the view transform's place (D19, FR-DEV-3j), after every other
operation and after the detail stage.
Those two are what the camera did — interpreting the sensor, and correcting
the amount of light that reached it — and they are only meaningful on
scene-linear values, which is what a film has to be handed. Everything below
is a decision about the picture, and a decision about the picture belongs
after the film has rendered it, exactly as it does when you scan a frame and
then work on the scan.
Before D19 it sat at order 25, after white balance and exposure, and every
decision below it acted on the film's output, as though the frame had been
scanned and then worked on. That put a display-referred rendering in the
middle of the chain, which is what D19 removes: every operation is now handed
the scene, and the film is the last thing that happens to the picture — an
edit is a decision about the exposure the negative receives. `Stage::View`
is what puts it there; this number only places it in the panel's order.
+18
View File
@@ -0,0 +1,18 @@
id: view_transform
order: 200
# A `rust:` node publishes its own descriptor; its attributes are on the type
# in `../src/ops/view_transform.rs`.
rust: ViewTransform
why_rust: |
It is composed at its defaults — a photograph with no view transform is a
scan, not a picture — which is `Stage::View`, and a declaration has no way to
say it. Its three uniforms are also the solution of two equations rather than
expressions over its parameters (`dr_pipeline::view::Sigmoid::new`).
placement: |
Last, after every scene operation and, when there is one, after the detail
stage (D19, FR-DEV-3j). It is the one stage allowed to map scene-linear colour
to a display range, so anything after it would be working on a rendering.
The order here only places it in the panel; the composer puts every
`Stage::View` node at the end whatever its number says.
+7
View File
@@ -20,6 +20,13 @@ placement: |
First. It is a correction to how the scene was captured, and every tonal
operation after it should act on a correctly balanced image.
# In camera RGB, ahead of the camera matrix, and the only node there (D19).
# Its multipliers scale the sensor's own channels — that is what the as-shot
# ones are, and what the picker solves for — and a matrix that mixes the
# channels, which is every body's, would turn the same numbers into a
# different correction once it had run.
stage: camera
params:
temperature:
label: param.temperature
+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
+7 -4
View File
@@ -49,7 +49,9 @@ pub struct Section {
/// A stable identifier, for a frontend that remembers which sections a
/// photographer folded away. Never shown.
pub id: &'static str,
/// What the section is called on screen.
/// What the section is called on screen, as a category path: `/`
/// separates the levels, so `Film/Colour` is a folder inside `Film`. The
/// same spelling a photographer's own preset names use for theirs.
pub title: &'static str,
/// The presets in it, every one reaching only what it names.
pub presets: PresetLibrary,
@@ -62,19 +64,20 @@ const SECTIONS: &[(&str, &str, &str)] = &[
"Essentials",
include_str!("../presets/essentials.drpl"),
),
("skies", "Skies", include_str!("../presets/skies.drpl")),
(
"colour_film",
"Colour film",
"Film/Colour",
include_str!("../presets/colour_film.drpl"),
),
(
"cinema_film",
"Cinema film",
"Film/Cinema",
include_str!("../presets/cinema_film.drpl"),
),
(
"bw_film",
"Black and white film",
"Film/Black and white",
include_str!("../presets/bw_film.drpl"),
),
];
+29
View File
@@ -271,6 +271,32 @@ pub struct Declaration {
/// Boxed so the rare node that declares one does not widen every
/// declaration by the size of a presentation it does not have.
pub presentation: Option<Box<PresentationDef>>,
/// Whether the node runs in camera RGB, ahead of the camera matrix —
/// `stage: camera`. See [`read_stage`].
pub camera_stage: bool,
}
/// TRACES: FR-DEV-3e | FR-DEV-2
/// Where in the chain a declared node's colour comes from: `stage: camera` or
/// `stage: scene`, the default.
///
/// Camera RGB is where white balance's multipliers are defined, and it is the
/// only thing that belongs there (D19): every other operation is handed
/// working-space colour, so that a hue or a luminance weight means the same
/// thing whichever body took the frame. `view` is not offered. The view
/// transform is hand-written, and a declared node that clipped into a display
/// range would be exactly what ARCH §6.14 forbids of every node before it.
fn read_stage(root: &Mapping) -> Result<bool, String> {
match root.get("stage") {
None => Ok(false),
Some(v) => match as_str(v, "stage")? {
"camera" => Ok(true),
"scene" => Ok(false),
other => Err(format!(
"unknown stage {other:?}; expected \"camera\" or \"scene\""
)),
},
}
}
impl Declaration {
@@ -512,6 +538,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
"define",
"label",
"attributes",
"stage",
] {
if root.contains_key(key) {
return Err(format!(
@@ -564,6 +591,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
.collect();
let tests = read_tests(root, &params, &uniform_names, &helper_names)?;
let presentation = read_presentation(root, &param_names)?;
let camera_stage = read_stage(root)?;
Ok(Node::Declared(Box::new(Declaration {
id,
@@ -580,6 +608,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
active,
tests,
presentation,
camera_stage,
})))
}
+11
View File
@@ -107,6 +107,8 @@ pub struct DeclaredOp {
helpers: Vec<Helper>,
presentation: Option<Presentation>,
order: i64,
/// See `decl::read_stage`.
camera_stage: bool,
}
/// One uniform: the name the fragment reads it by, and how to compute it.
@@ -208,6 +210,7 @@ impl DeclaredOp {
wgsl: declaration.wgsl_body(),
helpers,
presentation: declaration.presentation.as_deref().map(presentation),
camera_stage: declaration.camera_stage,
order: declaration.order,
})
}
@@ -292,6 +295,14 @@ impl Operation for DeclaredOp {
fn presentation(&self) -> Option<Presentation> {
self.presentation.clone()
}
fn stage(&self) -> crate::operation::Stage {
if self.camera_stage {
crate::operation::Stage::Camera
} else {
crate::operation::Stage::Scene
}
}
}
/// A declared parameter as the descriptor the panel reads.
+4 -4
View File
@@ -659,10 +659,10 @@ impl Attribute {
///
/// `Effect` after `Colour` is a look laid over a settled picture — and is
/// the one arguable slot. A spectral film simulation declares
/// [`crate::Operation::renders`] and replaces the base curve, which is an
/// argument for treating it as foundational rather than final; an array of
/// six cannot say "last, except when it is first". The tension is recorded
/// here rather than settled.
/// [`crate::Operation::renders`] and takes the view transform's place at
/// the very end of the chain (D19), which is an argument for treating it as
/// the rendering rather than one effect among others; an array of six
/// cannot say that. The tension is recorded here rather than settled.
///
/// Both ends were wrong for as long as this list only fed a row of chips
/// nobody reads in order. It stopped being harmless when the same list
+192 -252
View File
@@ -24,9 +24,10 @@
//! v
//! +------------------------------------------+
//! | the fused point-operation pass | one dispatch
//! | white balance, exposure, tone, colour |
//! | the mask layers |
//! | white balance (camera RGB) |
//! | camera RGB -> linear sRGB |
//! | exposure, tone, colour |
//! | the mask layers |
//! +------------------------------------------+
//! | rgba16float, linear, **unclipped**, at render resolution
//! v
@@ -34,7 +35,13 @@
//! | the detail stage - this module | one dispatch per pass
//! | sharpen, NR, clarity, texture, spots |
//! +------------------------------------------+
//! | the last pass applies the output transform
//! | rgba16float, still scene-linear and unclipped
//! v
//! +------------------------------------------+
//! | the view pass | one dispatch
//! | view transform, or the film stock |
//! | output transform, mask reveal |
//! +------------------------------------------+
//! v
//! rgba8unorm display or export texture
//! ```
@@ -52,14 +59,13 @@
//! texture, clarity, spot removal and sharpen/NR sit below the tone curve and
//! the colour mixer.
//!
//! **In linear light, after the camera matrix.** The fused pass works in
//! *camera* space, because white balance and exposure are physically
//! meaningful there and nowhere else. A detail pass is the opposite case: it
//! wants a luminance, and camera RGB has no luminance — the three channels are
//! **In linear light, after the camera matrix.** A detail pass wants a
//! luminance, and camera RGB has no luminance — the three channels are
//! whatever the CFA's dyes passed, and weighting them 0.2126/0.7152/0.0722
//! would be numerology. So the split is taken *after* the `cam_to_srgb`
//! multiply, where the working space is linear sRGB and a luminance is a
//! luminance.
//! would be numerology. Since D19 only white balance runs in camera RGB; the
//! `cam_to_srgb` multiply follows it, so every point operation, and every
//! detail pass after them, works in linear sRGB primaries, where a luminance
//! is a luminance.
//!
//! **Before the output transform, and before the clip.** FR-DEV-2 allows
//! exactly one quantisation, at the display or export stage. A detail pass
@@ -70,9 +76,11 @@
//! therefore `rgba16float` and holds linear values that have **not** been
//! clamped to `0..=1`: a recovered highlight is still above one at this point,
//! and clipping it before the sharpener sees it would put a hard edge exactly
//! where the sharpener is most visible. The last detail pass performs the
//! primaries conversion, the clip and the encode, so the single quantisation
//! stays single.
//! where the sharpener is most visible. Every detail pass writes such an
//! intermediate, the last one included, and the view pass after them — the
//! view transform, then the output transform's primaries, clip and encode —
//! is the one place the scene is fitted to a display (D19, ARCH §6.14), so
//! the single quantisation stays single.
//!
//! **After framing, at render resolution.** The alternative — running detail
//! on the demosaiced source before the framing prologue — is superficially
@@ -122,8 +130,6 @@
use std::fmt::Write as _;
use dr_types::ColourSpace;
use crate::operation::{Helper, Operation, Uniform};
/// Floats the generated detail uniform block always carries, before an
@@ -199,6 +205,10 @@ pub const DETAIL_BASE_UNIFORM_FIELDS: usize = 4;
pub struct RenderScale {
render: (u32, u32),
full: (u32, u32),
/// The whole framed photograph at source resolution: `full` before the
/// zoom and the tile were folded in. What a frame fraction is a fraction
/// of — see [`Self::frame_fraction`].
frame: (u32, u32),
}
impl RenderScale {
@@ -210,9 +220,27 @@ impl RenderScale {
/// [`crate::EditGraph::render_scale`] works both out from the framing, and
/// is what a caller should normally use.
pub fn new(render: (u32, u32), full: (u32, u32)) -> Self {
let full = (full.0.max(1), full.1.max(1));
Self {
render: (render.0.max(1), render.1.max(1)),
full: (full.0.max(1), full.1.max(1)),
full,
frame: full,
}
}
/// TRACES: FR-DSP-1 | FR-DSP-2
/// The same scale, for a render that shows only part of a larger frame.
///
/// `frame` is the whole framed photograph at source resolution — the crop
/// folded in, the zoom and any tile not. A zoomed view and an export tile
/// both look at part of the frame, and a clarity radius is a fraction of
/// the *frame*, not of the part: measured against the part, zooming in
/// shrinks the halo to a fraction of what the file will get, and two
/// neighbouring tiles of an export would each draw their own.
pub fn within(self, frame: (u32, u32)) -> Self {
Self {
frame: (frame.0.max(1), frame.1.max(1)),
..self
}
}
@@ -261,8 +289,19 @@ impl RenderScale {
/// For the compositional family — clarity, texture, dehaze — and the same
/// unit `dr-gpu`'s mask rasteriser already converts feathers in. An edit
/// stored this way is resolution-independent by construction.
///
/// Measured against the whole frame ([`Self::within`]), scaled by the
/// render's own short edge over the viewed region's. When the render shows
/// the whole frame the two sizes cancel and this is `fraction` of the
/// render's short edge exactly.
pub fn frame_fraction(&self, fraction: f32) -> f32 {
fraction * self.render.0.min(self.render.1) as f32
let render = self.render.0.min(self.render.1) as f32;
let viewed = self.full.0.min(self.full.1) as f32;
let frame = self.frame.0.min(self.frame.1) as f32;
if self.frame == self.full {
return fraction * render;
}
fraction * render * (frame / viewed)
}
/// Whether a radius stated in source pixels survives this render.
@@ -491,15 +530,6 @@ pub struct ComposedDetailPass {
pub radius: u32,
/// See [`DetailPass::output_scale`].
pub output_scale: u32,
/// Whether this pass writes the display/export texture rather than another
/// linear intermediate.
///
/// True for exactly the last pass in the chain, which carries the output
/// transform — the primaries conversion, the clip and the encode that the
/// fused pass performs when there is no detail stage at all. Folding them
/// into the last pass rather than adding a resolve dispatch keeps the cost
/// of the stage at one dispatch per pass, not one plus one.
pub writes_output: bool,
/// Identifies this pass's *structure*, for the pipeline cache. Covers the
/// generated source, not the uniform values — so moving a slider uploads a
/// buffer and reuses the compiled pipeline, exactly as the fused pass does.
@@ -537,6 +567,26 @@ impl ComposedDetail {
.max()
.unwrap_or(0)
}
/// TRACES: FR-DSP-2
/// How far the whole chain reads from the pixel it finally writes, in
/// render pixels: the halo a tile has to be grown by so that its interior
/// renders exactly as the untiled frame does.
///
/// The **sum** of the passes' reaches, not the widest of them. The passes
/// run one after another, so a pixel of the last one depends on pixels of
/// the one before it `r` away, each of which depends on pixels a further
/// `r'` away. A separable blur's two halves each reach `r` along one axis
/// and the sum over-counts them by a factor of two; that is the price of a
/// bound that is always safe, and it is paid only by export tiles.
///
/// One pixel per pass on top, for the reduced grids' bilinear taps.
pub fn reach(&self) -> u32 {
self.passes
.iter()
.map(|p| p.radius.saturating_mul(p.output_scale).saturating_add(1))
.fold(0u32, u32::saturating_add)
}
}
/// TRACES: FR-DEV-3 | FR-DSP-1
@@ -547,10 +597,11 @@ impl ComposedDetail {
/// an edit with no sharpening produces an empty chain and `dr-gpu` runs the
/// single dispatch it always did.
///
/// `output` is the space the **last** pass encodes into, and it is a parameter
/// for the same reason it is a parameter to [`crate::compose_with_framing`]: a
/// screen render and a Display P3 export are the same edit and different
/// shaders, and neither is more authoritative than the other.
/// No pass encodes. Every pass writes a linear intermediate, the last one
/// included, and the fused pass's view pass ([`crate::ComposedShader::view`])
/// reads the last and performs the view transform and the output transform
/// (D19). So the output space is not a parameter here: a screen render and a
/// Display P3 export share one detail stage.
///
/// # The generated uniform block
///
@@ -562,12 +613,8 @@ impl ComposedDetail {
/// is there because a two-pass operation emitting one body for both directions
/// is a reasonable thing to want, and would otherwise need a uniform of its
/// own purely to say which half it is in.
pub fn compose_detail(
ops: &[Box<dyn Operation>],
scale: RenderScale,
output: ColourSpace,
) -> ComposedDetail {
compose_detail_with(ops, &[], scale, output)
pub fn compose_detail(ops: &[Box<dyn Operation>], scale: RenderScale) -> ComposedDetail {
compose_detail_with(ops, &[], scale)
}
/// TRACES: FR-DEV-8
@@ -592,7 +639,6 @@ pub fn compose_detail_with(
ops: &[Box<dyn Operation>],
spots: &[DetailPass],
scale: RenderScale,
output: ColourSpace,
) -> ComposedDetail {
// Every pass of every active detail operation, flattened, carrying the
// operation it came from for the uniform prefix and the helper set.
@@ -624,45 +670,13 @@ pub fn compose_detail_with(
}
}
// An active detail operation that emitted nothing at this scale.
//
// Legal, and the honest answer for an acutance operation on a heavy proxy
// — a one-source-pixel radius is a third of a render pixel there and no
// kernel represents a third of a pixel (see [`RenderScale`]). But it opens
// a hole between the two halves of the composition: [`compose_full`]
// decides to hand on linear working values from the *operations*, which it
// must, having no scale to consult, so the fused pass has already stopped
// short of the output transform. Returning an empty chain here would leave
// that transform undone and bind an `rgba16float` shader to an
// `rgba8unorm` target, which surfaces as a wgpu validation failure a long
// way from the cause.
//
// So the chain is never empty when the fused pass is expecting one: a
// single pass with no body, which reads the intermediate and performs the
// output transform the fused pass skipped. One dispatch, in the uncommon
// case where a photographer has a kernel switched on at a scale that
// cannot draw it — against the alternative of the preview failing outright
// or `compose_full` growing a resolution argument it has no other use for.
if planned.is_empty() && ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
return ComposedDetail {
passes: vec![compose_one(
RESOLVE_ID,
&[],
&DetailPass {
output_scale: 1,
label: "resolve",
radius: 0,
wgsl: String::new(),
uniforms: Vec::new(),
storage: Vec::new(),
},
0,
scale,
output,
true,
)],
};
}
// An active detail operation may emit nothing at this scale — an
// acutance operation on a heavy proxy, whose one-source-pixel radius is a
// third of a render pixel (see [`RenderScale`]). The chain is then empty
// while the fused pass has stopped at linear working values, and that is
// fine: the fused pass's view pass reads the fused result directly and
// performs the output transform. Before D19 the last detail pass encoded,
// and this case needed a body-less resolve pass to do it.
// TRACES: NFR-P5
// A pass whose body is empty changes nothing but where the pixels are: it
@@ -674,12 +688,10 @@ pub fn compose_detail_with(
// 2560 x 1600 frame on the reference laptop with its clocks held down.
//
// Dropped here, where the chain is still a list, and only where dropping
// it is exact:
// it is exact. The last pass is no exception since D19: it writes an
// `rgba16float` intermediate like the others, and the view pass reads
// whichever one the chain last wrote.
//
// - **Not the last pass.** The last pass performs the output transform on
// what it read from an `rgba16float` intermediate. Moving that transform
// onto the pass before would apply it to that pass's `f32` result
// instead, which is a different rounding of the same picture.
// - **Not after a reduced pass.** A full-resolution pass ends the reduced
// chain (see `DetailRunner::encode`), so one that follows a scaled pass
// is what stops the next operation reading the last one's base. None of
@@ -688,45 +700,29 @@ pub fn compose_detail_with(
// Everywhere else the pass before and the pass after exchange the same
// `rgba16float` texels either way, `aux` included.
let mut kept: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::with_capacity(planned.len());
let total = planned.len();
for (position, entry) in planned.into_iter().enumerate() {
for entry in planned {
let after_full = kept.last().is_none_or(|(_, _, p, _)| p.output_scale <= 1);
let droppable = position + 1 < total && after_full && entry.2.is_identity();
let droppable = after_full && entry.2.is_identity();
if !droppable {
kept.push(entry);
}
}
let planned = kept;
let last = planned.len().saturating_sub(1);
let passes = planned
.into_iter()
.enumerate()
.map(|(position, (id, helpers, pass, index))| {
compose_one(id, helpers, &pass, index, scale, output, position == last)
})
.map(|(id, helpers, pass, index)| compose_one(id, helpers, &pass, index, scale))
.collect();
ComposedDetail { passes }
}
/// The operation id the resolve pass is labelled with.
///
/// Not an operation: no `ops/*.yaml` declares it and nothing in the chain
/// answers to it. It exists so the generated label reads `detail/resolve`
/// rather than borrowing the id of whichever operation happened to fall
/// through, which would send a reader looking for a bug in that operation.
const RESOLVE_ID: &str = "detail";
#[allow(clippy::too_many_arguments)]
fn compose_one(
id: &str,
helpers: &[Helper],
pass: &DetailPass,
index: usize,
scale: RenderScale,
output: ColourSpace,
writes_output: bool,
) -> ComposedDetailPass {
let prefix = format!("{}_{index}", crate::operation::sanitise(id));
@@ -772,41 +768,13 @@ fn compose_one(
let _ = writeln!(helper_src, "{}\n", h.source.trim_end());
}
// The storage format and the tail are the *only* difference between an
// intermediate pass and the final one. Everything above — the taps, the
// uniforms, the body — is identical, which is what lets an operation write
// one kernel without knowing whether it happens to be last in the chain.
let (store_format, tail) = if writes_output {
(
"rgba8unorm",
format!(
"{} // Clip to the output gamut and encode. The one quantisation\n\
\x20 // the pipeline performs (FR-DEV-2), and it is here rather than\n\
\x20 // in the fused pass because this is now the last thing to run.\n\
\x20 c = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0));\n\
\x20 textureStore(output, coord, vec4<f32>(encode_output(c), 1.0));",
crate::operation::primaries_conversion(output)
),
)
} else {
(
"rgba16float",
" // Another linear intermediate: no clip and no encode, because\n\
\x20 // the pass after this one still has to read real values.\n\
\x20 //\n\
\x20 // `aux` rides in alpha. A pass that never touches it hands on\n\
\x20 // whatever it was given, so the lane costs an operation that\n\
\x20 // does not want it exactly one copy of a value it already read.\n\
\x20 textureStore(output, coord, vec4<f32>(c, aux));"
.to_string(),
)
};
let encode_fn = if writes_output {
crate::operation::encode_output_fn(output)
} else {
String::new()
};
// Every pass writes another linear intermediate: no clip and no encode,
// because the view pass after the last one still has to read real values
// (D19). `aux` rides in alpha. A pass that never touches it hands on
// whatever it was given, so the lane costs an operation that does not want
// it exactly one copy of a value it already read.
let store_format = "rgba16float";
let tail = " textureStore(output, coord, vec4<f32>(c, aux));";
let label = format!("{id}/{}", pass.label);
let indented = body
@@ -823,7 +791,7 @@ fn compose_one(
// way back to a coordinate.
//
// In: linear sRGB, scene-referred, **unclipped**, at render resolution.
// Out: {}
// Out: the same, for the next pass or for the view pass after the last.
struct Params {{
{uniform_fields}}}
@@ -907,7 +875,7 @@ fn reduced_at(coord: vec2<i32>) -> f32 {{
return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y);
}}
{helper_src}{encode_fn}
{helper_src}
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
let dims = textureDimensions(output);
@@ -936,12 +904,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
{tail}
}}
",
if writes_output {
"display-encoded, in the output space."
} else {
"linear sRGB, for the next pass."
},
"
);
let structure_hash = crate::operation::hash_source(&source);
@@ -956,7 +919,6 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
// dispatch size and a declaration is data, which since FR-PLG-2 can
// come from a file this build did not write.
output_scale: pass.output_scale.max(1),
writes_output,
structure_hash,
}
}
@@ -1013,6 +975,21 @@ mod tests {
assert!((export.frame_fraction(0.01) - 40.0).abs() < 0.5);
}
#[test]
fn a_frame_fraction_does_not_shrink_with_the_zoom_or_the_tile() {
// TRACES: FR-DSP-1 | FR-DSP-2
// A 6000×4000 frame. At fit in a 1500×1000 panel, 1% of it is 10
// render pixels; zoomed to 1:1 on a 1500×1000 corner of it, the same
// 1% is 40 — the 40 the file gets — and an export tile of that corner
// must say 40 too, or each tile draws its own halo and the seams show.
let fit = RenderScale::new((1500, 1000), (6000, 4000));
assert!((fit.frame_fraction(0.01) - 10.0).abs() < 1e-3);
let zoomed = RenderScale::new((1500, 1000), (1500, 1000)).within((6000, 4000));
assert!((zoomed.frame_fraction(0.01) - 40.0).abs() < 1e-3);
let tile = RenderScale::full((1024, 1024)).within((6000, 4000));
assert!((tile.frame_fraction(0.01) - 40.0).abs() < 1e-3);
}
#[test]
fn zooming_to_one_to_one_makes_the_preview_exact() {
// The reason there is no separate full-resolution preview path: the
@@ -1095,27 +1072,19 @@ mod tests {
// dispatch. An unedited photograph must not pay for a sharpener it is
// not using.
let ops = with_blur(0.0);
let composed = compose_detail(
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
);
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
assert!(composed.is_empty());
assert_eq!(fused(&ops).output_mode, OutputMode::Encoded);
}
#[test]
fn a_separable_blur_becomes_two_passes_and_only_the_last_encodes() {
// The multi-pass case, which is the one the ping-pong exists for. The
// first pass writes a linear intermediate and the second writes the
// display texture — so the output transform happens exactly once, at
// the end, wherever the end happens to be.
fn a_separable_blur_becomes_two_passes_and_neither_encodes() {
// The multi-pass case, which is the one the ping-pong exists for. Both
// passes write linear intermediates, and the fused pass's view pass
// reads the second and performs the view transform and the output
// transform — so those happen exactly once, after every kernel (D19).
let ops = with_blur(0.05);
let composed = compose_detail(
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
);
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
assert_eq!(composed.len(), 2);
let first = &composed.passes[0];
@@ -1123,13 +1092,16 @@ mod tests {
assert_eq!(first.label, "detail_probe/horizontal");
assert_eq!(last.label, "detail_probe/vertical");
assert!(!first.writes_output);
assert!(first.source.contains("texture_storage_2d<rgba16float"));
assert!(!first.source.contains("fn encode_output"));
assert!(last.writes_output);
assert!(last.source.contains("texture_storage_2d<rgba8unorm"));
assert!(last.source.contains("fn encode_output"));
for pass in [first, last] {
assert!(pass.source.contains("texture_storage_2d<rgba16float"));
assert!(!pass.source.contains("fn encode_output"));
assert!(!pass.source.contains("view_sigmoid"));
}
let view = fused(&ops)
.view
.expect("a view pass follows the detail stage");
assert!(view.source.contains("fn encode_output"));
assert!(view.source.contains("c = view_sigmoid("));
// Two passes of one operation are two shaders, so they must not share
// a pipeline-cache entry — the classic way a second pass silently runs
@@ -1143,11 +1115,7 @@ mod tests {
// and the composer rewrites it to a prefixed struct field, so two
// operations may both call a uniform `radius` and neither has to know.
let ops = with_blur(0.05);
let composed = compose_detail(
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
);
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
let src = &composed.passes[0].source;
assert!(src.contains("detail_probe_0_radius: f32,"));
assert!(src.contains("let r = i32(u.detail_probe_0_radius);"));
@@ -1164,13 +1132,7 @@ mod tests {
// outright by the WGSL uniform address space rules, and the failure
// arrives as a shader compilation error against generated source.
let ops = with_blur(0.05);
for pass in compose_detail(
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
)
.passes
{
for pass in compose_detail(&ops, RenderScale::full((512, 512))).passes {
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
assert!(pass.uniforms.iter().all(|v| v.is_finite()));
// The base block is first and fixed, so a pass never addresses a
@@ -1189,7 +1151,7 @@ mod tests {
// the truth rather than zero.
let ops = with_blur(0.05);
let scale = RenderScale::full((400, 400));
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb);
let composed = compose_detail(&ops, scale);
let expected = BoxBlur::with_radius(0.05).kernel(scale);
assert_eq!(expected, 20, "5% of a 400px edge");
assert_eq!(composed.radius(), expected);
@@ -1208,7 +1170,7 @@ mod tests {
.iter()
.map(|&(w, h)| {
let scale = RenderScale::full((w, h));
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb);
let composed = compose_detail(&ops, scale);
composed.radius() as f32 / w.min(h) as f32
})
.collect();
@@ -1226,14 +1188,14 @@ mod tests {
// cannot see each other. `compose_full` decides to hand on linear
// working values from the *operations* — it has no resolution to
// consult — while this composer converts a radius and can legitimately
// decide there is nothing to draw at this size. An empty chain would
// then leave the output transform undone: the fused pass writes
// `rgba16float` and the frontend binds an `rgba8unorm` target to it.
// decide there is nothing to draw at this size.
//
// A photographer meets this by turning on capture sharpening or
// luminance noise reduction while the develop view is fitted to a
// large file, which is the normal way to work, so it is not an edge
// case that can be left to fail.
// large file, which is the normal way to work. Before D19 an empty
// chain left the output transform undone and needed a resolve pass;
// now the view pass does the output transform whatever the chain
// holds.
let ops = with_blur(0.001);
let scale = RenderScale::full((400, 400));
assert!(ops.last().expect("the blur").is_active());
@@ -1243,74 +1205,59 @@ mod tests {
"the premise: a radius too small to draw emits no pass"
);
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb);
assert_eq!(composed.len(), 1, "the chain must not be empty here");
assert_eq!(composed.radius(), 0, "it reads only the pixel it writes");
let resolve = &composed.passes[0];
assert_eq!(resolve.label, "detail/resolve");
assert!(resolve.writes_output);
assert!(resolve.source.contains("texture_storage_2d<rgba8unorm"));
assert!(resolve.source.contains("fn encode_output"));
// Exactly the fixed base block and no more: a pass with no body has
// nothing of its own to upload, and the block still has to be a
// multiple of sixteen bytes.
assert_eq!(resolve.uniforms.len(), DETAIL_BASE_UNIFORM_FIELDS);
assert_eq!(resolve.uniforms.len() % 4, 0);
// And it really is a copy: the fused pass composed alongside it is the
// one that stopped short, so the two agree about who encodes.
assert_eq!(fused(&ops).output_mode, OutputMode::LinearWorking);
// Empty, and that is fine since D19: nothing in the chain encodes, so
// there is no output transform for an empty chain to leave undone.
// The fused pass stopped at linear values and its view pass reads
// them directly.
let composed = compose_detail(&ops, scale);
assert!(composed.is_empty());
let fused = fused(&ops);
assert_eq!(fused.output_mode, OutputMode::LinearWorking);
let view = fused
.view
.expect("the view pass performs the output transform");
assert_eq!(view.output_mode, OutputMode::Encoded);
assert!(view.source.contains("fn encode_output"));
}
#[test]
fn a_pass_that_changes_nothing_is_dropped_where_that_is_exact() {
// TRACES: NFR-P5
// Capture sharpening at a scale too coarse to draw its radius emits a
// pass with an empty body. Between two other passes it costs a
// render-sized read and write and changes no texel, so it goes; as the
// last pass it performs the output transform on the intermediate, and
// moving that onto the pass before would round differently, so it
// stays.
use crate::ops::{capture_sharpen, CaptureSharpen, NoiseReduction};
let sharpen = || -> Box<dyn Operation> {
let mut op = CaptureSharpen::new();
op.set_param(capture_sharpen::AMOUNT, 60.0);
Box::new(op)
};
// A pass with an empty body costs a render-sized read and write and
// changes no texel, so it goes — wherever it falls since D19, the last
// position included, because the last pass writes an intermediate like
// every other and the view pass reads whichever the chain last wrote.
// Built by hand, and run as a repair so it goes first: no operation
// emits one any more (capture sharpening at a scale too coarse to draw
// its radius used to, and now emits nothing).
use crate::ops::NoiseReduction;
let chroma = || -> Box<dyn Operation> { Box::new(NoiseReduction::with_amounts(0.0, 60.0)) };
// A 24 MP frame fitted to a panel: a one-source-pixel radius is a
// quarter of a render pixel.
let scale = RenderScale::new((1500, 1000), (6000, 4000));
let unresolved = sharpen().detail().expect("a detail stage").passes(scale);
assert!(
unresolved.len() == 1 && unresolved[0].is_identity(),
"the premise: sharpening at this scale is one pass that does nothing"
);
let labels = |ops: &[Box<dyn Operation>]| -> Vec<String> {
compose_detail(ops, scale, dr_types::ColourSpace::Srgb)
let nothing = DetailPass {
output_scale: 1,
label: "nothing",
radius: 0,
wgsl: "// `c` already holds this pixel.".to_string(),
uniforms: Vec::new(),
storage: Vec::new(),
};
assert!(nothing.is_identity(), "the premise");
let labels: Vec<String> =
compose_detail_with(&[chroma()], std::slice::from_ref(&nothing), scale)
.passes
.iter()
.map(|p| p.label.clone())
.collect()
};
// First, ahead of the chroma passes: dropped.
let first = labels(&[sharpen(), chroma()]);
.collect();
assert_eq!(
first,
labels,
[
"noise_reduction/chroma-horizontal",
"noise_reduction/chroma-vertical"
]
);
// Last, after them: kept, and it is the pass that encodes.
let last = labels(&[chroma(), sharpen()]);
assert_eq!(last.len(), 3);
assert_eq!(last[2], "capture_sharpen/unresolved");
// Alone: kept, because the fused pass stopped short and something has
// to finish the frame.
assert_eq!(labels(&[sharpen()]), ["capture_sharpen/unresolved"]);
// Alone: dropped too, and the chain is empty — the view pass
// finishes the frame.
assert!(compose_detail_with(&[], &[nothing], scale).is_empty());
}
#[test]
@@ -1325,11 +1272,7 @@ mod tests {
// A pass that says nothing about `aux` hands on what it was given,
// which is why the box blur below needs no knowledge of it.
let ops = with_blur(0.05);
let composed = compose_detail(
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
);
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
for pass in &composed.passes {
assert!(
@@ -1344,11 +1287,12 @@ mod tests {
.contains("textureStore(output, coord, vec4<f32>(c, aux));"),
"an intermediate must carry the lane to the pass after it"
);
// The last pass writes the display texture, whose alpha is opacity and
// not scratch space. Readable there, not written — which is the right
// way round, because the combining pass is the one that reads it.
assert!(composed.passes[1].writes_output);
assert!(!composed.passes[1].source.contains("vec4<f32>(c, aux)"));
// The last pass carries it too: since D19 it writes an intermediate
// for the view pass rather than the display texture, whose alpha is
// opacity. The view pass reads only the colour.
assert!(composed.passes[1]
.source
.contains("textureStore(output, coord, vec4<f32>(c, aux));"));
}
#[test]
@@ -1357,11 +1301,7 @@ mod tests {
// overwhelmingly common edit: no sharpening means no chain, which
// means `dr-gpu` runs the single fused dispatch it always did.
let ops = crate::ops::chain();
let composed = compose_detail(
&ops,
RenderScale::full((64, 64)),
dr_types::ColourSpace::Srgb,
);
let composed = compose_detail(&ops, RenderScale::full((64, 64)));
assert!(composed.is_empty());
assert_eq!(composed.radius(), 0);
}
+57 -6
View File
@@ -260,8 +260,9 @@ impl CropRect {
///
/// `anchor` is the point of the rect that stays put, in the rect's own
/// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged,
/// so the far corner is the one that does not move, and `(0.5, 0.5)` when
/// a ratio is chosen and the composition should stay where it is.
/// so the far corner is the one that does not move, `(0.0, 0.5)` while
/// the right-hand edge is dragged, and `(0.5, 0.5)` when a ratio is
/// chosen and the composition should stay where it is.
///
/// **The rect grows onto the ratio rather than shrinking onto it.** The
/// axis that is short is extended; the long one is never trimmed. Fitting
@@ -269,6 +270,7 @@ impl CropRect {
/// along one axis alone would be immediately clamped back by the other,
/// and the handle would simply refuse to move. The result is then scaled
/// down, both axes together, only as far as the frame's edge demands.
/// The exception is an edge: see the note in the body.
pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self {
let rect = self.normalised();
let ratio = finite(ratio, 0.0);
@@ -286,8 +288,19 @@ impl CropRect {
let px = rect.x + ax * rect.width;
let py = rect.y + ay * rect.height;
let mut w = rect.width.max(rect.height * r);
let mut h = w / r;
// An anchor in the middle of one side is an *edge* being dragged, and
// then the axis across that edge leads: it is the only one the user
// moved. Growing the short axis instead would take the other side
// for the leader whenever the edge went inward, and the edge would be
// pushed straight back out — a handle that only ever grows the crop.
let (mut w, mut h) = if ax == 0.5 && ay != 0.5 {
(rect.height * r, rect.height)
} else if ay == 0.5 && ax != 0.5 {
(rect.width, rect.width / r)
} else {
let w = rect.width.max(rect.height * r);
(w, w / r)
};
// Scaled to fit, never clamped to fit: clamping one axis against the
// frame would break the very ratio this exists to hold.
@@ -1298,7 +1311,8 @@ impl Framing {
// count active stages, and a neutral graph must generate none.
if !self.is_active() {
return " // Source position, normalised and centred: the whole frame, unrotated.
let src_dims = textureDimensions(source);
let tex_dims = textureDimensions(source);
let src_dims = select(tex_dims, vec2<u32>(u.source_full.xy), u.source_full.x > 0.5);
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
var p = (uv - vec2<f32>(0.5)) * aspect;
@@ -1314,7 +1328,8 @@ impl Framing {
// warp chain expects: the centre is (0, 0) and the radius is 1 at the
// corner. Working here rather than in pixels is what makes the map
// independent of the resolution being rendered at.
let src_dims = textureDimensions(source);
let tex_dims = textureDimensions(source);
let src_dims = select(tex_dims, vec2<u32>(u.source_full.xy), u.source_full.x > 0.5);
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
",
@@ -2253,6 +2268,42 @@ mod tests {
assert!((c.y - start.y).abs() < 1e-5, "{c:?}");
}
#[test]
fn a_locked_edge_leads_and_the_far_side_stays_put() {
// An edge dragged inward under a lock must narrow the crop. With the
// short axis leading, the untouched height would win and push the
// edge straight back out.
let start = CropRect {
x: 0.2,
y: 0.2,
width: 0.4,
height: 0.6,
};
// Right edge held, dragged in: the left side and the vertical
// centre stay, the width is what was asked for.
let c = start.with_aspect(4000, 4000, 1.0, (0.0, 0.5));
assert!((c.x - start.x).abs() < 1e-5, "{c:?}");
assert!((c.width - start.width).abs() < 1e-5, "{c:?}");
assert!((c.height - start.width).abs() < 1e-5, "{c:?}");
assert!(
(c.y + c.height / 2.0 - (start.y + start.height / 2.0)).abs() < 1e-5,
"{c:?}"
);
// Top edge held: the bottom and the horizontal centre stay, the
// height is what was asked for.
let c = start.with_aspect(4000, 4000, 1.0, (0.5, 1.0));
assert!(
(c.y + c.height - (start.y + start.height)).abs() < 1e-5,
"{c:?}"
);
assert!((c.width - c.height).abs() < 1e-5, "{c:?}");
assert!(
(c.x + c.width / 2.0 - (start.x + start.width / 2.0)).abs() < 1e-5,
"{c:?}"
);
}
#[test]
fn a_locked_rect_grows_onto_the_ratio_rather_than_shrinking_onto_it() {
// Shrinking to fit makes a one-axis drag do nothing at all: the other
+82 -25
View File
@@ -83,6 +83,13 @@ impl ParamCapability {
}
}
/// TRACES: FR-DSP-2
/// How far past the framing's own footprint [`EditGraph::source_region`]
/// reaches when a lens warp is active, as a fraction of the frame on each
/// side. Distortion profiles move a corner by a few per cent of the frame; a
/// window short of what the warp reads would render the missing strip black.
pub const WARP_MARGIN: f32 = 0.04;
/// An ordered pipeline of operations, plus how the result is framed.
pub struct EditGraph {
ops: Vec<Box<dyn Operation>>,
@@ -292,6 +299,57 @@ impl EditGraph {
self.framing.output_size(width, height)
}
/// TRACES: FR-DSP-2 | NFR-RES-2
/// The part of the source the visible region reads, as a rectangle in
/// normalised source coordinates, clamped to the frame.
///
/// For a photograph larger than one texture: a render of part of it —
/// the canvas zoomed in, one tile of an export — binds only this window
/// of the source (see `dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS`).
///
/// The framing is walked on the CPU with [`Framing::source_at`], along
/// the border and across the interior, so a straightened or keystoned
/// view gets the box around the quadrilateral it actually reads. The lens
/// warps have no CPU mirror, so when one is active the box is widened
/// by [`WARP_MARGIN`] of the frame on each side: a distortion profile
/// moves a corner by a few per cent of the frame at most. `halo`, in
/// source pixels, is added on top — the detail stage's reach, which reads
/// beyond the pixels it writes.
pub fn source_region(&self, source: (u32, u32), halo: u32) -> crate::framing::CropRect {
const STEPS: usize = 16;
let (sw, sh) = (source.0.max(1), source.1.max(1));
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for j in 0..=STEPS {
for i in 0..=STEPS {
let out = (i as f32 / STEPS as f32, j as f32 / STEPS as f32);
let (x, y) = self.framing.source_at(out, sw, sh);
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
}
let warp = if crate::lens::compose_warps(&self.warps).is_active() {
WARP_MARGIN
} else {
0.0
};
// Two pixels beyond the halo: the bilinear tap's second texel, and
// the rounding of the box to whole pixels by the caller.
let px = (halo as f32 + 2.0) / sw as f32;
let py = (halo as f32 + 2.0) / sh as f32;
let x0 = (x0 - warp - px).clamp(0.0, 1.0);
let y0 = (y0 - warp - py).clamp(0.0, 1.0);
let x1 = (x1 + warp + px).clamp(0.0, 1.0);
let y1 = (y1 + warp + py).clamp(0.0, 1.0);
crate::framing::CropRect {
x: x0,
y: y0,
width: (x1 - x0).max(0.0),
height: (y1 - y0).max(0.0),
}
}
/// Descriptors for every operation, in order.
///
/// Operations only — framing is not one, and is reached through
@@ -952,46 +1010,35 @@ impl EditGraph {
((fw as f32 * view.width).round() as u32).max(1),
((fh as f32 * view.height).round() as u32).max(1),
);
crate::detail::RenderScale::new(render, full)
crate::detail::RenderScale::new(render, full).within((fw, fh))
}
/// TRACES: FR-DEV-3 | FR-DSP-1
/// Generate the detail stage for this edit at one resolution, to sRGB.
/// Generate the detail stage for this edit at one resolution.
///
/// Empty for every edit with no active neighbourhood operation, which is
/// almost all of them — and in that case [`Self::compose`] emits the
/// single encoded dispatch it always has.
pub fn compose_detail(
&self,
source: (u32, u32),
render: (u32, u32),
) -> crate::detail::ComposedDetail {
self.compose_detail_for(source, render, dr_types::ColourSpace::Srgb)
}
/// TRACES: FR-EXP-2
/// The detail stage, encoded into a chosen output space.
///
/// The space belongs here as well as on [`Self::compose_for`] because when
/// a detail stage exists it is the *last* pass that performs the output
/// transform — the fused pass stops at linear working values. Composing
/// the two halves for different spaces would encode the edit twice, or
/// not at all.
/// No output space: since D19 no detail pass encodes. The fused pass's
/// view pass reads what the last one wrote and performs the view transform
/// and the output transform, so it is [`Self::compose_for`] alone that
/// names the space.
///
/// `source` is the demosaiced image's size and `render` the size being
/// drawn. The scale is worked out here rather than handed in, because the
/// repairs need the *source* size as well — a spot is stored in normalised
/// source coordinates and has to be put through the framing to find out
/// where it lands on this render, and a [`crate::detail::RenderScale`]
/// describes the region on screen rather than the photograph.
pub fn compose_detail_for(
pub fn compose_detail(
&self,
source: (u32, u32),
render: (u32, u32),
output: dr_types::ColourSpace,
) -> crate::detail::ComposedDetail {
let scale = self.render_scale(source, render);
let spots = self.spots.passes(&self.framing, source, scale);
crate::detail::compose_detail_with(&self.ops, &spots, scale, output)
crate::detail::compose_detail_with(&self.ops, &spots, scale)
}
/// TRACES: FR-DEV-3d
@@ -1122,13 +1169,21 @@ mod tests {
fn a_fresh_graph_is_neutral() {
// Opening an unedited image must produce the image, not an
// interpretation of it.
//
// One block, and it is the view transform: a view operation is
// composed at its defaults, because a photograph with no view
// transform is a scan rather than a picture (FR-DEV-3j). It is still
// neutral in the sense that matters here — nothing moved, nothing is
// written — and every adjustment is absent.
let g = EditGraph::default_chain();
assert!(g.is_neutral());
let source = g.compose().source;
assert_eq!(
g.compose().source.matches("---- ").count(),
0,
"a neutral graph must generate no operation blocks"
source.matches("---- ").count(),
1,
"a neutral graph must generate no adjustment blocks"
);
assert!(source.contains("---- view_transform ----"));
}
#[test]
@@ -1207,13 +1262,15 @@ mod tests {
#[test]
fn only_active_operations_reach_the_shader() {
// The composition property, end to end: two adjustments out of seven
// available must generate a shader doing exactly two things.
// available must generate a shader doing exactly two things — and
// the view transform, which every render has (FR-DEV-3j).
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
let shader = g.compose();
assert_eq!(shader.source.matches("---- ").count(), 2);
assert_eq!(shader.source.matches("---- ").count(), 3);
assert!(shader.source.contains("---- view_transform ----"));
assert!(shader.source.contains("---- exposure ----"));
assert!(shader.source.contains("---- white_balance ----"));
assert!(!shader.source.contains("---- saturation ----"));
+3 -1
View File
@@ -581,7 +581,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
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_uniformity: 1.0,
},
+27 -5
View File
@@ -50,6 +50,8 @@ pub mod preset;
pub mod sidecar;
pub mod spot;
pub mod state;
pub mod tiles;
pub mod view;
pub use coverage::Coverage;
pub use declared::{Declaration, DeclaredOp};
@@ -66,8 +68,8 @@ pub use history::{Edit, Entry as HistoryEntry, History, Step};
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET,
RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
OutputMode, Stage, Uniform, CLIP_ONSET, RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
SOURCE_WINDOW_UNIFORM_FIELDS, SOURCE_WINDOW_UNIFORM_OFFSET, WHOLE_SOURCE_WINDOW,
};
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Reach, Scope};
pub use sidecar::{Sidecar, Version};
@@ -132,7 +134,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0,
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_uniformity: 0.97,
},
@@ -167,7 +171,21 @@ mod tests {
let mut fused_blocks = 0;
for desc in g.descriptors() {
let id = desc.id.0;
let point = shader.source.contains(&format!("---- {id} ----"));
// The film is loaded here, and a stock is a rendering: the view
// transform it replaces is correctly in neither stage (FR-DEV-3f,
// FR-DEV-3j).
if id == crate::ops::view_transform::ID.0 {
assert!(!shader.source.contains("---- view_transform ----"));
continue;
}
// A view operation is in the view pass when a detail stage
// follows, which it does here (D19).
let block = format!("---- {id} ----");
let point = shader.source.contains(&block)
|| shader
.view
.as_ref()
.is_some_and(|v| v.source.contains(&block));
let neighbourhood = detail
.passes
.iter()
@@ -180,8 +198,12 @@ mod tests {
);
fused_blocks += usize::from(point);
}
let view_blocks = shader
.view
.as_ref()
.map_or(0, |v| v.source.matches("---- ").count());
assert_eq!(
shader.source.matches("---- ").count(),
shader.source.matches("---- ").count() + view_blocks,
fused_blocks,
"the fused shader carries a block nothing in the chain asked for"
);
+407 -89
View File
@@ -73,7 +73,7 @@ use std::fmt::Write as _;
use std::sync::Arc;
use crate::coverage::Coverage;
use crate::descriptor::{Attribute, OpDescriptor, ParamId};
use crate::descriptor::{Attribute, OpDescriptor, ParamId, ParamKind};
use crate::operation::Operation;
use crate::ops;
@@ -1368,17 +1368,19 @@ pub struct MaskLayer {
/// This layer's adjustments.
///
/// A full chain, the same one [`crate::EditGraph`] holds. That is the
/// whole reason local adjustments need no per-operation support: the
/// composer already knows how to turn a chain into WGSL, and a mask layer
/// is a chain that happens to be multiplied by a mask afterwards.
/// whole reason local adjustments need no per-operation support: each
/// setting here is an offset from its default, added to the global chain's
/// setting and run where that operation runs, weighted by the mask (see
/// [`offset_onto`]).
pub ops: Vec<Box<dyn Operation>>,
}
/// The chain a mask layer holds: every point operation, and neither the
/// neighbourhood ones nor the optical corrections.
///
/// A layer's adjustments are fused into the colour dispatch and multiplied by
/// the mask afterwards, which is exactly why a layer needs no per-operation
/// A layer's adjustments are fused into the colour dispatch, each beside the
/// 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
/// 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
@@ -1642,8 +1644,8 @@ impl MaskLayer {
/// The operations in this layer's chain that reach the shader.
///
/// Neighbourhood operations are excluded, and not as an oversight. A
/// layer's chain is *fused into the point-operation pass* and multiplied
/// by the mask afterwards; the detail stage runs once, over the whole
/// layer's chain is *fused into the point-operation pass*, weighted by the
/// mask at each operation; the detail stage runs once, over the whole
/// frame, after that pass has finished (see [`crate::detail`]). There is
/// nowhere in that arrangement for a sharpening confined to one mask to
/// happen, so a detail operation in a layer would contribute an empty
@@ -1654,7 +1656,7 @@ impl MaskLayer {
self.ops
.iter()
.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.
@@ -1975,7 +1977,9 @@ impl MaskStack {
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) {
let Some(from) = self.layers.iter().position(|l| l.id == id) else {
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 uniform_fields: String,
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>,
/// TRACES: FR-DEV-19c
/// The block that draws one layer's mask over the finished picture, empty
/// when nothing is being revealed.
///
/// Kept apart from `body` because it belongs at the other end of the
/// shader. Everything in `body` runs on scene-referred colour in the
/// working space, where a flat tint would then be pushed through the base
/// curve and the camera matrix and arrive as some other colour, and a
/// Kept apart from the rest because it belongs at the other end of the
/// shader. Everything else runs on scene-referred colour in the working
/// space, where a flat tint would then be pushed through the view
/// transform and arrive as some other colour, and a
/// white-on-black alpha would arrive as neither. This runs after the
/// output transform, so what is written is what is seen.
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
/// at.
///
@@ -2068,11 +2151,18 @@ pub(crate) struct LayerShader {
/// 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
/// 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 {
uniform_fields: String::new(),
uniform_values: Vec::new(),
body: String::new(),
weights: String::new(),
ops: Vec::new(),
helpers: Vec::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());
let _ = writeln!(
out.body,
"\n // ======== mask {slot}: {} ({}) ========",
layer.display_name(),
layer.base().source.kind()
);
let _ = writeln!(out.body, " {{");
// A layer only being looked at moves no pixel, so it needs a slot for
// the reveal and no weight.
if layer.active_ops().next().is_none() {
continue;
}
// The weight, once per pixel, ahead of every operation that reads it.
//
// **`uv_src`, not `gid.xy`.** The mask array is rasterised in *source*
// space, and `uv_src` is the source position this output pixel came
// 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
// with `Framing::wgsl_prologue`, and the failure would be a mask that
// 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!(
out.body,
" m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);"
out.weights,
"\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!(
out.body,
" m = clamp(m * u.{prefix}_opacity, 0.0, 1.0);"
out.weights,
" {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() {
let id = op.descriptor().id.0;
// A fresh chain to hold the combined settings: the layer's own ops
// 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_uniforms = op.uniforms();
let op_uniforms = dst.uniforms();
if !op_uniforms.is_empty() {
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);
}
for h in op.helpers() {
for h in dst.helpers() {
if !out.helpers.iter().any(|e| e.name == h.name) {
out.helpers.push(*h);
}
}
let mut fragment = op.wgsl_body();
for u in &op_uniforms {
fragment = crate::operation::rewrite_uniform(
&fragment,
u.name,
&format!("u.{op_prefix}_{}", u.name),
);
let mut fragment = String::new();
if !dst.blends_settings() {
fragment = dst.wgsl_body();
for u in &op_uniforms {
fragment = crate::operation::rewrite_uniform(
&fragment,
u.name,
&format!("u.{op_prefix}_{}", u.name),
);
}
}
let _ = writeln!(out.body, " // ---- {id} ----");
let _ = writeln!(out.body, " {{");
for line in fragment.lines() {
let _ = writeln!(out.body, " {line}");
}
let _ = writeln!(out.body, " }}");
out.ops.push(LocalOp {
op: id,
slot,
fragment,
});
}
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
@@ -2309,6 +2424,78 @@ mod tests {
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]
fn a_layer_with_no_adjustment_is_not_in_the_shader() {
let layer = MaskLayer::new("m1", regions(&[1]));
@@ -2317,7 +2504,10 @@ mod tests {
let mut stack = MaskStack::new();
stack.push(layer);
assert!(stack.is_neutral());
assert_eq!(compose_layers_revealing(&stack, None).body, "");
assert_eq!(
compose_layers_revealing(&stack, None, &ops::chain()).weights,
""
);
}
#[test]
@@ -2403,11 +2593,11 @@ mod tests {
stack.push(lit_layer("m1", 1.0));
stack.push(lit_layer("m2", -1.0));
let shader = compose_layers_revealing(&stack, None);
assert!(shader.body.contains("sample_mask(uv_src, 0)"));
assert!(shader.body.contains("sample_mask(uv_src, 1)"));
assert!(shader.body.contains("u.mask0_opacity"));
assert!(shader.body.contains("u.mask1_opacity"));
let shader = compose_layers_revealing(&stack, None, &ops::chain());
assert!(shader.weights.contains("sample_mask(uv_src, 0)"));
assert!(shader.weights.contains("sample_mask(uv_src, 1)"));
assert!(shader.weights.contains("u.mask0_opacity"));
assert!(shader.weights.contains("u.mask1_opacity"));
}
/// The slot a layer renders through must follow `active()`, not the raw
@@ -2420,12 +2610,12 @@ mod tests {
stack.push(off);
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)"),
shader.weights.contains("sample_mask(uv_src, 0)"),
"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
@@ -2445,7 +2635,7 @@ mod tests {
stack.push(MaskLayer::new("m2", MaskSource::brush()));
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!(
stack.rendered_count(Some(&reveal)),
@@ -2483,7 +2673,7 @@ mod tests {
],
style: RevealStyle::Tint,
};
let shader = compose_layers_revealing(&stack, Some(&reveal));
let shader = compose_layers_revealing(&stack, Some(&reveal), &ops::chain());
let sky = shader
.reveal
@@ -2509,7 +2699,9 @@ mod tests {
fn nothing_is_revealed_unless_it_was_asked_for() {
let mut stack = MaskStack::new();
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
@@ -2520,9 +2712,11 @@ mod tests {
stack.push(lit_layer("m1", 1.0));
let reveal = Reveal::one("gone", RevealStyle::Tint);
assert_eq!(stack.rendered_count(Some(&reveal)), 1);
assert!(compose_layers_revealing(&stack, Some(&reveal))
.reveal
.is_empty());
assert!(
compose_layers_revealing(&stack, Some(&reveal), &ops::chain())
.reveal
.is_empty()
);
}
#[test]
@@ -2531,7 +2725,7 @@ mod tests {
stack.push(lit_layer("m1", 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("mask1_exposure_"));
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]
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();
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!(body.contains("var c = masked;"));
assert!(body.contains("masked = c;"));
assert!(body.contains("c = mix(c, masked, m);"));
assert!(src.contains("var c = local_in;"));
assert!(src.contains("local_sum = local_sum + mask_w0 * (c - local_global);"));
assert!(src.contains("c = max(local_sum, vec3<f32>(0.0));"));
}
/// **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]
+2 -2
View File
@@ -82,8 +82,8 @@ const FLOOR: f32 = 1e-4;
/// Move the graph so that `sample` renders neutral.
///
/// `sample` is the linear triple the operation's own gains multiply — camera
/// RGB with the camera's as-shot balance on, *before* the body's base curve
/// and matrix, and with the sampling operation at its defaults. Not the
/// RGB with the camera's as-shot balance on, *before* the camera matrix and
/// the view transform, and with the sampling operation at its defaults. Not the
/// pixel on the screen: the matrix mixes the channels on the way there, so
/// a colour read after it does not answer to these gains, and a solve over
/// one lands somewhere no sample asked for. Returns whether the graph was
File diff suppressed because it is too large Load Diff
+18 -58
View File
@@ -347,8 +347,15 @@ impl Operation for CaptureSharpen {
impl DetailStage for CaptureSharpen {
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
// A radius finer than one pixel of this render: the detail it would
// act on is not in this texture — it was lost to the downscale before
// this stage ran (FR-DSP-1). Guessing at it would put sharpening on
// screen that the exported file will not contain, so there is no
// pass, and the interface is free to say `zoom to 1:1`. An empty
// chain is a whole render since D19: the fused pass's view pass
// performs the output transform whatever the chain holds.
if !self.resolves(scale) {
return vec![nothing_to_sharpen()];
return Vec::new();
}
let extent = self.kernel(scale);
@@ -396,41 +403,6 @@ impl DetailStage for CaptureSharpen {
}
}
/// The pass emitted when the radius is finer than a render pixel.
///
/// One dispatch that changes nothing, rather than an empty chain, and the
/// difference is not stylistic. [`crate::operation::compose_full`] decides
/// from the *operations* — before any resolution is known — that an active
/// detail operation means the fused pass hands on unclipped linear values
/// instead of encoding its own output. If this returned no passes at all,
/// that decision would still stand and nothing downstream would ever perform
/// the output transform: `dr-gpu` would be handed a linear-working shader
/// with an empty chain and refuse it.
///
/// So the honest "nothing survives at this scale" still has to carry the
/// encode, and one pass that does only that is exactly the resolve step the
/// stage would otherwise need. It costs a single copy of a proxy-sized
/// texture, which is a rounding error against the dispatches around it.
fn nothing_to_sharpen() -> DetailPass {
DetailPass {
output_scale: 1,
label: "unresolved",
// Reads only the pixel it writes, so a tile needs no halo at all.
radius: 0,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: Vec::new(),
wgsl: "// The chosen radius is finer than one pixel of this render, so the detail
// it would act on is not in this texture — it was lost to the downscale
// before this stage ran (FR-DSP-1). Guessing at it would put sharpening on
// screen that the exported file will not contain, so this pass passes the
// colour through unchanged and the interface is free to say `zoom to 1:1`.
//
// `c` already holds this pixel; leaving it alone is the whole body."
.to_string(),
}
}
/// One axis of the separable unsharp mask.
///
/// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the
@@ -528,7 +500,6 @@ c = select(c, scaled, centre > 1e-5);"#;
mod tests {
use super::*;
use crate::EditGraph;
use dr_types::ColourSpace;
/// The develop chain with the sharpener turned up.
///
@@ -549,7 +520,7 @@ mod tests {
// The scale is what these tests vary, so it is rebuilt into the two
// sizes it stands for rather than handed over: a render of the full
// frame at `render_size`, from a source of `full_size`.
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb)
graph.compose_detail(scale.full_size(), scale.render_size())
}
#[test]
@@ -591,13 +562,11 @@ mod tests {
assert_eq!(first.label, "capture_sharpen/horizontal");
assert_eq!(last.label, "capture_sharpen/vertical");
assert!(!first.writes_output);
assert!(first.source.contains("texture_storage_2d<rgba16float"));
assert!(!first.source.contains("fn encode_output"));
assert!(last.writes_output);
assert!(last.source.contains("texture_storage_2d<rgba8unorm"));
assert!(last.source.contains("fn encode_output"));
// Neither encodes: the view pass after the detail stage does (D19).
for pass in [first, last] {
assert!(pass.source.contains("texture_storage_2d<rgba16float"));
assert!(!pass.source.contains("fn encode_output"));
}
// Two shaders, so two pipeline-cache entries. Sharing one would run
// the horizontal pass's uniforms through the vertical pass's slots.
@@ -723,20 +692,11 @@ mod tests {
let proxy = RenderScale::new((1000, 1000), (4000, 4000));
assert!(!proxy.resolves(1.0));
// Empty: since D19 nothing in the detail stage encodes, so a chain
// with nothing to draw is a whole render — the fused pass's view pass
// finishes it.
let composed = chain_at(&graph, proxy);
// Not empty, though. See `nothing_to_sharpen`: the fused pass has
// already been composed to hand on linear values, so *something* must
// still perform the output transform.
assert_eq!(composed.len(), 1);
assert_eq!(composed.radius(), 0, "it reads no neighbours");
let pass = &composed.passes[0];
assert_eq!(pass.label, "capture_sharpen/unresolved");
assert!(pass.writes_output);
assert!(pass.source.contains("fn encode_output"));
assert!(
!pass.source.contains("for (var i ="),
"the pass-through must not walk a kernel it has decided not to run"
);
assert!(composed.is_empty());
// Zooming to 1:1 is what brings it back — the view rect shrinks while
// the render target keeps its size — so there is no separate
+56 -19
View File
@@ -488,6 +488,36 @@ fn curve_eval(
}",
};
/// TRACES: FR-DEV-2
/// The curve continued past its last point, for scene values above the
/// widget's axis.
const CURVE_EXTEND: Helper = Helper {
name: "curve_extend",
source: "\
// A five-point curve at `x`, continued past its last point along the slope of
// its last span.
//
// The widget draws a 0..1 axis, and scene-referred values do not stop at 1
// (D19): exposure and highlight recovery put them above it, and the view
// transform after every operation is what brings them down. Flat past the last
// point — which is what `curve_eval` gives, and what this curve did until
// D19 — made every one of them the same number, a hard clip in the middle of
// the chain. Continued along the last span instead, an identity curve stays
// the identity to any height, and a curve that lifts the highlights keeps
// lifting them. The slope is the last span's secant, which is also the
// tangent `curve_eval` gives the last point, so the join is smooth; monotone
// points make it non-negative.
fn curve_extend(
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
x3: f32, y3: f32, x4: f32, y4: f32, x: f32,
) -> f32 {
if (x <= x4) {
return curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, x);
}
return y4 + (x - x4) * max((y4 - y3) / (x4 - x3), 0.0);
}",
};
/// One colour component through its own curve.
const CHANNEL_CURVE: Helper = Helper {
name: "channel_curve",
@@ -504,20 +534,21 @@ const CHANNEL_CURVE: Helper = Helper {
// it changes the proportions between the components, which is what makes it
// chromatic where the master is tonal.
//
// The clamp is the curve's promise rather than an oversight: its last point
// *is* white, so a component arriving above the axis takes the value the curve
// gives at 1. The master does the same to a luminance above 1, through the
// gain it applies; a channel curve that instead let highlights past unchanged
// would tint them differently from every tone below them, which reads as a
// coloured fringe along a blown edge.
// Above the axis the curve continues along its last span (`curve_extend`),
// exactly as the master's does, so a highlight is tinted the way every tone
// just below it is — a component that stopped at the curve's top instead
// would put a coloured fringe along a blown edge, and flattened every
// scene-referred highlight into one value besides (D19). Below zero there is
// no light to curve; the floor is the one clamp left, and it is at zero, not
// at one.
fn channel_curve(
v: f32,
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
x3: f32, y3: f32, x4: f32, y4: f32,
) -> f32 {
let encoded = pow(clamp(v, 0.0, 1.0), 1.0 / 2.2);
let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
return pow(clamp(curved, 0.0, 1.0), 2.2);
let encoded = pow(max(v, 0.0), 1.0 / 2.2);
let curved = curve_extend(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
return pow(max(curved, 0.0), 2.2);
}",
};
@@ -535,10 +566,11 @@ static MASTER_HELPERS: &[Helper] = &[
helpers::APPLY_TONE_GAIN,
CURVE_SPAN,
CURVE_EVAL,
CURVE_EXTEND,
];
/// The per-channel curves alone.
static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CHANNEL_CURVE];
static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CURVE_EXTEND, CHANNEL_CURVE];
/// Both.
static ALL_HELPERS: &[Helper] = &[
@@ -546,6 +578,7 @@ static ALL_HELPERS: &[Helper] = &[
helpers::APPLY_TONE_GAIN,
CURVE_SPAN,
CURVE_EVAL,
CURVE_EXTEND,
CHANNEL_CURVE,
];
@@ -553,16 +586,17 @@ static ALL_HELPERS: &[Helper] = &[
const MASTER_BODY: &str = "\
let luma = luminance(c);
if (luma > 0.0001) {
// The curve is authored on a display-referred 0..1 axis, which is where
// the eye reads tone and where the widget's grid lives. Scene-referred
// luminance is unbounded, so it is encoded to that axis, curved, and
// decoded back — otherwise a point placed at the middle of the grid
// would not correspond to the middle of the visible range.
let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2);
// The curve is authored on a 0..1 axis, which is where the widget's grid
// lives, with a 2.2 gamma so that a point placed at the middle of the
// grid means the middle of the visible range. Scene-referred luminance
// does not stop at 1: above the axis the curve continues along its last
// span (`curve_extend`) rather than clipping, because the view transform
// after every operation is what brings a highlight down (D19).
let encoded = pow(luma, 1.0 / 2.2);
let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
let curved = curve_extend(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
let decoded = pow(clamp(curved, 0.0, 1.0), 2.2);
let decoded = pow(max(curved, 0.0), 2.2);
// Applied as a ratio so hue is preserved, exactly as contrast does.
c = apply_tone_gain(c, decoded / luma);
}";
@@ -1288,7 +1322,10 @@ mod tests {
c.set_param(P2_Y, 0.7);
let body = c.wgsl_body();
assert!(body.contains("curve_eval("), "the master curve is missing");
assert!(
body.contains("curve_extend("),
"the master curve is missing"
);
assert!(
!body.contains("channel_curve("),
"an untouched channel reached the shader:\n{body}"
+1 -7
View File
@@ -541,14 +541,13 @@ c = (c - lifted) / t;";
mod tests {
use super::*;
use crate::detail::compose_detail;
use dr_types::ColourSpace;
fn ops(amount: f32) -> Vec<Box<dyn Operation>> {
vec![Box::new(Dehaze::with_amount(amount))]
}
fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail {
compose_detail(&ops(amount), scale, ColourSpace::Srgb)
compose_detail(&ops(amount), scale)
}
#[test]
@@ -658,11 +657,6 @@ mod tests {
let split = Split::of(Dehaze::with_amount(60.0).patch(RenderScale::full((2000, 1500))));
assert!(composed.passes.iter().all(|p| p.radius == split.extent()));
// Only the last writes the display texture, so the output transform
// happens exactly once (FR-DEV-2).
assert!(!composed.passes[0].writes_output);
assert!(composed.passes[1].writes_output);
// Nothing here uses the reduced chain — see the module documentation
// for why a second operation cannot pick its own `output_scale` while
// the runner holds one reduced buffer.
+272 -122
View File
@@ -1,30 +1,35 @@
//! TRACES: FR-DEV-3f
//! Film simulation — the stock renders the picture.
//!
//! # Why this one replaces the base curve
//! # Why this one is the view transform
//!
//! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The base
//! curve exists because sensor data is scene-referred and nothing anybody looks
//! at is (FR-DEV-3e); a film stock's characteristic curve does the same job,
//! from measurements, with a toe and a shoulder that were coated onto acetate
//! rather than drawn. Running both renders the image twice — the camera's
//! JPEG-ish rendering, and then a film's rendering of that — which is not what
//! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The view
//! transform exists because sensor data is scene-referred and nothing anybody
//! looks at is (FR-DEV-3j); a film stock's characteristic curve does the same
//! job, from measurements, with a toe and a shoulder that were coated onto
//! acetate rather than drawn. Running both renders the image twice — the
//! default rendering, and then a film's rendering of that — which is not what
//! either is for and looks like neither.
//!
//! So this node declares [`Operation::renders`], and the composer answers by
//! emitting neither the base curve nor the camera matrix. Both jobs move here:
//! the fragment takes camera RGB, converts it to linear sRGB itself with the
//! matrix already in the uniform block, and returns linear sRGB. That is a
//! contract worth stating plainly, because a node that got half of it wrong
//! would produce a picture that renders perfectly and is wrong everywhere.
//! So this node is in [`Stage::View`] and declares [`Operation::renders`]: when
//! a stock is loaded the composer puts it at the end of the chain in place of
//! the default sigmoid (D19). It is handed working-space colour — linear sRGB
//! primaries, scene-referred, after every other operation and after the detail
//! stage — and returns display-referred linear sRGB for the output transform.
//! Before D19 it ran at order 25, after exposure and before everything else,
//! and the operations below it acted on its output. They now act on the scene
//! it is shown: an edit is a decision about the exposure the negative
//! receives, and the film is the last thing that happens to the picture.
//!
//! # Why the tables are not parameters
//!
//! For the same reason [`crate::ops::vignetting`]'s coefficients are not: they
//! are measurements of a physical thing, not something a slider moves. The
//! sliders here are exposure and print exposure, which are what a photographer
//! and a printer actually control. `dr-film` turns a stock plus those two
//! numbers into [`FilmTables`]; this node knows only the layout.
//! sliders here are exposure, push, print exposure and format, which are what
//! a photographer and a printer actually control. `dr-film` turns a stock into
//! [`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
//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does
@@ -32,7 +37,7 @@
use std::sync::{Arc, LazyLock};
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Operation, Uniform};
use crate::operation::{Operation, Stage, Uniform};
pub const ID: OpId = OpId("film_sim");
pub const EXPOSURE: ParamId = ParamId("exposure");
@@ -63,6 +68,15 @@ static FORMATS: [LocalizedKey; 6] = [
/// take; [`FilmTables::is_well_formed`] is what stops the two drifting.
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.
///
/// A table rather than a formatted string, because a `Uniform`'s name is
@@ -74,6 +88,24 @@ static MATRIX_FIELDS: [[&str; 3]; 3] = [
["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(|| {
Arc::new(OpDescriptor {
// Tone and colour both, and not `Effect`: a stock is not something applied
@@ -115,48 +147,86 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
/// Layout is the contract between the two crates, so it is written down here
/// and checked rather than assumed:
///
/// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel `c`.
/// - `curves` — `CURVE_SAMPLES` density triples, uniform over
/// `[curve_log_min, curve_log_max]`.
/// - `lut` — `lut_size³` linear sRGB triples, uniform over `[0, density_max]`
/// on each axis, with the **red axis varying fastest**: index
/// `(b * size + g) * size + r`. That is the order a 3D texture upload
/// expects, 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.
/// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel
/// `c`, at unit gain: camera exposure is a per-pixel setting.
/// - `curves` — one row of `CURVE_SAMPLES` density triples per
/// `push_stations` entry, uniform over `[curve_log_min, curve_log_max]`,
/// and then, when printed, one more row: the paper's, uniform over
/// `[paper.log_min, paper.log_max]`.
/// - `lut` — `lut_size³` triples uniform over `[0, density_max]` on each
/// axis, with the **red axis varying fastest**: index
/// `(b * size + g) * size + r`. Linear sRGB when the film is viewed
/// directly; the paper's log₁₀ exposure through the negative when it is
/// printed, followed by a second cube, paper density over
/// `[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)]
pub struct FilmTables {
pub exposure_matrix: [[f32; 3]; 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_max: f32,
pub lut: Vec<[f32; 3]>,
pub density_max: f32,
pub lut_size: usize,
/// The print, for a negative printed on paper.
pub paper: Option<PaperTables>,
/// TRACES: FR-DEV-3f
/// Grains in one pixel's patch of film, per layer, with the density
/// ceiling and uniformity the variance is taken against. Zero particles
/// means no grain, which is how the control is turned off.
pub grain_particles: [f32; 3],
/// Grains in one pixel's patch of film, per format and then per layer,
/// with the density ceiling and uniformity the variance is taken against.
/// Zero particles means no grain, which is how the control is turned off.
pub grain_particles: [[f32; 3]; FORMAT_COUNT],
pub grain_density_max: [f32; 3],
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 {
/// 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.
///
/// Checked on the way in, because the failure otherwise is a shader
/// sampling past the end of a texture: undefined, silent, and different on
/// every driver.
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.len() == self.lut_size.pow(3)
&& self.lut.len() == self.lut_size.pow(3) * (1 + printed)
&& self.density_max > 0.0
&& self.curve_log_max > self.curve_log_min
&& paper_ok
}
}
@@ -237,69 +307,95 @@ impl Operation for FilmSim {
self.tables.is_some()
}
/// This node renders; the camera's own rendering must not also run.
/// This node renders; the default view transform must not also run.
fn renders(&self) -> bool {
true
}
/// TRACES: FR-DEV-3f | FR-DEV-3j
/// The view transform's place, at the end of the chain (D19).
fn stage(&self) -> Stage {
Stage::View
}
fn set_film_tables(&mut self, tables: Option<&FilmTables>) {
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> {
let Some(t) = &self.tables else {
return Vec::new();
};
let m = t.exposure_matrix;
// Exposure rides in the matrix on the CPU when the stock is baked, so
// what is left here is the *shader's* copy of the same nine numbers.
// 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() {
let mut out = Vec::with_capacity(64);
let mut push = |name: &'static str, value: f32| out.push(Uniform { name, value });
for (l, row) in t.exposure_matrix.iter().enumerate() {
for (c, v) in row.iter().enumerate() {
out.push(Uniform {
name: MATRIX_FIELDS[l][c],
value: *v,
});
push(MATRIX_FIELDS[l][c], *v);
}
}
for (l, name) in ["gn0", "gn1", "gn2"].into_iter().enumerate() {
out.push(Uniform {
name,
value: t.grain_particles[l],
});
for (f, per_layer) in t.grain_particles.iter().enumerate() {
for (l, v) in per_layer.iter().enumerate() {
push(GRAIN_FIELDS[f][l], *v);
}
}
for (l, name) in ["gd0", "gd1", "gd2"].into_iter().enumerate() {
out.push(Uniform {
name,
value: t.grain_density_max[l],
});
push(name, t.grain_density_max[l]);
}
out.push(Uniform {
name: "grain_u",
value: t.grain_uniformity,
});
out.push(Uniform {
name: "log_min",
value: t.curve_log_min,
});
out.push(Uniform {
name: "log_max",
value: t.curve_log_max,
});
out.push(Uniform {
name: "density_max",
value: t.density_max,
});
out.push(Uniform {
name: "lut_size",
value: t.lut_size as f32,
});
out.push(Uniform {
name: "print_exposure",
value: self.print_exposure,
push("grain_u", t.grain_uniformity);
push("log_min", t.curve_log_min);
push("log_max", t.curve_log_max);
push("density_max", t.density_max);
push("lut_size", t.lut_size as f32);
let last = *t.push_stations.last().unwrap_or(&0.0);
for (i, name) in PUSH_FIELDS.into_iter().enumerate() {
push(name, t.push_stations.get(i).copied().unwrap_or(last));
}
push("rows", t.curve_rows() as f32);
let paper = t.paper.unwrap_or(PaperTables {
balance: [0.0; 3],
log_min: 0.0,
log_max: 1.0,
density_max: 1.0,
});
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
}
@@ -309,20 +405,17 @@ impl Operation for FilmSim {
// sampler binding, and adding one to interpolate two lookups would
// cost a binding in every shader whether or not a film is loaded.
"\
// Camera RGB to linear sRGB. The film's exposure matrix is defined against
// sRGB primaries, and this node has taken over the conversion the composer
// would otherwise have emitted at the end — see `Operation::renders`.
let scene = vec3<f32>(
dot(u.cam_to_srgb_0.rgb, c),
dot(u.cam_to_srgb_1.rgb, c),
dot(u.cam_to_srgb_2.rgb, c),
);
// Working-space colour, which is linear sRGB primaries — what the film's
// exposure matrix is defined against. The composer converted out of camera
// RGB before any scene-stage operation ran (D19).
let scene = c;
// 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
// integral over wavelength collapsed into these nine numbers when the stock
// was baked.
let exposure = vec3<f32>(
// was baked. The camera's exposure is a gain on it, applied here rather than
// 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>(m10, m11, m12), scene),
dot(vec3<f32>(m20, m21, m22), scene),
@@ -331,12 +424,16 @@ let exposure = vec3<f32>(
// the curve, and the toe is where it belongs.
let log_exposure = log10(max(exposure, vec3<f32>(0.0)) + 1e-10);
// The characteristic curve: what density each layer develops to. Clamped, not
// extrapolated — past the shoulder a real emulsion stops responding, and
// extrapolating would turn a blown highlight into a colour cast that grows the
// more it is overexposed.
let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min),
vec3<f32>(0.0), vec3<f32>(1.0)));
// The characteristic curve: what density each layer develops to, at this
// pixel's push. Clamped, not extrapolated — past the shoulder a real emulsion
// stops responding, and extrapolating would turn a blown highlight into a
// colour cast that grows the more it is overexposed.
let density = film_curve_pushed(
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
// Grain, on the density and before the dye.
@@ -346,16 +443,40 @@ let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min),
// through whatever density resulted. Adding noise to the finished colour --
// which is what an effect does -- tints the highlights wrong, because that
// 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),
grain_u);
// Dye absorption, the print through the negative, the paper, the viewing
// illuminant and the chromatic adaptation — all of which take exactly three
// numbers in, which is why they fit in one lookup.
c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_size);"
.into()
// Dye absorption through to what comes next — all of it takes exactly three
// numbers in, which is why it fits in one lookup. Viewed directly, that is
// the picture; printed, it is the light the paper receives through the
// negative, in log exposure.
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] {
@@ -363,7 +484,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 {
name: "film_hash",
source: "\
@@ -453,9 +574,9 @@ fn log10(v: vec3<f32>) -> vec3<f32> {
crate::operation::Helper {
name: "film_curve",
source: "\
// Three characteristic curves, sampled from a 256-wide texture and
// interpolated by hand. `t` is already normalised to the curve's domain.
fn film_curve(t: vec3<f32>) -> vec3<f32> {
// Three characteristic curves, one row of a 256-wide texture, interpolated by
// hand. `t` is already normalised to the curve's domain.
fn film_curve(t: vec3<f32>, row: u32) -> vec3<f32> {
let samples = u32(textureDimensions(film_curves).x);
let last = f32(samples - 1u);
var out = vec3<f32>(0.0);
@@ -463,20 +584,45 @@ fn film_curve(t: vec3<f32>) -> vec3<f32> {
let x = t[ch] * last;
let i = min(u32(floor(x)), samples - 2u);
let f = x - f32(i);
let a = textureLoad(film_curves, vec2<i32>(i32(i), 0), 0);
let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, 0), 0);
let a = textureLoad(film_curves, vec2<i32>(i32(i), i32(row)), 0);
let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, i32(row)), 0);
out[ch] = mix(a[ch], b[ch], f);
}
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 {
name: "film_lut",
source: "\
// Trilinear interpolation of the density lookup, by hand for the same reason
// the curve above is: there is no sampler bound, and the eight loads are
// cache-neighbours.
fn film_lut(t: vec3<f32>, size: f32) -> vec3<f32> {
// Trilinear interpolation of one cube of the lookup, by hand for the same
// reason the curve above is: there is no sampler bound, and the eight loads
// are cache-neighbours. `z0` is where the cube starts in depth: the film's at
// zero, the paper's stacked after it.
fn film_lut(t: vec3<f32>, size: f32, z0: i32) -> vec3<f32> {
let n = i32(size);
let x = t * (size - 1.0);
let base = min(vec3<i32>(floor(x)), vec3<i32>(n - 2));
@@ -489,7 +635,7 @@ fn film_lut(t: vec3<f32>, size: f32) -> vec3<f32> {
let wy = select(1.0 - f.y, f.y, dy == 1);
for (var dz = 0; dz < 2; dz = 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
* textureLoad(film_lut_texture, p, 0).rgb;
}
@@ -513,7 +659,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0,
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_uniformity: 0.97,
}
@@ -558,10 +706,10 @@ mod tests {
#[test]
fn it_declares_itself_a_rendering_transform() {
// The whole reason the composer skips the base curve and the camera
// matrix. If this ever returned false the picture would be rendered
// twice and converted twice, which looks like a colour management bug
// a long way from here.
// The whole reason the composer emits the stock in the view
// transform's place rather than beside it. If this ever returned
// false the picture would be rendered twice, which looks like a
// colour management bug a long way from here.
assert!(FilmSim::new().renders());
}
@@ -584,13 +732,15 @@ mod tests {
}
#[test]
fn the_fragment_converts_out_of_camera_space_itself() {
// It has to: it has taken over the conversion the composer would
// otherwise emit at the end.
fn the_fragment_is_handed_working_space_colour() {
// TRACES: FR-DEV-3f
// D19: the composer leaves camera space before any scene-stage
// operation, so a film converting again would apply the camera
// matrix twice.
let mut op = FilmSim::new();
op.set_tables(Some(tables()));
let wgsl = op.wgsl_body();
assert!(wgsl.contains("cam_to_srgb_0"), "{wgsl}");
assert!(!wgsl.contains("cam_to_srgb"), "{wgsl}");
}
#[test]
+1 -2
View File
@@ -878,7 +878,6 @@ c = c * exp2(stops);"
mod tests {
use super::*;
use crate::detail::compose_detail;
use dr_types::ColourSpace;
/// The two controls, as the graph would hold them.
fn ops(clarity: f32, texture: f32) -> Vec<Box<dyn Operation>> {
@@ -889,7 +888,7 @@ mod tests {
}
fn composed(clarity: f32, texture: f32, scale: RenderScale) -> crate::ComposedDetail {
compose_detail(&ops(clarity, texture), scale, ColourSpace::Srgb)
compose_detail(&ops(clarity, texture), scale)
}
#[test]
+3 -1
View File
@@ -74,6 +74,7 @@ pub mod distortion;
pub mod film_sim;
pub mod local_contrast;
pub mod noise_reduction;
pub mod view_transform;
pub mod vignetting;
pub use aberration::Aberration;
@@ -82,11 +83,12 @@ pub use colour_mixer::ColourMixer;
pub use curve::ToneCurve;
pub use dehaze::Dehaze;
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
// documentation for why that is two nodes and not one.
pub use local_contrast::{Clarity, Texture};
pub use noise_reduction::NoiseReduction;
pub use view_transform::ViewTransform;
pub use vignetting::Vignetting;
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
+1 -7
View File
@@ -615,7 +615,6 @@ c = vec3<f32>(y0) + chroma_sum / weight_sum;";
#[cfg(test)]
mod tests {
use super::*;
use dr_types::ColourSpace;
/// A 24 MP frame, and the panel a develop view might show it in.
const FULL: (u32, u32) = (6000, 4000);
@@ -629,7 +628,7 @@ mod tests {
}
fn compose(op: NoiseReduction, scale: RenderScale) -> crate::detail::ComposedDetail {
crate::detail::compose_detail(&chain_with(op), scale, ColourSpace::Srgb)
crate::detail::compose_detail(&chain_with(op), scale)
}
#[test]
@@ -674,11 +673,6 @@ mod tests {
// The luminance pass runs first, so the chroma guide is the denoised
// luminance rather than the raw one.
assert_eq!(both.passes[0].label, "noise_reduction/luminance");
// And only the last pass in the whole chain performs the output
// transform, whichever pass that happens to be.
assert!(!both.passes[0].writes_output);
assert!(!both.passes[1].writes_output);
assert!(both.passes[2].writes_output);
}
#[test]
+211
View File
@@ -0,0 +1,211 @@
//! TRACES: FR-DEV-3j
//! The view transform as an operation: the photographer's two numbers for
//! the curve [`crate::view`] defines.
//!
//! # Why it is a node now, when the base curve could not be
//!
//! The base curve was kept out of the chain for reasons that were all about
//! the *body*: it was looked up by camera model, so as a node it would have
//! carried one camera's rendering onto another camera's file through a shared
//! sidecar, shown a dead slider on an unprofiled body, and opened a profiled
//! one reporting itself modified. D19 removed the premise. There is one view
//! transform for every body, so its settings are a decision about the picture
//! like any other, and they belong in the sidecar, the history and a mask
//! layer.
//!
//! # Why it is composed at its defaults
//!
//! A neutral operation is normally left out of the shader, and "active" means
//! "moved from its defaults". Both stay true here — an untouched photograph
//! writes no view transform parameters, and `every_node_starts_neutral` still
//! holds — but the composer emits this node whatever its state, because a
//! photograph with no view transform is a scan, not a picture. See
//! [`crate::operation::Stage::View`].
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::operation::{Helper, Operation, Stage, Uniform};
use crate::view::{Sigmoid, CONTRAST_RANGE, DEFAULT_CONTRAST, DEFAULT_WHITE, WHITE_RANGE};
pub const ID: OpId = OpId("view_transform");
pub const CONTRAST: ParamId = ParamId("contrast");
pub const WHITE: ParamId = ParamId("white");
static HELPERS: [Helper; 1] = [Helper {
name: "view_sigmoid",
source: crate::view::VIEW_SIGMOID_WGSL,
}];
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
// Tone: it is the tone response of the whole picture, and the panel's
// Light group is where a photographer looks for the white point.
attributes: vec![Attribute::Tone],
id: ID,
label: LocalizedKey("op.view_transform"),
params: vec![
ParamDescriptor::scalar(
"contrast",
"param.view_transform.contrast",
CONTRAST_RANGE.0,
CONTRAST_RANGE.1,
DEFAULT_CONTRAST,
Unit::None,
Scale::Linear,
2,
),
ParamDescriptor::scalar(
"white",
"param.view_transform.white",
WHITE_RANGE.0,
WHITE_RANGE.1,
DEFAULT_WHITE,
Unit::Stops,
Scale::Linear,
1,
),
],
})
});
#[derive(Debug, Clone)]
pub struct ViewTransform {
contrast: f32,
white: f32,
}
impl Default for ViewTransform {
fn default() -> Self {
Self {
contrast: DEFAULT_CONTRAST,
white: DEFAULT_WHITE,
}
}
}
impl ViewTransform {
pub fn new() -> Self {
Self::default()
}
/// The curve these settings solve to.
pub fn sigmoid(&self) -> Sigmoid {
Sigmoid::new(self.contrast, self.white)
}
}
impl Operation for ViewTransform {
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
CONTRAST => self.contrast = value,
WHITE => self.white = value,
_ => log::warn!("view_transform: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
CONTRAST => self.contrast,
WHITE => self.white,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
self.contrast != DEFAULT_CONTRAST || self.white != DEFAULT_WHITE
}
fn stage(&self) -> Stage {
Stage::View
}
fn wgsl_body(&self) -> String {
"\
// Skipped for an already-rendered source: a JPEG is a display rendering
// already, and rendering it again would compress it twice.
if (!non_linear) {
c = view_sigmoid(c, slope, inv_k, peak);
}"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
let s = self.sigmoid();
vec![
Uniform {
name: "slope",
value: s.n,
},
Uniform {
name: "inv_k",
value: s.inv_k,
},
Uniform {
name: "peak",
value: s.w,
},
]
}
fn helpers(&self) -> &[Helper] {
&HELPERS
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_starts_neutral_and_says_so() {
// TRACES: FR-DEV-3j
// Neutral in the sense every other node is: nothing moved, so nothing
// is written. It is still composed — see the module documentation.
let op = ViewTransform::new();
assert!(!op.is_active());
assert_eq!(op.sigmoid(), Sigmoid::default_curve());
}
#[test]
fn moving_either_slider_makes_it_active() {
let mut op = ViewTransform::new();
op.set_param(WHITE, 6.0);
assert!(op.is_active());
let mut op = ViewTransform::new();
op.set_param(CONTRAST, 2.0);
assert!(op.is_active());
}
#[test]
fn the_uniforms_are_the_solved_curve() {
let mut op = ViewTransform::new();
op.set_param(CONTRAST, 2.0);
op.set_param(WHITE, 6.0);
let s = Sigmoid::new(2.0, 6.0);
let u = op.uniforms();
assert_eq!(
u.iter().map(|u| u.value).collect::<Vec<_>>(),
vec![s.n, s.inv_k, s.w]
);
}
#[test]
fn the_descriptor_defaults_are_the_curve_defaults() {
// The sidecar treats a value equal to the descriptor's default as
// unedited; the two disagreeing would make every photograph open
// reporting a view transform edit it never had.
let d = ViewTransform::new().descriptor();
assert_eq!(
d.param(CONTRAST).expect("contrast").default,
DEFAULT_CONTRAST
);
assert_eq!(d.param(WHITE).expect("white").default, DEFAULT_WHITE);
}
}
+3 -1
View File
@@ -1280,7 +1280,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
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_uniformity: 1.0,
},
+3 -1
View File
@@ -2023,7 +2023,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
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_uniformity: 1.0,
},
+3 -1
View File
@@ -235,7 +235,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0,
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_uniformity: 1.0,
},
+155
View File
@@ -0,0 +1,155 @@
//! TRACES: FR-DSP-2 | NFR-RES-2
//! Cutting a render too large for one texture into tiles.
//!
//! The interactive path is not tiled, and on the evidence should not be
//! (`docs/dev/frame-budget.md`, TD-4): one fused dispatch over a viewport is
//! inside the frame budget, and a halo per tile nearly doubles the taps of a
//! wide kernel. What does not fit is a *file*. A 22927×8966 panorama has no
//! render target on a device whose textures stop at 16384, so its export, and
//! nothing else, is drawn a tile at a time.
//!
//! A tile is two rectangles in pixels of the framed output: the one rendered,
//! grown by the detail stage's reach ([`crate::ComposedDetail::reach`]) so
//! every kernel near its edge reads the pixels it would read untiled, and the
//! one kept, which is the tile proper. The kept rectangles cover the frame
//! exactly once.
//!
//! The rendered rectangle's origin is aligned to [`TILE_ALIGN`]. The detail
//! stage computes clarity's base on a reduced grid, and a tile starting half
//! way through a reduced texel would reduce different pixels together than
//! the untiled frame does, which shows as a faint seam.
/// A multiple of every reduced grid the detail stage uses, so a tile's
/// grids line up with the untiled frame's.
pub const TILE_ALIGN: u32 = 16;
/// One tile of a render: what to draw, and which part of it to keep.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Tile {
/// `[x, y, width, height]` in output pixels: the tile grown by the halo,
/// clamped to the frame. This is what is rendered.
pub grown: [u32; 4],
/// `[x, y, width, height]` in output pixels: the tile proper, which lies
/// inside `grown`. This is what is kept.
pub keep: [u32; 4],
}
impl Tile {
/// The rendered rectangle as a view on the frame, the rectangle
/// [`crate::Framing::set_view`] takes.
pub fn view(&self, frame: (u32, u32)) -> crate::framing::CropRect {
let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32);
crate::framing::CropRect {
x: self.grown[0] as f32 / fw,
y: self.grown[1] as f32 / fh,
width: self.grown[2] as f32 / fw,
height: self.grown[3] as f32 / fh,
}
}
/// Where the kept rectangle starts inside the rendered one.
pub fn keep_offset(&self) -> (u32, u32) {
(self.keep[0] - self.grown[0], self.keep[1] - self.grown[1])
}
}
/// Cut a `frame`-sized render into tiles no larger than `max_edge` once
/// grown by `halo` on every side.
///
/// Row-major, top to bottom, so a caller writing the file as it goes gets
/// its bands in order. A frame that fits whole is one tile with no halo.
/// `None` when the halo leaves no room for a tile at all — a spot heal
/// cloning from across a frame wider than the device can hold is the case,
/// and it has to be refused rather than drawn with a seam.
pub fn plan(frame: (u32, u32), max_edge: u32, halo: u32) -> Option<Vec<Tile>> {
let (fw, fh) = (frame.0.max(1), frame.1.max(1));
if fw <= max_edge && fh <= max_edge {
return Some(vec![Tile {
grown: [0, 0, fw, fh],
keep: [0, 0, fw, fh],
}]);
}
// The halo, rounded up so a grown origin lands on the grid; the tile
// proper a multiple of it for the same reason.
let halo = halo.div_ceil(TILE_ALIGN) * TILE_ALIGN;
let room = max_edge.checked_sub(2 * halo)?;
let step = room / TILE_ALIGN * TILE_ALIGN;
if step == 0 {
return None;
}
let mut out = Vec::new();
let mut y = 0;
while y < fh {
let kh = step.min(fh - y);
let mut x = 0;
while x < fw {
let kw = step.min(fw - x);
let gx = x.saturating_sub(halo);
let gy = y.saturating_sub(halo);
let gx1 = (x + kw + halo).min(fw);
let gy1 = (y + kh + halo).min(fh);
out.push(Tile {
grown: [gx, gy, gx1 - gx, gy1 - gy],
keep: [x, y, kw, kh],
});
x += kw;
}
y += kh;
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_frame_that_fits_is_one_tile_with_no_halo() {
let tiles = plan((6000, 4000), 8192, 200).unwrap();
assert_eq!(tiles.len(), 1);
assert_eq!(tiles[0].grown, [0, 0, 6000, 4000]);
assert_eq!(tiles[0].keep, tiles[0].grown);
}
#[test]
fn the_kept_rectangles_cover_the_frame_exactly_once() {
// The panorama that started this, against a 16384 device with a
// clarity-sized halo.
let frame = (22927, 8966);
let tiles = plan(frame, 16384, 230).unwrap();
let mut covered = vec![0u8; (frame.0 * frame.1) as usize];
for t in &tiles {
let [x, y, w, h] = t.keep;
for yy in y..y + h {
for xx in x..x + w {
covered[(yy * frame.0 + xx) as usize] += 1;
}
}
}
assert!(covered.iter().all(|&c| c == 1));
}
#[test]
fn every_tile_fits_the_device_and_holds_its_halo() {
let frame = (22927, 8966);
let (max, halo) = (8192, 300);
for t in plan(frame, max, halo).unwrap() {
let [gx, gy, gw, gh] = t.grown;
let [kx, ky, kw, kh] = t.keep;
assert!(gw <= max && gh <= max, "{t:?} does not fit");
assert_eq!(gx % TILE_ALIGN, 0, "{t:?} starts off the grid");
assert_eq!(gy % TILE_ALIGN, 0, "{t:?} starts off the grid");
// The halo is there on every side, or the frame ends first — in
// which case the untiled render stops at the same edge.
assert!(gx == 0 || kx - gx >= halo);
assert!(gy == 0 || ky - gy >= halo);
assert!(gx + gw == frame.0 || gx + gw - (kx + kw) >= halo);
assert!(gy + gh == frame.1 || gy + gh - (ky + kh) >= halo);
}
}
#[test]
fn a_halo_wider_than_the_device_is_refused() {
assert_eq!(plan((40000, 100), 16384, 9000), None);
}
}
+276
View File
@@ -0,0 +1,276 @@
//! TRACES: FR-DEV-3j | FR-DEV-2
//! The view transform — the one stage that maps scene-linear colour to a
//! display range (D19, ARCH §6.14).
//!
//! # What it is
//!
//! A log-logistic sigmoid, per channel:
//!
//! ```text
//! f(x) = w · r / (1 + r), r = (x / k)^n
//! ```
//!
//! `n` is the contrast — the slope in log-log terms, before the shoulder
//! bends it. `k` and `w` are solved from two conditions rather than set:
//! scene middle grey lands on display middle grey, and the scene white the
//! photographer chose lands on display white. So the curve has a toe, a
//! midtone slope and a shoulder that approaches `w` — a hair above 1.0 —
//! without ever reaching it. Everything the shoulder has not reached by the
//! white point is clipped by the output transform, which is the last moment
//! and the only place a clip belongs.
//!
//! # Why per channel, and why the middle channel is put back
//!
//! Per channel is what makes a bright saturated colour desaturate as it
//! approaches white — a blown sky rolls toward white rather than toward a
//! saturated corner of the gamut, which is what film and every camera JPEG
//! do. It also bends hue: the three channels sit at different places on the
//! curve, so their ratios change, and an orange flame drifts toward yellow.
//! So after the curve the middle channel is moved back to where it sat
//! *between the other two* before it — the same fraction of the way from the
//! smallest to the largest. The smallest and largest keep what the curve gave
//! them, which keeps the desaturation; the hue, which is decided by that
//! fraction, survives. It is the "preserve hue" step of darktable's sigmoid,
//! at full strength.
//!
//! # Why these defaults
//!
//! [`SCENE_GREY`] is where the retired default base curve put middle grey
//! (FR-DEV-3e): linear sensor data from a correctly exposed frame has it
//! near 13% of saturation, and a camera JPEG shows it at 18%. The contrast
//! and white defaults were chosen against that same retired curve: at 1.4 and
//! 4 stops the midtones stay within a quarter of a stop of it between scene
//! 0.03 and 1.0, while a highlight a stop past sensor saturation still rolls
//! into white rather than stopping dead at it. The upper midtones come out a
//! little darker than the curve had them, which is the price of that
//! headroom and what the white slider is for.
/// Scene-linear middle grey: where the retired default curve placed it.
pub const SCENE_GREY: f32 = 0.13;
/// Display-linear middle grey — what a camera JPEG shows a grey card as.
pub const DISPLAY_GREY: f32 = 0.18;
/// The default contrast, the sigmoid's log-log slope parameter `n`.
pub const DEFAULT_CONTRAST: f32 = 1.4;
/// The default white point, in stops above [`SCENE_GREY`].
pub const DEFAULT_WHITE: f32 = 4.0;
/// The contrast range a photographer is offered.
pub const CONTRAST_RANGE: (f32, f32) = (1.0, 3.0);
/// The white point range, in stops above middle grey.
///
/// The floor is not taste. The two conditions `k` and `w` are solved from
/// have a solution only while `2^(white · n)` exceeds `1 / DISPLAY_GREY`,
/// and at the lowest contrast that needs `white` above about 2.47 stops.
pub const WHITE_RANGE: (f32, f32) = (2.5, 10.0);
/// The curve's three numbers, solved from the photographer's two.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Sigmoid {
/// Contrast: the exponent.
pub n: f32,
/// `1 / k`, so the shader multiplies rather than divides.
pub inv_k: f32,
/// The asymptote the shoulder approaches, a little above 1.0.
pub w: f32,
}
impl Sigmoid {
/// Solve the curve for a contrast and a white point in stops.
///
/// Out-of-range inputs are clamped to [`CONTRAST_RANGE`] and
/// [`WHITE_RANGE`] rather than trusted: they arrive from a sidecar, which
/// may have been written by a build with other limits, and outside them
/// the solution below divides by something that is no longer positive.
///
/// With `r_g` the value of `r` at scene grey and `q = 2^(white · n)`, the
/// two conditions `f(grey) = display grey` and `f(grey · 2^white) = 1`
/// are `w·r_g/(1+r_g) = g` and `w·q·r_g/(1+q·r_g) = 1`. Dividing one by
/// the other eliminates `w` and leaves `r_g = (g·q − 1) / (q·(1 − g))`.
pub fn new(contrast: f32, white: f32) -> Self {
let n = contrast.clamp(CONTRAST_RANGE.0, CONTRAST_RANGE.1) as f64;
let white = white.clamp(WHITE_RANGE.0, WHITE_RANGE.1) as f64;
let g = f64::from(DISPLAY_GREY);
let q = (white * n).exp2();
let r_grey = (g * q - 1.0) / (q * (1.0 - g));
let w = g * (1.0 + r_grey) / r_grey;
// r = (x / k)^n, and r at scene grey is r_grey, so
// k = grey / r_grey^(1/n).
let k = f64::from(SCENE_GREY) / r_grey.powf(1.0 / n);
Self {
n: n as f32,
inv_k: (1.0 / k) as f32,
w: w as f32,
}
}
/// The default curve.
pub fn default_curve() -> Self {
Self::new(DEFAULT_CONTRAST, DEFAULT_WHITE)
}
/// One channel through the curve. The CPU reference the shader is
/// tested against.
pub fn channel(&self, x: f32) -> f32 {
let r = (x.max(0.0) * self.inv_k).powf(self.n);
self.w * r / (1.0 + r)
}
/// A colour through the curve, with the middle channel put back between
/// the other two. See the module documentation.
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
let x = c.map(|v| v.max(0.0));
let y = x.map(|v| self.channel(v));
let lo = x[0].min(x[1]).min(x[2]);
let hi = x[0].max(x[1]).max(x[2]);
if hi - lo <= 1e-9 {
return y;
}
let (y_lo, y_hi) = (self.channel(lo), self.channel(hi));
x.map(|v| y_lo + (y_hi - y_lo) * (v - lo) / (hi - lo))
}
}
/// The WGSL twin of [`Sigmoid::apply`], as a helper function.
pub const VIEW_SIGMOID_WGSL: &str = "\
// The view transform (FR-DEV-3j): a log-logistic sigmoid per channel, then the
// middle channel put back between the other two so that the hue survives the
// shoulder. See `dr_pipeline::view` for the derivation and the defaults.
fn view_sigmoid(c: vec3<f32>, n: f32, inv_k: f32, w: f32) -> vec3<f32> {
// Negative components are colours outside the working primaries. They are
// floored here, at the last stage, which is the one place a gamut clip
// belongs.
let x = max(c, vec3<f32>(0.0));
let lo = min(x.r, min(x.g, x.b));
let hi = max(x.r, max(x.g, x.b));
let r_lo = pow(lo * inv_k, n);
let r_hi = pow(hi * inv_k, n);
let y_lo = w * r_lo / (1.0 + r_lo);
let y_hi = w * r_hi / (1.0 + r_hi);
// Each channel's place between the smallest and the largest. A neutral
// has no spread, and every channel then takes the one value there is.
let spread = hi - lo;
let t = select((x - vec3<f32>(lo)) / max(spread, 1e-9), vec3<f32>(0.0), spread <= 1e-9);
return vec3<f32>(y_lo) + (y_hi - y_lo) * t;
}
";
#[cfg(test)]
mod tests {
use super::*;
/// The retired default base curve, for the acceptance comparison: five
/// points through the unit square, sampled here by straight lines between
/// them in log-log terms — close enough to the monotone spline that drew
/// it for a tolerance measured in quarters of a stop. Below its second
/// point it was a straight line from the origin.
fn retired_default(x: f32) -> f32 {
const P: [(f32, f32); 4] = [(0.04, 0.043), (0.13, 0.175), (0.45, 0.690), (1.0, 1.0)];
if x < 0.04 {
return x * (0.043 / 0.04);
}
let x = x.min(1.0);
let i = P.windows(2).position(|w| x <= w[1].0).unwrap_or(2);
let ((x0, y0), (x1, y1)) = (P[i], P[i + 1]);
let t = (x.ln() - x0.ln()) / (x1.ln() - x0.ln());
(y0.ln() + t * (y1.ln() - y0.ln())).exp()
}
#[test]
fn middle_grey_lands_on_display_grey() {
// TRACES: FR-DEV-3j
for (contrast, white) in [(1.0, 3.0), (1.4, 4.0), (2.5, 8.0), (3.0, 10.0)] {
let s = Sigmoid::new(contrast, white);
let got = s.channel(SCENE_GREY);
assert!(
(got - DISPLAY_GREY).abs() < 0.01,
"contrast {contrast}, white {white}: grey went to {got}"
);
}
}
#[test]
fn the_white_point_reaches_display_white() {
// TRACES: FR-DEV-3j
// The whole meaning of the slider: the scene value it names is where
// the picture reaches white, and not before.
for (contrast, white) in [(1.0, 3.0), (1.4, 4.0), (2.5, 8.0)] {
let s = Sigmoid::new(contrast, white);
let at = SCENE_GREY * white.exp2();
assert!((s.channel(at) - 1.0).abs() < 1e-4, "{}", s.channel(at));
assert!(s.channel(at * 0.9) < 1.0);
assert!(s.w > 1.0, "the shoulder must approach a value above white");
}
}
#[test]
fn the_curve_is_monotone_and_keeps_going_past_one() {
// TRACES: FR-DEV-3j | FR-DEV-2
// What the base curve got wrong: it was flat past 1.0, so every
// recovered highlight left it as the same number.
let s = Sigmoid::default_curve();
let mut last = -1.0;
for i in 0..=2000 {
let x = i as f32 * 0.004;
let y = s.channel(x);
assert!(y > last || (x == 0.0 && y == 0.0), "not increasing at {x}");
last = y;
}
assert!(s.channel(2.0) > s.channel(1.0));
}
#[test]
fn the_default_stays_close_to_the_retired_curve() {
// TRACES: FR-DEV-3j | FR-DEV-3e
// D19's promise to every existing photograph: the midtones do not
// move by more than a third of a stop.
let s = Sigmoid::default_curve();
let mut x = 0.03_f32;
while x <= 1.0 {
let ev = (s.channel(x) / retired_default(x)).log2();
assert!(ev.abs() < 0.3, "at scene {x} the default moved {ev:+.2} EV");
x *= 1.1;
}
}
#[test]
fn a_neutral_stays_neutral() {
// TRACES: FR-DEV-3j
let s = Sigmoid::default_curve();
for v in [0.0, 0.01, 0.13, 1.0, 7.0] {
let [r, g, b] = s.apply([v, v, v]);
assert_eq!(r, g);
assert_eq!(g, b);
}
}
#[test]
fn the_hue_survives_the_shoulder() {
// TRACES: FR-DEV-3j
// The middle channel's place between the other two is what decides
// the hue. Without the correction an orange at the shoulder drifts
// toward yellow as the red channel saturates first.
let s = Sigmoid::default_curve();
let orange = [2.0, 0.8, 0.1];
let out = s.apply(orange);
let before = (orange[1] - orange[2]) / (orange[0] - orange[2]);
let after = (out[1] - out[2]) / (out[0] - out[2]);
assert!((before - after).abs() < 1e-5, "{before} became {after}");
// And the extremes keep what the curve gave them — the desaturation
// toward white is the point of working per channel.
assert!((out[0] - s.channel(2.0)).abs() < 1e-6);
assert!((out[2] - s.channel(0.1)).abs() < 1e-6);
}
#[test]
fn out_of_range_settings_are_clamped_not_trusted() {
// A sidecar from another build may carry anything, and outside the
// range the solution divides by a value that is no longer positive.
let s = Sigmoid::new(0.0, 0.0);
assert!(s.n.is_finite() && s.inv_k.is_finite() && s.w.is_finite());
assert_eq!(s, Sigmoid::new(CONTRAST_RANGE.0, WHITE_RANGE.0));
}
}
+3 -1
View File
@@ -32,7 +32,8 @@
//! # What is not covered, and why that is honest
//!
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze` —
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze`,
//! `view_transform` —
//! names a hand-written type and has no declaration to interpret. It is not skipped
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
//! `ops/` between them, so a node that stops being declared cannot quietly
@@ -403,6 +404,7 @@ fn every_declared_node_is_checked() {
"noise_reduction",
"texture",
"tone_curve",
"view_transform",
"vignetting",
],
"the set of hand-written nodes changed; if that is deliberate, update \
+59 -6
View File
@@ -573,6 +573,32 @@ mod tests {
assert_eq!(report.failed, 0);
}
#[test]
fn a_subfolder_becomes_the_category_of_what_it_holds() {
// The folder picked is the root and names nothing; each folder under
// it is a level of category, as Lightroom's groups were.
let dir = tempdir("categories");
std::fs::write(dir.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap();
let nested = dir.join("Film").join("Colour");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap();
let names: Vec<_> = read_path(&dir).presets.into_iter().map(|p| p.0).collect();
assert_eq!(names, ["Film/Colour/Golden Hour", "Golden Hour"]);
}
#[test]
fn a_slash_in_a_displayed_name_is_not_a_category() {
let dir = tempdir("slash");
std::fs::write(
dir.join("p.xmp"),
ATTRIBUTE_FORM.replace("Warm Portrait", "Warm / Cool"),
)
.unwrap();
let report = read_path(&dir);
assert_eq!(report.presets[0].0, "Warm \u{2215} Cool");
}
#[test]
fn a_preset_without_a_name_is_called_after_its_file() {
// Lightroom writes the name it displays, which is not always the file
@@ -661,21 +687,37 @@ pub struct Report {
/// A folder because that is the shape a photographer's presets are in — an
/// exported Lightroom preset folder, nested one level per group — and asking
/// them to import ninety files one at a time would be asking them not to
/// bother. Nested folders are walked, which is what makes the group structure
/// available to whatever wants it later.
/// bother. Nested folders are walked, and each one below `path` becomes a
/// category: a preset in `Portraits/` is named `Portraits/Warm skin`, which is
/// how the preset menu files it (see `PresetLibrary`'s note on categories).
///
/// The *name* comes from `crs:Name` where the file carries one and from the
/// file stem where it does not. Lightroom writes the name it displays, which
/// is not always the file name, and the displayed name is the one the
/// photographer will look for.
/// photographer will look for. A `/` inside that name would read as a
/// category it never had, so it becomes `∕`, which looks the same and
/// separates nothing.
pub fn read_path(path: &std::path::Path) -> Report {
let mut report = Report::default();
read_into(path, &mut report);
read_into(path, "", &mut report);
report.presets.sort_by(|a, b| a.0.cmp(&b.0));
report
}
fn read_into(path: &std::path::Path, report: &mut Report) {
/// The category a folder below the import root files its presets under.
fn category_of(parent: &str, folder: &std::path::Path) -> String {
let Some(name) = folder.file_name() else {
return parent.to_string();
};
let name = name.to_string_lossy().replace('/', "\u{2215}");
if parent.is_empty() {
name
} else {
format!("{parent}/{name}")
}
}
fn read_into(path: &std::path::Path, category: &str, report: &mut Report) {
if path.is_dir() {
let Ok(entries) = std::fs::read_dir(path) else {
log::warn!("preset import: cannot read {}", path.display());
@@ -686,7 +728,12 @@ fn read_into(path: &std::path::Path, report: &mut Report) {
let mut paths: Vec<std::path::PathBuf> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
for path in paths {
read_into(&path, report);
let category = if path.is_dir() {
category_of(category, &path)
} else {
category.to_string()
};
read_into(&path, &category, report);
}
return;
}
@@ -710,6 +757,12 @@ fn read_into(path: &std::path::Path, report: &mut Report) {
.unwrap_or_default()
});
report.unsupported.extend(import.skipped);
let name = name.replace('/', "\u{2215}");
let name = if category.is_empty() {
name
} else {
format!("{category}/{name}")
};
report.presets.push((name, import.preset));
}
Err(e) => {
+51 -4
View File
@@ -72,17 +72,54 @@ pub enum ThumbSize {
/// Zoomed cells, the loupe, and the filmstrip. ~45 KB each, fetched only
/// where something actually asks for that detail.
Large = 1,
/// TRACES: FR-MRG-6
/// A panorama's cell two columns wide, at the height of one: long edge
/// sized for the width rather than for a square, since the grid class
/// of a 4:1 panorama is 256×64 — a smear across the cells. The wide
/// classes are made only for photographs that wide, so they cost a
/// library nothing else.
Wide2 = 2,
/// Three columns.
Wide3 = 3,
/// Four columns: the widest class.
Wide4 = 4,
}
/// The most columns a wide class spans.
pub const WIDEST_SPAN: usize = 4;
impl ThumbSize {
/// Long edge in pixels.
/// Long edge in pixels. A wide class is 512 per column it spans, which
/// keeps its short edge near the large class's for the aspect that
/// class is chosen for — sharp at the largest cells on a 2x display.
pub fn edge(self) -> u32 {
match self {
ThumbSize::Grid => 256,
ThumbSize::Large => 1024,
ThumbSize::Wide2 => 1024,
ThumbSize::Wide3 => 1536,
ThumbSize::Wide4 => 2048,
}
}
/// The wide class for a cell `span` columns wide: `None` for one
/// column, and the widest class for anything past it.
pub fn wide(span: usize) -> Option<Self> {
match span {
0 | 1 => None,
2 => Some(ThumbSize::Wide2),
3 => Some(ThumbSize::Wide3),
_ => Some(ThumbSize::Wide4),
}
}
/// The class for a cell `span` columns wide whose columns are drawn at
/// `pixels`: a wide class for any cell wider than one, whatever the
/// zoom, since its height is a column's and its width is not.
pub fn for_span(span: usize, pixels: u32) -> Self {
Self::wide(span).unwrap_or_else(|| Self::for_cell(pixels))
}
/// The smallest class that can fill a cell of this size without visibly
/// softening.
///
@@ -96,12 +133,22 @@ impl ThumbSize {
}
}
fn from_i64(v: i64) -> Self {
/// The class a stored discriminant names, or `None` for one this build
/// does not know.
pub fn from_stored(v: i64) -> Option<Self> {
match v {
1 => ThumbSize::Large,
_ => ThumbSize::Grid,
0 => Some(ThumbSize::Grid),
1 => Some(ThumbSize::Large),
2 => Some(ThumbSize::Wide2),
3 => Some(ThumbSize::Wide3),
4 => Some(ThumbSize::Wide4),
_ => None,
}
}
fn from_i64(v: i64) -> Self {
Self::from_stored(v).unwrap_or(ThumbSize::Grid)
}
}
/// Long edge of a grid thumbnail.
+78 -7
View File
@@ -34,7 +34,7 @@ document elaborates:
| UI | Slint | D1, D8 |
| GPU | wgpu → Vulkan (Linux + Android) | D1 |
| Shaders | Hand-written WGSL | D6 |
| RAW decode | rawler; LibRaw fallback behind a trait | D2 |
| RAW decode | rawler (0.7.2, carried patched in `third_party/`); LibRaw fallback behind a trait | D2 |
| Catalog | SQLite (WAL) — a rebuildable index | D5, §6.12 |
| Colour | lcms2 + GPU-side matrix/LUT transforms | D5 |
| Network | reqwest + quick-xml | D7 |
@@ -140,6 +140,11 @@ is *not* demosaiced. Demosaic is a GPU pipeline stage (§5.2).
> (§3.1's `read_range`), not the decoder. `dr_decode::Rawler` is the one implementation, and only
> the places that start a job name `dr_decode::default()`; everything below them takes a
> `&dyn Decoder`.
>
> rawler itself is built from `third_party/rawler-0.7.2` since 0.19.0: the crate as published,
> with its allocation guard raised so that a linear DNG wider than about 16 700 pixels (a
> stitched panorama) decodes rather than being refused
> ([third_party/README.md](../../third_party/README.md)).
### 3.3 Operation and descriptors
@@ -347,17 +352,20 @@ RawImage (sensor data, CPU)
│ upload
▼
┌─────────────────────┐
│ hot/dead photosites │ repaired on the mosaic (FR-RAW-3)
├─────────────────────┤
│ black/white levels │ integer normalise
├─────────────────────┤
│ demosaic │ Bayer or Markesteijn (X-Trans, FR-RAW-5)
├─────────────────────┤
│ AI denoise │ optional; raw-domain, joint with demosaic where possible
├─────────────────────┤
│ camera profile │ matrices + per-body base curve (FR-DEV-3e)
│ white balance │ camera RGB: as-shot, then the operation
├─────────────────────┤
│ → working space │ linear, wide-gamut, f16
│ camera profile │ the matrix (FR-DEV-3e) — no curve (D19)
├─────────────────────┤
│ → working space │ linear, unbounded, f16
├─────────────────────┤
│ white balance │
│ exposure/contrast │
│ highlights/shadows │ ← masks apply per-op from here down
│ tone curve │
@@ -366,10 +374,11 @@ RawImage (sensor data, CPU)
│ spot removal │
│ sharpen / NR │
│ lens corrections │
│ look (HaldCLUT) │ FR-DEV-3f
├─────────────────────┤
│ geometry │ crop, straighten, rotate
├─────────────────────┤
│ view transform │ sigmoid, or the film stock (FR-DEV-3j)
├─────────────────────┤
│ output transform │ → display or export profile
└─────────────────────┘
│
@@ -379,8 +388,37 @@ RawImage (sensor data, CPU)
Working precision is f16 in a linear wide-gamut space, quantising once at the output transform.
**Scene-referred until the view transform (D19, §6.14).** Everything between the matrix and the
view transform is linear and unbounded. The view transform is the one stage allowed to compress
the scene into a display range. With a detail stage it runs as a dispatch of its own after the
detail passes, generated by the same composer as the fused pass so that it gets the mask layers,
the film tables and the grain's source position. Without one it is the fused pass's tail. Both
the view transform and the output transform (primaries, gamut clip, encode) come after
everything that reads a neighbourhood.
**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
(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.
### 5.3 Tiling and scheduling
@@ -400,6 +438,20 @@ Tile results cache keyed by `(VersionId, tile, zoom, graph_hash_prefix)`, where
operations up to the first `Affects` change. Adjusting exposure reuses cached demosaic and camera
profile output for every tile.
> **As built (0.19.0).** The interactive path does not tile: one fused dispatch over the viewport
> is inside the frame budget ([frame-budget.md](frame-budget.md)), and there is no scheduler or tile
> cache. What tiles is a source too large for one texture. A linear DNG whose long edge passes
> `PROXY_EDGE` (8192) — a stitched panorama — is held at full resolution on the CPU and opened on
> a box-reduced copy, from which the canvas at fit, the thumbnail, the histograms and the masks
> work. A render finer than the copy — the canvas zoomed in, a tile of the export — samples a
> window cut from the full resolution (`DemosaicedImage::linear_rgb16_window`), which the fused
> shader addresses through two source-window uniforms so that crops, warps and grain seeds stay
> where they are in the frame. The canvas keeps one window while the view stays inside it. The
> export is cut by `dr_pipeline::tiles::plan` into 4096-pixel tiles on a 16-pixel grid, each
> grown by the detail chain's reach (`ComposedDetail::reach`, the sum of its passes' radii), and
> reassembled; `core/dr-gpu/tests/source_window.rs` holds it to the untiled render within one code
> value. A CFA file too large for one texture is still refused.
### 5.4 Mask rasterisation
**All masks rasterise on the GPU, including drawn brush strokes** (§6.11). Strokes arrive as
@@ -419,7 +471,7 @@ exactly the stall §6.1 exists to prevent.
**Two reductions, not one.** The display histogram (FR-DSP-7) counts the frame the output transform
produced: its axis is the output code value, and a clipped bin means a highlight that is gone as the
image currently stands. The raw histogram for culling (FR-CULL-3) counts the **demosaiced
scene-linear texture** — before white balance, the camera matrix, the base curve and the tone chain
scene-linear texture** — before white balance, the camera matrix, the tone chain and the view transform
— on an axis of stops below sensor saturation, which is how it reports headroom the embedded JPEG's
histogram cannot. A culling decision needs the second, an export decision needs the first, and
neither answers for the other. Both are drawn by the same panel and chosen between.
@@ -593,6 +645,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
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
Cooperative, with tokens threaded through every long operation. Observed within 100 ms
@@ -1225,6 +1284,17 @@ differently, f16 rounding varies. Cache keys and graph hashes are computed over
state, which is exactly deterministic. Cross-platform *rendering* equality is a bounded tolerance
(R1), not a checksum.
### 6.14 Scene-referred until the view transform
Added 2026-09-27 (D19). Between the camera matrix and the view transform, values are scene-linear
and unbounded, and no operation clamps above 1.0, applies a transfer function or maps to a display
gamut. The view transform (FR-DEV-3j) is the one stage that does, and the output transform after
it clips and encodes. This is the constraint the base curve broke and ARCH §5.2 had drawn all
along: a display-referred curve in the middle of the chain throws away what every later stage,
the neighbourhood ones above all, needs. A test (`scene_referred_until_the_view`, in `dr-gpu`)
runs every point operation over a ramp to 16.0 so that a fragment that clips fails the build rather
than the photograph.
---
## 13. Decisions
@@ -1248,6 +1318,7 @@ Full rationale in [requirements.md §8](requirements.md). Summary:
| D13 | Face inference runtime and model licensing | **Runtime answered**, reopened for per-device backends (docs/inference.md); licensing open |
| D14 | Segmentation source for local masking | Decided — arm C (docs/segmentation.md §14) |
| D15 | Target devices — 12-inch tablet and desktop, no phone | Decided (requirements D15) |
| D19 | Scene-referred pipeline, one view transform last | Decided (requirements D19) |
---
+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
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
the I/O pool behind it (ARCH §7.1).
**A job runner never touches the UI executor**, and `Interactive` work runs on the decode executor
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.
- **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
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
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
@@ -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
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
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
@@ -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 |
| 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 |
| 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,
because a merge that oscillates would upload on every sync forever.
+358
View File
@@ -0,0 +1,358 @@
# Learned denoise — joint demosaic and denoise on the mosaic
Design for **FR-DEV-3g** ([requirements.md](requirements.md)), the learned stage
[outstanding.md §3](outstanding.md) says is missing. Draft of 2026-09-27: nothing here is built,
and every figure marked *estimate* is waiting for the measurement that replaces it.
---
## 1. What we are matching
Lightroom's Denoise (April 2023, Eric Chan's "Denoise demystified") is the reference, and three
facts about it set the shape of this design:
- **It runs on the mosaic.** The network takes Bayer or X-Trans photosites before any demosaic
and emits full RGB: denoise and demosaic are one learned step. It descends from Adobe's 2019
learned demosaic (Raw Details). A photograph that is already demosaiced is not eligible.
- **It is run once, not per frame.** The result is written as a new linear DNG beside the
original, and every later edit reads that file. The amount is chosen once, from a preview crop.
- **It is trained on synthetic pairs.** Clean raws with sensor-modelled noise added, not
photographed pairs.
The reason the mosaic is the right place is physical: before the demosaic, noise is independent
per photosite with a known distribution (shot plus read). After it, the interpolation has
correlated that noise into colour blotches many photosites across, which classical noise reduction
cannot separate from texture. The same step removes demosaic artefacts
— maze, zipper, false colour, X-Trans worms (FR-RAW-5).
We match the first and third facts and not the second: our result is a cache, not a file in the
library (§7).
## 2. Where it sits
[architecture.md §5.2](architecture.md) already reserves the slot. The learned stage **replaces
the demosaic box** when it is on; nothing else in the chain moves.
```
RawImage ─► hot/dead photosites ─► black/white levels ─► ┬─ demosaic (classical) ─┬─► camera profile ─► …
└─ learned demosaic+NR ──┘
(cached, §7)
```
- **In:** the repaired, normalised mosaic, from the same buffer `Demosaicer::run` reads. The hot
pixel pass stays in front: an outlier of 50σ is outside anything the noise model generates, and
a network shown one invents a structure around it.
- **Out:** linear camera RGB, f16, full resolution — exactly the texture the classical demosaic
produces, so the camera profile, the raw histogram and every operation below it are unchanged.
- **Off by default, per photograph.** The classical path stays the default and the fallback; the
stage's absence degrades gracefully, as FR-DEV-3g requires.
## 3. The model
### 3.1 The 12×12 → 4×4 question
The proposal: a network that reads a 12×12 window of photosites and predicts the RGB of the
central 4×4, slid across the frame in steps of four.
**The output half is right. The input half is too small by a factor of five or more.**
*What is right about it.* Predicting a block aligned to the colour-filter period keeps the phase
fixed: every prediction sees the same arrangement of red, green and blue around it, so the network
never has to work out where it stands in the pattern. It also makes tiling trivial and exact.
Both properties are kept below — as the head of the network and as the tiling contract (§3.4).
*What is wrong with it.* A denoiser can only average away noise it can see around the pixel, and
at high ISO it needs to see a long way:
- The Canon 6D at ISO 6400 (clip ≈ 1,200 e⁻, read noise ≈ 2 e⁻ — *estimate*, §5 measures it) has a
mid-tone of ~150 e⁻, shot SNR ≈ 12, and a shadow three stops down of ~19 e⁻, SNR ≈ 4.
- A shadow that looks clean wants SNR ≈ 40: a factor of 10, which is ~100 independent same-colour
samples in a flat area. Red and blue are a quarter of the photosites, so that is ~400
photosites: a **20×20 window just for a flat shadow**, 40×40 two stops further down.
- A 12×12 window holds 36 red photosites. Averaged perfectly, that is a factor of 6 on red and
blue in a flat area, and less everywhere there is structure.
- Chroma blotches are low-frequency noise — 16 to 64 photosites across. A window smaller than the
blotch cannot tell it from a colour change.
Demosaic alone is content with 12×12: good classical demosaics read 5×5 to 9×9. So the proposal is
a good demosaic network and a weak denoiser — which is a useful ablation (experiment E1, §6.3).
*What it costs.* Adjacent 12×12 windows with a 4×4 output overlap nine-fold, so a network
evaluated per window recomputes each photosite's features nine times. A convolutional network is
the same computation with that work shared: it is "predict the central block from its
neighbourhood" evaluated everywhere at once.
### 3.2 The shape
```
mosaic (H×W) ──space-to-depth 2×2──► 4 ch @ H/2 × W/2 ┐
noise map σ(x) ─space-to-depth 2×2──► 4 ch @ H/2 × W/2 ┴► U-Net ─► 12 ch @ H/2 × W/2 ─depth-to-space─► RGB @ H×W
(2×2 block × RGB per position)
```
- **Packing.** Bayer is packed 2×2 into four channels at half resolution, so every input position
is one whole quad and every output position is the 2×2 block of RGB it covers — the proposal's
head, at the Bayer period. (A 4×4 packing with a 48-channel head is the same thing at a coarser
stride and is a free parameter.)
- **Phase unification.** Every body's pattern is cropped by a row or a column to RGGB before
packing, and the output is un-cropped. Flips are only used for augmentation in the CFA-preserving
form (Liu et al., "Bayer pattern unification and augmentation", 2019).
- **Body.** A U-Net with four downsamplings and NAFNet blocks (Chen et al., 2022; MIT). The
receptive field at the raw scale is several hundred photosites, which covers §3.1's worst case
with room.
- **Two sizes.** **M** (widths 32-64-128-256, ~6 M parameters, ~60 GMAC per raw megapixel —
*estimate*) is the desktop model and the one trained first. **S** (widths 16-32-64-128, fewer
bottleneck blocks, ~1 M parameters, ~12 GMAC/MP) is distilled from M for the tablet (§8).
### 3.3 Conditioning on the noise
The network is told how noisy each photosite is, rather than learning one model per ISO:
- A per-photosite standard-deviation map, `σ(x) = √(K·x + σ_r²)` from the body's gain `K` and read
noise `σ_r` at that ISO, packed alongside the mosaic (FFDNet's arrangement, Zhang et al., 2018).
- **This is what makes it camera-general.** A body it was never trained on only has to supply
`K` and `σ_r`. Three sources, in order of preference: a calibration table for the body (§5); the
DNG `NoiseProfile` tag, which Adobe's converter writes; a blind estimate from the photograph's
own flat regions (Foi et al., 2008), which always exists.
- **It is also the Amount control.** Scaling the map up tells the network there is more noise than
there is and it smooths harder; scaling it down preserves more grain. Changing the amount re-runs
inference (§7.2), which is why it is set on a preview crop, as Lightroom does.
The alternative — PMRID's k-sigma transform, which maps every ISO onto one noise level — is
simpler and gives no Amount control. It is the fallback if conditioning underperforms.
### 3.4 Tiling
A 20 MP frame does not go through a network in one piece on either device. Inference tiles the
mosaic into 512×512 input tiles with a 64-photosite halo on every side and keeps the central
384×384 of each output: the proposal's "12 in, 4 out", scaled up. Halo and tile sizes must be
multiples of 2 (the CFA phase) and of 16 (four downsamplings at half resolution), so the seams
land at identical positions in every tile's own coordinates.
This is inference-local tiling and does not depend on FR-DSP-2's render-path tiling, which stays
under the challenge [outstanding.md §4](outstanding.md) records.
## 4. Training data
### 4.1 What the library holds
From the reference catalog, 2026-09-27: 17,255 catalogued RAWs (9,345 DNG, 7,910 CR2), **all but
seven from one body, the Canon EOS 6D** (RGGB Bayer, 5472×3648, AA filter), 166 shooting days from
2015 to 2026.
| ISO | Frames | Use |
|---|---|---|
| ≤ 200 | 5,065 | Clean sources for synthetic pairs |
| 201–1600 | 7,807 | Low-noise end of the eval set |
| 1601–6400 | 3,379 | Real-noise eval set; noise-model check (§5.3) |
| > 6400 | 562 | The hard cases, by eye |
There are **no X-Trans raws**, which matters for §9. The catalog does not hold shutter speed, so
selection needs the files' EXIF. Whether the DNGs are mosaic (converted CR2) or linear must be
checked before they are counted as sources: a linear DNG has no photosites to learn from.
### 4.2 How a training pair is made
1. **Clean source.** A base-ISO 6D frame, black-subtracted and normalised.
2. **Full-colour truth by binning.** Each plane is resampled by half a photosite so the four
planes share a centre, then every 2×2 quad becomes one RGB pixel (R, mean of the two G, B):
a true full-colour image at 2736×1824 with no interpolation in it. This is the only way to have
ground truth for the demosaic half.
3. **Re-mosaic.** That RGB image is sampled back into an RGGB mosaic. (It can equally be sampled
into X-Trans, §9.)
4. **Darken and add noise.** Scale the signal by `1/g` for a target ISO `100·g`, then add noise
from the calibrated model at that ISO (§5): Poisson shot, Tukey-lambda read noise, row noise
and quantisation — the ELD model (Wei et al., CVPR 2020). The input is this mosaic; the target
is the clean RGB at the same scale.
5. **Augment.** Random blur (Gaussian, σ 0–0.7 px) before re-mosaicking, because a binned image is
sharper per pixel than the AA-filtered sensor the model will see; exposure jitter; white-balance
gains within the body's range; CFA-preserving flips.
**Why the target's own noise is tolerable.** A base-ISO frame is not noise-free, and binning only
halves the green noise; red and blue keep theirs. But darkening by `g` scales signal and target
noise together, while the added shot noise grows as `√g`. At ISO 3200 the input is ≈ 5.7× noisier
than its target, at ISO 800 only ≈ 2.8×. L1 against a noisy target converges on the median, which
is unbiased for symmetric noise. The low-ISO end is the one at risk of learning to keep grain: if
it does, bin 4×4 instead (red and blue noise halved, 1368×912 per source) for those samples.
**Why not the native mosaic as the target.** That trains denoise alone, with base-ISO noise baked
into the answer ("noisier2noise") and no demosaic truth at all.
### 4.3 How much
The limit is scene diversity, not pixel count; every source yields an effectively unlimited
number of pairs through random crops, ISO and noise draws.
| Figure | Value | Reasoning |
|---|---|---|
| Sources, train | **3,000** | 5,065 base-ISO frames, less bursts (perceptual-hash dedup), heavy clipping, motion blur and linear DNGs. For scale: ELD reaches state of the art trained on ~230 scenes; SID has ~5,000 pairs of ~400 scenes |
| Sources, validation | 200 | Split by shooting day, not by frame, so no scene is on both sides |
| Pixels | ~15 Gpx of RGB truth | 3,000 × 5 MP after binning |
| Crops per step | 8–16 × 256×256 photosites | Fits a 6 GB RTX 3050 at fp16 with M |
| Stored | ~20 GB | 24 random 512×512 crops per source, uint16, zstd. Keeping whole CR2s would be ~75 GB |
| Training | 200–400 k steps, one to two nights per run on the 3050 — *estimate*; expect three to five runs | |
Stratify the selection: across all 166 days, and deliberately include faces and hair (the library
has 19k detected faces, and skin is where over-smoothing shows first), foliage, fabric, text, and
any base-ISO tripod night work.
### 4.4 Reading raws the same way in training and in the app
The training data must be decoded by **the same decoder the app uses**. rawpy (LibRaw) and
`dr_decode::Rawler` can disagree on black level, white level, active area and therefore CFA phase,
and a network trained on one pattern phase and run on another produces colour moiré everywhere.
A `dr-decode` example that dumps the mosaic and its metadata as `.npy` is the only source the
training repo reads — not rawpy, as `darkroom-infill`'s `develop-raws.py` does.
## 5. The noise model and its calibration
### 5.1 What is measured
Per ISO: gain `K` (DN per electron), read-noise distribution (Gaussian σ and Tukey-λ shape),
row-noise σ, black-level offset and any fixed pattern. Canon's third-stop ISOs on bodies of the
6D's generation are digital gains of the full stops, so noise does not scale smoothly between
them; **every third stop is calibrated**, not interpolated.
### 5.2 The capture (one hour, once per body)
- **Darks.** Lens cap on, viewfinder covered, manual. Five frames at 1/4000 s and five at 1/30 s
at every third stop from ISO 100 to 25600. They give read noise, row noise and the black-level
pattern; the two shutter speeds confirm dark current is negligible.
- **Flats.** An evenly lit white wall, defocused, at every full stop: pairs at six exposure levels
from 1/64 of clip to 3/4 of it. The variance of each pair's difference against their mean is
the photon transfer curve, whose slope is `K`.
### 5.3 The check
Fit the same `(K, σ_r)` blindly from flat regions of the library's 3,379 ISO 1601–6400 frames
(§3.3's third source). If it disagrees with the calibration by more than ~10%, one of them is
wrong — and it tells us how far the blind estimate can be trusted for bodies with no calibration.
## 6. Evaluation
### 6.1 Real pairs (the test set)
Synthetic validation says whether the model learned the synthetic problem; only photographed pairs
say whether it learned the real one. On a tripod, with remote release and mirror lock-up, manual
focus and white balance: **12 scenes** — low-light interior, a night street, fabric, foliage, fine
text, a colour chart if one is to hand, and a still subject with skin and hair. At each, four
ISO 100 frames at a long exposure (averaged: the reference), then ISO 1600, 3200, 6400, 12800 and
25600 at the same aperture with the shutter shortened by the ISO ratio. A per-channel linear fit
against the reference absorbs residual exposure mismatch (ELD's protocol).
Plus 100 real library frames above ISO 3200 with no reference, judged by eye side by side.
### 6.2 Baseline and metrics
The baseline is today's path: the classical demosaic plus `ops/noise_reduction.rs` tuned by hand
per ISO on the validation set. If a Lightroom or DxO trial is to hand, their output on the same
twelve scenes is the ceiling, for our comparison only.
Metrics, measured after a fixed tone curve (the camera profile and an sRGB curve) and not in linear
light, where the highlights would dominate: PSNR and SSIM per ISO; chroma bias on flat patches,
because denoisers desaturate; a slanted-edge MTF for detail; and maze or zipper artefacts on the
resolution target at ISO 100.
### 6.3 Experiments that answer design questions
| | Question | Runs |
|---|---|---|
| E1 | How much context does denoise need? (§3.1) | Same data, receptive field 12, 36, 100, 300+ photosites; PSNR per ISO against it |
| E2 | Noise-map conditioning or k-sigma? (§3.3) | M both ways |
| E3 | Bin 2×2 or 4×4 for truth? (§4.2) | Compare at ISO 400–800, where it matters |
| E4 | Is the blind noise estimate good enough? (§5.3) | Inference with calibrated vs blind maps on the real pairs |
### 6.4 Acceptance
- On the real pairs, ≥ 3 dB over the baseline at ISO 6400, and **no ISO at which it is worse**,
ISO 100 included — at base ISO it has to be at least as good a demosaic as the classical one.
- Mean chroma error on flat patches under ΔE 1.
- No maze, zipper or false colour on the resolution target that the classical demosaic does not
also show.
- A 20 MP frame in ≤ 3 s on the laptop's GPU and ≤ 30 s on its CPU (§8).
## 7. In the application
### 7.1 A cache, not a new file
Lightroom writes a DNG into the library. We do not: the library is synced, a 20 MP linear RGB file
is ~120 MB, and a derived file inside a synced tree is exactly what
[storage.md](storage.md) refuses. Instead:
- The sidecar records the intent — denoise on, amount, model id — as the rest of the edit is
recorded, so it syncs and another device reproduces it.
- The result is a local cache entry: f16 linear camera RGB, zstd, keyed on
`(file identity, decoder version, model id, amount, noise source)`. ~60–80 MB per frame
(*estimate*), LRU under a budget (default 5 GB, §10).
- On open, the classical demosaic shows at once and the learned result swaps in when it is ready,
with progress over the canvas — the same pattern as a photograph that is only on the server.
- Export needs the result and computes it if the cache has lost it.
### 7.2 The Amount control
A Denoise toggle and one Amount slider in develop. Moving the slider runs inference on the
**visible viewport only** (~1 MP, a fraction of a second — *estimate*) so the photographer judges
on the real result; releasing it queues the whole frame. There is no per-frame blend between the
two paths: blending the classical output back in re-adds the noise the network removed.
### 7.3 Runtime
Through `dr-inference-engine`, as the other models run ([inference.md](inference.md)): TensorRT or
CUDA fp16 on the laptop, MIGraphX on the desktop, ORT CPU everywhere, QNN on the tablet. Work is
scheduled in the `Background` class so a slider never waits on it (architecture §5.3).
## 8. Speed and the tablet
M at ~60 GMAC/MP is ~1.2 TMAC for a 20 MP frame (*estimate*). On the RTX 3050 at fp16 that is
about a second; on 20 CPU threads, tens of seconds.
The tablet's Hexagon is fast — scrfd_10g's ~10 GFLOP in 3.2 ms, [inference.md §1.1](inference.md) —
but **accepts int8 only**, and int8 is hostile to this task: a 14-bit signal quantised to 256
levels loses the shadow steps the model exists to recover. Two ways round it, to be measured in
this order:
1. **Predict the residual, not the image.** S emits the correction to a cheap bilinear demosaic
computed in float outside the graph. The residual spans a few σ, which 256 levels resolve; the
addition happens in float. With a variance-stabilising transform (Anscombe) on the input.
2. **16-bit activations** (QNN's A16W8), if the partition log shows the HTP running them.
If neither holds S's quality within 0.5 dB of fp32 on the real pairs, **v1 is desktop-only** and the
tablet shows the classical path. The sidecar still records the intent, so a desktop can render the
learned result for a photograph edited on the tablet.
## 9. X-Trans
The requirements tie this stage to FR-RAW-5, and the library has no Fuji raws. What we can do
without a Fuji body:
- **Training does not need one.** §4.2 step 3 samples the binned RGB truth into any pattern.
X-Trans packs 6×6 into 36 channels at a sixth of the resolution, with a 108-channel head: the
same design at the X-Trans period. It is a separate model.
- **Noise does.** A calibration capture (§5.2) or, failing that, the blind estimate — plus the
DNG `NoiseProfile` of converted Fuji files.
- **The test set does.** raw.pixls.us has CC0 samples per body but no tripod ISO ladders. A few
hours with a borrowed X-Trans body and the §6.1 protocol is the honest version; without it,
X-Trans ships marked experimental.
## 10. Plan and open decisions
| Phase | Work | Output |
|---|---|---|
| P0 | Calibration capture; the `dr-decode` dump example; source selection and crop store | Noise tables, ~20 GB of crops, the 12-scene test set |
| P1 | M on Bayer; eval harness; E1–E4 | A model that passes §6.4 on the laptop |
| P2 | The stage in `dr-gpu`, cache, sidecar field, develop controls, export | A photograph denoised in the app |
| P3 | S distilled; int8 and the residual head on the tablet | Tablet in or out of v1 (§8) |
| P4 | X-Trans model | Experimental unless a body is borrowed |
Training lives in a sibling repo, `darkroom-denoise`, next to `darkroom-infill` and reusing its
hydration tools. The weights are trained from scratch on the author's own photographs with an
MIT architecture, so this model adds no third-party licence to D13.
**Decisions wanted before P1:**
1. Bin 2×2 or 4×4 for the truth, or both (E3 answers it, but the crop store is built once).
2. Cache budget and location.
3. Whether the tablet is in v1's scope or explicitly deferred behind §8's measurement.
4. Whether a Lightroom or DxO comparison is available for §6.2.
5. A borrowed X-Trans body, or X-Trans experimental in v1.
+1 -1
View File
@@ -20,7 +20,7 @@ and the reason is that some of the work is done and untagged.
| Requirement | Reality |
|---|---|
| FR-DSP-1 proxy rendering | **Done.** The develop view renders at viewport resolution, not source. |
| FR-DSP-2 tiled computation | **Absent, and §2 now says it should stay that way.** Measured: the fused pass is inside the budget everywhere. See [frame-budget.md](frame-budget.md). |
| FR-DSP-2 tiled computation | **Absent from the interactive path, and §2 now says it should stay that way.** Measured: the fused pass is inside the budget everywhere. See [frame-budget.md](frame-budget.md). *Since 0.19.0* the export of a linear DNG too large for one texture is drawn in halo-grown tiles, and the canvas renders such a file from a reduced copy and full-resolution windows (ARCH §5.3). |
| FR-DSP-3 interactive latency | **Measured and asserted** for the fused path — `core/dr-gpu/tests/frame_budget.rs`. Missed by one operation, clarity, for the reason recorded as TD-4. |
| FR-DSP-4 progressive refinement | **Built** (0.15.0), although §4's condition did not fire on the fused path: a half-resolution draft while a gesture moves, one sharp frame 120 ms after it stops, the histogram dimmed while it lags, and the draft faded out over 150 ms — `ui/dr-ui/src/refine.rs`. See [frame-budget.md](frame-budget.md). |
| FR-DSP-5 zoom and pan | **Done and tagged**, against tests that fail if the behaviour is removed — `core/dr-gpu/tests/zoom_resolution.rs`. `Framing::view` shrinks the sampled region while the render target keeps its size, so zooming *raises* the resolution the pipeline works at. That is FR-DSP-5's requirement, arrived at without tiles. |
+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
there is nothing to judge.
The merge's `match_faces` still matches by overlap alone across devices. It is the same question,
and the same answer would serve it; it is not changed here.
Since #77 (0.18.0) the merge's `match_faces` answers it too, within a photograph's `file_id` and
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.
+29 -3
View File
@@ -325,9 +325,12 @@ without measuring them:
Stated because §7 of [display-and-extension.md](display-and-extension.md) asks
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
in any row above. `render_masked` takes them and the fused shader addresses
them per layer, so a heavily masked edit costs more than `all`.
- **Local adjustments.** Not in any row above. Since 0.18.1 a layer is no longer
a separate chain after the global one: each operation a layer touches runs a
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.
- **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.
@@ -512,3 +515,26 @@ texture and dehaze; the scenes without dehaze moved within ±2%. The
picture is the same bits: a minimum is exact in any order, the window is
the one the split passes covered, and the rgba8 output hashed identically
before and after in all 64 scene, view and size combinations measured.
## The view pass after the detail stage — 2026-09-27
**Status:** Not measured. Every figure above predates it.
**A chain with a detail stage is now one dispatch longer** (`c07f81e`,
D19). The fused pass used to end in the rendering — the base curve, then
the output transform — before it stored, so every detail pass convolved
display-referred values, and the last detail pass encoded. Now the fused
pass stops before the view transform, every detail pass writes a
scene-linear `rgba16float` intermediate, the last one included, and a view
pass composed from the same inputs reads the result and runs the view
transform (or the film stock), the output transform and the mask reveal.
What that adds, per frame with a detail stage: one render-sized read and
write, and a third intermediate for a one-pass chain. By the dehaze
section's own figure that is about 4 ms at 2560 × 1600 on the laptop
RTX 3050 under its power cap. What it removed: a capture sharpening too
fine to draw at the current scale no longer emits a pass-through, and
there is no resolve pass for an active kernel with nothing to draw. A
chain with no detail operation is unchanged, one fused dispatch with the
view transform at its tail. The rows above that name a detail operation
should be re-run before they are quoted.
+2 -2
View File
@@ -446,8 +446,8 @@ reading a flag.
After the output transform, immediately before the clip and the encode — not
among the layer blocks. Everything there runs on scene-referred colour in the
working space, where a flat tint would be pushed through the base curve and
the camera matrix and arrive as some other colour, and an alpha's white on
working space, where a flat tint would be pushed through the view transform
(the base curve and the camera matrix, before D19) and arrive as some other colour, and an alpha's white on
black would arrive as neither.
### 6.3 Not on the graph
+68 -17
View File
@@ -38,6 +38,21 @@ 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,
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.
**And for 0.19.0.** D19 rebuilt the develop pipeline around one rule — scene-linear from the camera
matrix to a single view transform, last ([architecture.md §6.14](architecture.md)) — which neither
closed nor opened an entry here: FR-DEV-3e's per-body base curves are retired by decision rather than
left outstanding, and its DCP half stays deferred as before. §4's FR-DSP-2 and NFR-RES-2 entries
record the one case that now tiles, a linear DNG larger than one texture, and §11 the merge's frame
choice, which changes how FR-MRG-5 is met rather than whether.
---
## 1. Plugins — post-v1 since 2026-09-19
@@ -91,7 +106,7 @@ somebody reads the matrix.
## 2. Culling — the stated differentiator, half built
[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
`core/dr-gpu/src/focus.rs` and `ui/dr-ui/src/peaking.rs`; the raw histogram and the raw clipping
@@ -133,6 +148,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
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.
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.
@@ -160,9 +186,9 @@ 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. Built for one case, and otherwise 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,
and the evidence has since moved. `core/dr-gpu/tests/frame_budget.rs` carries the argument in its
own header: one fused dispatch over a viewport-sized target is comfortably inside the frame budget,
@@ -172,15 +198,22 @@ path stops being supported, and this test is what says so."
tiled convolution at clarity's radius reads nearly twice the taps that an untiled one does, so the
stage that looks most like it wants a tile cache is the stage that would be hurt most by one.
What exists is the declaration and not the mechanism: `DetailPass::radius` is documented as the halo
a tile would have to be grown by, with a test that pins it, and there is no scheduler to read it.
That is deliberate plumbing, not an oversight.
What existed until 0.19.0 was the declaration and not the mechanism: `DetailPass::radius`, the halo
a tile would have to be grown by, with a test that pins it, and nothing to read it. The export of an
oversized linear DNG (below) is now what reads it, through `ComposedDetail::reach`; there is still
no scheduler and no tile cache.
**So the open question here is not "when is tiling built" but "is FR-DSP-2 still a requirement" — and on 2026-09-19 the answer was: as written, until S6 runs.** FR-DSP-2 now carries a status note saying exactly that, and R5's note no longer claims it was rewritten.
Two measurements say it costs more than it saves on the interactive path. Neither says anything
about the export path or about a device under memory pressure, which is where the case for it
actually lives — and that is spike S6, which has not run.
**2026-09-27:** the export path does now tile, for the one case that forced it — a linear DNG
wider than any texture, a 22 927 × 8966 Lightroom panorama in the case that prompted it. See the
note under FR-DSP-2 in [requirements.md](requirements.md) and ARCH §5.3. The interactive path
renders such a file from a reduced copy and one full-resolution window rather than tiles, and S6
is still unrun.
**FR-DSP-4 — Progressive refinement. Built in 0.15.0.** While a gesture moves the canvas renders
a half-resolution draft, and the sharp frame lands once, 120 ms after the last movement: the
decision is `ui/dr-ui/src/refine.rs`, a debounce whose every draft re-arms the settle timer, driven
@@ -193,12 +226,25 @@ interface could see, and a refinement that was not a jarring swap.
**NFR-RES-2 — Images larger than GPU memory.** Half answered. NFR-R8's "decide explicitly" was
decided on 2026-09-19: there is no CPU render pipeline, the degraded mode is the viewer on
embedded previews with develop withheld, and NFR-RES-2 no longer promises a fallback render. What
remains unbuilt is the memory half: there is no headroom budget, no allocation-failure staging,
embedded previews with develop withheld, and NFR-RES-2 no longer promises a fallback render. Since
0.19.0 that mode no longer catches a linear DNG larger than one texture, which develops from a
reduced copy and exports in tiles; a CFA file that large still falls to it. What remains unbuilt is
the memory half: there is no headroom budget, no allocation-failure staging,
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
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
@@ -226,8 +272,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.
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
still scroll by flick alone, which is deliberate rather than outstanding.
the help sheet) are drawn only where `dr_plat::is_touch_first()` is false. On Android the same
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 +299,12 @@ about.
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
`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
path now carry `TRACES: FR-PLAT-AND-1`, and the matrix counts the requirement as covered. **That
overstates it.** The mechanism is the one the requirement names, but its subject is the library, and
Android still reaches a library through a Nextcloud account or a folder, over paths, like the
desktop. Either the tags narrow to FR-EXP-10 or the requirement is met for the library too; until
one of those, read the coverage figure with this one subtracted.
export through `DocumentsContract`; `ui/dr-ui/src/saf.rs` is the JNI bridge. They shipped tagged
`TRACES: FR-PLAT-AND-1`, which made the matrix count the requirement as covered, and that overstated
it: the mechanism is the one the requirement names, but its subject is the library, and Android
still reaches a library through a Nextcloud account or a folder, over paths, like the desktop. The
tags now say FR-EXP-10 alone (0.17.1), so FR-PLAT-AND-1 reads as uncovered again until the library
itself is reached through SAF.
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
@@ -534,7 +583,9 @@ whether something *should* be built — which is the opposite of the order §9 a
and focus stacking there with their data model decided. Its clauses entered the register with no
code behind them, which is why the coverage figure fell from 83.0% to 77.2% on that day — and the
panorama was then built in the same week: alignment, projections, the chunked composite written as
a DNG beside its sources, the auto-crop and the model's border fill.
a DNG beside its sources, the auto-crop and the model's border fill. In 0.19.0 a frame that cannot
be placed no longer ends the job: it is named on its row and the merge waits for it to be unticked,
and any frame can be left out that way without reading the rest again (panorama.md §15).
[panorama.md](panorama.md) §11 and §13 are where it stands.
Three clauses carry no tag:
+145 -10
View File
@@ -128,7 +128,11 @@ stays hot for it.
### 5.1 The tap — S15.3, answered by reading the composer
The fused shader's order, fixed by `operation.rs`'s own tests: warp → as-shot
white balance → operations → base curve → camera matrix → store. The store is
white balance → operations → base curve → camera matrix → store. *(Amended
2026-09-27, D19: warp → as-shot white balance → white balance → camera matrix
→ operations → view transform → store. The tap is unaffected: it has no
operations, its caller fills the matrix with the identity, and the composer
emits no view transform in `OutputMode::CameraLinear`.)* The store is
either the display encode or, in `OutputMode::LinearWorking`, an unclipped
`rgba16float` of linear sRGB. That mode exists for the detail stage and is
selected from the operations, never by a caller flag, so that a shader and
@@ -139,7 +143,9 @@ composer already makes that a matter of uniforms rather than structure: the
white balance, the matrix and the curve's active flag are all in the reserved
uniform block, and a fused pass with no operations, `as_shot_wb = 1`,
`cam_to_srgb = I` and `base_curve_last.z = 0` stores exactly camera-linear
RGB after the warp. So the tap is:
RGB after the warp. *(Since D19 there is no curve flag: the base curve is
gone, and the tap composes no view transform, so the white balance and the
matrix are the only uniforms it fills neutral.)* So the tap is:
- `EditGraph::compose_camera_linear()` — the `LinearWorking` tail with an
empty operation list and identity framing, paired by name with
@@ -154,8 +160,8 @@ be re-developed deserves the sensor's precision. The cost is 2× on buffers
FR-MRG-11 already bounds.
**What the DNG carries as a consequence:** the first source's `Make`,
`Model` and `UniqueCameraModel` — so `base_curve::for_body` finds the 6D's
curve — its `ColorMatrix1`/`2` with illuminants, and its `AsShotNeutral`. The
`Model` and `UniqueCameraModel` — so `base_curve::for_body` found the 6D's
curve, until D19 retired the per-body curves — its `ColorMatrix1`/`2` with illuminants, and its `AsShotNeutral`. The
composite then develops through the same profile as its sources, applied
once. The spike's 64 × 48 file (§8) already carries the matrix and neutral;
the body name is a string.
@@ -238,7 +244,9 @@ first source with a `-pano` suffix, beside it.
Three samples per pixel rather than a CFA: the warp resamples, and there is no
sensor grid to mosaic back onto. Nothing else about being a RAW is lost —
no white balance, no curve, no matrix, no clip has been applied — and the
photographer develops the panorama afterwards as one photograph.
photographer develops the panorama afterwards as one photograph. One sample
is rewritten: a blown one, which is written as the camera value the
composite's balance calls grey rather than as the sensor's (1, 1, 1) — §15.
The sources are portrait frames in the 6D set: `Orientation` is applied
before alignment (learned features are not rotation-invariant) and the
@@ -279,11 +287,60 @@ carrying the first source's EXIF in a sub-IFD as `dr-export` already does.
- The dialog shows the aligned proxies in the chosen projection, with the
projection, horizon and crop controls of FR-MRG-4, and the per-frame
residuals. A frame that failed to align is named there (FR-MRG-5), and the
merge cannot be confirmed with it in the set.
merge cannot be confirmed with it in the set. *(Since 2026-09-27 each row
has a box: an unticked frame is left out and the rest are solved again from
what the first pass measured — §15.)*
- Confirm starts the FR-MRG-7 job. The composite appears in the grid when the
file is written and catalogued, beside its sources, with the merge as the
first entry in its history.
**How it gets there (FR-MRG-6, 2026-09-28).** A rescan fired as the merge
finished raced the upload it followed — the 800 MB copy into a folder library
was still running when the folder was listed, and a Nextcloud upload takes
minutes — so the listing lacked the composite, recorded the folder's
validator, and the grid did not show it until the next sync pass. Now:
- *Catalogued by the merge.* `MergeEvent::Done` carries a `Composite` — the
name it will have, the size of the picture it opens on (the crop, or the
whole when filled), the capture time written into the DNG (the mean of the
frames'; the sources' earliest where none has one), the body, and its
thumbnails. `library::catalogue_composite` writes the row in one
transaction, keyed on `(root_id, source_ref)` exactly as the scan will list
the file, at `metadata_state = 2`, and the grid reloads. The name is chosen
against the catalog's names in that folder (`names_in_folder`), since the
upload replaces whatever is at its name.
- *The server's half after the upload.* Once a file the catalog already has
a row for is sent, the drain lists its folder once, records the file id the
server assigned (`record_uploaded`) and puts the merge's thumbnails in the
store under it; then the grid rescans. A scan that ran before the upload
leaves the row alone, and the one after it updates it in place.
- *Thumbnails from the merge.* The bands are box-reduced as they are written,
after the fill, to a copy 4096 pixels long (`merge_thumbs::Reduced`). That
copy is written as a linear DNG in memory with the composite's own profile,
header and crop and opened through `open_session` — develop's first open:
the D19 pipeline, the default view transform and tone mapping, the as-shot
balance and the working-space-to-display conversion. The grid, large and
wide classes are rendered from that session, staged in the outbox as
`x.dng.thumbs` before the rename releases the payload, and drawn from
memory until the upload has a file id to store them under. A test develops
a synthetic composite both ways and holds the mean, 95th and 99.5th luma
percentiles within 3–4 levels; the naive balanced-and-gamma picture misses
by 13. Older composites, which have no staged thumbnails, are thumbnailed
the ordinary way.
- *A wide cell.* `library_ui::layout` places the grid as a lattice of slots.
`natural_span` maps aspect to 2, 3 or 4 columns (from 1.9, 2.45 and 3.46 —
√(s(s+1)) is where two neighbouring classes leave the same share of their
cell empty), capped at the columns there are and the whole row on the
tablet, and the same number names the thumbnail class (`Wide2`–`Wide4`, 512
pixels of long edge per column). A wide cell that does not fit in the rest
of a row starts the next; nothing later moves into the gap, so ordinals —
the arrows, a shift-click's run, the timeline, burst folding — are
untouched, and up/down step by rows through the layout. The window's own
read carries `w` and `h`; where the wide ones sit in the whole list is one
query, run when the list changes, and a library with no panorama answers it
from the partial index `images_wide`, created on first use rather than by a
schema bump.
## 10. Order of work
1. **S15**, all four, before anything else. (1) and (2) are a day each and
@@ -310,7 +367,7 @@ Built, on branch `merge/panorama`, in the order §10 gave:
| The camera-space tap | `OutputMode::CameraLinear`, `AdjustPass::render_camera_linear` | Done, `rgba32float`, tiles by view rect |
| Linear DNG writer, streamed | `dr_export::write_linear_dng` | Done; rawler reads it back |
| A three-sample `RawImage` re-entering the pipeline | `dr-decode`, `DemosaicedImage::from_linear_rgb16` | Done |
| Warp, accumulate, resolve, chunk by chunk | `dr_gpu::MergePass`, `merge.wgsl` | Done; feathered blend, scalar gain |
| Warp, accumulate, resolve, chunk by chunk | `dr_gpu::MergePass`, `merge.wgsl` | Done; seams (§11.1) over a feather, scalar gain |
| The job: load, proxies, align, gains, confirm, merge, provenance | `dr_ui::merge` | Done; `examples/merge.rs` drives it headless |
| The page: table, preview, projection, Merge/Stop/Back; the grid's button | `merge.slint`, `merge_ui.rs` | Done; `DARKROOM_START_MERGE=a.CR2,b.CR2` lands on it |
| Placement beside the sources through the outbox, rescan | `merge_ui.rs` | Done, untested against a server |
@@ -328,9 +385,11 @@ half.
the file carries the black border. The largest inscribed rectangle over
the coverage, then the DNG's `DefaultCropOrigin`/`DefaultCropSize`, so
nothing is thrown away and the develop view opens on the picture.
2. **Seams and the pyramid** (§10 step 5). The feather hides exposure and
small misalignment; parallax on the near slope will show as a soft
double edge at 1:1.
2. **The pyramid** (§10 step 5). Seams landed 2026-09-30 (§11.1); the
blend across them is one width for every frequency, so an exposure step
the gains leave is narrowed to the seam's 64 px rather than hidden over
the old 200. A Laplacian pyramid would blend low frequencies wide and
detail narrow.
3. **Vignetting in the tap.** The lens profile's distortion is applied
before the fetch; its vignetting is an operation and is not. Frame edges
are darker than their centres by the lens's falloff, and the feather
@@ -345,6 +404,35 @@ half.
catalog's `content_hash` is null for most images most of the time. The
hash can join it when the catalog has one.
### 11.1 Seams — 2026-09-30
The feather averaged every overlap over 200 px, so anything the frames
disagreed on — parallax on the near slope, a walker, wind in a branch — came
out twice at half strength: a soft double edge at 1:1, reported as a glitch.
`dr_pano::seam` now chooses, per output texel at proxy resolution, which
frame it is taken from. Frames are laid down nearest-first; where a new one
overlaps the composite, each texel costs the gain-corrected difference
between the two, plus the detail either has there, plus nearness to either
frame's edge (vignetting, the lens correction's fringe), taken as the
**worst** over a 4-texel window so the path stays a blend radius clear of a
difference rather than grazing it. The cut is a dynamic-programming path
across the overlap, perpendicular to the line from the composite's frames to
the new one: §4's per-column seam, not a graph cut. The map is computed per
projection, for the page's preview and again for the merge.
`merge.wgsl` weights a frame by its tent-filtered share of the label map
about each pixel (`SeamMap::share`, repeated verbatim), over a window
`seam_blend_px` wide (64, capped at 4 texels either side). The edge feather
remains underneath as a factor and, with a 1e-4 floor, as the answer where
the map names no frame that reaches the pixel. `--feather-only` on
`examples/merge.rs` merges the old way, for comparison.
Known limits: one axis per new frame, so in a multi-row set a frame
overlapping its left neighbour and the row above is cut along a compromise
direction; the cost reads grey proxies, so a difference in hue alone is
invisible to it.
## 12. Filling the border instead of cropping it — MI-GAN, read and measured 2026-09-19
Raised after the first merges: the ragged border a cylinder leaves could be
@@ -559,3 +647,50 @@ gaps between cells — built and measured in `darkroom-infill`
and the next thing to port into `dr_pano::fill` (it needs the
discriminator as a second model, ~80 MB fp16). FR-MRG-4's *experimental*
stays.
## 15. Leaving a frame out, and white clouds — 2026-09-27
**A frame is left out from its row, not by starting again.** Until now a
frame that did not fit ended the job with its name, and the only way on was
Back, a smaller selection, and every frame read, demosaiced and searched for
keypoints again. Each row of the Frames table on the merge page now has a
box, ticked by default. Unticking one leaves the frame out and the rest are
solved again at once; ticking it brings it back.
What makes that cheap is a split in `dr_pano::align`. `match_pairs` does
the matching and the pairwise RANSAC once over every frame — about 5 s for
the twelve-frame fixture — and `solve` takes a subset and uses only the
links among the frames in it, about 0.1 s. Solving a subset by re-aligning
it from scratch was tried and is wrong: the RANSAC seeds are keyed on frame
position, so dropping a frame moved every seed after it, and on the fixture
that was enough to lose a marginal link and strand a neighbour of the frame
left out. A frame whose only overlap was with one left out is reported
unaligned, exactly as it would be had it never been measured with it.
**A frame that cannot be placed no longer ends the job either** (FR-MRG-5).
Its row names it and says why, and `Merge` stays off until it is unticked —
still never a silent drop. The headless example leaves such frames out the
same way, and takes `--leave-out N` to untick frame `N` once the first
alignment is in.
**Blown highlights stay white.** A clipped photosite reaches the merge as
camera (1, 1, 1), which the as-shot balance turns magenta. The alignment
preview balanced it with no highlight rule, so every blown cloud was pink
on the page. The DNG had a quieter form of the same fault: a frame's gain
below one moved a blown sample off the white level, and the feather mixed
it into a neighbour's real sky, after which the develop's own highlight
desaturation no longer recognised it. The merge shader (`merge.wgsl`) and
the preview (`grey_if_blown` in `dr_ui::merge`) now write a blown sample,
before the gain, as the camera value the composite's balance maps to grey —
the develop pipeline's neutral, fading in from `CLIP_ONSET` exactly as the
develop's does.
**A composite this wide is past two limits, both lifted the same day.**
They were found on a 22 927 × 8966 panorama from Lightroom, and the fixture's
own composite (22 993 × 5 980, §11) is past both. rawler's allocation guard,
sized in samples but worded in pixels, refuses a three-sample DNG past about
16 700 pixels wide; the copy in `third_party/` carries it raised
([README](../../third_party/README.md)). And no texture holds such a frame:
a linear DNG past 8192 pixels now opens on a box-reduced copy, a render finer
than the copy samples a window of the full resolution, and the export is drawn
in tiles (FR-DSP-2's note in requirements.md, ARCH §5.3).
+207 -14
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
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
compromise the process. Decode failures are reported per-file and do not abort a batch.
@@ -295,6 +306,21 @@ from source + graph.
per channel in a wide-gamut linear working space. Quantisation to the output bit depth happens
once, at the final export or display stage.
*Amended 2026-09-27 (D19):* quantisation is one of three things deferred to the end, not the only
one. Until the view transform (FR-DEV-3j), values are **scene-linear and unbounded**: nothing
clamps above 1.0, nothing applies a transfer function, and nothing maps to a display gamut. Every
operation between the camera matrix and the view transform receives and returns that. The
working space's primaries are linear Rec.709, carried unbounded, so a colour outside sRGB is a
negative component rather than a clipped one. That is wide-gamut in range, not in the primaries
the operations measure hue against; moving the primaries to Rec.2020 is deferred (D19).
*Acceptance:* every point operation at non-neutral settings, handed a ramp to 16.0, returns values
that are still monotone in the ramp and still above 1.0 where the ramp is, before the view
transform (`scene_referred_until_the_view`, `core/dr-gpu/tests/scene_referred.rs`, rendered on a
device: `dr-pipeline` has none). The view transform itself and film simulation are excluded,
because clipping into a display range is their job, and so is the detail stage, which a flat frame
cannot exercise.
**FR-DEV-3 — Adjustment set (v1).**
- White balance (temperature/tint, and picker)
@@ -315,6 +341,13 @@ once, at the final export or display stage.
- Crop, straighten, rotate, flip
- 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
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.
@@ -390,21 +423,31 @@ demosaic and the working-space conversion.
**v1 scope** (per D11 — good defaults rather than exhaustive colour science):
1. Embedded DNG `ColorMatrix1/2` and `ForwardMatrix1/2` tags
2. A hand-tuned base curve per launch camera body, shipped with the app
2. ~~A hand-tuned base curve per launch camera body, shipped with the app~~ — **retired
2026-09-27 (D19).** The tone half of "the camera's look" is the view transform's (FR-DEV-3j),
one for every body and adjustable. The colour half stays here, in the matrix and later the DCP.
3. HaldCLUT import (FR-DEV-3f)
The camera profile ends at the matrix, and the matrix runs **first**: white balance is applied in
camera RGB, where its multipliers are defined, and every other operation receives working-space
colour. Before D19 the edits ran in camera RGB and the matrix came after them, so a hue in the
colour mixer and the weights in `luminance()` meant something different on every body.
**Deferred but not foreclosed:** full `.dcp` support with `HueSatDeltas`, `ProfileLookTable`, and
dual-illuminant interpolation. The stage shall be structured so these are additions rather than a
pipeline reordering.
Rationale for the reduced scope: a bare 3×3 matrix produces the flat, poor-skin-tone rendering
characteristic of dcraw defaults, which is the documented reason people abandon darktable in the
first hour. A per-body base curve fixes most of that at a fraction of the cost of a full DCP
implementation. The profile database ships **versioned independently of the app binary** so bodies
and curves can be added without a release — and, under D8's GPLv3, contributed by users.
first hour. ~~A per-body base curve fixes most of that at a fraction of the cost of a full DCP
implementation.~~ The flat render is a missing *view transform*, not a missing per-body curve:
darktable's own answer to the first-hour complaint was a scene-referred default, and Ansel's is
the same. The per-body curves this clause shipped described themselves as hand-tuned shapes, not
measurements, and their provenance was not known well enough to keep them as defaults (D19).
*Acceptance:* for each launch body, the default render is subjectively comparable to the camera's
own JPEG. ΔE2000 validation against ColorChecker references applies once DCP support lands.
*Acceptance:* the default render is subjectively comparable to the camera's own JPEG — through
FR-DEV-3j's default, for every body. ΔE2000 validation against ColorChecker references applies
once DCP support lands.
**FR-DEV-3f — Look emulation.** Support HaldCLUT import, which inherits the existing free film
simulation ecosystem at near-zero implementation cost, plus reading the in-RAF film simulation tag
@@ -421,9 +464,13 @@ the picture along the film's own curve, shoulder and all, rather than scaling a
exposure. And the **data cost inverts**: a stock is ~17 kB of published measurements where one
HaldCLUT is ~800 kB of one person's grade.
A film simulation is a *rendering*, not an adjustment, so it replaces the camera profile's base
curve and the conversion out of camera space (`Operation::renders`) — applying both would render
the scene twice.
A film simulation is a *rendering*, not an adjustment, so it **is** the view transform when a
stock is chosen (FR-DEV-3j): it runs last, after every adjustment and after the detail stage, in
place of the default sigmoid, and never in addition to it. *Amended 2026-09-27 (D19):* it ran at
order 25 before this, after exposure and before everything else, so the edits below it acted on
the film's output. They now act on the scene the film is shown: an edit is a decision about the
exposure the negative receives, and the film is the last thing that happens to the picture.
Existing edits that combine a stock with tone or colour operations render differently.
*Acceptance:* a neutral scene printed through a colour negative's own paper renders neutral to
within 0.06 in linear sRGB; the baked lookup's interpolation error stays under one 8-bit code
@@ -434,6 +481,15 @@ reference implementation.
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).
*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
demosaic.
@@ -491,6 +547,34 @@ also written to the sidecar, run-length coded beside the layer, because a stored
that never runs a model. It is a materialisation of the identity, not the edit: it takes no part
in equality or merge, and the identity remains what the part means.
**FR-DEV-3j — View transform.** The last stage of the develop pipeline maps scene-linear
colour to a display range, and it is the only stage that may. By default it is a log-logistic
sigmoid applied per channel, with the middle channel's position between the other two restored
afterwards so a hue survives the shoulder, and the result clipped only by the output transform. A
stock chosen under FR-DEV-3f replaces it.
It is an operation with two parameters, persisted in the sidecar, adjustable in the develop panel,
and held per mask layer like any other:
- **Contrast** — the sigmoid's slope. Default 1.4.
- **White** — how far above middle grey, in stops, the scene reaches display white. Default 4.0,
so a highlight a stop past sensor saturation still rolls into white rather than clipping at it.
Scene middle grey is 0.13, where the retired default curve placed it (FR-DEV-3e), and it maps to
display 0.18. A photograph with the view transform at its defaults is **unedited**: the operation
is always composed, and "active" keeps meaning "moved from the defaults", so an untouched image
writes no parameters and every other operation's neutral is still the image.
An already-rendered source — a JPEG — is not rendered again: the view transform is skipped for it,
as the base curve was, so its two sliders do not move a JPEG. A film stock is not skipped, because
choosing one is an edit.
*Acceptance:* monotone in each channel; a neutral stays neutral; middle grey lands within 0.01 of
0.18; between scene 0.03 and 1.0, the default is within 0.3 EV of the retired default curve; the
scene value `0.13 · 2^white` reaches 1.0; and the shader agrees with the CPU reference.
*Added 2026-09-27 (D19).*
**FR-DEV-4 — Ordered, GPU-resident execution.** The pipeline executes as a sequence of GPU
compute stages. Intermediate results remain in GPU memory between stages. **Processed pixels
shall reach the display without a CPU round-trip.** *(This is a hard architectural constraint —
@@ -665,6 +749,15 @@ tiles are reused.
> number from the device the clause is about rather than from the one it is not. If S6 finds the
> fused pass inside budget there too, FR-DSP-2 becomes a scheduling concern for export and
> thumbnailing as frame-budget.md proposes; if not, S6 names the stage to tile.
>
> **2026-09-27: the export half is built, for sources larger than one texture.** A linear DNG
> past 8192 pixels (a stitched panorama, 22927×8966 in the case that prompted it) opens on a
> reduced copy, and a render finer than the copy samples a window of the full resolution
> through the fused shader's source-window uniforms. The export is drawn in 4096-pixel tiles
> grown by the detail chain's reach (`dr_pipeline::tiles`, `ComposedDetail::reach`) and matches
> the untiled render to within one code value (`core/dr-gpu/tests/source_window.rs`). The
> interactive path is still one dispatch over the viewport: a zoomed canvas cuts one window
> and keeps it while the view stays inside, which is not the tile cache this clause describes.
**FR-DSP-3 — Interactive latency.** Moving a slider updates the visible region within one frame
budget at proxy resolution. When a full-resolution result is needed it is computed
@@ -1309,6 +1402,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
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
Face recognition was deferred in §7 through the 2026-08-08 calibration. It is undeferred here in a
@@ -1804,7 +1912,7 @@ with no depth to recover — so it is where the shared machinery is built.
**FR-MRG-2 — What is stitched.** Each source enters the merge in **camera space**: after black
and white levels, demosaic and lens distortion correction, and before everything else — no white
balance, no base curve, no camera matrix, no edit. The composite carries the first source's body,
balance, no camera matrix, no edit, no view transform. The composite carries the first source's body,
colour matrix and as-shot neutral, so that it is developed afterwards exactly as one of its
sources would be: the camera profile, the white balance and every operation in §3.3 are applied
once, to the composite, in its own develop.
@@ -1813,9 +1921,11 @@ This is the clause that decides what the output *is*. Stitching the rendered edi
stitcher does; the result cannot be re-developed, and any difference between the frames' edits
becomes a seam. Stitching camera-space pixels produces a photograph the camera could have taken,
and nothing is applied twice. The cut sits *below* the profile, not above it, for a reason S15.3
found in the pipeline: the base curve is part of the profile (FR-DEV-3e) and is applied to every
frame of a known body, so a composite that baked it in and then developed as one would render the
curve twice. Lens correction alone sits above the cut, because a distorted frame does not align.
found in the pipeline: the profile's rendering was applied to every frame of a known body, so a
composite that baked it in and then developed as one would render it twice. *(Amended 2026-09-27,
D19: that rendering was the per-body base curve, retired; the view transform that replaces it is
applied to every develop, so the reason stands.)* Lens correction alone sits above the cut, because
a distorted frame does not align.
White balance sits below it because the sensor saw the same light in every frame: un-balanced
camera RGB agrees across the overlaps whether or not the camera's auto white balance drifted, and
the balanced values would not.
@@ -1869,6 +1979,11 @@ The same rule as `spot-removal.md`'s and D17's: a tool that quietly alters or om
photograph is the failure this application must not have, and here the omission would be an
entire frame.
*Amended 2026-09-27:* the job no longer stops. The frame is named on its row, with why, and the
merge cannot be confirmed until the photographer unticks it; the rest are then solved again from
the pairs already measured (panorama.md §15). The omission is the photographer's, made in view,
which is what this clause asks — never a silent drop.
**FR-MRG-6 — Provenance.** *(general to any merge)* The composite's sidecar carries
`derived_from`: the content hashes of its sources in order, and the merge parameters. The history
records the merge as the first entry, and export metadata declares the composite as one. Sources
@@ -2054,6 +2169,11 @@ tiling. GPU memory headroom is configurable. Where an allocation fails, work is
memory or refused with a typed error (`GpuError::TooLarge`) — never rendered by a CPU pipeline,
which does not exist (NFR-R8).
> **2026-09-27:** a linear DNG larger than one texture is no longer refused. It is developed from
> a reduced copy and full-resolution windows, and exported in tiles (FR-DSP-2's note). A CFA file
> too large for one texture, or whose photosites overrun the device's storage-buffer limits on
> upload, is still refused, and there is still no headroom budget.
**NFR-RES-3 — Mobile power.** On Android the app shall not render continuously when idle. Battery
and thermal behaviour are first-class concerns; background sync respects metered-connection and
battery-saver settings.
@@ -2145,6 +2265,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
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
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.
@@ -2291,6 +2424,7 @@ Rationale, evidence, and the eliminated alternatives are recorded in
| D11 | Product positioning | Culling-first differentiator; see below |
| D12 | Scope versus pace | **DECIDED 2026-09-19** — settled by events; full scope stands, no v1 date |
| D18 | Derived images | **DECIDED 2026-09-19** — a merge writes a new source file; no multi-source Version |
| D19 | Scene-referred pipeline | **DECIDED 2026-09-27** — edits on unbounded scene-linear colour; one view transform, last; per-body base curves retired |
### D11 — product positioning
@@ -2303,7 +2437,7 @@ Settled by requirements calibration, 2026-08-08.
| Culling | **The core differentiator** (§3.9) |
| Focus checking | Peaking *and* zoom |
| Ingest | Full workflow — template rename, checksum verify, dual-destination |
| Colour defaults | Good, not obsessive — matrices plus per-body base curve |
| Colour defaults | Good, not obsessive — matrices plus one scene-referred view transform for every body (D19; the per-body base curves are retired) |
| Film simulation | Fujifilm explicitly targeted |
| AI | Denoise in v1; masking deferred. Per-face eye state and head pose are in v1 **as culling evidence, not AI** (FR-CULL-8a, FR-CULL-13); gaze deferred (§7) |
| Local adjustments | Full masking, GPU-rasterised |
@@ -2537,6 +2671,65 @@ file, not an edit to the old one; and the composite occupies disk — a five-fra
where the output is still frame A, and inherits nothing from this decision but the provenance
rule.
### D19 — scene-referred pipeline · **DECIDED 2026-09-27**
**Edits operate on scene-linear, unbounded colour in the working space, and one view transform,
last, maps it to a display range.** Range, encoding and gamut are all deferred to that point, as
quantisation already was (FR-DEV-2).
*Why now.* The spec missed [Ansel](https://ansel.photos/), Aurélien Pierre's fork of darktable 4.0,
and with it the argument he spent years making in darktable: a display-referred curve early in the
pipeline throws away what every later stage needs. Reading the code against that argument found
four places it applied:
1. **The base curve clipped.** It was a five-point spline on the unit square, flat past its last
point, so every value above 1.0 — every recovered highlight — left it at the same number, per
channel.
2. **The detail stage was handed non-linear data.** The fused pass stops at "linear working
values" when a sharpener or a blur follows, but it stopped *after* the base curve, so the
neighbourhood operations convolved curved, clipped values while their comments promised the
opposite.
3. **The edits ran in camera RGB.** The matrix came after them, so `luminance()`'s Rec.709 weights
were applied to camera primaries and a hue in the colour mixer was a different hue on each
body. ARCH §5.2 had always drawn the matrix first; the code had drifted.
4. **The tone curve clamped** to [0, 1] and applied a 2.2 gamma around its spline, mid-chain.
*What changes.* The order becomes: demosaic → as-shot white balance and the white balance
operation, in camera RGB → the camera matrix → every other point operation and every mask layer →
the detail stage → the view transform (FR-DEV-3j), or the film stock (FR-DEV-3f) when one is chosen
→ the output transform. With a detail stage the view transform is a dispatch of its own after it,
composed by the same generator as the fused pass. Nothing before the view transform clamps above
1.0 or display-encodes, and a test says so (FR-DEV-2).
*What is retired.* The per-body base curves and their database (FR-DEV-3e). Their own file called
them hand-tuned shapes rather than measurements, and not enough was known about where the shapes
came from to keep them as defaults behind sliders. Body character is the matrix's, and the DCP's
when it lands.
*What it costs.*
- **Every photograph renders differently.** The default view transform was fitted so middle grey
lands where the retired default curve put it and midtones stay within 0.3 EV of it, but the
upper midtones are darker and the highlights roll off over two more stops. Previews rendered
before the change keep the old look until they are rendered again.
- **Film edits change meaning.** A tone or colour operation beside a stock used to act on the
film's output; it now acts on the scene the film receives.
- **Tablet and desktop must be released together.** No schema changes and the sidecar gains only
ordinary parameters, but two peers on different builds render the same edit differently.
- **One more dispatch with a detail stage**, for the view transform after it.
*Rejected.* Keeping the per-body curves as the view transform's per-body defaults, for the
provenance reason above. Leaving the film at order 25 and having it suppress the view transform:
simpler, and it kept existing film edits' meaning, but it left a display-referred rendering in the
middle of the chain, which is the thing this decision removes. A fixed view transform with no
controls: it would have been smaller, but a scene-referred pipeline whose white point cannot be
moved hands the photographer a shoulder they cannot place.
*Deferred.* Working-space primaries of Rec.2020 rather than Rec.709. The range is already
unbounded, but several fragments floor at zero, which clips a colour outside sRGB, and the colour
mixer's bands and the colour grading wheel would need their hues re-measured. Gamut compression
beyond the output transform's clip goes with it.
### D16 — plugin licensing · **OPEN, post-v1**
> Deferred with §3.10 on 2026-09-19. Still to be answered before the format is published as
+129 -128
View File
File diff suppressed because one or more lines are too long

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