Compare commits

...
41 Commits
Author SHA1 Message Date
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
1092 changed files with 75830 additions and 2952 deletions
Generated
+25 -27
View File
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]] [[package]]
name = "darkroom-android" name = "darkroom-android"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"dr-plat", "dr-plat",
@@ -1278,7 +1278,7 @@ dependencies = [
[[package]] [[package]]
name = "darkroom-desktop" name = "darkroom-desktop"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-plat", "dr-plat",
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]] [[package]]
name = "dr-bench" name = "dr-bench"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-catalog", "dr-catalog",
@@ -1471,7 +1471,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-catalog" name = "dr-catalog"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"dr-face", "dr-face",
"dr-plat", "dr-plat",
@@ -1486,7 +1486,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-decode" name = "dr-decode"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"env_logger", "env_logger",
@@ -1500,7 +1500,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-export" name = "dr-export"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-gpu", "dr-gpu",
@@ -1519,7 +1519,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-face" name = "dr-face"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1532,7 +1532,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-film" name = "dr-film"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -1541,7 +1541,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-gpu" name = "dr-gpu"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"dr-decode", "dr-decode",
@@ -1559,7 +1559,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-inference-engine" name = "dr-inference-engine"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"env_logger", "env_logger",
"libloading", "libloading",
@@ -1574,7 +1574,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ingest" name = "dr-ingest"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"dr-plat", "dr-plat",
"dr-types", "dr-types",
@@ -1586,7 +1586,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-lens" name = "dr-lens"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"lensfun", "lensfun",
"log", "log",
@@ -1594,7 +1594,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pano" name = "dr-pano"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-inference-engine", "dr-inference-engine",
@@ -1608,7 +1608,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pipeline" name = "dr-pipeline"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -1617,7 +1617,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-plat" name = "dr-plat"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"android-native-keyring-store", "android-native-keyring-store",
"dr-types", "dr-types",
@@ -1633,7 +1633,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-preset-xmp" name = "dr-preset-xmp"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"dr-pipeline", "dr-pipeline",
"log", "log",
@@ -1643,7 +1643,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-segment" name = "dr-segment"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1656,7 +1656,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync" name = "dr-sync"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-plat", "dr-plat",
@@ -1670,7 +1670,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-folder" name = "dr-sync-folder"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-sync", "dr-sync",
@@ -1682,7 +1682,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-nextcloud" name = "dr-sync-nextcloud"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-decode", "dr-decode",
@@ -1704,7 +1704,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-thumbs" name = "dr-thumbs"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"jpeg-encoder", "jpeg-encoder",
@@ -1716,7 +1716,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-types" name = "dr-types"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -1725,7 +1725,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ui" name = "dr-ui"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -1773,7 +1773,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-xmp" name = "dr-xmp"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -5513,8 +5513,6 @@ dependencies = [
[[package]] [[package]]
name = "rawler" name = "rawler"
version = "0.7.2" version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04f4cc35c23969a4a834e0b117c7da41ace812eb9053b5effc3fc5c77d114677"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"bitstream-io", "bitstream-io",
@@ -7109,7 +7107,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]] [[package]]
name = "traceability" name = "traceability"
version = "0.18.2" version = "0.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"proc-macro2", "proc-macro2",
+8 -6
View File
@@ -32,7 +32,7 @@ members = [
exclude = ["third_party"] exclude = ["third_party"]
[workspace.package] [workspace.package]
version = "0.18.2" version = "0.19.1"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
@@ -276,11 +276,13 @@ opt-level = 0
lto = "thin" lto = "thin"
codegen-units = 1 codegen-units = 1
# Two upstream crates carry a local patch so that the Android build can draw # Three upstream crates carry a local patch: wgpu-hal and Slint's Skia
# with wgpu on a rotated display (technical-debt.md TD-1). Both are exact # renderer so that the Android build can draw with wgpu on a rotated display
# copies of the version the lockfile already resolves, plus that patch; # (technical-debt.md TD-1), and rawler so that a linear DNG wider than 16 700
# third_party/README.md says what was changed and how to carry it forward # pixels decodes. Each is an exact copy of the version the lockfile already
# when Slint or wgpu moves. # 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] [patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" } wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" } i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
rawler = { path = "third_party/rawler-0.7.2" }
+33 -25
View File
@@ -25,26 +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, onto one copy with the spares in the trash. Face detection and identity,
with the index syncing between devices. with the index syncing between devices.
**Developing.** Eighteen declared operations fused into one compute **Developing.** Nineteen declared operations, those that read one pixel
dispatch, plus the neighbourhood work that cannot be: clarity, texture, fused into a generated shader rather than run a pass each, plus the
capture sharpening, noise reduction, lens correction, spectral film neighbourhood work that cannot be: clarity, texture, dehaze, capture
simulation. Crop, straighten and correct converging verticals, spot repair, sharpening, noise reduction, lens correction. Every edit works on the scene
and local adjustments over masks the model draws — click a subject or a as the camera recorded it — linear, highlights beyond white included — and
category, then paint, subtract a gradient or keep only where two selections one `Tone Mapping` step, last, after sharpening and noise reduction, fits it
agree, grow or shrink the edge. A mask's sliders add to the photograph's, the to the screen, with a contrast and a white point of its own; a spectral film
film's among them, so a sky can be burned in on the print as a darkroom stock takes its place when one is chosen. Crop, straighten and correct
printer would. Hot and dead photosites are mended before the demosaic, with converging verticals, spot repair, and local adjustments over masks the
nothing to set. Focus peaking and a raw histogram for judging model draws — click a subject or a category, then paint, subtract a gradient
what is recoverable. Presets, with a collection shipped in the application — 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 everyday corrections, and a look for each measured colour, cinema and
black-and-white stock — and Lightroom presets imported as looks that leave a black-and-white stock — and Lightroom presets imported as looks that leave a
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) [![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 **Panoramas.** Select the frames, align, untick any frame to leave it out
ragged border rather than crop it, and the composite lands beside its and the rest re-align at once, choose a projection, fill the ragged border
sources as a DNG, with a sidecar recording what it was merged from. 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) [![Twelve hand-held frames aligned on a cylinder](docs/manual/media/panorama-aligned.png)](docs/manual/README.md#merging-a-panorama)
@@ -96,19 +104,19 @@ controls, its place in the chain and its tests.
## Where it stands ## Where it stands
**0.18.2**, twenty-eight tagged releases in. 192 numbered requirements in **0.19.1**, thirty tagged releases in. 193 numbered requirements in
scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md); scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
the rest are written down rather than merely absent. the rest are written down rather than merely absent.
**Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and **Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
survey culling, AI denoise, tiled rendering, HDR merge and survey culling, AI denoise, tiled rendering beyond the export of an oversized
focus stacking, importing a Lightroom or darktable catalog, translations DNG, HDR merge and focus stacking, importing a Lightroom or darktable catalog,
beyond the launch screen, most of the Android platform integration beyond translations beyond the launch screen, most of the Android platform
running, and a Flatpak actually built and run in its sandbox. The integration beyond running, and a Flatpak actually built and run in its
performance targets are half verified: the per-commit benchmark suite §8 sandbox. The performance targets are half verified: the per-commit benchmark
requires exists for everything that does not need a frame — the catalog, suite §8 requires exists for everything that does not need a frame — the
the scan, the thumbnails — and not yet for the render path, so a regression catalog, the scan, the thumbnails — and not yet for the render path, so a
there fails nothing. regression there fails nothing.
[outstanding.md](docs/dev/outstanding.md) is the list, with the reasoning for [outstanding.md](docs/dev/outstanding.md) is the list, with the reasoning for
each. each.
-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 //! 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. //! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
pub mod base_curve;
mod decoder; mod decoder;
mod error; mod error;
mod locate; mod locate;
mod preview; mod preview;
pub mod profile; pub mod profile;
pub use base_curve::BaseCurve;
pub use decoder::{default, Decoder, Rawler}; pub use decoder::{default, Decoder, Rawler};
pub use error::DecodeError; pub use error::DecodeError;
pub use locate::{ pub use locate::{
@@ -125,19 +123,6 @@ pub struct RawImage {
/// for the light the frame was shot under; see [`profile::CameraProfile`]. /// for the light the frame was shot under; see [`profile::CameraProfile`].
pub color_matrix: Option<[f32; 9]>, pub color_matrix: Option<[f32; 9]>,
/// TRACES: FR-DEV-3e /// 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. /// The usable region of `data`, excluding masked and border photosites.
pub crop: CropRect, pub crop: CropRect,
/// TRACES: FR-MRG-3 /// 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(), 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 // TRACES: FR-MRG-3
// A linear DNG — three samples per pixel, no colour filter array — is a // A linear DNG — three samples per pixel, no colour filter array — is a
// composite this application wrote (or any other demosaiced DNG). It // 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), .unwrap_or(u16::MAX),
wb_coeffs, wb_coeffs,
color_matrix, color_matrix,
base_curve,
samples_per_pixel, samples_per_pixel,
profile, profile,
make: image.camera.clean_make.clone(), 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** //! 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 //! 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 //! **rendering** saying what to do with the resulting scene-referred values so
//! that a photograph looks like a photograph. This module supplies the first //! that a photograph looks like a photograph. This module supplies the first.
//! and looks up the second ([`crate::base_curve`]). //! 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 //! # What is extracted, and from where
//! //!
@@ -65,8 +67,8 @@
//! FR-DEV-3e defers full `.dcp` support — `HueSatDeltas` and //! FR-DEV-3e defers full `.dcp` support — `HueSatDeltas` and
//! `ProfileLookTable` — and requires that they arrive as *additions* rather //! `ProfileLookTable` — and requires that they arrive as *additions* rather
//! than as a pipeline reordering. They would: both are lookups applied to a //! 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 //! colour at this matrix, before any edit reaches it, so they extend
//! extend [`CameraProfile`] with more calibration data and extend the shader's //! [`CameraProfile`] with more calibration data and extend the shader's
//! camera-profile stage with more work. Nothing above would move. //! camera-profile stage with more work. Nothing above would move.
use crate::{cam_to_srgb_from, invert3}; use crate::{cam_to_srgb_from, invert3};
+9 -3
View File
@@ -1,9 +1,8 @@
# Film stocks # Film stocks
One file per stock in [`profiles/`](profiles/). Adding a stock is adding a 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 file — no code change, no shader, no new operation — because under the GPLv3
`dr-decode`'s base curves work that way: under the GPLv3 a stock should be a stock should be contributable without a release.
contributable without a release.
## What a profile is ## What a profile is
@@ -51,6 +50,13 @@ matters — see [`src/bake.rs`](src/bake.rs) for the argument:
log exposure through the negative, the enlarger's exposure is added there, 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. and the paper's own curve row and cube take it to linear sRGB.
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 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 fetch — two for a print. Splitting 2 from 3, rather than baking one LUT over exposure, is measured
rather than assumed: the curve carries all the sharp shape and the dye mixing rather than assumed: the curve carries all the sharp shape and the dye mixing
+3 -3
View File
@@ -2,9 +2,9 @@
//! //!
//! # Why it is data //! # Why it is data
//! //!
//! The same argument `dr_decode::base_curve` makes for camera bodies, and for //! Under the GPLv3 a stock should be contributable without a release (the
//! the same requirement: under the GPLv3 a stock should be contributable //! argument the retired per-body base curves made for camera bodies, before
//! without a release. A profile is three tables and a handful of facts, all of //! 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 //! 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. //! a file — not a code change, not a shader, and not a new operation.
//! //!
+1 -2
View File
@@ -182,8 +182,7 @@ fn render_to(
// and the example never has to know which it was handed. // and the example never has to know which it was handed.
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(image.size(), (w, h)); let scale = graph.render_scale(image.size(), (w, h));
let detail = let detail = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour); let key = graph.invalidation().through(Affects::Colour);
adjust adjust
.render_detailed(image, &shader, w, h, None, &detail, key) .render_detailed(image, &shader, w, h, None, &detail, key)
+170 -71
View File
@@ -34,16 +34,6 @@ use crate::{DemosaicedImage, GpuContext, GpuError};
/// reads them. /// reads them.
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS; 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. /// Runs composed operation chains against demosaiced images.
pub struct AdjustPass { pub struct AdjustPass {
ctx: GpuContext, ctx: GpuContext,
@@ -132,6 +122,8 @@ pub struct AdjustPass {
colour_dispatches: usize, colour_dispatches: usize,
/// Detail dispatches encoded. /// Detail dispatches encoded.
detail_dispatches: usize, detail_dispatches: usize,
/// View passes encoded — one per render with a detail stage (D19).
view_dispatches: usize,
} }
struct Target { struct Target {
@@ -663,6 +655,7 @@ impl AdjustPass {
sample: SampleCache::new(ctx), sample: SampleCache::new(ctx),
colour_dispatches: 0, colour_dispatches: 0,
detail_dispatches: 0, detail_dispatches: 0,
view_dispatches: 0,
} }
} }
@@ -1058,13 +1051,14 @@ impl AdjustPass {
/// Render one frame with a neighbourhood stage. /// Render one frame with a neighbourhood stage.
/// ///
/// `shader` and `detail` must be the two halves of **one** composition — /// `shader` and `detail` must be the two halves of **one** composition —
/// `EditGraph::compose_for` and `EditGraph::compose_detail_for` on the same /// `EditGraph::compose_for` and `EditGraph::compose_detail` on the same
/// graph, at the same output space. The fused pass stops at linear working /// graph. The fused pass stops at linear working values when a detail
/// values when a detail stage exists and the last detail pass performs the /// stage exists, and its view pass ([`ComposedShader::view`]) performs the
/// output transform, so a mismatched pair either encodes twice or not at /// view transform and the output transform after the last detail pass
/// all. /// (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 /// the honest thing to do rather than an optimisation: an edit with no
/// active sharpening *is* an ordinary edit, and it should cost exactly /// active sharpening *is* an ordinary edit, and it should cost exactly
/// what one costs. /// what one costs.
@@ -1104,17 +1098,25 @@ impl AdjustPass {
detail: &ComposedDetail, detail: &ComposedDetail,
colour_key: u64, colour_key: u64,
) -> Result<&wgpu::Texture, GpuError> { ) -> 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); return self.render_masked(source, shader, width, height, masks);
} }
if shader.output_mode != OutputMode::LinearWorking { let view = match (shader.output_mode, shader.view.as_deref()) {
(OutputMode::LinearWorking, Some(view)) => view,
_ => {
return Err(GpuError::ShaderCompilation( return Err(GpuError::ShaderCompilation(
"this detail chain expects a fused pass composed to hand on \ "this detail chain expects a fused pass composed to hand on \
linear working values, but the shader given encodes its own \ linear working values, with its view pass, but the shader \
output; compose both halves from the same graph" given encodes its own output; compose both halves from the \
same graph"
.into(), .into(),
)); ));
} }
};
let (width, height) = (width.max(1), height.max(1)); let (width, height) = (width.max(1), height.max(1));
self.ensure_target(width, height); self.ensure_target(width, height);
@@ -1127,6 +1129,7 @@ impl AdjustPass {
// both want `&mut self`, and the second holds its borrow across the // both want `&mut self`, and the second holds its borrow across the
// encode below. // encode below.
self.pipeline(shader)?; self.pipeline(shader)?;
self.pipeline(view)?;
let colour_view = self let colour_view = self
.detail .detail
.colour_target(detail.len(), width, height) .colour_target(detail.len(), width, height)
@@ -1217,20 +1220,12 @@ impl AdjustPass {
self.colour_dispatches += 1; self.colour_dispatches += 1;
} }
// One encoder for the colour pass and every detail pass, submitted // One encoder for the colour pass, every detail pass and the view pass,
// once — the shape `MaskPass::render` established. Submission order is // submitted once — the shape `MaskPass::render` established.
// the whole of the synchronisation: each pass reads what the previous // Submission order is the whole of the synchronisation: each pass
// one wrote, through the same queue. // reads what the previous one wrote, through the same queue.
let target_view = self.targets[self.current] let (ran, result) = match self.detail.encode(&mut enc, detail, width, height) {
.as_ref() Ok(done) => done,
.expect("ensured above")
.view
.clone();
let ran = match self
.detail
.encode(&mut enc, detail, &target_view, width, height)
{
Ok(ran) => ran,
Err(e) => { Err(e) => {
// Nothing is submitted, so a cache this frame was to write // Nothing is submitted, so a cache this frame was to write
// holds nothing, and must not be read as though it did. // holds nothing, and must not be read as though it did.
@@ -1240,6 +1235,86 @@ impl AdjustPass {
return Err(e); 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.ctx.queue.submit(Some(enc.finish()));
self.detail_dispatches += ran; self.detail_dispatches += ran;
self.colour_key = Some((key, width, height)); self.colour_key = Some((key, width, height));
@@ -1275,22 +1350,13 @@ impl AdjustPass {
// runs. See `DemosaicedImage::is_non_linear`. // runs. See `DemosaicedImage::is_non_linear`.
let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 }; 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]); uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]);
// TRACES: FR-DEV-3e // TRACES: FR-DSP-2 | NFR-RES-2
// The camera profile's base curve, packed the way the generated block // Which part of the photograph the texture holds. The whole of it for
// declares it: four x, four y, then the fifth point and the flag. The // every source that fits in one texture, which writes back exactly
// flag is what lets one compiled shader serve a profiled body and an // what the composer put there.
// unprofiled one, so the pipeline cache is not split in two by which let w = dr_pipeline::SOURCE_WINDOW_UNIFORM_OFFSET;
// camera took the frame. uniforms[w..w + dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS]
// .copy_from_slice(&source.window_uniforms());
// 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]);
uniforms uniforms
} }
@@ -1404,6 +1470,13 @@ impl AdjustPass {
self.detail_dispatches 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 /// How many linear intermediates have been allocated. For tests: see
/// [`crate::MaskPass::allocations`] for the regression this catches. /// [`crate::MaskPass::allocations`] for the regression this catches.
pub fn detail_allocations(&self) -> usize { pub fn detail_allocations(&self) -> usize {
@@ -1447,9 +1520,9 @@ impl AdjustPass {
/// one: the storage format is in the layout. The profile uniforms are /// one: the storage format is in the layout. The profile uniforms are
/// filled neutral here rather than from the source, which is the whole /// filled neutral here rather than from the source, which is the whole
/// point of the mode (`OutputMode::CameraLinear`): unit white balance, /// point of the mode (`OutputMode::CameraLinear`): unit white balance,
/// identity matrix, base curve off. The non-linear flag is kept, so a /// identity matrix, and no view transform composed. The non-linear flag
/// JPEG source is still linearised — camera space for a JPEG is the /// is kept, so a JPEG source is still linearised — camera space for a
/// decoded values made linear, which is the best that exists. /// 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 /// The texture stays on the device for a merge's warp to sample; see
/// [`Self::camera_texture`] and [`Self::read_camera_linear`]. /// [`Self::camera_texture`] and [`Self::read_camera_linear`].
@@ -1478,8 +1551,6 @@ impl AdjustPass {
uniforms[4..8].copy_from_slice(&[0.0, 1.0, 0.0, 0.0]); 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[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]); 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 let params_buf = self
.ctx .ctx
@@ -1707,7 +1778,7 @@ pub(crate) fn numbered(src: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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::ops::{colour_mixer, exposure, saturation};
use dr_pipeline::EditGraph; use dr_pipeline::EditGraph;
// For `Operation::detail`, which is how `the_whole_chain_at_once_compiles` // For `Operation::detail`, which is how `the_whole_chain_at_once_compiles`
@@ -1745,7 +1816,6 @@ mod tests {
// Identity, so the test reasons about the operations alone // Identity, so the test reasons about the operations alone
// rather than about a camera's colour response. // 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]), 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, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -1949,7 +2019,6 @@ mod tests {
white_level: 16383, white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0], 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]), 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, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -2381,7 +2450,7 @@ mod tests {
// find those operations in neither stage and fail for a reason that is // find those operations in neither stage and fail for a reason that is
// not a defect. Shadows the smaller size deliberately. // not a defect. Shadows the smaller size deliberately.
let (w, h) = g.output_size(512, 512); 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!( assert!(
!detail.is_empty(), !detail.is_empty(),
"the detail half composed nothing, so nothing of it was compiled" "the detail half composed nothing, so nothing of it was compiled"
@@ -2401,7 +2470,20 @@ mod tests {
let mut fused_blocks = 0; let mut fused_blocks = 0;
for desc in g.descriptors() { for desc in g.descriptors() {
let id = desc.id.0; 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 let neighbourhood = detail
.passes .passes
.iter() .iter()
@@ -2435,8 +2517,15 @@ mod tests {
// because the two catch different faults: the XOR catches an operation // 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 wrong stage, this catches a block in the shader that nothing
// in the chain asked for. // 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!( assert_eq!(
shader.source.matches("---- ").count(), shader.source.matches("---- ").count() + view_blocks,
fused_blocks + warp_blocks + 1, fused_blocks + warp_blocks + 1,
"the fused shader carries a block nothing in the chain asked for" "the fused shader carries a block nothing in the chain asked for"
); );
@@ -2538,7 +2627,6 @@ mod tests {
white_level: 16383, white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0], 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]), 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, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -2642,7 +2730,6 @@ mod tests {
white_level: 16383, white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0], 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]), 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, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -3227,11 +3314,16 @@ mod tests {
} }
#[test] #[test]
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() { fn a_jpeg_and_sensor_data_differ_by_exactly_the_view_transform() {
// The two producers must be interchangeable. A mid-grey that is // TRACES: FR-DEV-3j
// linearly 0.216 (sRGB 128) arriving as sensor data and as a JPEG // The two producers must be interchangeable up to the rendering. A
// must render the same, or an edit would mean different things // mid-grey that is linearly 0.216 (sRGB 128) arriving as sensor data
// depending on which decoder opened the file. // 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 Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx); let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose(); let shader = EditGraph::default_chain().compose();
@@ -3250,11 +3342,18 @@ mod tests {
read_centre(&ctx, t) 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!( assert!(
delta <= 3, delta <= 3,
"the same scene value rendered {from_sensor:?} from sensor data \ "sensor data rendered {from_sensor:?}, expected about {expected}"
and {from_jpeg:?} from a JPEG" );
let delta = (i32::from(from_jpeg[0]) - 128).abs();
assert!(
delta <= 3,
"a JPEG was rendered again: {from_jpeg:?} from sRGB 128"
); );
} }
} }
+151 -42
View File
@@ -10,7 +10,7 @@
//! pass over this texture; it does not re-demosaic, which is what keeps the //! pass over this texture; it does not re-demosaic, which is what keeps the
//! interaction budget (NFR-P9) reachable on a 24 MP file. //! 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 wgpu::util::DeviceExt;
use crate::{GpuContext, GpuError}; use crate::{GpuContext, GpuError};
@@ -109,23 +109,25 @@ pub struct DemosaicedImage {
color_matrix: [f32; 9], color_matrix: [f32; 9],
/// As-shot white balance, the neutral starting point for the WB control. /// As-shot white balance, the neutral starting point for the WB control.
as_shot_wb: [f32; 3], 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. /// Whether the texture holds gamma-encoded rather than linear values.
non_linear: bool, non_linear: bool,
/// Which upload this is, unique for the life of the process. See /// Which upload this is, unique for the life of the process. See
/// [`Self::id`]. /// [`Self::id`].
id: u64, 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`]. /// The next [`DemosaicedImage::id`].
fn next_image_id() -> u64 { fn next_image_id() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -143,10 +145,50 @@ impl DemosaicedImage {
&self.view &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) { 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) (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. /// 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 /// For a cache that has to know it is still looking at the same pixels
@@ -165,15 +207,6 @@ impl DemosaicedImage {
self.color_matrix 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. /// As-shot white balance multipliers, green-normalised.
/// ///
/// The white balance control is expressed *relative* to these, so its /// The white balance control is expressed *relative* to these, so its
@@ -287,13 +320,15 @@ impl DemosaicedImage {
color_matrix: IDENTITY_3X3, color_matrix: IDENTITY_3X3,
as_shot_wb: [1.0, 1.0, 1.0], as_shot_wb: [1.0, 1.0, 1.0],
// **The identity, and this is the whole reason the field is here // **The identity, and this is the whole reason the field is here
// rather than resolved further down.** A JPEG has already had its // rather than resolved further down.** A JPEG has already been
// camera's base curve baked in by the camera; applying one again // rendered by the camera; the view transform skips a source
// would render the rendering, crushing the shadows and flattening // flagged non-linear, since rendering the rendering would crush
// the highlights of an image that was already finished. // the shadows and flatten the highlights of an image that was
base_curve: BaseCurve::IDENTITY, // already finished.
non_linear: true, non_linear: true,
id: next_image_id(), id: next_image_id(),
frame: (width, height),
window: WHOLE_FRAME,
}) })
} }
} }
@@ -304,11 +339,46 @@ impl DemosaicedImage {
/// what a merge writes. No demosaic; the samples are normalised by the /// what a merge writes. No demosaic; the samples are normalised by the
/// file's black and white levels exactly as the demosaic kernel would /// file's black and white levels exactly as the demosaic kernel would
/// normalise a photosite, and everything else — the matrix, the /// 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 /// file, because the composite is developed as one photograph from the
/// body that took its sources. /// body that took its sources.
pub fn from_linear_rgb16(ctx: &GpuContext, raw: &RawImage) -> Result<Self, GpuError> { 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)); 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(); let limits = ctx.device.limits();
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d { if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
return Err(GpuError::TooLarge(format!( return Err(GpuError::TooLarge(format!(
@@ -328,19 +398,49 @@ impl DemosaicedImage {
} }
let black = black_per_cell(raw); let black = black_per_cell(raw);
let inv = inv_range_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); // One output row per task: a 200-megapixel reduction is a second of
for y in 0..height as usize { // one core, and the rows are independent.
let row = (raw.crop.y as usize + y) * stride + raw.crop.x as usize * 3; let row_texels = width as usize * 4;
for x in 0..width as usize { let mut half = vec![0u16; row_texels * height as usize];
let p = &raw.data[row + x * 3..row + x * 3 + 3]; 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 { for c in 0..3 {
let v = (f32::from(p[c]) - black[c]) * inv[c]; acc[c] += f32::from(p[c]);
half.push(f32_to_f16_bits_unclamped(v));
}
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( let texture = ctx.device.create_texture_with_data(
&ctx.queue, &ctx.queue,
&wgpu::TextureDescriptor { &wgpu::TextureDescriptor {
@@ -361,6 +461,17 @@ impl DemosaicedImage {
bytemuck::cast_slice(&half), bytemuck::cast_slice(&half),
); );
let view = texture.create_view(&Default::default()); 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 { Ok(Self {
texture, texture,
view, view,
@@ -368,9 +479,10 @@ impl DemosaicedImage {
height, height,
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3), color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]], as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
base_curve: raw.base_curve,
non_linear: false, non_linear: false,
id: next_image_id(), id: next_image_id(),
frame,
window,
}) })
} }
} }
@@ -784,12 +896,13 @@ impl Demosaicer {
// Whatever the profile database had for this body (FR-DEV-3e), // Whatever the profile database had for this body (FR-DEV-3e),
// resolved at decode because that is the only place the make and // resolved at decode because that is the only place the make and
// model are known. // model are known.
base_curve: raw.base_curve,
// Sensor data is linear by construction — the demosaic shader // Sensor data is linear by construction — the demosaic shader
// normalises against black and white levels and applies no // normalises against black and white levels and applies no
// transfer function. // transfer function.
non_linear: false, non_linear: false,
id: next_image_id(), id: next_image_id(),
frame: (width, height),
window: WHOLE_FRAME,
}) })
} }
} }
@@ -1284,7 +1397,6 @@ mod tests {
white_level: white, white_level: white,
wb_coeffs: [1.0, 1.0, 1.0, 1.0], wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None, color_matrix: None,
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -1400,7 +1512,6 @@ mod tests {
white_level: white, white_level: white,
wb_coeffs: [1.0, 1.0, 1.0, 1.0], wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None, color_matrix: None,
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -1697,7 +1808,6 @@ mod tests {
white_level: 16383, white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0], wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None, color_matrix: None,
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
@@ -1782,7 +1892,6 @@ mod tests {
1.0, 1.0,
], ],
color_matrix: None, color_matrix: None,
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
+32 -46
View File
@@ -43,12 +43,12 @@
//! dispatch is skipped, and dragging a sharpening slider costs the detail //! dispatch is skipped, and dragging a sharpening slider costs the detail
//! passes alone (FR-DEV-3d). //! passes alone (FR-DEV-3d).
//! //!
//! The remaining passes alternate between slots 1 and 2, and the last one //! The passes alternate between slots 1 and 2, the last one included: since
//! writes the display texture directly rather than an intermediate — so a //! D19 it hands its result to the adjust pass's **view pass**, which performs
//! chain of *N* passes costs *N* dispatches and not *N* + 1, and there is no //! the view transform and the output transform after every kernel, so no
//! resolve pass to pay for. That leaves the allocation at `1 + min(N-1, 2)` //! detail pass writes the display texture. A chain of *N* passes costs *N*
//! textures: one for a single-pass operation, two for a separable blur, three //! dispatches plus that one, and the allocation is `1 + min(N, 2)` textures.
//! however long the chain gets after that. //! An empty chain costs the view pass alone, reading slot 0.
//! //!
//! # The reduced chain, and why a second one was needed //! # 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. /// intermediate against a fresh colour result and never be told.
pub(crate) struct DetailRunner { pub(crate) struct DetailRunner {
ctx: GpuContext, 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, to_linear: Layout,
/// Layout for the last pass, which writes the display texture.
to_output: Layout,
/// Compiled pipelines by pass structure hash. /// Compiled pipelines by pass structure hash.
cache: HashMap<u64, wgpu::ComputePipeline>, cache: HashMap<u64, wgpu::ComputePipeline>,
pool: Intermediates, pool: Intermediates,
@@ -255,7 +254,6 @@ impl DetailRunner {
Self { Self {
ctx: ctx.clone(), ctx: ctx.clone(),
to_linear: Layout::new(ctx, INTERMEDIATE_FORMAT, "detail-linear"), to_linear: Layout::new(ctx, INTERMEDIATE_FORMAT, "detail-linear"),
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
cache: HashMap::new(), cache: HashMap::new(),
pool: Intermediates::new(), pool: Intermediates::new(),
reduced: Intermediates::new(), reduced: Intermediates::new(),
@@ -274,27 +272,30 @@ impl DetailRunner {
width: u32, width: u32,
height: u32, height: u32,
) -> &wgpu::TextureView { ) -> &wgpu::TextureView {
// One for the colour pass's result, then one per hand-off between // One for the colour pass's result, then one per pass, capped at two
// detail passes, capped at two because a ping-pong needs no more: the // because a ping-pong needs no more. The last pass writes an
// last pass writes the display texture rather than an intermediate. // intermediate like the others since D19 — the view pass reads it —
let needed = 1 + passes.saturating_sub(1).min(2); // 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.ensure(&self.ctx, needed, width, height);
&self.pool.slots[0].view &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 /// The caller must already have run the fused colour pass into
/// [`Self::colour_target`] — or established that a previous frame's is /// [`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( pub(crate) fn encode(
&mut self, &mut self,
encoder: &mut wgpu::CommandEncoder, encoder: &mut wgpu::CommandEncoder,
chain: &ComposedDetail, chain: &ComposedDetail,
output: &wgpu::TextureView,
width: u32, width: u32,
height: u32, height: u32,
) -> Result<usize, GpuError> { ) -> Result<(usize, wgpu::TextureView), GpuError> {
for pass in &chain.passes { for pass in &chain.passes {
self.compile(pass)?; self.compile(pass)?;
} }
@@ -340,17 +341,15 @@ impl DetailRunner {
for pass in chain.passes.iter() { for pass in chain.passes.iter() {
let scaled = pass.output_scale > 1; let scaled = pass.output_scale > 1;
// The last pass carries the output transform into the display // The last pass hands the view pass its input, which is read at
// texture, which is the render size by definition. A scaled pass // the render size by definition. A scaled pass there would leave
// there would bind a shader dispatching over a quarter-size grid // the result in the reduced chain and the view pass would read the
// to a full-size target and write a quarter of the picture — a // full-size slot before it — a wrong image rather than a
// wrong image rather than a validation failure, so it is caught // validation failure, so it is caught here and named.
// here and named. if scaled && std::ptr::eq(pass, chain.passes.last().expect("iterating")) {
if scaled && pass.writes_output {
return Err(GpuError::ShaderCompilation(format!( return Err(GpuError::ShaderCompilation(format!(
"detail pass {} declares output_scale {} and is last in \ "detail pass {} declares output_scale {} and is last in \
the chain; the output transform is written at the render \ the chain; the view pass reads the render size",
size",
pass.label, pass.output_scale pass.label, pass.output_scale
))); )));
} }
@@ -365,17 +364,14 @@ impl DetailRunner {
}; };
// Read what the previous pass in *this pass's own chain* wrote; // 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 // write the next slot of it. Alternating slots is what stops a pass reading the
// last pass. Alternating slots is what stops a pass reading the
// texture it is writing — on a compute pass that is not an error // texture it is writing — on a compute pass that is not an error
// the driver reports, merely a picture that depends on scheduling. // the driver reports, merely a picture that depends on scheduling.
let source = match (scaled, carried) { let source = match (scaled, carried) {
(true, Some(slot)) => &self.reduced.slots[slot].view, (true, Some(slot)) => &self.reduced.slots[slot].view,
_ => &self.pool.slots[full].view, _ => &self.pool.slots[full].view,
}; };
let destination = if pass.writes_output { let destination = if scaled {
output
} else if scaled {
&self.reduced.slots[reduced_writes % 2].view &self.reduced.slots[reduced_writes % 2].view
} else { } else {
&self.pool.slots[1 + (full_writes % 2)].view &self.pool.slots[1 + (full_writes % 2)].view
@@ -387,11 +383,7 @@ impl DetailRunner {
Some(slot) => &self.reduced.slots[slot].view, Some(slot) => &self.reduced.slots[slot].view,
None => &self.no_reduced, None => &self.no_reduced,
}; };
let layout = if pass.writes_output { let layout = &self.to_linear;
&self.to_output
} else {
&self.to_linear
};
let params = self let params = self
.ctx .ctx
@@ -464,9 +456,7 @@ impl DetailRunner {
compute.dispatch_workgroups(dispatch_w.div_ceil(8), dispatch_h.div_ceil(8), 1); compute.dispatch_workgroups(dispatch_w.div_ceil(8), dispatch_h.div_ceil(8), 1);
drop(compute); drop(compute);
if pass.writes_output { if scaled {
// Nothing downstream to hand anything to.
} else if scaled {
carried = Some(reduced_writes % 2); carried = Some(reduced_writes % 2);
reduced_writes += 1; reduced_writes += 1;
} else { } 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. /// 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()), source: wgpu::ShaderSource::Wgsl(pass.source.as_str().into()),
}); });
let layout = if pass.writes_output { let layout = &self.to_linear;
&self.to_output
} else {
&self.to_linear
};
let pipeline = self let pipeline = self
.ctx .ctx
+1 -1
View File
@@ -928,7 +928,7 @@ impl MaskPass {
// the only readers and they are skipped in that case. // the only readers and they are skipped in that case.
let source_step = match source { let source_step = match source {
Some(image) => { Some(image) => {
let (sw, sh) = image.size(); let (sw, sh) = image.texture_size();
[ [
sw as f32 / width.max(1) as f32, sw as f32 / width.max(1) as f32,
sh as f32 / height.max(1) as f32, sh as f32 / height.max(1) as f32,
+13 -2
View File
@@ -73,6 +73,10 @@ pub struct MergeOutput {
pub chunk: (u32, u32), pub chunk: (u32, u32),
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale. /// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
pub sample_scale: f32, 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 { impl MergeOutput {
@@ -109,7 +113,8 @@ struct WarpParams {
tile_origin: [f32; 2], tile_origin: [f32; 2],
tile_size: [u32; 2], tile_size: [u32; 2],
feather: f32, feather: f32,
_pad: f32, clip_onset: f32,
balance: [f32; 4],
} }
#[repr(C)] #[repr(C)]
@@ -334,7 +339,13 @@ impl MergePass {
tile_origin: [rect.0 as f32, rect.1 as f32], tile_origin: [rect.0 as f32, rect.1 as f32],
tile_size: [rect.2, rect.3], tile_size: [rect.2, rect.3],
feather: output.feather, 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,
],
}; };
self.accumulate(&params, tile); self.accumulate(&params, tile);
} }
+2 -2
View File
@@ -28,8 +28,8 @@
//! than leaving the specification and the code silently disagreeing. //! than leaving the specification and the code silently disagreeing.
//! //!
//! That texture is the right one on the merits. It is camera-native: no white //! 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, //! balance has been applied, no camera matrix, no tone curve, no view
//! no output transform. It is normalised by the sensor's own black and white //! 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 //! 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 //! *is* the headroom question, with no calibration to carry and no origin to
//! choose. //! choose.
+1 -1
View File
@@ -208,7 +208,7 @@ impl SegmentPass {
source: &DemosaicedImage, source: &DemosaicedImage,
opts: SegmentOptions, opts: SegmentOptions,
) -> Result<Segmentation, GpuError> { ) -> 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 (width, height) = proxy_size(src_w, src_h, opts.max_edge);
let n = (width * height) as u64; let n = (width * height) as u64;
+17 -2
View File
@@ -46,7 +46,10 @@ struct Params {
tile_size: vec2<u32>, tile_size: vec2<u32>,
// Pixels over which the weight ramps from the edge to full. // Pixels over which the weight ramps from the edge to full.
feather: f32, 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>,
}; };
@group(0) @binding(0) var<uniform> p: Params; @group(0) @binding(0) var<uniform> p: Params;
@@ -125,7 +128,19 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
return; return;
} }
// Colour is the alpha-weighted mean of the texels that exist. // 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 wa = w * s.a;
let i = gid.y * p.chunk_size.x + gid.x; let i = gid.y * p.chunk_size.x + gid.x;
acc[i] = acc[i] + vec4<f32>(rgb * wa, wa); 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 // 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 // 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 // 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 // exactly one place — the axis — and everything else here is deliberately the
// same construction, because the two reductions have the same shape and any // 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> { fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out)); let scale = graph.render_scale(source.size(), (out, out));
let detail = let detail = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key) pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render"); .expect("render");
@@ -413,7 +412,7 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
let pipelines = pass.cached_detail_pipelines(); let pipelines = pass.cached_detail_pipelines();
let allocations = pass.detail_allocations(); let allocations = pass.detail_allocations();
assert_eq!(pipelines, 2, "one per axis of the separable mask"); 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.detail_dispatches(), 2);
assert_eq!(pass.colour_dispatches(), 1); assert_eq!(pass.colour_dispatches(), 1);
@@ -453,13 +452,12 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
#[test] #[test]
fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() { 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 // Proved on a device rather than argued about. With the radius finer than
// device rather than argued about. With the radius finer than a render // a render pixel the operation declines to sharpen — but it is still
// pixel the operation declines to sharpen — but it is still active, so the // active, so the fused pass has already been composed to hand on
// fused pass has already been composed to hand on unclipped linear values, // unclipped linear values, and something must still perform the output
// and something must still perform the output transform. An empty chain // transform. Since D19 that is the view pass, whatever the chain holds:
// here would not be a soft preview: it would be a hard error out of // the chain is empty and the frame is still whole.
// `render_detailed`, on the most ordinary develop view there is.
let Some(ctx) = ctx() else { return }; let Some(ctx) = ctx() else { return };
const SOURCE: u32 = 128; const SOURCE: u32 = 128;
const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame 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 mut pass = AdjustPass::new(&ctx);
let sharp = render(&mut pass, &graph, &source, RENDER); 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 // And what reaches the screen is the unsharpened picture, not a black
// frame, a linear one, or a guess. // frame, a linear one, or a guess.
+22 -10
View File
@@ -38,15 +38,17 @@ fn grey(ctx: &GpuContext) -> DemosaicedImage {
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload") DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
} }
/// A pass that sums the instance list into the red channel and writes the /// A pass that sums the instance list into the red channel and writes a
/// output. Deliberately trivial: the value on screen is then a direct readout /// linear intermediate, which the view pass then encodes (D19). Deliberately
/// of what arrived in the buffer. /// 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 { fn summing_pass(storage: Vec<[f32; 4]>, structure: u64) -> ComposedDetailPass {
let source = " let source = "
@group(0) @binding(0) var source: texture_2d<f32>; @group(0) @binding(0) var source: texture_2d<f32>;
struct Params { detail_base: vec4<f32> } struct Params { detail_base: vec4<f32> }
@group(0) @binding(1) var<uniform> u: Params; @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>>; @group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
@compute @workgroup_size(8, 8, 1) @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) { for (var i = 0u; i < n; i = i + 1u) {
total = total + instances[i].x * f32(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(); .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], uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0],
storage, storage,
radius: 0, radius: 0,
writes_output: true,
// Any distinct number: the hash is a cache key, and these tests are // Any distinct number: the hash is a cache key, and these tests are
// what decide whether two chains share a pipeline. // what decide whether two chains share a pipeline.
structure_hash: structure, 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> { 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 // 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 // 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 pixels = render(&mut pass, &source, &chain);
let (red, green) = (pixels[0], pixels[1]); 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!( 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" "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 /// 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); let pixels = render(&mut pass, &source, &chain);
assert_eq!(pixels[0], 0, "the placeholder is zeroed"); 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: /// 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 _ = ctx;
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out)); let scale = graph.render_scale(source.size(), (out, out));
let detail = let detail = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key) pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render"); .expect("render");
@@ -301,7 +300,7 @@ fn dragging_a_slider_recompiles_nothing_and_reallocates_nothing() {
let pipelines = pass.cached_detail_pipelines(); let pipelines = pass.cached_detail_pipelines();
let allocations = pass.detail_allocations(); let allocations = pass.detail_allocations();
assert_eq!(pipelines, 2, "one per pass of the separable blur"); 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] { for radius in [0.06, 0.07, 0.08, 0.09] {
graph.set_param(PROBE, RADIUS, radius); 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 shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE)); let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
let detail = let detail = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
assert!(detail.is_empty()); assert!(detail.is_empty());
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0) pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
+1 -2
View File
@@ -15,7 +15,7 @@
//! model is checked against the reference, and the shader is checked against //! model is checked against the reference, and the shader is checked against
//! the CPU model. //! the CPU model.
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage}; use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_film::bake::{bake, Recipe, Settings}; use dr_film::bake::{bake, Recipe, Settings};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext, LabelField, MaskPass}; use dr_gpu::{AdjustPass, Demosaicer, GpuContext, LabelField, MaskPass};
use dr_pipeline::mask::{MaskLayer, MaskSource}; use dr_pipeline::mask::{MaskLayer, MaskSource};
@@ -48,7 +48,6 @@ fn flat_raw(level: u16) -> RawImage {
// Off deliberately: a film replaces the camera's rendering, and // Off deliberately: a film replaces the camera's rendering, and
// leaving a curve here would test the suppression rather than the // leaving a curve here would test the suppression rather than the
// film. `dr-pipeline` asserts the suppression on the generated source. // film. `dr-pipeline` asserts the suppression on the generated source.
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
+1 -2
View File
@@ -6,7 +6,7 @@
//! anything: the repair happens on the mosaic, and what a photographer would //! 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. //! see of a defect it missed is the coloured cross the demosaic makes of it.
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage}; use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext}; use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::EditGraph; use dr_pipeline::EditGraph;
@@ -32,7 +32,6 @@ fn frame(pattern: CfaPattern, level: u16, set: &[(u32, u32, u16)]) -> RawImage {
white_level: WHITE, white_level: WHITE,
wb_coeffs: [1.0, 1.0, 1.0, 1.0], 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]), 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, samples_per_pixel: 1,
profile: None, profile: None,
make: String::new(), make: String::new(),
+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> { fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out)); let scale = graph.render_scale(source.size(), (out, out));
let detail = let detail = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key) pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render"); .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 // `render_masked`, and was rejected for handing a linear-working shader
// to the plain path — so texture alone on a thumbnail did not render. // 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 // The seam was closed at the composition boundary, and closed again,
// composition boundary: `compose_detail` now emits a bodyless // more simply, by D19: no detail pass encodes any more, the fused pass's
// `detail/resolve` pass in exactly this case, which reads only the pixel // view pass performs the output transform whatever the chain holds, and
// it writes and performs the output transform the fused pass declined to // so the empty chain is a whole render. That is the honest description of
// do. So the chain is no longer empty — it carries precisely the one pass // "a two-pixel surface structure is not present in a 128-pixel
// that finishes the render and no kernel at all, which is the honest // rendering".
// 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 scale = graph.render_scale(source.size(), (128, 128));
let composed = let composed = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb); assert!(
assert_eq!( composed.is_empty(),
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,
"texture claimed a kernel it cannot draw" "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 // 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 // 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, scale: RenderScale,
) -> Vec<u8> { ) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let detail = let detail = graph.compose_detail(scale.full_size(), scale.render_size());
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key) pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
.expect("render"); .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 shader = graph.compose_for(ColourSpace::Srgb);
let (w, h) = graph.output_size(source.size().0, source.size().1); let (w, h) = graph.output_size(source.size().0, source.size().1);
let (w, h) = (w.min(out), h.min(out)); 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); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, w, h, None, &detail, key) pass.render_detailed(source, &shader, w, h, None, &detail, key)
.expect("render"); .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. //! best-connected frame; rotations chained along it.
//! 5. Bundle adjustment over every link's inliers (`bundle`). //! 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 //! 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 //! 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 //! 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 /// 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 /// 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 /// 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. /// is the panorama assumption and not checked — the caller has the EXIF.
pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, PanoError> { 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(); let n = frames.len();
if n < 2 { if n < 2 {
return Err(PanoError::Input( return Err(PanoError::Input(
@@ -156,7 +193,7 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
// 1 + 2: every pair. // 1 + 2: every pair.
let mut links = Vec::new(); let mut links = Vec::new();
let mut observations: Vec<Observation> = 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(); let t_match = std::time::Instant::now();
for i in 0..n { for i in 0..n {
for j in i + 1..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 { if matches.len() < 4 {
continue; continue;
} }
matched_any[i] = true; matched.push((i, j));
matched_any[j] = true;
let pairs: Vec<((f64, f64), (f64, f64))> = matches let pairs: Vec<((f64, f64), (f64, f64))> = matches
.iter() .iter()
.map(|m| { .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()); 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. // 3: the focal length.
let mut estimates: Vec<f64> = links 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_map(|l| homography::focal_from_homography(&l.h))
.filter(|f| f.is_finite() && *f > 0.0) .filter(|f| f.is_finite() && *f > 0.0)
.collect(); .collect();
let longest = frames let longest = pairs
.long_edges
.iter() .iter()
.map(|f| f.width.max(f.height) as f64) .zip(keep)
.filter(|(_, &kept)| kept)
.map(|(&e, _)| e)
.fold(0.0, f64::max); .fold(0.0, f64::max);
let focal = if !estimates.is_empty() { let focal = if !estimates.is_empty() {
estimates.sort_by(f64::total_cmp); estimates.sort_by(f64::total_cmp);
@@ -448,6 +550,40 @@ mod tests {
assert!(out.rotations[..3].iter().all(Option::is_some)); 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] #[test]
fn one_frame_is_refused() { fn one_frame_is_refused() {
let (frames, _) = synthetic_sweep(1, 0.3, 1400.0, 640, 480); let (frames, _) = synthetic_sweep(1, 0.3, 1400.0, 640, 480);
+1 -1
View File
@@ -46,7 +46,7 @@ pub mod projection;
#[cfg(feature = "xfeat")] #[cfg(feature = "xfeat")]
pub mod 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 bundle::Cameras;
pub use features::{Features, Keypoint}; pub use features::{Features, Keypoint};
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams}; pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
+10
View File
@@ -405,6 +405,7 @@ fn emit_node(out: &mut String, node: &Declaration) {
active, active,
tests, tests,
presentation, presentation,
camera_stage,
.. ..
} = node; } = node;
@@ -569,6 +570,15 @@ fn emit_node(out: &mut String, node: &Declaration) {
" fn is_active(&self) -> bool {{\n {active_expr}\n }}\n" " 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!( let _ = writeln!(
out, out,
" fn wgsl_body(&self) -> String {{\n {}.into()\n }}\n", " 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 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 twelve computed hue bands), `film_sim` (a stock's measured tables, which are
not parameters, and the one node that declares `Operation::renders` — see not parameters, and the one node that declares `Operation::renders` — see
below), `capture_sharpen` (a separable convolution) and `noise_reduction` (a below), `view_transform` (composed at its defaults, which a declaration cannot
kernel, and one that decides how many dispatches to emit at each resolution) — say — see [What is not a node](#what-is-not-a-node-and-why)), and the five
the last two for the reason the next section gives. `vignetting` is 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 hand-written too but is not in the develop chain — it carries lens-profile
coefficients that are not parameters. 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. `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 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 characteristic curve does the view transform's job, from measurements rather
measurements rather than from a curve somebody drew. Running both renders the than from a curve somebody chose. Running both renders the scene twice — the
scene twice — the camera's rendering, and then a film's rendering of *that* — default rendering, and then a film's rendering of *that* — which looks like
which looks like neither and reads as a colour-management bug with no neither and reads as a colour-management bug with no colour-management bug to
colour-management bug to find. find.
So a node declaring `renders` takes camera RGB and hands back linear sRGB, and So `film_sim` is in `Stage::View` beside `view_transform`, and while a stock
in exchange the composer emits neither the base curve nor the conversion out of is loaded the composer emits it in the view transform's place, last, after the
camera space. Both halves move to the node, together: the base curve is defined detail stage, and not the sigmoid (D19). It is handed working-space colour and
in camera RGB and the matrix is what leaves it, so a node replacing one has hands back display-referred linear sRGB for the output transform. `distortion` and
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
`aberration` are `Warp`s rather than operations: they rewrite coordinates `aberration` are `Warp`s rather than operations: they rewrite coordinates
before sampling rather than transforming a colour after it. 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. of `c` at any price.
They go in the **detail stage**, which runs after the fused pass, in linear 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 [`../src/detail.rs`](../src/detail.rs) for why each of those is a decision
rather than a convenience. A node of this kind: 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 ## What is not a node, and why
Three things act on every pixel and are deliberately not in this directory: Two things act on every pixel and are deliberately not in this directory: the
the as-shot white balance, the camera matrix, and the **base curve** as-shot white balance and the camera matrix. They are emitted by
(FR-DEV-3e). They are emitted by [`../src/operation.rs`](../src/operation.rs) [`../src/operation.rs`](../src/operation.rs) into the composed shader around the
into the composed shader's fixed preamble, around the block of nodes. 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 The **view transform** (FR-DEV-3j) *is* a node — `view_transform.yaml`, a
decision is it**. A node is something a photographer chose: it has parameters, `rust:` one — and that is a change of mind worth knowing about. It replaced the
it moves off a neutral, it lands in the sidecar, it can be undone. These three per-body base curve, which was kept out of this directory because it belonged
are properties of the *file*, at the same standing as the masked-photosite crop to the camera: as a node it would have carried one body's rendering onto
(FR-RAW-3) and the stored orientation (FR-DEV-3h). Nobody chose the sensor's another body's file through a shared sidecar. D19 retired the per-body curves,
green sensitivity or the body's rendering; they are what reading the file and with them the argument. One view transform serves every body, so its
correctly means. 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 ## Stages
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.
What it *does* share with the tone curve node is the spline. The composer asks `stage: camera` puts a node in camera RGB, ahead of the camera matrix; the
`ToneCurve` for its `curve_span`/`curve_eval` helpers rather than emitting a default, `stage: scene`, hands it working-space colour — linear sRGB
second copy, so a profile author placing a control point and a photographer primaries, scene-referred and unbounded. White balance is the only camera
dragging one mean the same thing by it. 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
The order still reads correctly from this directory: the base curve runs after whichever body took the frame (D19). The composer emits the camera nodes, then
every node in the chain and before the conversion out of camera space. That is the matrix, then the scene nodes, each group in `order:`, and the view
the same reasoning `exposure` records under `placement:` — corrections to transform last. `stage: view` is not offered to a declaration: a node that
capture are only meaningful on linear values, so the rendering goes last. 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 ## Errors
+2 -1
View File
@@ -64,7 +64,8 @@ wgsl: |
// to grey and its noise stays the size it was. // to grey and its noise stays the size it was.
// //
// The grey is (1, 1, 1) scaled, because this runs after white balance // The grey is (1, 1, 1) scaled, because this runs after white balance
// in the camera's space, where that is what neutral is. // and the camera matrix, which carries a balanced neutral to equal
// channels.
c = mix(c, vec3<f32>(0.18), -amount); c = mix(c, vec3<f32>(0.18), -amount);
} else { } else {
let luma = luminance(c); let luma = luminance(c);
+12 -10
View File
@@ -1,5 +1,5 @@
id: film_sim id: film_sim
order: 25 order: 190
# What this node is *about* is not written here, and cannot be: a `rust:` node # 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, # publishes its own descriptor, so `attributes:` in this file would be read,
# validated and then ignored. See `Attribute::Effect` on `FilmSim`'s descriptor # 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 characteristic curves and a density lookup — which are not parameters and
which no `uniforms:` expression could produce. Its neutral is "no stock 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 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 `Operation::renders`, so while a stock is loaded the composer emits it in
and the conversion out of camera space on its behalf. the view transform's place instead of the default sigmoid.
placement: | 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 Before D19 it sat at order 25, after white balance and exposure, and every
the amount of light that reached it — and they are only meaningful on decision below it acted on the film's output, as though the frame had been
scene-linear values, which is what a film has to be handed. Everything below scanned and then worked on. That put a display-referred rendering in the
is a decision about the picture, and a decision about the picture belongs middle of the chain, which is what D19 removes: every operation is now handed
after the film has rendered it, exactly as it does when you scan a frame and the scene, and the film is the last thing that happens to the picture — an
then work on the scan. 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 First. It is a correction to how the scene was captured, and every tonal
operation after it should act on a correctly balanced image. 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: params:
temperature: temperature:
label: param.temperature label: param.temperature
+6 -4
View File
@@ -49,7 +49,9 @@ pub struct Section {
/// A stable identifier, for a frontend that remembers which sections a /// A stable identifier, for a frontend that remembers which sections a
/// photographer folded away. Never shown. /// photographer folded away. Never shown.
pub id: &'static str, 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, pub title: &'static str,
/// The presets in it, every one reaching only what it names. /// The presets in it, every one reaching only what it names.
pub presets: PresetLibrary, pub presets: PresetLibrary,
@@ -65,17 +67,17 @@ const SECTIONS: &[(&str, &str, &str)] = &[
("skies", "Skies", include_str!("../presets/skies.drpl")), ("skies", "Skies", include_str!("../presets/skies.drpl")),
( (
"colour_film", "colour_film",
"Colour film", "Film/Colour",
include_str!("../presets/colour_film.drpl"), include_str!("../presets/colour_film.drpl"),
), ),
( (
"cinema_film", "cinema_film",
"Cinema film", "Film/Cinema",
include_str!("../presets/cinema_film.drpl"), include_str!("../presets/cinema_film.drpl"),
), ),
( (
"bw_film", "bw_film",
"Black and white film", "Film/Black and white",
include_str!("../presets/bw_film.drpl"), 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 /// Boxed so the rare node that declares one does not widen every
/// declaration by the size of a presentation it does not have. /// declaration by the size of a presentation it does not have.
pub presentation: Option<Box<PresentationDef>>, 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 { impl Declaration {
@@ -512,6 +538,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
"define", "define",
"label", "label",
"attributes", "attributes",
"stage",
] { ] {
if root.contains_key(key) { if root.contains_key(key) {
return Err(format!( return Err(format!(
@@ -564,6 +591,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
.collect(); .collect();
let tests = read_tests(root, &params, &uniform_names, &helper_names)?; let tests = read_tests(root, &params, &uniform_names, &helper_names)?;
let presentation = read_presentation(root, &param_names)?; let presentation = read_presentation(root, &param_names)?;
let camera_stage = read_stage(root)?;
Ok(Node::Declared(Box::new(Declaration { Ok(Node::Declared(Box::new(Declaration {
id, id,
@@ -580,6 +608,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
active, active,
tests, tests,
presentation, presentation,
camera_stage,
}))) })))
} }
+11
View File
@@ -107,6 +107,8 @@ pub struct DeclaredOp {
helpers: Vec<Helper>, helpers: Vec<Helper>,
presentation: Option<Presentation>, presentation: Option<Presentation>,
order: i64, order: i64,
/// See `decl::read_stage`.
camera_stage: bool,
} }
/// One uniform: the name the fragment reads it by, and how to compute it. /// One uniform: the name the fragment reads it by, and how to compute it.
@@ -208,6 +210,7 @@ impl DeclaredOp {
wgsl: declaration.wgsl_body(), wgsl: declaration.wgsl_body(),
helpers, helpers,
presentation: declaration.presentation.as_deref().map(presentation), presentation: declaration.presentation.as_deref().map(presentation),
camera_stage: declaration.camera_stage,
order: declaration.order, order: declaration.order,
}) })
} }
@@ -292,6 +295,14 @@ impl Operation for DeclaredOp {
fn presentation(&self) -> Option<Presentation> { fn presentation(&self) -> Option<Presentation> {
self.presentation.clone() 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. /// 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 /// `Effect` after `Colour` is a look laid over a settled picture — and is
/// the one arguable slot. A spectral film simulation declares /// the one arguable slot. A spectral film simulation declares
/// [`crate::Operation::renders`] and replaces the base curve, which is an /// [`crate::Operation::renders`] and takes the view transform's place at
/// argument for treating it as foundational rather than final; an array of /// the very end of the chain (D19), which is an argument for treating it as
/// six cannot say "last, except when it is first". The tension is recorded /// the rendering rather than one effect among others; an array of six
/// here rather than settled. /// 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 /// 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 /// nobody reads in order. It stopped being harmless when the same list
+192 -252
View File
@@ -24,9 +24,10 @@
//! v //! v
//! +------------------------------------------+ //! +------------------------------------------+
//! | the fused point-operation pass | one dispatch //! | the fused point-operation pass | one dispatch
//! | white balance, exposure, tone, colour | //! | white balance (camera RGB) |
//! | the mask layers |
//! | camera RGB -> linear sRGB | //! | camera RGB -> linear sRGB |
//! | exposure, tone, colour |
//! | the mask layers |
//! +------------------------------------------+ //! +------------------------------------------+
//! | rgba16float, linear, **unclipped**, at render resolution //! | rgba16float, linear, **unclipped**, at render resolution
//! v //! v
@@ -34,7 +35,13 @@
//! | the detail stage - this module | one dispatch per pass //! | the detail stage - this module | one dispatch per pass
//! | sharpen, NR, clarity, texture, spots | //! | 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 //! v
//! rgba8unorm display or export texture //! rgba8unorm display or export texture
//! ``` //! ```
@@ -52,14 +59,13 @@
//! texture, clarity, spot removal and sharpen/NR sit below the tone curve and //! texture, clarity, spot removal and sharpen/NR sit below the tone curve and
//! the colour mixer. //! the colour mixer.
//! //!
//! **In linear light, after the camera matrix.** The fused pass works in //! **In linear light, after the camera matrix.** A detail pass wants a
//! *camera* space, because white balance and exposure are physically //! luminance, and camera RGB has no luminance — the three channels are
//! 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
//! whatever the CFA's dyes passed, and weighting them 0.2126/0.7152/0.0722 //! 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` //! would be numerology. Since D19 only white balance runs in camera RGB; the
//! multiply, where the working space is linear sRGB and a luminance is a //! `cam_to_srgb` multiply follows it, so every point operation, and every
//! luminance. //! 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 //! **Before the output transform, and before the clip.** FR-DEV-2 allows
//! exactly one quantisation, at the display or export stage. A detail pass //! 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 //! therefore `rgba16float` and holds linear values that have **not** been
//! clamped to `0..=1`: a recovered highlight is still above one at this point, //! 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 //! 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 //! where the sharpener is most visible. Every detail pass writes such an
//! primaries conversion, the clip and the encode, so the single quantisation //! intermediate, the last one included, and the view pass after them — the
//! stays single. //! 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 //! **After framing, at render resolution.** The alternative — running detail
//! on the demosaiced source before the framing prologue — is superficially //! on the demosaiced source before the framing prologue — is superficially
@@ -122,8 +130,6 @@
use std::fmt::Write as _; use std::fmt::Write as _;
use dr_types::ColourSpace;
use crate::operation::{Helper, Operation, Uniform}; use crate::operation::{Helper, Operation, Uniform};
/// Floats the generated detail uniform block always carries, before an /// 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 { pub struct RenderScale {
render: (u32, u32), render: (u32, u32),
full: (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 { impl RenderScale {
@@ -210,9 +220,27 @@ impl RenderScale {
/// [`crate::EditGraph::render_scale`] works both out from the framing, and /// [`crate::EditGraph::render_scale`] works both out from the framing, and
/// is what a caller should normally use. /// is what a caller should normally use.
pub fn new(render: (u32, u32), full: (u32, u32)) -> Self { pub fn new(render: (u32, u32), full: (u32, u32)) -> Self {
let full = (full.0.max(1), full.1.max(1));
Self { Self {
render: (render.0.max(1), render.1.max(1)), 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 /// For the compositional family — clarity, texture, dehaze — and the same
/// unit `dr-gpu`'s mask rasteriser already converts feathers in. An edit /// unit `dr-gpu`'s mask rasteriser already converts feathers in. An edit
/// stored this way is resolution-independent by construction. /// 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 { 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. /// Whether a radius stated in source pixels survives this render.
@@ -491,15 +530,6 @@ pub struct ComposedDetailPass {
pub radius: u32, pub radius: u32,
/// See [`DetailPass::output_scale`]. /// See [`DetailPass::output_scale`].
pub output_scale: u32, 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 /// Identifies this pass's *structure*, for the pipeline cache. Covers the
/// generated source, not the uniform values — so moving a slider uploads a /// generated source, not the uniform values — so moving a slider uploads a
/// buffer and reuses the compiled pipeline, exactly as the fused pass does. /// buffer and reuses the compiled pipeline, exactly as the fused pass does.
@@ -537,6 +567,26 @@ impl ComposedDetail {
.max() .max()
.unwrap_or(0) .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 /// 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 /// an edit with no sharpening produces an empty chain and `dr-gpu` runs the
/// single dispatch it always did. /// single dispatch it always did.
/// ///
/// `output` is the space the **last** pass encodes into, and it is a parameter /// No pass encodes. Every pass writes a linear intermediate, the last one
/// for the same reason it is a parameter to [`crate::compose_with_framing`]: a /// included, and the fused pass's view pass ([`crate::ComposedShader::view`])
/// screen render and a Display P3 export are the same edit and different /// reads the last and performs the view transform and the output transform
/// shaders, and neither is more authoritative than the other. /// (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 /// # The generated uniform block
/// ///
@@ -562,12 +613,8 @@ impl ComposedDetail {
/// is there because a two-pass operation emitting one body for both directions /// 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 /// is a reasonable thing to want, and would otherwise need a uniform of its
/// own purely to say which half it is in. /// own purely to say which half it is in.
pub fn compose_detail( pub fn compose_detail(ops: &[Box<dyn Operation>], scale: RenderScale) -> ComposedDetail {
ops: &[Box<dyn Operation>], compose_detail_with(ops, &[], scale)
scale: RenderScale,
output: ColourSpace,
) -> ComposedDetail {
compose_detail_with(ops, &[], scale, output)
} }
/// TRACES: FR-DEV-8 /// TRACES: FR-DEV-8
@@ -592,7 +639,6 @@ pub fn compose_detail_with(
ops: &[Box<dyn Operation>], ops: &[Box<dyn Operation>],
spots: &[DetailPass], spots: &[DetailPass],
scale: RenderScale, scale: RenderScale,
output: ColourSpace,
) -> ComposedDetail { ) -> ComposedDetail {
// Every pass of every active detail operation, flattened, carrying the // Every pass of every active detail operation, flattened, carrying the
// operation it came from for the uniform prefix and the helper set. // 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. // An active detail operation may emit nothing at this scale — an
// // acutance operation on a heavy proxy, whose one-source-pixel radius is a
// Legal, and the honest answer for an acutance operation on a heavy proxy // third of a render pixel (see [`RenderScale`]). The chain is then empty
// — a one-source-pixel radius is a third of a render pixel there and no // while the fused pass has stopped at linear working values, and that is
// kernel represents a third of a pixel (see [`RenderScale`]). But it opens // fine: the fused pass's view pass reads the fused result directly and
// a hole between the two halves of the composition: [`compose_full`] // performs the output transform. Before D19 the last detail pass encoded,
// decides to hand on linear working values from the *operations*, which it // and this case needed a body-less resolve pass to do 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,
)],
};
}
// TRACES: NFR-P5 // TRACES: NFR-P5
// A pass whose body is empty changes nothing but where the pixels are: it // 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. // 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 // 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 // - **Not after a reduced pass.** A full-resolution pass ends the reduced
// chain (see `DetailRunner::encode`), so one that follows a scaled pass // 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 // 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 // Everywhere else the pass before and the pass after exchange the same
// `rgba16float` texels either way, `aux` included. // `rgba16float` texels either way, `aux` included.
let mut kept: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::with_capacity(planned.len()); let mut kept: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::with_capacity(planned.len());
let total = planned.len(); for entry in planned {
for (position, entry) in planned.into_iter().enumerate() {
let after_full = kept.last().is_none_or(|(_, _, p, _)| p.output_scale <= 1); 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 { if !droppable {
kept.push(entry); kept.push(entry);
} }
} }
let planned = kept; let planned = kept;
let last = planned.len().saturating_sub(1);
let passes = planned let passes = planned
.into_iter() .into_iter()
.enumerate() .map(|(id, helpers, pass, index)| compose_one(id, helpers, &pass, index, scale))
.map(|(position, (id, helpers, pass, index))| {
compose_one(id, helpers, &pass, index, scale, output, position == last)
})
.collect(); .collect();
ComposedDetail { passes } 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( fn compose_one(
id: &str, id: &str,
helpers: &[Helper], helpers: &[Helper],
pass: &DetailPass, pass: &DetailPass,
index: usize, index: usize,
scale: RenderScale, scale: RenderScale,
output: ColourSpace,
writes_output: bool,
) -> ComposedDetailPass { ) -> ComposedDetailPass {
let prefix = format!("{}_{index}", crate::operation::sanitise(id)); let prefix = format!("{}_{index}", crate::operation::sanitise(id));
@@ -772,41 +768,13 @@ fn compose_one(
let _ = writeln!(helper_src, "{}\n", h.source.trim_end()); let _ = writeln!(helper_src, "{}\n", h.source.trim_end());
} }
// The storage format and the tail are the *only* difference between an // Every pass writes another linear intermediate: no clip and no encode,
// intermediate pass and the final one. Everything above — the taps, the // because the view pass after the last one still has to read real values
// uniforms, the body — is identical, which is what lets an operation write // (D19). `aux` rides in alpha. A pass that never touches it hands on
// one kernel without knowing whether it happens to be last in the chain. // whatever it was given, so the lane costs an operation that does not want
let (store_format, tail) = if writes_output { // it exactly one copy of a value it already read.
( let store_format = "rgba16float";
"rgba8unorm", let tail = " textureStore(output, coord, vec4<f32>(c, aux));";
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()
};
let label = format!("{id}/{}", pass.label); let label = format!("{id}/{}", pass.label);
let indented = body let indented = body
@@ -823,7 +791,7 @@ fn compose_one(
// way back to a coordinate. // way back to a coordinate.
// //
// In: linear sRGB, scene-referred, **unclipped**, at render resolution. // 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 {{ struct Params {{
{uniform_fields}}} {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); 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) @compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
let dims = textureDimensions(output); let dims = textureDimensions(output);
@@ -936,12 +904,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
{tail} {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); 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 // dispatch size and a declaration is data, which since FR-PLG-2 can
// come from a file this build did not write. // come from a file this build did not write.
output_scale: pass.output_scale.max(1), output_scale: pass.output_scale.max(1),
writes_output,
structure_hash, structure_hash,
} }
} }
@@ -1013,6 +975,21 @@ mod tests {
assert!((export.frame_fraction(0.01) - 40.0).abs() < 0.5); 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] #[test]
fn zooming_to_one_to_one_makes_the_preview_exact() { fn zooming_to_one_to_one_makes_the_preview_exact() {
// The reason there is no separate full-resolution preview path: the // 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 // dispatch. An unedited photograph must not pay for a sharpener it is
// not using. // not using.
let ops = with_blur(0.0); let ops = with_blur(0.0);
let composed = compose_detail( let composed = compose_detail(&ops, RenderScale::full((512, 512)));
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
);
assert!(composed.is_empty()); assert!(composed.is_empty());
assert_eq!(fused(&ops).output_mode, OutputMode::Encoded); assert_eq!(fused(&ops).output_mode, OutputMode::Encoded);
} }
#[test] #[test]
fn a_separable_blur_becomes_two_passes_and_only_the_last_encodes() { fn a_separable_blur_becomes_two_passes_and_neither_encodes() {
// The multi-pass case, which is the one the ping-pong exists for. The // The multi-pass case, which is the one the ping-pong exists for. Both
// first pass writes a linear intermediate and the second writes the // passes write linear intermediates, and the fused pass's view pass
// display texture — so the output transform happens exactly once, at // reads the second and performs the view transform and the output
// the end, wherever the end happens to be. // transform — so those happen exactly once, after every kernel (D19).
let ops = with_blur(0.05); let ops = with_blur(0.05);
let composed = compose_detail( let composed = compose_detail(&ops, RenderScale::full((512, 512)));
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
);
assert_eq!(composed.len(), 2); assert_eq!(composed.len(), 2);
let first = &composed.passes[0]; let first = &composed.passes[0];
@@ -1123,13 +1092,16 @@ mod tests {
assert_eq!(first.label, "detail_probe/horizontal"); assert_eq!(first.label, "detail_probe/horizontal");
assert_eq!(last.label, "detail_probe/vertical"); assert_eq!(last.label, "detail_probe/vertical");
assert!(!first.writes_output); for pass in [first, last] {
assert!(first.source.contains("texture_storage_2d<rgba16float")); assert!(pass.source.contains("texture_storage_2d<rgba16float"));
assert!(!first.source.contains("fn encode_output")); assert!(!pass.source.contains("fn encode_output"));
assert!(!pass.source.contains("view_sigmoid"));
assert!(last.writes_output); }
assert!(last.source.contains("texture_storage_2d<rgba8unorm")); let view = fused(&ops)
assert!(last.source.contains("fn encode_output")); .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 // 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 // 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 // and the composer rewrites it to a prefixed struct field, so two
// operations may both call a uniform `radius` and neither has to know. // operations may both call a uniform `radius` and neither has to know.
let ops = with_blur(0.05); let ops = with_blur(0.05);
let composed = compose_detail( let composed = compose_detail(&ops, RenderScale::full((512, 512)));
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
);
let src = &composed.passes[0].source; let src = &composed.passes[0].source;
assert!(src.contains("detail_probe_0_radius: f32,")); assert!(src.contains("detail_probe_0_radius: f32,"));
assert!(src.contains("let r = i32(u.detail_probe_0_radius);")); 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 // outright by the WGSL uniform address space rules, and the failure
// arrives as a shader compilation error against generated source. // arrives as a shader compilation error against generated source.
let ops = with_blur(0.05); let ops = with_blur(0.05);
for pass in compose_detail( for pass in compose_detail(&ops, RenderScale::full((512, 512))).passes {
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
)
.passes
{
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label); assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
assert!(pass.uniforms.iter().all(|v| v.is_finite())); assert!(pass.uniforms.iter().all(|v| v.is_finite()));
// The base block is first and fixed, so a pass never addresses a // The base block is first and fixed, so a pass never addresses a
@@ -1189,7 +1151,7 @@ mod tests {
// the truth rather than zero. // the truth rather than zero.
let ops = with_blur(0.05); let ops = with_blur(0.05);
let scale = RenderScale::full((400, 400)); 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); let expected = BoxBlur::with_radius(0.05).kernel(scale);
assert_eq!(expected, 20, "5% of a 400px edge"); assert_eq!(expected, 20, "5% of a 400px edge");
assert_eq!(composed.radius(), expected); assert_eq!(composed.radius(), expected);
@@ -1208,7 +1170,7 @@ mod tests {
.iter() .iter()
.map(|&(w, h)| { .map(|&(w, h)| {
let scale = RenderScale::full((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 composed.radius() as f32 / w.min(h) as f32
}) })
.collect(); .collect();
@@ -1226,14 +1188,14 @@ mod tests {
// cannot see each other. `compose_full` decides to hand on linear // cannot see each other. `compose_full` decides to hand on linear
// working values from the *operations* — it has no resolution to // working values from the *operations* — it has no resolution to
// consult — while this composer converts a radius and can legitimately // consult — while this composer converts a radius and can legitimately
// decide there is nothing to draw at this size. An empty chain would // decide there is nothing to draw at this size.
// then leave the output transform undone: the fused pass writes
// `rgba16float` and the frontend binds an `rgba8unorm` target to it.
// //
// A photographer meets this by turning on capture sharpening or // A photographer meets this by turning on capture sharpening or
// luminance noise reduction while the develop view is fitted to a // 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 // large file, which is the normal way to work. Before D19 an empty
// case that can be left to fail. // 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 ops = with_blur(0.001);
let scale = RenderScale::full((400, 400)); let scale = RenderScale::full((400, 400));
assert!(ops.last().expect("the blur").is_active()); 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" "the premise: a radius too small to draw emits no pass"
); );
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb); // Empty, and that is fine since D19: nothing in the chain encodes, so
assert_eq!(composed.len(), 1, "the chain must not be empty here"); // there is no output transform for an empty chain to leave undone.
assert_eq!(composed.radius(), 0, "it reads only the pixel it writes"); // The fused pass stopped at linear values and its view pass reads
// them directly.
let resolve = &composed.passes[0]; let composed = compose_detail(&ops, scale);
assert_eq!(resolve.label, "detail/resolve"); assert!(composed.is_empty());
assert!(resolve.writes_output); let fused = fused(&ops);
assert!(resolve.source.contains("texture_storage_2d<rgba8unorm")); assert_eq!(fused.output_mode, OutputMode::LinearWorking);
assert!(resolve.source.contains("fn encode_output")); let view = fused
// Exactly the fixed base block and no more: a pass with no body has .view
// nothing of its own to upload, and the block still has to be a .expect("the view pass performs the output transform");
// multiple of sixteen bytes. assert_eq!(view.output_mode, OutputMode::Encoded);
assert_eq!(resolve.uniforms.len(), DETAIL_BASE_UNIFORM_FIELDS); assert!(view.source.contains("fn encode_output"));
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);
} }
#[test] #[test]
fn a_pass_that_changes_nothing_is_dropped_where_that_is_exact() { fn a_pass_that_changes_nothing_is_dropped_where_that_is_exact() {
// TRACES: NFR-P5 // TRACES: NFR-P5
// Capture sharpening at a scale too coarse to draw its radius emits a // A pass with an empty body costs a render-sized read and write and
// pass with an empty body. Between two other passes it costs a // changes no texel, so it goes — wherever it falls since D19, the last
// render-sized read and write and changes no texel, so it goes; as the // position included, because the last pass writes an intermediate like
// last pass it performs the output transform on the intermediate, and // every other and the view pass reads whichever the chain last wrote.
// moving that onto the pass before would round differently, so it // Built by hand, and run as a repair so it goes first: no operation
// stays. // emits one any more (capture sharpening at a scale too coarse to draw
use crate::ops::{capture_sharpen, CaptureSharpen, NoiseReduction}; // its radius used to, and now emits nothing).
let sharpen = || -> Box<dyn Operation> { use crate::ops::NoiseReduction;
let mut op = CaptureSharpen::new();
op.set_param(capture_sharpen::AMOUNT, 60.0);
Box::new(op)
};
let chroma = || -> Box<dyn Operation> { Box::new(NoiseReduction::with_amounts(0.0, 60.0)) }; 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 scale = RenderScale::new((1500, 1000), (6000, 4000));
let unresolved = sharpen().detail().expect("a detail stage").passes(scale); let nothing = DetailPass {
assert!( output_scale: 1,
unresolved.len() == 1 && unresolved[0].is_identity(), label: "nothing",
"the premise: sharpening at this scale is one pass that does nothing" radius: 0,
); wgsl: "// `c` already holds this pixel.".to_string(),
let labels = |ops: &[Box<dyn Operation>]| -> Vec<String> { uniforms: Vec::new(),
compose_detail(ops, scale, dr_types::ColourSpace::Srgb) storage: Vec::new(),
};
assert!(nothing.is_identity(), "the premise");
let labels: Vec<String> =
compose_detail_with(&[chroma()], std::slice::from_ref(&nothing), scale)
.passes .passes
.iter() .iter()
.map(|p| p.label.clone()) .map(|p| p.label.clone())
.collect() .collect();
};
// First, ahead of the chroma passes: dropped.
let first = labels(&[sharpen(), chroma()]);
assert_eq!( assert_eq!(
first, labels,
[ [
"noise_reduction/chroma-horizontal", "noise_reduction/chroma-horizontal",
"noise_reduction/chroma-vertical" "noise_reduction/chroma-vertical"
] ]
); );
// Last, after them: kept, and it is the pass that encodes. // Alone: dropped too, and the chain is empty — the view pass
let last = labels(&[chroma(), sharpen()]); // finishes the frame.
assert_eq!(last.len(), 3); assert!(compose_detail_with(&[], &[nothing], scale).is_empty());
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"]);
} }
#[test] #[test]
@@ -1325,11 +1272,7 @@ mod tests {
// A pass that says nothing about `aux` hands on what it was given, // 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. // which is why the box blur below needs no knowledge of it.
let ops = with_blur(0.05); let ops = with_blur(0.05);
let composed = compose_detail( let composed = compose_detail(&ops, RenderScale::full((512, 512)));
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
);
for pass in &composed.passes { for pass in &composed.passes {
assert!( assert!(
@@ -1344,11 +1287,12 @@ mod tests {
.contains("textureStore(output, coord, vec4<f32>(c, aux));"), .contains("textureStore(output, coord, vec4<f32>(c, aux));"),
"an intermediate must carry the lane to the pass after it" "an intermediate must carry the lane to the pass after it"
); );
// The last pass writes the display texture, whose alpha is opacity and // The last pass carries it too: since D19 it writes an intermediate
// not scratch space. Readable there, not written — which is the right // for the view pass rather than the display texture, whose alpha is
// way round, because the combining pass is the one that reads it. // opacity. The view pass reads only the colour.
assert!(composed.passes[1].writes_output); assert!(composed.passes[1]
assert!(!composed.passes[1].source.contains("vec4<f32>(c, aux)")); .source
.contains("textureStore(output, coord, vec4<f32>(c, aux));"));
} }
#[test] #[test]
@@ -1357,11 +1301,7 @@ mod tests {
// overwhelmingly common edit: no sharpening means no chain, which // overwhelmingly common edit: no sharpening means no chain, which
// means `dr-gpu` runs the single fused dispatch it always did. // means `dr-gpu` runs the single fused dispatch it always did.
let ops = crate::ops::chain(); let ops = crate::ops::chain();
let composed = compose_detail( let composed = compose_detail(&ops, RenderScale::full((64, 64)));
&ops,
RenderScale::full((64, 64)),
dr_types::ColourSpace::Srgb,
);
assert!(composed.is_empty()); assert!(composed.is_empty());
assert_eq!(composed.radius(), 0); assert_eq!(composed.radius(), 0);
} }
+4 -2
View File
@@ -1298,7 +1298,8 @@ impl Framing {
// count active stages, and a neutral graph must generate none. // count active stages, and a neutral graph must generate none.
if !self.is_active() { if !self.is_active() {
return " // Source position, normalised and centred: the whole frame, unrotated. 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 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); let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
var p = (uv - vec2<f32>(0.5)) * aspect; var p = (uv - vec2<f32>(0.5)) * aspect;
@@ -1314,7 +1315,8 @@ impl Framing {
// warp chain expects: the centre is (0, 0) and the radius is 1 at the // 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 // corner. Working here rather than in pixels is what makes the map
// independent of the resolution being rendered at. // 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); 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); var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
", ",
+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. /// An ordered pipeline of operations, plus how the result is framed.
pub struct EditGraph { pub struct EditGraph {
ops: Vec<Box<dyn Operation>>, ops: Vec<Box<dyn Operation>>,
@@ -292,6 +299,57 @@ impl EditGraph {
self.framing.output_size(width, height) 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. /// Descriptors for every operation, in order.
/// ///
/// Operations only — framing is not one, and is reached through /// 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), ((fw as f32 * view.width).round() as u32).max(1),
((fh as f32 * view.height).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 /// 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 /// Empty for every edit with no active neighbourhood operation, which is
/// almost all of them — and in that case [`Self::compose`] emits the /// almost all of them — and in that case [`Self::compose`] emits the
/// single encoded dispatch it always has. /// 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 /// No output space: since D19 no detail pass encodes. The fused pass's
/// a detail stage exists it is the *last* pass that performs the output /// view pass reads what the last one wrote and performs the view transform
/// transform — the fused pass stops at linear working values. Composing /// and the output transform, so it is [`Self::compose_for`] alone that
/// the two halves for different spaces would encode the edit twice, or /// names the space.
/// not at all. ///
/// `source` is the demosaiced image's size and `render` the size being /// `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 /// 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 /// 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 /// source coordinates and has to be put through the framing to find out
/// where it lands on this render, and a [`crate::detail::RenderScale`] /// where it lands on this render, and a [`crate::detail::RenderScale`]
/// describes the region on screen rather than the photograph. /// describes the region on screen rather than the photograph.
pub fn compose_detail_for( pub fn compose_detail(
&self, &self,
source: (u32, u32), source: (u32, u32),
render: (u32, u32), render: (u32, u32),
output: dr_types::ColourSpace,
) -> crate::detail::ComposedDetail { ) -> crate::detail::ComposedDetail {
let scale = self.render_scale(source, render); let scale = self.render_scale(source, render);
let spots = self.spots.passes(&self.framing, source, scale); 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 /// TRACES: FR-DEV-3d
@@ -1122,13 +1169,21 @@ mod tests {
fn a_fresh_graph_is_neutral() { fn a_fresh_graph_is_neutral() {
// Opening an unedited image must produce the image, not an // Opening an unedited image must produce the image, not an
// interpretation of it. // 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(); let g = EditGraph::default_chain();
assert!(g.is_neutral()); assert!(g.is_neutral());
let source = g.compose().source;
assert_eq!( assert_eq!(
g.compose().source.matches("---- ").count(), source.matches("---- ").count(),
0, 1,
"a neutral graph must generate no operation blocks" "a neutral graph must generate no adjustment blocks"
); );
assert!(source.contains("---- view_transform ----"));
} }
#[test] #[test]
@@ -1207,13 +1262,15 @@ mod tests {
#[test] #[test]
fn only_active_operations_reach_the_shader() { fn only_active_operations_reach_the_shader() {
// The composition property, end to end: two adjustments out of seven // 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(); let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
g.set_param(white_balance::ID, white_balance::TINT, 25.0); g.set_param(white_balance::ID, white_balance::TINT, 25.0);
let shader = g.compose(); 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("---- exposure ----"));
assert!(shader.source.contains("---- white_balance ----")); assert!(shader.source.contains("---- white_balance ----"));
assert!(!shader.source.contains("---- saturation ----")); assert!(!shader.source.contains("---- saturation ----"));
+24 -4
View File
@@ -50,6 +50,8 @@ pub mod preset;
pub mod sidecar; pub mod sidecar;
pub mod spot; pub mod spot;
pub mod state; pub mod state;
pub mod tiles;
pub mod view;
pub use coverage::Coverage; pub use coverage::Coverage;
pub use declared::{Declaration, DeclaredOp}; 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 lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
pub use operation::{ pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation, compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET, OutputMode, Stage, Uniform, CLIP_ONSET, RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
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 preset::{LibraryParseError, NameError, Preset, PresetLibrary, Reach, Scope};
pub use sidecar::{Sidecar, Version}; pub use sidecar::{Sidecar, Version};
@@ -169,7 +171,21 @@ mod tests {
let mut fused_blocks = 0; let mut fused_blocks = 0;
for desc in g.descriptors() { for desc in g.descriptors() {
let id = desc.id.0; 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 let neighbourhood = detail
.passes .passes
.iter() .iter()
@@ -182,8 +198,12 @@ mod tests {
); );
fused_blocks += usize::from(point); fused_blocks += usize::from(point);
} }
let view_blocks = shader
.view
.as_ref()
.map_or(0, |v| v.source.matches("---- ").count());
assert_eq!( assert_eq!(
shader.source.matches("---- ").count(), shader.source.matches("---- ").count() + view_blocks,
fused_blocks, fused_blocks,
"the fused shader carries a block nothing in the chain asked for" "the fused shader carries a block nothing in the chain asked for"
); );
+2 -2
View File
@@ -2068,8 +2068,8 @@ pub(crate) struct LayerShader {
/// ///
/// Kept apart from the rest because it belongs at the other end of the /// Kept apart from the rest because it belongs at the other end of the
/// shader. Everything else runs on scene-referred colour in the working /// shader. Everything else runs on scene-referred colour in the working
/// space, where a flat tint would then be pushed through the base curve /// space, where a flat tint would then be pushed through the view
/// and the camera matrix and arrive as some other colour, and a /// transform and arrive as some other colour, and a
/// white-on-black alpha would arrive as neither. This runs after the /// white-on-black alpha would arrive as neither. This runs after the
/// output transform, so what is written is what is seen. /// output transform, so what is written is what is seen.
pub reveal: String, pub reveal: String,
+2 -2
View File
@@ -82,8 +82,8 @@ const FLOOR: f32 = 1e-4;
/// Move the graph so that `sample` renders neutral. /// Move the graph so that `sample` renders neutral.
/// ///
/// `sample` is the linear triple the operation's own gains multiply — camera /// `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 /// RGB with the camera's as-shot balance on, *before* the camera matrix and
/// and matrix, and with the sampling operation at its defaults. Not the /// 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 /// 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 /// 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 /// 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 { impl DetailStage for CaptureSharpen {
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> { 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) { if !self.resolves(scale) {
return vec![nothing_to_sharpen()]; return Vec::new();
} }
let extent = self.kernel(scale); 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. /// One axis of the separable unsharp mask.
/// ///
/// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the /// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the
@@ -528,7 +500,6 @@ c = select(c, scaled, centre > 1e-5);"#;
mod tests { mod tests {
use super::*; use super::*;
use crate::EditGraph; use crate::EditGraph;
use dr_types::ColourSpace;
/// The develop chain with the sharpener turned up. /// 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 // 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 // sizes it stands for rather than handed over: a render of the full
// frame at `render_size`, from a source of `full_size`. // 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] #[test]
@@ -591,13 +562,11 @@ mod tests {
assert_eq!(first.label, "capture_sharpen/horizontal"); assert_eq!(first.label, "capture_sharpen/horizontal");
assert_eq!(last.label, "capture_sharpen/vertical"); assert_eq!(last.label, "capture_sharpen/vertical");
assert!(!first.writes_output); // Neither encodes: the view pass after the detail stage does (D19).
assert!(first.source.contains("texture_storage_2d<rgba16float")); for pass in [first, last] {
assert!(!first.source.contains("fn encode_output")); assert!(pass.source.contains("texture_storage_2d<rgba16float"));
assert!(!pass.source.contains("fn encode_output"));
assert!(last.writes_output); }
assert!(last.source.contains("texture_storage_2d<rgba8unorm"));
assert!(last.source.contains("fn encode_output"));
// Two shaders, so two pipeline-cache entries. Sharing one would run // Two shaders, so two pipeline-cache entries. Sharing one would run
// the horizontal pass's uniforms through the vertical pass's slots. // 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)); let proxy = RenderScale::new((1000, 1000), (4000, 4000));
assert!(!proxy.resolves(1.0)); 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); let composed = chain_at(&graph, proxy);
// Not empty, though. See `nothing_to_sharpen`: the fused pass has assert!(composed.is_empty());
// 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"
);
// Zooming to 1:1 is what brings it back — the view rect shrinks while // 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 // 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. /// One colour component through its own curve.
const CHANNEL_CURVE: Helper = Helper { const CHANNEL_CURVE: Helper = Helper {
name: "channel_curve", name: "channel_curve",
@@ -504,20 +534,21 @@ const CHANNEL_CURVE: Helper = Helper {
// it changes the proportions between the components, which is what makes it // it changes the proportions between the components, which is what makes it
// chromatic where the master is tonal. // chromatic where the master is tonal.
// //
// The clamp is the curve's promise rather than an oversight: its last point // Above the axis the curve continues along its last span (`curve_extend`),
// *is* white, so a component arriving above the axis takes the value the curve // exactly as the master's does, so a highlight is tinted the way every tone
// gives at 1. The master does the same to a luminance above 1, through the // just below it is — a component that stopped at the curve's top instead
// gain it applies; a channel curve that instead let highlights past unchanged // would put a coloured fringe along a blown edge, and flattened every
// would tint them differently from every tone below them, which reads as a // scene-referred highlight into one value besides (D19). Below zero there is
// coloured fringe along a blown edge. // no light to curve; the floor is the one clamp left, and it is at zero, not
// at one.
fn channel_curve( fn channel_curve(
v: f32, v: f32,
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
x3: f32, y3: f32, x4: f32, y4: f32, x3: f32, y3: f32, x4: f32, y4: f32,
) -> f32 { ) -> f32 {
let encoded = pow(clamp(v, 0.0, 1.0), 1.0 / 2.2); let encoded = pow(max(v, 0.0), 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);
return pow(clamp(curved, 0.0, 1.0), 2.2); return pow(max(curved, 0.0), 2.2);
}", }",
}; };
@@ -535,10 +566,11 @@ static MASTER_HELPERS: &[Helper] = &[
helpers::APPLY_TONE_GAIN, helpers::APPLY_TONE_GAIN,
CURVE_SPAN, CURVE_SPAN,
CURVE_EVAL, CURVE_EVAL,
CURVE_EXTEND,
]; ];
/// The per-channel curves alone. /// 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. /// Both.
static ALL_HELPERS: &[Helper] = &[ static ALL_HELPERS: &[Helper] = &[
@@ -546,6 +578,7 @@ static ALL_HELPERS: &[Helper] = &[
helpers::APPLY_TONE_GAIN, helpers::APPLY_TONE_GAIN,
CURVE_SPAN, CURVE_SPAN,
CURVE_EVAL, CURVE_EVAL,
CURVE_EXTEND,
CHANNEL_CURVE, CHANNEL_CURVE,
]; ];
@@ -553,16 +586,17 @@ static ALL_HELPERS: &[Helper] = &[
const MASTER_BODY: &str = "\ const MASTER_BODY: &str = "\
let luma = luminance(c); let luma = luminance(c);
if (luma > 0.0001) { if (luma > 0.0001) {
// The curve is authored on a display-referred 0..1 axis, which is where // The curve is authored on a 0..1 axis, which is where the widget's grid
// the eye reads tone and where the widget's grid lives. Scene-referred // lives, with a 2.2 gamma so that a point placed at the middle of the
// luminance is unbounded, so it is encoded to that axis, curved, and // grid means the middle of the visible range. Scene-referred luminance
// decoded back — otherwise a point placed at the middle of the grid // does not stop at 1: above the axis the curve continues along its last
// would not correspond to the middle of the visible range. // span (`curve_extend`) rather than clipping, because the view transform
let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2); // 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. // Applied as a ratio so hue is preserved, exactly as contrast does.
c = apply_tone_gain(c, decoded / luma); c = apply_tone_gain(c, decoded / luma);
}"; }";
@@ -1288,7 +1322,10 @@ mod tests {
c.set_param(P2_Y, 0.7); c.set_param(P2_Y, 0.7);
let body = c.wgsl_body(); 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!( assert!(
!body.contains("channel_curve("), !body.contains("channel_curve("),
"an untouched channel reached the shader:\n{body}" "an untouched channel reached the shader:\n{body}"
+1 -7
View File
@@ -541,14 +541,13 @@ c = (c - lifted) / t;";
mod tests { mod tests {
use super::*; use super::*;
use crate::detail::compose_detail; use crate::detail::compose_detail;
use dr_types::ColourSpace;
fn ops(amount: f32) -> Vec<Box<dyn Operation>> { fn ops(amount: f32) -> Vec<Box<dyn Operation>> {
vec![Box::new(Dehaze::with_amount(amount))] vec![Box::new(Dehaze::with_amount(amount))]
} }
fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail { fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail {
compose_detail(&ops(amount), scale, ColourSpace::Srgb) compose_detail(&ops(amount), scale)
} }
#[test] #[test]
@@ -658,11 +657,6 @@ mod tests {
let split = Split::of(Dehaze::with_amount(60.0).patch(RenderScale::full((2000, 1500)))); let split = Split::of(Dehaze::with_amount(60.0).patch(RenderScale::full((2000, 1500))));
assert!(composed.passes.iter().all(|p| p.radius == split.extent())); 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 // Nothing here uses the reduced chain — see the module documentation
// for why a second operation cannot pick its own `output_scale` while // for why a second operation cannot pick its own `output_scale` while
// the runner holds one reduced buffer. // the runner holds one reduced buffer.
+38 -31
View File
@@ -1,22 +1,25 @@
//! TRACES: FR-DEV-3f //! TRACES: FR-DEV-3f
//! Film simulation — the stock renders the picture. //! 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 //! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The view
//! curve exists because sensor data is scene-referred and nothing anybody looks //! transform exists because sensor data is scene-referred and nothing anybody
//! at is (FR-DEV-3e); a film stock's characteristic curve does the same job, //! looks at is (FR-DEV-3j); a film stock's characteristic curve does the same
//! from measurements, with a toe and a shoulder that were coated onto acetate //! job, from measurements, with a toe and a shoulder that were coated onto
//! rather than drawn. Running both renders the image twice — the camera's //! acetate rather than drawn. Running both renders the image twice — the
//! JPEG-ish rendering, and then a film's rendering of that — which is not what //! default rendering, and then a film's rendering of that — which is not what
//! either is for and looks like neither. //! either is for and looks like neither.
//! //!
//! So this node declares [`Operation::renders`], and the composer answers by //! So this node is in [`Stage::View`] and declares [`Operation::renders`]: when
//! emitting neither the base curve nor the camera matrix. Both jobs move here: //! a stock is loaded the composer puts it at the end of the chain in place of
//! the fragment takes camera RGB, converts it to linear sRGB itself with the //! the default sigmoid (D19). It is handed working-space colour — linear sRGB
//! matrix already in the uniform block, and returns linear sRGB. That is a //! primaries, scene-referred, after every other operation and after the detail
//! contract worth stating plainly, because a node that got half of it wrong //! stage — and returns display-referred linear sRGB for the output transform.
//! would produce a picture that renders perfectly and is wrong everywhere. //! 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 //! # Why the tables are not parameters
//! //!
@@ -34,7 +37,7 @@
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId}; 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 ID: OpId = OpId("film_sim");
pub const EXPOSURE: ParamId = ParamId("exposure"); pub const EXPOSURE: ParamId = ParamId("exposure");
@@ -304,11 +307,17 @@ impl Operation for FilmSim {
self.tables.is_some() 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 { fn renders(&self) -> bool {
true 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>) { fn set_film_tables(&mut self, tables: Option<&FilmTables>) {
self.set_tables(tables.cloned()); self.set_tables(tables.cloned());
} }
@@ -396,14 +405,10 @@ impl Operation for FilmSim {
// sampler binding, and adding one to interpolate two lookups would // sampler binding, and adding one to interpolate two lookups would
// cost a binding in every shader whether or not a film is loaded. // 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 // Working-space colour, which is linear sRGB primaries — what the film's
// sRGB primaries, and this node has taken over the conversion the composer // exposure matrix is defined against. The composer converted out of camera
// would otherwise have emitted at the end — see `Operation::renders`. // RGB before any scene-stage operation ran (D19).
let scene = vec3<f32>( let scene = c;
dot(u.cam_to_srgb_0.rgb, c),
dot(u.cam_to_srgb_1.rgb, c),
dot(u.cam_to_srgb_2.rgb, c),
);
// What each emulsion layer was exposed to. A matrix, exactly: the scene // What each emulsion layer was exposed to. A matrix, exactly: the scene
// spectrum reconstructed from an sRGB triple is linear in that triple, so the // spectrum reconstructed from an sRGB triple is linear in that triple, so the
@@ -701,10 +706,10 @@ mod tests {
#[test] #[test]
fn it_declares_itself_a_rendering_transform() { fn it_declares_itself_a_rendering_transform() {
// The whole reason the composer skips the base curve and the camera // The whole reason the composer emits the stock in the view
// matrix. If this ever returned false the picture would be rendered // transform's place rather than beside it. If this ever returned
// twice and converted twice, which looks like a colour management bug // false the picture would be rendered twice, which looks like a
// a long way from here. // colour management bug a long way from here.
assert!(FilmSim::new().renders()); assert!(FilmSim::new().renders());
} }
@@ -727,13 +732,15 @@ mod tests {
} }
#[test] #[test]
fn the_fragment_converts_out_of_camera_space_itself() { fn the_fragment_is_handed_working_space_colour() {
// It has to: it has taken over the conversion the composer would // TRACES: FR-DEV-3f
// otherwise emit at the end. // 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(); let mut op = FilmSim::new();
op.set_tables(Some(tables())); op.set_tables(Some(tables()));
let wgsl = op.wgsl_body(); let wgsl = op.wgsl_body();
assert!(wgsl.contains("cam_to_srgb_0"), "{wgsl}"); assert!(!wgsl.contains("cam_to_srgb"), "{wgsl}");
} }
#[test] #[test]
+1 -2
View File
@@ -878,7 +878,6 @@ c = c * exp2(stops);"
mod tests { mod tests {
use super::*; use super::*;
use crate::detail::compose_detail; use crate::detail::compose_detail;
use dr_types::ColourSpace;
/// The two controls, as the graph would hold them. /// The two controls, as the graph would hold them.
fn ops(clarity: f32, texture: f32) -> Vec<Box<dyn Operation>> { 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 { 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] #[test]
+2
View File
@@ -74,6 +74,7 @@ pub mod distortion;
pub mod film_sim; pub mod film_sim;
pub mod local_contrast; pub mod local_contrast;
pub mod noise_reduction; pub mod noise_reduction;
pub mod view_transform;
pub mod vignetting; pub mod vignetting;
pub use aberration::Aberration; pub use aberration::Aberration;
@@ -87,6 +88,7 @@ pub use film_sim::{FilmSim, FilmTables, PaperTables};
// documentation for why that is two nodes and not one. // documentation for why that is two nodes and not one.
pub use local_contrast::{Clarity, Texture}; pub use local_contrast::{Clarity, Texture};
pub use noise_reduction::NoiseReduction; pub use noise_reduction::NoiseReduction;
pub use view_transform::ViewTransform;
pub use vignetting::Vignetting; pub use vignetting::Vignetting;
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by // 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use dr_types::ColourSpace;
/// A 24 MP frame, and the panel a develop view might show it in. /// A 24 MP frame, and the panel a develop view might show it in.
const FULL: (u32, u32) = (6000, 4000); const FULL: (u32, u32) = (6000, 4000);
@@ -629,7 +628,7 @@ mod tests {
} }
fn compose(op: NoiseReduction, scale: RenderScale) -> crate::detail::ComposedDetail { 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] #[test]
@@ -674,11 +673,6 @@ mod tests {
// The luminance pass runs first, so the chroma guide is the denoised // The luminance pass runs first, so the chroma guide is the denoised
// luminance rather than the raw one. // luminance rather than the raw one.
assert_eq!(both.passes[0].label, "noise_reduction/luminance"); 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] #[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);
}
}
+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 //! # What is not covered, and why that is honest
//! //!
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`, //! 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 //! 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 //! silently: [`every_declared_node_is_checked`] asserts the two sets partition
//! `ops/` between them, so a node that stops being declared cannot quietly //! `ops/` between them, so a node that stops being declared cannot quietly
@@ -403,6 +404,7 @@ fn every_declared_node_is_checked() {
"noise_reduction", "noise_reduction",
"texture", "texture",
"tone_curve", "tone_curve",
"view_transform",
"vignetting", "vignetting",
], ],
"the set of hand-written nodes changed; if that is deliberate, update \ "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); 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] #[test]
fn a_preset_without_a_name_is_called_after_its_file() { fn a_preset_without_a_name_is_called_after_its_file() {
// Lightroom writes the name it displays, which is not always the 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 /// 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 /// 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 /// 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 /// bother. Nested folders are walked, and each one below `path` becomes a
/// available to whatever wants it later. /// 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 /// 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 /// 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 /// 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 { pub fn read_path(path: &std::path::Path) -> Report {
let mut report = Report::default(); 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.presets.sort_by(|a, b| a.0.cmp(&b.0));
report 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() { if path.is_dir() {
let Ok(entries) = std::fs::read_dir(path) else { let Ok(entries) = std::fs::read_dir(path) else {
log::warn!("preset import: cannot read {}", path.display()); 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(); let mut paths: Vec<std::path::PathBuf> = entries.flatten().map(|e| e.path()).collect();
paths.sort(); paths.sort();
for path in paths { 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; return;
} }
@@ -710,6 +757,12 @@ fn read_into(path: &std::path::Path, report: &mut Report) {
.unwrap_or_default() .unwrap_or_default()
}); });
report.unsupported.extend(import.skipped); 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)); report.presets.push((name, import.preset));
} }
Err(e) => { 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 /// Zoomed cells, the loupe, and the filmstrip. ~45 KB each, fetched only
/// where something actually asks for that detail. /// where something actually asks for that detail.
Large = 1, 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 { 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 { pub fn edge(self) -> u32 {
match self { match self {
ThumbSize::Grid => 256, ThumbSize::Grid => 256,
ThumbSize::Large => 1024, 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 /// The smallest class that can fill a cell of this size without visibly
/// softening. /// 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 { match v {
1 => ThumbSize::Large, 0 => Some(ThumbSize::Grid),
_ => 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. /// Long edge of a grid thumbnail.
+47 -6
View File
@@ -34,7 +34,7 @@ document elaborates:
| UI | Slint | D1, D8 | | UI | Slint | D1, D8 |
| GPU | wgpu → Vulkan (Linux + Android) | D1 | | GPU | wgpu → Vulkan (Linux + Android) | D1 |
| Shaders | Hand-written WGSL | D6 | | 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 | | Catalog | SQLite (WAL) — a rebuildable index | D5, §6.12 |
| Colour | lcms2 + GPU-side matrix/LUT transforms | D5 | | Colour | lcms2 + GPU-side matrix/LUT transforms | D5 |
| Network | reqwest + quick-xml | D7 | | 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 > (§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 > the places that start a job name `dr_decode::default()`; everything below them takes a
> `&dyn Decoder`. > `&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 ### 3.3 Operation and descriptors
@@ -355,11 +360,12 @@ RawImage (sensor data, CPU)
├─────────────────────┤ ├─────────────────────┤
│ AI denoise │ optional; raw-domain, joint with demosaic where possible │ 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 │ │ exposure/contrast │
│ highlights/shadows │ ← masks apply per-op from here down │ highlights/shadows │ ← masks apply per-op from here down
│ tone curve │ │ tone curve │
@@ -368,10 +374,11 @@ RawImage (sensor data, CPU)
│ spot removal │ │ spot removal │
│ sharpen / NR │ │ sharpen / NR │
│ lens corrections │ │ lens corrections │
│ look (HaldCLUT) │ FR-DEV-3f
├─────────────────────┤ ├─────────────────────┤
│ geometry │ crop, straighten, rotate │ geometry │ crop, straighten, rotate
├─────────────────────┤ ├─────────────────────┤
│ view transform │ sigmoid, or the film stock (FR-DEV-3j)
├─────────────────────┤
│ output transform │ → display or export profile │ output transform │ → display or export profile
└─────────────────────┘ └─────────────────────┘
│ │
@@ -381,6 +388,14 @@ RawImage (sensor data, CPU)
Working precision is f16 in a linear wide-gamut space, quantising once at the output transform. Working precision is f16 in a linear wide-gamut space, quantising once at the output transform.
**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 **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 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 (`shaders/hot_pixels.wgsl`, run by `Demosaicer::run` into a second buffer) replaces a photosite that
@@ -423,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 operations up to the first `Affects` change. Adjusting exposure reuses cached demosaic and camera
profile output for every tile. 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 ### 5.4 Mask rasterisation
**All masks rasterise on the GPU, including drawn brush strokes** (§6.11). Strokes arrive as **All masks rasterise on the GPU, including drawn brush strokes** (§6.11). Strokes arrive as
@@ -442,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 **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 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 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 — 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 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. neither answers for the other. Both are drawn by the same panel and chosen between.
@@ -1255,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 state, which is exactly deterministic. Cross-platform *rendering* equality is a bounded tolerance
(R1), not a checksum. (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 ## 13. Decisions
@@ -1278,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 | | 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) | | 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) | | D15 | Target devices — 12-inch tablet and desktop, no phone | Decided (requirements D15) |
| D19 | Scene-referred pipeline, one view transform last | Decided (requirements D19) |
--- ---
+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 | | Requirement | Reality |
|---|---| |---|---|
| FR-DSP-1 proxy rendering | **Done.** The develop view renders at viewport resolution, not source. | | 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-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-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. | | 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. |
+23
View File
@@ -515,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 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 the one the split passes covered, and the rgba8 output hashed identically
before and after in all 64 scene, view and size combinations measured. 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 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 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 working space, where a flat tint would be pushed through the view transform
the camera matrix and arrive as some other colour, and an alpha's white on (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. black would arrive as neither.
### 6.3 Not on the graph ### 6.3 Not on the graph
+25 -7
View File
@@ -46,6 +46,13 @@ before the demosaic, for Bayer and X-Trans alike and with no setting (FR-RAW-3;
reader in `dr-decode` is still not wired in, and a CR2 carries no map for it to read); and the 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. 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 ## 1. Plugins — post-v1 since 2026-09-19
@@ -181,7 +188,7 @@ place.
## 4. The render path — FR-DSP-2, FR-DSP-4, NFR-RES-2, NFR-ARCH-1 ## 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, 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 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, own header: one fused dispatch over a viewport-sized target is comfortably inside the frame budget,
@@ -191,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 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. 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 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 there is no scheduler to read it. a tile would have to be grown by, with a test that pins it, and nothing to read it. The export of an
That is deliberate plumbing, not an oversight. 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. **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 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 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. 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 **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 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 decision is `ui/dr-ui/src/refine.rs`, a debounce whose every draft re-arms the settle timer, driven
@@ -212,8 +226,10 @@ 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 **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 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 embedded previews with develop withheld, and NFR-RES-2 no longer promises a fallback render. Since
remains unbuilt is the memory half: there is no headroom budget, no allocation-failure staging, 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 and no spill. Spike S6 — a tiled pipeline on a
mid-range Android device with an image larger than available GPU memory — is the one that would mid-range Android device with an image larger than available GPU memory — is the one that would
settle both this and FR-DSP-2, and there is no evidence it has run. settle both this and FR-DSP-2, and there is no evidence it has run.
@@ -567,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 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 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 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. [panorama.md](panorama.md) §11 and §13 are where it stands.
Three clauses carry no tag: Three clauses carry no tag:
+110 -6
View File
@@ -128,7 +128,11 @@ stays hot for it.
### 5.1 The tap — S15.3, answered by reading the composer ### 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 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 either the display encode or, in `OutputMode::LinearWorking`, an unclipped
`rgba16float` of linear sRGB. That mode exists for the detail stage and is `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 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 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`, 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 `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 - `EditGraph::compose_camera_linear()` — the `LinearWorking` tail with an
empty operation list and identity framing, paired by name with 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. FR-MRG-11 already bounds.
**What the DNG carries as a consequence:** the first source's `Make`, **What the DNG carries as a consequence:** the first source's `Make`,
`Model` and `UniqueCameraModel` — so `base_curve::for_body` finds the 6D's `Model` and `UniqueCameraModel` — so `base_curve::for_body` found the 6D's
curve — its `ColorMatrix1`/`2` with illuminants, and its `AsShotNeutral`. The 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 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; once. The spike's 64 × 48 file (§8) already carries the matrix and neutral;
the body name is a string. 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 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 — 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 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 The sources are portrait frames in the 6D set: `Orientation` is applied
before alignment (learned features are not rotation-invariant) and the 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 - 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 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 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 - 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 file is written and catalogued, beside its sources, with the merge as the
first entry in its history. 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 ## 10. Order of work
1. **S15**, all four, before anything else. (1) and (2) are a day each and 1. **S15**, all four, before anything else. (1) and (2) are a day each and
@@ -559,3 +616,50 @@ gaps between cells — built and measured in `darkroom-infill`
and the next thing to port into `dr_pano::fill` (it needs the 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* discriminator as a second model, ~80 MB fp16). FR-MRG-4's *experimental*
stays. 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).
+152 -14
View File
@@ -306,6 +306,21 @@ from source + graph.
per channel in a wide-gamut linear working space. Quantisation to the output bit depth happens per channel in a wide-gamut linear working space. Quantisation to the output bit depth happens
once, at the final export or display stage. 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).** **FR-DEV-3 — Adjustment set (v1).**
- White balance (temperature/tint, and picker) - White balance (temperature/tint, and picker)
@@ -408,21 +423,31 @@ demosaic and the working-space conversion.
**v1 scope** (per D11 — good defaults rather than exhaustive colour science): **v1 scope** (per D11 — good defaults rather than exhaustive colour science):
1. Embedded DNG `ColorMatrix1/2` and `ForwardMatrix1/2` tags 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) 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 **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 dual-illuminant interpolation. The stage shall be structured so these are additions rather than a
pipeline reordering. pipeline reordering.
Rationale for the reduced scope: a bare 3×3 matrix produces the flat, poor-skin-tone rendering 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 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 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 implementation.~~ The flat render is a missing *view transform*, not a missing per-body curve:
and curves can be added without a release — and, under D8's GPLv3, contributed by users. 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 *Acceptance:* the default render is subjectively comparable to the camera's own JPEG — through
own JPEG. ΔE2000 validation against ColorChecker references applies once DCP support lands. 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 **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 simulation ecosystem at near-zero implementation cost, plus reading the in-RAF film simulation tag
@@ -439,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 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. 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 A film simulation is a *rendering*, not an adjustment, so it **is** the view transform when a
curve and the conversion out of camera space (`Operation::renders`) — applying both would render stock is chosen (FR-DEV-3j): it runs last, after every adjustment and after the detail stage, in
the scene twice. 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 *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 within 0.06 in linear sRGB; the baked lookup's interpolation error stays under one 8-bit code
@@ -518,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 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. 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 **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 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 — shall reach the display without a CPU round-trip.** *(This is a hard architectural constraint —
@@ -692,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 > 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 > 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. > 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 **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 budget at proxy resolution. When a full-resolution result is needed it is computed
@@ -1846,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 **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 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 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 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. once, to the composite, in its own develop.
@@ -1855,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 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, 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 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 found in the pipeline: the profile's rendering was applied to every frame of a known body, so a
frame of a known body, so a composite that baked it in and then developed as one would render the composite that baked it in and then developed as one would render it twice. *(Amended 2026-09-27,
curve twice. Lens correction alone sits above the cut, because a distorted frame does not align. 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 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 camera RGB agrees across the overlaps whether or not the camera's auto white balance drifted, and
the balanced values would not. the balanced values would not.
@@ -1911,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 photograph is the failure this application must not have, and here the omission would be an
entire frame. 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 **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 `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 records the merge as the first entry, and export metadata declares the composite as one. Sources
@@ -2096,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, memory or refused with a typed error (`GpuError::TooLarge`) — never rendered by a CPU pipeline,
which does not exist (NFR-R8). 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 **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 and thermal behaviour are first-class concerns; background sync respects metered-connection and
battery-saver settings. battery-saver settings.
@@ -2346,6 +2424,7 @@ Rationale, evidence, and the eliminated alternatives are recorded in
| D11 | Product positioning | Culling-first differentiator; see below | | 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 | | 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 | | 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 ### D11 — product positioning
@@ -2358,7 +2437,7 @@ Settled by requirements calibration, 2026-08-08.
| Culling | **The core differentiator** (§3.9) | | Culling | **The core differentiator** (§3.9) |
| Focus checking | Peaking *and* zoom | | Focus checking | Peaking *and* zoom |
| Ingest | Full workflow — template rename, checksum verify, dual-destination | | 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 | | 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) | | 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 | | Local adjustments | Full masking, GPU-rasterised |
@@ -2592,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 where the output is still frame A, and inherits nothing from this decision but the provenance
rule. 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** ### D16 — plugin licensing · **OPEN, post-v1**
> Deferred with §3.10 on 2026-09-19. Still to be answered before the format is published as > Deferred with §3.10 on 2026-09-19. Still to be answered before the format is published as
+107 -106
View File
File diff suppressed because one or more lines are too long
+50 -50
View File
@@ -39,7 +39,7 @@ The list is longer than it is tall, so a way to walk it that cannot be lost to t
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered. Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
<sub>`ui/dr-ui/ui/app.slint:1961`</sub> <sub>`ui/dr-ui/ui/app.slint:1982`</sub>
### Move a magnified photograph about ### Move a magnified photograph about
@@ -50,7 +50,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture. Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
<sub>`ui/dr-ui/ui/app.slint:2057`</sub> <sub>`ui/dr-ui/ui/app.slint:2078`</sub>
### Paint a mask by hand ### Paint a mask by hand
@@ -60,7 +60,7 @@ Only once there is something outside the viewport to reach, which is why the cur
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make. A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
<sub>`ui/dr-ui/ui/app.slint:2148`</sub> <sub>`ui/dr-ui/ui/app.slint:2169`</sub>
### Open this list ### Open this list
@@ -70,7 +70,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for. Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for.
<sub>`ui/dr-ui/ui/app.slint:2374`</sub> <sub>`ui/dr-ui/ui/app.slint:2395`</sub>
### Take back the last change ### Take back the last change
@@ -81,7 +81,7 @@ Most of the keys are develop's, and a reference that could only be opened from t
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does. A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
<sub>`ui/dr-ui/ui/app.slint:2404`</sub> <sub>`ui/dr-ui/ui/app.slint:2425`</sub>
### Do it again after taking it back ### Do it again after taking it back
@@ -90,7 +90,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y` - **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
- **See it** — [in the manual](manual/README.md#history-snapshots-presets) - **See it** — [in the manual](manual/README.md#history-snapshots-presets)
<sub>`ui/dr-ui/ui/app.slint:2418`</sub> <sub>`ui/dr-ui/ui/app.slint:2439`</sub>
### Remove a repair ### Remove a repair
@@ -98,7 +98,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
- **Pointer** — Click it, then Delete Repair - **Pointer** — Click it, then Delete Repair
- **Keyboard** — `Delete` or `Backspace`, while repairing - **Keyboard** — `Delete` or `Backspace`, while repairing
<sub>`ui/dr-ui/ui/app.slint:2438`</sub> <sub>`ui/dr-ui/ui/app.slint:2459`</sub>
### Copy the settings from this photograph ### Copy the settings from this photograph
@@ -109,7 +109,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way. The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
<sub>`ui/dr-ui/ui/app.slint:2457`</sub> <sub>`ui/dr-ui/ui/app.slint:2478`</sub>
### Paste the settings onto this photograph ### Paste the settings onto this photograph
@@ -120,7 +120,7 @@ The button is the copy that has to work: a tablet has no modifier key to hold an
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope. The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
<sub>`ui/dr-ui/ui/app.slint:2470`</sub> <sub>`ui/dr-ui/ui/app.slint:2491`</sub>
### Choose which kinds of edit a copy carries ### Choose which kinds of edit a copy carries
@@ -131,7 +131,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying. Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
<sub>`ui/dr-ui/ui/app.slint:2488`</sub> <sub>`ui/dr-ui/ui/app.slint:2509`</sub>
### Export this photograph as the last one was ### Export this photograph as the last one was
@@ -142,7 +142,7 @@ Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving e
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again. Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
<sub>`ui/dr-ui/ui/app.slint:2513`</sub> <sub>`ui/dr-ui/ui/app.slint:2534`</sub>
### Choose how to export, then export ### Choose how to export, then export
@@ -153,7 +153,7 @@ Every export runs on the defaults in Settings, so "as the last one was" is what
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses. The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
<sub>`ui/dr-ui/ui/app.slint:2526`</sub> <sub>`ui/dr-ui/ui/app.slint:2547`</sub>
### Keep a crop that leaves a mask outside ### Keep a crop that leaves a mask outside
@@ -161,7 +161,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back - **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step - **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
<sub>`ui/dr-ui/ui/app.slint:2591`</sub> <sub>`ui/dr-ui/ui/app.slint:2612`</sub>
### Go back to the grid ### Go back to the grid
@@ -171,7 +171,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back. Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
<sub>`ui/dr-ui/ui/app.slint:2608`</sub> <sub>`ui/dr-ui/ui/app.slint:2629`</sub>
### Nudge the control last moved ### Nudge the control last moved
@@ -181,7 +181,7 @@ Lightroom's key for the grid. Escape gets there too, but a step at a time — ou
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments. Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
<sub>`ui/dr-ui/ui/app.slint:2637`</sub> <sub>`ui/dr-ui/ui/app.slint:2658`</sub>
### Change which group of adjustments is on screen ### Change which group of adjustments is on screen
@@ -192,7 +192,7 @@ Lightroom's keys for the selected slider. There is no focus ring on a slider her
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added. The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
<sub>`ui/dr-ui/ui/app.slint:2665`</sub> <sub>`ui/dr-ui/ui/app.slint:2686`</sub>
### Look at the photograph at 1:1 ### Look at the photograph at 1:1
@@ -203,7 +203,7 @@ The groups are whatever the operation set declares itself to be about, so there
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur. Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
<sub>`ui/dr-ui/ui/app.slint:2701`</sub> <sub>`ui/dr-ui/ui/app.slint:2722`</sub>
### Rate this photograph ### Rate this photograph
@@ -211,7 +211,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
- **Pointer** — Click a star in the top bar - **Pointer** — Click a star in the top bar
- **Keyboard** — `0`–`5` - **Keyboard** — `0`–`5`
<sub>`ui/dr-ui/ui/app.slint:2758`</sub> <sub>`ui/dr-ui/ui/app.slint:2779`</sub>
### Pick or reject this photograph ### Pick or reject this photograph
@@ -221,7 +221,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on. The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
<sub>`ui/dr-ui/ui/app.slint:2764`</sub> <sub>`ui/dr-ui/ui/app.slint:2785`</sub>
### Give this photograph a colour label ### Give this photograph a colour label
@@ -232,7 +232,7 @@ The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging h
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark. The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
<sub>`ui/dr-ui/ui/app.slint:2794`</sub> <sub>`ui/dr-ui/ui/app.slint:2815`</sub>
### Move to the next or previous photograph ### Move to the next or previous photograph
@@ -243,7 +243,7 @@ The grid's keys, on the photograph that is open, so labelling while stepping thr
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse. The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse.
<sub>`ui/dr-ui/ui/app.slint:2819`</sub> <sub>`ui/dr-ui/ui/app.slint:2840`</sub>
### See the photograph before you edited it ### See the photograph before you edited it
@@ -254,7 +254,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards. Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
<sub>`ui/dr-ui/ui/app.slint:2949`</sub> <sub>`ui/dr-ui/ui/app.slint:2970`</sub>
### Put one control back to its default ### Put one control back to its default
@@ -338,7 +338,7 @@ The question a correction raises is whether it did what it was for — whether t
One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past. One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past.
<sub>`ui/dr-ui/ui/app.slint:1021`</sub> <sub>`ui/dr-ui/ui/app.slint:1024`</sub>
### Do what a sheet offers ### Do what a sheet offers
@@ -346,7 +346,7 @@ One key for "up one", innermost first: a question before the sheet under it, a s
- **Pointer** — Press its button — Export, or Copy - **Pointer** — Press its button — Export, or Copy
- **Keyboard** — `Enter`, on the export and copy sheets - **Keyboard** — `Enter`, on the export and copy sheets
<sub>`ui/dr-ui/ui/app.slint:1031`</sub> <sub>`ui/dr-ui/ui/app.slint:1034`</sub>
### Scroll by the scrollbar ### Scroll by the scrollbar
@@ -521,7 +521,7 @@ The right match confidence is a property of your library, not of the model. "Wha
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found. Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
<sub>`ui/dr-ui/ui/library.slint:1706`</sub> <sub>`ui/dr-ui/ui/library.slint:1732`</sub>
### Add or remove one photograph ### Add or remove one photograph
@@ -531,7 +531,7 @@ Touch has no ctrl, so without a mode there is no way to select a second photogra
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back. While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
<sub>`ui/dr-ui/ui/library.slint:1716`</sub> <sub>`ui/dr-ui/ui/library.slint:1742`</sub>
### Leave selecting ### Leave selecting
@@ -540,7 +540,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
- **Keyboard** — `Escape`, or `Back`; an open sheet closes first - **Keyboard** — `Escape`, or `Back`; an open sheet closes first
- **See it** — [in the manual](manual/README.md#selecting-several) - **See it** — [in the manual](manual/README.md#selecting-several)
<sub>`ui/dr-ui/ui/library.slint:1725`</sub> <sub>`ui/dr-ui/ui/library.slint:1751`</sub>
### Pick a photograph up to drag it ### Pick a photograph up to drag it
@@ -550,7 +550,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
A finger on a photograph might be starting a scroll, and for the first half-second the grid assumes it is. Holding says otherwise, and the ring is the grid saying it heard — from there the drag cannot be lost to a scroll. A mouse never waits: the cursor is precise enough that a sideways drag is unambiguous from the first pixel. A finger on a photograph might be starting a scroll, and for the first half-second the grid assumes it is. Holding says otherwise, and the ring is the grid saying it heard — from there the drag cannot be lost to a scroll. A mouse never waits: the cursor is precise enough that a sideways drag is unambiguous from the first pixel.
<sub>`ui/dr-ui/ui/library.slint:1756`</sub> <sub>`ui/dr-ui/ui/library.slint:1782`</sub>
### Select a range ### Select a range
@@ -561,7 +561,7 @@ A finger on a photograph might be starting a scroll, and for the first half-seco
This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out. This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
<sub>`ui/dr-ui/ui/library.slint:1822`</sub> <sub>`ui/dr-ui/ui/library.slint:1848`</sub>
### Take the blinks out of a burst ### Take the blinks out of a burst
@@ -571,7 +571,7 @@ This replaced a double tap, which had no visible state and could take forty phot
Face indexing reads each face's eyes. The chip drops frames where the chosen people are caught blinking, and leaves sunglasses and eyes it could not read alone. Face indexing reads each face's eyes. The chip drops frames where the chosen people are caught blinking, and leaves sunglasses and eyes it could not read alone.
<sub>`ui/dr-ui/ui/library.slint:2540`</sub> <sub>`ui/dr-ui/ui/library.slint:2573`</sub>
### Find photographs with two people in them ### Find photographs with two people in them
@@ -581,7 +581,7 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar. "Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
<sub>`ui/dr-ui/ui/library.slint:2570`</sub> <sub>`ui/dr-ui/ui/library.slint:2603`</sub>
### Show only photographs with one colour label ### Show only photographs with one colour label
@@ -591,7 +591,7 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
Each chip is the label's mark and its name, so the one you want is found by reading it; tap the lit chip again to show every label. Each chip is the label's mark and its name, so the one you want is found by reading it; tap the lit chip again to show every label.
<sub>`ui/dr-ui/ui/library.slint:2694`</sub> <sub>`ui/dr-ui/ui/library.slint:2727`</sub>
### Export the selection as the last export was ### Export the selection as the last export was
@@ -602,7 +602,7 @@ Each chip is the label's mark and its name, so the one you want is found by read
Lightroom's and darktable's chords. Every export runs on the saved defaults, so the plain chord opens them beside an Export button and the shifted one skips straight to exporting. Lightroom's and darktable's chords. Every export runs on the saved defaults, so the plain chord opens them beside an Export button and the shifted one skips straight to exporting.
<sub>`ui/dr-ui/ui/library.slint:3164`</sub> <sub>`ui/dr-ui/ui/library.slint:3197`</sub>
### Paste copied settings onto the selection ### Paste copied settings onto the selection
@@ -611,7 +611,7 @@ Lightroom's and darktable's chords. Every export runs on the saved defaults, so
- **Keyboard** — `Ctrl+V` - **Keyboard** — `Ctrl+V`
- **See it** — [in the manual](manual/README.md#copying-settings) - **See it** — [in the manual](manual/README.md#copying-settings)
<sub>`ui/dr-ui/ui/library.slint:3188`</sub> <sub>`ui/dr-ui/ui/library.slint:3221`</sub>
### Keyword the selection ### Keyword the selection
@@ -621,7 +621,7 @@ Lightroom's and darktable's chords. Every export runs on the saved defaults, so
Lightroom's keywording chord. The sheet opens with its field ready for typing, so the keys that judge in the grid are out of the way until it closes. Lightroom's keywording chord. The sheet opens with its field ready for typing, so the keys that judge in the grid are out of the way until it closes.
<sub>`ui/dr-ui/ui/library.slint:3217`</sub> <sub>`ui/dr-ui/ui/library.slint:3250`</sub>
### Show only photographs with some number of stars ### Show only photographs with some number of stars
@@ -632,7 +632,7 @@ Lightroom's keywording chord. The sheet opens with its field ready for typing, s
The chips say "this many or more". A range with a ceiling — the twos and threes still to be decided — is the keyboard's alone, and the bar says so in words while it holds. The chips say "this many or more". A range with a ceiling — the twos and threes still to be decided — is the keyboard's alone, and the bar says so in words while it holds.
<sub>`ui/dr-ui/ui/library.slint:3251`</sub> <sub>`ui/dr-ui/ui/library.slint:3284`</sub>
### Give photographs a colour label ### Give photographs a colour label
@@ -643,7 +643,7 @@ The chips say "this many or more". A range with a ceiling — the twos and three
Lightroom's keys, so hands that learned them there need not learn them again. Purple has no key there either, and is on the bar. Every mark carries its label's initial, so the label is read without telling the colours apart. Lightroom's keys, so hands that learned them there need not learn them again. Purple has no key there either, and is on the bar. Every mark carries its label's initial, so the label is read without telling the colours apart.
<sub>`ui/dr-ui/ui/library.slint:3301`</sub> <sub>`ui/dr-ui/ui/library.slint:3334`</sub>
### Pick or reject a photograph ### Pick or reject a photograph
@@ -653,7 +653,7 @@ Lightroom's keys, so hands that learned them there need not learn them again. Pu
The keys every culling tool uses, so muscle memory built elsewhere works here. The keys every culling tool uses, so muscle memory built elsewhere works here.
<sub>`ui/dr-ui/ui/library.slint:3325`</sub> <sub>`ui/dr-ui/ui/library.slint:3358`</sub>
### Move photographs to the trash ### Move photographs to the trash
@@ -663,7 +663,7 @@ The keys every culling tool uses, so muscle memory built elsewhere works here.
The bin acts on one photograph, so a stray click cannot trash a selection; the key acts on the selection because that is what every file manager's Delete does. Both are undone from the trash view. The bin acts on one photograph, so a stray click cannot trash a selection; the key acts on the selection because that is what every file manager's Delete does. Both are undone from the trash view.
<sub>`ui/dr-ui/ui/library.slint:3352`</sub> <sub>`ui/dr-ui/ui/library.slint:3385`</sub>
### Open this list ### Open this list
@@ -671,7 +671,7 @@ The bin acts on one photograph, so a stray click cannot trash a selection; the k
- **Pointer** — Press Help in the header, and Done to put it away - **Pointer** — Press Help in the header, and Done to put it away
- **Keyboard** — `F1`, and `Escape` to put it away - **Keyboard** — `F1`, and `Escape` to put it away
<sub>`ui/dr-ui/ui/library.slint:3377`</sub> <sub>`ui/dr-ui/ui/library.slint:3410`</sub>
### Rename the collection the grid is showing ### Rename the collection the grid is showing
@@ -679,7 +679,7 @@ The bin acts on one photograph, so a stray click cannot trash a selection; the k
- **Pointer** — Double-click it in the sidebar - **Pointer** — Double-click it in the sidebar
- **Keyboard** — `F2` - **Keyboard** — `F2`
<sub>`ui/dr-ui/ui/library.slint:3385`</sub> <sub>`ui/dr-ui/ui/library.slint:3418`</sub>
### Move through the grid ### Move through the grid
@@ -689,7 +689,7 @@ The bin acts on one photograph, so a stray click cannot trash a selection; the k
The cursor selects what it lands on, so walking and judging are one hand's work. The cursor selects what it lands on, so walking and judging are one hand's work.
<sub>`ui/dr-ui/ui/library.slint:3405`</sub> <sub>`ui/dr-ui/ui/library.slint:3438`</sub>
### Resize the thumbnails ### Resize the thumbnails
@@ -700,7 +700,7 @@ The cursor selects what it lands on, so walking and judging are one hand's work.
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach. There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
<sub>`ui/dr-ui/ui/library.slint:3536`</sub> <sub>`ui/dr-ui/ui/library.slint:3567`</sub>
### File photographs in a collection ### File photographs in a collection
@@ -710,7 +710,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture. The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
<sub>`ui/dr-ui/ui/library.slint:3735`</sub> <sub>`ui/dr-ui/ui/library.slint:3766`</sub>
### Open a photograph ### Open a photograph
@@ -721,7 +721,7 @@ The selection is what the drag carries, which is why selecting several is worth
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush. A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
<sub>`ui/dr-ui/ui/library.slint:4040`</sub> <sub>`ui/dr-ui/ui/library.slint:4073`</sub>
### Rate a photograph without opening it ### Rate a photograph without opening it
@@ -732,7 +732,7 @@ A tap opens; a tap that *moved* does not. Travel is what separates a deliberate
A star has to take the press without it also reaching the cell, or every rating throws the user into develop. A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
<sub>`ui/dr-ui/ui/library.slint:4163`</sub> <sub>`ui/dr-ui/ui/library.slint:4196`</sub>
### Choose the frame a folded burst shows ### Choose the frame a folded burst shows
@@ -742,7 +742,7 @@ A star has to take the press without it also reaching the cell, or every rating
A folded burst draws its earliest frame, which is a fact about the clock and not a judgement about the photograph — nothing in this application ranks a frame (FR-CULL-5). But the point of a burst is that one of the twelve is better than the other eleven, and the photographer is the only one who knows which. So the choice is offered on the frames themselves, while they are open and side by side, which is the one moment the alternatives are on screen to be compared. A folded burst draws its earliest frame, which is a fact about the clock and not a judgement about the photograph — nothing in this application ranks a frame (FR-CULL-5). But the point of a burst is that one of the twelve is better than the other eleven, and the photographer is the only one who knows which. So the choice is offered on the frames themselves, while they are open and side by side, which is the one moment the alternatives are on screen to be compared.
<sub>`ui/dr-ui/ui/library.slint:4296`</sub> <sub>`ui/dr-ui/ui/library.slint:4329`</sub>
### Drop the selection but keep selecting ### Drop the selection but keep selecting
@@ -753,7 +753,7 @@ A folded burst draws its earliest frame, which is a fact about the clock and not
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away. Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
<sub>`ui/dr-ui/ui/library.slint:4987`</sub> <sub>`ui/dr-ui/ui/library.slint:5022`</sub>
### Select everything the grid is showing ### Select everything the grid is showing
@@ -764,7 +764,7 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it. A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
<sub>`ui/dr-ui/ui/library.slint:5006`</sub> <sub>`ui/dr-ui/ui/library.slint:5041`</sub>
### Take photographs out of a collection ### Take photographs out of a collection
@@ -774,7 +774,7 @@ A scoped grid of two hundred frames is two hundred taps otherwise, and "all of t
The badge on a cell says a photograph is filed in three collections and never which. This is the sheet that names them, and the only way out of one the grid is not currently scoped to. The badge on a cell says a photograph is filed in three collections and never which. This is the sheet that names them, and the only way out of one the grid is not currently scoped to.
<sub>`ui/dr-ui/ui/library.slint:5187`</sub> <sub>`ui/dr-ui/ui/library.slint:5222`</sub>
## Settings ## Settings
+64 -12
View File
@@ -75,6 +75,14 @@ past the window. On a tablet they scroll by flick alone, and a thin line at
the right-hand edge shows where the view is while it moves, fading once it the right-hand edge shows where the view is while it moves, fading once it
stops; it is only a picture, and a flick that starts on it scrolls the list. stops; it is only a picture, and a flick that starts on it scrolls the list.
A panorama gets a wider cell: about twice as wide as it is tall and it spans
two columns, then three, then four for the widest — whichever leaves the least
of the cell empty — with a thumbnail made for that width. One that would not
fit in what is left of a row starts the next, so the grid still reads in the
order the photographs were taken; the arrows walk it in that order, and up and
down go to whatever is above or below. On a tablet, or with too few columns to
put it beside anything, a panorama takes the whole row.
`Help` in the header, or `F1`, opens the controls and shortcuts: every key and `Help` in the header, or `F1`, opens the controls and shortcuts: every key and
gesture, screen by screen, with `See it` beside those this page shows, and gesture, screen by screen, with `See it` beside those this page shows, and
`Manual` to open this page. In develop it is the `?` beside `Settings`. `Manual` to open this page. In develop it is the `?` beside `Settings`.
@@ -176,6 +184,18 @@ Hold `Before` to see the photograph as it was.
![Raising exposure, pulling the highlights, lifting the shadows, then holding Before](media/develop-light.gif) ![Raising exposure, pulling the highlights, lifting the shadows, then holding Before](media/develop-light.gif)
`Tone Mapping`, last in the group, is how the scene is fitted onto the
screen, and it runs after every other adjustment. `White Point` says how many
stops above middle grey reach white — raise it to bring a bright sky back
from white, lower it for a brighter, punchier picture — and `Contrast` sets
the slope of the curve between. Every adjustment above it works on the scene
as the camera recorded it, highlights beyond white included, so pulling the
highlights recovers what the sensor caught rather than what the screen could
show. A JPEG has been fitted to a screen already, by the camera, so on a
JPEG these two do nothing.
![Raising the white point to bring the clouds back from white, lowering it for a brighter picture, raising the contrast, then holding Before](media/develop-tonemap.gif)
### Looking closer ### Looking closer
Double-click for 1:1; drag to move about; double-click again to fit. The Double-click for 1:1; drag to move about; double-click again to fit. The
@@ -269,7 +289,9 @@ opacity are in the panel. Heal blends; clone copies.
The `Film` chooser at the head of Adjust applies a spectral simulation of a The `Film` chooser at the head of Adjust applies a spectral simulation of a
named stock; below it, the print exposure and push controls a film has and a named stock; below it, the print exposure and push controls a film has and a
sensor does not. The list opens over the column and scrolls on its own — by sensor does not. A stock takes the place of `Tone Mapping`: it is the last
thing that happens to the picture, so every other adjustment decides the
exposure the negative receives. The list opens over the column and scrolls on its own — by
wheel, drag or flick, or with `Up`, `Down` and `Enter` — down to the wheel, drag or flick, or with `Up`, `Down` and `Enter` — down to the
black-and-white stocks at its end. black-and-white stocks at its end.
@@ -287,22 +309,32 @@ a gradient over the sky whose `Print Exposure` burns it in.
### History, snapshots, presets ### History, snapshots, presets
Every change is a step; `Undo` and the History panel walk them. `Snapshot` Every change is a step; `Undo` and the History panel walk them. `Snapshot`
keeps the current state under a name. `Presets…` saves the settings to keeps the current state under a name. `Presets`, at the foot of the tool
apply elsewhere, and imports Lightroom presets — `Folder…` for a folder of rail on the left, opens a menu of presets beside the rail, over the photograph, filed in
them, `.xmp file…` for one. folders that start closed: your own under `Yours`, then the ones DarkRoom
ships — `Essentials`, `Skies`, and `Film`, which holds `Colour`, `Cinema`
and `Black and white`, one measured stock each. Choosing a folder opens it;
choosing a preset applies it. The menu's last row, `Save or manage…`, opens
the presets sheet, which lists the same folders and saves the settings to
apply elsewhere, renames and deletes them, and imports Lightroom presets —
`Folder…` for a folder of them, `.xmp file…` for one.
The sheet lists your own presets first, then the ones DarkRoom ships — A `/` in a name files the preset: `Portraits/Warm skin` is `Warm skin` in a
Essentials, Skies, and colour, cinema and black-and-white film, one measured stock `Portraits` folder under `Yours`, and renaming it is how it moves. An
each. A shipped preset is a look: it changes what it names and leaves the imported Lightroom folder keeps its groups the same way.
A shipped preset is a look: it changes what it names and leaves the
photograph's own corrections alone, as an imported Lightroom preset does. photograph's own corrections alone, as an imported Lightroom preset does.
Saving under a shipped preset's name makes your version the one that name Saving under a shipped preset's name makes your version the one that name
applies, marked *changed*; `Revert` brings the shipped one back, and renaming applies, marked *changed*; `Revert` brings the shipped one back, and renaming
yours makes it one of your own. A film preset carries its stock: choosing one yours makes it one of your own. A film preset carries its stock: choosing one
sets the `Film` chooser and leaves the rest of the edit where it was. sets the `Film` chooser and leaves the rest of the edit where it was.
![The presets menu beside the tool rail, with Film and its colour stocks open](media/presets-menu.png)
![The presets sheet: a name for the current edit, the shipped Essentials, importing, and what an apply carries](media/presets.png) ![The presets sheet: a name for the current edit, the shipped Essentials, importing, and what an apply carries](media/presets.png)
![Scrolling down the shipped presets to the black-and-white films, applying Ilford HP5 Plus, then holding Before](media/presets-film.gif) ![Opening Film, then Black and white, in the presets menu and applying Ilford HP5 Plus, then holding Before](media/presets-film.gif)
### Copying settings ### Copying settings
@@ -310,7 +342,7 @@ sets the `Film` chooser and leaves the rest of the edit where it was.
or Ctrl+V, puts them on another, and says what it would paste — how many or Ctrl+V, puts them on another, and says what it would paste — how many
adjustments, and whether the crop comes too. In the grid, `Paste to N` on the adjustments, and whether the crop comes too. In the grid, `Paste to N` on the
selection bar pastes onto every photograph selected. Which kinds of edit a selection bar pastes onto every photograph selected. Which kinds of edit a
copy carries is chosen in `Presets…`, or with Ctrl+Shift+C: a look carried copy carries is chosen in the presets sheet, or with Ctrl+Shift+C: a look carried
across a shoot usually leaves each frame's own crop alone. across a shoot usually leaves each frame's own crop alone.
## Merging a panorama ## Merging a panorama
@@ -321,15 +353,35 @@ outlined where it landed — twelve hand-held portrait frames across an alpine
valley, here. Change the projection (a 150° sweep on a flat perspective is valley, here. Change the projection (a 150° sweep on a flat perspective is
what the middle of the film shows, and why cylindrical is suggested), ask for what the middle of the film shows, and why cylindrical is suggested), ask for
the border to be filled rather than cropped, then `Merge`. The composite is the border to be filled rather than cropped, then `Merge`. The composite is
written beside its sources as a DNG and appears in the grid with the merge written beside its sources as a DNG and is in the grid the moment it is
as the first step in its history. written — in a wide cell beside its frames, placed by when they were taken —
with the merge as the first step in its history. Its thumbnail is made during
the merge, from the finished picture, as develop will show it when you open
it; on a server library it is in the grid while the file is still uploading.
A second merge of the same frames is named `-pano-2`, never written over the
first.
![Twelve frames aligned, the projections tried, and the border filled](media/panorama.gif) Each frame has a box in the `Frames` list. Untick one to leave it out, and
the rest are aligned again at once, without reading the frames again; tick
it to bring it back. A frame that cannot be placed is named there with why,
and `Merge` stays off until it is unticked. Blown sky stays white in the
preview and in the composite.
A panorama is usually far wider than a graphics card can draw in one piece.
It opens in develop all the same, on a reduced copy that is drawn from the
full resolution wherever you zoom in, and it exports at full size. The same
goes for a panorama Lightroom stitched and saved as a DNG.
![A 22 927 × 8966 panorama of a glacier at dusk: zoomed from the whole frame into the peaks, panned along the ridge and back out, then 1:1 on the peaks with a double-click](media/giant-pano.gif)
![Twelve frames aligned, the first left out and brought back, the projections tried, and the border filled](media/panorama.gif)
![The alignment on a cylinder, each frame outlined where it landed](media/panorama-aligned.png) ![The alignment on a cylinder, each frame outlined where it landed](media/panorama-aligned.png)
![The same, with the ragged border filled by the model rather than cropped away](media/panorama-filled.png) ![The same, with the ragged border filled by the model rather than cropped away](media/panorama-filled.png)
![The composite in the grid straight after the merge, spanning four columns beside the twelve frames it was made from](media/panorama-in-grid.png)
## Export ## Export
`Export` in the develop header, or `Export N` from a selection. Format, `Export` in the develop header, or `Export N` from a selection. Format,
+55 -12
View File
@@ -194,6 +194,13 @@ the sidebar, the develop column and Settings have one too whenever they run
past the window. On a tablet they scroll by flick alone, and a thin line at past the window. On a tablet they scroll by flick alone, and a thin line at
the right-hand edge shows where the view is while it moves, fading once it the right-hand edge shows where the view is while it moves, fading once it
stops; it is only a picture, and a flick that starts on it scrolls the list.</p> stops; it is only a picture, and a flick that starts on it scrolls the list.</p>
<p>A panorama gets a wider cell: about twice as wide as it is tall and it spans
two columns, then three, then four for the widest — whichever leaves the least
of the cell empty — with a thumbnail made for that width. One that would not
fit in what is left of a row starts the next, so the grid still reads in the
order the photographs were taken; the arrows walk it in that order, and up and
down go to whatever is above or below. On a tablet, or with too few columns to
put it beside anything, a panorama takes the whole row.</p>
<p><code>Help</code> in the header, or <code>F1</code>, opens the controls and shortcuts: every key and <p><code>Help</code> in the header, or <code>F1</code>, opens the controls and shortcuts: every key and
gesture, screen by screen, with <code>See it</code> beside those this page shows, and gesture, screen by screen, with <code>See it</code> beside those this page shows, and
<code>Manual</code> to open this page. In develop it is the <code>?</code> beside <code>Settings</code>.</p> <code>Manual</code> to open this page. In develop it is the <code>?</code> beside <code>Settings</code>.</p>
@@ -264,6 +271,16 @@ the strip at its head narrows it to one group.</p>
<p>Exposure, contrast, highlights, shadows, blacks, whites and a tone curve. <p>Exposure, contrast, highlights, shadows, blacks, whites and a tone curve.
Hold <code>Before</code> to see the photograph as it was.</p> Hold <code>Before</code> to see the photograph as it was.</p>
<figure><img loading="lazy" src="media/develop-light.gif" alt="Raising exposure, pulling the highlights, lifting the shadows, then holding Before"><figcaption>Raising exposure, pulling the highlights, lifting the shadows, then holding Before</figcaption></figure> <figure><img loading="lazy" src="media/develop-light.gif" alt="Raising exposure, pulling the highlights, lifting the shadows, then holding Before"><figcaption>Raising exposure, pulling the highlights, lifting the shadows, then holding Before</figcaption></figure>
<p><code>Tone Mapping</code>, last in the group, is how the scene is fitted onto the
screen, and it runs after every other adjustment. <code>White Point</code> says how many
stops above middle grey reach white — raise it to bring a bright sky back
from white, lower it for a brighter, punchier picture — and <code>Contrast</code> sets
the slope of the curve between. Every adjustment above it works on the scene
as the camera recorded it, highlights beyond white included, so pulling the
highlights recovers what the sensor caught rather than what the screen could
show. A JPEG has been fitted to a screen already, by the camera, so on a
JPEG these two do nothing.</p>
<figure><img loading="lazy" src="media/develop-tonemap.gif" alt="Raising the white point to bring the clouds back from white, lowering it for a brighter picture, raising the contrast, then holding Before"><figcaption>Raising the white point to bring the clouds back from white, lowering it for a brighter picture, raising the contrast, then holding Before</figcaption></figure>
<h3 id="looking-closer">Looking closer</h3> <h3 id="looking-closer">Looking closer</h3>
<p>Double-click for 1:1; drag to move about; double-click again to fit. The <p>Double-click for 1:1; drag to move about; double-click again to fit. The
wheel zooms to any amount in between. Past 1:1 the file's own pixels are wheel zooms to any amount in between. Past 1:1 the file's own pixels are
@@ -329,7 +346,9 @@ opacity are in the panel. Heal blends; clone copies.</p>
<h3 id="film">Film</h3> <h3 id="film">Film</h3>
<p>The <code>Film</code> chooser at the head of Adjust applies a spectral simulation of a <p>The <code>Film</code> chooser at the head of Adjust applies a spectral simulation of a
named stock; below it, the print exposure and push controls a film has and a named stock; below it, the print exposure and push controls a film has and a
sensor does not. The list opens over the column and scrolls on its own — by sensor does not. A stock takes the place of <code>Tone Mapping</code>: it is the last
thing that happens to the picture, so every other adjustment decides the
exposure the negative receives. The list opens over the column and scrolls on its own — by
wheel, drag or flick, or with <code>Up</code>, <code>Down</code> and <code>Enter</code> — down to the wheel, drag or flick, or with <code>Up</code>, <code>Down</code> and <code>Enter</code> — down to the
black-and-white stocks at its end.</p> black-and-white stocks at its end.</p>
<figure><img loading="lazy" src="media/film.gif" alt="Opening the film list, scrolling it, choosing Velvia, then holding Before"><figcaption>Opening the film list, scrolling it, choosing Velvia, then holding Before</figcaption></figure> <figure><img loading="lazy" src="media/film.gif" alt="Opening the film list, scrolling it, choosing Velvia, then holding Before"><figcaption>Opening the film list, scrolling it, choosing Velvia, then holding Before</figcaption></figure>
@@ -342,25 +361,33 @@ a gradient over the sky whose <code>Print Exposure</code> burns it in.</p>
<figure><img loading="lazy" src="media/film-local.gif" alt="Kodak Ektar 100 on the whole frame, a gradient turned to cover the sky, its Print Exposure raised to burn the sky in, then holding Before"><figcaption>Kodak Ektar 100 on the whole frame, a gradient turned to cover the sky, its Print Exposure raised to burn the sky in, then holding Before</figcaption></figure> <figure><img loading="lazy" src="media/film-local.gif" alt="Kodak Ektar 100 on the whole frame, a gradient turned to cover the sky, its Print Exposure raised to burn the sky in, then holding Before"><figcaption>Kodak Ektar 100 on the whole frame, a gradient turned to cover the sky, its Print Exposure raised to burn the sky in, then holding Before</figcaption></figure>
<h3 id="history-snapshots-presets">History, snapshots, presets</h3> <h3 id="history-snapshots-presets">History, snapshots, presets</h3>
<p>Every change is a step; <code>Undo</code> and the History panel walk them. <code>Snapshot</code> <p>Every change is a step; <code>Undo</code> and the History panel walk them. <code>Snapshot</code>
keeps the current state under a name. <code>Presets…</code> saves the settings to keeps the current state under a name. <code>Presets</code>, at the foot of the tool
apply elsewhere, and imports Lightroom presets — <code>Folder…</code> for a folder of rail on the left, opens a menu of presets beside the rail, over the photograph, filed in
them, <code>.xmp file…</code> for one.</p> folders that start closed: your own under <code>Yours</code>, then the ones DarkRoom
<p>The sheet lists your own presets first, then the ones DarkRoom ships — ships — <code>Essentials</code>, <code>Skies</code>, and <code>Film</code>, which holds <code>Colour</code>, <code>Cinema</code>
Essentials, Skies, and colour, cinema and black-and-white film, one measured stock and <code>Black and white</code>, one measured stock each. Choosing a folder opens it;
each. A shipped preset is a look: it changes what it names and leaves the choosing a preset applies it. The menu's last row, <code>Save or manage…</code>, opens
the presets sheet, which lists the same folders and saves the settings to
apply elsewhere, renames and deletes them, and imports Lightroom presets —
<code>Folder…</code> for a folder of them, <code>.xmp file…</code> for one.</p>
<p>A <code>/</code> in a name files the preset: <code>Portraits/Warm skin</code> is <code>Warm skin</code> in a
<code>Portraits</code> folder under <code>Yours</code>, and renaming it is how it moves. An
imported Lightroom folder keeps its groups the same way.</p>
<p>A shipped preset is a look: it changes what it names and leaves the
photograph's own corrections alone, as an imported Lightroom preset does. photograph's own corrections alone, as an imported Lightroom preset does.
Saving under a shipped preset's name makes your version the one that name Saving under a shipped preset's name makes your version the one that name
applies, marked <em>changed</em>; <code>Revert</code> brings the shipped one back, and renaming applies, marked <em>changed</em>; <code>Revert</code> brings the shipped one back, and renaming
yours makes it one of your own. A film preset carries its stock: choosing one yours makes it one of your own. A film preset carries its stock: choosing one
sets the <code>Film</code> chooser and leaves the rest of the edit where it was.</p> sets the <code>Film</code> chooser and leaves the rest of the edit where it was.</p>
<figure><img loading="lazy" src="media/presets-menu.png" alt="The presets menu beside the tool rail, with Film and its colour stocks open"><figcaption>The presets menu beside the tool rail, with Film and its colour stocks open</figcaption></figure>
<figure><img loading="lazy" src="media/presets.png" alt="The presets sheet: a name for the current edit, the shipped Essentials, importing, and what an apply carries"><figcaption>The presets sheet: a name for the current edit, the shipped Essentials, importing, and what an apply carries</figcaption></figure> <figure><img loading="lazy" src="media/presets.png" alt="The presets sheet: a name for the current edit, the shipped Essentials, importing, and what an apply carries"><figcaption>The presets sheet: a name for the current edit, the shipped Essentials, importing, and what an apply carries</figcaption></figure>
<figure><img loading="lazy" src="media/presets-film.gif" alt="Scrolling down the shipped presets to the black-and-white films, applying Ilford HP5 Plus, then holding Before"><figcaption>Scrolling down the shipped presets to the black-and-white films, applying Ilford HP5 Plus, then holding Before</figcaption></figure> <figure><img loading="lazy" src="media/presets-film.gif" alt="Opening Film, then Black and white, in the presets menu and applying Ilford HP5 Plus, then holding Before"><figcaption>Opening Film, then Black and white, in the presets menu and applying Ilford HP5 Plus, then holding Before</figcaption></figure>
<h3 id="copying-settings">Copying settings</h3> <h3 id="copying-settings">Copying settings</h3>
<p><code>Copy</code> in the top bar, or Ctrl+C, takes this photograph's settings; <code>Paste</code>, <p><code>Copy</code> in the top bar, or Ctrl+C, takes this photograph's settings; <code>Paste</code>,
or Ctrl+V, puts them on another, and says what it would paste — how many or Ctrl+V, puts them on another, and says what it would paste — how many
adjustments, and whether the crop comes too. In the grid, <code>Paste to N</code> on the adjustments, and whether the crop comes too. In the grid, <code>Paste to N</code> on the
selection bar pastes onto every photograph selected. Which kinds of edit a selection bar pastes onto every photograph selected. Which kinds of edit a
copy carries is chosen in <code>Presets…</code>, or with Ctrl+Shift+C: a look carried copy carries is chosen in the presets sheet, or with Ctrl+Shift+C: a look carried
across a shoot usually leaves each frame's own crop alone.</p> across a shoot usually leaves each frame's own crop alone.</p>
<h2 id="merging-a-panorama">Merging a panorama</h2> <h2 id="merging-a-panorama">Merging a panorama</h2>
<p>Select the frames, then <code>Merge to panorama</code> from the selection bar. The <p>Select the frames, then <code>Merge to panorama</code> from the selection bar. The
@@ -369,11 +396,27 @@ outlined where it landed — twelve hand-held portrait frames across an alpine
valley, here. Change the projection (a 150° sweep on a flat perspective is valley, here. Change the projection (a 150° sweep on a flat perspective is
what the middle of the film shows, and why cylindrical is suggested), ask for what the middle of the film shows, and why cylindrical is suggested), ask for
the border to be filled rather than cropped, then <code>Merge</code>. The composite is the border to be filled rather than cropped, then <code>Merge</code>. The composite is
written beside its sources as a DNG and appears in the grid with the merge written beside its sources as a DNG and is in the grid the moment it is
as the first step in its history.</p> written — in a wide cell beside its frames, placed by when they were taken —
<figure><img loading="lazy" src="media/panorama.gif" alt="Twelve frames aligned, the projections tried, and the border filled"><figcaption>Twelve frames aligned, the projections tried, and the border filled</figcaption></figure> with the merge as the first step in its history. Its thumbnail is made during
the merge, from the finished picture, as develop will show it when you open
it; on a server library it is in the grid while the file is still uploading.
A second merge of the same frames is named <code>-pano-2</code>, never written over the
first.</p>
<p>Each frame has a box in the <code>Frames</code> list. Untick one to leave it out, and
the rest are aligned again at once, without reading the frames again; tick
it to bring it back. A frame that cannot be placed is named there with why,
and <code>Merge</code> stays off until it is unticked. Blown sky stays white in the
preview and in the composite.</p>
<p>A panorama is usually far wider than a graphics card can draw in one piece.
It opens in develop all the same, on a reduced copy that is drawn from the
full resolution wherever you zoom in, and it exports at full size. The same
goes for a panorama Lightroom stitched and saved as a DNG.</p>
<figure><img loading="lazy" src="media/giant-pano.gif" alt="A 22 927 × 8966 panorama of a glacier at dusk: zoomed from the whole frame into the peaks, panned along the ridge and back out, then 1:1 on the peaks with a double-click"><figcaption>A 22 927 × 8966 panorama of a glacier at dusk: zoomed from the whole frame into the peaks, panned along the ridge and back out, then 1:1 on the peaks with a double-click</figcaption></figure>
<figure><img loading="lazy" src="media/panorama.gif" alt="Twelve frames aligned, the first left out and brought back, the projections tried, and the border filled"><figcaption>Twelve frames aligned, the first left out and brought back, the projections tried, and the border filled</figcaption></figure>
<figure><img loading="lazy" src="media/panorama-aligned.png" alt="The alignment on a cylinder, each frame outlined where it landed"><figcaption>The alignment on a cylinder, each frame outlined where it landed</figcaption></figure> <figure><img loading="lazy" src="media/panorama-aligned.png" alt="The alignment on a cylinder, each frame outlined where it landed"><figcaption>The alignment on a cylinder, each frame outlined where it landed</figcaption></figure>
<figure><img loading="lazy" src="media/panorama-filled.png" alt="The same, with the ragged border filled by the model rather than cropped away"><figcaption>The same, with the ragged border filled by the model rather than cropped away</figcaption></figure> <figure><img loading="lazy" src="media/panorama-filled.png" alt="The same, with the ragged border filled by the model rather than cropped away"><figcaption>The same, with the ragged border filled by the model rather than cropped away</figcaption></figure>
<figure><img loading="lazy" src="media/panorama-in-grid.png" alt="The composite in the grid straight after the merge, spanning four columns beside the twelve frames it was made from"><figcaption>The composite in the grid straight after the merge, spanning four columns beside the twelve frames it was made from</figcaption></figure>
<h2 id="export">Export</h2> <h2 id="export">Export</h2>
<p><code>Export</code> in the develop header, or <code>Export N</code> from a selection. Format, <p><code>Export</code> in the develop header, or <code>Export N</code> from a selection. Format,
size, colour space, sharpening and naming are in Settings, and apply to every size, colour space, sharpening and naming are in Settings, and apply to every
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

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