Compare commits

...
125 Commits
Author SHA1 Message Date
dtourolle a9dfe66a70 Count the denoise model in the Windows installer's smoke test
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
Build and test / Desktop (Linux) (push) Successful in 1h27m54s
🐳 Windows image / Build and push (push) Successful in 5s
Build and test / windows-image (push) Successful in 5s
Build and test / Layer separation (push) Successful in 33s
Build and test / Android (aarch64) (push) Successful in 24m50s
Build and test / Windows (x86_64, cross) (push) Successful in 55m19s
Build and test / Publish the release (push) Successful in 1m20s
package.sh stages models/denoise beside face, scene and inpaint, and
the smoke test counted only the other three, so 0.21.0's Windows job
failed with "expected 14 model files, installed 15". The count reads
the same directories package.sh copies, as its comment intends.
2026-10-04 02:49:48 -04:00
dtourolle ff0effbfe1 Count the denoise model in the APK's bundled-model list
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m17s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 56s
Build and test / Android (aarch64) (push) Successful in 47m9s
Build and test / android-image (push) Successful in 3s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 1h2m3s
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 30s
Build and test / Windows (x86_64, cross) (push) Failing after 49m14s
Build and test / Publish the release (push) Skipped
96001480 added mosaic-1408.onnx to BUNDLED as a fifteenth entry and
left the array's declared length at 14, so the Android build failed
and 0.21.0 got no release. The workspace gates never compile the
Android crate, which is why nothing before CI saw it.
2026-10-03 22:14:26 -04:00
dtourolle 1a03cb52b4 Release 0.21.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 8m38s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m27s
Build and test / Android (aarch64) (push) Failing after 29m52s
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 1h2m28s
Build and test / windows-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / Layer separation (push) Successful in 30s
Build and test / Windows (x86_64, cross) (push) Failing after 48m48s
Build and test / Publish the release (push) Skipped
2026-10-03 17:06:49 -04:00
dtourolle ff4b30fbaa Link the inference engine for macOS in a zig container
`docker/macos` builds for aarch64-apple-darwin from Linux with
cargo-zigbuild. Zig carries libSystem and the C headers, so tract's SIMD
kernels compile and the engine's test binaries and examples link as Mach-O
arm64 — the check `cargo check --target` could not do, because tract's
build script needs a macOS C compiler. Crates that link an Apple framework
(dr-plat's keyring, and so the app) still need the Xcode SDK and fail at
the link; macos.md says so.
2026-10-03 16:50:37 -04:00
dtourolle c73743394f Add a CoreML rung on macOS
The macOS ladder was the CPU provider alone, with CoreML listed as a gap.
It is now CoreML, then the CPU, then tract — unmeasured, since nobody here
has a Mac, and safe to ship unmeasured because the probe's clock rejects a
CoreML slower than the CPU and `attempt` refuses one that crashes.

- `Rung::CoreMl`, a compiling rung like TensorRT: an ML Program with every
  compute unit allowed, falling back to the CPU until each model's program
  is built. The embedder stays on the CPU, as on the Hexagon (§7).
- The cache is one directory per model and runtime version. CoreML keys a
  model committed from memory on its input and node names, not its
  weights (ONNX Runtime 1.29, coreml_execution_provider.cc), so two
  exports of one architecture would otherwise share a program.
- The fingerprint on macOS is the chip and the OS release, which ships
  CoreML.
- The desktop looks for the runtime in the bundle's Contents/Frameworks
  and Homebrew's prefixes; fetch-desktop-runtime.sh on a Mac downloads
  ONNX Runtime 1.29.0 for Apple silicon, which carries CoreML.

docs/dev/macos.md says what exists, how to build it, and which log lines
to ask a Mac user for.
2026-10-03 16:50:37 -04:00
dtourolle 872e35670c Log like a debug build on macOS, where a Mac user can find it
Nobody working on DarkRoom has a Mac, so every macOS build is in the hands
of someone who can send a log and cannot attach a debugger. Three changes
make that log worth sending:

- The desktop's default filter on macOS is `debug` for every `dr_*` crate,
  the desktop crate and `onnxruntime` (the runtime's own session log).
- The state directory — the log and crash records — is `~/Library/Logs`
  on macOS rather than the `~/.local/state` Finder hides; Console.app
  lists it. Config and data keep the Unix rules.
- A `diagnostic` cargo profile: release plus line tables, so a crash
  record's backtrace reads file:line. On macOS the tables are in the
  `.dSYM` beside the executable, which the bundle must keep.
2026-10-03 16:49:58 -04:00
dtourolle b562d7b1af Stop retrying a provider that took the app down
The probe runs in the app's process, and a provider can fail by aborting
rather than by returning an error — XNNPACK did on SCRFD. A rung that does
that once would do it on every launch, before the first photograph is on
screen.

Every session build above the CPU, the probe's and each background
compile's, now writes what it is attempting to `attempt` in the cache
directory first and removes it after. After two launches in a row that
died inside the same attempt it is refused and recorded — a rung in
`failed`, an engine in the new `refused` — until the fingerprint changes.
Two, not one, because quitting during a TensorRT compile leaves the same
file.
2026-10-03 16:49:58 -04:00
dtourolle 3689b06c35 Send ONNX Runtime's session log to the app's log
A native session's messages went to ONNX Runtime's stdio logger, which is
nowhere once the app is launched from a menu — and what a provider says
while partitioning a graph (nodes taken, operators declined, a library that
failed to load) is most of what a failed rung tells you. Each session now
forwards them to `log` under the target `onnxruntime`: warnings always,
the runtime's info lines at `debug`, its verbose lines at `trace`.
2026-10-03 16:49:58 -04:00
dtourolle 64ea44aefe Stop reading dates at the first sign the server is unreachable
A window of cells whose thumbnails were cached but whose dates were not
sent a header read per cell, and offline each one was three attempts at
a 15 s connect timeout: `is_transient` counts a network error as worth
retrying, and `read_metadata_only` returned a bare bool that could not
say why a read failed. So the grid sat on "reading N dates" for minutes
against a server that was not there, and no banner went up, because
nothing in that loop ever reported the connection.

`read_metadata_only` now returns a `DateRead`: reached, failed, or
offline. An offline error is returned on the first attempt rather than
retried — a dead server answers the second exactly as the first — while
a 423 lock is still retried, which is what the retry was for. The grid's
worker stops on it and sends `Offline`, as its fetch loop already did,
so the banner goes up and the bar stops. The sweep's lanes stop on it
too, one timeout each rather than one per image.
2026-10-03 16:49:49 -04:00
dtourolle 32a4da0e94 Import DEFAULT_CONTRAST only where the tests use it
The calibration commit imported it at module level, where only the
test module reads it; the build warned and clippy's -D warnings
refuses that.
2026-10-03 16:45:02 -04:00
dtourolle 33779a70bd Answer a thumbnail miss with the other stored class before the network
Offline, a grid zoomed past 256px was blank wherever it had not been
zoomed over before. The store was asked only for the exact class the cell
wanted, and the sweep stores only the grid class, so every zoomed cell
missed and went to a server that was not there — with its 256px thumbnail
sitting in the store the whole time. Online it cost the same round trip,
just without the blank cell at the end of it.

The split now tries the other class on a miss. A smaller one stands in
and the fetch for the real class still goes out; a larger one answers
the request outright, since there is nothing a fetch would improve on.
`ThumbnailReady` carries the class its pixels are, and the drain records
that rather than the batch's class, so a stand-in is replaced on the next
reload instead of being counted as served. `already_served` counts a
held large thumbnail as serving the grid class too, so zooming back out
does not re-read the store for pixels already on screen.

The split moves into `split_by_store` so it can be tested without a
worker thread or a network.
2026-10-03 16:44:58 -04:00
dtourolle 185e134ead Describe the measured tone and vibrance in DarkRoom's own terms
The calibration commits named DarkRoom's default curve after another
product and described vibrance as doing what another editor's does at
the same value. The curve is the DNG SDK's reference, so it is called
that; vibrance is scaled to deliver the strength its value names, as
measured against the photographer's earlier exports. Two test names
follow. The measurements and where they came from are unchanged.
2026-10-03 16:41:11 -04:00
dtourolle 7ae1e27810 Make vibrance deliver the strength its value names
Vibrance delivered about a third of its nominal effect. Its falloff measured
saturation on scene-linear values, where an ordinary tan reads as 0.78 and
keeps a twentieth of the effect; and its skin guard halved it wherever red led
green led blue — 38 % of the pixels of the gallery's exports, every warm colour
rather than skin. The Vivid presets lean on vibrance, which is why they added
less colour than their values promised.

Saturation is now judged on display-encoded values, the guard covers skin hues
(about 10-50 degrees, not strongly saturated), and the gain is fitted: on 45
Lightroom exports whose only colour setting was Vibrance (about +24), the
measured-to-nominal scale was 1.9 before and 1.08 at a gain of 1.2, so 1.3.
2026-10-03 15:54:41 -04:00
dtourolle a4ff7ec2b9 Render raws through the DNG reference curve by default, at contrast 1.5
Decides D21 by measurement. The photo gallery holds Lightroom 6 exports of
raws in the library, each carrying its Camera Raw settings; clustered by
those settings, 663 had no look applied. On 60 of them with their raws,
a third held out, the held-out MSE against Lightroom's JPEG was about 1200
for 0.20.0's sigmoid (0.7 EV darker and flatter), 224 for the DNG reference curve
after baseline exposure, and about 140 once its input is bent by 1.5/1.4
about grey.

So the curve choice defaults to the DNG reference, keeping its index (sidecars
record it), and the default contrast is 1.5. Contrast under the DNG reference curve is
now a power relative to REFERENCE_CONTRAST (1.4), where the table is
untouched; the sigmoid at that contrast still matches the retired base
curve. JPEGs are unaffected: the view transform skips a rendered source.
2026-10-03 15:54:21 -04:00
dtourolle b69fb3e191 Give the denoise work's test images a baseline exposure
The learned-denoise branch merged while this one was open, and two of
its test fixtures build a RawImage without the baseline_exposure field
this branch added; zero is the no-op value.
2026-10-03 14:44:48 -04:00
dtourolle 7a09b640d7 Satisfy clippy on the reference tone curve's data
One sample of the ACR3 table is 0.70711, which clippy reads as an
approximation of 1/sqrt(2). It is the curve's published value, so the
lint is allowed on the table with that reason rather than the number
replaced. And the pair-count check uses is_multiple_of.
2026-10-03 14:22:49 -04:00
dtourolle 8294e6b59f Call the reference curve what it is, and drop wording that reads as copying
The view transform's second curve is the DNG SDK's published reference
rendering — the ACR3 default curve applied by RefBaselineRGBTone — so
it is the "DNG Reference" curve in the panel, D21 and the code, not a
name borrowed from another product. Comments and docs that justified a
choice by another editor doing it ("as their Amount", "so a
photographer arriving from it finds the name") now give the actual
reason. The Vivid presets no longer describe themselves as reaching
for another editor's look; they are DarkRoom's own.

Factual mentions stay: which program wrote the library's DNGs, what
was measured against, and preset import. camera-profiles.md gains §15,
on starting a photograph from the edit it already carries.
2026-10-03 14:22:49 -04:00
dtourolle e12783da9f Open a photograph with the edit it already carries, when DarkRoom has none
The library's DNGs carry the photographer's earlier develop settings
in their embedded XMP — the house style their photographs were made
with. A photograph opened with no edit of DarkRoom's now starts from
that earlier edit, translated (HSL bands, highlights, blacks and the
rest), as one undoable step named "Earlier Edit"; from there it is an
ordinary edit, saved with the photograph. Export does the same, so a
photograph never opened exports as opening it would show.

Only on positive evidence that there is no DarkRoom edit: a local file
with no sidecar beside it, or a server that answered "no such file"
with nothing in the cache. The stored-edit fetch now says which
(FetchedSidecar::absent). Offline, unreachable or unreadable never
counts — the earlier edit would otherwise be saved over a real edit
that merely failed to arrive.
2026-10-03 14:22:49 -04:00
dtourolle 013596e1bd Translate Lightroom's HSL panel, and read the edit inside a DNG
The library's DNGs carry a Lightroom house look in their embedded XMP
— Blue +58, Aqua +50, Yellow and Purple +23, Highlights -40, Blacks
-20 on most — and that, not the camera profile, is why the same files
look richer in Lightroom. The importer skipped exactly that part: the
HSL panel was on its list of structures it did not translate.

Lightroom's eight HSL bands now map onto the colour mixer, hue,
saturation and luminance each one for one: Aqua to our cyan and Purple
to our violet, the nearest of our twelve bands by hue; chartreuse,
spring, azure and rose are left alone. The figures are a first
translation that lr-fit's measurement against Lightroom's output may
yet scale.

read_embedded finds the XMP packet in a photograph's bytes by its
delimiters and translates it, or answers None for a file whose XMP has
no Camera Raw settings — darktable's sidecars, a camera's own packet.
A test reads the library's _MG_9080.dng when it is present.
2026-10-03 14:22:48 -04:00
dtourolle 1e8594724e Keep the sigmoid as the default curve; Camera Raw's tone is a choice
The Camera Raw default rested on comparing against Lightroom previews
of photographs that carry the user's Lightroom edits — HSL saturation
Blue +58, Aqua +50 and more, Highlights -40, Blacks -20, in every
DNG's XMP — so it measured the house look, not Camera Raw's base
rendering. Under the ACR3 curve _MG_9080 renders brighter than its
Lightroom preview (mean 0.39 against 0.31).

So the curve choice's first variant, the default, is the sigmoid again
and every raw renders as in 0.20.0 apart from baseline exposure. D21
and camera-profiles.md §12 now say the default is open, to be decided
by measuring against Lightroom exports of unedited photographs. Tests
that are about Camera Raw's tone choose it explicitly.
2026-10-03 14:22:48 -04:00
dtourolle 0730ef1016 Sync camera profiles through the library, and name the tone curves
camera-profiles.md §13: the derived sync pass gains a profiles step,
after the catalog and before the place, that exchanges the profiles
directory with <library>/.darkroom-derived/profiles. Profiles are
immutable and named for what they hold, so name and size decide: it
uploads what the server lacks or holds at another size and downloads
what this device lacks — parsed before it is kept, written beside its
name and renamed — then reloads the set, so a profile copied out of a
DNG on the desktop renders the body's CR2s on the tablet after its
next sync. Like the place it never fails the pass. Not a catalog
table: a schema change would stop an older peer merging at all.

Labels for the view transform's new curve choice: Curve, Camera Raw,
Sigmoid.
2026-10-03 14:22:47 -04:00
dtourolle db7593f3dc Render raws through Camera Raw's tone by default, after baseline exposure
The rendering half of camera-profiles.md §11-§12 (D21). The view
transform gains a curve choice — Camera Raw (the default) or D19's
sigmoid. Camera Raw converts to linear ProPhoto, clips to [0, 1], runs
the curve on the largest and smallest channel and places the middle
one at its old fraction between them (RefBaselineRGBTone), and
converts back: hue kept, saturation raised where the curve is steep,
which is where Adobe Standard's look desaturated. White sets the input
scale (1 at its default, so sensor white is display white) and
contrast bends the input about grey (1 at its default).

The curve rides in the profile buffer after the tables: the profile's
own, else the ACR3 default, which the placeholder every profile-less
source binds also carries — so a CR2 with no .dcp still gets Camera
Raw's tone. Baseline exposure is a gain folded into the rendering
matrix at upload; RawImage::color_matrix stays the file's for the
merge's linear DNG.

camera_raw::apply_reference is the CPU statement; GPU tests hold the
shader to it on 256 colours and on greys against the ACR3 table. The
sigmoid's own tests now choose it explicitly.
2026-10-03 14:22:47 -04:00
dtourolle 38d414912c Read baseline exposure and profile tone curves; carry the ACR3 curve
The decoding half of camera-profiles.md §11-§12. RawImage gains
baseline_exposure: the file's BaselineExposure plus the chosen
profile's BaselineExposureOffset, as the DNG SDK sums them (+0.25 for
the library's 6D DNGs). A profile copied out of a DNG carries that
DNG's baseline as its offset, so the body's CR2s, which have none,
land at the same total.

ProfileTables gains the profile's ProfileToneCurve, resampled at
decode onto 1025 points with a natural cubic spline; an identity curve
counts as none. dr-types now holds Camera Raw's ACR3 default curve,
RawTherapee's adobe_camera_raw_default_curve copied value for value,
for every raw whose profile has no curve. Nothing renders through
either yet.
2026-10-03 14:22:46 -04:00
dtourolle b58873ef57 Spec baseline exposure, Camera Raw tone and profile sync (D21)
camera-profiles.md §11-§14 close what 0.20.0 left open. Baseline
exposure is the file's plus the profile's offset, applied as a gain on
the camera matrix, and a copied profile carries the DNG's baseline so a
CR2 lands at the same brightness. The view transform gains a Camera Raw
curve — the profile's ProfileToneCurve, else the ACR3 default — applied
Camera Raw's way, on the outer channels in linear ProPhoto, and it is
the default for every raw (D21, the user's choice). Profiles sync
through .darkroom-derived/profiles on the server as a step of the
derived sync pass, not as a catalog table.
2026-10-03 14:22:45 -04:00
dtourolle ababd628ed Show AI denoise in the manual, on a night frame with no faces
A section after Looking closer: what it is for, the switch and its wait,
Keep grain, which cameras it takes and where its noise figures come from.
The scene opens the Brooklyn Bridge at ISO 8000 from the face-free demo
set at 1:1, switches it on, waits for the result to land and keeps some
grain, with a still before and after. It waits on the app's own log line
rather than a fixed time: the network takes seconds on a GPU and more on
the CPU, which is where the recording X server leaves it (13 s).
2026-10-03 12:05:26 -04:00
dtourolle 4eb7cf77f5 Record what the learned denoise shipped as, and what was measured
The grain blend replaces the Amount of §7.2, and why its objection to a
blend does not hold for brightness alone; the Hexagon is out (int8 -6 to
-9 dB); §11 holds the data, the noise model taken from the library, the
model, the validation table, the blind estimate's reach and the speed.
2026-10-03 11:51:02 -04:00
dtourolle 960014803a Ship the denoise model in the Arch package, the APK and the Windows installer
Same LFS-pointer guard as the other models; the APK copies it out of its
assets with the rest.
2026-10-03 11:51:01 -04:00
dtourolle dd43f498fb Run the learned denoise in develop, and export with it
A Bayer photograph keeps its mosaic in the session and is offered the AI
Denoise switch. Asked for, the network runs on the decode executor from a
hot-pixel-repaired copy — the app's own pass — with the frame's noise from
its best source, and its progress in the activity bar; the classical
demosaic shows until the result lands, and the finished job says where the
noise figures came from. Keep grain is a GrainBlend of the two, made once
per value; the render draws it as its source and the adjust pass never
knows. demosaiced stays the classical result, so the raw histogram, the
white balance picker, masks and segmentation still read the sensor.

The develop view reconciles on a 250 ms poll rather than on each way an
edit can change (slider, undo, preset, version, a sidecar from another
device): two comparisons when nothing changed, and no path that can forget.
A failure is not retried until the switch is toggled. An export of a
photograph that asks for it waits for a running job or computes it.
2026-10-03 11:51:00 -04:00
dtourolle ad6bb892f3 Carry the learned denoise's switch and grain as edit settings
Whether to use the learned denoise, and how much grain to keep, are what a
photographer sets, so they travel the one road every setting does: published
as a capability, captured by Preset, stored in the sidecar, replayed by the
undo stack (FR-DEV-3c). Published only on a photograph that can take it, for
the lens switch's reason; the availability is derived from the file and is
not in the state. Off by default, grain 0; a reset returns both.
2026-10-03 11:22:29 -04:00
dtourolle 8ea3c3181a Upload the learned demosaic's result, and blend grain back into it
DemosaicedImage::from_rgb_f32 takes the network's linear camera RGB and
stands it beside the classical source of the same photograph: the matrix,
profile tables and as-shot balance are that source's, the id is new, so
nothing downstream can tell which demosaic ran and every cache keyed on the
source sees a new one.

GrainBlend is the denoise's live control. It returns only the brightness of
the noise the network removed, taken after the as-shot balance and handed
back divided by it, so the grain is neutral in the finished picture; colour
speckle and demosaic false colour stay out. It writes a new source rather
than adding a term to the adjust shader: the blend depends on two images and
one number, a 20 MP pass is milliseconds, and a fresh source id is all the
adjust pass's caches need. The test reads it back: at 0 the network's
result, at 1 the same white-balanced step in every channel.
2026-10-03 11:20:48 -04:00
dtourolle d8304d7c82 Add dr-denoise: the learned demosaic and denoise, without the UI
The noise model takes the best source the frame has: the body's measured
table (the Canon EOS 6D's, from the library), the DNG's NoiseProfile, or
the frame itself — read, row and column noise from its masked border, and
only the shot gain estimated, from the quietest flat patches. Checked on
130 6D frames, the estimate is within 10 % from ISO 1000 up; the network
loses under 0.3 dB for a sigma off by 15-20 %, so every Bayer body is
eligible.

Tiles of 1408 keep their central 1024 behind a 192-photosite halo, past the
185-photosite receptive field, and the frame is extended by reflection,
which keeps every photosite's colour; a pattern that starts on another
colour is read from one photosite up or left so the network sees RGGB, and
nothing is cropped. The tests run every Bayer phase, tiled against whole,
with a stand-in network of known reach.

The model ships as models/denoise/mosaic-1408.onnx (LFS), trained in
darkroom-denoise on the maintainer's own photographs, GPL like the code.
denoise_raw runs a file end to end: on a 6D frame at ISO 8000 the result
matches the training repository's own path to 2.5e-4 at worst, and takes
3.1 s on TensorRT fp16 (75 dB from f32) or 14.4 s on the CPU.
2026-10-03 11:15:50 -04:00
dtourolle 20b7bd7663 Feed every input a model declares when probing a rung
The probe built one zero tensor from the first input and ran the session
with it. Every model so far had one input; the denoiser has two (mosaic and
sigma), so every rung failed with "Missing Input: sigma" and the role was
left on the CPU: 14.4 s for a 20 MP frame where TensorRT fp16 takes 3.1 s.
Zeros now go to each input by name.
2026-10-03 11:15:49 -04:00
dtourolle 6b0d29cc15 Read a DNG's NoiseProfile
The converter's measured noise for the body at that ISO, (S, O) per CFA
plane: the learned denoise's best source for a body with no table of its
own (denoise.md §3.3). Read from the header beside the colour tags, and
printed by rawinfo. Checked against tifffile on a 6D DNG at ISO 5000: all
six values agree.
2026-10-03 10:39:15 -04:00
dtourolle eb91fa02c2 Give the inference engine a denoiser role, kept off the Hexagon
The learned demosaic-and-denoise (denoise.md) runs through the engine like
every other model. fp16 cost it nothing measurable (0.00 dB at every ISO on
validation tiles), so it takes TensorRT's and MIGraphX's fp16 like the
detectors. int8 cost it 6 to 9 dB, far past a 0.5 dB gate, so the Hexagon
refuses the role outright rather than relying on no int8 sibling existing,
and the tablet runs it on the CPU.
2026-10-03 10:39:14 -04:00
dtourolle d4248bc0dd Run the app's hot-pixel pass alone, and dump through it
The learned demosaic replaces the classical one and takes its input, the
mosaic hot_pixels.wgsl leaves (denoise.md §2), so its training data and its
input in the app must come through that pass and not a lookalike. The pass
was recorded inline in Demosaicer::run; it is now built by hot_pass and
recorded by record_hot_pass, which run still uses unchanged, and
Demosaicer::repair_hot_pixels runs it on its own and reads the mosaic back.

mosaic_dump moves to dr-gpu to call it, records how many photosites changed,
and keeps --unrepaired for a raw readout.
2026-10-03 10:25:28 -04:00
dtourolle 1f266a4478 Dump RAW mosaics for training the learned denoise
denoise.md §4.4 requires the training repo to read photosites through
dr-decode, not LibRaw, so black and white levels, the active area and the
CFA phase match what the app will feed the network. mosaic_dump reads
`input<TAB>prefix` lines and writes the whole readout as .npy plus a JSON
of what decode and metadata report. The masked border is kept: its
optically black photosites are a free dark frame for the noise profile.
2026-10-03 10:25:27 -04:00
dtourolle 2fad846cd1 Release 0.20.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 9m1s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m25s
Build and test / Android (aarch64) (push) Successful in 48m24s
Build and test / android-image (push) Successful in 4s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / Desktop (Linux) (push) Successful in 1h22m17s
Build and test / windows-image (push) Successful in 3s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / Layer separation (push) Successful in 37s
Build and test / Windows (x86_64, cross) (push) Successful in 55m30s
Build and test / Publish the release (push) Successful in 1m27s
2026-10-02 23:07:52 -04:00
dtourolle 5c26dc5033 Record what 0.20.0 closes and leaves open in FR-DEV-3e
The DCP half that outstanding.md listed as deferred is built (D20).
What stays open is the profiles directory, which does not sync, and
the profile tone curve and baseline exposure, which are read and not
applied — and which camera-profiles.md §1 measured as where the rest of
the gap to Lightroom's colour is.
2026-10-02 23:06:17 -04:00
dtourolle abefb94daa Give the export-ignores-the-viewport tests the viewport zoom now takes
0b06e31b ("Let a zoomed view fill the viewport...") added the
viewport's size to DevelopSession::zoom_about and left this
integration test calling it with three arguments, so dr-ui's tests did
not compile. The photograph here is 64×64; a 64×64 viewport keeps the
zoomed view the tests were written against.
2026-10-02 22:44:17 -04:00
dtourolle 54772f94d6 Correct what D20 claimed the profile tables would do for colour
Measured after building them, on four of the library's 6D DNGs: Adobe
Standard's tables lower mean saturation by 3-9 % at defaults, and the
look at 200 % lowers it further. The 6D's look table scales saturation
by 0.925 in its darkest value rows; it was tuned to sit under Camera
Raw's default RGB tone curve, which DarkRoom does not apply, and the
dark-tone desaturation is what is left without it. On _MG_9080 the
Lightroom preview measures 0.49, the matrix alone 0.38, the profile
0.35.

So the spec's "that gap is most of why the same file looks flatter"
was wrong: the gap is tone. The tables stay — they put each hue where
Adobe put it — and the spec, D20 and the Vivid file now say so.
"Stronger camera look" is removed: a stronger Adobe Standard look is a
less saturated picture, the opposite of its name. The Vivid presets,
measured at 0.40-0.46 on the same frame, are what answers "more
colourful" today.
2026-10-02 22:38:07 -04:00
dtourolle 65c1f1a468 Let the develop example render the profile off, the look doubled, or a preset
Diagnostic only. "matrix" switches the camera profile off and
"look200" doubles its look, so a DNG's tables can be judged against
the matrix render; "preset:<name>" applies a shipped preset as the
menu does. The example now renders through render_detailed, the path
every frontend takes, because a preset with clarity in it composes a
detail stage that plain render refuses.
2026-10-02 22:38:07 -04:00
dtourolle cb7ad0bbe7 Ship a Vivid section of presets
Five looks for "more colourful than the default": Vivid, Vivid strong,
Vivid landscape, Vivid warm and Vivid portrait. They lean on vibrance,
which lifts muted colours most and holds skin back, and use saturation
sparingly on top; landscape and portrait work the colour mixer's bands
so foliage and sky get richer while skin does not. A sixth, Stronger
camera look, pushes the camera profile's look table to 175 %, about the
step from Adobe Standard to Adobe Vivid, and only moves vibrance where
a photograph has no profile.

All change only what they name, so they keep a corrected exposure or
white balance, and the shipped-preset tests bound every key and value.
2026-10-02 22:38:07 -04:00
dtourolle eae720ce75 Say which camera profile a photograph renders through, and offer to copy it
The info panel gains a line under the lens: "Adobe Standard · in the
file", the .dcp it came from, "· off" when the photographer switched it
off, or "No camera profile · matrix only" — the ordinary case for a
CR2, worded as a fact rather than a failure. A DNG whose embedded
profile may be copied, for a body with no installed profile, also gets
"Use this profile for every Canon EOS 6D →", which saves it into the
profiles directory; the body's CR2s render through it from their next
decode.

The profiles directory is <data>/profiles, read at start-up on desktop
and Android before anything decodes. The library open path never set
the lens line; it now sets both. Labels: Camera Profile, Use Profile,
Look Amount.
2026-10-02 22:38:07 -04:00
dtourolle c02b401a9a Apply a camera profile's HueSatMap and LookTable after exposure
The second half of D20: a camera_profile scene operation at order 25
that converts working colour into linear ProPhoto, runs the DNG SDK's
HSV lookup through the HueSatMap and then the LookTable, and converts
back. Hue and saturation do not change under the uniform gains before
it, so a 2.5-D HueSatMap gives the same answer as straight after the
matrix, and the look sees the photographer's exposure as it does in
the SDK. Two departures for scene-referred values: value is not
clamped on the way out, and a colour outside ProPhoto passes through.

The operation holds only the switch (on by default) and a look
strength of 0-200 %. It is composed while the switch is on — a new
Operation::composes() separates "does something" from "moved from the
defaults", so an untouched raw renders through its profile and still
writes nothing. The tables come from the source: dr-gpu uploads the
ones DemosaicedImage carries into a storage buffer at @binding(8),
whose two-entry header tells the fragment whether there is anything to
apply, and binds a header of zeros for every other source.

apply_reference is the lookup on the CPU. The GPU test holds the
shader to it over 256 colours, through synthetic tables strong enough
that a wrong index shows, and through the library's real Adobe
Standard tables when the 6D DNG is present.
2026-10-02 22:38:06 -04:00
dtourolle f6a3f3f4e2 Read DCP camera profiles: embedded in a DNG, or a .dcp beside the app
The first half of D20. dr-decode now finds a camera profile's HueSatMap
and LookTable in the order camera-profiles.md §4 gives: the profile a
DNG embeds, then a .dcp in the profiles directory whose
UniqueCameraModel names the body, then none. A .dcp brings its own
matrices, since its tables were measured against its forward matrix.

The HueSatMap is blended for the frame's colour temperature with the
same mired weight the matrices use, once per decode, and the result
rides on RawImage as profile_tables beside color_matrix, so every path
that renders a decoded file gets the same profile without a setter to
forget. Nothing applies the tables yet.

A profile whose embed policy allows copying can be written back out as
a .dcp (rawler's TIFF writer with the RC magic patched in), which is how
the library's 6D CR2s will get the Adobe Standard their DNGs carry. The
table type lives in dr-types because decode, pipeline and GPU all need
its layout. Tests read the library's 6D DNG when it is present.
2026-10-02 22:38:06 -04:00
dtourolle 05ac2416c6 Spec DCP camera profiles (D20)
The library's Canon 6D DNGs were written by Lightroom and embed Adobe
Standard with its HueSatMap and LookTable; DarkRoom renders them through
the matrix alone, which is most of why the same file looks flatter here
than in Lightroom.

camera-profiles.md designs the deferred half of FR-DEV-3e: the tables
applied by a camera_profile scene operation after exposure, the profile
taken from the DNG or from a matched .dcp, tables carried with the
decoded image like the matrix, a look-strength control, and copying an
embedded profile out where its policy allows. FR-DEV-3e gains item 4
and D20 records the placement and what was rejected.
2026-10-02 22:37:58 -04:00
dtourolle 0b06e31bf3 Let a zoomed view fill the viewport rather than keep the photograph's shape
The view was the same fraction of each axis, so it kept the frame's
aspect at every zoom: a portrait zoomed on a landscape screen stayed a
portrait strip with the screen's sides empty. Each axis now shows as
much of the frame as the viewport holds at that magnification, capped
at the whole frame, and the render is fitted to the viewed region
rather than to the frame. A redraw re-cuts a zoomed view about its
centre when the viewport or the crop changes shape.
2026-10-02 22:29:48 -04:00
dtourolle d5c93ae795 Step to a library photograph without flashing frames in between
Benchmarks / Frame budget (on demand) (push) Canceled after 0s
Benchmarks / CPU and I/O (per commit) (push) Canceled after 5s
Traceability / Requirement traces (push) Canceled after 0s
Build and test / Desktop (Linux) (push) Successful in 1h21m7s
Build and test / Layer separation (push) Successful in 46s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Successful in 47m42s
Build and test / Windows (x86_64, cross) (push) Successful in 54m42s
Build and test / Publish the release (push) Successful in 52s
A step along the roll showed up to four pictures: the grid thumbnail,
the previous photograph again (the thumbnail was dropped when the bytes
landed, while the canvas still held the last texture through the decode
and first render), the new one at its defaults when a cached original
beat the sidecar, and then its edit.

The thumbnail now stays up until render_now draws the new photograph's
first frame, and that first frame waits up to 400 ms for the stored edit
before drawing at the defaults. A later arrival still redraws.
2026-10-02 20:46:05 -04:00
dtourolle 379dd1afcc Keep a late sidecar off the next photograph
A stored edit that arrived after the view had stepped on was applied to
whatever session was open by then — the next photograph's. The wait now
stops once the open it belongs to is no longer the current one.
2026-10-02 20:44:43 -04:00
dtourolle 825c5af20a Release 0.19.4
Benchmarks / Frame budget (on demand) (push) Canceled after 0s
Benchmarks / CPU and I/O (per commit) (push) Canceled after 5m49s
Traceability / Requirement traces (push) Canceled after 0s
Build and test / android-image (push) Canceled after 0s
🐳 Android image / Build and push (push) Canceled after 0s
Build and test / Android (aarch64) (push) Canceled after 0s
Build and test / windows-image (push) Canceled after 0s
🐳 Windows image / Build and push (push) Canceled after 0s
Build and test / Windows (x86_64, cross) (push) Canceled after 0s
Build and test / Layer separation (push) Canceled after 0s
Build and test / Publish the release (push) Canceled after 0s
Build and test / Desktop (Linux) (push) Canceled after 17s
2026-10-02 20:40:01 -04:00
dtourolle 23a2f13b46 Apply the collision policy to exports bound for the server
A queued export could not see the server, so its name check always
answered "free" and the upload PUT over whatever was there: Increment
and Skip behaved as Overwrite on Nextcloud, and two exports of the same
name queued before either uploaded landed on one file.

The batch now names around what the album records of earlier exports
and what the outbox already holds for that folder. The outbox record
carries the policy, and the drain lists each destination folder once
and applies it against what the server holds: Increment steps past a
taken name and re-points the album's row, Skip drops the entry. A
record without a policy (older builds, a merge's composite) is sent as
named, as before. The album is recorded before the drain starts so a
rename has a row to move.
2026-10-02 19:40:27 -04:00
dtourolle 2c947430e6 Release 0.19.3
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m26s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m9s
Build and test / Android (aarch64) (push) Successful in 30m44s
Build and test / android-image (push) Successful in 3s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 49m45s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 35s
Build and test / Windows (x86_64, cross) (push) Successful in 36m12s
Build and test / Publish the release (push) Successful in 1m12s
2026-09-30 22:46:17 -04:00
dtourolle 36556729f1 Let Back close the presets menu instead of the application
The presets menu at the foot of the tool rail is a PopupWindow, and
showing a popup takes focus off the develop view until it closes. The
menu held nothing focusable, so Android's Back gesture, pressed to
dismiss it, found no focus item, went unanswered, and the platform
closed the application. Slint closes a popup on Escape by itself but
not on Back.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The manual says where presets are now and gains a picture of the open
menu. The scenes reach the sheet through the menu and aim the manage
row from the rail's entry, as the film list's rows are aimed, because
the automation reports a popup's elements in the popup's coordinates.
2026-09-27 16:08:33 -04:00
dtourolle e22128c16b Release 0.18.2
Benchmarks / CPU and I/O (per commit) (push) Successful in 5m34s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m0s
Build and test / Android (aarch64) (push) Successful in 30m28s
Build and test / android-image (push) Successful in 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 47m53s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 34s
Build and test / Windows (x86_64, cross) (push) Successful in 36m29s
Build and test / Publish the release (push) Successful in 1m10s
2026-09-27 08:23:04 -04:00
dtourolle 81ea9359bc Re-record the manual for 0.18.2
Every scene, recorded from this commit's desktop build. Since the last
recording (0.17.0) contrast flattens toward grey, a mask layer's
settings apply as offsets, and the film's settings are per-pixel, which
touch the develop, film, local and preset pictures; `--changed` could
not be trusted after the rebases, so nothing was left out.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- dr-film bakes no slider. Exposure is a gain in the shader; push
  interpolates the stock's measured processes, one curve row each; the
  print is split at the paper's log exposure, so print exposure is an
  addition between two lookups and exact at any setting. The enlarger
  stays balanced at the photograph's exposure.
- film_sim reads all four settings as uniforms, format one-hot over a
  grain count per format, so every uniform is linear in what it does.
- Operation::blends_settings lets the composer average each overlapping
  layer's uniforms with the global ones by mask weight, the global
  setting taking whatever weight the layers leave, and run the fragment
  once. Three layers at full weight give the mean of their settings.
- The stock picker is hidden on a layer. Only the photograph's exposure
  re-solves the print balance; push, print exposure and format need no
  rebake at all now.
2026-09-26 23:29:13 -04:00
1201 changed files with 91977 additions and 3755 deletions
+14 -1
View File
@@ -157,6 +157,19 @@ jobs:
- name: Test - name: Test
run: cargo test --workspace run: cargo test --workspace
# The runner has one 99 GB disk shared with its images, and this job
# ended at 92 GB (2.1 GB free) on v0.18.0's run; v0.18.1's release
# build then died with "No space left on device". The test executables
# and examples in target/debug are the largest things in it, are never
# reused (a changed source relinks them) and are not what the cache is
# for — the dependency rlibs are — so they go before the release build
# rather than competing with it.
- name: Free the test binaries before the release build
run: |
find target/debug/deps -maxdepth 1 -type f -executable -delete
rm -rf target/debug/examples target/debug/incremental
df -h /workspace 2>/dev/null || df -h .
- name: Build - name: Build
run: cargo build --workspace --release run: cargo build --workspace --release
@@ -477,7 +490,7 @@ jobs:
# As many files as package.sh stages: everything but the READMEs in # As many files as package.sh stages: everything but the READMEs in
# the directories it copies. A literal here went stale the first # the directories it copies. A literal here went stale the first
# time a model was added. # time a model was added.
WANT=$(find models/face models/scene models/inpaint -maxdepth 1 -type f ! -name README.md | wc -l) WANT=$(find models/face models/scene models/inpaint models/denoise -maxdepth 1 -type f ! -name README.md | wc -l)
GOT=$(ls "$INST/models" | wc -l) GOT=$(ls "$INST/models" | wc -l)
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT model files, installed $GOT"; exit 1; } [ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT model files, installed $GOT"; exit 1; }
# The manual, and every picture it shows, counted the same way. # The manual, and every picture it shows, counted the same way.
+12 -2
View File
@@ -148,9 +148,9 @@ screen looks like, [`tools/manual`](tools/manual/README.md) says how to record
it again. The pre-commit hook regenerates the matrix, the gesture book and the it again. The pre-commit hook regenerates the matrix, the gesture book and the
page; CI runs all three checks. page; CI runs all three checks.
## Two invariants the build defends ## Three invariants the build defends
Worth knowing before you trip one, because both failures name a requirement Worth knowing before you trip one, because each failure names a requirement
rather than a line: rather than a line:
- **No operation may be named in `ui/`** (FR-DEV-3a). Special-casing one - **No operation may be named in `ui/`** (FR-DEV-3a). Special-casing one
@@ -162,6 +162,16 @@ rather than a line:
`order:`, a filename disagreeing with its `id:`, a default outside its own `order:`, a filename disagreeing with its `id:`, a default outside its own
range, an expression naming something that is not a parameter. Each error range, an expression naming something that is not a parameter. Each error
names the key you got wrong and exits rather than panicking. names the key you got wrong and exits rather than panicking.
- **No verdict is written without a user action** (FR-CULL-13). A rating,
flag, colour label or trash membership is the photographer's to set, never a
signal's. `tools/traceability/src/verdicts.rs` finds every write of one in
the shipped code — the catalog setters, SQL that assigns those columns, the
sidecar's judgement amendment — and holds each to a reviewed list with its
reason: inside a Slint `on_*` callback, writing for callers that are checked
in turn, or carrying a verdict made elsewhere, such as a sidecar pull or the
sync merge. A new write fails `cargo test` (the `traceability` crate's tests,
part of the workspace run) until it is listed, and so does a listed one that
has gone; `cargo run -p traceability -- verdicts` prints the list.
## Commit messages ## Commit messages
Generated
+45 -27
View File
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]] [[package]]
name = "darkroom-android" name = "darkroom-android"
version = "0.18.1" version = "0.21.0"
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.1" version = "0.21.0"
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.1" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-catalog", "dr-catalog",
@@ -1471,7 +1471,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-catalog" name = "dr-catalog"
version = "0.18.1" version = "0.21.0"
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.1" version = "0.21.0"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"env_logger", "env_logger",
@@ -1498,9 +1498,26 @@ dependencies = [
"zune-jpeg 0.4.21", "zune-jpeg 0.4.21",
] ]
[[package]]
name = "dr-denoise"
version = "0.21.0"
dependencies = [
"dr-decode",
"dr-gpu",
"dr-inference-engine",
"env_logger",
"log",
"ndarray",
"ort",
"pollster",
"serde",
"serde_norway",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "dr-export" name = "dr-export"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-gpu", "dr-gpu",
@@ -1519,7 +1536,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-face" name = "dr-face"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1532,7 +1549,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-film" name = "dr-film"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -1541,7 +1558,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-gpu" name = "dr-gpu"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"dr-decode", "dr-decode",
@@ -1559,7 +1576,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-inference-engine" name = "dr-inference-engine"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"env_logger", "env_logger",
"libloading", "libloading",
@@ -1574,7 +1591,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ingest" name = "dr-ingest"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"dr-plat", "dr-plat",
"dr-types", "dr-types",
@@ -1586,7 +1603,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-lens" name = "dr-lens"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"lensfun", "lensfun",
"log", "log",
@@ -1594,7 +1611,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pano" name = "dr-pano"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-inference-engine", "dr-inference-engine",
@@ -1608,7 +1625,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pipeline" name = "dr-pipeline"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -1617,7 +1634,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-plat" name = "dr-plat"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"android-native-keyring-store", "android-native-keyring-store",
"dr-types", "dr-types",
@@ -1633,7 +1650,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-preset-xmp" name = "dr-preset-xmp"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"dr-pipeline", "dr-pipeline",
"log", "log",
@@ -1643,7 +1660,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-segment" name = "dr-segment"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"dr-inference-engine", "dr-inference-engine",
"env_logger", "env_logger",
@@ -1656,7 +1673,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync" name = "dr-sync"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-plat", "dr-plat",
@@ -1670,7 +1687,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-folder" name = "dr-sync-folder"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-sync", "dr-sync",
@@ -1682,7 +1699,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-nextcloud" name = "dr-sync-nextcloud"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-decode", "dr-decode",
@@ -1704,7 +1721,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-thumbs" name = "dr-thumbs"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"jpeg-encoder", "jpeg-encoder",
@@ -1716,7 +1733,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-types" name = "dr-types"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -1725,12 +1742,13 @@ dependencies = [
[[package]] [[package]]
name = "dr-ui" name = "dr-ui"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"dr-catalog", "dr-catalog",
"dr-decode", "dr-decode",
"dr-denoise",
"dr-export", "dr-export",
"dr-face", "dr-face",
"dr-film", "dr-film",
@@ -1773,7 +1791,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-xmp" name = "dr-xmp"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -5513,8 +5531,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,12 +7125,14 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]] [[package]]
name = "traceability" name = "traceability"
version = "0.18.1" version = "0.21.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"proc-macro2",
"pulldown-cmark", "pulldown-cmark",
"serde", "serde",
"serde_json", "serde_json",
"syn 2.0.119",
] ]
[[package]] [[package]]
+28 -6
View File
@@ -5,6 +5,7 @@ members = [
"core/dr-catalog", "core/dr-catalog",
"core/dr-thumbs", "core/dr-thumbs",
"core/dr-decode", "core/dr-decode",
"core/dr-denoise",
"core/dr-export", "core/dr-export",
"core/dr-face", "core/dr-face",
"core/dr-film", "core/dr-film",
@@ -32,7 +33,7 @@ members = [
exclude = ["third_party"] exclude = ["third_party"]
[workspace.package] [workspace.package]
version = "0.18.1" version = "0.21.0"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
@@ -44,6 +45,7 @@ dr-types = { path = "core/dr-types" }
dr-catalog = { path = "core/dr-catalog" } dr-catalog = { path = "core/dr-catalog" }
dr-thumbs = { path = "core/dr-thumbs" } dr-thumbs = { path = "core/dr-thumbs" }
dr-decode = { path = "core/dr-decode" } dr-decode = { path = "core/dr-decode" }
dr-denoise = { path = "core/dr-denoise" }
dr-export = { path = "core/dr-export" } dr-export = { path = "core/dr-export" }
# Stated explicitly for the same reason as `dr-segment` below: no dependant # Stated explicitly for the same reason as `dr-segment` below: no dependant
# should drag in an ONNX runtime by accident. Members opt in with # should drag in an ONNX runtime by accident. Members opt in with
@@ -134,6 +136,12 @@ serde_json = "1"
# Slint's Markdown parser, so this adds a dependency edge and no crate; only # Slint's Markdown parser, so this adds a dependency edge and no crate; only
# the HTML writer is needed, not the command-line front end. # the HTML writer is needed, not the command-line front end.
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
# The verdict-writer check (tools/traceability, FR-CULL-13) reads Rust as Rust:
# a text scan cannot tell a call from a comment, a test module from shipped
# code, or which callback closure a call sits in. Both already in the tree as
# every proc macro's parser; `span-locations` gives a problem its line.
syn = { version = "2", default-features = false, features = ["full", "parsing", "visit", "printing"] }
proc-macro2 = { version = "1", default-features = false, features = ["span-locations"] }
base64 = "0.23" base64 = "0.23"
# Display-server clients, for FR-DSP-8's per-display profile acquisition. # Display-server clients, for FR-DSP-8's per-display profile acquisition.
@@ -270,11 +278,25 @@ 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 # A release build that can say where it panicked: line tables, so a crash
# with wgpu on a rotated display (technical-debt.md TD-1). Both are exact # record's backtrace (`dr_plat::crash`) reads `file.rs:123` rather than bare
# copies of the version the lockfile already resolves, plus that patch; # addresses. The macOS build uses it (docs/dev/macos.md) — no one here can
# third_party/README.md says what was changed and how to carry it forward # reproduce a Mac bug, so its reports carry what a debugger would have — at
# when Slint or wgpu moves. # the price of a larger binary and no slower code. On macOS the tables land
# in a `.dSYM` beside the executable (rustc's default `packed`), and the
# bundle must carry that directory next to the binary for the backtrace to
# find it.
[profile.diagnostic]
inherits = "release"
debug = "line-tables-only"
# Three upstream crates carry a local patch: wgpu-hal and Slint's Skia
# renderer so that the Android build can draw with wgpu on a rotated display
# (technical-debt.md TD-1), and rawler so that a linear DNG wider than 16 700
# pixels decodes. Each is an exact copy of the version the lockfile already
# resolves, plus its patch; third_party/README.md says what was changed and
# how to carry it forward when Slint, wgpu or rawler moves.
[patch.crates-io] [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" }
+135 -27
View File
@@ -25,23 +25,34 @@ dated folder and a backup beside it — is found, proved the same, and folded
onto one copy with the spares in the trash. Face detection and identity, 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. Focus peaking and a raw histogram for judging to the screen, with a contrast and a white point of its own; a spectral film
what is recoverable. Presets, with a collection shipped in the application — stock takes its place when one is chosen. Crop, straighten and correct
converging verticals, spot repair, and local adjustments over masks the
model draws — click a subject or a category, then paint, subtract a gradient
or keep only where two selections agree, grow or shrink the edge. A mask's
sliders add to the photograph's, the film's among them, so a sky can be
burned in on the print as a darkroom printer would. Hot and dead photosites
are mended before the demosaic, with nothing to set. Focus peaking and a raw
histogram for judging what is recoverable. Presets, a click away in a menu
at the foot of the tool rail, with a collection shipped in the application —
everyday corrections, and a look for each measured colour, cinema and 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)
@@ -72,40 +83,137 @@ texture directly — no readback between the GPU and the screen.
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned | | Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned |
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; folders are chosen through the portal, but no Flatpak has been built to prove it | | Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; folders are chosen through the portal, but no Flatpak has been built to prove it |
Or build it. Git LFS is required for the model weights, and the toolchain ## Building from source
pins itself to 1.92.0:
**Before anything.** Git LFS holds the model weights and the manual's
pictures; a clone without it has ~130-byte pointers in their place, and every
packager below refuses to ship one. The Rust toolchain pins itself to 1.92.0
through `rust-toolchain.toml`, so rustup is all you install. Slint needs a few
system headers, and the app needs a Vulkan driver at runtime:
```bash ```bash
git clone https://gitea.tourolle.paris/dtourolle/DarkRoom.git && cd DarkRoom
git lfs install && git lfs pull git lfs install && git lfs pull
# Debian / Ubuntu
sudo apt-get install pkg-config libfontconfig1-dev libxkbcommon-dev libvulkan1
# Arch
sudo pacman -S --needed pkgconf fontconfig libxkbcommon vulkan-icd-loader
```
**To try it** from the checkout, without installing anything:
```bash
cargo run --release -p darkroom-desktop cargo run --release -p darkroom-desktop
``` ```
Android, through the containerised toolchain ([docker/android](docker/android/README.md)): This is for development. The binary under `target/` finds no face, scene or
panorama-fill models, and a release build does not find the manual either:
it looks for all of them in the system data directories an install creates
(`$XDG_DATA_DIRS/darkroom`, by default `/usr/local/share/darkroom` and
`/usr/share/darkroom`), never in the checkout. Those features show as
unavailable until it is installed.
### Linux: build and install
**On Arch**, build a package from the checkout and install it with pacman,
so it can be upgraded and removed like any other:
```bash ```bash
./docker/android/build.sh cargo ndk -t arm64-v8a build --release cd packaging && makepkg -si
``` ```
[CONTRIBUTING.md](CONTRIBUTING.md) has the system packages, the four **Elsewhere**, build the release binary and install it under `/usr/local`
by hand. These are the same files, in the same places, as the Arch package
([`packaging/PKGBUILD`](packaging/PKGBUILD)'s `package()` is the reference):
```bash
cargo build --release --locked -p darkroom-desktop
# -> target/release/darkroom-desktop
P=/usr/local
sudo install -Dm755 target/release/darkroom-desktop $P/bin/darkroom-desktop
# The models: faces and eye state, scene categories, panorama border fill
sudo install -d $P/share/darkroom/models
sudo install -m644 models/face/*.onnx models/scene/* models/inpaint/*.onnx \
$P/share/darkroom/models/
# The offline manual the Help menu opens
sudo install -Dm644 docs/manual/index.html $P/share/darkroom/manual/index.html
sudo install -Dm644 -t $P/share/darkroom/manual/media docs/manual/media/*
# Launcher entry, icon and software-centre description
sudo install -Dm644 packaging/paris.tourolle.darkroom.desktop \
$P/share/applications/paris.tourolle.darkroom.desktop
sudo install -Dm644 ui/dr-ui/ui/app-icon.png \
$P/share/icons/hicolor/256x256/apps/paris.tourolle.darkroom.png
sudo install -Dm644 packaging/paris.tourolle.darkroom.metainfo.xml \
$P/share/metainfo/paris.tourolle.darkroom.metainfo.xml
```
Then run `darkroom-desktop`, or open it from the application menu. To
uninstall, remove those files and `/usr/local/share/darkroom`. Your catalog,
settings and thumbnails live in `darkroom/` under your own XDG data, config
and cache directories (`~/.local/share`, `~/.config`, `~/.cache`) and are
not touched by either.
Optional at runtime: `gnome-keyring` or `kwallet` to remember Nextcloud
credentials, and an ONNX Runtime in `/usr/lib` (CPU, or ROCm on an AMD GPU) to
run the models on every core rather than on the built-in engine.
### Windows: build the installer
The `.exe` is cross-built from Linux in a container (podman or docker), with
no Windows machine involved. Two steps — the executable, then the NSIS
installer that carries it with its models and manual:
```bash
./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
./docker/windows/build.sh docker/windows/package.sh
```
Both land in the container's cache on the host, `~/.cache/darkroom-windows/target/`:
the bare executable under `x86_64-pc-windows-gnu/release/darkroom-desktop.exe`,
the installer under `installer/DarkRoom-<version>-x86_64-setup.exe`.
Copy that to the Windows machine and run it — it installs per user, needs no
administrator rights, and adds an uninstaller. Run on its own, the bare
`.exe` looks for `models\` and `manual\` beside itself, so use the installer.
[docker/windows](docker/windows/README.md) has the details.
### Android: build the APK
Also containerised ([docker/android](docker/android/README.md)). This
builds, packages and debug-signs the APK, and with `--install` puts it on a
device connected over adb:
```bash
./docker/android/package.sh --install
```
A debug-signed APK cannot replace one installed from a release; uninstall
that first.
[CONTRIBUTING.md](CONTRIBUTING.md) has the four
commands CI runs against what you send, and the shortest useful commands CI runs against what you send, and the shortest useful
contribution — a develop operation is one YAML file, and it arrives with its contribution — a develop operation is one YAML file, and it arrives with its
controls, its place in the chain and its tests. controls, its place in the chain and its tests.
## Where it stands ## Where it stands
**0.18.1**, twenty-seven tagged releases in. 192 numbered requirements in **0.21.0**, thirty-five 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.
@@ -4,9 +4,10 @@
Deliberately minimal: this packages the viewer for on-device testing (spike Deliberately minimal: this packages the viewer for on-device testing (spike
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
here is a distribution manifest yet. Only network access is declared: file here is a distribution manifest yet. The library grid needs no storage
access needs no manifest permission because the library grid reads through permission, because it reads through SAF, which grants per-tree at runtime
SAF, which grants per-tree at runtime (ARCH §6.9). (ARCH §6.9); the one storage permission declared is for importing from a
camera card, which is read by path.
Minimal is not the same as empty, and the entries below that are not the Minimal is not the same as empty, and the entries below that are not the
activity are the difference. A manifest is the only place a component can be activity are the difference. A manifest is the only place a component can be
@@ -21,13 +22,29 @@
WebDAV listing, thumbnail and image fetches. Without it Android refuses WebDAV listing, thumbnail and image fetches. Without it Android refuses
socket creation outright, and the failure is invisible — no panic to socket creation outright, and the failure is invisible — no panic to
catch, no log line, just a worker thread that stops. Storage is the catch, no log line, just a worker thread that stops. Storage is the
separate case that genuinely needs no permission here, because SAF separate case: the library and album folders need no permission
grants per-tree at runtime (ARCH §6.9). --> here, because SAF grants per-tree at runtime (ARCH §6.9). -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background <!-- Read before deciding whether a sync may run: FR-NC-6 gates background
work on unmetered-and-charging, which means knowing the network type. --> work on unmetered-and-charging, which means knowing the network type. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- FR-CAT-10: importing from a camera card. The importer reads the card
as files, and "all files access" is what makes an SD card or a USB
card reader readable by path on API 30 and up (see Cards.java). It is
granted on a system settings page, not a dialog; the import page
sends the user there when it is missing. READ_EXTERNAL_STORAGE is the
same thing for API 28 and 29, and means nothing above them; on 29 it
reads by path only with requestLegacyExternalStorage, which is why
<application> carries that flag.
Google Play limits MANAGE_EXTERNAL_STORAGE to a short list of app
kinds. DarkRoom is not distributed through Play. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is <!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
dependable (NFR-COMPAT-1). Marked required so an unsupported device dependable (NFR-COMPAT-1). Marked required so an unsupported device
fails at install rather than at first frame. --> fails at install rather than at first frame. -->
@@ -53,6 +70,7 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:hasCode="true" android:hasCode="true"
android:allowBackup="false" android:allowBackup="false"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true"> android:supportsRtl="true">
<!-- NativeActivity rather than a Kotlin Activity: android-activity's <!-- NativeActivity rather than a Kotlin Activity: android-activity's
@@ -0,0 +1,150 @@
package paris.tourolle.darkroom;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.provider.Settings;
import android.util.Log;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* Finding a camera card, and the permission that makes it readable (FR-CAT-10).
*
* <p>An import reads the card as files: the survey walks it, the probe reads
* each header and the copy streams each original, all through the same
* {@code std::fs} code the desktop uses. Android hands out such paths —
* {@code /storage/9C33-6BBD/DCIM} — to an app holding "all files access"
* ({@code MANAGE_EXTERNAL_STORAGE}, API 30), which covers the root of an SD
* card and of a USB card reader. Below API 30 the same paths are readable
* with {@code READ_EXTERNAL_STORAGE}.
*
* <p>Not the folder picker {@link FolderPicker} uses for albums. A tree
* granted through SAF is {@code content://} URIs, not paths, and since API 30
* the picker refuses the root of a card outright; reading a card through it
* would mean a second storage implementation under the importer, where this
* needs none.
*
* <p>Google Play restricts this permission to file managers and the like.
* DarkRoom is not distributed through Play, so the restriction does not
* apply; it would need revisiting if that changed.
*/
public final class Cards {
private static final String TAG = "DarkRoom";
private Cards() {
}
/** Whether this app may read a card's files by path. */
public static boolean hasAccess(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
return Environment.isExternalStorageManager();
}
return context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
== PackageManager.PERMISSION_GRANTED;
}
/**
* Open the system page where the user grants it.
*
* <p>A settings page rather than a permission dialog because there is no
* dialog for this one on API 30 and up: the user flips "Allow access to
* manage all files" for this app. Below 30 the context is the application
* context, which cannot raise a runtime permission request (that needs an
* Activity's result), so the app's own settings page is the route there
* too. Either way the app learns of the grant by asking
* {@link #hasAccess} again.
*/
public static void requestAccess(Context context) {
Uri self = Uri.parse("package:" + context.getPackageName());
Intent intent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, self);
} else {
intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, self);
}
// The context is not an Activity; see FolderPicker.start.
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
context.startActivity(intent);
} catch (RuntimeException e) {
// Some builds ship without the per-app page; the list of every
// app holding the permission is the fallback that always exists.
Log.w(TAG, "no per-app all-files page; opening the list", e);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent list = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
list.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(list);
}
}
}
/**
* Every mounted volume other than the device's own storage.
*
* <p>One string per volume, {@code path \t description \t removable},
* where removable is {@code 1} or {@code 0}: the reason {@link Intents}
* gives for keeping the JNI surface to strings. The primary volume is left
* out — it is the device's internal storage, never a card — and so is
* anything not mounted, which is a card being ejected or one the system
* could not read.
*/
public static String[] volumes(Context context) {
List<String> out = new ArrayList<String>();
StorageManager manager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
if (manager == null) {
return new String[0];
}
for (StorageVolume volume : manager.getStorageVolumes()) {
if (volume.isPrimary()) {
continue;
}
String state = volume.getState();
if (!Environment.MEDIA_MOUNTED.equals(state)
&& !Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
continue;
}
String path = path(volume);
if (path == null) {
Log.w(TAG, "a mounted volume with no path: " + volume);
continue;
}
String description = volume.getDescription(context);
if (description == null) {
description = new File(path).getName();
}
out.add(path + "\t" + description.replace('\t', ' ') + "\t"
+ (volume.isRemovable() ? "1" : "0"));
}
return out.toArray(new String[0]);
}
/**
* Where the volume is mounted.
*
* <p>{@code getDirectory} is API 30. Below it the same answer is the
* hidden {@code getPath}, which every release from 24 to 29 has, reached by
* reflection because android.jar does not declare it.
*/
private static String path(StorageVolume volume) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
File dir = volume.getDirectory();
return dir == null ? null : dir.getPath();
}
try {
Object path = StorageVolume.class.getMethod("getPath").invoke(volume);
return path == null ? null : path.toString();
} catch (ReflectiveOperationException e) {
Log.w(TAG, "StorageVolume.getPath", e);
return null;
}
}
}
+5 -2
View File
@@ -300,7 +300,9 @@ fn install_bundled_models(app: slint::android::AndroidApp) {
// on the worker because `AAssetManager` is thread-safe by contract and // on the worker because `AAssetManager` is thread-safe by contract and
// reading the pointer takes only the app's read lock, which `poll_events` // reading the pointer takes only the app's read lock, which `poll_events`
// also only ever holds shared. // also only ever holds shared.
std::thread::spawn(move || unpack_bundled_models(&app)); dr_ui::executors::spawn(dr_ui::executors::Executor::Io, "models", move || {
unpack_bundled_models(&app)
});
} }
/// The copy itself, on the worker [`install_bundled_models`] starts. /// The copy itself, on the worker [`install_bundled_models`] starts.
@@ -333,7 +335,7 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
// The int8 forms beside the three detectors are what the Hexagon runs // The int8 forms beside the three detectors are what the Hexagon runs
// (docs/dev/inference.md §5); the engine loads the sibling when the probe // (docs/dev/inference.md §5); the engine loads the sibling when the probe
// chose that rung and ignores it otherwise. // chose that rung and ignores it otherwise.
const BUNDLED: [(&std::ffi::CStr, &str); 14] = [ const BUNDLED: [(&std::ffi::CStr, &str); 15] = [
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"), (c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
( (
c"models/scrfd_500m_640.int8.onnx", c"models/scrfd_500m_640.int8.onnx",
@@ -358,6 +360,7 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
(c"models/categories.txt", "categories.txt"), (c"models/categories.txt", "categories.txt"),
// The panorama border filler (FR-MRG-4); MIT, 28 MB. // The panorama border filler (FR-MRG-4); MIT, 28 MB.
(c"models/migan-512.onnx", "migan-512.onnx"), (c"models/migan-512.onnx", "migan-512.onnx"),
(c"models/mosaic-1408.onnx", "mosaic-1408.onnx"),
]; ];
let dir = dr_ui::shared_face_models_dir(); let dir = dr_ui::shared_face_models_dir();
+35 -4
View File
@@ -15,6 +15,22 @@ use std::path::PathBuf;
use dr_plat::diagnostics::Installed; use dr_plat::diagnostics::Installed;
/// What the log keeps when `RUST_LOG` does not say.
#[cfg(not(target_os = "macos"))]
const DEFAULT_LOG: &str =
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn";
/// The same, and `debug` from this application's own crates and from ONNX
/// Runtime, whose `debug` is how many nodes each provider took
/// (docs/dev/macos.md). Nobody here runs a Mac: every macOS build is in
/// the hands of someone who can send us a log and cannot attach a debugger,
/// so the log is written as if for a debug build. `dr_` is a prefix, and
/// `env_logger` matches directives by prefix, so it names every `dr-*`
/// crate — present and future — without naming a dependency.
#[cfg(target_os = "macos")]
const DEFAULT_LOG: &str = "info,dr_=debug,darkroom_desktop=debug,onnxruntime=debug,\
wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn";
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
// TRACES: FR-PLAT-WIN-3 // TRACES: FR-PLAT-WIN-3
// Before the logger, the crash hook and everything else: this exists so a // Before the logger, the crash hook and everything else: this exists so a
@@ -33,10 +49,9 @@ fn main() -> anyhow::Result<()> {
// (NFR-OPS-1). `filter()` is asked afterwards because the environment may // (NFR-OPS-1). `filter()` is asked afterwards because the environment may
// have overridden the default below, and the file must not be quieter than // have overridden the default below, and the file must not be quieter than
// the terminal. // the terminal.
let console = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or( let console =
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn", env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(DEFAULT_LOG))
)) .build();
.build();
let level = console.filter(); let level = console.filter();
let logging = dr_plat::diagnostics::install(Box::new(console), level); let logging = dr_plat::diagnostics::install(Box::new(console), level);
@@ -109,5 +124,21 @@ fn runtime_dirs() -> Vec<PathBuf> {
PathBuf::from("/usr/lib/darkroom"), PathBuf::from("/usr/lib/darkroom"),
PathBuf::from("/usr/lib"), PathBuf::from("/usr/lib"),
]); ]);
// An app bundle keeps its libraries in `Contents/Frameworks`, beside
// the `Contents/MacOS` the executable is in; then Homebrew's
// `onnxruntime`, Apple silicon's prefix before Intel's. Homebrew's build
// may lack CoreML, which the probe finds out for itself.
#[cfg(target_os = "macos")]
{
if let Ok(exe) = std::env::current_exe() {
if let Some(bin) = exe.parent() {
dirs.push(bin.join("../Frameworks"));
}
}
dirs.extend([
PathBuf::from("/opt/homebrew/lib"),
PathBuf::from("/usr/local/lib"),
]);
}
dirs dirs
} }
+4 -1
View File
@@ -27,7 +27,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use dr_catalog::{keywords, rating, schema, Catalog}; use dr_catalog::{keywords, name_dates, rating, schema, Catalog};
fn main() { fn main() {
let mut args: Vec<String> = std::env::args().skip(1).collect(); let mut args: Vec<String> = std::env::args().skip(1).collect();
@@ -108,6 +108,9 @@ fn main() {
time(" keywords::adopt_orphan_terms", 20, || { time(" keywords::adopt_orphan_terms", 20, || {
keywords::adopt_orphan_terms(conn).unwrap(); keywords::adopt_orphan_terms(conn).unwrap();
}); });
time(" name_dates::fill", 20, || {
name_dates::fill(conn, None).unwrap();
});
interactive(conn); interactive(conn);
+64
View File
@@ -306,6 +306,48 @@ pub fn record_exports(
Ok(()) Ok(())
} }
/// Every file name an album records, for an export choosing a name to know
/// what it would land on.
///
/// A server album cannot be asked while the export is queued offline, and
/// the names this app put there are the ones a second export of the same
/// photographs will collide with. One read of the album's rows, not one per
/// candidate name.
pub fn file_names(
conn: &Connection,
id: AlbumId,
) -> Result<std::collections::HashSet<String>, CatalogError> {
ensure_tables(conn)?;
let mut stmt = conn.prepare("SELECT file_name FROM album_exports WHERE album_id = ?1")?;
let rows = stmt
.query_map([id.0 as i64], |r| r.get(0))?
.collect::<Result<_, _>>()?;
Ok(rows)
}
/// A file the upload had to give another name: the server held one by the
/// name the export recorded, put there by something this catalog never
/// saw. The album row follows the file to the name it was given.
///
/// By the album's server folder, because that is all an outbox entry knows.
/// `folder` is spelled as [`Place::Server`] spells it, without slashes at
/// either end.
pub fn rename_export(
conn: &Connection,
folder: &str,
from: &str,
to: &str,
) -> Result<(), CatalogError> {
ensure_tables(conn)?;
conn.execute(
"UPDATE OR REPLACE album_exports SET file_name = ?3
WHERE file_name = ?2
AND album_id IN (SELECT id FROM albums WHERE server_path = ?1 AND deleted = 0)",
rusqlite::params![folder.trim_matches('/'), from, to],
)?;
Ok(())
}
/// The photographs behind an album's files, most recently exported first — /// The photographs behind an album's files, most recently exported first —
/// what the grid shows when the album is opened. /// what the grid shows when the album is opened.
pub fn sources(conn: &Connection, id: AlbumId) -> Result<Vec<ImageId>, CatalogError> { pub fn sources(conn: &Connection, id: AlbumId) -> Result<Vec<ImageId>, CatalogError> {
@@ -463,6 +505,28 @@ mod tests {
assert_eq!(sources(conn, album).unwrap(), vec![b]); assert_eq!(sources(conn, album).unwrap(), vec![b]);
} }
#[test]
fn a_renamed_upload_moves_the_row_of_the_server_album_only() {
let cat = catalog();
let conn = cat.connection();
let web = create(conn, "Web", &Place::Server("Albums/Web".into())).unwrap();
let other = create(conn, "Other", &Place::Server("Albums/Other".into())).unwrap();
let a = image(conn, "a.cr3");
record_exports(conn, web, &[(a, "a.jpg".into())]).unwrap();
record_exports(conn, other, &[(a, "a.jpg".into())]).unwrap();
rename_export(conn, "/Albums/Web", "a.jpg", "a-1.jpg").unwrap();
assert_eq!(
file_names(conn, web).unwrap(),
["a-1.jpg".to_string()].into()
);
assert_eq!(
file_names(conn, other).unwrap(),
["a.jpg".to_string()].into()
);
}
#[test] #[test]
fn moving_to_the_server_forgets_the_local_folder() { fn moving_to_the_server_forgets_the_local_folder() {
let cat = catalog(); let cat = catalog();
+1
View File
@@ -50,6 +50,7 @@ pub mod faces;
pub mod jobs; pub mod jobs;
pub mod keywords; pub mod keywords;
pub mod merge; pub mod merge;
pub mod name_dates;
pub mod query; pub mod query;
pub mod rating; pub mod rating;
pub mod recovery; pub mod recovery;
+387
View File
@@ -0,0 +1,387 @@
//! TRACES: FR-CAT-5
//! A capture time read from the file's name, for an image whose header has
//! none.
//!
//! # Why
//!
//! A photograph with no EXIF date sorts after everything else, so it is lost
//! at the end of the grid and absent from the timeline. The files that end up
//! there are rarely without a date — they are without *EXIF*: WhatsApp strips
//! every tag and names the file `WhatsApp Image 2023-06-15 at 07.00.42.jpeg`,
//! a Windows Phone wrote `WP_20140922_14_16_27_Pro.jpg`, a phone camera
//! `IMG_20190812_153012.jpg`, and darktable's import renames to
//! `20230629_0001.jpeg`. On the reference library 250 of 274 undated images
//! carried their date in the name or in the folder above it.
//!
//! # What is accepted
//!
//! A date is `YYYYMMDD` as a whole run of digits, or `YYYY`, `MM` and `DD`
//! joined by `-`, `_` or `.`. A time may follow it — `HHMMSS` as one run (or
//! nine digits, milliseconds appended), or three two-digit runs joined by
//! `-`, `_`, `.` or `:` — after `_`, `-`, `.`, `T`, a space or ` at `.
//! Anything else after the date leaves it at midnight: `_0059` in
//! `20230628_0059` is a sequence number, not 00:59, and reading it as a time
//! would invent one.
//!
//! The name is tried first and then each folder above it, innermost first —
//! `2016/2016-11-11/IMG_7910.jpg` is dated by its folder. A bare year folder
//! is not a date: putting a photograph at 1 January is a wrong answer, and an
//! undated one at least says it does not know.
//!
//! The reading is wall-clock time with no zone, stored as EXIF's is
//! (`dr_decode::parse_exif_datetime`), and EXIF always wins: this only fills
//! rows whose `captured_at` is still empty.
use rusqlite::Connection;
use crate::CatalogError;
/// The capture time a path's name states, as wall-clock Unix seconds.
pub fn date_from_path(source_ref: &str) -> Option<i64> {
let mut parts = source_ref.rsplit(['/', '\\']);
let name = parts.next()?;
let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
date_in(stem).or_else(|| parts.find_map(date_in))
}
/// Date every examined, undated image whose name states one.
///
/// `only` limits the pass to the images just examined — what the sweep hands
/// in — and `None` visits every undated image, which is the backfill's case.
/// Both read the undated side alone (`images_captured` answers
/// `captured_at IS NULL` with a seek), never the library.
///
/// Returns how many images were dated.
pub fn fill(conn: &Connection, only: Option<&[i64]>) -> Result<usize, CatalogError> {
let rows: Vec<(i64, String)> = match only {
None => {
let mut stmt = conn.prepare(
"SELECT id, source_ref FROM images
WHERE captured_at IS NULL AND metadata_state >= 2",
)?;
let rows = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<Result<_, _>>()?;
rows
}
Some(ids) => {
let mut stmt = conn.prepare_cached(
"SELECT source_ref FROM images
WHERE id = ?1 AND captured_at IS NULL AND metadata_state >= 2",
)?;
let mut rows = Vec::new();
for &id in ids {
let mut q = stmt.query([id])?;
if let Some(r) = q.next()? {
rows.push((id, r.get(0)?));
}
}
rows
}
};
let dated: Vec<(i64, i64)> = rows
.iter()
.filter_map(|(id, path)| date_from_path(path).map(|at| (*id, at)))
.collect();
if dated.is_empty() {
return Ok(0);
}
// A savepoint rather than a transaction, so a caller already inside one
// can still call this: the backfill's 250 rows are one commit, not 250.
conn.execute_batch("SAVEPOINT name_dates")?;
let written = (|| {
let mut stmt = conn.prepare_cached(
"UPDATE images SET captured_at = ?2 WHERE id = ?1 AND captured_at IS NULL",
)?;
let mut n = 0;
for (id, at) in &dated {
n += stmt.execute(rusqlite::params![id, at])?;
}
Ok::<_, CatalogError>(n)
})();
match written {
Ok(n) => {
conn.execute_batch("RELEASE name_dates")?;
Ok(n)
}
Err(e) => {
let _ = conn.execute_batch("ROLLBACK TO name_dates; RELEASE name_dates");
Err(e)
}
}
}
/// The first date, with its time if one follows, in one name component.
fn date_in(s: &str) -> Option<i64> {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() {
// Only at the start of a run of digits: a date inside a longer number
// is a coincidence, not a date.
if b[i].is_ascii_digit() && (i == 0 || !b[i - 1].is_ascii_digit()) {
if let Some(at) = date_at(b, i) {
return Some(at);
}
}
i += 1;
}
None
}
/// A date starting at `i`, and the time after it if there is one.
fn date_at(b: &[u8], i: usize) -> Option<i64> {
let run = digits(b, i);
let ((y, mo, d), after) = match run.len() {
// YYYYMMDD, or YYYYMMDDHHMMSS written as one number.
8 | 14 => ((num(&run[..4]), num(&run[4..6]), num(&run[6..8])), i + 8),
4 => {
let sep = |at: usize| matches!(b.get(at), Some(b'-' | b'_' | b'.'));
let mo_at = i + 4 + 1;
let d_at = mo_at + 2 + 1;
if !(sep(i + 4) && digits(b, mo_at).len() == 2 && sep(mo_at + 2))
|| digits(b, d_at).len() != 2
{
return None;
}
(
(num(run), num(&b[mo_at..mo_at + 2]), num(&b[d_at..d_at + 2])),
d_at + 2,
)
}
_ => return None,
};
let day = civil_days(y, mo, d)?;
let time = if run.len() == 14 {
hms(num(&run[8..10]), num(&run[10..12]), num(&run[12..14]))
} else {
time_at(b, after)
};
Some(day * 86_400 + time.unwrap_or(0))
}
/// The time following a date that ends at `i`, as seconds into the day.
fn time_at(b: &[u8], i: usize) -> Option<i64> {
let rest = &b[i..];
let start = if rest.starts_with(b" at ") {
i + 4
} else if matches!(rest.first(), Some(b'_' | b'-' | b'.' | b'T' | b' ')) {
i + 1
} else {
return None;
};
let run = digits(b, start);
match run.len() {
// HHMMSS, or with milliseconds appended (Pixel's PXL_…_123456789).
6 | 9 => hms(num(&run[..2]), num(&run[2..4]), num(&run[4..6])),
2 => {
let sep = |at: usize| matches!(b.get(at), Some(b'-' | b'_' | b'.' | b':'));
let (m_at, s_at) = (start + 3, start + 6);
if !(sep(start + 2) && digits(b, m_at).len() == 2 && sep(m_at + 2))
|| digits(b, s_at).len() != 2
{
return None;
}
hms(num(run), num(&b[m_at..m_at + 2]), num(&b[s_at..s_at + 2]))
}
_ => None,
}
}
/// The run of ASCII digits starting at `i`.
fn digits(b: &[u8], i: usize) -> &[u8] {
let rest = b.get(i..).unwrap_or(&[]);
let n = rest.iter().take_while(|c| c.is_ascii_digit()).count();
&rest[..n]
}
fn num(d: &[u8]) -> i64 {
d.iter().fold(0, |n, c| n * 10 + i64::from(c - b'0'))
}
fn hms(h: i64, m: i64, s: i64) -> Option<i64> {
((0..24).contains(&h) && (0..60).contains(&m) && (0..61).contains(&s))
.then_some(h * 3_600 + m * 60 + s)
}
/// Days since 1970-01-01 for a valid civil date, `None` for anything else.
///
/// The year range is EXIF's (`parse_exif_datetime`): wide enough for scanned
/// film, narrow enough that a counter such as `12345678` is not a date.
fn civil_days(y: i64, mo: i64, d: i64) -> Option<i64> {
let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
let month_len = match mo {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => return None,
};
if !(1900..=2200).contains(&y) || !(1..=month_len).contains(&d) {
return None;
}
let y_adj = if mo <= 2 { y - 1 } else { y };
let era = y_adj.div_euclid(400);
let yoe = y_adj - era * 400;
let mp = (mo + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
Some(era * 146_097 + doe - 719_468)
}
#[cfg(test)]
mod tests {
use super::*;
/// Wall-clock seconds for a date and time, the expected side of each case.
fn at(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64) -> Option<i64> {
Some(civil_days(y, mo, d).unwrap() * 86_400 + h * 3_600 + mi * 60 + s)
}
#[test]
fn the_names_in_the_reference_library_are_read() {
// Every shape here is a file that sat undated at the end of the grid.
for (path, want) in [
(
"PhotosRaw/alps trip/alps whatsapp/WhatsApp Image 2023-06-15 at 07.00.42.jpeg",
at(2023, 6, 15, 7, 0, 42),
),
(
"PhotosRaw/alps trip/alps whatsapp/WhatsApp Image 2023-06-17 at 12.45.52 (1).jpeg",
at(2023, 6, 17, 12, 45, 52),
),
(
"PhotosRaw/WP_20140922_14_16_27_Pro.jpg",
at(2014, 9, 22, 14, 16, 27),
),
// A sequence number after the date is not a time.
(
"PhotosRaw/Darktable/20230629_no_name/20230629_0001.jpeg",
at(2023, 6, 29, 0, 0, 0),
),
("PhotosRaw/20230628_0059.jpg", at(2023, 6, 28, 0, 0, 0)),
(
"PhotosRaw/backdrops/IMG_20130625_0021.jpg",
at(2013, 6, 25, 0, 0, 0),
),
(
"PhotosRaw/alps trip/20230628_0641 - 20230628_0661.jpg",
at(2023, 6, 28, 0, 0, 0),
),
] {
assert_eq!(date_from_path(path), want, "{path}");
}
}
#[test]
fn common_camera_and_app_names_are_read() {
for (path, want) in [
("IMG_20190812_153012.jpg", at(2019, 8, 12, 15, 30, 12)),
("PXL_20210101_123456789.jpg", at(2021, 1, 1, 12, 34, 56)),
(
"Screenshot_2021-03-04-12-30-45.png",
at(2021, 3, 4, 12, 30, 45),
),
(
"Screenshot from 2021-03-04 12-30-45.png",
at(2021, 3, 4, 12, 30, 45),
),
("IMG-20210304-WA0001.jpg", at(2021, 3, 4, 0, 0, 0)),
("20210304143012.jpg", at(2021, 3, 4, 14, 30, 12)),
("2019.12.25 party.jpg", at(2019, 12, 25, 0, 0, 0)),
("signal-2022-01-02-101112.jpg", at(2022, 1, 2, 10, 11, 12)),
("2022-01-02T10:11:12.jpg", at(2022, 1, 2, 10, 11, 12)),
] {
assert_eq!(date_from_path(path), want, "{path}");
}
}
#[test]
fn a_folder_dates_a_name_that_does_not() {
assert_eq!(
date_from_path("PhotosRaw/2016/2016-11-11/IMG_7910.jpg"),
at(2016, 11, 11, 0, 0, 0)
);
// The innermost folder that states a date wins.
assert_eq!(
date_from_path("2016-01-01 trip/2016-01-03/_MG_1.jpg"),
at(2016, 1, 3, 0, 0, 0)
);
// The name beats its folder.
assert_eq!(
date_from_path("2016-11-11/IMG_20161112_080000.jpg"),
at(2016, 11, 12, 8, 0, 0)
);
}
#[test]
fn numbers_that_are_not_dates_are_left_alone() {
for path in [
"PhotosRaw/_MG_9002.jpg",
"PhotosRaw/scanning/fau_2.jpg",
// A year folder is not a day.
"PhotosRaw/2016/_MG_1.jpg",
"IMG_1999.jpg",
"DSC_12345678.jpg", // month 56
"20230230_0001.jpg", // 30 February
"120230615.jpg", // the date is inside a longer number
"1612345678901.jpg", // a millisecond epoch, not a civil date
"2023-6-15.jpg", // a one-digit month is too loose to trust
] {
assert_eq!(date_from_path(path), None, "{path}");
}
}
#[test]
fn a_time_that_cannot_be_is_dropped_and_the_date_kept() {
assert_eq!(
date_from_path("20230615_256199.jpg"),
at(2023, 6, 15, 0, 0, 0)
);
}
#[test]
fn fill_dates_only_examined_undated_rows_and_never_overrides_exif() {
let c = Connection::open_in_memory().unwrap();
crate::schema::migrate(&c).unwrap();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
[],
)
.unwrap();
// (id, name, captured_at, metadata_state)
for (id, name, captured, state) in [
(1i64, "IMG_20190812_153012.jpg", None, 2i64),
// EXIF already answered; the name disagrees and loses.
(2, "IMG_20190812_153012b.jpg", Some(42i64), 2),
// Not yet examined: EXIF may still come, so the name waits.
(3, "IMG_20190813_000000.jpg", None, 1),
(4, "_MG_9002.jpg", None, 2),
] {
c.execute(
"INSERT INTO images(id, root_id, source_ref, captured_at, metadata_state, added_at)
VALUES (?1, 1, ?2, ?3, ?4, 0)",
rusqlite::params![id, name, captured, state],
)
.unwrap();
}
let captured = |id: i64| -> Option<i64> {
c.query_row("SELECT captured_at FROM images WHERE id = ?1", [id], |r| {
r.get(0)
})
.unwrap()
};
assert_eq!(fill(&c, Some(&[2, 3, 4])).unwrap(), 0);
assert_eq!(fill(&c, None).unwrap(), 1);
assert_eq!(captured(1), at(2019, 8, 12, 15, 30, 12));
assert_eq!(captured(2), Some(42));
assert_eq!(captured(3), None);
assert_eq!(captured(4), None);
// Nothing left to do is a no-op, not a rewrite.
assert_eq!(fill(&c, None).unwrap(), 0);
}
}
+9
View File
@@ -356,6 +356,15 @@ pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, Catalog
out.push(("keyword_terms", n)); out.push(("keyword_terms", n));
} }
// TRACES: FR-CAT-5
// A date from the file's name for every examined image EXIF left undated.
// The sweep does this as it examines each image; this is for the images
// examined by a build that did not, and reads the undated side alone.
let n = crate::name_dates::fill(conn, None)?;
if n > 0 {
out.push(("dates_from_names", n));
}
Ok(out) Ok(out)
} }
+11
View File
@@ -798,6 +798,17 @@ mod tests {
let tmp = target.with_extension("tmp"); let tmp = target.with_extension("tmp");
fs::write(&tmp, bytes).expect("write"); fs::write(&tmp, bytes).expect("write");
fs::rename(&tmp, &target).expect("rename"); fs::rename(&tmp, &target).expect("rename");
// A scan tells a changed file by its mtime, at whole-second
// resolution; a resave landing in the same second as the scan
// before it looks unchanged, and the test fails when the machine
// is fast enough. Two seconds ahead, on the file and its folder,
// is what a real resave some time later would look like.
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
for p in [target.as_path(), target.parent().expect("parent")] {
fs::File::open(p)
.and_then(|f| f.set_modified(later))
.expect("set mtime");
}
self self
} }
+4
View File
@@ -32,6 +32,10 @@ fn main() {
println!("black {:?}", raw.black_level); println!("black {:?}", raw.black_level);
println!("white {}", raw.white_level); println!("white {}", raw.white_level);
println!("wb_coeffs {:?}", raw.wb_coeffs); println!("wb_coeffs {:?}", raw.wb_coeffs);
match dr_decode::noise_profile(&bytes) {
Some(p) => println!("noise profile {p:?} ((S, O) per plane)"),
None => println!("noise profile none"),
}
match raw.color_matrix { match raw.color_matrix {
Some(m) => { Some(m) => {
-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());
}
}
+806
View File
@@ -0,0 +1,806 @@
//! TRACES: FR-DEV-3e
//! DNG camera profiles: the tables on top of the matrix (D20).
//!
//! A profile is what [`crate::profile`] already reads — colour and forward
//! matrices per calibration illuminant — plus two lookups over HSV: the
//! `ProfileHueSatMap`, a calibration, and the `ProfileLookTable`, a rendering
//! intent. `docs/dev/camera-profiles.md` is the design; this module finds
//! them, in the order its §4 gives:
//!
//! 1. embedded in the DNG being decoded ([`Dcp::from_ifd`]);
//! 2. a `.dcp` file in the profiles directory whose `UniqueCameraModel`
//! names this body ([`find`]);
//! 3. nowhere, and the matrix renders alone.
//!
//! A `.dcp` is a TIFF whose magic is `RC` (0x4352) rather than 42, holding one
//! IFD of the same tags a DNG carries. rawler's TIFF reader does not check the
//! magic, so both sources go through the one parser and [`Dcp::from_ifd`].
//!
//! Nothing here applies a table. The lookup is the shader's, with its CPU
//! reference in `dr-pipeline`; this module resolves *which* tables, and blends
//! the HueSatMap for the light the frame was shot under, once per decode.
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock};
use dr_types::{HueSatTable, ProfileOrigin, ProfileTables};
use rawler::formats::tiff::{
DirectoryWriter, GenericTiffReader, SRational, TiffWriter, Value, IFD,
};
use rawler::imgop::xyz::Illuminant;
use rawler::tags::DngTag;
use crate::profile::{illuminant_temperature, Calibration, CameraProfile};
/// The magic a `.dcp` carries where a TIFF carries 42.
const DCP_MAGIC: u16 = 0x4352;
/// `ProfileEmbedPolicy` values that permit copying a profile out of the file
/// it came in: 0, "allow copying", and 3, "no restrictions". 1 ("embed if
/// used") and 2 ("embed never") do not.
const COPYABLE_POLICIES: [u32; 2] = [0, 3];
/// A camera profile as a DNG or a `.dcp` states it.
///
/// Indexed `[0]`/`[1]` for calibration 1 and 2, positionally, because that is
/// how the file pairs a matrix and a table with its illuminant.
#[derive(Debug, Clone, PartialEq)]
pub struct Dcp {
/// `ProfileName`. Empty where the file names none.
pub name: String,
/// `UniqueCameraModel`: the body the profile was made for.
pub unique_camera_model: Option<String>,
pub copyright: Option<String>,
pub calibration_signature: Option<String>,
/// `ProfileEmbedPolicy`; 0 where absent, as the DNG specification
/// defaults it.
pub embed_policy: u32,
/// `CalibrationIlluminant1/2`, as EXIF light-source codes.
pub illuminants: [Option<u16>; 2],
/// `ColorMatrix1/2`: XYZ → camera.
pub color_matrix: [Option<[[f32; 3]; 3]>; 2],
/// `ForwardMatrix1/2`: white-balanced camera → XYZ (D50).
pub forward_matrix: [Option<[[f32; 3]; 3]>; 2],
/// `ProfileHueSatMapData1/2`, sharing one dimensions tag.
pub hue_sat: [Option<HueSatTable>; 2],
/// `ProfileLookTableData`.
pub look: Option<HueSatTable>,
/// `ProfileToneCurve`, as stored: input/output pairs. Carried so a copy
/// keeps it, never applied — tone is the view transform's (D19, D20).
pub tone_curve: Option<Vec<f32>>,
/// `BaselineExposureOffset`, in stops: the profile's correction to the
/// file's `BaselineExposure` (camera-profiles.md §11). A profile copied
/// out of a DNG carries that DNG's baseline here, so a raw with no
/// baseline of its own lands at the same brightness.
pub baseline_exposure_offset: f32,
}
impl Dcp {
/// TRACES: FR-DEV-3e
/// Read a profile out of an IFD — a DNG's root, or a `.dcp`'s only one.
///
/// `None` where the IFD carries neither table. A DNG always has matrices
/// and the decoder already reads them; what makes a *profile* worth
/// carrying separately is a table, so its absence is "no profile" rather
/// than a profile that says nothing.
pub fn from_ifd(ifd: &IFD) -> Option<Self> {
let hue_sat_dims = dims(ifd, DngTag::ProfileHueSatMapDims);
let hue_sat_srgb = encoding(ifd, DngTag::ProfileHueSatMapEncoding);
let hue_sat = [DngTag::ProfileHueSatMapData1, DngTag::ProfileHueSatMapData2]
.map(|tag| hue_sat_dims.and_then(|d| table(ifd, tag, d, hue_sat_srgb)));
let look = dims(ifd, DngTag::ProfileLookTableDims).and_then(|d| {
table(
ifd,
DngTag::ProfileLookTableData,
d,
encoding(ifd, DngTag::ProfileLookTableEncoding),
)
});
if hue_sat[0].is_none() && hue_sat[1].is_none() && look.is_none() {
return None;
}
Some(Self {
name: string(ifd, DngTag::ProfileName).unwrap_or_default(),
unique_camera_model: string(ifd, DngTag::UniqueCameraModel),
copyright: string(ifd, DngTag::ProfileCopyright),
calibration_signature: string(ifd, DngTag::ProfileCalibrationSignature),
embed_policy: ifd
.get_entry(DngTag::ProfileEmbedPolicy)
.and_then(|e| e.value.get_u32(0).ok().flatten())
.unwrap_or(0),
illuminants: [
DngTag::CalibrationIlluminant1,
DngTag::CalibrationIlluminant2,
]
.map(|tag| {
ifd.get_entry(tag)
.and_then(|e| e.value.get_u16(0).ok().flatten())
}),
color_matrix: [DngTag::ColorMatrix1, DngTag::ColorMatrix2].map(|t| matrix(ifd, t)),
forward_matrix: [DngTag::ForwardMatrix1, DngTag::ForwardMatrix2]
.map(|t| matrix(ifd, t)),
hue_sat,
look,
tone_curve: ifd
.get_entry(DngTag::ProfileToneCurve)
.and_then(|e| floats(&e.value))
.filter(|v| v.len() >= 4 && v.len() % 2 == 0),
baseline_exposure_offset: stops(ifd, DngTag::BaselineExposureOffset),
})
}
/// TRACES: FR-DEV-3e
/// Parse a `.dcp` file's bytes.
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
if bytes.len() < 8 {
return Err("too short to be a camera profile".into());
}
let magic = match &bytes[..2] {
b"II" => u16::from_le_bytes([bytes[2], bytes[3]]),
b"MM" => u16::from_be_bytes([bytes[2], bytes[3]]),
_ => return Err("not a TIFF-structured file".into()),
};
if magic != DCP_MAGIC {
return Err(format!("magic {magic:#x} is not a camera profile's"));
}
let reader =
GenericTiffReader::new_with_buffer(bytes, 0, 0, Some(0)).map_err(|e| e.to_string())?;
use rawler::formats::tiff::reader::TiffReader;
let profile = Self::from_ifd(reader.root_ifd())
.ok_or_else(|| "a profile with no HueSatMap and no LookTable".to_string())?;
if profile.color_matrix[0].is_none() {
return Err("a profile with no ColorMatrix1".into());
}
Ok(profile)
}
/// Whether the file this profile came in allows it to be copied out
/// (camera-profiles.md §4).
pub fn may_copy(&self) -> bool {
COPYABLE_POLICIES.contains(&self.embed_policy)
}
/// TRACES: FR-DEV-3e
/// Whether this profile was made for the body named.
///
/// `unique` is the file's own `UniqueCameraModel`, where a DNG carries
/// one; `make` and `model` are rawler's cleaned names, joined as Adobe
/// spells a body ("Canon EOS 6D"). Case and runs of spaces are ignored,
/// because the two spellings come from different vendors' tables.
pub fn is_for(&self, unique: Option<&str>, make: &str, model: &str) -> bool {
let Some(mine) = self.unique_camera_model.as_deref().map(normalise) else {
return false;
};
let joined = if normalise(model).starts_with(&normalise(make)) {
normalise(model)
} else {
normalise(&format!("{make} {model}"))
};
unique.map(normalise).as_deref() == Some(mine.as_str()) || joined == mine
}
/// TRACES: FR-DEV-3e
/// The matrices this profile was built against, as the decoder's
/// [`CameraProfile`], with the frame's own as-shot neutral.
///
/// A `.dcp` is a whole profile: its tables were measured relative to its
/// forward matrix, so using them over the file's matrices would apply a
/// correction for a different starting point. `None` where no calibration
/// is usable, and the caller keeps the file's.
pub fn camera_profile(&self, neutral: Option<[f32; 3]>) -> Option<CameraProfile> {
let calibrations = (0..2)
.filter_map(|i| {
let xyz_to_cam = self.color_matrix[i]?;
let temperature = self.temperature(i)?;
Some(Calibration {
temperature,
xyz_to_cam,
forward: self.forward_matrix[i],
})
})
.collect();
CameraProfile::new(calibrations, neutral)
}
/// TRACES: FR-DEV-3e
/// The tables to render this frame with: the HueSatMap blended for the
/// scene's colour temperature, by the same mired weight the matrices use,
/// and the LookTable as it is.
///
/// Tables that change nothing are dropped here, so the shader is never
/// asked to look up an identity.
pub fn tables(&self, scene_temperature: f32, origin: ProfileOrigin) -> ProfileTables {
let hue_sat = match (&self.hue_sat, self.temperature(0), self.temperature(1)) {
([Some(a), Some(b)], Some(ta), Some(tb)) => {
let t = mired_weight(ta, tb, scene_temperature);
a.lerp(b, t).or_else(|| Some(a.clone()))
}
([Some(a), _], _, _) => Some(a.clone()),
([None, Some(b)], _, _) => Some(b.clone()),
([None, None], _, _) => None,
};
ProfileTables {
name: self.name.clone(),
origin,
hue_sat: hue_sat.filter(|t| !t.is_identity()),
look: self.look.clone().filter(|t| !t.is_identity()),
tone_curve: self
.tone_curve
.as_deref()
.and_then(dr_types::tone::resample_tone_curve),
}
}
fn temperature(&self, i: usize) -> Option<f32> {
let code = self.illuminants[i]?;
let illuminant: Illuminant = code.try_into().ok()?;
illuminant_temperature(illuminant)
}
/// TRACES: FR-DEV-3e
/// This profile as `.dcp` bytes, for [`save`].
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
let mut cursor = std::io::Cursor::new(Vec::new());
let writer = TiffWriter::new(&mut cursor).map_err(|e| e.to_string())?;
let mut dir = DirectoryWriter::new();
if let Some(model) = &self.unique_camera_model {
dir.add_tag(DngTag::UniqueCameraModel, model.as_str());
}
dir.add_tag(DngTag::ProfileName, self.name.as_str());
if let Some(c) = &self.copyright {
dir.add_tag(DngTag::ProfileCopyright, c.as_str());
}
if let Some(s) = &self.calibration_signature {
dir.add_tag(DngTag::ProfileCalibrationSignature, s.as_str());
}
dir.add_tag(DngTag::ProfileEmbedPolicy, self.embed_policy);
let illuminant_tags = [
DngTag::CalibrationIlluminant1,
DngTag::CalibrationIlluminant2,
];
for (tag, code) in illuminant_tags.into_iter().zip(self.illuminants) {
if let Some(code) = code {
dir.add_tag(tag, code);
}
}
for (tag, m) in [DngTag::ColorMatrix1, DngTag::ColorMatrix2]
.into_iter()
.zip(self.color_matrix)
.chain(
[DngTag::ForwardMatrix1, DngTag::ForwardMatrix2]
.into_iter()
.zip(self.forward_matrix),
)
{
if let Some(m) = m {
dir.add_value(tag, srational_matrix(&m));
}
}
if let Some(first) = self.hue_sat.iter().flatten().next() {
dir.add_tag(
DngTag::ProfileHueSatMapDims,
[
first.hue_divisions,
first.sat_divisions,
first.val_divisions,
],
);
dir.add_tag(
DngTag::ProfileHueSatMapEncoding,
u32::from(first.srgb_encoded),
);
for (tag, t) in [DngTag::ProfileHueSatMapData1, DngTag::ProfileHueSatMapData2]
.into_iter()
.zip(&self.hue_sat)
{
if let Some(t) = t {
dir.add_value(
tag,
Value::Float(t.entries.iter().flatten().copied().collect()),
);
}
}
}
if let Some(t) = &self.look {
dir.add_tag(
DngTag::ProfileLookTableDims,
[t.hue_divisions, t.sat_divisions, t.val_divisions],
);
dir.add_tag(DngTag::ProfileLookTableEncoding, u32::from(t.srgb_encoded));
dir.add_value(
DngTag::ProfileLookTableData,
Value::Float(t.entries.iter().flatten().copied().collect()),
);
}
if let Some(curve) = &self.tone_curve {
dir.add_value(DngTag::ProfileToneCurve, Value::Float(curve.clone()));
}
if self.baseline_exposure_offset != 0.0 {
dir.add_value(
DngTag::BaselineExposureOffset,
Value::SRational(vec![SRational::new(
(self.baseline_exposure_offset * 100.0).round() as i32,
100,
)]),
);
}
writer.build(dir).map_err(|e| e.to_string())?;
let mut bytes = cursor.into_inner();
// The writer stamps TIFF's 42 in its own byte order; a profile is the
// same structure with its own magic in the same place.
bytes[2..4].copy_from_slice(&DCP_MAGIC.to_ne_bytes());
Ok(bytes)
}
}
/// The weight toward calibration 2, by reciprocal temperature — the same
/// interpolation [`CameraProfile`] gives the matrices, so the tables and the
/// matrix agree about how far between the two lights a frame was shot.
fn mired_weight(t1: f32, t2: f32, scene: f32) -> f32 {
let mired = |k: f32| 1.0e6 / k.max(1.0);
let (a, b) = (mired(t1), mired(t2));
if (a - b).abs() < 1e-6 {
return 0.0;
}
((mired(scene) - a) / (b - a)).clamp(0.0, 1.0)
}
fn normalise(s: &str) -> String {
s.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
fn string(ifd: &IFD, tag: DngTag) -> Option<String> {
ifd.get_entry(tag)
.and_then(|e| e.value.as_string().cloned())
.map(|s| s.trim_end_matches('\0').trim().to_string())
.filter(|s| !s.is_empty())
}
/// A single rational tag in stops, zero where absent or unreadable — the
/// DNG specification's default for both exposure tags.
fn stops(ifd: &IFD, tag: DngTag) -> f32 {
ifd.get_entry(tag)
.and_then(|e| e.value.get_f32(0).ok().flatten())
.filter(|v| v.is_finite())
.unwrap_or(0.0)
}
fn floats(value: &Value) -> Option<Vec<f32>> {
(0..value.count())
.map(|i| value.get_f32(i).ok().flatten())
.collect()
}
fn matrix(ifd: &IFD, tag: DngTag) -> Option<[[f32; 3]; 3]> {
let v = floats(&ifd.get_entry(tag)?.value)?;
if v.len() != 9 || v.iter().any(|x| !x.is_finite()) {
return None;
}
Some([[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]])
}
fn dims(ifd: &IFD, tag: DngTag) -> Option<[u32; 3]> {
let e = ifd.get_entry(tag)?;
let at = |i| e.value.get_u32(i).ok().flatten();
Some([at(0)?, at(1)?, at(2)?])
}
fn encoding(ifd: &IFD, tag: DngTag) -> bool {
ifd.get_entry(tag)
.and_then(|e| e.value.get_u32(0).ok().flatten())
== Some(1)
}
fn table(ifd: &IFD, tag: DngTag, [h, s, v]: [u32; 3], srgb: bool) -> Option<HueSatTable> {
let data = floats(&ifd.get_entry(tag)?.value)?;
if data.len() % 3 != 0 {
return None;
}
let entries = data.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
HueSatTable::new(h, s, v, srgb, entries)
}
fn srational_matrix(m: &[[f32; 3]; 3]) -> Value {
const SCALE: i32 = 10_000;
Value::SRational(
m.iter()
.flatten()
.map(|v| SRational::new((v * SCALE as f32).round() as i32, SCALE))
.collect(),
)
}
// ---- the profiles directory -------------------------------------------------
/// The `.dcp` files the photographer has installed, loaded once per process.
struct Library {
dir: PathBuf,
/// `(file name, profile)`, sorted by file name so that two profiles for
/// one body resolve the same way on every run (camera-profiles.md §4).
profiles: Vec<(String, Arc<Dcp>)>,
}
fn library() -> &'static RwLock<Option<Library>> {
static LIBRARY: OnceLock<RwLock<Option<Library>>> = OnceLock::new();
LIBRARY.get_or_init(|| RwLock::new(None))
}
/// TRACES: FR-DEV-3e
/// Name the profiles directory and read every `.dcp` in it.
///
/// Called once at start-up by the application, with a path under the
/// platform data directory. A decode before this, or in a process that never
/// calls it (a test, a bench), finds no directory profiles, which is the
/// matrix-only render it always had.
pub fn set_profiles_directory(dir: PathBuf) {
let profiles = load(&dir);
if let Ok(mut lib) = library().write() {
*lib = Some(Library { dir, profiles });
}
}
/// The directory [`set_profiles_directory`] named, if any.
pub fn profiles_directory() -> Option<PathBuf> {
library().read().ok()?.as_ref().map(|l| l.dir.clone())
}
fn load(dir: &Path) -> Vec<(String, Arc<Dcp>)> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut out: Vec<(String, Arc<Dcp>)> = entries
.flatten()
.filter(|e| {
e.path()
.extension()
.is_some_and(|x| x.eq_ignore_ascii_case("dcp"))
})
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let bytes = std::fs::read(e.path()).ok()?;
match Dcp::parse(&bytes) {
Ok(p) => Some((name, Arc::new(p))),
Err(why) => {
log::warn!("camera profile {name} skipped: {why}");
None
}
}
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
log::info!(
"camera profiles: {} loaded from {}",
out.len(),
dir.display()
);
out
}
/// TRACES: FR-DEV-3e
/// The first installed profile, by file name, made for this body.
pub fn find(unique: Option<&str>, make: &str, model: &str) -> Option<(String, Arc<Dcp>)> {
let lib = library().read().ok()?;
lib.as_ref()?
.profiles
.iter()
.find(|(_, p)| p.is_for(unique, make, model))
.cloned()
}
/// TRACES: FR-DEV-3e
/// Save a profile copied out of a photograph into the profiles directory, and
/// make it available to the next decode.
///
/// Refuses a profile whose embed policy does not allow copying, and refuses
/// when no directory is set. Named after the body and the profile, so a
/// second copy of the same profile replaces the first rather than piling up.
pub fn save(profile: &Dcp) -> Result<PathBuf, String> {
if !profile.may_copy() {
return Err("this profile's embed policy does not allow copying it".into());
}
let model = profile
.unique_camera_model
.as_deref()
.ok_or("the profile names no camera")?;
let dir = profiles_directory().ok_or("no profiles directory is set")?;
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let file_name: String = format!("{model} {}.dcp", profile.name)
.chars()
.map(|c| {
if c.is_alphanumeric() || " -_.".contains(c) {
c
} else {
'_'
}
})
.collect();
let path = dir.join(file_name.trim());
std::fs::write(&path, profile.to_bytes()?).map_err(|e| e.to_string())?;
set_profiles_directory(dir);
Ok(path)
}
/// TRACES: FR-DEV-3e
/// The profile embedded in a file, read on demand — for the panel's offer to
/// copy it, which happens long after the decode that rendered it.
///
/// Reads the header only; no photosite is unpacked.
pub fn embedded_in(bytes: &[u8]) -> Option<Dcp> {
let source = rawler::rawsource::RawSource::new_from_slice(bytes);
let decoder = rawler::get_decoder(&source).ok()?;
let root = decoder
.ifd(rawler::decoders::WellKnownIFD::Root)
.ok()
.flatten()?;
// The copy carries the file's baseline as its offset, so a raw from the
// same body that has no baseline of its own — a CR2 — gets the total the
// DNG renders at (camera-profiles.md §11).
let mut profile = Dcp::from_ifd(&root)?;
profile.baseline_exposure_offset += stops(&root, DngTag::BaselineExposure);
Some(profile)
}
/// TRACES: FR-DEV-3e
/// What one decode resolved: the matrices to render through, the tables on
/// top of them, and the embedded profile if the file had one — kept whole so
/// the panel can offer to copy it.
pub struct Resolved {
pub profile: Option<CameraProfile>,
pub tables: Option<Arc<ProfileTables>>,
pub embedded: Option<Arc<Dcp>>,
/// Stops to add at render: the file's `BaselineExposure` plus the
/// chosen profile's `BaselineExposureOffset` (camera-profiles.md §11).
pub baseline_exposure: f32,
}
/// TRACES: FR-DEV-3e
/// Apply camera-profiles.md §4's order to one decoded file.
///
/// `matrices` is the profile the decoder built from the file; `root` the
/// file's root IFD, where a DNG keeps its embedded profile.
pub fn resolve(
matrices: Option<CameraProfile>,
root: Option<&IFD>,
make: &str,
model: &str,
) -> Resolved {
let embedded = root.and_then(Dcp::from_ifd).map(Arc::new);
let file_baseline = root.map_or(0.0, |r| stops(r, DngTag::BaselineExposure));
if let Some(dcp) = &embedded {
let tables = matrices
.as_ref()
.map(|m| dcp.tables(m.scene_temperature(), ProfileOrigin::Embedded))
.filter(|t| !t.is_empty())
.map(Arc::new);
return Resolved {
profile: matrices,
tables,
baseline_exposure: file_baseline + dcp.baseline_exposure_offset,
embedded,
};
}
let unique = root.and_then(|r| string(r, DngTag::UniqueCameraModel));
if let Some((file, dcp)) = find(unique.as_deref(), make, model) {
let neutral = matrices.as_ref().and_then(|m| m.neutral());
if let Some(own) = dcp.camera_profile(neutral) {
let tables = dcp.tables(own.scene_temperature(), ProfileOrigin::File(file));
return Resolved {
tables: (!tables.is_empty()).then(|| Arc::new(tables)),
profile: Some(own),
embedded: None,
baseline_exposure: file_baseline + dcp.baseline_exposure_offset,
};
}
}
Resolved {
profile: matrices,
tables: None,
embedded: None,
baseline_exposure: file_baseline,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn table(h: u32, s: u32, v: u32, fill: [f32; 3]) -> HueSatTable {
HueSatTable::new(h, s, v, false, vec![fill; (h * s * v) as usize]).unwrap()
}
fn sample() -> Dcp {
Dcp {
name: "Test Standard".into(),
unique_camera_model: Some("Canon EOS 6D".into()),
copyright: Some("nobody".into()),
calibration_signature: Some("com.example".into()),
embed_policy: 0,
illuminants: [Some(17), Some(21)],
color_matrix: [
Some([
[0.7546, -0.1435, -0.0929],
[-0.3846, 1.1488, 0.2692],
[-0.0332, 0.1209, 0.637],
]),
Some([
[0.7034, -0.0804, -0.1014],
[-0.442, 1.2564, 0.2058],
[-0.0851, 0.1994, 0.5758],
]),
],
forward_matrix: [
Some([
[0.7763, 0.0065, 0.1815],
[0.2364, 0.8351, -0.0715],
[-0.0059, -0.4228, 1.2538],
]),
Some([
[0.7464, 0.1044, 0.1135],
[0.2648, 0.9173, -0.182],
[0.0113, -0.2154, 1.0292],
]),
],
hue_sat: [
Some(table(6, 3, 1, [2.0, 1.1, 1.0])),
Some(table(6, 3, 1, [-2.0, 0.9, 1.0])),
],
look: Some(table(4, 2, 3, [0.0, 1.2, 0.95])),
tone_curve: Some(vec![0.0, 0.0, 0.5, 0.6, 1.0, 1.0]),
baseline_exposure_offset: 0.25,
}
}
#[test]
fn a_profile_survives_being_written_and_read_back() {
let original = sample();
let bytes = original.to_bytes().unwrap();
assert_eq!(&bytes[2..4], &DCP_MAGIC.to_ne_bytes());
let back = Dcp::parse(&bytes).unwrap();
assert_eq!(
back.hue_sat, original.hue_sat,
"tables are stored as f32 and come back exact"
);
assert_eq!(back.look, original.look);
assert_eq!(back.name, original.name);
assert_eq!(back.unique_camera_model, original.unique_camera_model);
assert_eq!(back.illuminants, original.illuminants);
assert_eq!(back.tone_curve, original.tone_curve);
assert_eq!(back.baseline_exposure_offset, 0.25);
assert_eq!(
back.forward_matrix, original.forward_matrix,
"four decimals, as the file has"
);
}
#[test]
fn a_tiff_is_not_a_profile() {
let mut bytes = sample().to_bytes().unwrap();
bytes[2..4].copy_from_slice(&42u16.to_ne_bytes());
assert!(Dcp::parse(&bytes).is_err());
assert!(Dcp::parse(b"nonsense").is_err());
}
#[test]
fn a_body_matches_by_unique_model_or_by_make_and_model() {
let p = sample();
assert!(p.is_for(None, "Canon", "EOS 6D"));
assert!(p.is_for(None, "canon", "eos 6d"));
assert!(p.is_for(Some("Canon EOS 6D"), "", ""));
assert!(!p.is_for(None, "Canon", "EOS 6D Mark II"));
assert!(!p.is_for(Some("Canon EOS 5D"), "Canon", "EOS 5D"));
// A model that already starts with the make is not doubled.
assert!(p.is_for(None, "Canon", "Canon EOS 6D"));
}
#[test]
fn the_hue_sat_map_follows_the_light_the_frame_was_shot_under() {
let p = sample();
let at = |k| {
p.tables(k, ProfileOrigin::Embedded)
.hue_sat
.unwrap()
.entries[0]
};
assert_eq!(at(2856.0), [2.0, 1.1, 1.0], "tungsten is calibration 1");
assert_eq!(at(6504.0), [-2.0, 0.9, 1.0], "daylight is calibration 2");
let mid = at(4000.0);
assert!(mid[0] > -2.0 && mid[0] < 2.0, "{mid:?}");
}
#[test]
fn a_table_that_changes_nothing_is_not_handed_on() {
let mut p = sample();
p.hue_sat = [Some(table(6, 3, 1, [0.0, 1.0, 1.0])), None];
let t = p.tables(5000.0, ProfileOrigin::Embedded);
assert!(t.hue_sat.is_none());
assert!(t.look.is_some());
}
#[test]
fn only_a_copyable_policy_may_be_copied() {
let mut p = sample();
for (policy, ok) in [(0, true), (1, false), (2, false), (3, true)] {
p.embed_policy = policy;
assert_eq!(p.may_copy(), ok, "policy {policy}");
}
}
/// A Canon 6D DNG from the library, written by Lightroom 6.14 with Adobe
/// Standard embedded. Read from `DR_DCP_SAMPLE`, else the library path the
/// figures in camera-profiles.md §1 came from; skipped where neither
/// exists, because the file is not ours to put in the repository.
fn six_d_dng() -> Option<Vec<u8>> {
let path = std::env::var_os("DR_DCP_SAMPLE")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| {
PathBuf::from(h).join("Nextcloud/PhotosRaw/2017/2017-08-12/_MG_9080.dng")
})
})?;
let bytes = std::fs::read(&path).ok();
if bytes.is_none() {
eprintln!("skipped: no sample DNG at {}", path.display());
}
bytes
}
#[test]
fn the_libraries_six_d_dngs_carry_adobe_standard() {
let Some(bytes) = six_d_dng() else { return };
let p = embedded_in(&bytes).expect("an embedded profile");
assert_eq!(p.name, "Adobe Standard");
assert_eq!(p.unique_camera_model.as_deref(), Some("Canon EOS 6D"));
assert_eq!(p.embed_policy, 0);
let hs = p.hue_sat[0].as_ref().unwrap();
assert_eq!(
(hs.hue_divisions, hs.sat_divisions, hs.val_divisions),
(90, 30, 1)
);
assert!(p.hue_sat[1].is_some());
let look = p.look.as_ref().unwrap();
assert_eq!(
(look.hue_divisions, look.sat_divisions, look.val_divisions),
(36, 8, 16)
);
assert!(p.tone_curve.is_none());
assert!(p.may_copy());
let back = Dcp::parse(&p.to_bytes().unwrap()).unwrap();
assert_eq!(
back.hue_sat, p.hue_sat,
"a copied profile keeps its tables bit for bit"
);
assert_eq!(back.look, p.look);
assert!(
back.is_for(None, "Canon", "EOS 6D"),
"and so applies to the body's CR2s"
);
}
#[test]
fn decoding_the_six_d_dng_hands_on_its_tables() {
let Some(bytes) = six_d_dng() else { return };
let raw = crate::decode(&bytes).unwrap();
let tables = raw.profile_tables.expect("tables");
assert_eq!(tables.origin, ProfileOrigin::Embedded);
assert_eq!(tables.name, "Adobe Standard");
assert!(tables.hue_sat.is_some() && tables.look.is_some());
assert!(
tables.tone_curve.is_none(),
"Adobe Standard has no curve of its own"
);
assert_eq!(raw.baseline_exposure, 0.25);
}
#[test]
fn a_profile_brings_its_own_matrices() {
let p = sample();
let cam = p.camera_profile(Some([0.5, 1.0, 0.7])).unwrap();
assert_eq!(cam.calibrations().len(), 2);
assert!(cam.calibrations().iter().all(|c| c.forward.is_some()));
assert!(cam.cam_to_srgb().is_some());
}
}
+49 -27
View File
@@ -16,14 +16,13 @@
//! 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; pub mod dcp;
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 +124,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
@@ -152,8 +138,23 @@ pub struct RawImage {
/// carry on: calibrations and the as-shot neutral. `None` for a body the /// carry on: calibrations and the as-shot neutral. `None` for a body the
/// decoder has no matrix for. /// decoder has no matrix for.
pub profile: Option<profile::CameraProfile>, pub profile: Option<profile::CameraProfile>,
/// The body, as rawler cleans the names: what `Make`/`Model` say and what /// TRACES: FR-DEV-3e
/// the base-curve database matches on. /// The camera profile's HueSatMap and LookTable, resolved for this frame
/// (D20): embedded in the DNG, or from a matched `.dcp`. `None` renders
/// through the matrix alone.
///
/// Carried with the image, as `color_matrix` is, so that every path that
/// renders a decoded file renders it through the same profile without
/// having to be told — see camera-profiles.md §3.
pub profile_tables: Option<std::sync::Arc<dr_types::ProfileTables>>,
/// TRACES: FR-DEV-3e
/// Stops the render adds before anything else: the file's
/// `BaselineExposure` plus the profile's `BaselineExposureOffset`
/// (camera-profiles.md §11). Applied by the GPU side as a gain on the
/// camera matrix; `color_matrix` itself stays the file's.
pub baseline_exposure: f32,
/// The body, as rawler cleans the names: what `Make`/`Model` say, and
/// what a `.dcp`'s `UniqueCameraModel` is matched against.
pub make: String, pub make: String,
pub model: String, pub model: String,
} }
@@ -549,6 +550,17 @@ pub(crate) fn parse_exif_offset(s: &str) -> Option<i32> {
Some(sign * (h * 60 + m)) Some(sign * (h * 60 + m))
} }
/// TRACES: FR-DEV-3g
/// The DNG `NoiseProfile` of a file, if it carries one: `(S, O)` per CFA
/// colour plane, variance `S·x + O` in black-to-white normalised units. See
/// [`profile::read_noise_profile`]. Reads the header, not the image.
pub fn noise_profile(bytes: &[u8]) -> Option<Vec<(f32, f32)>> {
use rawler::rawsource::RawSource;
let source = RawSource::new_from_slice(bytes);
let decoder = rawler::get_decoder(&source).ok()?;
profile::read_noise_profile(decoder.as_ref())
}
/// TRACES: FR-RAW-3 | FR-EXP-9 /// TRACES: FR-RAW-3 | FR-EXP-9
/// Fully decode sensor data. /// Fully decode sensor data.
/// ///
@@ -586,21 +598,30 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
// is now structural, because there is only one interpolated matrix and // is now structural, because there is only one interpolated matrix and
// both callers ask the same object for it. // both callers ask the same object for it.
let profile = profile::CameraProfile::extract(&image, &dng); let profile = profile::CameraProfile::extract(&image, &dng);
// TRACES: FR-DEV-3e
// The tables, and — where a `.dcp` supplies them — the matrices they were
// built against, which then stand in for the file's (D20).
let root = decoder
.ifd(rawler::decoders::WellKnownIFD::Root)
.ok()
.flatten();
let dcp::Resolved {
profile,
tables: profile_tables,
baseline_exposure,
..
} = dcp::resolve(
profile,
root.as_deref(),
&image.camera.clean_make,
&image.camera.clean_model,
);
let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb()); let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb());
let wb_coeffs = sane_wb( let wb_coeffs = sane_wb(
image.wb_coeffs, image.wb_coeffs,
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,9 +704,10 @@ 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,
profile_tables,
baseline_exposure,
make: image.camera.clean_make.clone(), make: image.camera.clean_make.clone(),
model: image.camera.clean_model.clone(), model: image.camera.clean_model.clone(),
}) })
+36 -5
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};
@@ -519,7 +521,7 @@ fn cct_from_xy(x: f32, y: f32) -> f32 {
/// but a profile calibrated under fluorescent light is describing a sensor /// but a profile calibrated under fluorescent light is describing a sensor
/// under fluorescent light, and placing it at roughly the right colour is much /// under fluorescent light, and placing it at roughly the right colour is much
/// better than discarding it. /// better than discarding it.
fn illuminant_temperature(illuminant: Illuminant) -> Option<f32> { pub(crate) fn illuminant_temperature(illuminant: Illuminant) -> Option<f32> {
Some(match illuminant { Some(match illuminant {
// CIE standard illuminant A: a tungsten filament at 2856 K. The low // CIE standard illuminant A: a tungsten filament at 2856 K. The low
// end of essentially every dual-illuminant profile ever written. // end of essentially every dual-illuminant profile ever written.
@@ -738,6 +740,35 @@ pub fn read_dng_matrices(decoder: &dyn rawler::decoders::Decoder) -> DngMatrices
} }
} }
/// TRACES: FR-DEV-3g
/// The DNG `NoiseProfile` tag (51041): the converter's measured noise for
/// this body at this ISO, as `(S, O)` per CFA colour plane, so that a
/// photosite's variance is `S·x + O` with `x` normalised black-to-white.
///
/// One pair means all planes share it. `None` where the file has no such
/// tag — every proprietary raw, and DNGs from converters that do not measure
/// — or where a value is not a finite non-negative number. The learned
/// denoise's second-best noise source (denoise.md §3.3), after a measured
/// table for the body.
pub fn read_noise_profile(decoder: &dyn rawler::decoders::Decoder) -> Option<Vec<(f32, f32)>> {
use rawler::decoders::WellKnownIFD;
use rawler::tags::DngTag;
let ifd = decoder.ifd(WellKnownIFD::Root).ok()??;
let entry = ifd.get_entry_recursive(DngTag::NoiseProfile)?;
let n = entry.count() as usize;
if n < 2 || !n.is_multiple_of(2) {
return None;
}
let pairs: Vec<(f32, f32)> = (0..n / 2)
.map(|i| (entry.force_f32(2 * i), entry.force_f32(2 * i + 1)))
.collect();
pairs
.iter()
.all(|(s, o)| s.is_finite() && o.is_finite() && *s >= 0.0 && *o >= 0.0)
.then_some(pairs)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "dr-denoise"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
dr-decode.workspace = true
serde = { workspace = true }
serde_norway.workspace = true
thiserror.workspace = true
log.workspace = true
# The network runs under the inference engine like every other model
# (docs/dev/inference.md): `ort` is the API, the engine picks the rung.
# Optional so the noise model and the tiling test without a runtime.
ort = { workspace = true, optional = true }
dr-inference-engine = { workspace = true, optional = true }
ndarray = { workspace = true, optional = true }
[features]
default = ["onnx"]
onnx = ["dep:ort", "dep:dr-inference-engine", "dep:ndarray"]
# A real ONNX Runtime from disk rather than tract alone, as the app links it.
native = ["onnx", "dr-inference-engine/native"]
[dev-dependencies]
# The example repairs hot photosites with the app's own pass, as develop will.
dr-gpu.workspace = true
pollster.workspace = true
env_logger.workspace = true
+132
View File
@@ -0,0 +1,132 @@
//! Denoise one RAW file end to end, as develop will, and time it.
//!
//! ```sh
//! DARKROOM_ORT_DIR=~/.local/share/darkroom/runtime \
//! cargo run --release -p dr-denoise --features native --example denoise_raw -- IMG.CR2 out
//! ```
//!
//! Decode, the app's hot-pixel pass, the frame's noise from its best source,
//! then the shipped network under the inference engine on whatever rung this
//! machine probes to. Writes `out.npy` — the active area, `h×w×3` f32 linear
//! camera RGB — for comparison with the training repo's own path
//! (`tools/compare_rust.py` in darkroom-denoise). `DARKROOM_ORT_DIR` points
//! at an ONNX Runtime build; the engine's cache goes to `DR_ENGINE_CACHE` or
//! a temporary directory.
use std::path::PathBuf;
use std::time::{Duration, Instant};
use dr_denoise::onnx::OnnxNet;
use dr_inference_engine::{Config, Role};
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let mut args = std::env::args().skip(1);
let (Some(input), Some(out)) = (args.next(), args.next()) else {
eprintln!("usage: denoise_raw RAW OUT_PREFIX");
std::process::exit(2);
};
let model =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../models/denoise/mosaic-1408.onnx");
let cache = std::env::var_os("DR_ENGINE_CACHE")
.map(PathBuf::from)
.unwrap_or_else(|| std::env::temp_dir().join("dr-denoise-engines"));
let started = Instant::now();
dr_inference_engine::init(Config {
runtime_dirs: std::env::var_os("DARKROOM_ORT_DIR")
.map(PathBuf::from)
.into_iter()
.collect(),
cache_dir: cache,
models: vec![(Role::Denoiser, model.clone())],
embedded: Vec::new(),
ceiling: None,
threads: 0,
decay: Duration::ZERO,
});
// Wait for the probe and the engine build, so the timing below is the
// rung this machine settles on, not the fallback used while it compiles.
// The probe starts on its own thread; give it a moment to say so.
std::thread::sleep(Duration::from_secs(1));
loop {
let s = dr_inference_engine::status();
if !s.probing && s.engines.0 >= s.engines.1 {
println!(
"engine {} ({:.1} s to settle)",
s.line(),
started.elapsed().as_secs_f64()
);
break;
}
std::thread::sleep(Duration::from_millis(200));
}
let bytes = std::fs::read(&input).expect("read raw");
let t = Instant::now();
let mut raw = dr_decode::decode(&bytes).expect("decode");
let meta = dr_decode::metadata(&bytes).expect("metadata");
let decode = t.elapsed();
let t = Instant::now();
let ctx =
pollster::block_on(dr_gpu::GpuContext::new_headless()).expect("GPU for the hot-pixel pass");
let repaired = dr_gpu::Demosaicer::new(&ctx)
.expect("demosaicer")
.repair_hot_pixels(&mut raw)
.expect("repair");
let repair = t.elapsed();
let noise = dr_denoise::noise::for_frame(&raw, &bytes, meta.iso)
.expect("no noise source for this frame");
println!(
"frame {} {} ISO {:?}, {}×{}, {:?}, {repaired} hot photosites repaired",
raw.make, raw.model, meta.iso, raw.crop.width, raw.crop.height, raw.cfa_pattern
);
println!(
"noise {} — σ at 10 % grey (G) {:.5}, read {:.5}, row {:.5}, col {:.5}",
noise.source.label(),
noise.sigma(1, 0.1),
noise.o[1].sqrt(),
noise.row,
noise.col
);
let mut net = OnnxNet::from_path(&model).expect("model");
println!(
"rung {}",
net.rung().map(|r| r.label()).unwrap_or("?")
);
let t = Instant::now();
let rgb = dr_denoise::denoise(&raw, &noise, &mut net, &mut |done, total| {
eprint!("\rtile {done}/{total}");
true
})
.expect("denoise")
.expect("not cancelled");
let run = t.elapsed();
eprintln!();
println!(
"time decode {:.2} s · hot pixels {:.2} s · network {:.2} s ({:.1} MP)",
decode.as_secs_f64(),
repair.as_secs_f64(),
run.as_secs_f64(),
(raw.crop.width * raw.crop.height) as f64 / 1e6
);
let (h, w) = (raw.crop.height as usize, raw.crop.width as usize);
let mut npy = Vec::with_capacity(rgb.len() * 4 + 128);
let mut header =
format!("{{'descr': '<f4', 'fortran_order': False, 'shape': ({h}, {w}, 3), }}");
while (10 + header.len() + 1) % 64 != 0 {
header.push(' ');
}
header.push('\n');
npy.extend_from_slice(b"\x93NUMPY\x01\x00");
npy.extend_from_slice(&(header.len() as u16).to_le_bytes());
npy.extend_from_slice(header.as_bytes());
for v in &rgb {
npy.extend_from_slice(&v.to_le_bytes());
}
std::fs::write(format!("{out}.npy"), npy).expect("write");
println!("wrote {out}.npy");
}
+79
View File
@@ -0,0 +1,79 @@
//! TRACES: FR-DEV-3g
//! Learned demosaic and denoise on the raw mosaic (docs/dev/denoise.md).
//!
//! A network trained on the library's own base-ISO raws with the 6D's
//! measured noise added takes the repaired, normalised mosaic and a σ for
//! every photosite, and returns linear camera RGB at full resolution — the
//! texture the classical demosaic would have produced, with the noise gone.
//! It replaces the demosaic box; nothing downstream changes (§2).
//!
//! - [`noise`] says how noisy each photosite is, from the best source the
//! frame has.
//! - [`tile`] runs a fixed-shape network over a whole frame, exactly.
//! - [`onnx`] is that network under the inference engine.
//!
//! The input must already have been through the app's hot-pixel pass
//! (`dr_gpu::Demosaicer::repair_hot_pixels`): the noise model was fitted
//! with what that pass removes left out.
pub mod noise;
#[cfg(feature = "onnx")]
pub mod onnx;
pub mod tile;
use dr_decode::RawImage;
pub use noise::{NoiseModel, Source};
pub use tile::{TileNet, HALO};
#[derive(Debug, thiserror::Error)]
pub enum DenoiseError {
#[error("the network cannot take this photograph: {0}")]
Unsupported(String),
#[error("the denoise model misbehaved: {0}")]
Model(String),
#[error("could not read the denoise model: {0}")]
ModelRead(#[from] std::io::Error),
#[cfg(feature = "onnx")]
#[error(transparent)]
Engine(#[from] dr_inference_engine::Error),
#[cfg(feature = "onnx")]
#[error(transparent)]
Ort(#[from] ort::Error),
}
/// Whether the learned stage can take this frame at all: a Bayer mosaic.
/// X-Trans needs its own model (§9); a linear DNG has no photosites.
pub fn eligible(raw: &RawImage) -> bool {
raw.samples_per_pixel == 1 && tile::rggb_offset(raw.cfa_pattern).is_some()
}
/// The active area of `raw`, denoised and demosaiced: `crop.height ×
/// crop.width` interleaved RGB, linear camera space, normalised black 0 and
/// white 1 per photosite as the classical demosaic normalises.
///
/// `raw` must be hot-pixel repaired. `None` when `progress` stopped it.
pub fn denoise(
raw: &RawImage,
noise: &NoiseModel,
net: &mut dyn TileNet,
progress: &mut dyn FnMut(usize, usize) -> bool,
) -> Result<Option<Vec<f32>>, DenoiseError> {
if !eligible(raw) {
return Err(DenoiseError::Unsupported(format!(
"{:?} with {} samples per photosite",
raw.cfa_pattern, raw.samples_per_pixel
)));
}
let active = noise::active(raw);
let (h, w) = (active.h, active.w);
tile::run_tiled(
net,
h,
w,
raw.cfa_pattern,
&|y, x| active.at(y, x),
&|c, v| noise.sigma(c, v),
progress,
)
}
+407
View File
@@ -0,0 +1,407 @@
//! TRACES: FR-DEV-3g
//! How noisy each photosite is: the network is told, not left to guess
//! (denoise.md §3.3).
//!
//! The model is `σ² = S·x + O + row² + col²` per photosite, `x` the signal
//! normalised black-to-white the way the demosaic normalises it. Three
//! sources, best first:
//!
//! 1. **A measured table** for the body ([`Source::Table`]) — the Canon EOS 6D
//! today, from the library's own frames.
//! 2. **The DNG's `NoiseProfile`** ([`Source::DngProfile`]) — what Adobe's
//! converter measured for the body at that ISO.
//! 3. **The frame itself** ([`Source::Measured`]) — read, row and column
//! noise from its masked border, which is a dark frame taken in the same
//! instant, and only the shot gain estimated, from the quietest flat
//! patches. Checked against the 6D's table on 130 frames: within ±10 % at
//! ISO 1000 and above, scattered below; the network loses under 0.3 dB for
//! a σ off by 15–20 %, and over-estimating costs half what
//! under-estimating does, so the estimate leans high.
//!
//! Row and column noise come from the masked border whenever the frame has
//! one, whatever the source of the rest.
use dr_decode::{CfaPattern, RawImage};
use serde::Deserialize;
/// Where a frame's noise figures came from, for develop to say.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
Table,
DngProfile,
Measured,
}
impl Source {
pub fn label(self) -> &'static str {
match self {
Source::Table => "measured for this camera",
Source::DngProfile => "from the DNG's noise profile",
Source::Measured => "estimated from this photograph",
}
}
}
/// Per-photosite noise in the frame's own normalisation (black 0, white 1).
#[derive(Clone, Debug, PartialEq)]
pub struct NoiseModel {
/// Shot gain per colour, R G B.
pub s: [f32; 3],
/// Read variance per colour, R G B.
pub o: [f32; 3],
/// Standard deviation shared by a whole row, and by a whole column.
pub row: f32,
pub col: f32,
pub source: Source,
}
impl NoiseModel {
/// σ for a photosite of colour `c` (0 R, 1 G, 2 B) reading `x`.
#[inline]
pub fn sigma(&self, c: usize, x: f32) -> f32 {
(self.s[c] * x.max(0.0) + self.o[c] + self.row * self.row + self.col * self.col).sqrt()
}
/// The same figures scaled for the Amount the spec describes (§3.3):
/// above 1 tells the network there is more noise than there is.
pub fn scaled(&self, amount: f32) -> NoiseModel {
let a2 = amount * amount;
NoiseModel {
s: self.s.map(|v| v * a2),
o: self.o.map(|v| v * a2),
row: self.row * amount,
col: self.col * amount,
source: self.source,
}
}
}
/// The frame's noise, from the best source it has.
///
/// `bytes` is the file (for a DNG's `NoiseProfile`), `iso` its EXIF ISO.
/// `None` only for a frame with no masked border, no profile and no table
/// that is also too dark or too busy to measure.
pub fn for_frame(raw: &RawImage, bytes: &[u8], iso: Option<u32>) -> Option<NoiseModel> {
for_frame_with(raw, dr_decode::noise_profile(bytes).as_deref(), iso)
}
/// [`for_frame`], given the file's `NoiseProfile` already read
/// ([`dr_decode::noise_profile`]) rather than the file, for a caller that
/// keeps the header's answer and not the bytes.
pub fn for_frame_with(
raw: &RawImage,
profile: Option<&[(f32, f32)]>,
iso: Option<u32>,
) -> Option<NoiseModel> {
let dark = dark_border(raw);
let mut model = iso
.and_then(|iso| from_table(raw, iso))
.or_else(|| profile.and_then(|p| from_dng_profile(raw, p)))
.or_else(|| measured(raw, dark.as_ref()))?;
if let Some(d) = dark {
// The border saw this exposure's row and column noise directly.
if model.source != Source::Table {
model.row = d.row;
model.col = d.col;
}
}
Some(model)
}
#[derive(Deserialize)]
struct Table {
make: String,
model: String,
rows: Vec<TableRow>,
}
#[derive(Deserialize)]
struct TableRow {
iso: u32,
s_dn: [f32; 4],
o_dn: [f32; 4],
row_dn: f32,
col_dn: f32,
}
const TABLES: &[&str] = &[include_str!("../tables/canon-eos-6d.yaml")];
/// The body's measured table at the nearest ISO it holds, converted from DN
/// to this frame's normalisation.
pub fn from_table(raw: &RawImage, iso: u32) -> Option<NoiseModel> {
let table = TABLES.iter().find_map(|t| {
let t: Table = serde_norway::from_str(t).ok()?;
(t.make.eq_ignore_ascii_case(&raw.make) && t.model.eq_ignore_ascii_case(&raw.model))
.then_some(t)
})?;
let row = table.rows.iter().min_by(|a, b| {
let d = |r: &TableRow| ((r.iso as f32).ln() - (iso as f32).ln()).abs();
d(a).total_cmp(&d(b))
})?;
let span = span(raw);
// RGGB positions → colours: the greens share.
let s = [row.s_dn[0], 0.5 * (row.s_dn[1] + row.s_dn[2]), row.s_dn[3]].map(|v| v / span);
let o =
[row.o_dn[0], 0.5 * (row.o_dn[1] + row.o_dn[2]), row.o_dn[3]].map(|v| v / (span * span));
Some(NoiseModel {
s,
o,
row: row.row_dn / span,
col: row.col_dn / span,
source: Source::Table,
})
}
/// A DNG's `NoiseProfile`: one pair for every plane, or one per colour plane
/// (R, G, B for a Bayer DNG), already in the file's black-to-white units —
/// which are the units `dr-decode` normalises by.
pub fn from_dng_profile(raw: &RawImage, pairs: &[(f32, f32)]) -> Option<NoiseModel> {
if raw.cfa_pattern.is_xtrans() || raw.samples_per_pixel != 1 {
return None;
}
let (s, o) = match pairs {
[(s, o)] => ([*s; 3], [*o; 3]),
[r, g, b, ..] => ([r.0, g.0, b.0], [r.1, g.1, b.1]),
_ => return None,
};
Some(NoiseModel {
s,
o,
row: 0.0,
col: 0.0,
source: Source::DngProfile,
})
}
/// Read, row and column noise measured on the masked border, normalised.
#[derive(Clone, Copy, Debug)]
pub struct Dark {
pub read: f32,
pub row: f32,
pub col: f32,
}
/// The optically black photosites beside and above the active area.
///
/// Keeps well clear of the active area: on the 6D the dozen columns nearest
/// it see light. Photosites over 8σ are the strip's own hot photosites — the
/// same ones in every frame — and are left out, as the app's hot-pixel pass
/// removes their kin before the network sees them.
pub fn dark_border(raw: &RawImage) -> Option<Dark> {
let (x0, y0, w, h) = (
raw.crop.x as usize,
raw.crop.y as usize,
raw.crop.width as usize,
raw.crop.height as usize,
);
let stride = raw.width as usize;
let span = span(raw);
if x0 < 40 || raw.samples_per_pixel != 1 {
return None;
}
let cols = 4..x0 - 16;
let nc = cols.len() as f32;
// Residual after removing each row's mean and each column's mean.
let mut row_means = Vec::with_capacity(h);
let mut col_sum = vec![0.0f64; cols.len()];
for y in y0..y0 + h {
let line = &raw.data[y * stride..y * stride + x0];
let m = cols.clone().map(|x| line[x] as f32).sum::<f32>() / nc;
row_means.push(m);
for (k, x) in cols.clone().enumerate() {
col_sum[k] += (line[x] as f32 - m) as f64;
}
}
let col_mean: Vec<f32> = col_sum.iter().map(|s| (*s / h as f64) as f32).collect();
let resid = |y: usize, k: usize, x: usize| {
raw.data[y * stride + x] as f32 - row_means[y - y0] - col_mean[k]
};
let (mut s1, mut n) = (0.0f64, 0usize);
for y in y0..y0 + h {
for (k, x) in cols.clone().enumerate() {
s1 += (resid(y, k, x) as f64).powi(2);
n += 1;
}
}
let rough = (s1 / n as f64).sqrt() as f32;
let (mut s2, mut n2) = (0.0f64, 0usize);
for y in y0..y0 + h {
for (k, x) in cols.clone().enumerate() {
let r = resid(y, k, x);
if r.abs() < 8.0 * rough {
s2 += (r as f64).powi(2);
n2 += 1;
}
}
}
let read = (s2 / n2.max(1) as f64).sqrt() as f32;
let rm = row_means.iter().sum::<f32>() / h as f32;
let row_var = row_means.iter().map(|m| (m - rm).powi(2)).sum::<f32>() / h as f32;
let row = (row_var - read * read / nc).max(0.0).sqrt();
// Columns: the masked rows above the image span every column.
let col = if y0 >= 24 {
let rows = 4..y0 - 12;
let nr = rows.len() as f32;
let means: Vec<f32> = (x0..x0 + w)
.map(|x| {
rows.clone()
.map(|y| raw.data[y * stride + x] as f32)
.sum::<f32>()
/ nr
})
.collect();
let mm = means.iter().sum::<f32>() / means.len() as f32;
let var = means.iter().map(|m| (m - mm).powi(2)).sum::<f32>() / means.len() as f32;
(var - read * read / nr).max(0.0).sqrt()
} else {
0.0
};
Some(Dark {
read: read / span,
row: row / span,
col: col / span,
})
}
/// The quietest-third bias of the patch variance, and the residual bias the
/// estimate showed against the 6D's table (0.91 at the median), in one: the
/// estimate is divided by this.
const QUIET_FACTOR: f32 = 0.85 * 0.91;
/// The frame's own noise: read noise from the border (or, lacking one, the
/// floor of the quietest patches), shot gain from flat patches of one green
/// plane, the same for every colour, as a sensor's gain is.
pub fn measured(raw: &RawImage, dark: Option<&Dark>) -> Option<NoiseModel> {
if raw.cfa_pattern.is_xtrans() || raw.samples_per_pixel != 1 {
return None;
}
let m = active(raw);
let (h, w) = (m.h, m.w);
// One green plane at a two-photosite pitch.
let (gy, gx) = green_offset(raw.cfa_pattern)?;
let ph = (h - gy) / 2;
let pw = (w - gx) / 2;
let g = |y: usize, x: usize| m.at(gy + 2 * y, gx + 2 * x);
const B: usize = 8;
let mut patches: Vec<(f32, f32)> = Vec::new(); // (level, variance)
for by in 0..ph / B {
for bx in 0..(pw - 2) / B {
let (mut s, mut s2, mut lv) = (0.0f32, 0.0f32, 0.0f32);
for y in by * B..by * B + B {
for x in bx * B..bx * B + B {
// Second difference: cancels any gradient; var = 6σ².
let d = g(y, x + 2) - 2.0 * g(y, x + 1) + g(y, x);
s += d;
s2 += d * d;
lv += g(y, x + 1);
}
}
let n = (B * B) as f32;
let var = (s2 / n - (s / n).powi(2)) / 6.0;
patches.push((lv / n, var));
}
}
let floor = dark.map(|d| d.read);
let lo = 4.0 * floor.unwrap_or(0.002);
patches.retain(|(l, _)| *l > lo && *l < 0.7);
if patches.len() < 500 {
return None;
}
patches.sort_by(|a, b| a.0.total_cmp(&b.0));
let bins = 12;
let per = patches.len() / bins;
let mut ests = Vec::new();
let mut floors = Vec::new();
for b in 0..bins {
let mut bin: Vec<(f32, f32)> = patches[b * per..(b + 1) * per].to_vec();
if bin.len() < 60 {
continue;
}
bin.sort_by(|a, b| a.1.total_cmp(&b.1));
let quiet = &bin[..bin.len() / 3];
let read2 = floor.map(|r| r * r);
let mut e: Vec<f32> = quiet
.iter()
.map(|(l, v)| (v / QUIET_FACTOR - read2.unwrap_or(0.0)) / l)
.collect();
e.sort_by(f32::total_cmp);
ests.push(e[e.len() / 2]);
floors.push(quiet[quiet.len() / 2]);
}
ests.sort_by(f32::total_cmp);
let s = *ests.get(ests.len() / 2)?;
if !(s.is_finite() && s > 0.0) {
return None;
}
// No border: the read variance is what the darkest bin leaves unexplained.
let read2 = match floor {
Some(r) => r * r,
None => {
let (l, v) = floors.first().copied()?;
(v / QUIET_FACTOR - s * l).max(1e-9)
}
};
Some(NoiseModel {
s: [s; 3],
o: [read2; 3],
row: dark.map_or(0.0, |d| d.row),
col: dark.map_or(0.0, |d| d.col),
source: Source::Measured,
})
}
/// Black-to-white range of the frame, as the demosaic normalises it.
pub(crate) fn span(raw: &RawImage) -> f32 {
let black = raw.black_level.iter().map(|&b| b as f32).sum::<f32>() / 4.0;
(raw.white_level as f32 - black).max(1.0)
}
/// Where a green photosite sits in the pattern's 2×2 cell, (dy, dx).
fn green_offset(p: CfaPattern) -> Option<(usize, usize)> {
match p {
CfaPattern::Rggb | CfaPattern::Bggr => Some((0, 1)),
CfaPattern::Grbg | CfaPattern::Gbrg => Some((0, 0)),
_ => None,
}
}
/// The active area, normalised, read lazily.
pub(crate) struct Active<'a> {
raw: &'a RawImage,
black: [f32; 4],
inv: [f32; 4],
pub h: usize,
pub w: usize,
}
impl Active<'_> {
/// Photosite (y, x) of the active area, black 0, white 1.
#[inline]
pub fn at(&self, y: usize, x: usize) -> f32 {
let c = (y & 1) * 2 + (x & 1);
let v = self.raw.data[(self.raw.crop.y as usize + y) * self.raw.width as usize
+ self.raw.crop.x as usize
+ x];
(v as f32 - self.black[c]) * self.inv[c]
}
}
/// Black levels per position of the crop's 2×2 cell, as the demosaic reads
/// them: one reported level is broadcast.
pub(crate) fn active(raw: &RawImage) -> Active<'_> {
let b = raw.black_level;
let black = if b[1] == 0 && b[2] == 0 && b[3] == 0 {
[b[0] as f32; 4]
} else {
b.map(|v| v as f32)
};
let inv = black.map(|bl| 1.0 / (raw.white_level as f32 - bl).max(1.0));
Active {
raw,
black,
inv,
h: raw.crop.height as usize,
w: raw.crop.width as usize,
}
}
+66
View File
@@ -0,0 +1,66 @@
//! TRACES: FR-DEV-3g
//! The denoise network under the inference engine.
//!
//! The shipped export takes `mosaic` and `sigma`, `1×1×1408×1408`, and
//! returns `rgb`, `1×3×1408×1408` (darkroom-denoise `denoise/export.py`,
//! fixed shape because every model the engine runs is). The engine picks the
//! rung: fp16 on TensorRT and MIGraphX, which measured 0.00 dB from f32; f32
//! on CUDA and the CPU; never the Hexagon, where int8 lost 6–9 dB.
use crate::tile::TileNet;
use crate::DenoiseError;
use dr_inference_engine::{Model, Role};
/// The edge of the tile the shipped export takes.
pub const TILE: usize = 1408;
pub struct OnnxNet {
model: Model,
tile: usize,
}
impl OnnxNet {
pub fn from_path(path: &std::path::Path) -> Result<Self, DenoiseError> {
let (path, form) = dr_inference_engine::resolve_model(Role::Denoiser, path);
let bytes = std::fs::read(&path)?;
Ok(OnnxNet {
model: dr_inference_engine::open(Role::Denoiser, form, &bytes)?,
tile: TILE,
})
}
/// Where it runs, for a status line.
pub fn rung(&self) -> Result<dr_inference_engine::Rung, DenoiseError> {
Ok(self.model.acquire()?.rung())
}
}
impl TileNet for OnnxNet {
fn tile(&self) -> usize {
self.tile
}
fn run(&mut self, mosaic: &[f32], sigma: &[f32]) -> Result<Vec<f32>, DenoiseError> {
let n = self.tile;
let shape = ndarray::IxDyn(&[1, 1, n, n]);
let m = ort::value::Tensor::from_array(
ndarray::Array::from_shape_vec(shape.clone(), mosaic.to_vec())
.map_err(|e| DenoiseError::Model(e.to_string()))?,
)?;
let s = ort::value::Tensor::from_array(
ndarray::Array::from_shape_vec(shape, sigma.to_vec())
.map_err(|e| DenoiseError::Model(e.to_string()))?,
)?;
let acquired = self.model.acquire()?;
let mut session = acquired.lock();
let outputs = session.run(ort::inputs!["mosaic" => m, "sigma" => s])?;
let (shape, data) = outputs[0].try_extract_tensor::<f32>()?;
let dims: Vec<i64> = shape.iter().copied().collect();
if dims != [1, 3, n as i64, n as i64] {
return Err(DenoiseError::Model(format!(
"output is {dims:?}, expected [1, 3, {n}, {n}]"
)));
}
Ok(data.to_vec())
}
}
+295
View File
@@ -0,0 +1,295 @@
//! TRACES: FR-DEV-3g
//! A whole frame through a fixed-shape network, exactly (denoise.md §3.4).
//!
//! The network sees `TILE_IN`² photosites and its output is exact in the
//! central `TILE_IN − 2·HALO`: the halo is wider than its receptive field
//! (185 photosites, counted from the layers), so a tile's centre equals the
//! whole frame's at the same place. The frame is extended by reflection
//! about its edge photosites, which keeps every photosite's CFA colour, so
//! edge tiles see real context too.
//!
//! **Phase.** The network was trained on RGGB. A frame whose pattern starts
//! on another colour is read from one photosite up and/or left — the
//! reflection supplies that row or column — so its top-left is red, and the
//! output is read back from the same offset. Nothing is cropped.
use dr_decode::CfaPattern;
/// Photosites of context beyond a tile's kept centre, on every side.
pub const HALO: usize = 192;
/// A fixed-shape network: `mosaic` and `sigma`, `n×n` RGGB, in; `3×n×n`
/// planar linear camera RGB out.
pub trait TileNet {
/// The edge `n` of the square tile the network takes.
fn tile(&self) -> usize;
fn run(&mut self, mosaic: &[f32], sigma: &[f32]) -> Result<Vec<f32>, crate::DenoiseError>;
}
/// Index into `0..n` by reflection about the end photosites, any distance
/// out: …2 1 [0 1 2 … n−1] n−2 n−3…, period `2(n−1)`. Parity is kept, which
/// is what keeps a CFA colour.
#[inline]
pub fn reflect(i: isize, n: usize) -> usize {
if n == 1 {
return 0;
}
let p = 2 * (n as isize - 1);
let m = i.rem_euclid(p);
(if m < n as isize { m } else { p - m }) as usize
}
/// How far up and left to start reading so the first photosite is red.
pub fn rggb_offset(p: CfaPattern) -> Option<(usize, usize)> {
match p {
CfaPattern::Rggb => Some((0, 0)),
CfaPattern::Grbg => Some((0, 1)),
CfaPattern::Gbrg => Some((1, 0)),
CfaPattern::Bggr => Some((1, 1)),
_ => None,
}
}
/// Run `net` over an `h×w` mosaic given by `at(y, x)`, with σ from
/// `sigma(colour, value)`, and return `h×w` interleaved RGB.
///
/// `progress(done, total)` is called after each tile and stops the run by
/// returning `false`, in which case the result is `Ok(None)`.
#[allow(clippy::too_many_arguments)]
pub fn run_tiled(
net: &mut dyn TileNet,
h: usize,
w: usize,
pattern: CfaPattern,
at: &dyn Fn(usize, usize) -> f32,
sigma: &dyn Fn(usize, f32) -> f32,
progress: &mut dyn FnMut(usize, usize) -> bool,
) -> Result<Option<Vec<f32>>, crate::DenoiseError> {
let (dy, dx) = rggb_offset(pattern).ok_or_else(|| {
crate::DenoiseError::Unsupported(format!("{pattern:?} is not a Bayer pattern"))
})?;
let n = net.tile();
if n <= 2 * HALO || !(n - 2 * HALO).is_multiple_of(2) {
return Err(crate::DenoiseError::Model(format!(
"tile {n} leaves no even centre past a {HALO} halo"
)));
}
let core = n - 2 * HALO;
// In unified coordinates the frame spans u ∈ [dy, dy + h), v ∈ [dx, dx + w).
let (uh, uw) = (h + dy, w + dx);
let (ty, tx) = (uh.div_ceil(core), uw.div_ceil(core));
let total = ty * tx;
let mut out = vec![0.0f32; h * w * 3];
let mut mos = vec![0.0f32; n * n];
let mut sig = vec![0.0f32; n * n];
// RGGB colour of unified position (u, v).
let colour = |u: usize, v: usize| [[0, 1], [1, 2]][u & 1][v & 1];
for (k, (i, j)) in (0..ty)
.flat_map(|i| (0..tx).map(move |j| (i, j)))
.enumerate()
{
let (u0, v0) = (i * core, j * core);
for r in 0..n {
// Unified row u = u0 + r − HALO; frame row y = u − dy, reflected.
let u = u0 as isize + r as isize - HALO as isize;
let y = reflect(u - dy as isize, h);
for c in 0..n {
let v = v0 as isize + c as isize - HALO as isize;
let x = reflect(v - dx as isize, w);
let val = at(y, x);
mos[r * n + c] = val;
sig[r * n + c] = sigma(colour(r, c), val);
}
}
let rgb = net.run(&mos, &sig)?;
if rgb.len() != 3 * n * n {
return Err(crate::DenoiseError::Model(format!(
"network returned {} values for a {n}² tile",
rgb.len()
)));
}
for r in HALO..HALO + core {
let u = u0 + r - HALO;
if u < dy || u >= uh {
continue;
}
let y = u - dy;
for c in HALO..HALO + core {
let v = v0 + c - HALO;
if v < dx || v >= uw {
continue;
}
let x = v - dx;
let o = (y * w + x) * 3;
for ch in 0..3 {
out[o + ch] = rgb[ch * n * n + r * n + c];
}
}
}
if !progress(k + 1, total) {
return Ok(None);
}
}
Ok(Some(out))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reflection_keeps_parity_any_distance_out() {
let n = 7;
for i in -40isize..40 {
let r = reflect(i, n);
assert!(r < n);
assert_eq!(
r % 2,
i.rem_euclid(2) as usize,
"index {i} reflected to {r}"
);
}
assert_eq!(reflect(-1, n), 1);
assert_eq!(reflect(7, n), 5);
}
/// A stand-in network with a known, finite reach: each output photosite
/// is its 2×2 quad's (R, mean G, B), averaged over the quads within
/// `reach` quads. Purely a function of the tile, like the real one.
struct BoxNet {
n: usize,
reach: usize,
}
impl TileNet for BoxNet {
fn tile(&self) -> usize {
self.n
}
fn run(&mut self, m: &[f32], _s: &[f32]) -> Result<Vec<f32>, crate::DenoiseError> {
let n = self.n;
let q = n / 2;
let quad = |qy: usize, qx: usize| {
let (y, x) = (2 * qy, 2 * qx);
[
m[y * n + x],
0.5 * (m[y * n + x + 1] + m[(y + 1) * n + x]),
m[(y + 1) * n + x + 1],
]
};
let mut out = vec![0.0; 3 * n * n];
for qy in 0..q {
for qx in 0..q {
let mut acc = [0.0f32; 3];
let mut cnt = 0.0;
for a in qy.saturating_sub(self.reach)..(qy + self.reach + 1).min(q) {
for b in qx.saturating_sub(self.reach)..(qx + self.reach + 1).min(q) {
let v = quad(a, b);
for c in 0..3 {
acc[c] += v[c];
}
cnt += 1.0;
}
}
for (dy, dx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
for c in 0..3 {
out[c * n * n + (2 * qy + dy) * n + 2 * qx + dx] = acc[c] / cnt;
}
}
}
}
Ok(out)
}
}
/// The mosaic of a smooth colour field in `pattern`, read at (y, x).
fn field(pattern: CfaPattern) -> impl Fn(usize, usize) -> f32 {
move |y, x| {
let rgb = [0.2 + 0.0004 * x as f32, 0.5, 0.1 + 0.0003 * y as f32];
rgb[pattern.colour_at(x as u32, y as u32) as usize]
}
}
#[test]
fn every_bayer_phase_comes_back_as_its_own_colours() {
// A frame of each pattern, its colours known: the network must see
// red where the frame's red photosites are, whatever the phase.
for p in [
CfaPattern::Rggb,
CfaPattern::Grbg,
CfaPattern::Gbrg,
CfaPattern::Bggr,
] {
let (h, w) = (300, 410);
let at = field(p);
let mut net = BoxNet {
n: 2 * HALO + 64,
reach: 0,
};
let out = run_tiled(&mut net, h, w, p, &at, &|_, _| 0.01, &mut |_, _| true)
.unwrap()
.unwrap();
for (y, x) in [(10, 10), (150, 201), (299, 409), (0, 0), (77, 333)] {
let o = &out[(y * w + x) * 3..(y * w + x) * 3 + 3];
let want = [0.2 + 0.0004 * x as f32, 0.5, 0.1 + 0.0003 * y as f32];
for c in 0..3 {
// Within the quad the binned value is at most a photosite away.
assert!(
(o[c] - want[c]).abs() < 0.0012,
"{p:?} at ({y},{x}) channel {c}: {} vs {}",
o[c],
want[c]
);
}
}
}
}
#[test]
fn tiles_reproduce_one_pass_over_the_reflected_frame() {
// A network whose reach is inside the halo gives the same answer
// tiled small as in one tile covering everything.
let (h, w) = (230, 170);
for p in [CfaPattern::Rggb, CfaPattern::Bggr] {
let at = |y: usize, x: usize| ((y * 7919 + x * 104729) % 1000) as f32 / 1000.0;
let mut small = BoxNet {
n: 2 * HALO + 32,
reach: 20,
};
let mut big = BoxNet {
n: 2 * HALO + 256,
reach: 20,
};
let a = run_tiled(&mut small, h, w, p, &at, &|_, _| 0.0, &mut |_, _| true)
.unwrap()
.unwrap();
let b = run_tiled(&mut big, h, w, p, &at, &|_, _| 0.0, &mut |_, _| true)
.unwrap()
.unwrap();
let worst = a
.iter()
.zip(&b)
.map(|(x, y)| (x - y).abs())
.fold(0.0f32, f32::max);
assert!(worst < 1e-5, "{p:?}: tiled and whole differ by {worst}");
}
}
#[test]
fn a_cancelled_run_returns_nothing() {
let mut net = BoxNet {
n: 2 * HALO + 32,
reach: 0,
};
let r = run_tiled(
&mut net,
100,
100,
CfaPattern::Rggb,
&|_, _| 0.5,
&|_, _| 0.0,
&mut |done, _| done < 2,
)
.unwrap();
assert!(r.is_none());
}
}
+36
View File
@@ -0,0 +1,36 @@
# Canon EOS 6D noise, measured from the library's own frames (denoise.md §5).
# Shot gain S and read variance O per RGGB position from Adobe's NoiseProfile in
# converted DNGs, in DN at the ISO's own white level; read noise checked against
# the masked border (within 2-3 %); row and column noise from the masked border.
# ISO 50 and 100 are extrapolated (S proportional to ISO). Generated by
# darkroom-denoise tools/profile.py; regenerate there, never edit by hand.
make: Canon
model: EOS 6D
black: 2048
rows:
- {iso: 50, white: 15000, s_dn: [0.0854021, 0.085467, 0.085467, 0.0839724], o_dn: [38.2741, 38.6675, 38.6675, 38.9649], row_dn: 0.3423, col_dn: 0.505}
- {iso: 100, white: 15000, s_dn: [0.170804, 0.170934, 0.170934, 0.167945], o_dn: [38.339, 38.7332, 38.7332, 39.031], row_dn: 0.3423, col_dn: 0.505}
- {iso: 125, white: 15035, s_dn: [0.228108, 0.230361, 0.230361, 0.228345], o_dn: [36.9455, 37.8995, 37.8995, 38.2358], row_dn: 0.3423, col_dn: 0.505}
- {iso: 160, white: 12373, s_dn: [0.289653, 0.294915, 0.294915, 0.286887], o_dn: [15.3717, 16.1346, 16.1346, 16.0413], row_dn: 0.212, col_dn: 0.07151}
- {iso: 200, white: 15035, s_dn: [0.370969, 0.369922, 0.369922, 0.361443], o_dn: [24.2761, 24.0847, 24.0847, 24.2414], row_dn: 0.2692, col_dn: 0}
- {iso: 250, white: 15035, s_dn: [0.461889, 0.457975, 0.457975, 0.449318], o_dn: [38.0975, 37.5041, 37.5041, 37.7424], row_dn: 0.3345, col_dn: 0.4786}
- {iso: 320, white: 12323, s_dn: [0.590765, 0.59755, 0.59755, 0.576843], o_dn: [18.5426, 18.9163, 18.9163, 19.1202], row_dn: 0.3158, col_dn: 0.5174}
- {iso: 400, white: 15035, s_dn: [0.753591, 0.740586, 0.740586, 0.729874], o_dn: [29.3028, 29.8496, 29.8496, 29.7961], row_dn: 0.4378, col_dn: 0.2691}
- {iso: 500, white: 15035, s_dn: [0.937458, 0.920836, 0.920836, 0.899293], o_dn: [45.2196, 46.3954, 46.3954, 46.1012], row_dn: 0.5473, col_dn: 0.4328}
- {iso: 640, white: 12323, s_dn: [1.12726, 1.13527, 1.13527, 1.10159], o_dn: [24.9951, 25.1029, 25.1029, 25.7183], row_dn: 0.316, col_dn: 0.4544}
- {iso: 800, white: 15035, s_dn: [1.44048, 1.42299, 1.42299, 1.40795], o_dn: [38.7891, 39.302, 39.302, 40.079], row_dn: 0.3877, col_dn: 0.2132}
- {iso: 1000, white: 15000, s_dn: [1.77595, 1.75662, 1.75662, 1.74584], o_dn: [63.9499, 64.4203, 64.4203, 65.0739], row_dn: 0.4593, col_dn: 0.3307}
- {iso: 1250, white: 12346, s_dn: [2.18211, 2.18313, 2.18313, 2.11979], o_dn: [41.6124, 42.9483, 42.9483, 43.2075], row_dn: 0.3979, col_dn: 0.4496}
- {iso: 1600, white: 15035, s_dn: [2.75544, 2.74633, 2.74633, 2.69951], o_dn: [66.3905, 66.4104, 66.4104, 67.253], row_dn: 0.4944, col_dn: 0.4593}
- {iso: 2000, white: 15035, s_dn: [3.42754, 3.40445, 3.40445, 3.36808], o_dn: [104.349, 103.648, 103.648, 106.404], row_dn: 0.6094, col_dn: 0.3602}
- {iso: 2500, white: 12330, s_dn: [4.17112, 4.17551, 4.17551, 4.17175], o_dn: [94.4289, 91.8508, 91.8508, 96.3598], row_dn: 0.5671, col_dn: 0}
- {iso: 3200, white: 15035, s_dn: [5.30088, 5.25742, 5.25742, 5.21782], o_dn: [147.421, 147.302, 147.302, 147.01], row_dn: 0.748, col_dn: 0.8611}
- {iso: 4000, white: 15035, s_dn: [6.62037, 6.59922, 6.59922, 6.60871], o_dn: [224.765, 232.408, 232.408, 231.419], row_dn: 0.9335, col_dn: 1.125}
- {iso: 5000, white: 12323, s_dn: [8.49542, 8.48265, 8.48265, 8.41176], o_dn: [232.672, 233.922, 233.922, 256.059], row_dn: 1.085, col_dn: 1.852}
- {iso: 6400, white: 15035, s_dn: [10.6956, 10.7417, 10.7417, 10.6503], o_dn: [360.311, 368.198, 368.198, 362.848], row_dn: 1.326, col_dn: 2.277}
- {iso: 8000, white: 15035, s_dn: [13.1307, 13.3864, 13.3864, 13.147], o_dn: [615.02, 566.666, 566.666, 611.738], row_dn: 1.768, col_dn: 3.141}
- {iso: 10000, white: 12365, s_dn: [16.5338, 16.7603, 16.7603, 16.4739], o_dn: [914.064, 904.583, 904.583, 938.024], row_dn: 2.214, col_dn: 3.605}
- {iso: 12800, white: 15000, s_dn: [18.4717, 20.9315, 20.9315, 19.3821], o_dn: [1431.85, 1432.33, 1432.33, 1477.82], row_dn: 2.568, col_dn: 4.661}
- {iso: 16000, white: 15000, s_dn: [20.527, 26.0841, 26.0841, 21.8866], o_dn: [2203.77, 2357.78, 2357.78, 2193.1], row_dn: 3.521, col_dn: 5.805}
- {iso: 20000, white: 13000, s_dn: [25.1517, 32.5307, 32.5307, 26.2303], o_dn: [3490.34, 3647.93, 3647.93, 3423.33], row_dn: 4.336, col_dn: 7.143}
- {iso: 25600, white: 15000, s_dn: [22.8743, 40.4641, 40.4641, 23.5537], o_dn: [5184.57, 5690.43, 5690.43, 5286.07], row_dn: 5.682, col_dn: 9.193}
+31 -9
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
@@ -41,18 +40,41 @@ matters — see [`src/bake.rs`](src/bake.rs) for the argument:
1. **A 3×3 matrix**, linear sRGB to the three layers' exposure. Exact, not an 1. **A 3×3 matrix**, linear sRGB to the three layers' exposure. Exact, not an
approximation: the reconstructed scene spectrum is linear in the sRGB approximation: the reconstructed scene spectrum is linear in the sRGB
triple, so the integral collapses into nine numbers. triple, so the integral collapses into nine numbers.
2. **Three 1D curves**, log exposure to density, sampled at 256 points. 2. **Three 1D curves**, log exposure to density, sampled at 256 points — one
3. **One 32³ lookup**, density to linear sRGB — dye absorption, the print row per development time the datasheet measures. Push picks between the
through the negative, the paper, the viewing illuminant and the chromatic rows, and interpolating them is exact, because density is linear in push
adaptation, all of which take exactly three numbers in. between two measured processes.
3. **One 32³ lookup**, density to linear sRGB — dye absorption, the viewing
illuminant and the chromatic adaptation, all of which take exactly three
numbers in. A printed negative is two: the film's cube ends at the paper's
log exposure through the negative, the enlarger's exposure is added there,
and the paper's own curve row and cube take it to linear sRGB.
Per pixel that is a matrix multiply, three curve taps and one texture fetch. The stock is the last thing that happens to the picture. It runs in the view
Splitting 2 from 3, rather than baking one LUT over exposure, is measured transform's place (D19): handed linear sRGB, scene-referred, after every other
adjustment and after sharpening and noise reduction, and handing back the
rendering the output transform encodes. So every other slider decides the
exposure the negative receives, and the default tone mapping is not applied
on top.
Per pixel that is a matrix multiply, a handful of curve taps and one texture
fetch — two for a print. Splitting 2 from 3, rather than baking one LUT over exposure, is measured
rather than assumed: the curve carries all the sharp shape and the dye mixing rather than assumed: the curve carries all the sharp shape and the dye mixing
is smooth, so folding the curve into the 3D lookup would need it three times is smooth, so folding the curve into the 3D lookup would need it three times
larger for the same error. At 32³ the worst interpolation error is about 0.003 larger for the same error. At 32³ the worst interpolation error is about 0.003
in linear sRGB, below one 8-bit code value, and there is a test that says so. in linear sRGB, below one 8-bit code value, and there is a test that says so.
**No slider is baked.** Camera exposure is a gain before the matrix, push
chooses between curve rows, print exposure is the addition between the two
cubes, and format sets the grain; each reaches the shader as a uniform that is
linear in what it does. That is what lets a mask layer hold its own film
settings, and a pixel under several layers take the weighted average of them.
Only the enlarger's filtration is solved at bake time, against the
photograph's exposure — an enlarger has one filtration for the whole print —
so the film's Exposure, set on the whole photograph, is the one slider that
rebakes. The stock and its
paper are the photograph's; a layer has no picker.
## Adding a stock ## Adding a stock
If spektrafilm has it, add its name to `STOCKS` in If spektrafilm has it, add its name to `STOCKS` in
+443 -112
View File
@@ -21,14 +21,16 @@
//! curves and dyes, the viewing illuminant, the adaptation — all of it takes //! curves and dyes, the viewing illuminant, the adaptation — all of it takes
//! three numbers in and gives three numbers out. So it bakes into one small //! three numbers in and gives three numbers out. So it bakes into one small
//! 3D lookup, and the per-pixel cost is a matrix multiply, three curve taps //! 3D lookup, and the per-pixel cost is a matrix multiply, three curve taps
//! and one texture fetch. //! and one texture fetch. A print is two: the film's lookup ends at the
//! paper's log exposure, where the enlarger's exposure is an addition, and
//! the paper's curve and lookup take it from there — see [`Paper`].
//! //!
//! Splitting 2 from 3 rather than baking a single LUT over exposure is //! Splitting 2 from 3 rather than baking a single LUT over exposure is
//! deliberate and measured: the curve carries all of the sharp shape and the //! deliberate and measured: the curve carries all of the sharp shape and the
//! dye mixing is smooth, so putting the curve in the 3D LUT would force it //! dye mixing is smooth, so putting the curve in the 3D LUT would force it
//! three times larger for the same error. //! three times larger for the same error.
use crate::profile::Profile; use crate::profile::{Profile, CURVE_SAMPLES};
use crate::spectrum::{illuminant, Spectrum, Viewing}; use crate::spectrum::{illuminant, Spectrum, Viewing};
use crate::tables::{SPECTRUM, SRGB_BASIS}; use crate::tables::{SPECTRUM, SRGB_BASIS};
@@ -47,7 +49,19 @@ pub const MID_GREY: f32 = 0.184;
/// that on: the error is already under what the output can represent. /// that on: the error is already under what the output can represent.
pub const LUT_SIZE: usize = 32; pub const LUT_SIZE: usize = 32;
/// What to develop, and how. /// TRACES: FR-DEV-3f
/// The most development times a stock may measure: one curve row, and one
/// push station, each. Every stock shipped measures five; the ceiling is what
/// the shader's fixed uniform block can hold.
pub const MAX_CURVE_ROWS: usize = 8;
/// What to develop: the materials, and where the enlarger is balanced.
///
/// **Not how far, and not how bright.** Push, print exposure and camera
/// exposure are [`Settings`], evaluated per pixel against these tables, so
/// that a mask layer can hold its own and a pixel under it can take the
/// weighted average of everyone's (FR-DEV-3f). What is left here is what a
/// photograph has one of.
pub struct Recipe<'a> { pub struct Recipe<'a> {
/// The stock the picture was taken on. /// The stock the picture was taken on.
pub film: &'a Profile, pub film: &'a Profile,
@@ -55,17 +69,15 @@ pub struct Recipe<'a> {
/// what a reversal stock wants and what makes a negative come out orange /// what a reversal stock wants and what makes a negative come out orange
/// and inverted — that being what a negative actually looks like. /// and inverted — that being what a negative actually looks like.
pub print: Option<&'a Profile>, pub print: Option<&'a Profile>,
/// Camera exposure, in stops. /// The camera exposure the enlarger is balanced at, in stops. Ignored
pub exposure_ev: f32, /// without a `print`.
/// Enlarger exposure, in stops. Ignored without a `print`.
pub print_exposure_ev: f32,
/// TRACES: FR-DEV-3f
/// Development, in stops of push. Positive develops longer.
/// ///
/// Ignored by a stock measured at one process, of which there are many — /// The *photograph's* exposure, never a region's. An enlarger has one
/// see [`crate::profile::Profile::curves_at_push`], which returns the one /// filtration for the whole print: a negative exposed a stop brighter in
/// measured curve rather than inventing a pushed one. /// one corner prints a stop darker there, and that difference is the
pub push_stops: f32, /// picture — balancing it away per pixel would erase every local exposure
/// change a layer made.
pub exposure_ev: f32,
} }
impl<'a> Recipe<'a> { impl<'a> Recipe<'a> {
@@ -76,12 +88,27 @@ impl<'a> Recipe<'a> {
film, film,
print, print,
exposure_ev: 0.0, exposure_ev: 0.0,
print_exposure_ev: 0.0,
push_stops: 0.0,
} }
} }
} }
/// TRACES: FR-DEV-3f
/// What a pixel is developed with, against a [`Baked`] stock.
///
/// The shader's uniforms, as the CPU sees them: every field is linear in what
/// the tables are indexed by, which is what lets the composer blend several
/// layers' settings into one before the fragment runs.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Settings {
/// Camera exposure, in stops: a gain on the scene.
pub exposure_ev: f32,
/// Development, in stops of push. Positive develops longer. Nothing for a
/// stock measured at one process, of which there are many.
pub push_stops: f32,
/// Enlarger exposure, in stops. Nothing without a print.
pub print_exposure_ev: f32,
}
/// A recipe reduced to three tables. /// A recipe reduced to three tables.
/// ///
/// Plain `f32` with a documented layout, and no notion of a texture: what to /// Plain `f32` with a documented layout, and no notion of a texture: what to
@@ -89,16 +116,31 @@ impl<'a> Recipe<'a> {
/// the whole model be tested on the CPU. /// the whole model be tested on the CPU.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Baked { pub struct Baked {
/// Linear sRGB to the three layers' log₁₀ exposure, before the log — row /// Linear sRGB to the three layers' exposure, before the log — row `l`,
/// `l`, column `c` is layer `l`'s response to sRGB channel `c`. /// column `c` is layer `l`'s response to sRGB channel `c`. At unit gain:
/// [`Settings::exposure_ev`] is applied per pixel.
pub exposure_matrix: [[f32; 3]; 3], pub exposure_matrix: [[f32; 3]; 3],
/// The characteristic curves, `CURVE_SAMPLES` samples per layer, uniform /// The characteristic curves: `curve_rows` rows of `CURVE_SAMPLES`
/// over `[curve_log_min, curve_log_max]`. /// samples, row after row, each uniform over
/// `[curve_log_min, curve_log_max]`.
///
/// Row `r` is the stock as measured at its `r`th development time, which
/// is push [`Self::push_stations`]`[r]`. The rows are the measurements
/// themselves rather than a resampling: between two, density is linear in
/// push (development is interpolated in log time, and push is log time),
/// so interpolating the rows by push reproduces
/// [`Profile::curves_at_push`] exactly. A stock measured at one process
/// has one row.
pub curves: Vec<[f32; 3]>, pub curves: Vec<[f32; 3]>,
pub curve_rows: usize,
/// The push each row was developed to, ascending, one per row.
pub push_stations: Vec<f32>,
pub curve_log_min: f32, pub curve_log_min: f32,
pub curve_log_max: f32, pub curve_log_max: f32,
/// Density to linear sRGB, `LUT_SIZE³` entries uniform over /// Film density to what comes next, `LUT_SIZE³` entries uniform over
/// `[0, density_max]` on each axis. /// `[0, density_max]` on each axis: linear sRGB when the film is viewed
/// directly, and the paper's log₁₀ exposure through it, per layer, when it
/// is printed.
/// ///
/// **The red axis varies fastest**, then green, then blue — that is, /// **The red axis varies fastest**, then green, then blue — that is,
/// `lut[(b * size + g) * size + r]`. Stated because it is not the order /// `lut[(b * size + g) * size + r]`. Stated because it is not the order
@@ -108,60 +150,142 @@ pub struct Baked {
/// picture with red and blue transposed, which looks like a plausible /// picture with red and blue transposed, which looks like a plausible
/// photograph of the wrong colour. /// photograph of the wrong colour.
pub lut: Vec<[f32; 3]>, pub lut: Vec<[f32; 3]>,
/// The paper, when there is one. See [`Paper`].
pub paper: Option<Paper>,
/// The deepest density any row develops to, so one lookup covers every
/// push.
pub density_max: f32, pub density_max: f32,
pub lut_size: usize, pub lut_size: usize,
} }
/// TRACES: FR-DEV-3f
/// The print half of a baked stock: enlarger to paper to viewing.
///
/// Split from the film's lookup at the paper's log exposure, for the reason
/// the film is split from its own curve. The enlarger's exposure is a shift
/// *in that log exposure*, the same stops on all three layers, so a print
/// exposure is an addition between the two lookups — exact at any value and
/// free per pixel. Baking it into one lookup instead needs a slice per
/// setting, and interpolating between slices misses by several code values,
/// because the paper's curve is the sharpest thing in the print.
#[derive(Debug, Clone)]
pub struct Paper {
/// The enlarger's filtration, per layer, in log₁₀ exposure: what makes a
/// mid-grey scene print neutral at the photograph's exposure. See
/// [`Recipe::exposure_ev`].
pub balance: [f32; 3],
/// The paper's characteristic curves, `CURVE_SAMPLES` samples uniform
/// over `[log_min, log_max]`.
pub curves: Vec<[f32; 3]>,
pub log_min: f32,
pub log_max: f32,
/// Paper density to linear sRGB, laid out as [`Baked::lut`] is, uniform
/// over `[0, density_max]`.
pub lut: Vec<[f32; 3]>,
pub density_max: f32,
}
/// Where `push` falls among the rows: the lower row and the fraction toward
/// the next. Clamped at both ends, as `curves_at_push` clamps to the first and
/// last measured process.
fn push_row(stations: &[f32], push: f32) -> (usize, f32) {
if stations.len() < 2 {
return (0, 0.0);
}
let last = stations.len() - 1;
let hi = stations
.iter()
.position(|p| *p >= push)
.unwrap_or(last)
.max(1);
let lo = hi - 1;
let f = (push - stations[lo]) / (stations[hi] - stations[lo]).max(1e-6);
(lo, f.clamp(0.0, 1.0))
}
impl Baked { impl Baked {
/// Look a colour up the way the shader will, for tests and for previews. /// Look a colour up the way the shader will, at the stock's own settings.
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
self.apply_at(rgb, &Settings::default())
}
/// Look a colour up the way the shader will, for tests and for previews.
pub fn apply_at(&self, rgb: [f32; 3], settings: &Settings) -> [f32; 3] {
let gain = 2f32.powf(settings.exposure_ev);
let mut log_exposure = [0.0f32; 3]; let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() { for (l, slot) in log_exposure.iter_mut().enumerate() {
let m = self.exposure_matrix[l]; let m = self.exposure_matrix[l];
let e = m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]; let e = gain * (m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]);
*slot = (e.max(0.0) + 1e-10).log10(); *slot = (e.max(0.0) + 1e-10).log10();
} }
self.sample_lut(self.sample_curves(log_exposure)) let density = self.sample_curves(log_exposure, settings.push_stops);
let through = sample_cube(&self.lut, self.lut_size, density, self.density_max);
let Some(paper) = &self.paper else {
return through;
};
let shift = settings.print_exposure_ev * 2f32.log10();
let paper_log = [0, 1, 2].map(|l| through[l] + paper.balance[l] + shift);
let paper_density = sample_curve(&paper.curves, paper.log_min, paper.log_max, paper_log);
sample_cube(&paper.lut, self.lut_size, paper_density, paper.density_max)
} }
fn sample_curves(&self, log_exposure: [f32; 3]) -> [f32; 3] { fn sample_curves(&self, log_exposure: [f32; 3], push_stops: f32) -> [f32; 3] {
let last = self.curves.len() - 1; let (row, g) = push_row(&self.push_stations, push_stops);
let span = self.curve_log_max - self.curve_log_min; let lo = self.sample_curve_row(log_exposure, row);
let mut out = [0.0f32; 3]; if self.curve_rows < 2 {
for (c, slot) in out.iter_mut().enumerate() { return lo;
let t = ((log_exposure[c] - self.curve_log_min) / span).clamp(0.0, 1.0) * last as f32;
let i = (t.floor() as usize).min(last - 1);
let f = t - i as f32;
*slot = self.curves[i][c] * (1.0 - f) + self.curves[i + 1][c] * f;
} }
out let hi = self.sample_curve_row(log_exposure, row + 1);
[0, 1, 2].map(|c| lo[c] * (1.0 - g) + hi[c] * g)
} }
fn sample_lut(&self, density: [f32; 3]) -> [f32; 3] { fn sample_curve_row(&self, log_exposure: [f32; 3], row: usize) -> [f32; 3] {
let n = self.lut_size; let samples = self.curves.len() / self.curve_rows;
let mut base = [0usize; 3]; let curve = &self.curves[row * samples..(row + 1) * samples];
let mut frac = [0f32; 3]; sample_curve(curve, self.curve_log_min, self.curve_log_max, log_exposure)
for c in 0..3 { }
let t = (density[c] / self.density_max).clamp(0.0, 1.0) * (n - 1) as f32; }
base[c] = (t.floor() as usize).min(n - 2);
frac[c] = t - base[c] as f32; /// Three curves sampled uniformly over `[log_min, log_max]`, read at a log
} /// exposure per layer. Clamped at both ends, as the shader's is.
let mut out = [0.0f32; 3]; fn sample_curve(curve: &[[f32; 3]], log_min: f32, log_max: f32, at: [f32; 3]) -> [f32; 3] {
for dx in 0..2 { let last = curve.len() - 1;
for dy in 0..2 { let span = log_max - log_min;
for dz in 0..2 { let mut out = [0.0f32; 3];
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] } for (c, slot) in out.iter_mut().enumerate() {
* if dy == 0 { 1.0 - frac[1] } else { frac[1] } let t = ((at[c] - log_min) / span).clamp(0.0, 1.0) * last as f32;
* if dz == 0 { 1.0 - frac[2] } else { frac[2] }; let i = (t.floor() as usize).min(last - 1);
let e = self.lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx]; let f = t - i as f32;
for c in 0..3 { *slot = curve[i][c] * (1.0 - f) + curve[i + 1][c] * f;
out[c] += w * e[c]; }
} out
}
/// A cube of `n³` triples over `[0, max]` per axis, red fastest, read
/// trilinearly.
fn sample_cube(lut: &[[f32; 3]], n: usize, density: [f32; 3], max: f32) -> [f32; 3] {
let mut base = [0usize; 3];
let mut frac = [0f32; 3];
for c in 0..3 {
let t = (density[c] / max).clamp(0.0, 1.0) * (n - 1) as f32;
base[c] = (t.floor() as usize).min(n - 2);
frac[c] = t - base[c] as f32;
}
let mut out = [0.0f32; 3];
for dx in 0..2 {
for dy in 0..2 {
for dz in 0..2 {
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] }
* if dy == 0 { 1.0 - frac[1] } else { frac[1] }
* if dz == 0 { 1.0 - frac[2] } else { frac[2] };
let e = lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx];
for c in 0..3 {
out[c] += w * e[c];
} }
} }
} }
out
} }
out
} }
/// Linear sRGB to the three layers' exposure, mid-grey normalised. /// Linear sRGB to the three layers' exposure, mid-grey normalised.
@@ -204,12 +328,7 @@ pub fn exposure_matrix(film: &Profile) -> [[f32; 3]; 3] {
/// goes: the mask is a fixed density, so balancing mid-grey to neutral cancels /// goes: the mask is a fixed density, so balancing mid-grey to neutral cancels
/// it — which is why a printed negative looks like a photograph while a scanned /// it — which is why a printed negative looks like a photograph while a scanned
/// one looks orange. /// one looks orange.
fn print_balance( pub fn print_balance(film: &Profile, paper: &Profile, exposure_ev: f32) -> [f32; 3] {
film: &Profile,
paper: &Profile,
exposure_ev: f32,
print_exposure_ev: f32,
) -> [f32; 3] {
let matrix = exposure_matrix(film); let matrix = exposure_matrix(film);
let scene = MID_GREY * 2f32.powf(exposure_ev); let scene = MID_GREY * 2f32.powf(exposure_ev);
let mut log_exposure = [0.0f32; 3]; let mut log_exposure = [0.0f32; 3];
@@ -227,7 +346,7 @@ fn print_balance(
let mut offsets = [0.0f32; 3]; let mut offsets = [0.0f32; 3];
for (l, slot) in offsets.iter_mut().enumerate() { for (l, slot) in offsets.iter_mut().enumerate() {
*slot = target - (mid_raw[l] + 1e-10).log10() + print_exposure_ev * 2f32.log10(); *slot = target - (mid_raw[l] + 1e-10).log10();
} }
offsets offsets
} }
@@ -257,77 +376,117 @@ fn paper_exposure(film: &Profile, paper: &Profile, density: [f32; 3]) -> [f32; 3
/// Bake a recipe into the tables a shader runs. /// Bake a recipe into the tables a shader runs.
pub fn bake(recipe: &Recipe) -> Baked { pub fn bake(recipe: &Recipe) -> Baked {
let film = recipe.film; let film = recipe.film;
let mut matrix = exposure_matrix(film); // At unit gain. Camera exposure is a scalar on a linear quantity, so the
// Camera exposure rides in the matrix rather than in the shader: it is a // shader applies it for the price of one multiply — and has to, since a
// scalar on a linear quantity, and folding it in here costs nothing and // layer may hold its own.
// keeps the per-pixel work identical whether or not it has been moved. let matrix = exposure_matrix(film);
let gain = 2f32.powf(recipe.exposure_ev);
for row in &mut matrix {
for v in row.iter_mut() {
*v *= gain;
}
}
// TRACES: FR-DEV-3f // TRACES: FR-DEV-3f
// Developed to the requested push before anything else reads the curves: // Every measured process, not the one the slider is at: the shader
// the density ceiling, the print balance and the grain all depend on how // interpolates between rows per pixel, so a layer can push a region.
// far this film was taken, and a push that only reached one of them would // Resampled to one length because the rows share a texture.
// be a contrast change wearing a push's name. let measured = film.development_curves.len() >= 2
let curves = film.curves_at_push(recipe.push_stops); && film.development_times.len() == film.development_curves.len();
let density_max = curves let (curves, push_stations): (Vec<[f32; 3]>, Vec<f32>) = if measured {
.iter() let rows = film.development_curves.len().min(MAX_CURVE_ROWS);
.flat_map(|row| row.iter()) (
.fold(0.0f32, |a, &b| a.max(b)) film.development_curves[..rows]
.max(1e-3); .iter()
.flat_map(|c| resample(c))
let viewing = match recipe.print { .collect(),
Some(paper) => Viewing::new(&paper.viewing_illuminant), film.development_times[..rows]
None => Viewing::new(&film.viewing_illuminant), .iter()
.map(|t| 2.0 * (t / film.development_normal).log2())
.collect(),
)
} else {
(resample(&film.density_curves), vec![0.0])
}; };
let balance = recipe let curve_rows = push_stations.len();
.print // The ceiling of the deepest row, so one lookup covers every push.
.map(|paper| print_balance(film, paper, recipe.exposure_ev, recipe.print_exposure_ev)); let density_max = ceiling(&curves);
let n = LUT_SIZE; let n = LUT_SIZE;
let mut lut = Vec::with_capacity(n * n * n);
// Blue outermost and red innermost, so the red axis varies fastest. See // Blue outermost and red innermost, so the red axis varies fastest. See
// `Baked::lut`: this is the layout a 3D texture upload wants, and getting // `Baked::lut`: this is the layout a 3D texture upload wants, and getting
// it backwards transposes red and blue in the finished picture. // it backwards transposes red and blue in the finished picture.
for b in 0..n { let cube = |max: f32, f: &dyn Fn([f32; 3]) -> [f32; 3]| {
for g in 0..n { let mut out = Vec::with_capacity(n * n * n);
for r in 0..n { for b in 0..n {
let density = [ for g in 0..n {
density_max * r as f32 / (n - 1) as f32, for r in 0..n {
density_max * g as f32 / (n - 1) as f32, let step = max / (n - 1) as f32;
density_max * b as f32 / (n - 1) as f32, out.push(f([r as f32 * step, g as f32 * step, b as f32 * step]));
]; }
lut.push(match recipe.print.zip(balance) {
Some((paper, offsets)) => {
let raw = paper_exposure(film, paper, density);
let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() {
*slot = (raw[l] + 1e-10).log10() + offsets[l];
}
let paper_density = paper.density_at(log_exposure);
viewing.to_srgb(&paper.transmittance(paper_density))
}
None => viewing.to_srgb(&film.transmittance(density)),
});
} }
} }
} out
};
let (lut, paper) = match recipe.print {
None => {
let viewing = Viewing::new(&film.viewing_illuminant);
(
cube(density_max, &|d| viewing.to_srgb(&film.transmittance(d))),
None,
)
}
Some(paper) => {
let viewing = Viewing::new(&paper.viewing_illuminant);
let curves = resample(&paper.density_curves);
let paper_max = ceiling(&curves);
let lut = cube(density_max, &|d| {
paper_exposure(film, paper, d).map(|raw| (raw + 1e-10).log10())
});
let paper = Paper {
balance: print_balance(film, paper, recipe.exposure_ev),
log_min: paper.log_exposure_min,
log_max: paper.log_exposure_max,
lut: cube(paper_max, &|d| viewing.to_srgb(&paper.transmittance(d))),
density_max: paper_max,
curves,
};
(lut, Some(paper))
}
};
Baked { Baked {
exposure_matrix: matrix, exposure_matrix: matrix,
curves, curves,
curve_rows,
push_stations,
curve_log_min: film.log_exposure_min, curve_log_min: film.log_exposure_min,
curve_log_max: film.log_exposure_max, curve_log_max: film.log_exposure_max,
lut, lut,
paper,
density_max, density_max,
lut_size: n, lut_size: n,
} }
} }
/// A curve at `CURVE_SAMPLES`, uniform over the same domain it came in on.
fn resample(curve: &[[f32; 3]]) -> Vec<[f32; 3]> {
if curve.len() == CURVE_SAMPLES {
return curve.to_vec();
}
(0..CURVE_SAMPLES)
.map(|i| {
let at = i as f32 / (CURVE_SAMPLES - 1) as f32;
sample_curve(curve, 0.0, 1.0, [at; 3])
})
.collect()
}
/// The deepest density in a set of curves, floored so a lookup over it has
/// a width.
fn ceiling(curves: &[[f32; 3]]) -> f32 {
curves
.iter()
.flat_map(|row| row.iter())
.fold(0.0f32, |a, &b| a.max(b))
.max(1e-3)
}
fn mean(s: &Spectrum) -> f32 { fn mean(s: &Spectrum) -> f32 {
s.iter().sum::<f32>() / SPECTRUM as f32 s.iter().sum::<f32>() / SPECTRUM as f32
} }
@@ -467,6 +626,10 @@ mod tests {
#[test] #[test]
fn exposure_moves_the_print_the_way_it_moves_a_photograph() { fn exposure_moves_the_print_the_way_it_moves_a_photograph() {
// The photograph's exposure: the enlarger balanced at it, and the
// scene brighter by it. Mid-grey stays where the balance puts it —
// that is what the balance is for — so what a stop more does to a
// print is lift everything either side of it along the paper's curve.
let film = portra(); let film = portra();
let paper = endura(); let paper = endura();
let brighter = bake(&Recipe { let brighter = bake(&Recipe {
@@ -474,7 +637,155 @@ mod tests {
..Recipe::new(&film, Some(&paper)) ..Recipe::new(&film, Some(&paper))
}); });
let base = bake(&Recipe::new(&film, Some(&paper))); let base = bake(&Recipe::new(&film, Some(&paper)));
assert!(brighter.apply([MID_GREY; 3])[1] > base.apply([MID_GREY; 3])[1]); let one_stop = Settings {
exposure_ev: 1.0,
..Settings::default()
};
for v in [0.02f32, 0.6] {
assert!(
brighter.apply_at([v; 3], &one_stop)[1] > base.apply([v; 3])[1],
"{v} did not print brighter a stop up"
);
}
let (a, b) = (
brighter.apply_at([MID_GREY; 3], &one_stop)[1],
base.apply([MID_GREY; 3])[1],
);
assert!(
(a - b).abs() < 1.0 / 255.0,
"the balance let mid-grey move: {a} vs {b}"
);
}
#[test]
fn a_region_exposed_brighter_prints_brighter_than_the_enlarger_expects() {
// TRACES: FR-DEV-3f
// A layer's exposure is the scene's, not the enlarger's: the balance
// stays where the photograph put it, so the region prints lighter by
// more than the whole photograph would, which is what dodging at the
// camera is.
let film = portra();
let paper = endura();
let base = bake(&Recipe::new(&film, Some(&paper)));
let rebalanced = bake(&Recipe {
exposure_ev: 1.0,
..Recipe::new(&film, Some(&paper))
});
let one_stop = Settings {
exposure_ev: 1.0,
..Settings::default()
};
let local = base.apply_at([MID_GREY; 3], &one_stop)[1];
let global = rebalanced.apply_at([MID_GREY; 3], &one_stop)[1];
assert!(local > base.apply([MID_GREY; 3])[1], "not brighter at all");
assert!(
local > global,
"a region was rebalanced as though it were the whole print: {local} vs {global}"
);
}
#[test]
fn more_light_through_the_enlarger_darkens_the_print() {
// TRACES: FR-DEV-3f
// Paper is negative-working. Opening the enlarger a stop is burning
// in, and a slider that brightened would be the wrong way round for
// anyone who has printed.
let film = portra();
let paper = endura();
let baked = bake(&Recipe::new(&film, Some(&paper)));
let at = |stops: f32| {
baked.apply_at(
[MID_GREY; 3],
&Settings {
print_exposure_ev: stops,
..Settings::default()
},
)[1]
};
assert!(at(1.0) < at(0.0) && at(0.0) < at(-1.0));
}
#[test]
fn a_push_on_a_row_is_the_measured_curve() {
// TRACES: FR-DEV-3f
// The rows are the measured processes, so at a row the table must be
// that curve exactly, and between rows — density being linear in push
// there — it must be `curves_at_push` to rounding.
let film = profile(include_str!("../profiles/kodak_doublex.yaml"));
let baked = bake(&Recipe::new(&film, None));
assert_eq!(
baked.curve_rows, 5,
"Double-X measures five development times"
);
let span = film.log_exposure_max - film.log_exposure_min;
let mut worst = 0.0f32;
let stations = baked.push_stations.clone();
let mut pushes: Vec<(f32, bool)> = stations.iter().map(|p| (*p, true)).collect();
for k in 0..=16 {
pushes.push((-1.0 + 4.0 * k as f32 / 16.0, false));
}
for (push, on_row) in pushes {
let exact = film.curves_at_push(push);
for i in (0..exact.len()).step_by(7) {
let log = film.log_exposure_min + span * i as f32 / (exact.len() - 1) as f32;
let got = baked.sample_curves([log; 3], push);
for c in 0..3 {
let err = (got[c] - exact[i][c]).abs();
if on_row {
assert!(err < 1e-4, "push {push} is a row but misses it by {err}");
}
worst = worst.max(err);
}
}
}
assert!(worst < 1e-3, "between rows the density is off by {worst}");
}
#[test]
fn a_print_exposure_is_exact_at_any_setting() {
// TRACES: FR-DEV-3f
// The enlarger's exposure is added between the two lookups rather than
// baked into either, so no setting is nearer the tables than another.
// Compared against the chain evaluated spectrally, end to end, at
// settings chosen off every half and whole stop.
let film = portra();
let paper = endura();
let baked = bake(&Recipe::new(&film, Some(&paper)));
let offsets = print_balance(&film, &paper, 0.0);
let viewing = Viewing::new(&paper.viewing_illuminant);
let mut worst = 0.0f32;
for stops in [-2.3f32, -0.6, 0.0, 0.35, 1.7] {
for i in 0..14 {
let v = 0.004 * 2f32.powf(i as f32 * 0.6);
let rgb = [v, v * 0.8, v * 1.1];
let mut log_exposure = [0.0f32; 3];
for (l, slot) in log_exposure.iter_mut().enumerate() {
let m = baked.exposure_matrix[l];
*slot =
((m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]).max(0.0) + 1e-10).log10();
}
let raw = paper_exposure(&film, &paper, film.density_at(log_exposure));
let paper_log =
[0, 1, 2].map(|l| (raw[l] + 1e-10).log10() + offsets[l] + stops * 2f32.log10());
let exact = viewing.to_srgb(&paper.transmittance(paper.density_at(paper_log)));
let approx = baked.apply_at(
rgb,
&Settings {
print_exposure_ev: stops,
..Settings::default()
},
);
for c in 0..3 {
worst = worst.max((exact[c] - approx[c]).abs());
}
}
}
assert!(
worst < 1.0 / 255.0,
"the print misses the spectral chain by {worst}"
);
} }
#[test] #[test]
@@ -550,5 +861,25 @@ mod tests {
let baked = bake(&Recipe::new(&film, None)); let baked = bake(&Recipe::new(&film, None));
assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE); assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE);
assert_eq!(baked.curves.len(), CURVE_SAMPLES); assert_eq!(baked.curves.len(), CURVE_SAMPLES);
assert_eq!(baked.curve_rows, 1);
assert!(baked.paper.is_none());
// A print has a second lookup and a curve of its own; a development
// series a row per push. Neither is inferred from the other.
let negative = portra();
let paper = endura();
let printed = bake(&Recipe::new(&negative, Some(&paper)));
let print = printed
.paper
.as_ref()
.expect("a printed negative has a paper");
assert_eq!(print.lut.len(), LUT_SIZE.pow(3));
assert_eq!(print.curves.len(), CURVE_SAMPLES);
let pushable = profile(include_str!("../profiles/kodak_doublex.yaml"));
let rows = bake(&Recipe::new(&pushable, None));
assert_eq!(rows.curve_rows, pushable.development_times.len());
assert_eq!(rows.push_stations.len(), rows.curve_rows);
assert_eq!(rows.curves.len(), rows.curve_rows * CURVE_SAMPLES);
} }
} }
+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.
//! //!
+39 -3
View File
@@ -24,7 +24,7 @@ fn main() {
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
let Some(input) = args.next() else { let Some(input) = args.next() else {
eprintln!("usage: develop <file.cr2> [out.ppm] [preset]"); eprintln!("usage: develop <file.cr2> [out.ppm] [preset]");
eprintln!(" preset: neutral (default) | punchy | recover"); eprintln!(" preset: neutral (default) | matrix | look200 | punchy | recover | …");
std::process::exit(2); std::process::exit(2);
}; };
let output = args.next().unwrap_or_else(|| "develop.ppm".into()); let output = args.next().unwrap_or_else(|| "develop.ppm".into());
@@ -78,6 +78,24 @@ fn main() {
graph.set_param(brilliance::ID, brilliance::BRILLIANCE, 40.0); graph.set_param(brilliance::ID, brilliance::BRILLIANCE, 40.0);
graph.set_param(white_balance::ID, white_balance::TEMPERATURE, 15.0); graph.set_param(white_balance::ID, white_balance::TEMPERATURE, 15.0);
} }
// The camera profile switched off: the matrix alone, as every
// photograph rendered before D20. Beside "neutral" on a DNG that
// embeds a profile, the difference is the profile's tables.
"matrix" => {
graph.set_param(
dr_pipeline::ops::camera_profile::ID,
dr_pipeline::ops::camera_profile::APPLY,
0.0,
);
}
// The profile's look table at twice its strength.
"look200" => {
graph.set_param(
dr_pipeline::ops::camera_profile::ID,
dr_pipeline::ops::camera_profile::LOOK,
200.0,
);
}
// Contrast alone, so its effect can be judged without anything else // Contrast alone, so its effect can be judged without anything else
// moving. // moving.
"contrast" => { "contrast" => {
@@ -109,6 +127,14 @@ fn main() {
graph.set_param(curve::ID, curve::P0_Y, 0.12); graph.set_param(curve::ID, curve::P0_Y, 0.12);
graph.set_param(curve::ID, curve::P1_Y, 0.32); graph.set_param(curve::ID, curve::P1_Y, 0.32);
} }
// A shipped preset by name — `preset:Vivid landscape` — applied as
// the presets menu applies it, so a look can be judged on a real file.
named if named.starts_with("preset:") => {
let name = &named["preset:".len()..];
let preset = dr_pipeline::bundled::lookup(&Default::default(), name)
.unwrap_or_else(|| panic!("no shipped preset called {name:?}"));
let _ = preset.apply(&mut graph, dr_pipeline::Scope::adjustments());
}
_ => {} _ => {}
} }
@@ -123,8 +149,15 @@ fn main() {
let mut adjust = AdjustPass::new(&ctx); let mut adjust = AdjustPass::new(&ctx);
let (w, h) = image.size(); let (w, h) = image.size();
// Through the detail stage when the edit has one — clarity, sharpening
// — which is the path every frontend takes; `render` alone refuses such
// a shader.
let t2 = std::time::Instant::now(); let t2 = std::time::Instant::now();
adjust.render(&image, &shader, w, h).expect("adjust"); let detail = graph.compose_detail(image.size(), (w, h));
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
adjust
.render_detailed(&image, &shader, w, h, None, &detail, key)
.expect("adjust");
ctx.device ctx.device
.poll(wgpu::PollType::wait_indefinitely()) .poll(wgpu::PollType::wait_indefinitely())
.expect("poll"); .expect("poll");
@@ -134,8 +167,11 @@ fn main() {
// path, and it must not recompile. // path, and it must not recompile.
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.31); graph.set_param(exposure::ID, exposure::EXPOSURE, 0.31);
let again = graph.compose(); let again = graph.compose();
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
let t3 = std::time::Instant::now(); let t3 = std::time::Instant::now();
adjust.render(&image, &again, w, h).expect("adjust"); adjust
.render_detailed(&image, &again, w, h, None, &detail, key)
.expect("adjust");
ctx.device ctx.device
.poll(wgpu::PollType::wait_indefinitely()) .poll(wgpu::PollType::wait_indefinitely())
.expect("poll"); .expect("poll");
+4 -1
View File
@@ -680,15 +680,18 @@ fn film_tables() -> FilmTables {
FilmTables { FilmTables {
exposure_matrix: baked.exposure_matrix, exposure_matrix: baked.exposure_matrix,
curves: baked.curves.clone(), curves: baked.curves.clone(),
push_stations: baked.push_stations.clone(),
curve_log_min: baked.curve_log_min, curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max, curve_log_max: baked.curve_log_max,
lut: baked.lut.clone(), lut: baked.lut.clone(),
density_max: baked.density_max, density_max: baked.density_max,
lut_size: baked.lut_size, lut_size: baked.lut_size,
// Viewed directly: `STOCK` is baked without a paper above.
paper: None,
// Grain off. It is a per-pixel hash and would be measured; it is also // Grain off. It is a per-pixel hash and would be measured; it is also
// not part of every edit, and the chain being measured here is "every // not part of every edit, and the chain being measured here is "every
// operation active", not "every option of every operation". // operation active", not "every option of every operation".
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; dr_pipeline::ops::film_sim::FORMAT_COUNT],
grain_density_max: [baked.density_max; 3], grain_density_max: [baked.density_max; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
} }
+115
View File
@@ -0,0 +1,115 @@
//! Dump RAW files' mosaics for training the learned denoise (FR-DEV-3g).
//!
//! The training repo must read photosites the way the app reads them —
//! same black and white levels, same active area, same CFA phase — or a
//! network trained on one phase runs on another and paints moiré everywhere
//! (denoise.md §4.4). So it reads this, not LibRaw.
//!
//! The photosites are those the demosaic reads: hot and dead ones repaired by
//! the app's own pass ([`Demosaicer::repair_hot_pixels`], the same shader
//! `run` dispatches), because the learned stage replaces the demosaic and
//! takes its input (denoise.md §2). `--unrepaired` skips it.
//!
//! Reads `input<TAB>output-prefix` lines on stdin and writes, per line,
//! `prefix.npy` (the whole readout, masked border included, `u16`, row-major)
//! and `prefix.json` (what `decode` and `metadata` say about it). The border
//! is kept, and the repair never touches it, because its optically black
//! photosites are a dark frame for free: read noise and row noise at that ISO.
//!
//! ```sh
//! printf 'IMG_0001.CR2\tout/IMG_0001\n' |
//! cargo run --release -p dr-gpu --example mosaic_dump
//! ```
use std::io::{BufRead, Write};
use dr_gpu::{Demosaicer, GpuContext};
fn main() {
let repair = !std::env::args().any(|a| a == "--unrepaired");
let ctx = pollster::block_on(GpuContext::new_headless()).expect("a GPU for the hot-pixel pass");
let demosaicer = Demosaicer::new(&ctx).expect("demosaicer");
let mut failed = 0;
for line in std::io::stdin().lock().lines() {
let line = line.expect("stdin");
let Some((input, prefix)) = line.split_once('\t') else {
continue;
};
match dump(input, prefix, repair.then_some(&demosaicer)) {
Ok(()) => println!("ok\t{input}"),
Err(e) => {
failed += 1;
println!("fail\t{input}\t{e}");
}
}
std::io::stdout().flush().ok();
}
std::process::exit(if failed > 0 { 1 } else { 0 });
}
fn dump(input: &str, prefix: &str, repair: Option<&Demosaicer>) -> Result<(), String> {
let bytes = std::fs::read(input).map_err(|e| e.to_string())?;
let mut raw = dr_decode::decode(&bytes).map_err(|e| e.to_string())?;
if raw.samples_per_pixel != 1 {
return Err("linear DNG: no photosites".into());
}
let repaired = match repair {
Some(d) => d.repair_hot_pixels(&mut raw).map_err(|e| e.to_string())? as i64,
None => -1,
};
let meta = dr_decode::metadata(&bytes).map_err(|e| e.to_string())?;
let mut npy = Vec::with_capacity(raw.data.len() * 2 + 128);
let mut header = format!(
"{{'descr': '<u2', 'fortran_order': False, 'shape': ({}, {}), }}",
raw.height, raw.width
);
// The header, its magic and length are padded to a multiple of 64.
while (10 + header.len() + 1) % 64 != 0 {
header.push(' ');
}
header.push('\n');
npy.extend_from_slice(b"\x93NUMPY\x01\x00");
npy.extend_from_slice(&(header.len() as u16).to_le_bytes());
npy.extend_from_slice(header.as_bytes());
for v in &raw.data {
npy.extend_from_slice(&v.to_le_bytes());
}
std::fs::write(format!("{prefix}.npy"), npy).map_err(|e| e.to_string())?;
let opt = |v: Option<f32>| v.map_or("null".to_string(), |v| v.to_string());
let matrix = raw
.color_matrix
.map_or("null".to_string(), |m| format!("{m:?}"));
let json = format!(
concat!(
"{{\"source\": {:?}, \"make\": {:?}, \"model\": {:?}, ",
"\"width\": {}, \"height\": {}, ",
"\"crop\": [{}, {}, {}, {}], \"cfa\": {:?}, ",
"\"black\": {:?}, \"white\": {}, \"wb\": {:?}, \"cam_to_srgb\": {}, ",
"\"iso\": {}, \"shutter\": {}, \"aperture\": {}, \"captured_at\": {}, ",
"\"hot_repaired\": {}}}\n"
),
input,
raw.make,
raw.model,
raw.width,
raw.height,
raw.crop.x,
raw.crop.y,
raw.crop.width,
raw.crop.height,
format!("{:?}", raw.cfa_pattern),
raw.black_level,
raw.white_level,
raw.wb_coeffs,
matrix,
meta.iso.map_or("null".to_string(), |v| v.to_string()),
opt(meta.shutter),
opt(meta.aperture),
meta.captured_at
.map_or("null".to_string(), |v| v.to_string()),
repaired,
);
std::fs::write(format!("{prefix}.json"), json).map_err(|e| e.to_string())
}
+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)
+284 -80
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,
@@ -81,6 +71,14 @@ pub struct AdjustPass {
empty_film_lut: wgpu::TextureView, empty_film_lut: wgpu::TextureView,
/// The loaded stock's tables, once uploaded. See [`Self::set_film`]. /// The loaded stock's tables, once uploaded. See [`Self::set_film`].
film: Option<FilmTextures>, film: Option<FilmTextures>,
/// TRACES: FR-DEV-3e
/// Bound at `@binding(8)` for a source with no camera profile tables: the
/// two-entry header of zeros that tells the fragment there is nothing to
/// apply (D20).
empty_profile: wgpu::Buffer,
/// The current source's tables, uploaded, keyed by
/// [`DemosaicedImage::id`] — one upload per source rather than per frame.
profile: Option<(u64, wgpu::Buffer)>,
/// TRACES: FR-DEV-3 | FR-DEV-3d /// TRACES: FR-DEV-3 | FR-DEV-3d
/// The neighbourhood stage — sharpening, noise reduction, clarity and the /// The neighbourhood stage — sharpening, noise reduction, clarity and the
/// rest of FR-DEV-3's detail set, which cannot be fused into the shader /// rest of FR-DEV-3's detail set, which cannot be fused into the shader
@@ -132,6 +130,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 {
@@ -382,9 +382,23 @@ fn film_key(t: &dr_pipeline::ops::FilmTables) -> u64 {
t.curve_log_max, t.curve_log_max,
t.density_max, t.density_max,
t.lut_size as f32, t.lut_size as f32,
t.curves.len() as f32,
t.lut.len() as f32,
] { ] {
mix(v.to_bits()); mix(v.to_bits());
} }
for v in &t.push_stations {
mix(v.to_bits());
}
// The paper's balance is left out on purpose: it reaches the shader as
// uniforms, not texels, and it is what moves when the photograph's
// exposure does — keying on it would re-upload a megabyte per tick of a
// slider that changes three floats.
if let Some(p) = &t.paper {
for v in [p.log_min, p.log_max, p.density_max] {
mix(v.to_bits());
}
}
for e in t.lut.iter().step_by(8).chain(t.curves.iter().step_by(8)) { for e in t.lut.iter().step_by(8).chain(t.curves.iter().step_by(8)) {
mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22)); mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22));
} }
@@ -431,12 +445,16 @@ impl AdjustPass {
return; return;
} }
// One row per curve — the film's at each measured push, then the
// paper's — and one cube per stage stacked in depth. The shader reads
// the layout from `FilmTables`' uniforms, not from these sizes.
let samples = dr_pipeline::ops::film_sim::CURVE_SAMPLES as u32;
let curves = self.upload_film( let curves = self.upload_film(
"adjust-film-curves", "adjust-film-curves",
wgpu::TextureDimension::D2, wgpu::TextureDimension::D2,
wgpu::Extent3d { wgpu::Extent3d {
width: t.curves.len() as u32, width: samples,
height: 1, height: t.curves.len() as u32 / samples,
depth_or_array_layers: 1, depth_or_array_layers: 1,
}, },
&to_rgba(&t.curves), &to_rgba(&t.curves),
@@ -448,7 +466,7 @@ impl AdjustPass {
wgpu::Extent3d { wgpu::Extent3d {
width: n, width: n,
height: n, height: n,
depth_or_array_layers: n, depth_or_array_layers: t.lut.len() as u32 / (n * n),
}, },
&to_rgba(&t.lut), &to_rgba(&t.lut),
); );
@@ -502,6 +520,37 @@ impl AdjustPass {
} }
/// The curve texture to bind: the loaded stock's, or the placeholder. /// The curve texture to bind: the loaded stock's, or the placeholder.
/// TRACES: FR-DEV-3e
/// A source's camera profile tables as the storage buffer
/// `@binding(8)` reads, laid out by `dr_pipeline`'s `profile_buffer`.
fn upload_profile(ctx: &GpuContext, tables: Option<&dr_types::ProfileTables>) -> wgpu::Buffer {
let data = dr_pipeline::ops::camera_profile::profile_buffer(tables);
ctx.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-profile-tables"),
contents: bytemuck::cast_slice(&data),
usage: wgpu::BufferUsages::STORAGE,
})
}
/// TRACES: FR-DEV-3e
/// The buffer to bind for `source`: its tables, uploaded once per source,
/// or the empty header. A cheap handle, cloned out so a caller holding
/// other borrows of `self` can bind it.
fn profile_buffer(&mut self, source: &DemosaicedImage) -> wgpu::Buffer {
let Some(tables) = source.profile_tables() else {
return self.empty_profile.clone();
};
if let Some((id, buffer)) = &self.profile {
if *id == source.id() {
return buffer.clone();
}
}
let buffer = Self::upload_profile(&self.ctx, Some(tables));
self.profile = Some((source.id(), buffer.clone()));
buffer
}
fn film_curves_view(&self) -> &wgpu::TextureView { fn film_curves_view(&self) -> &wgpu::TextureView {
self.film self.film
.as_ref() .as_ref()
@@ -635,6 +684,8 @@ impl AdjustPass {
empty_film_curves, empty_film_curves,
empty_film_lut, empty_film_lut,
film: None, film: None,
empty_profile: Self::upload_profile(ctx, None),
profile: None,
detail: DetailRunner::new(ctx), detail: DetailRunner::new(ctx),
linear_bind_group_layout, linear_bind_group_layout,
linear_pipeline_layout, linear_pipeline_layout,
@@ -645,6 +696,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,
} }
} }
@@ -764,6 +816,17 @@ impl AdjustPass {
}, },
count: None, count: None,
}, },
// The camera profile's tables (FR-DEV-3e, D20).
wgpu::BindGroupLayoutEntry {
binding: 8,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
], ],
}) })
} }
@@ -962,6 +1025,7 @@ impl AdjustPass {
usage: wgpu::BufferUsages::UNIFORM, usage: wgpu::BufferUsages::UNIFORM,
}); });
let profile = self.profile_buffer(source);
let pipeline = self let pipeline = self
.cache .cache
.get(&shader.structure_hash) .get(&shader.structure_hash)
@@ -1009,6 +1073,10 @@ impl AdjustPass {
binding: 7, binding: 7,
resource: wgpu::BindingResource::TextureView(&sample_out), resource: wgpu::BindingResource::TextureView(&sample_out),
}, },
wgpu::BindGroupEntry {
binding: 8,
resource: profile.as_entire_binding(),
},
], ],
}); });
@@ -1040,13 +1108,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.
@@ -1086,17 +1155,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()) {
return Err(GpuError::ShaderCompilation( (OutputMode::LinearWorking, Some(view)) => view,
"this detail chain expects a fused pass composed to hand on \ _ => {
linear working values, but the shader given encodes its own \ return Err(GpuError::ShaderCompilation(
output; compose both halves from the same graph" "this detail chain expects a fused pass composed to hand on \
.into(), linear working values, with its view pass, but the shader \
)); given encodes its own output; compose both halves from the \
} same graph"
.into(),
));
}
};
let (width, height) = (width.max(1), height.max(1)); let (width, height) = (width.max(1), height.max(1));
self.ensure_target(width, height); self.ensure_target(width, height);
@@ -1109,6 +1186,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)
@@ -1118,6 +1196,7 @@ impl AdjustPass {
// be read off `self` at the point the bind group is built. // be read off `self` at the point the bind group is built.
let film_curves = self.film_curves_view().clone(); let film_curves = self.film_curves_view().clone();
let film_lut = self.film_lut_view().clone(); let film_lut = self.film_lut_view().clone();
let profile = self.profile_buffer(source);
let mut enc = self let mut enc = self
.ctx .ctx
@@ -1181,6 +1260,10 @@ impl AdjustPass {
binding: 7, binding: 7,
resource: wgpu::BindingResource::TextureView(&sample_out), resource: wgpu::BindingResource::TextureView(&sample_out),
}, },
wgpu::BindGroupEntry {
binding: 8,
resource: profile.as_entire_binding(),
},
], ],
}); });
let pipeline = self let pipeline = self
@@ -1199,20 +1282,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.
@@ -1222,6 +1297,90 @@ 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),
},
wgpu::BindGroupEntry {
binding: 8,
resource: profile.as_entire_binding(),
},
],
});
{
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));
@@ -1257,22 +1416,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
} }
@@ -1386,6 +1536,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 {
@@ -1429,9 +1586,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`].
@@ -1460,8 +1617,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
@@ -1520,6 +1675,10 @@ impl AdjustPass {
binding: 7, binding: 7,
resource: wgpu::BindingResource::TextureView(&self.sample.no_sample_out), resource: wgpu::BindingResource::TextureView(&self.sample.no_sample_out),
}, },
wgpu::BindGroupEntry {
binding: 8,
resource: self.empty_profile.as_entire_binding(),
},
], ],
}); });
@@ -1689,7 +1848,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`
@@ -1727,9 +1886,10 @@ 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,
profile_tables: None,
baseline_exposure: 0.0,
make: String::new(), make: String::new(),
model: String::new(), model: String::new(),
crop: CropRect { crop: CropRect {
@@ -1931,9 +2091,10 @@ 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,
profile_tables: None,
baseline_exposure: 0.0,
make: String::new(), make: String::new(),
model: String::new(), model: String::new(),
crop: CropRect { crop: CropRect {
@@ -2303,7 +2464,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; N * N * N], lut: vec![[0.5, 0.5, 0.5]; N * N * N],
density_max: 3.0, density_max: 3.0,
lut_size: N, lut_size: N,
grain_particles: [0.0; 3], push_stations: vec![0.0],
paper: None,
grain_particles: [[0.0; 3]; dr_pipeline::ops::film_sim::FORMAT_COUNT],
grain_density_max: [3.0; 3], grain_density_max: [3.0; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
} }
@@ -2361,7 +2524,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"
@@ -2381,7 +2544,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()
@@ -2415,8 +2591,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"
); );
@@ -2518,9 +2701,10 @@ 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,
profile_tables: None,
baseline_exposure: 0.0,
make: String::new(), make: String::new(),
model: String::new(), model: String::new(),
crop: CropRect { crop: CropRect {
@@ -2622,9 +2806,10 @@ 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,
profile_tables: None,
baseline_exposure: 0.0,
make: String::new(), make: String::new(),
model: String::new(), model: String::new(),
crop: CropRect { crop: CropRect {
@@ -3207,11 +3392,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();
@@ -3230,11 +3420,25 @@ mod tests {
read_centre(&ctx, t) read_centre(&ctx, t)
}; };
let delta = (i32::from(from_sensor[0]) - i32::from(from_jpeg[0])).abs(); // The default rendering is the DNG reference curve (D21); for a grey its
// ProPhoto round trip is the identity, so the reference applies as is.
let scene = 3537.0 / 16383.0;
let viewed = dr_pipeline::camera_raw::apply_reference(
&dr_types::tone::ACR3_DEFAULT,
[scene; 3],
dr_pipeline::view::DEFAULT_CONTRAST,
dr_pipeline::view::DEFAULT_WHITE,
)[0];
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"
); );
} }
} }
+641 -43
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};
@@ -57,6 +57,32 @@ struct XTransParams {
tile: [u32; 4], tile: [u32; 4],
} }
/// Uniform block for the hot-pixel repair. Layout must match
/// `hot_pixels.wgsl`.
///
/// One block for both colour filter arrays: the repair asks only "which
/// photosites share this one's colour", and a 6×6 tile answers that for a
/// Bayer cell as well as for X-Trans.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct HotPixelParams {
crop_x: u32,
crop_y: u32,
width: u32,
height: u32,
stride: u32,
words: u32,
row_invocations: u32,
samples: u32,
black: [f32; 4],
inv_range: [f32; 4],
tile: [u32; 4],
}
/// The repair's workgroup width. Must match `@workgroup_size` in
/// `hot_pixels.wgsl`.
const HOT_PIXEL_GROUP: u32 = 64;
/// A demosaiced image living on the GPU. /// A demosaiced image living on the GPU.
/// ///
/// RGBA16Float, scene-referred, camera colour space. This is the input every /// RGBA16Float, scene-referred, camera colour space. This is the input every
@@ -81,25 +107,32 @@ pub struct DemosaicedImage {
height: u32, height: u32,
/// Carried through for the camera→sRGB transform in the adjust pass. /// Carried through for the camera→sRGB transform in the adjust pass.
color_matrix: [f32; 9], color_matrix: [f32; 9],
/// TRACES: FR-DEV-3e
/// The camera profile's tables, carried through with the matrix for the
/// adjust pass to upload (D20). `None` for a JPEG and for a raw with no
/// profile.
profile_tables: Option<std::sync::Arc<dr_types::ProfileTables>>,
/// 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);
@@ -117,10 +150,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
@@ -140,12 +213,9 @@ impl DemosaicedImage {
} }
/// TRACES: FR-DEV-3e /// TRACES: FR-DEV-3e
/// The camera profile's base curve, as five `(x, y)` points. /// The camera profile's tables this source renders through, if any.
/// pub fn profile_tables(&self) -> Option<&std::sync::Arc<dr_types::ProfileTables>> {
/// [`BaseCurve::IDENTITY`] where the body is unprofiled or the source was self.profile_tables.as_ref()
/// 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.
@@ -259,30 +329,157 @@ impl DemosaicedImage {
width, width,
height, height,
color_matrix: IDENTITY_3X3, color_matrix: IDENTITY_3X3,
profile_tables: None,
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,
}) })
} }
} }
impl DemosaicedImage { impl DemosaicedImage {
/// TRACES: FR-DEV-3g
/// The learned demosaic's output for the photograph `like` was
/// demosaiced from: `width × height` interleaved RGB, linear camera
/// space, normalised as the demosaic normalises — the same texture the
/// classical path made, with the noise gone (denoise.md §2).
///
/// Everything that describes the photograph rather than its pixels —
/// matrix, profile tables, as-shot balance — is `like`'s, so nothing
/// downstream can tell which demosaic ran. A new [`Self::id`], so every
/// cache keyed on the source sees a new source.
pub fn from_rgb_f32(
ctx: &GpuContext,
like: &DemosaicedImage,
width: u32,
height: u32,
rgb: &[f32],
) -> Result<Self, GpuError> {
let n = width as usize * height as usize;
if rgb.len() != n * 3 {
return Err(GpuError::TooLarge(format!(
"{} values for a {width}×{height} RGB image",
rgb.len()
)));
}
let limits = ctx.device.limits();
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
return Err(GpuError::TooLarge(format!(
"{width}×{height} exceeds the device limit of {}",
limits.max_texture_dimension_2d
)));
}
let mut half = vec![0u16; n * 4];
let one = f32_to_f16_bits(1.0);
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
let per = n.div_ceil(threads).max(1);
std::thread::scope(|scope| {
for (k, out) in half.chunks_mut(per * 4).enumerate() {
scope.spawn(move || {
for (i, texel) in out.chunks_mut(4).enumerate() {
let src = &rgb[(k * per + i) * 3..(k * per + i) * 3 + 3];
for c in 0..3 {
texel[c] = f32_to_f16_bits_unclamped(src[c]);
}
texel[3] = one;
}
});
}
});
let texture = ctx.device.create_texture_with_data(
&ctx.queue,
&wgpu::TextureDescriptor {
label: Some("learned-demosaic-source"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
bytemuck::cast_slice(&half),
);
Ok(like.sibling(texture, width, height))
}
/// A new source standing for the same photograph as `self`: its
/// description kept, its pixels `texture`, a fresh id.
pub(crate) fn sibling(&self, texture: wgpu::Texture, width: u32, height: u32) -> Self {
let view = texture.create_view(&Default::default());
Self {
texture,
view,
width,
height,
color_matrix: self.color_matrix,
profile_tables: self.profile_tables.clone(),
as_shot_wb: self.as_shot_wb,
non_linear: self.non_linear,
id: next_image_id(),
frame: self.frame,
window: self.window,
}
}
/// TRACES: FR-MRG-3 /// TRACES: FR-MRG-3
/// A source that is already RGB in camera space: a linear DNG, which is /// A source that is already RGB in camera space: a linear DNG, which is
/// 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!(
@@ -302,19 +499,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]| {
for c in 0..3 { let sy0 = y0 as usize + ty * k as usize;
let v = (f32::from(p[c]) - black[c]) * inv[c]; let sy1 = (sy0 + k as usize).min((y0 + rh) as usize);
half.push(f32_to_f16_bits_unclamped(v)); for tx in 0..width as usize {
let sx0 = x0 as usize + tx * k as usize;
let sx1 = (sx0 + k as usize).min((x0 + rw) as usize);
let mut acc = [0f32; 3];
for sy in sy0..sy1 {
let row = (raw.crop.y as usize + sy) * stride + raw.crop.x as usize * 3;
for sx in sx0..sx1 {
let p = &raw.data[row + sx * 3..row + sx * 3 + 3];
for c in 0..3 {
acc[c] += f32::from(p[c]);
}
}
} }
half.push(f32_to_f16_bits(1.0)); let n = ((sy1 - sy0) * (sx1 - sx0)).max(1) as f32;
let texel = &mut out[tx * 4..tx * 4 + 4];
for c in 0..3 {
let v = (acc[c] / n - black[c]) * inv[c];
texel[c] = f32_to_f16_bits_unclamped(v);
}
texel[3] = f32_to_f16_bits(1.0);
} }
} };
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
let rows_per = (height as usize).div_ceil(threads).max(1);
std::thread::scope(|scope| {
for (chunk, rows) in half.chunks_mut(rows_per * row_texels).enumerate() {
let fill_row = &fill_row;
scope.spawn(move || {
for (i, out) in rows.chunks_mut(row_texels).enumerate() {
fill_row(chunk * rows_per + i, out);
}
});
}
});
let texture = ctx.device.create_texture_with_data( let texture = ctx.device.create_texture_with_data(
&ctx.queue, &ctx.queue,
&wgpu::TextureDescriptor { &wgpu::TextureDescriptor {
@@ -335,20 +562,50 @@ 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,
width, width,
height, height,
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3), color_matrix: rendering_matrix(raw),
profile_tables: raw.profile_tables.clone(),
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,
}) })
} }
} }
/// TRACES: FR-DEV-3e
/// The camera matrix a raw renders through: the file's — identity where the
/// body is uncalibrated, so the image renders with no colour transform
/// rather than not at all — times the baseline exposure as a gain
/// (camera-profiles.md §11).
///
/// A uniform gain commutes with every scene operation before the view
/// transform, so folding it in here is the same as an exposure step at the
/// head of the chain, at no cost. The camera-space tap and the white-balance
/// probe read camera RGB before this matrix and are unaffected.
/// `RawImage::color_matrix` stays the file's: a merge writes a linear DNG
/// from it and must not bake a gain into its pixels.
fn rendering_matrix(raw: &RawImage) -> [f32; 9] {
let gain = raw.baseline_exposure.exp2();
raw.color_matrix.unwrap_or(IDENTITY_3X3).map(|v| v * gain)
}
/// Convert an f32 to half-precision bits, the general case: sign, /// Convert an f32 to half-precision bits, the general case: sign,
/// subnormals, round-to-nearest-even, saturation at the largest finite. /// subnormals, round-to-nearest-even, saturation at the largest finite.
/// ///
@@ -437,6 +694,8 @@ pub struct Demosaicer {
pipeline: wgpu::ComputePipeline, pipeline: wgpu::ComputePipeline,
xtrans_pipeline: wgpu::ComputePipeline, xtrans_pipeline: wgpu::ComputePipeline,
bind_group_layout: wgpu::BindGroupLayout, bind_group_layout: wgpu::BindGroupLayout,
hot_pixel_pipeline: wgpu::ComputePipeline,
hot_pixel_layout: wgpu::BindGroupLayout,
} }
impl Demosaicer { impl Demosaicer {
@@ -527,11 +786,15 @@ impl Demosaicer {
cache: None, cache: None,
}); });
let (hot_pixel_pipeline, hot_pixel_layout) = hot_pixel_pipeline(ctx);
Ok(Self { Ok(Self {
ctx: ctx.clone(), ctx: ctx.clone(),
pipeline, pipeline,
xtrans_pipeline, xtrans_pipeline,
bind_group_layout, bind_group_layout,
hot_pixel_pipeline,
hot_pixel_layout,
}) })
} }
@@ -558,8 +821,12 @@ impl Demosaicer {
// the buffer outlive the `if` that chose them. // the buffer outlive the `if` that chose them.
let bayer_params; let bayer_params;
let xtrans_params; let xtrans_params;
// Kept for the hot-pixel repair: finding the X-Trans phase reads the
// whole frame on the CPU, and once per photograph is enough.
let mut xtrans_tile = None;
let (pipeline, params_bytes) = if raw.cfa_pattern.is_xtrans() { let (pipeline, params_bytes) = if raw.cfa_pattern.is_xtrans() {
xtrans_params = xtrans_params_for(raw, width, height); xtrans_params = xtrans_params_for(raw, width, height);
xtrans_tile = Some(xtrans_params.tile);
(&self.xtrans_pipeline, bytemuck::bytes_of(&xtrans_params)) (&self.xtrans_pipeline, bytemuck::bytes_of(&xtrans_params))
} else { } else {
let pattern = match raw.cfa_pattern { let pattern = match raw.cfa_pattern {
@@ -598,6 +865,16 @@ impl Demosaicer {
usage: wgpu::BufferUsages::STORAGE, usage: wgpu::BufferUsages::STORAGE,
}); });
// TRACES: FR-RAW-3
// The mosaic the demosaic actually reads: the readout with its hot and
// dead photosites repaired.
let hot = self.hot_pass(
raw,
(width, height),
&raw_buf,
packed.len() as u32,
xtrans_tile,
);
let params_buf = self let params_buf = self
.ctx .ctx
.device .device
@@ -636,7 +913,7 @@ impl Demosaicer {
entries: &[ entries: &[
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 0, binding: 0,
resource: raw_buf.as_entire_binding(), resource: hot.repaired.as_entire_binding(),
}, },
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 1, binding: 1,
@@ -655,6 +932,10 @@ impl Demosaicer {
.create_command_encoder(&wgpu::CommandEncoderDescriptor { .create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("demosaic-encoder"), label: Some("demosaic-encoder"),
}); });
// Two passes in one submission. wgpu orders a storage write in one
// pass before a read of the same buffer in the next, so the demosaic
// sees every repair.
self.record_hot_pass(&mut enc, &hot);
{ {
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("demosaic-pass"), label: Some("demosaic-pass"),
@@ -673,21 +954,186 @@ impl Demosaicer {
height, height,
// Identity where the body is uncalibrated: the image renders with // Identity where the body is uncalibrated: the image renders with
// no colour transform rather than not at all. // no colour transform rather than not at all.
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3), color_matrix: rendering_matrix(raw),
profile_tables: raw.profile_tables.clone(),
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]],
// 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,
}) })
} }
} }
/// The hot-pixel pass's resources for one frame, ready to record.
struct HotPass {
repaired: wgpu::Buffer,
bind_group: wgpu::BindGroup,
groups: (u32, u32),
}
impl Demosaicer {
/// Buffers and bindings for the hot and dead photosite repair of `raw`,
/// whose packed samples are in `raw_buf`.
fn hot_pass(
&self,
raw: &RawImage,
(width, height): (u32, u32),
raw_buf: &wgpu::Buffer,
words: u32,
xtrans_tile: Option<[u32; 4]>,
) -> HotPass {
// A second buffer rather than in place, because every photosite's
// verdict reads its neighbours' originals.
let repaired = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("raw-repaired"),
size: raw_buf.size(),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let groups = words.div_ceil(HOT_PIXEL_GROUP).max(1);
// A 24 MP readout is 190,000 workgroups, past the 65,535 one
// dispatch dimension may hold, so the grid folds into rows.
let groups_x = groups.min(
self.ctx
.device
.limits()
.max_compute_workgroups_per_dimension,
);
let groups_y = groups.div_ceil(groups_x);
let hot_params = hot_pixel_params(
raw,
(width, height),
words,
groups_x * HOT_PIXEL_GROUP,
xtrans_tile,
);
let hot_params_buf =
self.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hot-pixel-params"),
contents: bytemuck::bytes_of(&hot_params),
usage: wgpu::BufferUsages::UNIFORM,
});
let bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("hot-pixel-bg"),
layout: &self.hot_pixel_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: raw_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: hot_params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: repaired.as_entire_binding(),
},
],
});
HotPass {
repaired,
bind_group,
groups: (groups_x, groups_y),
}
}
fn record_hot_pass(&self, enc: &mut wgpu::CommandEncoder, hot: &HotPass) {
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("hot-pixel-pass"),
timestamp_writes: None,
});
pass.set_pipeline(&self.hot_pixel_pipeline);
pass.set_bind_group(0, &hot.bind_group, &[]);
pass.dispatch_workgroups(hot.groups.0, hot.groups.1, 1);
}
/// TRACES: FR-RAW-3 | FR-DEV-3g
/// Repair `raw`'s hot and dead photosites in place, exactly as [`Self::run`]
/// does before it demosaics, and return how many changed.
///
/// For the learned demosaic (denoise.md §2), which reads the same repaired
/// mosaic the classical one does: its training data and its input in the
/// app must have been through this one pass, not a lookalike.
pub fn repair_hot_pixels(&self, raw: &mut RawImage) -> Result<usize, GpuError> {
if raw.samples_per_pixel != 1 {
return Ok(0);
}
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
let xtrans_tile = raw
.cfa_pattern
.is_xtrans()
.then(|| xtrans_params_for(raw, width, height).tile);
let packed = pack_samples(&raw.data);
let raw_buf = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("raw-samples"),
contents: bytemuck::cast_slice(&packed),
usage: wgpu::BufferUsages::STORAGE,
});
let hot = self.hot_pass(
raw,
(width, height),
&raw_buf,
packed.len() as u32,
xtrans_tile,
);
let readback = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("raw-repaired-readback"),
size: hot.repaired.size(),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = self
.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("hot-pixel-encoder"),
});
self.record_hot_pass(&mut enc, &hot);
enc.copy_buffer_to_buffer(&hot.repaired, 0, &readback, 0, hot.repaired.size());
self.ctx.queue.submit(Some(enc.finish()));
let slice = readback.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
self.ctx
.device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| GpuError::Readback(e.to_string()))?;
rx.recv()
.map_err(|e| GpuError::Readback(e.to_string()))?
.map_err(|e| GpuError::Readback(e.to_string()))?;
let words: Vec<u32> = bytemuck::cast_slice(&slice.get_mapped_range()).to_vec();
readback.unmap();
let mut changed = 0;
for (i, v) in raw.data.iter_mut().enumerate() {
let w = words[i / 2];
let new = if i % 2 == 0 { w & 0xFFFF } else { w >> 16 } as u16;
changed += usize::from(new != *v);
*v = new;
}
Ok(changed)
}
}
const IDENTITY_3X3: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]; const IDENTITY_3X3: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
/// Pack u16 samples two per u32, little-endian within the word. /// Pack u16 samples two per u32, little-endian within the word.
@@ -960,6 +1406,136 @@ fn detect_xtrans_phase(raw: &RawImage) -> (u32, u32) {
/// TRACES: FR-RAW-5 /// TRACES: FR-RAW-5
/// Everything the X-Trans shader needs about one image. /// Everything the X-Trans shader needs about one image.
/// TRACES: FR-RAW-3
/// The hot-pixel repair's pipeline and its three bindings: the readout, the
/// uniform block, and the repaired copy it writes.
fn hot_pixel_pipeline(ctx: &GpuContext) -> (wgpu::ComputePipeline, wgpu::BindGroupLayout) {
let shader = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("hot-pixels"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/hot_pixels.wgsl").into()),
});
let storage = |binding, read_only| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let layout = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("hot-pixel-bgl"),
entries: &[
storage(0, true),
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
storage(2, false),
],
});
let pipeline_layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("hot-pixel-layout"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let pipeline = ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("hot-pixel-pipeline"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
(pipeline, layout)
}
/// The colour of each position of a Bayer cell, row-major, for the pattern
/// the decoder reported: 0=R, 1=G, 2=B. `None` for anything that is not a
/// 2×2 pattern.
fn bayer_cell(pattern: CfaPattern) -> Option<[u32; 4]> {
match pattern {
CfaPattern::Rggb => Some([0, 1, 1, 2]),
CfaPattern::Bggr => Some([2, 1, 1, 0]),
CfaPattern::Grbg => Some([1, 0, 2, 1]),
CfaPattern::Gbrg => Some([1, 2, 0, 1]),
_ => None,
}
}
/// A Bayer cell as the 6×6 sensor-anchored tile the repair indexes.
///
/// The decoder's pattern is phased for the *crop* origin, and the tile is
/// indexed by sensor coordinate, so each position is shifted by the crop.
/// Six is even, so a column's parity modulo 6 is its parity outright and the
/// cell repeats cleanly.
fn pack_bayer_tile(cell: [u32; 4], crop_x: u32, crop_y: u32) -> [u32; 4] {
let mut out = [0u32; 4];
for row in 0..6u32 {
for col in 0..6u32 {
let i = (((row + crop_y) & 1) * 2 + ((col + crop_x) & 1)) as usize;
out[(row >> 1) as usize] |= cell[i] << ((row & 1) * 12 + col * 2);
}
}
out
}
/// The repair's uniforms for one readout.
///
/// `xtrans_tile` is the tile the X-Trans demosaic was given, when it was one;
/// anything else must be a Bayer pattern, which `run` has already checked.
fn hot_pixel_params(
raw: &RawImage,
(width, height): (u32, u32),
words: u32,
row_invocations: u32,
xtrans_tile: Option<[u32; 4]>,
) -> HotPixelParams {
let (black, inv_range, tile) = match (xtrans_tile, bayer_cell(raw.cfa_pattern)) {
(Some(tile), _) => {
let (black, inv_range) = xtrans_levels(raw);
([black; 4], [inv_range; 4], tile)
}
(None, Some(cell)) => (
black_per_cell(raw),
inv_range_per_cell(raw),
pack_bayer_tile(cell, raw.crop.x, raw.crop.y),
),
// Not reached from `run`, which refuses any other pattern before
// this. A zero tile judges every photosite against all of its
// neighbours, which is right for a sensor with no colour filter.
(None, None) => (black_per_cell(raw), inv_range_per_cell(raw), [0; 4]),
};
HotPixelParams {
crop_x: raw.crop.x,
crop_y: raw.crop.y,
width,
height,
stride: raw.width,
words,
row_invocations,
samples: raw.data.len() as u32,
black,
inv_range,
tile,
}
}
fn xtrans_params_for(raw: &RawImage, width: u32, height: u32) -> XTransParams { fn xtrans_params_for(raw: &RawImage, width: u32, height: u32) -> XTransParams {
let (black, inv_range) = xtrans_levels(raw); let (black, inv_range) = xtrans_levels(raw);
let wb = wb_gains(raw); let wb = wb_gains(raw);
@@ -994,6 +1570,24 @@ mod tests {
} }
} }
/// The repair's tile is indexed by sensor coordinate, the decoder's
/// pattern by crop coordinate. A crop at an odd origin must shift one
/// into the other, or the repair compares red with green.
#[test]
fn the_bayer_tile_is_anchored_to_the_sensor_not_the_crop() {
let cell = bayer_cell(CfaPattern::Rggb).unwrap();
let colour = |tile: [u32; 4], x: u32, y: u32| {
(tile[((y % 6) >> 1) as usize] >> (((y % 6) & 1) * 12 + (x % 6) * 2)) & 3
};
for (cx, cy) in [(0, 0), (1, 0), (0, 1), (1, 1), (7, 4)] {
let tile = pack_bayer_tile(cell, cx, cy);
// Red is the crop's first photosite, wherever the crop starts.
assert_eq!(colour(tile, cx, cy), 0, "crop at ({cx}, {cy})");
assert_eq!(colour(tile, cx + 1, cy + 1), 2, "crop at ({cx}, {cy})");
assert_eq!(colour(tile, cx + 1, cy), 1, "crop at ({cx}, {cy})");
}
}
#[test] #[test]
fn unclamped_half_keeps_shadows_signs_and_highlights() { fn unclamped_half_keeps_shadows_signs_and_highlights() {
// A 14-bit LSB, normalised: subnormal in f16, and must not be zero. // A 14-bit LSB, normalised: subnormal in f16, and must not be zero.
@@ -1030,9 +1624,10 @@ 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,
profile_tables: None,
baseline_exposure: 0.0,
make: String::new(), make: String::new(),
model: String::new(), model: String::new(),
crop: CropRect { crop: CropRect {
@@ -1146,9 +1741,10 @@ 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,
profile_tables: None,
baseline_exposure: 0.0,
make: String::new(), make: String::new(),
model: String::new(), model: String::new(),
crop: CropRect { crop: CropRect {
@@ -1443,9 +2039,10 @@ 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,
profile_tables: None,
baseline_exposure: 0.0,
make: String::new(), make: String::new(),
model: String::new(), model: String::new(),
crop: CropRect { crop: CropRect {
@@ -1528,9 +2125,10 @@ 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,
profile_tables: None,
baseline_exposure: 0.0,
make: String::new(), make: String::new(),
model: String::new(), model: String::new(),
crop: CropRect { crop: CropRect {
+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
+219
View File
@@ -0,0 +1,219 @@
//! TRACES: FR-DEV-3g
//! Grain back into a denoised photograph, as brightness only.
//!
//! The learned denoise's one live control. The network's result and the
//! classical demosaic of the same mosaic differ by the noise the network
//! removed — plus the classical path's colour speckle and demosaic false
//! colour, which nobody wants back. So only the brightness of the difference
//! is returned, in proportion to `grain`:
//!
//! `out = denoised + grain · ΔY / wb`, with `ΔY = Y(wb · (classical − denoised))`
//!
//! `Y` is taken after the as-shot balance and handed back divided by it, so
//! the grain is neutral in the finished picture rather than tinted the
//! colour of the sensor's raw response. At 0 the result is the network's
//! exactly; at 1 the brightness noise is all back, the colour noise none.
//!
//! A pass of its own producing a new source rather than a term in the
//! adjust shader: the blend depends only on the two images and one number,
//! a 20 MP pass is a few milliseconds, and a new source id is all the
//! adjust pass's caches need to know it changed.
use std::sync::Arc;
use crate::demosaic::DemosaicedImage;
use crate::{GpuContext, GpuError};
const SHADER: &str = r#"
struct Params {
grain: f32,
_pad0: f32,
_pad1: f32,
_pad2: f32,
wb: vec4<f32>,
}
@group(0) @binding(0) var denoised: texture_2d<f32>;
@group(0) @binding(1) var classical: texture_2d<f32>;
@group(0) @binding(2) var<uniform> p: Params;
@group(0) @binding(3) var out: texture_storage_2d<rgba16float, write>;
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = textureDimensions(denoised);
if (gid.x >= dims.x || gid.y >= dims.y) {
return;
}
let xy = vec2<i32>(gid.xy);
let d = textureLoad(denoised, xy, 0).rgb;
let c = textureLoad(classical, xy, 0).rgb;
let wb = p.wb.rgb;
let dy = p.grain * dot(vec3<f32>(0.2126, 0.7152, 0.0722), wb * (c - d));
textureStore(out, xy, vec4<f32>(d + dy / wb, 1.0));
}
"#;
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
grain: f32,
_pad: [f32; 3],
wb: [f32; 4],
}
pub struct GrainBlend {
ctx: GpuContext,
pipeline: wgpu::ComputePipeline,
layout: wgpu::BindGroupLayout,
}
impl GrainBlend {
pub fn new(ctx: &GpuContext) -> Self {
let device = &ctx.device;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("grain-blend"),
source: wgpu::ShaderSource::Wgsl(SHADER.into()),
});
let texture = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("grain-blend-layout"),
entries: &[
texture(0),
texture(1),
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: DemosaicedImage::FORMAT,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("grain-blend-pipeline-layout"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("grain-blend"),
layout: Some(&pipeline_layout),
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
Self {
ctx: ctx.clone(),
pipeline,
layout,
}
}
/// `denoised` with `grain` (0–1) of `classical`'s brightness noise back.
/// Both must be the same photograph at the same size.
pub fn blend(
&self,
denoised: &DemosaicedImage,
classical: &DemosaicedImage,
grain: f32,
) -> Result<Arc<DemosaicedImage>, GpuError> {
let (w, h) = (denoised.texture().width(), denoised.texture().height());
if (classical.texture().width(), classical.texture().height()) != (w, h) {
return Err(GpuError::TooLarge(format!(
"grain from a {}×{} source into a {w}×{h} one",
classical.texture().width(),
classical.texture().height()
)));
}
let device = &self.ctx.device;
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("grain-blended-source"),
size: wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DemosaicedImage::FORMAT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let out_view = texture.create_view(&Default::default());
let wb = denoised.as_shot_wb();
let g = wb[1].max(1e-6);
let params = Params {
grain: grain.clamp(0.0, 1.0),
_pad: [0.0; 3],
// Green-normalised, and never zero: the shader divides by it.
wb: [(wb[0] / g).max(1e-3), 1.0, (wb[2] / g).max(1e-3), 1.0],
};
use wgpu::util::DeviceExt;
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("grain-blend-params"),
contents: bytemuck::bytes_of(&params),
usage: wgpu::BufferUsages::UNIFORM,
});
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("grain-blend-bg"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(denoised.view()),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(classical.view()),
},
wgpu::BindGroupEntry {
binding: 2,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&out_view),
},
],
});
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("grain-blend"),
});
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("grain-blend"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bind, &[]);
pass.dispatch_workgroups(w.div_ceil(8), h.div_ceil(8), 1);
}
self.ctx.queue.submit(Some(enc.finish()));
Ok(Arc::new(denoised.sibling(texture, w, h)))
}
}
+2
View File
@@ -26,6 +26,7 @@ mod demosaic;
mod detail; mod detail;
mod error; mod error;
mod focus; mod focus;
mod grain;
mod histogram; mod histogram;
mod mask; mod mask;
mod merge; mod merge;
@@ -41,6 +42,7 @@ pub use demosaic::{DemosaicedImage, Demosaicer};
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT; pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
pub use error::GpuError; pub use error::GpuError;
pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity}; pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity};
pub use grain::GrainBlend;
pub use merge::{Band, MergeFrame, MergeOutput, MergePass}; pub use merge::{Band, MergeFrame, MergeOutput, MergePass};
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing // Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
// at all at a crate root shared with demosaic and segmentation. // at all at a crate root shared with demosaic and segmentation.
+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,
+122 -10
View File
@@ -30,18 +30,27 @@
//! are the caller's to provide and cache — `source` is asked for frame `k` //! are the caller's to provide and cache — `source` is asked for frame `k`
//! as it is needed, and a caller short of memory may demosaic on demand. //! as it is needed, and a caller short of memory may demosaic on demand.
//! //!
//! # The blend
//!
//! With a seam map (`dr_pano::seam`), a frame's weight at a pixel is its
//! share of the map about that pixel — whole on its own side of a seam,
//! nothing on the other, and a ramp across a window `seam_blend` pixels
//! wide that follows the seam. Without one, or where the map has nothing
//! to say, the weight is the distance to the frame's edge over `feather`,
//! which hides exposure steps and does not hide parallax: the average draws
//! anything the frames disagree on twice.
//!
//! # What is not here yet //! # What is not here yet
//! //!
//! A feathered blend, not seams and a Laplacian pyramid: the weight is the //! A Laplacian pyramid, which would let the seam's blend be narrow for
//! distance to the frame's edge, which hides exposure steps and small //! detail and wide for exposure at once. Gain is a scalar per frame the
//! misalignments and does not hide parallax. Gain is a scalar per frame //! caller supplies.
//! the caller supplies. Both are panorama.md §10's step 5, after the path
//! writes a file end to end.
use std::sync::Arc; use std::sync::Arc;
use dr_pano::bundle::Cameras; use dr_pano::bundle::Cameras;
use dr_pano::projection::{Bounds, Projection}; use dr_pano::projection::{Bounds, Projection};
use dr_pano::seam::SeamMap;
use wgpu::util::DeviceExt; use wgpu::util::DeviceExt;
use crate::readback::await_mapping; use crate::readback::await_mapping;
@@ -58,7 +67,7 @@ pub struct MergeFrame {
} }
/// The output the merge produces. /// The output the merge produces.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct MergeOutput { pub struct MergeOutput {
pub projection: Projection, pub projection: Projection,
/// The projection's scale in output pixels: the cylinder's radius, the /// The projection's scale in output pixels: the cylinder's radius, the
@@ -69,10 +78,19 @@ pub struct MergeOutput {
pub bounds: Bounds, pub bounds: Bounds,
/// Pixels over which a frame's weight ramps up from its edge. /// Pixels over which a frame's weight ramps up from its edge.
pub feather: f32, pub feather: f32,
/// Which frame each part of the output is taken from, laid out at the
/// proxies' scale; `None` for the feathered average everywhere.
pub seams: Option<Arc<SeamMap>>,
/// The width, in output pixels, of the blend across a seam.
pub seam_blend: f32,
/// Chunk size: the unit of GPU work and of memory. /// Chunk size: the unit of GPU work and of memory.
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 +127,14 @@ 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],
seam_origin: [f32; 2],
seam_size: [u32; 2],
seam_px: f32,
seam_radius: f32,
frame_index: u32,
seam_on: u32,
} }
#[repr(C)] #[repr(C)]
@@ -177,6 +202,16 @@ impl MergePass {
count: None, count: None,
}, },
storage(2, false), storage(2, false),
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Uint,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
], ],
}); });
let resolve_layout = let resolve_layout =
@@ -274,6 +309,32 @@ impl MergePass {
let mut band_cov = vec![false; (out_w * ch) as usize]; let mut band_cov = vec![false; (out_w * ch) as usize];
let mut chunk_px: Vec<u32> = Vec::new(); let mut chunk_px: Vec<u32> = Vec::new();
// The seam map, once for the whole output, and where it sits in
// this output's coordinates. A one-texel stand-in when there is
// none, because the binding is not optional.
let (seam_tex, seam_origin, seam_px, seam_radius, seam_size) = match &output.seams {
Some(m) => {
let ((ou, ov), px) = m.at_scale(output.scale);
let radius = m.blend_radius(output.scale, f64::from(output.seam_blend));
(
self.label_texture(m.width as u32, m.height as u32, &m.labels),
[ou as f32, ov as f32],
px as f32,
radius as f32,
[m.width as u32, m.height as u32],
)
}
None => (
self.label_texture(1, 1, &[dr_pano::seam::NONE]),
[0.0; 2],
1.0,
1.0,
[1, 1],
),
};
let seam_view = seam_tex.create_view(&Default::default());
let seam_on = u32::from(output.seams.is_some());
let mut y = 0u32; let mut y = 0u32;
while y < out_h { while y < out_h {
let rows = ch.min(out_h - y); let rows = ch.min(out_h - y);
@@ -334,9 +395,21 @@ 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,
],
seam_origin,
seam_size,
seam_px,
seam_radius,
frame_index: k as u32,
seam_on,
}; };
self.accumulate(&params, tile); self.accumulate(&params, tile, &seam_view);
} }
self.resolve_chunk((cols, rows), output.sample_scale, &mut chunk_px)?; self.resolve_chunk((cols, rows), output.sample_scale, &mut chunk_px)?;
@@ -374,7 +447,42 @@ impl MergePass {
self.ctx.queue.submit(Some(enc.finish())); self.ctx.queue.submit(Some(enc.finish()));
} }
fn accumulate(&mut self, params: &WarpParams, tile: &wgpu::Texture) { /// The seam map's labels as an `r8uint` texture.
fn label_texture(&self, width: u32, height: u32, labels: &[u8]) -> wgpu::Texture {
let size = wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
};
let tex = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("merge-seams"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Uint,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
self.ctx.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
labels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(width),
rows_per_image: Some(height),
},
size,
);
tex
}
fn accumulate(&mut self, params: &WarpParams, tile: &wgpu::Texture, seams: &wgpu::TextureView) {
let chunk = (params.chunk_size[0], params.chunk_size[1]); let chunk = (params.chunk_size[0], params.chunk_size[1]);
let uniforms = self let uniforms = self
.ctx .ctx
@@ -406,6 +514,10 @@ impl MergePass {
binding: 2, binding: 2,
resource: acc.as_entire_binding(), resource: acc.as_entire_binding(),
}, },
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(seams),
},
], ],
}); });
let mut enc = self.ctx.device.create_command_encoder(&Default::default()); let mut enc = self.ctx.device.create_command_encoder(&Default::default());
+2 -2
View File
@@ -28,8 +28,8 @@
//! than leaving the specification and the code silently disagreeing. //! 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;
+185
View File
@@ -0,0 +1,185 @@
// Hot and dead photosite repair, on the raw mosaic, before demosaic.
//
// A hot photosite reads far above anything the light put there — a leaky
// well, lit by its own dark current on a long or high-ISO exposure. Left in,
// the demosaic spreads it into its neighbours' interpolated channels and it
// becomes a coloured cross, three pixels wide, that no later stage can take
// back out: by then it is five pixels of plausible colour rather than one
// photosite of nonsense. So it is repaired here, where it is still one value.
//
// **What counts as hot.** A photosite far above *every* photosite of its own
// colour in its 5x5 window, and also far above every one of its eight
// immediate neighbours whatever their colour. The second half is what keeps a
// star or a glint: real light arrives through a lens and an anti-aliasing
// filter, so even the sharpest point lands on a patch of photosites, and the
// ones beside it are lit too. A hot photosite's neighbours are as dark as the
// rest of the frame. Dead photosites are the mirror image and are handled the
// same way.
//
// **What it becomes.** The brightest (for a hot photosite) or darkest (for a
// dead one) same-colour neighbour — the value nearest to what it read that
// the neighbourhood can vouch for. An average would soften the one case this
// gets wrong, a real highlight that happened to pass both tests; a clamp to
// the neighbourhood's range cannot invent anything.
//
// Written for either colour filter array: the colour of a photosite comes from
// a 6x6 tile anchored to the sensor, which holds the X-Trans pattern as it is
// and a Bayer 2x2 cell repeated nine times.
struct HotPixelParams {
// The cropped area, in sensor photosites. Only photosites inside it are
// judged, and only photosites inside it are asked as neighbours — the
// masked border sits at black and would make everything look hot.
crop_x: u32,
crop_y: u32,
width: u32,
height: u32,
// Row stride of the readout, in samples, and the number of u32 words.
stride: u32,
words: u32,
// How many invocations one row of the dispatch grid holds, so a frame
// wider than a dispatch dimension can be addressed as two.
row_invocations: u32,
// Samples in the readout. One less than twice `words` when the count is
// odd, and the padding half of the last word is never judged.
samples: u32,
// Per-position black levels and reciprocal ranges, indexed by the
// photosite's parity within the *crop*: (y&1)*2 + (x&1) counted from its
// origin, as the demosaic counts them.
black: vec4<f32>,
inv_range: vec4<f32>,
// The 6x6 colour tile, two bits per photosite, indexed by sensor
// coordinate modulo 6: word k holds row 2k in its low 12 bits and row
// 2k+1 in the next 12. The fourth word is padding.
tile: vec4<u32>,
}
@group(0) @binding(0) var<storage, read> raw: array<u32>;
@group(0) @binding(1) var<uniform> params: HotPixelParams;
@group(0) @binding(2) var<storage, read_write> repaired: array<u32>;
// How far above its brightest neighbour a photosite must read to be hot, as a
// ratio and a margin in normalised units. Twice the neighbourhood and two
// percent of the range above it: far enough that shot noise in a lit area
// never qualifies, near enough that a hot photosite in a night sky — reading
// a third of the range over a sky at one percent — always does.
const HOT_RATIO: f32 = 2.0;
const HOT_MARGIN: f32 = 0.02;
// A dead photosite reads under half its darkest neighbour, and only counts
// where that neighbour is at least this bright: in the shadows, a photosite
// at zero is noise that clipped at the black point, not a defect.
const DEAD_RATIO: f32 = 0.5;
const DEAD_FLOOR: f32 = 0.05;
fn value_at(index: u32) -> u32 {
let word = raw[index >> 1u];
return select(word & 0xFFFFu, word >> 16u, (index & 1u) == 1u);
}
fn colour_at(sx: u32, sy: u32) -> u32 {
let row = sy % 6u;
let col = sx % 6u;
let word = params.tile[row >> 1u];
return (word >> ((row & 1u) * 12u + col * 2u)) & 3u;
}
// A raw value against its own black level and range. Compared rather than
// stored, so it is left unclamped at the top: a hot photosite above white is
// still more above white than its neighbours are.
fn level(sx: u32, sy: u32, v: u32) -> f32 {
let cell = ((sy - params.crop_y) & 1u) * 2u + ((sx - params.crop_x) & 1u);
return max(f32(v) - params.black[cell], 0.0) * params.inv_range[cell];
}
// The value to store for the photosite at `index`.
fn repair(index: u32) -> u32 {
let v = value_at(index);
let sx = index % params.stride;
let sy = index / params.stride;
if (sx < params.crop_x || sy < params.crop_y
|| sx >= params.crop_x + params.width || sy >= params.crop_y + params.height) {
return v;
}
let centre = level(sx, sy, v);
let colour = colour_at(sx, sy);
var same_hi = -1.0;
var same_lo = 1.0e9;
var same_hi_raw = v;
var same_lo_raw = v;
var same_count = 0u;
var adjacent_hi = 0.0;
var adjacent_lo = 1.0e9;
for (var dy = -2; dy <= 2; dy++) {
for (var dx = -2; dx <= 2; dx++) {
if (dx == 0 && dy == 0) {
continue;
}
let nx = i32(sx) + dx;
let ny = i32(sy) + dy;
if (nx < i32(params.crop_x) || ny < i32(params.crop_y)
|| nx >= i32(params.crop_x + params.width)
|| ny >= i32(params.crop_y + params.height)) {
continue;
}
let nsx = u32(nx);
let nsy = u32(ny);
let nv = value_at(nsy * params.stride + nsx);
let n = level(nsx, nsy, nv);
if (abs(dx) <= 1 && abs(dy) <= 1) {
adjacent_hi = max(adjacent_hi, n);
adjacent_lo = min(adjacent_lo, n);
}
if (colour_at(nsx, nsy) == colour) {
same_count += 1u;
if (n > same_hi) {
same_hi = n;
same_hi_raw = nv;
}
if (n < same_lo) {
same_lo = n;
same_lo_raw = nv;
}
}
}
}
// A corner of the crop can leave a photosite with a single same-colour
// neighbour, and one witness is not a neighbourhood.
if (same_count < 2u) {
return v;
}
let hot_line_same = same_hi * HOT_RATIO + HOT_MARGIN;
let hot_line_adjacent = adjacent_hi * HOT_RATIO + HOT_MARGIN;
if (centre > hot_line_same && centre > hot_line_adjacent) {
return same_hi_raw;
}
if (same_lo >= DEAD_FLOOR && centre < same_lo * DEAD_RATIO
&& centre < adjacent_lo * DEAD_RATIO) {
return same_lo_raw;
}
return v;
}
// One invocation per u32 word: two photosites, packed as the demosaic reads
// them. A word may straddle two rows when the stride is odd, which `repair`
// does not mind — it addresses by sample index.
@compute @workgroup_size(64, 1, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let word = gid.y * params.row_invocations + gid.x;
if (word >= params.words) {
return;
}
let lo = repair(word * 2u);
// The padding half of an odd-length readout is copied, not judged: it is
// not a photosite, and the demosaic never addresses it.
var hi = raw[word] >> 16u;
if (word * 2u + 1u < params.samples) {
hi = repair(word * 2u + 1u);
}
repaired[word] = (lo & 0xFFFFu) | (hi << 16u);
}
+102 -5
View File
@@ -5,8 +5,9 @@
// pixel it asks which direction that pixel looks along, turns the // pixel it asks which direction that pixel looks along, turns the
// direction into the frame's camera, projects it to a source pixel, and // direction into the frame's camera, projects it to a source pixel, and
// if that pixel is inside the tile that was rendered for this chunk, // if that pixel is inside the tile that was rendered for this chunk,
// samples it and adds it — weighted by its distance from the frame's edge // samples it and adds it — weighted by the frame's share of the seam map
// — into the accumulator. `resolve` runs once per chunk after every frame // there, or by its distance from the frame's edge where there is no map —
// into the accumulator. `resolve` runs once per chunk after every frame
// has been added: divides the sums by the weights and packs the result as // has been added: divides the sums by the weights and packs the result as
// sixteen-bit samples at the sensor's scale (FR-MRG-3). // sixteen-bit samples at the sensor's scale (FR-MRG-3).
// //
@@ -46,13 +47,87 @@ 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>,
// The seam map (`dr_pano::seam`): where its texel (0, 0)'s corner sits
// in this output's centred coordinates, its size, output pixels per
// texel, the blend's radius in texels, which frame this dispatch is,
// and whether there is a map at all.
seam_origin: vec2<f32>,
seam_size: vec2<u32>,
seam_px: f32,
seam_radius: f32,
frame_index: u32,
seam_on: u32,
}; };
@group(0) @binding(0) var<uniform> p: Params; @group(0) @binding(0) var<uniform> p: Params;
@group(0) @binding(1) var tile: texture_2d<f32>; @group(0) @binding(1) var tile: texture_2d<f32>;
// rgb·w summed, then w: four floats per chunk pixel. // rgb·w summed, then w: four floats per chunk pixel.
@group(0) @binding(2) var<storage, read_write> acc: array<vec4<f32>>; @group(0) @binding(2) var<storage, read_write> acc: array<vec4<f32>>;
// One frame index per texel, 255 for none.
@group(0) @binding(3) var seams: texture_2d<u32>;
const NO_FRAME: u32 = 255u;
fn label(i: i32, j: i32) -> u32 {
if (i < 0 || j < 0 || i >= i32(p.seam_size.x) || j >= i32(p.seam_size.y)) {
return NO_FRAME;
}
return textureLoad(seams, vec2<i32>(i, j), 0).r;
}
// This frame's share of the seam map about output point (u, v): the
// tent-weighted fraction of the texels within the radius that it owns, and
// the weight of the texels owned by anyone (zero where the map has nothing
// to say). `SeamMap::share` verbatim.
fn seam_share(u: f32, v: f32) -> vec2<f32> {
let x = (u - p.seam_origin.x) / p.seam_px - 0.5;
let y = (v - p.seam_origin.y) / p.seam_px - 0.5;
let r = max(p.seam_radius, 1.0);
let x0 = i32(ceil(x - r));
let x1 = i32(floor(x + r));
let y0 = i32(ceil(y - r));
let y1 = i32(floor(y + r));
// Most pixels are nowhere near a seam: if the window's corners, edge
// midpoints and centre agree, so does the window. A seam crossing it
// has to cross its border, between two of those.
let xm = i32(round(x));
let ym = i32(round(y));
let c = label(xm, ym);
if (label(x0, y0) == c && label(x1, y0) == c && label(x0, y1) == c && label(x1, y1) == c
&& label(xm, y0) == c && label(xm, y1) == c && label(x0, ym) == c && label(x1, ym) == c) {
if (c == NO_FRAME) {
return vec2<f32>(0.0, 0.0);
}
return vec2<f32>(select(0.0, 1.0, c == p.frame_index), 1.0);
}
var mine = 0.0;
var owned = 0.0;
for (var j = y0; j <= y1; j = j + 1) {
let wy = 1.0 - abs(y - f32(j)) / r;
if (wy <= 0.0) {
continue;
}
for (var i = x0; i <= x1; i = i + 1) {
let wx = 1.0 - abs(x - f32(i)) / r;
let l = label(i, j);
if (wx <= 0.0 || l == NO_FRAME) {
continue;
}
owned = owned + wx * wy;
if (l == p.frame_index) {
mine = mine + wx * wy;
}
}
}
if (owned <= 0.0) {
return vec2<f32>(0.0, 0.0);
}
return vec2<f32>(mine / owned, 1.0);
}
fn to_direction(u: f32, v: f32) -> vec3<f32> { fn to_direction(u: f32, v: f32) -> vec3<f32> {
let s = p.proj_scale; let s = p.proj_scale;
@@ -95,7 +170,17 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
if (edge <= 0.0) { if (edge <= 0.0) {
return; return;
} }
let w = clamp(edge / max(p.feather, 1.0), 0.0, 1.0); var w = clamp(edge / max(p.feather, 1.0), 0.0, 1.0);
// With seams, the share of the map scales it. The small floor keeps
// the feather underneath as the answer wherever no frame that reaches
// this pixel owns it — the map is coarser than the output, so at the
// frames' outer edges it can name a frame that falls just short.
if (p.seam_on != 0u) {
let s = seam_share(u, v);
if (s.y > 0.0) {
w = w * (s.x + 1e-4);
}
}
// Into the tile. // Into the tile.
let tx = sx - p.tile_origin.x; let tx = sx - p.tile_origin.x;
let ty = sy - p.tile_origin.y; let ty = sy - p.tile_origin.y;
@@ -125,7 +210,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;
}
}
+291
View File
@@ -0,0 +1,291 @@
//! TRACES: FR-DEV-3e
//! The camera profile's tables, end to end on a device (D20).
//!
//! `dr-pipeline` holds the lookup to the DNG SDK's algorithm on the CPU
//! (`ops::camera_profile::apply_reference`). Nothing there would notice a
//! shader that disagreed with it — a transposed constant matrix, an index
//! off by one column, a buffer bound in the wrong order — so this renders a
//! frame of 256 different colours through tables that move every one of them
//! a long way, and holds each pixel to the reference.
//!
//! The source is a linear three-sample frame, so the colours arrive exactly
//! as written with no demosaic between, and an identity stands in the view
//! transform's place so the readback is the scene colour, display-encoded.
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};
use dr_pipeline::operation::{Operation, Stage, Uniform};
use dr_pipeline::ops::camera_profile::{apply_reference, CameraProfile, APPLY, LOOK};
use dr_types::{HueSatTable, ProfileOrigin, ProfileTables, Transfer};
const SIZE: u32 = 16;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// An identity in the view transform's place.
struct IdentityView;
impl Operation for IdentityView {
fn descriptor(&self) -> Arc<OpDescriptor> {
Arc::new(OpDescriptor {
id: OpId("identity_view"),
label: LocalizedKey("identity_view"),
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 {
Stage::View
}
fn renders(&self) -> bool {
true
}
fn wgsl_body(&self) -> String {
String::new()
}
fn uniforms(&self) -> Vec<Uniform> {
Vec::new()
}
}
/// 256 colours across hue, saturation and value, kept under the prologue's
/// highlight desaturation and above black.
fn colours() -> Vec<[f32; 3]> {
(0..SIZE * SIZE)
.map(|i| {
let f = |k: u32| {
let x = (i.wrapping_mul(2_654_435_761).rotate_left(k * 7) >> 8) % 1000;
0.04 + 0.86 * x as f32 / 1000.0
};
[f(1), f(2), f(3)]
})
.collect()
}
fn frame(tables: Option<ProfileTables>) -> RawImage {
let data = colours()
.iter()
.flat_map(|c| c.map(|v| (v * 65535.0).round() as u16))
.collect();
RawImage {
width: SIZE,
height: SIZE,
data,
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: 3,
profile: None,
profile_tables: tables.map(Arc::new),
baseline_exposure: 0.0,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
}
}
/// Tables that move every colour by a different amount: hue shifts of tens
/// of degrees, saturation scales either side of one, and a 3-D, sRGB-indexed
/// look whose value scale varies down the value axis.
fn strong_tables() -> ProfileTables {
let (hd, sd) = (12u32, 5u32);
let hue_sat = (0..hd * sd)
.map(|i| {
let (h, s) = (i / sd, i % sd);
let a = h as f32 / hd as f32 * std::f32::consts::TAU;
[25.0 * a.sin(), 1.0 + 0.3 * a.cos() * s as f32 / 4.0, 1.0]
})
.collect();
let (lh, ls, lv) = (8u32, 4u32, 5u32);
let look = (0..lh * ls * lv)
.map(|i| {
let v = i / (lh * ls);
let h = (i / ls) % lh;
[
-15.0 + 4.0 * h as f32,
1.25 - 0.05 * v as f32,
0.85 + 0.06 * v as f32,
]
})
.collect();
let mut look = HueSatTable::new(lh, ls, lv, true, look).unwrap();
look.srgb_encoded = true;
ProfileTables {
name: "strong".into(),
origin: ProfileOrigin::Embedded,
hue_sat: Some(HueSatTable::new(hd, sd, 1, false, hue_sat).unwrap()),
look: Some(look),
tone_curve: None,
}
}
fn render(ctx: &GpuContext, raw: &RawImage, op: CameraProfile) -> Vec<[u8; 3]> {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(raw)
.expect("upload");
let ops: Vec<Box<dyn Operation>> = vec![Box::new(op), Box::new(IdentityView)];
let shader = dr_pipeline::compose(&ops);
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
pixels.chunks_exact(4).map(|p| [p[0], p[1], p[2]]).collect()
}
fn encode(c: [f32; 3]) -> [i32; 3] {
c.map(|v| (Transfer::Srgb.encode(v.clamp(0.0, 1.0)) * 255.0).round() as i32)
}
fn assert_agrees(got: &[[u8; 3]], expected: impl Fn([f32; 3]) -> [f32; 3], what: &str) {
let mut moved = 0;
for (i, (c, g)) in colours().into_iter().zip(got).enumerate() {
let want = encode(expected(c));
let g = g.map(i32::from);
// Two 8-bit steps: the half-float source and intermediate, and the
// rounding either side of the encode.
assert!(
want.iter().zip(g).all(|(w, g)| (w - g).abs() <= 2),
"{what}: pixel {i} {c:?} rendered {g:?}, the reference says {want:?}"
);
if want != encode(c) {
moved += 1;
}
}
assert!(
moved > 200,
"{what}: only {moved} of 256 colours moved; the test proves little"
);
}
#[test]
fn the_shader_agrees_with_the_cpu_reference() {
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let tables = strong_tables();
let got = render(&ctx, &frame(Some(tables.clone())), CameraProfile::new());
assert_agrees(&got, |c| apply_reference(&tables, c, 1.0), "at defaults");
let mut doubled = CameraProfile::new();
doubled.set_param(LOOK, 200.0);
let got = render(&ctx, &frame(Some(tables.clone())), doubled);
assert_agrees(&got, |c| apply_reference(&tables, c, 2.0), "look at 200%");
}
#[test]
fn camera_raw_tone_agrees_with_its_cpu_reference() {
// TRACES: FR-DEV-3j
// D21's rendering on 256 colours: the ProPhoto round trip, the clip, the
// curve from the profile buffer's placeholder, and RGBTone's placement
// of the middle channel, against `camera_raw::apply_reference`.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let source = Demosaicer::new(&ctx)
.expect("demosaicer")
.run(&frame(None))
.expect("upload");
let mut view = dr_pipeline::ops::ViewTransform::new();
view.set_param(
dr_pipeline::ops::view_transform::CURVE,
dr_pipeline::ops::view_transform::CAMERA_RAW,
);
let ops: Vec<Box<dyn Operation>> = vec![Box::new(view)];
let shader = dr_pipeline::compose(&ops);
let mut adjust = AdjustPass::new(&ctx);
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let got: Vec<[u8; 3]> = pixels.chunks_exact(4).map(|p| [p[0], p[1], p[2]]).collect();
let curve = &dr_types::tone::ACR3_DEFAULT;
assert_agrees(
&got,
|c| {
dr_pipeline::camera_raw::apply_reference(
curve,
c,
dr_pipeline::view::DEFAULT_CONTRAST,
dr_pipeline::view::DEFAULT_WHITE,
)
},
"DNG reference tone",
);
}
#[test]
fn switched_off_or_absent_the_render_is_unchanged() {
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let bare = render(&ctx, &frame(None), CameraProfile::new());
let mut off = CameraProfile::new();
off.set_param(APPLY, 0.0);
let switched_off = render(&ctx, &frame(Some(strong_tables())), off);
assert_eq!(bare, switched_off, "the switch off is the matrix alone");
// Against the source colours, one 8-bit step for the half-float texture
// the source is uploaded in; the exact comparison is the one above.
for (c, g) in colours().into_iter().zip(&bare) {
let want = encode(c);
assert!(
want.iter()
.zip(g)
.all(|(w, g)| (w - i32::from(*g)).abs() <= 1),
"no tables, no change: {c:?} rendered {g:?}"
);
}
}
#[test]
fn the_libraries_adobe_standard_renders_as_the_reference_does() {
// The real tables, when the library's 6D DNG is on this machine: a 90×30
// HueSatMap and a 36×8×16 LookTable, at the sizes no synthetic test
// reaches.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let path = std::env::var_os("DR_DCP_SAMPLE")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| {
std::path::PathBuf::from(h).join("Nextcloud/PhotosRaw/2017/2017-08-12/_MG_9080.dng")
})
});
let Some(bytes) = path.and_then(|p| std::fs::read(p).ok()) else {
eprintln!("skipping: no sample DNG");
return;
};
let tables = dr_decode::dcp::embedded_in(&bytes)
.expect("Adobe Standard")
.tables(5000.0, ProfileOrigin::Embedded);
let got = render(&ctx, &frame(Some(tables.clone())), CameraProfile::new());
for (i, (c, g)) in colours().into_iter().zip(&got).enumerate() {
let want = encode(apply_reference(&tables, c, 1.0));
let g = g.map(i32::from);
assert!(
want.iter().zip(g).all(|(w, g)| (w - g).abs() <= 2),
"pixel {i} {c:?} rendered {g:?}, the reference says {want:?}"
);
}
}
+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)
+266 -8
View File
@@ -15,10 +15,13 @@
//! 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}; use dr_film::bake::{bake, Recipe, Settings};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext}; use dr_gpu::{AdjustPass, Demosaicer, GpuContext, LabelField, MaskPass};
use dr_pipeline::ops::FilmTables; use dr_pipeline::mask::{MaskLayer, MaskSource};
use dr_pipeline::ops::film_sim;
use dr_pipeline::ops::film_sim::FORMAT_COUNT;
use dr_pipeline::ops::{FilmTables, PaperTables};
use dr_pipeline::EditGraph; use dr_pipeline::EditGraph;
const SIZE: u32 = 16; const SIZE: u32 = 16;
@@ -45,9 +48,10 @@ 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,
profile_tables: None,
baseline_exposure: 0.0,
make: String::new(), make: String::new(),
model: String::new(), model: String::new(),
crop: CropRect { crop: CropRect {
@@ -77,15 +81,31 @@ fn tables(baked: &dr_film::Baked) -> FilmTables {
/// split a grain that never left the CPU would look exactly like a passing /// split a grain that never left the CPU would look exactly like a passing
/// test suite. /// test suite.
fn tables_with_grain(baked: &dr_film::Baked, particles: [f32; 3]) -> FilmTables { fn tables_with_grain(baked: &dr_film::Baked, particles: [f32; 3]) -> FilmTables {
// The paper, when there is one, rides behind the film: its curve as one
// more row, its cube stacked after the film's.
let mut curves = baked.curves.clone();
let mut lut = baked.lut.clone();
let paper = baked.paper.as_ref().map(|p| {
curves.extend_from_slice(&p.curves);
lut.extend_from_slice(&p.lut);
PaperTables {
balance: p.balance,
log_min: p.log_min,
log_max: p.log_max,
density_max: p.density_max,
}
});
FilmTables { FilmTables {
exposure_matrix: baked.exposure_matrix, exposure_matrix: baked.exposure_matrix,
curves: baked.curves.clone(), curves,
push_stations: baked.push_stations.clone(),
curve_log_min: baked.curve_log_min, curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max, curve_log_max: baked.curve_log_max,
lut: baked.lut.clone(), lut,
density_max: baked.density_max, density_max: baked.density_max,
lut_size: baked.lut_size, lut_size: baked.lut_size,
grain_particles: particles, paper,
grain_particles: [particles; FORMAT_COUNT],
grain_density_max: [baked.density_max; 3], grain_density_max: [baked.density_max; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
} }
@@ -241,3 +261,241 @@ fn grain_reaches_the_shader_and_scales_with_the_pixel() {
"grain never reached the shader: the coarsest setting moved the pixel by {coarse_err}" "grain never reached the shader: the coarsest setting moved the pixel by {coarse_err}"
); );
} }
/// The same render, with the film's sliders set and mask layers laid over it.
///
/// Every layer is a `Regions` mask over a field splitting the frame down the
/// middle: region 0, the left half, at full weight, and the right half
/// untouched. Returns the left and right centre pixels, linear.
fn rendered_split(
ctx: &GpuContext,
level: u16,
tables: FilmTables,
global: Settings,
layers: Vec<MaskLayer>,
) -> ([f32; 3], [f32; 3]) {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(&flat_raw(level))
.expect("demosaic");
let mut graph = EditGraph::default_chain();
graph.set_film(Some(dr_pipeline::graph::Film {
stock: "under_test".to_string(),
print: None,
tables: tables.clone(),
}));
graph.set_param(film_sim::ID, film_sim::EXPOSURE, global.exposure_ev);
graph.set_param(film_sim::ID, film_sim::PUSH, global.push_stops);
graph.set_param(
film_sim::ID,
film_sim::PRINT_EXPOSURE,
global.print_exposure_ev,
);
for layer in layers {
graph.masks_mut().push(layer);
}
let shader = graph.compose();
let labels: Vec<u32> = (0..SIZE * SIZE)
.map(|i| u32::from(i % SIZE >= SIZE / 2))
.collect();
let field = LabelField::upload(ctx, &labels, SIZE, SIZE, 2).expect("label upload");
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks
.render(graph.masks(), Some(&field), None, None, SIZE, SIZE)
.expect("rasterise");
let mut adjust = AdjustPass::new(ctx);
adjust.set_film(Some(&tables));
adjust
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
.expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
let at = |x: u32| {
let c = (((SIZE / 2) * SIZE + x) * 4) as usize;
[0, 1, 2].map(|i| srgb_to_linear(f32::from(pixels[c + i]) / 255.0))
};
(at(SIZE / 4), at(3 * SIZE / 4))
}
/// A layer over the left half holding these film offsets.
fn left_half(id: &str, offsets: &[(dr_pipeline::descriptor::ParamId, f32)]) -> MaskLayer {
let mut layer = MaskLayer::new(
id,
MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
},
);
for (param, v) in offsets {
layer.set_param(film_sim::ID.0, *param, *v);
}
layer
}
fn assert_close(got: [f32; 3], want: [f32; 3], what: &str) {
for c in 0..3 {
assert!(
(got[c] - want[c]).abs() < 0.02,
"{what}, channel {c}: GPU gave {got:?}, the model says {want:?}"
);
}
}
#[test]
fn a_print_renders_on_the_gpu_the_way_it_does_on_the_cpu_at_any_setting() {
// TRACES: FR-DEV-3f
// The print path — the film's lookup into the paper's log exposure, the
// enlarger added between, the paper's curve and its own lookup — is read
// from the same two textures as the film, at offsets. Every one of those
// offsets is a way to render a plausible print of the wrong thing.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_portra_400").expect("stock");
let paper = dr_film::default_print(film).expect("paper");
let baked = bake(&Recipe::new(film, Some(paper)));
for settings in [
Settings::default(),
Settings {
print_exposure_ev: -1.3,
..Settings::default()
},
Settings {
exposure_ev: 0.4,
print_exposure_ev: 0.8,
..Settings::default()
},
] {
for level in [6_000u16, 20_000] {
let input = f32::from(level) / f32::from(u16::MAX);
let (got, _) = rendered_split(&ctx, level, tables(&baked), settings, Vec::new());
assert_close(
got,
baked.apply_at([input; 3], &settings),
&format!("{settings:?} at {level}"),
);
}
}
}
#[test]
fn a_push_between_two_measured_processes_renders_as_the_model_does() {
// TRACES: FR-DEV-3f
// Double-X measures five processes; a push between two is a mix of two
// rows of the curve texture, found by searching the stations uniform.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_doublex").expect("stock");
let baked = bake(&Recipe::new(film, None));
assert!(baked.curve_rows > 2, "Double-X has a development series");
for push in [-0.8f32, 0.4, 1.3, 2.9] {
let settings = Settings {
push_stops: push,
..Settings::default()
};
let level = 12_000u16;
let input = f32::from(level) / f32::from(u16::MAX);
let (got, _) = rendered_split(&ctx, level, tables(&baked), settings, Vec::new());
assert_close(
got,
baked.apply_at([input; 3], &settings),
&format!("push {push}"),
);
}
}
#[test]
fn a_layer_develops_its_region_on_its_own_settings() {
// TRACES: FR-DEV-3f
// Offsets to the photograph's: print exposure +1 on a photograph at +0.5
// is +1.5 under the layer, and the rest of the print is untouched. Before
// film was blended as settings the layer's sliders moved and nothing
// happened, because the layer's copy of the node had no stock.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_portra_400").expect("stock");
let paper = dr_film::default_print(film).expect("paper");
let baked = bake(&Recipe::new(film, Some(paper)));
let level = 12_000u16;
let input = f32::from(level) / f32::from(u16::MAX);
let global = Settings {
print_exposure_ev: 0.5,
..Settings::default()
};
let layer = left_half(
"burn",
&[(film_sim::PRINT_EXPOSURE, 1.0), (film_sim::EXPOSURE, -0.5)],
);
let (left, right) = rendered_split(&ctx, level, tables(&baked), global, vec![layer]);
let under = Settings {
exposure_ev: -0.5,
print_exposure_ev: 1.5,
..Settings::default()
};
assert_close(left, baked.apply_at([input; 3], &under), "under the layer");
assert_close(right, baked.apply_at([input; 3], &global), "outside it");
assert!(
left[1] < right[1] - 0.01,
"the burn did not darken: {left:?} vs {right:?}"
);
}
#[test]
fn overlapping_layers_take_the_average_of_their_settings() {
// TRACES: FR-DEV-3f
// Three layers over the same pixels at full weight: the plain mean of what
// each asks for, and the global setting has no weight left. Summed, the
// offsets would be -2 stops of print exposure and +1 of exposure; the mean
// is (-1, -1, 0) / 3 and (0, 0, +1) / 3.
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let film = dr_film::find("kodak_portra_400").expect("stock");
let paper = dr_film::default_print(film).expect("paper");
let baked = bake(&Recipe::new(film, Some(paper)));
let level = 12_000u16;
let input = f32::from(level) / f32::from(u16::MAX);
let layers = vec![
left_half("a", &[(film_sim::PRINT_EXPOSURE, -1.0)]),
left_half("b", &[(film_sim::PRINT_EXPOSURE, -1.0)]),
left_half("c", &[(film_sim::EXPOSURE, 1.0)]),
];
let (left, right) = rendered_split(&ctx, level, tables(&baked), Settings::default(), layers);
let mean = Settings {
exposure_ev: 1.0 / 3.0,
print_exposure_ev: -2.0 / 3.0,
..Settings::default()
};
let summed = Settings {
exposure_ev: 1.0,
print_exposure_ev: -2.0,
..Settings::default()
};
let (m, s) = (
baked.apply_at([input; 3], &mean)[1],
baked.apply_at([input; 3], &summed)[1],
);
// Three times the tolerance the GPU is held to below, or a sum could pass
// for a mean.
assert!(
(m - s).abs() > 0.06,
"the mean and the sum render alike ({m} vs {s}), so this proves nothing"
);
assert_close(left, baked.apply_at([input; 3], &mean), "under all three");
assert_close(right, baked.apply([input; 3]), "outside them");
}
+146
View File
@@ -0,0 +1,146 @@
//! TRACES: FR-DEV-3g
//! The grain blend, read back off the device.
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_gpu::{DemosaicedImage, Demosaicer, GpuContext, GrainBlend};
const W: u32 = 16;
const H: u32 = 8;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A photograph to stand the uploads beside: its as-shot balance is what
/// the grain is made neutral under.
fn like(ctx: &GpuContext) -> DemosaicedImage {
let raw = RawImage {
width: W,
height: H,
data: vec![400; (W * H) as usize],
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: 4095,
wb_coeffs: [2.0, 1.0, 1.5, 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,
profile_tables: None,
baseline_exposure: 0.0,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: W,
height: H,
},
};
Demosaicer::new(ctx).unwrap().run(&raw).unwrap()
}
fn read(ctx: &GpuContext, img: &DemosaicedImage) -> Vec<[f32; 4]> {
let (w, h) = (img.texture().width(), img.texture().height());
let padded =
(w * 8).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: (padded * h) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = ctx.device.create_command_encoder(&Default::default());
enc.copy_texture_to_buffer(
img.texture().as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(h),
},
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
ctx.queue.submit(Some(enc.finish()));
let slice = buf.slice(..);
slice.map_async(wgpu::MapMode::Read, |_| {});
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.unwrap();
let bytes = slice.get_mapped_range();
let mut out = Vec::new();
for y in 0..h as usize {
let row: &[u16] =
bytemuck::cast_slice(&bytes[y * padded as usize..y * padded as usize + w as usize * 8]);
for t in row.chunks(4) {
out.push([0, 1, 2, 3].map(|c| half_to_f32(t[c])));
}
}
out
}
fn half_to_f32(h: u16) -> f32 {
let s = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
let e = ((h >> 10) & 0x1f) as i32;
let m = (h & 0x3ff) as f32;
if e == 0 {
s * m * 2f32.powi(-24)
} else {
s * (1.0 + m / 1024.0) * 2f32.powi(e - 15)
}
}
#[test]
fn grain_returns_only_neutral_brightness() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let base = like(&ctx);
let n = (W * H) as usize;
let d: Vec<f32> = (0..n).flat_map(|_| [0.20, 0.30, 0.10]).collect();
// The classical result: the same colour plus noise, coloured noise too.
let c: Vec<f32> = (0..n)
.flat_map(|i| {
let a = ((i * 37) % 11) as f32 / 110.0 - 0.05;
let b = ((i * 53) % 7) as f32 / 140.0 - 0.025;
[0.20 + a, 0.30 + b, 0.10 - a]
})
.collect();
let denoised = DemosaicedImage::from_rgb_f32(&ctx, &base, W, H, &d).unwrap();
let classical = DemosaicedImage::from_rgb_f32(&ctx, &base, W, H, &c).unwrap();
let blend = GrainBlend::new(&ctx);
let wb = [2.0f32, 1.0, 1.5];
let none = read(&ctx, &blend.blend(&denoised, &classical, 0.0).unwrap());
for p in &none {
for ch in 0..3 {
assert!(
(p[ch] - d[ch]).abs() < 1e-3,
"grain 0 must be the network's result: {p:?}"
);
}
}
let all = read(&ctx, &blend.blend(&denoised, &classical, 1.0).unwrap());
for (i, p) in all.iter().enumerate() {
let want_dy: f32 = [0.2126f32, 0.7152, 0.0722]
.iter()
.enumerate()
.map(|(ch, k)| k * wb[ch] * (c[i * 3 + ch] - d[ch]))
.sum();
// After white balance every channel moved by the same amount.
for ch in 0..3 {
let moved = wb[ch] * (p[ch] - d[ch]);
assert!(
(moved - want_dy).abs() < 2e-3,
"pixel {i} channel {ch}: moved {moved}, want {want_dy}"
);
}
}
}
+173
View File
@@ -0,0 +1,173 @@
//! TRACES: FR-RAW-3
//! Hot and dead photosite repair, end to end on a device.
//!
//! Each test renders a frame twice — once with a defect, once without — and
//! compares the finished pixels. That is the only comparison that means
//! anything: the repair happens on the mosaic, and what a photographer would
//! see of a defect it missed is the coloured cross the demosaic makes of it.
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::EditGraph;
const SIZE: u32 = 36;
const WHITE: u16 = 4095;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat frame at `level`, with `set` applied to its photosites.
fn frame(pattern: CfaPattern, level: u16, set: &[(u32, u32, u16)]) -> RawImage {
let mut data = vec![level; (SIZE * SIZE) as usize];
for &(x, y, v) in set {
data[(y * SIZE + x) as usize] = v;
}
RawImage {
width: SIZE,
height: SIZE,
data,
cfa_pattern: pattern,
black_level: [0; 4],
white_level: WHITE,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
samples_per_pixel: 1,
profile: None,
profile_tables: None,
baseline_exposure: 0.0,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
}
}
fn render(ctx: &GpuContext, raw: &RawImage) -> Vec<u8> {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(raw)
.expect("demosaic");
let shader = EditGraph::default_chain().compose();
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
adjust.export_pixels().expect("readback").0
}
/// The largest channel difference between two renders.
fn worst(a: &[u8], b: &[u8]) -> u8 {
a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap()
}
const MIDDLE: u32 = SIZE / 2;
/// **The feature.** A photosite at white in a dark frame — a hot pixel in a
/// night sky — leaves no trace in the rendered picture.
#[test]
fn a_hot_photosite_in_a_dark_frame_is_invisible() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let clean = render(&ctx, &frame(CfaPattern::Rggb, 40, &[]));
for (x, y) in [
(MIDDLE, MIDDLE),
(MIDDLE + 1, MIDDLE),
(MIDDLE + 1, MIDDLE + 1),
] {
let hot = render(&ctx, &frame(CfaPattern::Rggb, 40, &[(x, y, WHITE)]));
let diff = worst(&clean, &hot);
assert!(
diff <= 1,
"a hot photosite at ({x}, {y}) still shows, by {diff}"
);
}
}
/// The same for one stuck dark in a lit area.
#[test]
fn a_dead_photosite_in_a_lit_frame_is_invisible() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let clean = render(&ctx, &frame(CfaPattern::Rggb, 1600, &[]));
let dead = render(&ctx, &frame(CfaPattern::Rggb, 1600, &[(MIDDLE, MIDDLE, 0)]));
let diff = worst(&clean, &dead);
assert!(diff <= 1, "a dead photosite still shows, by {diff}");
}
/// **What it must not eat.** A point of real light lands on a patch of
/// photosites, not one — so a 3×3 highlight survives, even at its brightest.
#[test]
fn a_small_real_highlight_survives() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut star = Vec::new();
for dy in 0..3 {
for dx in 0..3 {
star.push((MIDDLE - 1 + dx, MIDDLE - 1 + dy, WHITE));
}
}
let clean = render(&ctx, &frame(CfaPattern::Rggb, 40, &[]));
let lit = render(&ctx, &frame(CfaPattern::Rggb, 40, &star));
let at = ((MIDDLE * SIZE + MIDDLE) * 4 + 1) as usize;
assert!(
lit[at] > clean[at] + 100,
"the highlight was repaired away: {} against a background of {}",
lit[at],
clean[at]
);
}
/// The Fujifilm path goes through the same repair, with its own tile.
#[test]
fn a_hot_photosite_on_x_trans_is_invisible() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let clean = render(&ctx, &frame(CfaPattern::XTrans, 40, &[]));
let hot = render(
&ctx,
&frame(CfaPattern::XTrans, 40, &[(MIDDLE, MIDDLE, WHITE)]),
);
let diff = worst(&clean, &hot);
assert!(diff <= 1, "a hot X-Trans photosite still shows, by {diff}");
}
/// The repair alone, read back (FR-DEV-3g): the learned demosaic takes the
/// mosaic this pass leaves, so it must be the same pass and nothing more —
/// the hot photosite replaced, a real highlight and every other photosite
/// untouched.
#[test]
fn the_repaired_mosaic_reads_back_with_only_the_defect_changed() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let d = Demosaicer::new(&ctx).expect("demosaicer");
let mut star = vec![(MIDDLE, MIDDLE, WHITE)];
for dy in 0..3 {
for dx in 0..3 {
star.push((4 + dx, 4 + dy, WHITE));
}
}
let before = frame(CfaPattern::Rggb, 40, &star);
let mut raw = before.clone();
let changed = d.repair_hot_pixels(&mut raw).expect("repair");
assert_eq!(changed, 1, "only the lone hot photosite should change");
let at = (MIDDLE * SIZE + MIDDLE) as usize;
assert_eq!(
raw.data[at], 40,
"repaired to its brightest same-colour neighbour"
);
let others = (0..raw.data.len()).filter(|&i| i != at);
assert!(others.into_iter().all(|i| raw.data[i] == before.data[i]));
}
+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");
+198
View File
@@ -0,0 +1,198 @@
//! 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,
profile_tables: None,
baseline_exposure: 0.0,
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");
}
+210
View File
@@ -0,0 +1,210 @@
//! 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,
profile_tables: None,
baseline_exposure: 0.0,
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");
+227
View File
@@ -0,0 +1,227 @@
//! 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,
profile_tables: None,
baseline_exposure: 0.0,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
}
}
/// The default chain with D19's sigmoid chosen explicitly, so these tests
/// stay about the sigmoid whichever curve is the default (D21).
fn sigmoid_chain() -> EditGraph {
let mut g = EditGraph::default_chain();
g.set_param(
dr_pipeline::ops::view_transform::ID,
dr_pipeline::ops::view_transform::CURVE,
dr_pipeline::ops::view_transform::SIGMOID,
);
g
}
#[test]
fn camera_raw_tone_agrees_with_the_acr3_curve() {
// TRACES: FR-DEV-3j
// D21: a raw with no profile, the DNG reference curve chosen, renders a grey
// through the ACR3 default curve, which the profile buffer's placeholder
// carries.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let mut graph = EditGraph::default_chain();
graph.set_param(
dr_pipeline::ops::view_transform::ID,
dr_pipeline::ops::view_transform::CURVE,
dr_pipeline::ops::view_transform::CAMERA_RAW,
);
// The table itself, so its own contrast: the default bends the input a
// little past it (D21).
graph.set_param(
dr_pipeline::ops::view_transform::ID,
dr_pipeline::ops::view_transform::CONTRAST,
dr_pipeline::view::REFERENCE_CONTRAST,
);
for level in [500u16, 4_000, 8_520, 20_000, 40_000] {
let scene = f32::from(level) / f32::from(u16::MAX);
let display = dr_types::tone::evaluate(&dr_types::tone::ACR3_DEFAULT, scene);
let expected = (dr_types::Transfer::Srgb.encode(display) * 255.0).round() as i32;
let got = i32::from(rendered(&ctx, level, &graph));
assert!(
(got - expected).abs() <= 2,
"raw {level} rendered as {got}, the ACR3 curve says {expected}"
);
}
}
/// 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 = sigmoid_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 = sigmoid_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}"
);
}
+5
View File
@@ -38,6 +38,11 @@ ort = { workspace = true, features = ["cuda", "tensorrt"] }
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
ort = { workspace = true, features = ["qnn"] } ort = { workspace = true, features = ["qnn"] }
# The Apple rung: CoreML's option builder, which fills the runtime's generic
# key/value map. `ort-sys`'s `coreml` feature is empty; nothing links.
[target.'cfg(target_os = "macos")'.dependencies]
ort = { workspace = true, features = ["coreml"] }
[features] [features]
# The floor: `tract` supplies the API table when no runtime file is found, or # The floor: `tract` supplies the API table when no runtime file is found, or
# always, in a build without `native`. Tests want this and nothing else. # always, in a build without `native`. Tests want this and nothing else.
+32 -3
View File
@@ -44,6 +44,20 @@ pub fn context_path(cfg: &Config, bytes: &[u8]) -> PathBuf {
.join(format!("{:016x}_ctx.onnx", hash(bytes))) .join(format!("{:016x}_ctx.onnx", hash(bytes)))
} }
/// Where CoreML compiles `bytes` to: one directory per model, because
/// CoreML's own cache key leaves out the weights of a model loaded from
/// memory (`session::coreml`), and one per runtime version, which wrote it.
pub fn coreml_dir(cfg: &Config, bytes: &[u8]) -> PathBuf {
let runtime = match crate::api::runtime() {
crate::Runtime::OnnxRuntime { version, .. } => version,
crate::Runtime::Tract => "tract".into(),
};
cfg.cache_dir
.join("coreml")
.join(runtime)
.join(format!("{:016x}", hash(bytes)))
}
/// After the probe: compile every configured model the selected rung can /// After the probe: compile every configured model the selected rung can
/// take, smallest first, recording each as it lands. /// take, smallest first, recording each as it lands.
pub fn run() { pub fn run() {
@@ -90,12 +104,27 @@ pub fn run() {
Source::Bytes(b) => (b.to_vec(), format!("embedded {role:?}")), Source::Bytes(b) => (b.to_vec(), format!("embedded {role:?}")),
}; };
let key = key(rung, &bytes); let key = key(rung, &bytes);
if state().lock().unwrap().cache.compiled.contains(&key) { {
continue; let s = state().lock().unwrap();
if s.cache.compiled.contains(&key) || s.cache.refused.contains(&key) {
continue;
}
} }
log::info!("inference: compiling {name} for {}", rung.label()); log::info!("inference: compiling {name} for {}", rung.label());
let started = std::time::Instant::now(); let started = std::time::Instant::now();
match crate::session::build(rung, role, &bytes, &cfg) { let built = match crate::probe::attempt(&cfg, &key, || {
crate::session::build(rung, role, &bytes, &cfg)
}) {
Ok(built) => built,
Err(_) => {
// Refused: the process died inside this compile before.
let mut s = state().lock().unwrap();
s.cache.refused.insert(key);
crate::probe::write_cache(&s.config, &s.cache);
continue;
}
};
match built {
Ok(session) => { Ok(session) => {
drop(session); drop(session);
let mut s = state().lock().unwrap(); let mut s = state().lock().unwrap();
+51 -3
View File
@@ -45,6 +45,11 @@ pub enum Role {
/// convolutions, so any rung serves it; fp16 on TensorRT and int8 on /// convolutions, so any rung serves it; fp16 on TensorRT and int8 on
/// the Hexagon are the point of it. /// the Hexagon are the point of it.
Inpainter, Inpainter,
/// The learned demosaic and denoise on the raw mosaic (docs/dev/denoise.md).
/// fp16 costs it nothing measurable; int8 costs 6–9 dB, because 256
/// levels cannot hold the shadow steps it exists to recover — so the
/// Hexagon does not take it.
Denoiser,
} }
/// Which numeric form of a model a session was built from. /// Which numeric form of a model a session was built from.
@@ -78,6 +83,12 @@ pub enum Rung {
MiGraphX, MiGraphX,
/// Qualcomm's Hexagon NPU through QNN, int8 models only. Android only. /// Qualcomm's Hexagon NPU through QNN, int8 models only. Android only.
Hexagon, Hexagon,
/// Apple, through CoreML: the Neural Engine, the GPU or the CPU, as
/// CoreML schedules it. macOS only. Compiles an ML Program per model on
/// first use, so it is a compiling rung with the CPU below it. The
/// embedder stays on the CPU, as on the Hexagon: the Neural Engine
/// computes in fp16 (§7).
CoreMl,
} }
impl Rung { impl Rung {
@@ -88,6 +99,7 @@ impl Rung {
Rung::TensorRt => "TensorRT", Rung::TensorRt => "TensorRT",
Rung::MiGraphX => "MIGraphX", Rung::MiGraphX => "MIGraphX",
Rung::Hexagon => "Hexagon NPU", Rung::Hexagon => "Hexagon NPU",
Rung::CoreMl => "CoreML",
} }
} }
@@ -96,13 +108,16 @@ impl Rung {
fn fallback(self) -> Rung { fn fallback(self) -> Rung {
match self { match self {
Rung::TensorRt => Rung::Cuda, Rung::TensorRt => Rung::Cuda,
Rung::MiGraphX | Rung::Hexagon | Rung::Cuda | Rung::Cpu => Rung::Cpu, Rung::MiGraphX | Rung::Hexagon | Rung::CoreMl | Rung::Cuda | Rung::Cpu => Rung::Cpu,
} }
} }
/// Whether a session on this rung needs an engine built first. /// Whether a session on this rung needs an engine built first.
fn compiles(self) -> bool { fn compiles(self) -> bool {
matches!(self, Rung::TensorRt | Rung::MiGraphX | Rung::Hexagon) matches!(
self,
Rung::TensorRt | Rung::MiGraphX | Rung::Hexagon | Rung::CoreMl
)
} }
/// The model form this rung wants for a role. /// The model form this rung wants for a role.
@@ -116,9 +131,14 @@ impl Rung {
/// Whether this rung runs `role` at all. The Hexagon takes int8 graphs /// Whether this rung runs `role` at all. The Hexagon takes int8 graphs
/// only, and the embedder is never int8 (§7) — it runs on the CPU /// only, and the embedder is never int8 (§7) — it runs on the CPU
/// beside a detector on the NPU, so its vectors compare across devices. /// beside a detector on the NPU, so its vectors compare across devices.
/// Nor is the denoiser: its int8 form failed the 0.5 dB gate by 6–9 dB
/// (denoise.md §8), so it runs on the CPU there too. CoreML is kept off
/// the embedder for the same reason as the Hexagon: the Neural Engine is
/// fp16, and which unit runs a graph is CoreML's choice.
fn serves(self, role: Role) -> bool { fn serves(self, role: Role) -> bool {
match self { match self {
Rung::Hexagon => role != Role::Embedder, Rung::Hexagon => !matches!(role, Role::Embedder | Role::Denoiser),
Rung::CoreMl => role != Role::Embedder,
_ => true, _ => true,
} }
} }
@@ -348,6 +368,12 @@ struct Cache {
/// the fingerprint changes: a wedged driver must not cost every launch /// the fingerprint changes: a wedged driver must not cost every launch
/// thirty seconds. /// thirty seconds.
failed: Vec<(Rung, String)>, failed: Vec<(Rung, String)>,
/// Engine keys whose compile the process died inside, launch after
/// launch (`probe::attempt`). Left on the fallback until the
/// fingerprint changes. Defaulted, so a cache from before this field
/// still reads.
#[serde(default)]
refused: BTreeSet<String>,
} }
struct State { struct State {
@@ -586,6 +612,7 @@ mod tests {
#[test] #[test]
fn the_hexagon_never_takes_the_embedder() { fn the_hexagon_never_takes_the_embedder() {
assert!(!Rung::Hexagon.serves(Role::Embedder)); assert!(!Rung::Hexagon.serves(Role::Embedder));
assert!(!Rung::Hexagon.serves(Role::Denoiser));
assert!(Rung::Hexagon.serves(Role::Detector)); assert!(Rung::Hexagon.serves(Role::Detector));
assert_eq!(Rung::Hexagon.form(Role::Detector), Form::Int8); assert_eq!(Rung::Hexagon.form(Role::Detector), Form::Int8);
// A detector offered in f32 on a Hexagon device lands on the CPU. // A detector offered in f32 on a Hexagon device lands on the CPU.
@@ -631,6 +658,27 @@ mod tests {
); );
} }
#[test]
fn coreml_takes_a_compiled_detector_and_never_the_embedder() {
let hash = engines::hash(b"detector");
let mut s = State {
config: Config::default(),
cache: Cache {
rung: Some(Rung::CoreMl),
..Cache::default()
},
probing: false,
wanted: 0,
};
let on = |s: &State, role| effective_rung(s, Rung::CoreMl, role, Form::F32, hash);
// Before its program is compiled the detector waits on the CPU.
assert_eq!(on(&s, Role::Detector), Rung::Cpu);
s.cache.compiled.insert(engines::key_of(Rung::CoreMl, hash));
assert_eq!(on(&s, Role::Detector), Rung::CoreMl);
// The embedder does not move, compiled or not (§7).
assert_eq!(on(&s, Role::Embedder), Rung::Cpu);
}
#[test] #[test]
fn the_status_reports_only_the_rungs_above_the_selection() { fn the_status_reports_only_the_rungs_above_the_selection() {
let _serial = serial(); let _serial = serial();
+171 -15
View File
@@ -16,10 +16,15 @@ use crate::{api::Runtime, state, Cache, Config, Form, Role, Rung};
fn ladder(ceiling: Option<Rung>) -> Vec<Rung> { fn ladder(ceiling: Option<Rung>) -> Vec<Rung> {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
let all = [Rung::Hexagon]; let all = [Rung::Hexagon];
// Unmeasured (§2 ⁵): it is on the ladder because the probe's clock and
// `attempt` make a wrong guess cost one slow or failed probe, not a
// slow or crashing app.
#[cfg(target_os = "macos")]
let all = [Rung::CoreMl];
// A desktop has one vendor's GPU; the other vendor's providers are // A desktop has one vendor's GPU; the other vendor's providers are
// "not enabled in this build" or a library that fails to load, and // "not enabled in this build" or a library that fails to load, and
// either answer arrives in milliseconds. // either answer arrives in milliseconds.
#[cfg(not(target_os = "android"))] #[cfg(not(any(target_os = "android", target_os = "macos")))]
let all = [Rung::TensorRt, Rung::Cuda, Rung::MiGraphX]; let all = [Rung::TensorRt, Rung::Cuda, Rung::MiGraphX];
all.into_iter() all.into_iter()
.filter(|r| ceiling.is_none_or(|c| *r <= c)) .filter(|r| ceiling.is_none_or(|c| *r <= c))
@@ -81,7 +86,11 @@ pub fn run(runtime: Runtime) {
log::info!("inference: floor {floor:.1} ms on the CPU provider"); log::info!("inference: floor {floor:.1} ms on the CPU provider");
for rung in ladder(cfg.ceiling) { for rung in ladder(cfg.ceiling) {
match time_rung(rung, role, &canonical, &cfg) { let timed = attempt(&cfg, &format!("probe {}", rung.label()), || {
time_rung(rung, role, &canonical, &cfg)
})
.and_then(|timed| timed);
match timed {
Ok((ms, key)) if ms < floor => { Ok((ms, key)) if ms < floor => {
cache.rung = Some(rung); cache.rung = Some(rung);
cache.reason = format!("{ms:.1} ms against {floor:.1} ms on the CPU"); cache.reason = format!("{ms:.1} ms against {floor:.1} ms on the CPU");
@@ -118,6 +127,52 @@ fn finish(cache: Cache) {
s.probing = false; s.probing = false;
} }
/// How many launches in a row may die inside one attempt before it is
/// refused. Two, not one: quitting the app while TensorRT spends forty
/// seconds on an engine leaves the same trace as a provider that aborted.
const STRIKES: u32 = 2;
/// Run `f` — a session build on a provider — with `what` written down
/// first, so that if the provider takes the process with it the next launch
/// knows what to stop trying.
///
/// A provider can fail by aborting rather than by returning an error:
/// XNNPACK did on SCRFD (§2), and a C++ exception or a panic across the C
/// API is an abort. The probe runs in the app's own process, so a rung that
/// does this once would do it on every launch, before the first photograph
/// is on screen. The file (`attempt` in the cache directory) holds the
/// attempt and how many launches have started it without finishing;
/// finishing, by success or by error, removes it. After [`STRIKES`] the
/// attempt is refused, and the caller records the refusal in the cache,
/// where it lasts until the fingerprint changes like any other failure.
pub fn attempt<T>(cfg: &Config, what: &str, f: impl FnOnce() -> T) -> Result<T, String> {
if cfg.cache_dir.as_os_str().is_empty() {
return Ok(f());
}
let path = cfg.cache_dir.join("attempt");
let died = std::fs::read_to_string(&path)
.ok()
.and_then(|s| {
let (w, n) = s.split_once('\t')?;
(w == what).then(|| n.trim().parse::<u32>().ok())?
})
.unwrap_or(0);
if died >= STRIKES {
log::error!("inference: the app died during `{what}` on the last {died} launches; not trying it again");
return Err(format!(
"the app died while trying this on {died} launches in a row"
));
}
if died > 0 {
log::warn!("inference: the last launch died during `{what}`; trying it once more");
}
let _ = std::fs::create_dir_all(&cfg.cache_dir);
let _ = std::fs::write(&path, format!("{what}\t{}", died + 1));
let out = f();
let _ = std::fs::remove_file(&path);
Ok(out)
}
/// The smallest detector, or the smallest model of any role if there is /// The smallest detector, or the smallest model of any role if there is
/// none. A ~2 MB detector is the cheapest real test of a provider, and the /// none. A ~2 MB detector is the cheapest real test of a provider, and the
/// detector is the role the int8 forms exist for — the eye classifiers are /// detector is the role the int8 forms exist for — the eye classifiers are
@@ -168,21 +223,33 @@ fn time_rung(
started.elapsed().as_secs_f64() started.elapsed().as_secs_f64()
); );
let shape: Vec<usize> = session.inputs()[0] // Zeros for every input the model declares, by name — the denoiser
.dtype() // takes two (mosaic and σ), and a probe that fed only the first failed
.tensor_shape() // every rung and left it on the CPU.
.ok_or("model input is not a tensor")? let feeds: Vec<(String, Vec<usize>)> = session
.inputs()
.iter() .iter()
.map(|&d| if d > 0 { d as usize } else { 1 }) .map(|i| {
.collect(); let shape = i
let zeros = vec![0f32; shape.iter().product()]; .dtype()
.tensor_shape()
.ok_or("model input is not a tensor")?
.iter()
.map(|&d| if d > 0 { d as usize } else { 1 })
.collect();
Ok((i.name().to_string(), shape))
})
.collect::<Result<_, &str>>()?;
let run = |session: &mut ort::session::Session| -> Result<f64, String> { let run = |session: &mut ort::session::Session| -> Result<f64, String> {
let input = ort::value::Tensor::from_array((shape.clone(), zeros.clone())) let mut inputs: Vec<(String, ort::session::SessionInputValue)> = Vec::new();
.map_err(|e| e.to_string())?; for (name, shape) in &feeds {
let zeros = vec![0f32; shape.iter().product()];
let t = ort::value::Tensor::from_array((shape.clone(), zeros))
.map_err(|e| e.to_string())?;
inputs.push((name.clone(), t.into()));
}
let t = Instant::now(); let t = Instant::now();
let out = session let out = session.run(inputs).map_err(|e| e.to_string())?;
.run(ort::inputs![input])
.map_err(|e| e.to_string())?;
let _ = out[0] let _ = out[0]
.try_extract_tensor::<f32>() .try_extract_tensor::<f32>()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -310,7 +377,50 @@ fn system_property(name: &str) -> String {
String::from_utf8_lossy(&buf[..n.max(0) as usize]).into_owned() String::from_utf8_lossy(&buf[..n.max(0) as usize]).into_owned()
} }
#[cfg(not(any(target_os = "linux", target_os = "android")))] #[cfg(target_os = "macos")]
fn device_identity() -> String {
// The chip, and the OS release: CoreML ships with the OS, so a macOS
// update is a new provider as surely as a new driver is on Linux.
format!(
"{} macOS {}",
sysctl("machdep.cpu.brand_string"),
sysctl("kern.osproductversion")
)
}
#[cfg(target_os = "macos")]
fn sysctl(name: &str) -> String {
extern "C" {
fn sysctlbyname(
name: *const std::ffi::c_char,
oldp: *mut std::ffi::c_void,
oldlenp: *mut usize,
newp: *mut std::ffi::c_void,
newlen: usize,
) -> i32;
}
let name = std::ffi::CString::new(name).unwrap();
let mut buf = [0u8; 256];
let mut len = buf.len();
// SAFETY: libSystem's documented call; `len` is the buffer's size in and
// the string's length, with its terminator, out.
let rc = unsafe {
sysctlbyname(
name.as_ptr(),
buf.as_mut_ptr().cast(),
&mut len,
std::ptr::null_mut(),
0,
)
};
if rc != 0 {
return String::new();
}
let s = &buf[..len.min(buf.len())];
String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned()
}
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos")))]
fn device_identity() -> String { fn device_identity() -> String {
String::new() String::new()
} }
@@ -338,3 +448,49 @@ pub fn write_cache(cfg: &Config, cache: &Cache) {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn a_cache_dir(name: &str) -> Config {
let dir = std::env::temp_dir().join(format!("dr-attempt-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
Config {
cache_dir: dir,
..Config::default()
}
}
/// What a launch that died inside `what` leaves behind.
fn died_inside(cfg: &Config, what: &str, launches: u32) {
std::fs::create_dir_all(&cfg.cache_dir).unwrap();
std::fs::write(cfg.cache_dir.join("attempt"), format!("{what}\t{launches}")).unwrap();
}
#[test]
fn a_finished_attempt_leaves_no_trace() {
let cfg = a_cache_dir("finished");
assert_eq!(attempt(&cfg, "probe CoreML", || 7), Ok(7));
assert!(!cfg.cache_dir.join("attempt").exists());
}
#[test]
fn one_death_is_forgiven_and_two_are_not() {
let cfg = a_cache_dir("strikes");
died_inside(&cfg, "probe CoreML", 1);
assert_eq!(attempt(&cfg, "probe CoreML", || 7), Ok(7));
died_inside(&cfg, "probe CoreML", 2);
let mut ran = false;
assert!(attempt(&cfg, "probe CoreML", || ran = true).is_err());
assert!(!ran, "a refused attempt must not run");
}
#[test]
fn another_attempts_deaths_do_not_count() {
let cfg = a_cache_dir("other");
died_inside(&cfg, "probe TensorRT", 2);
assert_eq!(attempt(&cfg, "probe CUDA", || 7), Ok(7));
}
}
+85 -10
View File
@@ -19,24 +19,61 @@ pub fn build(rung: Rung, role: Role, bytes: &[u8], cfg: &Config) -> ort::Result<
// `stack_tensors`) — a panic across the C API, which is an abort. The // `stack_tensors`) — a panic across the C API, which is an abort. The
// app never asked tract for that and does not start now. // app never asked tract for that and does not start now.
let mut b = Session::builder()?.with_intra_threads(threads(cfg))?; let mut b = Session::builder()?.with_intra_threads(threads(cfg))?;
if crate::api::runtime().is_native() {
b = with_runtime_log(b)?;
}
// A Hexagon session loads the compiled context when there is one and // A Hexagon session loads the compiled context when there is one and
// compiles it from the model when there is not; the engine thread is // compiles it from the model when there is not; the engine thread is
// what makes the second case rare (§6). // what makes the second case rare (§6).
let context = (rung == Rung::Hexagon).then(|| crate::engines::context_path(cfg, bytes)); let context = (rung == Rung::Hexagon).then(|| crate::engines::context_path(cfg, bytes));
let ready = context.as_ref().is_some_and(|p| p.is_file()); let ready = context.as_ref().is_some_and(|p| p.is_file());
b = providers( // What the rung keeps for this model: the context the Hexagon is to
b, // write, or the directory CoreML compiles into.
rung, let per_model = match rung {
role, Rung::CoreMl => Some(crate::engines::coreml_dir(cfg, bytes)),
cfg, _ if ready => None,
if ready { None } else { context.as_deref() }, _ => context.clone(),
)?; };
b = providers(b, rung, role, cfg, per_model.as_deref())?;
match (ready, context) { match (ready, context) {
(true, Some(path)) => b.commit_from_file(path), (true, Some(path)) => b.commit_from_file(path),
_ => b.commit_from_memory(bytes), _ => b.commit_from_memory(bytes),
} }
} }
/// Send the runtime's own messages for this session to `log`, under the
/// target `onnxruntime`, instead of to ONNX Runtime's stdio logger.
///
/// Its stderr is nowhere once the app is launched from a menu, and what a
/// provider says while it partitions a graph — how many nodes it took, which
/// operator it declined, the library it failed to load — is most of what a
/// failed rung tells you (docs/dev/inference.md §4). The level follows the
/// filter: warnings always, `debug` adds the runtime's info lines (the
/// partition counts), `trace` its verbose ones (every node placement).
fn with_runtime_log(
b: ort::session::builder::SessionBuilder,
) -> ort::Result<ort::session::builder::SessionBuilder> {
use ort::logging::LogLevel;
let level = if log::log_enabled!(target: "onnxruntime", log::Level::Trace) {
LogLevel::Verbose
} else if log::log_enabled!(target: "onnxruntime", log::Level::Debug) {
LogLevel::Info
} else {
LogLevel::Warning
};
let forward = |level: LogLevel, _category: &str, _id: &str, location: &str, message: &str| {
let level = match level {
LogLevel::Verbose => log::Level::Trace,
LogLevel::Info => log::Level::Debug,
LogLevel::Warning => log::Level::Warn,
LogLevel::Error | LogLevel::Fatal => log::Level::Error,
};
log::log!(target: "onnxruntime", level, "{message} ({location})");
};
Ok(b.with_logger(std::sync::Arc::new(forward))?
.with_log_level(level)?)
}
/// The intra-op pool: what the config says, else the cores less two for /// The intra-op pool: what the config says, else the cores less two for
/// the compositor and the decoder (§9). tract ignores it. /// the compositor and the decoder (§9). tract ignores it.
fn threads(cfg: &Config) -> usize { fn threads(cfg: &Config) -> usize {
@@ -54,11 +91,12 @@ fn providers(
rung: Rung, rung: Rung,
role: Role, role: Role,
cfg: &Config, cfg: &Config,
_generate_context: Option<&std::path::Path>, per_model: Option<&std::path::Path>,
) -> ort::Result<ort::session::builder::SessionBuilder> { ) -> ort::Result<ort::session::builder::SessionBuilder> {
use ort::ep; use ort::ep;
match rung { match rung {
Rung::Cpu => Ok(b), Rung::Cpu => Ok(b),
Rung::CoreMl => coreml(b, per_model),
Rung::Cuda => { Rung::Cuda => {
Ok(b.with_execution_providers([ep::CUDA::default().build().error_on_failure()])?) Ok(b.with_execution_providers([ep::CUDA::default().build().error_on_failure()])?)
} }
@@ -103,6 +141,43 @@ fn providers(
} }
} }
/// CoreML, compiling an ML Program — the format with the operators these
/// graphs use and the one that reaches the Neural Engine — into `cache`.
///
/// The option names are those ONNX Runtime 1.29 reads from the generic
/// key/value map (`coreml_options.cc`), which is what `ort`'s builder
/// fills. The cache is per model because of how CoreML keys it: a model
/// committed from memory, as every session here is, has no path, and the
/// key falls back to a hash of the graph's input and node names — not its
/// weights. Two exports of one architecture would share a program. The
/// directory `engines::coreml_dir` names is the hash of the bytes.
///
/// Every compute unit is allowed, so CoreML may place a graph on the
/// Neural Engine, the GPU or the CPU; the probe's clock judges the result.
#[cfg(target_os = "macos")]
fn coreml(
b: ort::session::builder::SessionBuilder,
cache: Option<&std::path::Path>,
) -> ort::Result<ort::session::builder::SessionBuilder> {
use ort::ep::{self, coreml};
let mut ep = ep::CoreML::default()
.with_model_format(coreml::ModelFormat::MLProgram)
.with_compute_units(coreml::ComputeUnits::All);
if let Some(dir) = cache {
let _ = std::fs::create_dir_all(dir);
ep = ep.with_model_cache_dir(dir.to_string_lossy());
}
Ok(b.with_execution_providers([ep.build().error_on_failure()])?)
}
#[cfg(not(any(target_os = "android", target_os = "macos")))]
fn coreml(
_b: ort::session::builder::SessionBuilder,
_cache: Option<&std::path::Path>,
) -> ort::Result<ort::session::builder::SessionBuilder> {
unreachable!("the CoreML rung is on the macOS ladder only")
}
/// Register MIGraphX through ONNX Runtime's generic key/value entry point. /// Register MIGraphX through ONNX Runtime's generic key/value entry point.
/// ///
/// `ort`'s own builder (`ep::MIGraphX`) fills the legacy /// `ort`'s own builder (`ep::MIGraphX`) fills the legacy
@@ -175,8 +250,8 @@ fn providers(
.build() .build()
.error_on_failure()])?) .error_on_failure()])?)
} }
Rung::Cuda | Rung::TensorRt | Rung::MiGraphX => { Rung::Cuda | Rung::TensorRt | Rung::MiGraphX | Rung::CoreMl => {
unreachable!("no desktop GPU rung on Android") unreachable!("no desktop rung on Android")
} }
} }
} }
+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);
+4 -1
View File
@@ -22,6 +22,7 @@
//! - [`align`] — the whole thing, from features to cameras, honest about //! - [`align`] — the whole thing, from features to cameras, honest about
//! what it could not place. //! what it could not place.
//! - [`projection`] — perspective, cylindrical, spherical. //! - [`projection`] — perspective, cylindrical, spherical.
//! - [`seam`] — which frame each output pixel is taken from.
//! - [`linalg`] — the small dense algebra all of it uses. //! - [`linalg`] — the small dense algebra all of it uses.
//! //!
//! # What it depends on //! # What it depends on
@@ -43,15 +44,17 @@ pub mod matching;
#[cfg(feature = "xfeat")] #[cfg(feature = "xfeat")]
pub mod migan; pub mod migan;
pub mod projection; pub mod projection;
pub mod seam;
#[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};
pub use image::Gray; pub use image::Gray;
pub use projection::Projection; pub use projection::Projection;
pub use seam::{SeamMap, SeamOptions};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum PanoError { pub enum PanoError {
+691
View File
@@ -0,0 +1,691 @@
//! TRACES: FR-MRG-10
//! Where each frame gives way to the next.
//!
//! The first merges averaged every overlap: each frame weighted by its
//! distance from its own edge, so that across two hundred pixels one frame
//! faded into the other. That hides an exposure step and does not hide
//! anything that differs between the frames — parallax on a near slope, a
//! walker, a branch in the wind — which the average draws twice, half as
//! bright, a soft double edge at 1:1.
//!
//! A seam answers it the way every stitcher does: in an overlap, each output
//! pixel is taken from *one* frame, and the line where the choice changes is
//! put where the frames agree and the picture is smooth — through sky,
//! along a shadow, round the walker rather than through him — and away from
//! either frame's edge, where vignetting and the lens correction's fringe
//! live. The blend is then narrow and only across that line.
//!
//! # How
//!
//! At proxy resolution, on the output surface, which fits (panorama.md §5:
//! "it is a mask, not an image"):
//!
//! 1. Frames are laid down one at a time, each next to one already placed.
//! The composite so far is a label per texel and the value its owner saw.
//! 2. Where a new frame overlaps the composite, a cost per texel: the
//! difference between the two (after the gains), how much detail either
//! has there, and how near either frame's edge it is — smoothed over a
//! few texels, because "agree" means locally, not at one pixel.
//! 3. The cut is a path across the overlap, perpendicular to the line from
//! the composite's frames to the new one, found by dynamic programming
//! one row at a time: the per-column seam panorama.md §4 chose over a
//! graph cut because it is the GPU-friendly shape. Texels on the new
//! frame's side of the path become its own.
//!
//! What the merge reads is [`SeamMap::share`]: the fraction of a small
//! window about a point that is labelled with a frame, tent-weighted, which
//! is a narrow blend that follows the seam. `merge.wgsl` computes the same
//! thing on the GPU from the same labels.
use crate::bundle::Cameras;
use crate::image::Gray;
use crate::projection::{self, Projection};
/// No frame owns this texel.
pub const NONE: u8 = 255;
/// The most frames a map can label: one less than [`NONE`].
pub const MAX_FRAMES: usize = NONE as usize;
/// Which frame each texel of the output takes its pixels from.
#[derive(Debug, Clone, PartialEq)]
pub struct SeamMap {
pub width: usize,
pub height: usize,
/// The projection scale the map was laid out at: the proxies' focal
/// length. Output coordinates at any other scale are this times the
/// ratio of the scales.
pub scale: f64,
/// Centred output coordinates, at `scale`, of texel (0, 0)'s top-left
/// corner.
pub origin: (f64, f64),
/// Output units per texel, at `scale`.
pub px: f64,
/// Row-major, one per texel: the frame's index, or [`NONE`].
pub labels: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SeamOptions {
/// The widest the map is laid out, in texels. Wider than the proxies'
/// own resolution buys nothing.
pub max_width: usize,
/// How much detail costs against disagreement: a seam through texture
/// shows even where the frames agree, because the blend across it
/// softens it.
pub detail: f32,
/// How much a frame's edge costs, and how far in from it the cost
/// reaches, in proxy pixels. Frame edges are where vignetting is
/// darkest and the lens correction ran out of sensor.
pub edge: f32,
pub edge_margin: f32,
/// The radius, in texels, a texel's cost looks about it for the worst
/// of its neighbours: at least the radius the merge blends across.
pub smoothing: usize,
}
impl Default for SeamOptions {
fn default() -> Self {
SeamOptions {
max_width: 2048,
detail: 0.5,
edge: 0.5,
edge_margin: 24.0,
smoothing: 4,
}
}
}
/// The most texels a blend reaches either side of a seam. The merge's
/// shader loads the square of twice this per pixel per frame near a seam.
pub const MAX_BLEND_RADIUS: f64 = 4.0;
/// Cost of a texel outside the overlap: high enough that the path keeps to
/// the overlap wherever there is one, finite so that a row with a gap in it
/// still has an answer.
const OUTSIDE: f32 = 1.0e3;
impl SeamMap {
/// The map's origin and texel size in the coordinates of an output
/// laid out at `scale` (the full-resolution focal length, or a fraction
/// of it).
pub fn at_scale(&self, scale: f64) -> ((f64, f64), f64) {
let r = scale / self.scale;
((self.origin.0 * r, self.origin.1 * r), self.px * r)
}
/// The radius, in texels, of a blend `blend_px` output pixels wide in an
/// output laid out at `scale`: what [`Self::share`] and the shader are
/// given, so that the preview and the merge blend alike.
pub fn blend_radius(&self, scale: f64, blend_px: f64) -> f64 {
let (_, px) = self.at_scale(scale);
(blend_px / 2.0 / px).clamp(1.0, MAX_BLEND_RADIUS)
}
/// The share frame `k` has of output point `(u, v)` given at `scale`:
/// the tent-weighted fraction of the texels within `radius` (in texels)
/// that it owns. `None` where no texel in reach is owned at all — the
/// map has nothing to say there, and the caller falls back to its
/// feather.
///
/// This is the function `merge.wgsl`'s `seam_share` repeats; the two
/// must agree.
pub fn share(&self, k: usize, u: f64, v: f64, scale: f64, radius: f64) -> Option<f32> {
let ((ou, ov), px) = self.at_scale(scale);
let x = (u - ou) / px - 0.5;
let y = (v - ov) / px - 0.5;
let r = radius.max(1.0);
let (x0, x1) = ((x - r).ceil() as i64, (x + r).floor() as i64);
let (y0, y1) = ((y - r).ceil() as i64, (y + r).floor() as i64);
let (mut mine, mut all) = (0.0f64, 0.0f64);
for j in y0.max(0)..=y1.min(self.height as i64 - 1) {
let wy = 1.0 - (y - j as f64).abs() / r;
if wy <= 0.0 {
continue;
}
for i in x0.max(0)..=x1.min(self.width as i64 - 1) {
let wx = 1.0 - (x - i as f64).abs() / r;
if wx <= 0.0 {
continue;
}
let l = self.labels[j as usize * self.width + i as usize];
if l == NONE {
continue;
}
all += wx * wy;
if usize::from(l) == k {
mine += wx * wy;
}
}
}
(all > 0.0).then(|| (mine / all) as f32)
}
}
/// One frame warped onto the map: its gain-corrected value and its distance
/// from its own edge (in proxy pixels) per texel, NaN where it does not
/// reach.
struct Warped {
value: Vec<f32>,
edge: Vec<f32>,
}
/// Lay seams across the overlaps of `proxies`, aligned by `cameras` (at the
/// proxies' scale), with `gains` the linear multipliers the merge will
/// apply. `None` if the frames project nowhere or there are more than
/// [`MAX_FRAMES`].
pub fn find(
proxies: &[&Gray],
cameras: &Cameras,
gains: &[f32],
projection: Projection,
opts: &SeamOptions,
) -> Option<SeamMap> {
let n = proxies.len();
if n == 0 || n > MAX_FRAMES || cameras.rotations.len() != n || gains.len() != n {
return None;
}
let (fw, fh) = (proxies[0].width as f64, proxies[0].height as f64);
let scale = cameras.focal;
let bounds = projection::bounds(projection, scale, cameras, (fw, fh))?;
let width = opts.max_width.min(bounds.width().ceil() as usize).max(1);
let px = bounds.width() / width as f64;
let height = ((bounds.height() / px).ceil() as usize).max(1);
let mut map = SeamMap {
width,
height,
scale,
origin: (bounds.min_u, bounds.min_v),
px,
labels: vec![NONE; width * height],
};
// Where each frame's centre lands, in texels: what orders the frames
// and orients each cut.
let centres: Vec<(f64, f64)> = (0..n)
.map(|k| {
let d = cameras.bearing(k, (0.0, 0.0));
projection
.from_direction(scale, d)
.map(|(u, v)| ((u - bounds.min_u) / px, (v - bounds.min_v) / px))
.unwrap_or((width as f64 / 2.0, height as f64 / 2.0))
})
.collect();
// The composite so far: what its owner saw, and how far from the
// owner's edge.
let mut value = vec![f32::NAN; width * height];
let mut edge = vec![f32::NAN; width * height];
for k in order(&centres, (width as f64 / 2.0, height as f64 / 2.0)) {
let w = warp(&map, proxies[k], cameras, k, gains[k], projection);
let overlap: Vec<usize> = (0..width * height)
.filter(|&i| map.labels[i] != NONE && !w.value[i].is_nan())
.collect();
// Texels nobody owns yet are the new frame's without a cut.
let mut take: Vec<bool> = map
.labels
.iter()
.zip(&w.value)
.map(|(&l, v)| l == NONE && !v.is_nan())
.collect();
if !overlap.is_empty() {
cut(
&map, &value, &edge, &w, &overlap, &centres, k, opts, &mut take,
);
}
for i in 0..width * height {
if take[i] {
map.labels[i] = k as u8;
value[i] = w.value[i];
edge[i] = w.edge[i];
}
}
}
Some(map)
}
/// The order frames are laid down in: the one nearest the middle first,
/// then always the unplaced frame nearest any placed one, so that each new
/// frame meets the composite along an overlap rather than across a gap.
fn order(centres: &[(f64, f64)], middle: (f64, f64)) -> Vec<usize> {
let d2 = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2);
let n = centres.len();
let mut placed = vec![false; n];
let mut out = Vec::with_capacity(n);
let first = (0..n)
.min_by(|&a, &b| d2(centres[a], middle).total_cmp(&d2(centres[b], middle)))
.expect("at least one frame");
placed[first] = true;
out.push(first);
while out.len() < n {
let next = (0..n)
.filter(|&k| !placed[k])
.min_by(|&a, &b| {
let near = |k: usize| {
out.iter()
.map(|&p| d2(centres[k], centres[p]))
.fold(f64::MAX, f64::min)
};
near(a).total_cmp(&near(b))
})
.expect("an unplaced frame");
placed[next] = true;
out.push(next);
}
out
}
/// Frame `k` sampled at every texel's centre, bilinearly. The proxy is
/// gamma-encoded grey, so the gain (linear) becomes `gain^(1/2.2)` on it.
fn warp(
map: &SeamMap,
g: &Gray,
cameras: &Cameras,
k: usize,
gain: f32,
projection: Projection,
) -> Warped {
let (fw, fh) = (g.width as f64, g.height as f64);
let gain = gain.max(1e-6).powf(1.0 / 2.2);
let mut value = vec![f32::NAN; map.width * map.height];
let mut edge = vec![f32::NAN; map.width * map.height];
for ty in 0..map.height {
let v = map.origin.1 + (ty as f64 + 0.5) * map.px;
for tx in 0..map.width {
let u = map.origin.0 + (tx as f64 + 0.5) * map.px;
let d = projection.to_direction(map.scale, u, v);
let Some((x, y)) = cameras.project(k, d) else {
continue;
};
let (x, y) = (x + fw / 2.0 - 0.5, y + fh / 2.0 - 0.5);
let e = x.min(fw - 1.0 - x).min(y).min(fh - 1.0 - y);
if e < 0.0 {
continue;
}
let (x0, y0) = (x.floor() as usize, y.floor() as usize);
let (x1, y1) = ((x0 + 1).min(g.width - 1), (y0 + 1).min(g.height - 1));
let (ax, ay) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
let at = |xx: usize, yy: usize| g.data[yy * g.width + xx];
let top = at(x0, y0) * (1.0 - ax) + at(x1, y0) * ax;
let bot = at(x0, y1) * (1.0 - ax) + at(x1, y1) * ax;
let i = ty * map.width + tx;
value[i] = (top * (1.0 - ay) + bot * ay) * gain;
edge[i] = e as f32;
}
}
Warped { value, edge }
}
/// Central-difference gradient magnitude of `plane` at texel `i`, from the
/// neighbours that exist.
fn detail(plane: &[f32], width: usize, height: usize, i: usize) -> f32 {
let (x, y) = (i % width, i / width);
let c = plane[i];
let mut g = 0.0f32;
let mut diff = |j: usize| {
let n = plane[j];
if !n.is_nan() {
g = g.max((n - c).abs());
}
};
if x > 0 {
diff(i - 1);
}
if x + 1 < width {
diff(i + 1);
}
if y > 0 {
diff(i - width);
}
if y + 1 < height {
diff(i + width);
}
g
}
/// Cut the overlap between the composite and frame `k`, marking in `take`
/// the overlap texels that go to `k`.
#[allow(clippy::too_many_arguments)]
fn cut(
map: &SeamMap,
value: &[f32],
edge: &[f32],
new: &Warped,
overlap: &[usize],
centres: &[(f64, f64)],
k: usize,
opts: &SeamOptions,
take: &mut [bool],
) {
let (w, h) = (map.width, map.height);
// The raw cost per overlap texel.
let mut raw = vec![f32::NAN; w * h];
let margin = opts.edge_margin.max(1.0);
for &i in overlap {
let differ = (value[i] - new.value[i]).abs();
let detail = detail(value, w, h, i).max(detail(&new.value, w, h, i));
let near = (1.0 - edge[i].min(new.edge[i]) / margin).max(0.0);
raw[i] = differ + opts.detail * detail + opts.edge * near * near + 1e-3;
}
// The worst over a small window: a texel is only cheap if its whole
// neighbourhood agrees, so the path keeps at least the blend's radius
// clear of a difference rather than threading the one lucky texel
// beside it — the blend straddles the path by that much and would
// otherwise reach the difference anyway.
let r = opts.smoothing as isize;
let mut cost = vec![OUTSIDE; w * h];
for &i in overlap {
let (x, y) = ((i % w) as isize, (i / w) as isize);
let mut worst = 0.0f32;
for dy in -r..=r {
for dx in -r..=r {
let (xx, yy) = (x + dx, y + dy);
if xx < 0 || yy < 0 || xx >= w as isize || yy >= h as isize {
continue;
}
let c = raw[yy as usize * w + xx as usize];
if !c.is_nan() {
worst = worst.max(c);
}
}
}
cost[i] = worst;
}
// The axis the cut crosses: from the composite's frames, weighted by how
// much of the overlap each owns, to the new frame.
let mut from = (0.0f64, 0.0f64);
for &i in overlap {
let c = centres[usize::from(map.labels[i])];
from = (from.0 + c.0, from.1 + c.1);
}
let m = overlap.len() as f64;
from = (from.0 / m, from.1 / m);
let to = centres[k];
let (mut ax, mut ay) = (to.0 - from.0, to.1 - from.1);
let len = (ax * ax + ay * ay).sqrt();
if len < 1e-6 {
(ax, ay) = (1.0, 0.0);
} else {
(ax, ay) = (ax / len, ay / len);
}
// Along the cut: perpendicular to the axis.
let (bx, by) = (-ay, ax);
// The overlap's extent in (s along the cut, t across it).
let st = |i: usize| {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
(x * bx + y * by, x * ax + y * ay)
};
let (mut s0, mut s1, mut t0, mut t1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
for &i in overlap {
let (s, t) = st(i);
s0 = s0.min(s);
s1 = s1.max(s);
t0 = t0.min(t);
t1 = t1.max(t);
}
let rows = (s1 - s0).round() as usize + 1;
let cols = (t1 - t0).round() as usize + 1;
// The grid in (s, t), each cell sampled from the texel it falls in, so
// that a rotated overlap has no holes.
let mut grid = vec![OUTSIDE; rows * cols];
let mut any = vec![false; rows];
for si in 0..rows {
for ti in 0..cols {
let (s, t) = (s0 + si as f64, t0 + ti as f64);
let x = s * bx + t * ax;
let y = s * by + t * ay;
if x < 0.0 || y < 0.0 {
continue;
}
let (x, y) = (x as usize, y as usize);
if x >= w || y >= h {
continue;
}
let c = cost[y * w + x];
if c < OUTSIDE {
grid[si * cols + ti] = c;
any[si] = true;
}
}
}
// Dynamic programming down the rows: the path moves at most one column
// per row, and starts afresh after a row with no overlap in it.
let mut acc = grid.clone();
let mut from_col = vec![0u32; rows * cols];
for si in 1..rows {
if !any[si] {
continue;
}
let prev = &acc[(si - 1) * cols..si * cols].to_vec();
if !any[si - 1] {
continue;
}
for ti in 0..cols {
let mut best = (prev[ti], ti);
if ti > 0 && prev[ti - 1] < best.0 {
best = (prev[ti - 1], ti - 1);
}
if ti + 1 < cols && prev[ti + 1] < best.0 {
best = (prev[ti + 1], ti + 1);
}
acc[si * cols + ti] += best.0;
from_col[si * cols + ti] = best.1 as u32;
}
}
// Back up from the end of each run of rows with overlap.
let mut seam = vec![usize::MAX; rows];
let mut si = rows;
while si > 0 {
si -= 1;
if !any[si] {
continue;
}
let row = &acc[si * cols..(si + 1) * cols];
let mut t = (0..cols)
.min_by(|&a, &b| row[a].total_cmp(&row[b]))
.unwrap_or(0);
loop {
seam[si] = t;
if si == 0 || !any[si - 1] {
break;
}
t = from_col[si * cols + t] as usize;
si -= 1;
}
}
// The new frame takes the side of the path its centre is on.
for &i in overlap {
let (s, t) = st(i);
let si = ((s - s0).round() as usize).min(rows - 1);
let ti = (t - t0).round();
if seam[si] != usize::MAX && ti >= seam[si] as f64 {
take[i] = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linalg::{Mat3, Vec3};
/// A scene as a function of direction, and frames of it rendered by the
/// same cameras the seam reads.
fn render(
cameras: &Cameras,
k: usize,
size: (usize, usize),
scene: impl Fn(Vec3) -> f32,
) -> Gray {
let (w, h) = size;
let mut data = vec![0.0; w * h];
for y in 0..h {
for x in 0..w {
let p = (
x as f64 + 0.5 - w as f64 / 2.0,
y as f64 + 0.5 - h as f64 / 2.0,
);
data[y * w + x] = scene(cameras.bearing(k, p));
}
}
Gray {
width: w,
height: h,
data,
}
}
fn yaw(a: f64) -> Mat3 {
let (s, c) = a.sin_cos();
Mat3([[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]])
}
/// Smooth, with a little texture: what a sky over a slope looks like to
/// the cost.
fn landscape(d: Vec3) -> f32 {
let (x, y) = (d.x() / d.z(), d.y() / d.z());
let texture = if y > 0.1 { 0.1 * (y * 40.0).sin() } else { 0.0 };
(0.5 + 0.2 * (x * 3.0).sin() + texture).clamp(0.0, 1.0) as f32
}
fn pair() -> Cameras {
Cameras {
rotations: vec![Mat3::IDENTITY, yaw(0.35)],
focal: 300.0,
}
}
#[test]
fn one_frame_owns_everything_it_reaches() {
let cameras = Cameras {
rotations: vec![Mat3::IDENTITY],
focal: 300.0,
};
let g = render(&cameras, 0, (320, 240), landscape);
let map = find(
&[&g],
&cameras,
&[1.0],
Projection::Perspective,
&Default::default(),
)
.unwrap();
let owned = map.labels.iter().filter(|&&l| l == 0).count();
assert!(owned as f64 > 0.95 * (map.width * map.height) as f64);
}
#[test]
fn each_frame_keeps_its_own_side() {
let cameras = pair();
let frames: Vec<Gray> = (0..2)
.map(|k| render(&cameras, k, (320, 240), landscape))
.collect();
let refs: Vec<&Gray> = frames.iter().collect();
let map = find(
&refs,
&cameras,
&[1.0, 1.0],
Projection::Cylindrical,
&Default::default(),
)
.unwrap();
let mid = map.height / 2 * map.width;
assert_eq!(map.labels[mid + 2], 0, "the left edge is frame 0's alone");
assert_eq!(
map.labels[mid + map.width - 3],
1,
"the right edge is frame 1's"
);
// One change of owner along every row that both frames cross.
for y in 0..map.height {
let row = &map.labels[y * map.width..(y + 1) * map.width];
let owned: Vec<u8> = row.iter().copied().filter(|&l| l != NONE).collect();
let changes = owned.windows(2).filter(|p| p[0] != p[1]).count();
assert!(changes <= 1, "row {y} changes owner {changes} times");
}
}
#[test]
fn the_seam_goes_round_what_only_one_frame_saw() {
// Frame 1 saw something frame 0 did not — a figure that walked into
// the overlap — in the middle of where the two meet.
let cameras = pair();
let figure = Vec3::new(0.175f64.sin(), 0.0, 0.175f64.cos());
let walker = |d: Vec3| {
let near = (d.x() - figure.x()).abs() < 0.04 && (d.y() - figure.y()).abs() < 0.15;
if near {
0.95
} else {
landscape(d)
}
};
let frames = [
render(&cameras, 0, (320, 240), landscape),
render(&cameras, 1, (320, 240), walker),
];
let refs: Vec<&Gray> = frames.iter().collect();
let map = find(
&refs,
&cameras,
&[1.0, 1.0],
Projection::Cylindrical,
&Default::default(),
)
.unwrap();
// Every texel of the figure is taken from the same frame, with a
// blend radius of room to spare, so it is either all there or not at
// all — never half.
let (u, v) = Projection::Cylindrical
.from_direction(map.scale, figure)
.unwrap();
let mut owners = std::collections::HashSet::new();
// The figure's extent on the surface, plus the blend's radius.
let radius = 3.0;
let reach = |half: f64| half * map.scale + radius * map.px;
let (ru, rv) = (reach(0.04), reach(0.15));
let mut dv = -rv;
while dv <= rv {
let mut du = -ru;
while du <= ru {
let s = map.share(1, u + du, v + dv, map.scale, radius);
owners.insert((s.unwrap() * 100.0).round() as i32);
du += map.px;
}
dv += map.px;
}
assert_eq!(owners.len(), 1, "the figure is split: shares {owners:?}");
}
#[test]
fn share_is_a_blend_across_the_seam_and_whole_away_from_it() {
let map = SeamMap {
width: 8,
height: 1,
scale: 1.0,
origin: (0.0, 0.0),
px: 1.0,
labels: vec![0, 0, 0, 0, 1, 1, 1, 1],
};
assert_eq!(map.share(0, 1.5, 0.5, 1.0, 2.0), Some(1.0));
assert_eq!(map.share(1, 6.5, 0.5, 1.0, 2.0), Some(1.0));
let at_seam = map.share(0, 4.0, 0.5, 1.0, 2.0).unwrap();
assert!((at_seam - 0.5).abs() < 1e-6, "{at_seam}");
// And at twice the scale, the same point is twice as far out.
assert_eq!(
map.share(0, 8.0, 1.0, 2.0, 2.0),
map.share(0, 4.0, 0.5, 1.0, 2.0)
);
let empty = SeamMap {
labels: vec![NONE; 8],
..map
};
assert_eq!(empty.share(0, 4.0, 0.5, 1.0, 2.0), None);
}
}
+10
View File
@@ -405,6 +405,7 @@ fn emit_node(out: &mut String, node: &Declaration) {
active, 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
+19
View File
@@ -0,0 +1,19 @@
id: camera_profile
order: 25
# A `rust:` node publishes its own descriptor; its attributes are on the type
# in `../src/ops/camera_profile.rs`.
rust: CameraProfile
why_rust: |
It reads the source's profile tables from a storage buffer no declaration can
name, and it is composed at its defaults — a raw whose profile is on is
rendered through it without the photographer having touched anything —
which a declared node cannot say (D20).
placement: |
After exposure, before contrast (D20, camera-profiles.md §3). Hue and
saturation do not change under the uniform gains before it, so a 2.5-D
HueSatMap gives the same answer here as straight after the matrix; and the
LookTable sees the exposure the photographer chose, as the DNG SDK's does.
Contrast, tone and the colour controls then act on the profiled colour, as
they do in Camera Raw.
+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.
+36 -10
View File
@@ -21,24 +21,41 @@ params:
kind: amount kind: amount
uniforms: uniforms:
amount: vibrance / 100 amount:
value: vibrance / 100 * 1.3
doc: |
Scaled so that a value delivers the strength it names. Measured, not
chosen: fitted on 45 of the photographer's earlier exports whose only
colour setting was a vibrance of about +24, against their raws.
helpers: [luminance, tone_position, colour_saturation] helpers: [luminance]
wgsl: | wgsl: |
let luma = luminance(c); let luma = luminance(c);
let sat = colour_saturation(c);
// How saturated a colour *looks*, so measured on display-encoded values.
// In scene-linear light an ordinary tan reads as 0.78 saturated and the
// falloff below would leave it a twentieth of the effect; encoded, it reads
// as 0.5, which is what the eye sees.
let e = pow(max(c, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));
let e_hi = max(e.r, max(e.g, e.b));
let e_lo = min(e.r, min(e.g, e.b));
let sat = select(0.0, (e_hi - e_lo) / e_hi, e_hi > 0.00001);
// The vibrance curve: full effect on grey, tapering to nothing on colours // The vibrance curve: full effect on grey, tapering to nothing on colours
// that are already saturated. Squaring the falloff keeps the mid-range // that are already saturated. Squaring the falloff keeps the mid-range
// responsive while still protecting the extremes. // responsive while still protecting the extremes.
let falloff = (1.0 - sat) * (1.0 - sat); let falloff = (1.0 - sat) * (1.0 - sat);
// Skin protection. Skin sits in a narrow band of hue where red leads green // Skin protection, for skin: hues between about 10 and 50 degrees (red
// leads blue; pushing it is what makes vibrance look wrong on portraits. // leading, green between red and blue) that are not strongly saturated.
// Detected by channel ordering rather than a hue angle, which costs a // Red-over-green-over-blue alone is every warm colour in a photograph —
// conversion and buys nothing here. // wood, sand, brick, sunlit grass — and halving all of them is most of why
let is_skin = f32(c.r > c.g && c.g > c.b); // vibrance used to do so little.
let span = max(e_hi - e_lo, 0.00001);
let skin_hue = select(0.0, 60.0 * (e.g - e.b) / span, e.r >= e.g && e.g >= e.b);
let in_band = smoothstep(4.0, 12.0, skin_hue) * (1.0 - smoothstep(42.0, 52.0, skin_hue));
let is_skin = in_band * (1.0 - smoothstep(0.45, 0.7, sat)) * f32(e.r >= e.g && e.g >= e.b);
let skin_guard = 1.0 - is_skin * 0.5; let skin_guard = 1.0 - is_skin * 0.5;
let strength = amount * falloff * skin_guard; let strength = amount * falloff * skin_guard;
@@ -49,9 +66,18 @@ tests:
- name: it_starts_neutral - name: it_starts_neutral
expect_active: false expect_active: false
- name: the_amount_is_normalised_to_unit_range - name: the_amount_is_the_measured_scale
why: |
Fitted against the photographer's earlier exports, so a value delivers
the strength it names.
set: { vibrance: 100 } set: { vibrance: 100 }
expect: { amount: 1.0 } expect: { amount: 1.3 }
- name: saturation_is_judged_as_displayed
why: |
Judged in scene-linear light, ordinary warm colours read as nearly
saturated and get almost none of the effect.
expect_wgsl: ["let e = pow(max(c, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));"]
- name: muted_colours_get_more_than_saturated_ones - name: muted_colours_get_more_than_saturated_ones
why: | why: |
+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
+65
View File
@@ -0,0 +1,65 @@
drpl 1
# Vivid: more colour than the default rendering (camera-profiles.md §9).
#
# These do the work themselves, and work on every photograph — a JPEG, a body with no profile. They lean on
# vibrance before saturation: vibrance lifts muted colours most and holds
# skin back, so a frame gets richer before anything in it looks painted.
# Saturation, which moves every colour alike, is used sparingly on top.
#
# Each changes only what it names (FR-DEV-6), so a corrected exposure or
# white balance survives applying one.
[preset Vivid]
contrast.contrast = 10
saturation.saturation = 8
vibrance.vibrance = 30
[preset Vivid, strong]
blacks_whites.blacks = -10
clarity.amount = 8
contrast.contrast = 18
saturation.saturation = 15
vibrance.vibrance = 45
# Foliage and sky: green and chartreuse for leaves and grass, azure and blue
# for sky and water, a little yellow for dry grass and stone. The skin bands
# — red and orange — are left where they are, so a figure in a landscape
# keeps a human complexion.
[preset Vivid landscape]
clarity.amount = 10
colour_mixer.azure_lum = -10
colour_mixer.azure_sat = 20
colour_mixer.blue_lum = -10
colour_mixer.blue_sat = 15
colour_mixer.chartreuse_sat = 15
colour_mixer.green_sat = 20
colour_mixer.yellow_sat = 10
contrast.contrast = 12
saturation.saturation = 5
vibrance.vibrance = 25
# Golden hour: oranges and yellows up and a warm cast laid over the
# highlights only, so shadows stay clean rather than muddy.
[preset Vivid warm]
colour_grading.highlight_hue = 45
colour_grading.highlight_strength = 12
colour_mixer.orange_sat = 15
colour_mixer.red_sat = 8
colour_mixer.yellow_sat = 15
contrast.contrast = 8
vibrance.vibrance = 25
# People: everything around the subject gets richer while skin does not.
# Vibrance already protects skin; the orange and red bands are then held a
# little below where they started, because a face is the one colour every
# viewer knows the right value of.
[preset Vivid portrait]
colour_mixer.azure_sat = 10
colour_mixer.blue_sat = 12
colour_mixer.green_sat = 12
colour_mixer.orange_sat = -10
colour_mixer.red_sat = -5
contrast.contrast = 6
saturation.saturation = -5
vibrance.vibrance = 25
+7 -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,
@@ -63,19 +65,20 @@ const SECTIONS: &[(&str, &str, &str)] = &[
include_str!("../presets/essentials.drpl"), include_str!("../presets/essentials.drpl"),
), ),
("skies", "Skies", include_str!("../presets/skies.drpl")), ("skies", "Skies", include_str!("../presets/skies.drpl")),
("vivid", "Vivid", include_str!("../presets/vivid.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"),
), ),
]; ];
+168
View File
@@ -0,0 +1,168 @@
//! TRACES: FR-DEV-3j | FR-DEV-3e
//! The DNG SDK's reference tone, as a rendering the view transform can
//! choose (D21).
//!
//! The DNG specification's reference rendering runs a raw through the
//! profile's `ProfileToneCurve`, or the ACR3 default for a profile with none.
//! Half of what the curve does is *how* it is applied. The SDK's
//! `RefBaselineRGBTone` runs it on the largest and the smallest channel, and
//! places the middle channel at the fraction between them it had before. Hue
//! is kept; saturation rises wherever the curve is steeper than the
//! diagonal, which for the ACR3 curve is the shadows and the midtones.
//!
//! It runs in linear ProPhoto, as the SDK does, on values clipped to
//! `[0, 1]`; its output is linear and goes to the output transform as the
//! sigmoid's does. The curve is read from the profile buffer
//! (`ops::camera_profile::profile_buffer`), which always carries one.
//!
//! [`apply_reference`] is the arithmetic on the CPU; the GPU test holds the
//! shader to it.
use crate::ops::camera_profile::{mul, working_prophoto};
use crate::view::{DEFAULT_WHITE, REFERENCE_CONTRAST, SCENE_GREY};
/// The input scale for a white point: 1 at the default, so sensor white is
/// display white as in the SDK's reference; each stop of `white` above it halves the
/// input.
pub fn input_scale(white: f32) -> f32 {
(DEFAULT_WHITE - white).exp2()
}
/// The power the input is bent by about middle grey: 1 at
/// [`REFERENCE_CONTRAST`], where the curve is the reference's untouched.
///
/// The default contrast sits above it, so a photograph out of the camera is
/// bent by `DEFAULT_CONTRAST / REFERENCE_CONTRAST` — the extra contrast
/// Lightroom's exports showed over the bare reference curve (D21 addendum).
pub fn contrast_power(contrast: f32) -> f32 {
contrast / REFERENCE_CONTRAST
}
/// The curve, its scale and its contrast applied to one ProPhoto colour.
fn rgb_tone(curve: &[f32], p: [f32; 3]) -> [f32; 3] {
let p = p.map(|v| v.clamp(0.0, 1.0));
let hi = p[0].max(p[1]).max(p[2]);
let lo = p[0].min(p[1]).min(p[2]);
let (c_hi, c_lo) = (
dr_types::tone::evaluate(curve, hi),
dr_types::tone::evaluate(curve, lo),
);
if hi - lo <= 1e-7 {
return [c_hi; 3];
}
p.map(|v| c_lo + (c_hi - c_lo) * (v - lo) / (hi - lo))
}
/// TRACES: FR-DEV-3j
/// The view transform's DNG reference rendering of one working-space colour.
pub fn apply_reference(curve: &[f32], c: [f32; 3], contrast: f32, white: f32) -> [f32; 3] {
let (to, back) = working_prophoto();
let scale = input_scale(white);
let power = contrast_power(contrast);
let mut p = mul(to, c).map(|v| v * scale);
if power != 1.0 {
p = p.map(|v| SCENE_GREY * (v.max(0.0) / SCENE_GREY).powf(power));
}
mul(back, rgb_tone(curve, p))
}
/// The WGSL, a helper the view transform asks for after
/// `ops::camera_profile`'s ProPhoto constants. Mirrors [`apply_reference`].
pub const CAMERA_RAW_WGSL: &str = "
fn camera_raw_curve(x: f32) -> f32 {
let base = profile_curve_base();
let n = u32(profile_table[2].x);
let s = clamp(x, 0.0, 1.0) * f32(n - 1u);
let i = min(u32(s), n - 2u);
return mix(profile_table[base + i].x, profile_table[base + i + 1u].x, s - f32(i));
}
// The SDK's RGBTone: the curve on the largest and smallest channel, the
// middle one kept at its fraction between them, so hue survives.
fn camera_raw_tone(c: vec3<f32>, scale: f32, power: f32, grey: f32) -> vec3<f32> {
var p = PROFILE_FROM_WORKING * c * scale;
if (power != 1.0) {
p = grey * pow(max(p, vec3<f32>(0.0)) / grey, vec3<f32>(power));
}
p = clamp(p, vec3<f32>(0.0), vec3<f32>(1.0));
let hi = max(p.r, max(p.g, p.b));
let lo = min(p.r, min(p.g, p.b));
let c_hi = camera_raw_curve(hi);
let c_lo = camera_raw_curve(lo);
var out = vec3<f32>(c_hi);
if (hi - lo > 1e-7) {
out = vec3<f32>(c_lo) + (c_hi - c_lo) * (p - vec3<f32>(lo)) / (hi - lo);
}
return PROFILE_TO_WORKING * out;
}
";
#[cfg(test)]
mod tests {
use super::*;
use crate::view::DEFAULT_CONTRAST;
use dr_types::tone::{evaluate, ACR3_DEFAULT};
fn identity() -> Vec<f32> {
(0..1025).map(|i| i as f32 / 1024.0).collect()
}
#[test]
fn at_the_reference_the_input_is_untouched() {
assert_eq!(input_scale(DEFAULT_WHITE), 1.0);
assert_eq!(contrast_power(REFERENCE_CONTRAST), 1.0);
}
#[test]
fn the_default_adds_the_measured_contrast() {
// Fitted on Lightroom exports with neutral settings (D21 addendum):
// the bare reference curve is a little flat against them.
let p = contrast_power(DEFAULT_CONTRAST);
assert!((1.05..1.12).contains(&p), "{p}");
}
#[test]
fn grey_goes_through_the_curve_and_stays_grey() {
for v in [0.02, 0.13, 0.5] {
let out = apply_reference(&ACR3_DEFAULT, [v; 3], REFERENCE_CONTRAST, DEFAULT_WHITE);
let want = evaluate(&ACR3_DEFAULT, v);
assert!(
out.iter().all(|o| (o - want).abs() < 1e-4),
"{v}: {out:?} vs {want}"
);
}
}
#[test]
fn an_identity_curve_changes_nothing_inside_the_range() {
let c = [0.4, 0.2, 0.1];
let out = apply_reference(&identity(), c, REFERENCE_CONTRAST, DEFAULT_WHITE);
assert!(
out.iter().zip(c).all(|(o, c)| (o - c).abs() < 1e-4),
"{out:?}"
);
}
#[test]
fn the_middle_channel_keeps_its_place_between_the_other_two() {
let p = [0.3, 0.12, 0.05];
let out = rgb_tone(&ACR3_DEFAULT, p);
let before = (p[1] - p[2]) / (p[0] - p[2]);
let after = (out[1] - out[2]) / (out[0] - out[2]);
assert!((before - after).abs() < 1e-5, "{before} {after}");
}
#[test]
fn the_acr_curve_raises_saturation_in_the_midtones() {
let p = [0.15, 0.08, 0.05];
let out = rgb_tone(&ACR3_DEFAULT, p);
let sat = |c: [f32; 3]| (c[0] - c[2]) / c[0];
assert!(sat(out) > sat(p), "{p:?} -> {out:?}");
}
#[test]
fn white_halves_the_input_per_stop() {
assert_eq!(input_scale(DEFAULT_WHITE + 1.0), 0.5);
assert!(contrast_power(2.8) > 1.0);
}
}
+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.
+13 -4
View File
@@ -464,6 +464,15 @@ impl ParamDescriptor {
} }
} }
/// The same choice with another variant as its default.
///
/// For a choice whose variants were numbered before its default was
/// settled: a sidecar records the index, so reordering the variants to
/// put the default first would change what saved edits mean.
pub fn with_default(self, default: f32) -> Self {
Self { default, ..self }
}
/// A 0…1 fraction — a proportion of something, rather than an amount. /// A 0…1 fraction — a proportion of something, rather than an amount.
/// ///
/// Its own constructor because the crop rect needs four of them and the /// Its own constructor because the crop rect needs four of them and the
@@ -659,10 +668,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);
} }
+57 -6
View File
@@ -260,8 +260,9 @@ impl CropRect {
/// ///
/// `anchor` is the point of the rect that stays put, in the rect's own /// `anchor` is the point of the rect that stays put, in the rect's own
/// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged, /// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged,
/// so the far corner is the one that does not move, and `(0.5, 0.5)` when /// so the far corner is the one that does not move, `(0.0, 0.5)` while
/// a ratio is chosen and the composition should stay where it is. /// the right-hand edge is dragged, and `(0.5, 0.5)` when a ratio is
/// chosen and the composition should stay where it is.
/// ///
/// **The rect grows onto the ratio rather than shrinking onto it.** The /// **The rect grows onto the ratio rather than shrinking onto it.** The
/// axis that is short is extended; the long one is never trimmed. Fitting /// axis that is short is extended; the long one is never trimmed. Fitting
@@ -269,6 +270,7 @@ impl CropRect {
/// along one axis alone would be immediately clamped back by the other, /// along one axis alone would be immediately clamped back by the other,
/// and the handle would simply refuse to move. The result is then scaled /// and the handle would simply refuse to move. The result is then scaled
/// down, both axes together, only as far as the frame's edge demands. /// down, both axes together, only as far as the frame's edge demands.
/// The exception is an edge: see the note in the body.
pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self { pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self {
let rect = self.normalised(); let rect = self.normalised();
let ratio = finite(ratio, 0.0); let ratio = finite(ratio, 0.0);
@@ -286,8 +288,19 @@ impl CropRect {
let px = rect.x + ax * rect.width; let px = rect.x + ax * rect.width;
let py = rect.y + ay * rect.height; let py = rect.y + ay * rect.height;
let mut w = rect.width.max(rect.height * r); // An anchor in the middle of one side is an *edge* being dragged, and
let mut h = w / r; // then the axis across that edge leads: it is the only one the user
// moved. Growing the short axis instead would take the other side
// for the leader whenever the edge went inward, and the edge would be
// pushed straight back out — a handle that only ever grows the crop.
let (mut w, mut h) = if ax == 0.5 && ay != 0.5 {
(rect.height * r, rect.height)
} else if ay == 0.5 && ax != 0.5 {
(rect.width, rect.width / r)
} else {
let w = rect.width.max(rect.height * r);
(w, w / r)
};
// Scaled to fit, never clamped to fit: clamping one axis against the // Scaled to fit, never clamped to fit: clamping one axis against the
// frame would break the very ratio this exists to hold. // frame would break the very ratio this exists to hold.
@@ -1298,7 +1311,8 @@ impl Framing {
// count active stages, and a neutral graph must generate none. // 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 +1328,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);
", ",
@@ -2253,6 +2268,42 @@ mod tests {
assert!((c.y - start.y).abs() < 1e-5, "{c:?}"); assert!((c.y - start.y).abs() < 1e-5, "{c:?}");
} }
#[test]
fn a_locked_edge_leads_and_the_far_side_stays_put() {
// An edge dragged inward under a lock must narrow the crop. With the
// short axis leading, the untouched height would win and push the
// edge straight back out.
let start = CropRect {
x: 0.2,
y: 0.2,
width: 0.4,
height: 0.6,
};
// Right edge held, dragged in: the left side and the vertical
// centre stay, the width is what was asked for.
let c = start.with_aspect(4000, 4000, 1.0, (0.0, 0.5));
assert!((c.x - start.x).abs() < 1e-5, "{c:?}");
assert!((c.width - start.width).abs() < 1e-5, "{c:?}");
assert!((c.height - start.width).abs() < 1e-5, "{c:?}");
assert!(
(c.y + c.height / 2.0 - (start.y + start.height / 2.0)).abs() < 1e-5,
"{c:?}"
);
// Top edge held: the bottom and the horizontal centre stay, the
// height is what was asked for.
let c = start.with_aspect(4000, 4000, 1.0, (0.5, 1.0));
assert!(
(c.y + c.height - (start.y + start.height)).abs() < 1e-5,
"{c:?}"
);
assert!((c.width - c.height).abs() < 1e-5, "{c:?}");
assert!(
(c.x + c.width / 2.0 - (start.x + start.width / 2.0)).abs() < 1e-5,
"{c:?}"
);
}
#[test] #[test]
fn a_locked_rect_grows_onto_the_ratio_rather_than_shrinking_onto_it() { fn a_locked_rect_grows_onto_the_ratio_rather_than_shrinking_onto_it() {
// Shrinking to fit makes a one-axis drag do nothing at all: the other // Shrinking to fit makes a one-axis drag do nothing at all: the other
+212 -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>>,
@@ -163,6 +170,18 @@ pub struct EditGraph {
/// correction the photograph asked for — see /// correction the photograph asked for — see
/// [`crate::descriptor::ParamDescriptor::switch_on`]. /// [`crate::descriptor::ParamDescriptor::switch_on`].
lens_profile_applied: bool, lens_profile_applied: bool,
/// TRACES: FR-DEV-3g
/// Whether this photograph can take the learned denoise — a Bayer
/// mosaic — set by whoever opened it. Derived from the file like the
/// lens profile, so not in the state; it only decides whether the
/// switch below is offered.
denoise_available: bool,
/// Whether the learned denoise replaces the demosaic. An edit: published
/// as [`crate::learned_denoise`], captured, stored and undone with the
/// rest (FR-DEV-3c).
denoise_applied: bool,
/// How much of the removed noise's brightness to put back, 0–100.
denoise_grain: f32,
} }
/// TRACES: FR-DEV-3f /// TRACES: FR-DEV-3f
@@ -219,6 +238,9 @@ impl EditGraph {
], ],
lens_profile: None, lens_profile: None,
lens_profile_applied: true, lens_profile_applied: true,
denoise_available: false,
denoise_applied: false,
denoise_grain: 0.0,
} }
} }
@@ -292,6 +314,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
@@ -341,6 +414,26 @@ impl EditGraph {
self.lens_profile.as_ref() self.lens_profile.as_ref()
} }
/// TRACES: FR-DEV-3g
/// Offer the learned denoise, or not: true for a Bayer mosaic.
pub fn set_denoise_available(&mut self, available: bool) {
self.denoise_available = available;
}
/// TRACES: FR-DEV-3g
/// Whether the learned denoise is asked for. A setting kept on a
/// photograph that cannot take it is harmless and does nothing, as a
/// lens switch with no profile does.
pub fn denoise_applied(&self) -> bool {
self.denoise_applied
}
/// TRACES: FR-DEV-3g
/// The grain to keep, 0–1.
pub fn denoise_grain(&self) -> f32 {
self.denoise_grain / 100.0
}
/// TRACES: FR-DEV-3 /// TRACES: FR-DEV-3
/// Whether the matched profile is being applied. /// Whether the matched profile is being applied.
pub fn lens_profile_applied(&self) -> bool { pub fn lens_profile_applied(&self) -> bool {
@@ -523,9 +616,37 @@ impl EditGraph {
} }
}); });
// TRACES: FR-DEV-3g
// Offered only where the photograph can take it, for the lens
// switch's reason: a control that can do nothing must not look as if
// it could.
let denoise = self.denoise_available.then(|| {
let desc = crate::learned_denoise::descriptor();
OpCapability {
id: desc.id,
label: desc.label,
active: self.denoise_applied,
params: desc
.params
.iter()
.map(|p| ParamCapability {
id: p.id,
label: p.label,
kind: p.kind.clone(),
default: p.default,
value: self.param(desc.id, p.id).unwrap_or(p.default),
facet: p.facet,
})
.collect(),
presentation: None,
attributes: desc.attributes.clone(),
}
});
switch switch
.into_iter() .into_iter()
.chain(warps) .chain(warps)
.chain(denoise)
.chain(ops) .chain(ops)
.chain(std::iter::once(framing)) .chain(std::iter::once(framing))
.collect() .collect()
@@ -617,6 +738,11 @@ impl EditGraph {
// `capabilities`, with the operations and the warps and for the // `capabilities`, with the operations and the warps and for the
// same reason (FR-DEV-3c). // same reason (FR-DEV-3c).
lens_profile_applied: _, lens_profile_applied: _,
// Derived from the file, like the profile above.
denoise_available: _,
// Edits, in the state through `capabilities` like the lens switch.
denoise_applied: _,
denoise_grain: _,
masks, masks,
film, film,
spots, spots,
@@ -688,6 +814,16 @@ impl EditGraph {
} }
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) { pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
if op == crate::learned_denoise::ID {
match param {
p if p == crate::learned_denoise::APPLY => self.denoise_applied = value != 0.0,
p if p == crate::learned_denoise::GRAIN => {
self.denoise_grain = value.clamp(0.0, 100.0)
}
_ => log::warn!("unknown parameter {param} on {op}; ignoring"),
}
return;
}
if op == crate::lens::profile_switch::ID { if op == crate::lens::profile_switch::ID {
if param != crate::lens::profile_switch::APPLY { if param != crate::lens::profile_switch::APPLY {
log::warn!("unknown parameter {param} on {op}; ignoring"); log::warn!("unknown parameter {param} on {op}; ignoring");
@@ -745,6 +881,15 @@ impl EditGraph {
/// Read a parameter back. /// Read a parameter back.
pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> { pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> {
if op == crate::learned_denoise::ID {
return match param {
p if p == crate::learned_denoise::APPLY => {
Some(if self.denoise_applied { 1.0 } else { 0.0 })
}
p if p == crate::learned_denoise::GRAIN => Some(self.denoise_grain),
_ => None,
};
}
if op == crate::lens::profile_switch::ID { if op == crate::lens::profile_switch::ID {
return (param == crate::lens::profile_switch::APPLY) return (param == crate::lens::profile_switch::APPLY)
.then_some(if self.lens_profile_applied { 1.0 } else { 0.0 }); .then_some(if self.lens_profile_applied { 1.0 } else { 0.0 });
@@ -791,6 +936,10 @@ impl EditGraph {
// a reset does not change which lens took the photograph. What returns // a reset does not change which lens took the photograph. What returns
// to default is the answer to whether to use it, which is on. // to default is the answer to whether to use it, which is on.
self.set_lens_profile_applied(true); self.set_lens_profile_applied(true);
// The learned denoise returns to off; whether it is available is the
// file's and stays.
self.denoise_applied = false;
self.denoise_grain = 0.0;
} }
/// Set the crop rectangle. Clamped to keep it inside the frame. /// Set the crop rectangle. Clamped to keep it inside the frame.
@@ -952,46 +1101,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 +1260,23 @@ 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.
//
// Two blocks, the view transform and the camera profile: both are
// composed at their defaults, because a photograph with no view
// transform is a scan rather than a picture (FR-DEV-3j) and a raw
// with a profile is rendered through it (D20). They are 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, 2,
"a neutral graph must generate no operation blocks" "a neutral graph must generate no adjustment blocks"
); );
assert!(source.contains("---- camera_profile ----"));
assert!(source.contains("---- view_transform ----"));
} }
#[test] #[test]
@@ -1207,13 +1355,16 @@ 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 and camera profile, which every render has
// (FR-DEV-3j, D20).
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(), 4);
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 ----"));
@@ -1935,4 +2086,40 @@ mod tests {
let after = cropped.render_scale(source, (1500, 1000)); let after = cropped.render_scale(source, (1500, 1000));
assert!(after.ratio() > fit.ratio()); assert!(after.ratio() > fit.ratio());
} }
#[test]
fn the_learned_denoise_is_offered_only_where_it_can_run() {
use crate::learned_denoise;
let mut g = EditGraph::default_chain();
assert!(!g.capabilities().iter().any(|c| c.id == learned_denoise::ID));
g.set_denoise_available(true);
let cap = g
.capabilities()
.into_iter()
.find(|c| c.id == learned_denoise::ID)
.expect("offered");
assert!(!cap.active, "off until asked for");
g.set_param(learned_denoise::ID, learned_denoise::APPLY, 1.0);
g.set_param(learned_denoise::ID, learned_denoise::GRAIN, 30.0);
assert!(g.denoise_applied());
assert!((g.denoise_grain() - 0.3).abs() < 1e-6);
g.reset();
assert!(!g.denoise_applied());
assert_eq!(g.denoise_grain(), 0.0);
}
#[test]
fn the_learned_denoise_travels_in_the_state() {
use crate::learned_denoise;
let mut g = EditGraph::default_chain();
g.set_denoise_available(true);
g.set_param(learned_denoise::ID, learned_denoise::APPLY, 1.0);
g.set_param(learned_denoise::ID, learned_denoise::GRAIN, 40.0);
let state = g.state();
let mut h = EditGraph::default_chain();
h.set_denoise_available(true);
let _ = h.set_state(&state);
assert!(h.denoise_applied());
assert!((h.denoise_grain() - 0.4).abs() < 1e-6);
}
} }
+3 -1
View File
@@ -581,7 +581,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 8], lut: vec![[0.5, 0.5, 0.5]; 8],
density_max: 2.0, density_max: 2.0,
lut_size: 2, lut_size: 2,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [2.0; 3], grain_density_max: [2.0; 3],
grain_uniformity: 1.0, grain_uniformity: 1.0,
}, },
+51
View File
@@ -0,0 +1,51 @@
//! TRACES: FR-DEV-3g
//! The learned denoise's settings: whether to use it, and how much grain to
//! keep.
//!
//! Not an [`crate::operation::Operation`]: the learned stage replaces the
//! demosaic and runs once per photograph, off the render path
//! (docs/dev/denoise.md §2, §7), and the grain is a blend of its result with
//! the classical one, done where the source is chosen. But what a
//! photographer sets travels the one road every setting travels — the
//! capability list feeds the panel, [`crate::Preset`] captures it, the
//! sidecar stores it, the undo stack replays it (FR-DEV-3c) — so it is
//! published as a capability, like the lens profile switch.
use std::sync::{Arc, LazyLock};
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, ParamDescriptor, Scale, Unit};
use crate::{OpId, ParamId};
pub const ID: OpId = OpId("learned_denoise");
pub const APPLY: ParamId = ParamId("apply");
pub const GRAIN: ParamId = ParamId("grain");
/// Off by default: it costs seconds per photograph and replaces the
/// demosaic, which is the photographer's call. Grain 0 is the network's
/// result as it is.
pub(crate) static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: ID,
label: LocalizedKey("op.learned_denoise"),
params: vec![
ParamDescriptor::switch("apply", "param.learned_denoise.apply"),
ParamDescriptor::scalar(
"grain",
"param.learned_denoise.grain",
0.0,
100.0,
0.0,
Unit::Percent,
Scale::Linear,
0,
),
],
// With the classical noise reduction, which is what a photographer
// looks for it beside.
attributes: vec![Attribute::Detail],
})
});
pub fn descriptor() -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
+39 -5
View File
@@ -33,6 +33,7 @@
//! data neither would be physically meaningful (ARCH §5.2). //! data neither would be physically meaningful (ARCH §5.2).
pub mod bundled; pub mod bundled;
pub mod camera_raw;
pub mod coverage; pub mod coverage;
pub mod declared; pub mod declared;
pub mod descriptor; pub mod descriptor;
@@ -40,6 +41,7 @@ pub mod detail;
pub mod framing; pub mod framing;
pub mod graph; pub mod graph;
pub mod history; pub mod history;
pub mod learned_denoise;
pub mod lens; pub mod lens;
pub mod mask; pub mod mask;
pub mod neutral; pub mod neutral;
@@ -50,6 +52,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 +70,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};
@@ -113,6 +117,16 @@ mod tests {
} }
} }
// Flipping every switch turns the camera profile *off*, which is
// active — moved from the default — and composes nothing. Put it
// back on; its look strength stays moved, so it is still active and
// now doing something, which is what "fully active" means here (D20).
g.set_param(
crate::ops::camera_profile::ID,
crate::ops::camera_profile::APPLY,
1.0,
);
// `film_sim` is the one node a moved parameter cannot activate: it // `film_sim` is the one node a moved parameter cannot activate: it
// needs a stock's measured tables, which are not parameters and which // needs a stock's measured tables, which are not parameters and which
// no slider produces. So it is loaded explicitly here. // no slider produces. So it is loaded explicitly here.
@@ -132,7 +146,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0, density_max: 3.0,
lut_size: 32, lut_size: 32,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; crate::ops::film_sim::FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [3.0; 3], grain_density_max: [3.0; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
}, },
@@ -167,7 +183,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()
@@ -180,8 +210,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"
); );
+121 -12
View File
@@ -1656,7 +1656,7 @@ impl MaskLayer {
self.ops self.ops
.iter() .iter()
.map(|o| o.as_ref()) .map(|o| o.as_ref())
.filter(|o| o.is_active() && o.detail().is_none()) .filter(|o| moves(*o) && o.detail().is_none())
} }
/// Whether any part of this mask belongs to a different segmentation. /// Whether any part of this mask belongs to a different segmentation.
@@ -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,
@@ -2087,9 +2087,30 @@ pub(crate) struct LocalOp {
/// Empty when the offsets cancel the global setting back to neutral. That /// Empty when the offsets cancel the global setting back to neutral. That
/// is still an entry, because it still means something: inside the mask /// is still an entry, because it still means something: inside the mask
/// this operation does nothing at all. /// this operation does nothing at all.
///
/// Empty, too, for an operation blended as settings: that entry stands
/// for this layer's uniforms, `mask{slot}_{op}_*`, which the composer
/// averages into the one fragment it runs.
pub fragment: String, pub fragment: String,
} }
/// Whether a layer's copy of `op` holds an adjustment.
///
/// An operation's own answer, except for one blended as settings
/// ([`Operation::blends_settings`]): that one is active by what it *holds* —
/// a film is active when a stock is loaded — and a layer never holds a stock,
/// only offsets to the photograph's. So a layer's film counts as moved when
/// its sliders are, which is the question being asked.
fn moves(op: &dyn Operation) -> bool {
op.is_active()
|| (op.blends_settings()
&& op
.descriptor()
.params
.iter()
.any(|p| op.param(p.id) != p.default))
}
/// A layer's settings for one operation, applied as offsets to the global /// A layer's settings for one operation, applied as offsets to the global
/// operation's. /// operation's.
/// ///
@@ -2215,7 +2236,7 @@ pub(crate) fn compose_layers_revealing(
let mut combined = layer_chain(); let mut combined = layer_chain();
for (dst, local) in combined.iter_mut().zip(&layer.ops) { for (dst, local) in combined.iter_mut().zip(&layer.ops) {
let local = local.as_ref(); let local = local.as_ref();
if !local.is_active() || local.detail().is_some() { if !moves(local) || local.detail().is_some() {
continue; continue;
} }
let id = local.descriptor().id.0; let id = local.descriptor().id.0;
@@ -2223,9 +2244,22 @@ pub(crate) fn compose_layers_revealing(
.iter() .iter()
.map(|o| o.as_ref()) .map(|o| o.as_ref())
.find(|o| o.descriptor().id.0 == id); .find(|o| o.descriptor().id.0 == id);
// The photograph's stock, lent to the layer's copy: a layer holds
// offsets to a film, never one of its own.
if let Some(g) = g {
dst.set_film_tables(g.film_tables());
}
offset_onto(dst.as_mut(), local, g); offset_onto(dst.as_mut(), local, g);
if !dst.is_active() { if dst.blends_settings() {
// Its uniforms, and no fragment: the composer blends this
// layer's settings with the others' and runs the global
// fragment once. With no stock loaded there is nothing to
// blend, and the global side skips the operation too.
if !dst.is_active() {
continue;
}
} else if !dst.is_active() {
out.ops.push(LocalOp { out.ops.push(LocalOp {
op: id, op: id,
slot, slot,
@@ -2250,13 +2284,16 @@ pub(crate) fn compose_layers_revealing(
} }
} }
let mut fragment = dst.wgsl_body(); let mut fragment = String::new();
for u in &op_uniforms { if !dst.blends_settings() {
fragment = crate::operation::rewrite_uniform( fragment = dst.wgsl_body();
&fragment, for u in &op_uniforms {
u.name, fragment = crate::operation::rewrite_uniform(
&format!("u.{op_prefix}_{}", u.name), &fragment,
); u.name,
&format!("u.{op_prefix}_{}", u.name),
);
}
} }
out.ops.push(LocalOp { out.ops.push(LocalOp {
op: id, op: id,
@@ -2387,6 +2424,78 @@ mod tests {
layer layer
} }
/// A stock the shader can index, with values that are not a real one's.
fn film_fixture() -> crate::graph::Film {
use crate::ops::film_sim::{CURVE_SAMPLES, FORMAT_COUNT};
crate::graph::Film {
stock: "fixture".into(),
print: None,
tables: crate::ops::FilmTables {
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
curves: vec![[0.5; 3]; CURVE_SAMPLES],
push_stations: vec![0.0],
curve_log_min: -3.0,
curve_log_max: 1.0,
lut: vec![[0.5; 3]; 8],
density_max: 2.0,
lut_size: 2,
paper: None,
grain_particles: [[0.0; 3]; FORMAT_COUNT],
grain_density_max: [2.0; 3],
grain_uniformity: 1.0,
},
}
}
#[test]
fn a_layers_film_is_blended_as_settings_and_developed_once() {
// TRACES: FR-DEV-3f
// The film is a rendering: a layer's version of it run beside the
// global one and cross-faded would be the photograph developed twice.
// So the layer's uniforms are averaged into the global ones by weight
// and the fragment appears once.
use crate::ops::film_sim;
let mut graph = crate::EditGraph::default_chain();
graph.set_film(Some(film_fixture()));
let mut layer = MaskLayer::new("m1", regions(&[1]));
layer.set_param(film_sim::ID.0, film_sim::PRINT_EXPOSURE, 1.0);
assert!(layer.is_active(), "a film offset is an adjustment");
graph.masks_mut().push(layer);
let source = graph.compose().source;
assert!(source.contains("let set_w = mask_w0;"), "{source}");
assert!(source.contains("let set_g = max(1.0 - set_w, 0.0);"));
assert!(
source.contains("u.mask0_film_sim_pev"),
"the layer's setting is not read"
);
assert_eq!(
source.matches("let density = film_curve_pushed(").count(),
1,
"the film was developed more than once"
);
assert!(
!source.contains("let local_in"),
"the film was blended as a result"
);
}
#[test]
fn a_layers_film_without_a_stock_composes_to_nothing() {
// TRACES: FR-DEV-3f
// The offsets are kept — a stock chosen later brings them back — but
// with no film on the photograph there is nothing for them to offset.
use crate::ops::film_sim;
let mut graph = crate::EditGraph::default_chain();
let mut layer = MaskLayer::new("m1", regions(&[1]));
layer.set_param(film_sim::ID.0, film_sim::PUSH, 1.0);
graph.masks_mut().push(layer);
let source = graph.compose().source;
assert!(!source.contains("---- film_sim ----"), "{source}");
assert!(!source.contains("mask0_film_sim"));
}
#[test] #[test]
fn a_layer_with_no_adjustment_is_not_in_the_shader() { fn a_layer_with_no_adjustment_is_not_in_the_shader() {
let layer = MaskLayer::new("m1", regions(&[1])); let layer = MaskLayer::new("m1", regions(&[1]));
+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
+723
View File
@@ -0,0 +1,723 @@
//! TRACES: FR-DEV-3e
//! The camera profile's tables as an operation (D20).
//!
//! The matrix turns camera RGB into colour; a DNG camera profile adds two
//! lookups over hue, saturation and value on top of it — the `HueSatMap`, a
//! calibration, and the `LookTable`, a rendering intent. This operation
//! applies them. `docs/dev/camera-profiles.md` is the design.
//!
//! # Where the tables come from
//!
//! Not from here. They belong to the *source*, like the matrix: `dr-decode`
//! resolves them per file and `dr-gpu` uploads them to the storage buffer
//! every generated shader declares at `@binding(8)`, laid out by
//! [`profile_buffer`]. This operation holds only the photographer's two
//! settings — whether to use the profile, and how strongly to apply its look
//! — so a render path never has to remember to hand it anything.
//!
//! # Why it is composed at its defaults
//!
//! A profile that is on is the rendering, not an edit: an untouched raw
//! renders through it and writes no parameters. So [`Operation::composes`]
//! answers "is the switch on", not "has anything moved". The fragment then
//! branches on the buffer's header, which says whether this source has tables
//! at all; a JPEG, or a raw with no profile, reads two zeros and passes
//! through.
//!
//! # The lookup
//!
//! The DNG SDK's `RefBaselineHueSatMap`, with the two departures §2 of the
//! design gives for scene-referred values: value is not clamped on the way
//! out, and a colour with a negative ProPhoto component passes through.
//! [`apply_reference`] is the same arithmetic on the CPU, and the GPU tests
//! hold the shader to it.
use std::sync::{Arc, LazyLock};
use dr_types::{HueSatTable, ProfileTables};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::operation::{Helper, Operation, Uniform};
pub const ID: OpId = OpId("camera_profile");
pub const APPLY: ParamId = ParamId("apply");
pub const LOOK: ParamId = ParamId("look");
/// The look's strength at which the LookTable is applied as the profile
/// states it, in percent.
pub const DEFAULT_LOOK: f32 = 100.0;
/// Twice the profile's look.
pub const MAX_LOOK: f32 = 200.0;
/// Entries of the buffer's header, before the entries themselves: one
/// `vec4` describing each table — `(hue divisions, saturation divisions,
/// value divisions, sRGB-encoded)`, zero hue divisions meaning absent — and
/// a third whose `.x` is the tone curve's length (camera-profiles.md §12).
pub const HEADER_ENTRIES: usize = 3;
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
attributes: vec![Attribute::Colour],
id: ID,
label: LocalizedKey("op.camera_profile"),
params: vec![
ParamDescriptor::switch_on("apply", "param.camera_profile.apply"),
ParamDescriptor::scalar(
"look",
"param.camera_profile.look",
0.0,
MAX_LOOK,
DEFAULT_LOOK,
Unit::Percent,
Scale::Linear,
0,
),
],
})
});
/// Linear sRGB (the working space) to linear ProPhoto, and back, row-major,
/// each row scaled to sum to one so that working white is ProPhoto white
/// exactly and a neutral reaches the tables with zero saturation.
pub(crate) fn working_prophoto() -> &'static ([f32; 9], [f32; 9]) {
static M: LazyLock<([f32; 9], [f32; 9])> = LazyLock::new(|| {
let to = normalise_rows(dr_types::ColourSpace::ProPhoto.from_linear_srgb());
let back = normalise_rows(invert(&to).expect("ProPhoto's matrix is invertible"));
(to, back)
});
&M
}
fn normalise_rows(mut m: [f32; 9]) -> [f32; 9] {
for row in m.chunks_exact_mut(3) {
let sum: f32 = row.iter().sum();
row.iter_mut().for_each(|v| *v /= sum);
}
m
}
fn invert(m: &[f32; 9]) -> Option<[f32; 9]> {
let [a, b, c, d, e, f, g, h, i] = m.map(f64::from);
let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
if det.abs() < 1e-12 {
return None;
}
let inv = [
(e * i - f * h) / det,
(c * h - b * i) / det,
(b * f - c * e) / det,
(f * g - d * i) / det,
(a * i - c * g) / det,
(c * d - a * f) / det,
(d * h - e * g) / det,
(b * g - a * h) / det,
(a * e - b * d) / det,
];
Some(inv.map(|v| v as f32))
}
pub(crate) fn mul(m: &[f32; 9], c: [f32; 3]) -> [f32; 3] {
std::array::from_fn(|r| m[r * 3] * c[0] + m[r * 3 + 1] * c[1] + m[r * 3 + 2] * c[2])
}
/// A row-major matrix as a WGSL `mat3x3`, whose constructor takes columns.
fn wgsl_mat(m: &[f32; 9]) -> String {
let col = |j: usize| format!("vec3<f32>({:e}, {:e}, {:e})", m[j], m[3 + j], m[6 + j]);
format!("mat3x3<f32>({}, {}, {})", col(0), col(1), col(2))
}
/// The working space to ProPhoto and back, as WGSL constants, and where
/// the profile buffer's sections begin. A helper of its own because the
/// view transform's DNG reference curve needs it too, and helpers are emitted
/// once each, in the order first asked for.
pub(crate) static PROPHOTO_HELPER: LazyLock<Helper> = LazyLock::new(|| {
let (to, back) = working_prophoto();
let source = format!(
"const PROFILE_FROM_WORKING = {};\nconst PROFILE_TO_WORKING = {};\n{SECTIONS_WGSL}",
wgsl_mat(to),
wgsl_mat(back)
);
Helper {
name: "profile_curve_base",
source: Box::leak(source.into_boxed_str()),
}
});
static HELPERS: LazyLock<[Helper; 2]> = LazyLock::new(|| {
[
*PROPHOTO_HELPER,
Helper {
name: "profile_apply",
source: LOOKUP_WGSL,
},
]
});
/// Where each section of the profile buffer starts, from its header.
const SECTIONS_WGSL: &str = "
fn profile_entries(dims: vec4<f32>) -> u32 {
return u32(dims.x * dims.y * dims.z);
}
fn profile_look_base() -> u32 {
return 3u + profile_entries(profile_table[0]);
}
fn profile_curve_base() -> u32 {
return profile_look_base() + profile_entries(profile_table[1]);
}
";
/// The lookup, in WGSL. Mirrors [`apply_reference`] line for line.
const LOOKUP_WGSL: &str = r#"
fn profile_srgb_encode(v: f32) -> f32 {
if (v <= 0.0031308) { return v * 12.92; }
return 1.055 * pow(v, 1.0 / 2.4) - 0.055;
}
fn profile_srgb_decode(v: f32) -> f32 {
if (v <= 0.04045) { return v / 12.92; }
return pow((v + 0.055) / 1.055, 2.4);
}
// The DNG SDK's HSV: hue in [0, 6), saturation (max - min) / max, value max.
fn profile_rgb_to_hsv(c: vec3<f32>) -> vec3<f32> {
let v = max(c.r, max(c.g, c.b));
let gap = v - min(c.r, min(c.g, c.b));
if (gap <= 0.0) {
return vec3<f32>(0.0, 0.0, v);
}
var h: f32;
if (c.r == v) {
h = (c.g - c.b) / gap;
if (h < 0.0) { h += 6.0; }
} else if (c.g == v) {
h = 2.0 + (c.b - c.r) / gap;
} else {
h = 4.0 + (c.r - c.g) / gap;
}
return vec3<f32>(h, gap / v, v);
}
fn profile_hsv_to_rgb(hsv: vec3<f32>) -> vec3<f32> {
let s = hsv.y;
let v = hsv.z;
if (s <= 0.0) {
return vec3<f32>(v);
}
let h = hsv.x - 6.0 * floor(hsv.x / 6.0);
let i = min(floor(h), 5.0);
let f = h - i;
let p = v * (1.0 - s);
let q = v * (1.0 - s * f);
let t = v * (1.0 - s * (1.0 - f));
switch (i32(i)) {
case 0: { return vec3<f32>(v, t, p); }
case 1: { return vec3<f32>(q, v, p); }
case 2: { return vec3<f32>(p, v, t); }
case 3: { return vec3<f32>(p, q, v); }
case 4: { return vec3<f32>(t, p, v); }
default: { return vec3<f32>(v, p, q); }
}
}
fn profile_entry(base: u32, at: u32) -> vec3<f32> {
return profile_table[base + at].xyz;
}
// (hue shift in degrees, saturation scale, value scale) at `hsv`: bilinear
// over hue and saturation, hue wrapping, and linear over value for a 3-D
// table. Indices are the SDK's.
fn profile_lookup(dims: vec4<f32>, base: u32, hsv: vec3<f32>) -> vec3<f32> {
let hd = u32(dims.x);
let sd = u32(dims.y);
let vd = u32(dims.z);
var h0 = 0u;
var h1 = 0u;
var hf = 0.0;
if (hd > 1u) {
let hs = hsv.x * f32(hd) / 6.0;
h0 = min(u32(hs), hd - 1u);
hf = hs - f32(h0);
h1 = h0 + 1u;
if (h1 >= hd) { h1 = 0u; }
}
let ss = hsv.y * f32(sd - 1u);
let s0 = min(u32(ss), sd - 2u);
let sf = ss - f32(s0);
var v0 = 0u;
var vf = 0.0;
if (vd > 1u) {
var ve = clamp(hsv.z, 0.0, 1.0);
if (dims.w > 0.5) { ve = profile_srgb_encode(ve); }
let vs = ve * f32(vd - 1u);
v0 = min(u32(vs), vd - 2u);
vf = vs - f32(v0);
}
let val_step = hd * sd;
let lo = v0 * val_step;
var d = mix(
mix(profile_entry(base, lo + h0 * sd + s0), profile_entry(base, lo + h1 * sd + s0), hf),
mix(profile_entry(base, lo + h0 * sd + s0 + 1u), profile_entry(base, lo + h1 * sd + s0 + 1u), hf),
sf);
if (vd > 1u) {
let hi = lo + val_step;
let e = mix(
mix(profile_entry(base, hi + h0 * sd + s0), profile_entry(base, hi + h1 * sd + s0), hf),
mix(profile_entry(base, hi + h0 * sd + s0 + 1u), profile_entry(base, hi + h1 * sd + s0 + 1u), hf),
sf);
d = mix(d, e, vf);
}
return d;
}
// One table applied to a ProPhoto colour, its deltas scaled by `amount`.
fn profile_apply(dims: vec4<f32>, base: u32, c: vec3<f32>, amount: f32) -> vec3<f32> {
let hsv = profile_rgb_to_hsv(c);
var d = profile_lookup(dims, base, hsv);
d = vec3<f32>(d.x * amount, max(1.0 + (d.y - 1.0) * amount, 0.0), max(1.0 + (d.z - 1.0) * amount, 0.0));
let h = hsv.x + d.x * (6.0 / 360.0);
let s = min(hsv.y * d.y, 1.0);
var v = hsv.z * d.z;
if (dims.w > 0.5) {
// The scale is defined on the encoded value; applied as the ratio it
// makes at min(v, 1), so a value above 1.0 is scaled, not clipped.
let vc = min(hsv.z, 1.0);
v = hsv.z;
if (vc > 0.0) {
v = hsv.z * profile_srgb_decode(profile_srgb_encode(vc) * d.z) / vc;
}
}
return profile_hsv_to_rgb(vec3<f32>(h, s, v));
}
"#;
#[derive(Debug, Clone)]
pub struct CameraProfile {
apply: bool,
look: f32,
}
impl Default for CameraProfile {
fn default() -> Self {
Self {
apply: true,
look: DEFAULT_LOOK,
}
}
}
impl CameraProfile {
pub fn new() -> Self {
Self::default()
}
}
impl Operation for CameraProfile {
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
APPLY => self.apply = value != 0.0,
LOOK => self.look = value,
_ => log::warn!("camera_profile: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
APPLY => f32::from(u8::from(self.apply)),
LOOK => self.look,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
!self.apply || self.look != DEFAULT_LOOK
}
fn composes(&self) -> bool {
self.apply
}
fn wgsl_body(&self) -> String {
"\
let hue_sat_dims = profile_table[0];
let look_dims = profile_table[1];
if (hue_sat_dims.x > 0.0 || look_dims.x > 0.0) {
var p = PROFILE_FROM_WORKING * c;
// A colour outside ProPhoto has no HSV the tables were made for; it
// passes through rather than being floored, which would clip it (D19).
if (min(p.r, min(p.g, p.b)) >= 0.0) {
if (hue_sat_dims.x > 0.0) {
p = profile_apply(hue_sat_dims, 3u, p, 1.0);
}
if (look_dims.x > 0.0 && look > 0.0) {
p = profile_apply(look_dims, profile_look_base(), p, look);
}
c = PROFILE_TO_WORKING * p;
}
}"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![Uniform {
name: "look",
value: self.look / 100.0,
}]
}
fn helpers(&self) -> &[Helper] {
HELPERS.as_slice()
}
}
/// TRACES: FR-DEV-3e | FR-DEV-3j
/// The storage buffer a source's profile is uploaded as: the three header
/// `vec4`s, the HueSatMap's entries, the LookTable's, each entry
/// `(hue shift, saturation scale, value scale, 0)`, then the tone curve's
/// samples in `.x`.
///
/// The curve is always there: the profile's own where it has one, Camera
/// Raw's ACR3 default otherwise — including in the placeholder every source
/// without a profile binds, whose tables are absent, so a raw with no
/// profile still has the reference tone curve when it is chosen (D21).
pub fn profile_buffer(tables: Option<&ProfileTables>) -> Vec<[f32; 4]> {
let header = |t: Option<&HueSatTable>| match t {
Some(t) => [
t.hue_divisions as f32,
t.sat_divisions as f32,
t.val_divisions as f32,
if t.srgb_encoded { 1.0 } else { 0.0 },
],
None => [0.0; 4],
};
let hue_sat = tables.and_then(|t| t.hue_sat.as_ref());
let look = tables.and_then(|t| t.look.as_ref());
let curve: &[f32] = tables
.and_then(|t| t.tone_curve.as_deref())
.unwrap_or(&dr_types::tone::ACR3_DEFAULT);
let mut out = vec![
header(hue_sat),
header(look),
[curve.len() as f32, 0.0, 0.0, 0.0],
];
for t in [hue_sat, look].into_iter().flatten() {
out.extend(t.entries.iter().map(|e| [e[0], e[1], e[2], 0.0]));
}
out.extend(curve.iter().map(|&v| [v, 0.0, 0.0, 0.0]));
out
}
/// TRACES: FR-DEV-3e
/// The fragment's arithmetic on the CPU: a working-space colour through the
/// source's tables, the look at `look` (1.0 = as the profile states it).
///
/// The reference the shader is tested against, and the statement of the
/// algorithm a reader can step through.
pub fn apply_reference(tables: &ProfileTables, c: [f32; 3], look: f32) -> [f32; 3] {
let (to, back) = working_prophoto();
let mut p = mul(to, c);
if p.iter().any(|v| *v < 0.0) {
return c;
}
if let Some(t) = &tables.hue_sat {
p = apply_table(t, p, 1.0);
}
if let Some(t) = tables.look.as_ref().filter(|_| look > 0.0) {
p = apply_table(t, p, look);
}
mul(back, p)
}
fn srgb_encode(v: f32) -> f32 {
if v <= 0.003_130_8 {
v * 12.92
} else {
1.055 * v.powf(1.0 / 2.4) - 0.055
}
}
fn srgb_decode(v: f32) -> f32 {
if v <= 0.040_45 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
/// The SDK's `DNG_RGBtoHSV`: hue in `[0, 6)`.
pub fn rgb_to_hsv([r, g, b]: [f32; 3]) -> [f32; 3] {
let v = r.max(g).max(b);
let gap = v - r.min(g).min(b);
if gap <= 0.0 {
return [0.0, 0.0, v];
}
let h = if r == v {
let h = (g - b) / gap;
if h < 0.0 {
h + 6.0
} else {
h
}
} else if g == v {
2.0 + (b - r) / gap
} else {
4.0 + (r - g) / gap
};
[h, gap / v, v]
}
pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
if s <= 0.0 {
return [v; 3];
}
let h = h - 6.0 * (h / 6.0).floor();
let i = h.floor().min(5.0);
let f = h - i;
let p = v * (1.0 - s);
let q = v * (1.0 - s * f);
let t = v * (1.0 - s * (1.0 - f));
match i as i32 {
0 => [v, t, p],
1 => [q, v, p],
2 => [p, v, t],
3 => [p, q, v],
4 => [t, p, v],
_ => [v, p, q],
}
}
fn lookup(t: &HueSatTable, [h, s, v]: [f32; 3]) -> [f32; 3] {
let (hd, sd, vd) = (t.hue_divisions, t.sat_divisions, t.val_divisions);
let (mut h0, mut h1, mut hf) = (0u32, 0u32, 0.0f32);
if hd > 1 {
let hs = h * hd as f32 / 6.0;
h0 = (hs as u32).min(hd - 1);
hf = hs - h0 as f32;
h1 = if h0 + 1 >= hd { 0 } else { h0 + 1 };
}
let ss = s * (sd - 1) as f32;
let s0 = (ss as u32).min(sd - 2);
let sf = ss - s0 as f32;
let (mut v0, mut vf) = (0u32, 0.0f32);
if vd > 1 {
let mut ve = v.clamp(0.0, 1.0);
if t.srgb_encoded {
ve = srgb_encode(ve);
}
let vs = ve * (vd - 1) as f32;
v0 = (vs as u32).min(vd - 2);
vf = vs - v0 as f32;
}
let mix = |a: [f32; 3], b: [f32; 3], w: f32| -> [f32; 3] {
std::array::from_fn(|i| a[i] + (b[i] - a[i]) * w)
};
let at = |v: u32, h: u32, s: u32| t.entries[t.index(h, s, v)];
let plane = |v: u32| {
mix(
mix(at(v, h0, s0), at(v, h1, s0), hf),
mix(at(v, h0, s0 + 1), at(v, h1, s0 + 1), hf),
sf,
)
};
let d = plane(v0);
if vd > 1 {
mix(d, plane(v0 + 1), vf)
} else {
d
}
}
fn apply_table(t: &HueSatTable, c: [f32; 3], amount: f32) -> [f32; 3] {
let hsv = rgb_to_hsv(c);
let d = lookup(t, hsv);
let d = [
d[0] * amount,
(1.0 + (d[1] - 1.0) * amount).max(0.0),
(1.0 + (d[2] - 1.0) * amount).max(0.0),
];
let h = hsv[0] + d[0] * (6.0 / 360.0);
let s = (hsv[1] * d[1]).min(1.0);
let v = if t.srgb_encoded {
let vc = hsv[2].min(1.0);
if vc > 0.0 {
hsv[2] * srgb_decode(srgb_encode(vc) * d[2]) / vc
} else {
hsv[2]
}
} else {
hsv[2] * d[2]
};
hsv_to_rgb([h, s, v])
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::ProfileOrigin;
fn uniform(h: u32, s: u32, v: u32, e: [f32; 3]) -> HueSatTable {
HueSatTable::new(h, s, v, false, vec![e; (h * s * v) as usize]).unwrap()
}
fn tables(hue_sat: Option<HueSatTable>, look: Option<HueSatTable>) -> ProfileTables {
ProfileTables {
name: "test".into(),
origin: ProfileOrigin::Embedded,
hue_sat,
look,
tone_curve: None,
}
}
fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
a.iter()
.zip(b)
.all(|(x, y)| (x - y).abs() <= tol * y.abs().max(1.0))
}
#[test]
fn it_starts_neutral_and_composed() {
let op = CameraProfile::new();
assert!(!op.is_active(), "an untouched photograph writes nothing");
assert!(op.composes(), "and still renders through its profile");
let mut off = CameraProfile::new();
off.set_param(APPLY, 0.0);
assert!(off.is_active() && !off.composes());
}
#[test]
fn the_working_space_round_trips_through_prophoto() {
let (to, back) = working_prophoto();
for c in [[1.0, 1.0, 1.0], [0.2, 0.5, 0.1], [4.0, 0.3, 0.02]] {
assert!(close(mul(back, mul(to, c)), c, 1e-5), "{c:?}");
}
let white = mul(to, [1.0; 3]);
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-6), "{white:?}");
}
#[test]
fn hsv_round_trips() {
for c in [
[0.9, 0.2, 0.1],
[0.1, 0.7, 0.3],
[0.2, 0.3, 0.8],
[0.5, 0.5, 0.5],
[3.0, 1.0, 2.0],
] {
assert!(close(hsv_to_rgb(rgb_to_hsv(c)), c, 1e-6), "{c:?}");
}
}
#[test]
fn grey_passes_through() {
let t = tables(
Some(uniform(6, 3, 1, [30.0, 1.5, 1.0])),
Some(uniform(6, 3, 1, [-20.0, 1.3, 1.0])),
);
for v in [0.0, 0.18, 1.0, 8.0] {
let out = apply_reference(&t, [v; 3], 1.0);
assert!(close(out, [v; 3], 1e-5), "{v}: {out:?}");
}
}
#[test]
fn an_identity_table_changes_nothing() {
let t = tables(
Some(uniform(90, 30, 1, [0.0, 1.0, 1.0])),
Some(uniform(36, 8, 16, [0.0, 1.0, 1.0])),
);
for c in [[0.9, 0.2, 0.1], [0.05, 0.4, 0.2], [2.0, 0.5, 0.3]] {
assert!(close(apply_reference(&t, c, 1.0), c, 1e-5), "{c:?}");
}
}
#[test]
fn a_saturation_scale_scales_saturation() {
let t = tables(Some(uniform(6, 3, 1, [0.0, 1.2, 1.0])), None);
let (to, _) = working_prophoto();
let c = [0.6, 0.3, 0.2];
let before = rgb_to_hsv(mul(to, c));
let after = rgb_to_hsv(mul(to, apply_reference(&t, c, 1.0)));
assert!(
(after[1] - before[1] * 1.2).abs() < 1e-4,
"{before:?} {after:?}"
);
assert!((after[0] - before[0]).abs() < 1e-4);
assert!((after[2] - before[2]).abs() < 1e-4);
}
#[test]
fn hue_interpolation_wraps_from_the_last_column_to_the_first() {
// Four hue columns: a shift only in the first. A hue just short of
// 6.0 (red, from the magenta side) sits between the last column and
// the first, and must take most of the first's shift.
let mut e = vec![[0.0, 1.0, 1.0]; 4 * 2];
e[0] = [40.0, 1.0, 1.0];
e[1] = [40.0, 1.0, 1.0];
let t = HueSatTable::new(4, 2, 1, false, e).unwrap();
let d = lookup(&t, [5.9, 0.5, 0.5]);
assert!(d[0] > 30.0, "{d:?}");
// Columns sit at hue 0, 1.5, 3 and 4.5; between the third and the
// fourth, neither of which shifts, nothing moves.
let d = lookup(&t, [3.7, 0.5, 0.5]);
assert!(d[0].abs() < 1e-6, "{d:?}");
}
#[test]
fn a_value_above_one_stays_above_one() {
let t = tables(None, Some(uniform(6, 3, 4, [5.0, 1.1, 0.9])));
let out = apply_reference(&t, [6.0, 3.0, 2.0], 1.0);
assert!(out.iter().any(|v| *v > 1.0), "{out:?}");
let mut srgb = uniform(6, 3, 4, [0.0, 1.0, 0.9]);
srgb.srgb_encoded = true;
let out = apply_reference(&tables(None, Some(srgb)), [6.0, 3.0, 2.0], 1.0);
assert!(out.iter().all(|v| v.is_finite()) && out[0] > 1.0, "{out:?}");
}
#[test]
fn the_look_strength_scales_the_look_alone() {
let hs = uniform(6, 3, 1, [0.0, 1.1, 1.0]);
let look = uniform(6, 3, 1, [0.0, 1.2, 1.0]);
let t = tables(Some(hs.clone()), Some(look));
let c = [0.5, 0.3, 0.2];
let none = apply_reference(&t, c, 0.0);
assert!(close(
none,
apply_reference(&tables(Some(hs), None), c, 1.0),
1e-6
));
let (to, _) = working_prophoto();
let s = |x| rgb_to_hsv(mul(to, x))[1];
assert!(s(apply_reference(&t, c, 2.0)) > s(apply_reference(&t, c, 1.0)));
}
#[test]
fn the_buffer_puts_the_header_first_and_the_look_after_the_hue_sat_map() {
let bare = profile_buffer(None);
assert_eq!(bare[..2], [[0.0; 4]; 2], "no tables");
assert_eq!(bare[2][0], 1025.0, "and the reference default curve");
assert_eq!(bare.len(), HEADER_ENTRIES + 1025);
let t = tables(
Some(uniform(2, 2, 1, [1.0, 2.0, 3.0])),
Some(uniform(3, 2, 2, [4.0, 5.0, 6.0])),
);
let b = profile_buffer(Some(&t));
assert_eq!(b[0], [2.0, 2.0, 1.0, 0.0]);
assert_eq!(b[1], [3.0, 2.0, 2.0, 0.0]);
assert_eq!(b.len(), HEADER_ENTRIES + 4 + 12 + 1025);
assert_eq!(b[HEADER_ENTRIES], [1.0, 2.0, 3.0, 0.0]);
assert_eq!(b[HEADER_ENTRIES + 4], [4.0, 5.0, 6.0, 0.0]);
assert_eq!(b[HEADER_ENTRIES + 16][0], dr_types::tone::ACR3_DEFAULT[0]);
}
}
+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.
+272 -122
View File
@@ -1,30 +1,35 @@
//! 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
//! //!
//! For the same reason [`crate::ops::vignetting`]'s coefficients are not: they //! For the same reason [`crate::ops::vignetting`]'s coefficients are not: they
//! are measurements of a physical thing, not something a slider moves. The //! are measurements of a physical thing, not something a slider moves. The
//! sliders here are exposure and print exposure, which are what a photographer //! sliders here are exposure, push, print exposure and format, which are what
//! and a printer actually control. `dr-film` turns a stock plus those two //! a photographer and a printer actually control. `dr-film` turns a stock into
//! numbers into [`FilmTables`]; this node knows only the layout. //! [`FilmTables`] that hold none of them; the shader applies all four per
//! pixel, which is what lets a mask layer hold its own (see
//! [`Operation::blends_settings`]). This node knows only the layout.
//! //!
//! Declared as a plain struct here rather than imported, so that dr-pipeline //! Declared as a plain struct here rather than imported, so that dr-pipeline
//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does //! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does
@@ -32,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");
@@ -63,6 +68,15 @@ static FORMATS: [LocalizedKey; 6] = [
/// take; [`FilmTables::is_well_formed`] is what stops the two drifting. /// take; [`FilmTables::is_well_formed`] is what stops the two drifting.
pub const CURVE_SAMPLES: usize = 256; pub const CURVE_SAMPLES: usize = 256;
/// The most development times a stock may measure — a curve row and a push
/// station each. Must agree with `dr_film::bake::MAX_CURVE_ROWS`, for the
/// reason [`CURVE_SAMPLES`] must; the uniform block holds this many stations.
pub const MAX_CURVE_ROWS: usize = 8;
/// How many frames [`FORMATS`] offers, and so how many grain counts a stock
/// carries.
pub const FORMAT_COUNT: usize = 6;
/// The uniform field names the fragment reads the exposure matrix from. /// The uniform field names the fragment reads the exposure matrix from.
/// ///
/// A table rather than a formatted string, because a `Uniform`'s name is /// A table rather than a formatted string, because a `Uniform`'s name is
@@ -74,6 +88,24 @@ static MATRIX_FIELDS: [[&str; 3]; 3] = [
["m20", "m21", "m22"], ["m20", "m21", "m22"],
]; ];
/// Grains per pixel, per format and layer: `gn{format}{layer}`.
static GRAIN_FIELDS: [[&str; 3]; FORMAT_COUNT] = [
["gn00", "gn01", "gn02"],
["gn10", "gn11", "gn12"],
["gn20", "gn21", "gn22"],
["gn30", "gn31", "gn32"],
["gn40", "gn41", "gn42"],
["gn50", "gn51", "gn52"],
];
/// The push each curve row was developed to, padded with the last.
static PUSH_FIELDS: [&str; MAX_CURVE_ROWS] =
["ps0", "ps1", "ps2", "ps3", "ps4", "ps5", "ps6", "ps7"];
/// Which format this is, one-hot. See [`FilmSim::uniforms`] for why a choice
/// reaches the shader as six weights rather than an index.
static FORMAT_FIELDS: [&str; FORMAT_COUNT] = ["fmt0", "fmt1", "fmt2", "fmt3", "fmt4", "fmt5"];
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| { static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor { Arc::new(OpDescriptor {
// Tone and colour both, and not `Effect`: a stock is not something applied // Tone and colour both, and not `Effect`: a stock is not something applied
@@ -115,48 +147,86 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
/// Layout is the contract between the two crates, so it is written down here /// Layout is the contract between the two crates, so it is written down here
/// and checked rather than assumed: /// and checked rather than assumed:
/// ///
/// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel `c`. /// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel
/// - `curves` — `CURVE_SAMPLES` density triples, uniform over /// `c`, at unit gain: camera exposure is a per-pixel setting.
/// `[curve_log_min, curve_log_max]`. /// - `curves` — one row of `CURVE_SAMPLES` density triples per
/// - `lut` — `lut_size³` linear sRGB triples, uniform over `[0, density_max]` /// `push_stations` entry, uniform over `[curve_log_min, curve_log_max]`,
/// on each axis, with the **red axis varying fastest**: index /// and then, when printed, one more row: the paper's, uniform over
/// `(b * size + g) * size + r`. That is the order a 3D texture upload /// `[paper.log_min, paper.log_max]`.
/// expects, so the consumer hands the slice straight to the driver. Filling /// - `lut` — `lut_size³` triples uniform over `[0, density_max]` on each
/// it the other way round transposes red and blue in the finished picture — /// axis, with the **red axis varying fastest**: index
/// which is a plausible photograph of the wrong colour, and which the unit /// `(b * size + g) * size + r`. Linear sRGB when the film is viewed
/// tests on both sides of this seam happily pass, because each side is /// directly; the paper's log₁₀ exposure through the negative when it is
/// internally consistent. `dr-film` pins it; `dr-gpu`'s `film_sim` test /// printed, followed by a second cube, paper density over
/// catches it end to end. /// `[0, paper.density_max]` to linear sRGB. That is the order a 3D texture
/// upload expects with the cubes stacked in depth, so the consumer hands the
/// slice straight to the driver. Filling it the other way round transposes
/// red and blue in the finished picture — which is a plausible photograph
/// of the wrong colour, and which the unit tests on both sides of this seam
/// happily pass, because each side is internally consistent. `dr-film` pins
/// it; `dr-gpu`'s `film_sim` test catches it end to end.
///
/// Everything the sliders move — exposure, push, print exposure, format — is
/// absent. They are per-pixel settings the shader applies against these
/// tables, which is what lets a mask layer hold its own.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct FilmTables { pub struct FilmTables {
pub exposure_matrix: [[f32; 3]; 3], pub exposure_matrix: [[f32; 3]; 3],
pub curves: Vec<[f32; 3]>, pub curves: Vec<[f32; 3]>,
/// The push each film row was developed to, ascending: one entry for a
/// stock measured at a single process.
pub push_stations: Vec<f32>,
pub curve_log_min: f32, pub curve_log_min: f32,
pub curve_log_max: f32, pub curve_log_max: f32,
pub lut: Vec<[f32; 3]>, pub lut: Vec<[f32; 3]>,
pub density_max: f32, pub density_max: f32,
pub lut_size: usize, pub lut_size: usize,
/// The print, for a negative printed on paper.
pub paper: Option<PaperTables>,
/// TRACES: FR-DEV-3f /// TRACES: FR-DEV-3f
/// Grains in one pixel's patch of film, per layer, with the density /// Grains in one pixel's patch of film, per format and then per layer,
/// ceiling and uniformity the variance is taken against. Zero particles /// with the density ceiling and uniformity the variance is taken against.
/// means no grain, which is how the control is turned off. /// Zero particles means no grain, which is how the control is turned off.
pub grain_particles: [f32; 3], pub grain_particles: [[f32; 3]; FORMAT_COUNT],
pub grain_density_max: [f32; 3], pub grain_density_max: [f32; 3],
pub grain_uniformity: f32, pub grain_uniformity: f32,
} }
/// The print half of [`FilmTables`]: where the paper's row and cube are read.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaperTables {
/// The enlarger's filtration, per layer, in log₁₀ exposure.
pub balance: [f32; 3],
pub log_min: f32,
pub log_max: f32,
pub density_max: f32,
}
impl FilmTables { impl FilmTables {
/// Film rows, not counting the paper's.
pub fn curve_rows(&self) -> usize {
self.push_stations.len()
}
/// Whether these tables are the shape the shader will index them at. /// Whether these tables are the shape the shader will index them at.
/// ///
/// Checked on the way in, because the failure otherwise is a shader /// Checked on the way in, because the failure otherwise is a shader
/// sampling past the end of a texture: undefined, silent, and different on /// sampling past the end of a texture: undefined, silent, and different on
/// every driver. /// every driver.
pub fn is_well_formed(&self) -> bool { pub fn is_well_formed(&self) -> bool {
self.curves.len() == CURVE_SAMPLES let rows = self.curve_rows();
let printed = usize::from(self.paper.is_some());
let paper_ok = self
.paper
.is_none_or(|p| p.density_max > 0.0 && p.log_max > p.log_min);
(1..=MAX_CURVE_ROWS).contains(&rows)
&& self.push_stations.windows(2).all(|w| w[0] < w[1])
&& self.curves.len() == CURVE_SAMPLES * (rows + printed)
&& self.lut_size >= 2 && self.lut_size >= 2
&& self.lut.len() == self.lut_size.pow(3) && self.lut.len() == self.lut_size.pow(3) * (1 + printed)
&& self.density_max > 0.0 && self.density_max > 0.0
&& self.curve_log_max > self.curve_log_min && self.curve_log_max > self.curve_log_min
&& paper_ok
} }
} }
@@ -237,69 +307,95 @@ 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());
} }
fn film_tables(&self) -> Option<&FilmTables> {
self.tables.as_ref()
}
/// TRACES: FR-DEV-3f
/// A layer's film is its settings, not its own picture blended over the
/// global one.
///
/// Blending outputs would be a photograph developed twice and cross-faded;
/// a region on a pushed film is not that. Every uniform below is linear
/// in what it controls, so the composer can take each layer's weighted
/// average of them and develop the pixel once.
fn blends_settings(&self) -> bool {
true
}
/// Every value here is linear in what the shader does with it, which is
/// what [`Self::blends_settings`] rests on. The format is the one that
/// needs arranging: an index averaged between layers is a format nobody
/// chose, so it goes out one-hot and the shader mixes the six grain
/// counts by it — two layers on 35 mm and 6x7 meet at the average grain.
fn uniforms(&self) -> Vec<Uniform> { fn uniforms(&self) -> Vec<Uniform> {
let Some(t) = &self.tables else { let Some(t) = &self.tables else {
return Vec::new(); return Vec::new();
}; };
let m = t.exposure_matrix; let mut out = Vec::with_capacity(64);
// Exposure rides in the matrix on the CPU when the stock is baked, so let mut push = |name: &'static str, value: f32| out.push(Uniform { name, value });
// what is left here is the *shader's* copy of the same nine numbers. for (l, row) in t.exposure_matrix.iter().enumerate() {
// Spelled out one at a time because a uniform is a named `f32` in this
// pipeline and a matrix would be a second kind of thing for one caller.
let mut out = Vec::with_capacity(MATRIX_FIELDS.len() + 5);
for (l, row) in m.iter().enumerate() {
for (c, v) in row.iter().enumerate() { for (c, v) in row.iter().enumerate() {
out.push(Uniform { push(MATRIX_FIELDS[l][c], *v);
name: MATRIX_FIELDS[l][c],
value: *v,
});
} }
} }
for (l, name) in ["gn0", "gn1", "gn2"].into_iter().enumerate() { for (f, per_layer) in t.grain_particles.iter().enumerate() {
out.push(Uniform { for (l, v) in per_layer.iter().enumerate() {
name, push(GRAIN_FIELDS[f][l], *v);
value: t.grain_particles[l], }
});
} }
for (l, name) in ["gd0", "gd1", "gd2"].into_iter().enumerate() { for (l, name) in ["gd0", "gd1", "gd2"].into_iter().enumerate() {
out.push(Uniform { push(name, t.grain_density_max[l]);
name,
value: t.grain_density_max[l],
});
} }
out.push(Uniform { push("grain_u", t.grain_uniformity);
name: "grain_u", push("log_min", t.curve_log_min);
value: t.grain_uniformity, push("log_max", t.curve_log_max);
}); push("density_max", t.density_max);
out.push(Uniform { push("lut_size", t.lut_size as f32);
name: "log_min",
value: t.curve_log_min, let last = *t.push_stations.last().unwrap_or(&0.0);
}); for (i, name) in PUSH_FIELDS.into_iter().enumerate() {
out.push(Uniform { push(name, t.push_stations.get(i).copied().unwrap_or(last));
name: "log_max", }
value: t.curve_log_max, push("rows", t.curve_rows() as f32);
});
out.push(Uniform { let paper = t.paper.unwrap_or(PaperTables {
name: "density_max", balance: [0.0; 3],
value: t.density_max, log_min: 0.0,
}); log_max: 1.0,
out.push(Uniform { density_max: 1.0,
name: "lut_size",
value: t.lut_size as f32,
});
out.push(Uniform {
name: "print_exposure",
value: self.print_exposure,
}); });
push("printed", if t.paper.is_some() { 1.0 } else { 0.0 });
for (l, name) in ["pb0", "pb1", "pb2"].into_iter().enumerate() {
push(name, paper.balance[l]);
}
push("plog_min", paper.log_min);
push("plog_max", paper.log_max);
push("pdmax", paper.density_max);
// The sliders.
push("ev", self.exposure);
push("push", self.push);
push("pev", self.print_exposure);
let chosen = (self.format.max(0.0).round() as usize).min(FORMAT_COUNT - 1);
for (f, name) in FORMAT_FIELDS.into_iter().enumerate() {
push(name, if f == chosen { 1.0 } else { 0.0 });
}
out out
} }
@@ -309,20 +405,17 @@ 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
// integral over wavelength collapsed into these nine numbers when the stock // integral over wavelength collapsed into these nine numbers when the stock
// was baked. // was baked. The camera's exposure is a gain on it, applied here rather than
let exposure = vec3<f32>( // baked in so that a layer can hold its own.
let exposure = exp2(ev) * vec3<f32>(
dot(vec3<f32>(m00, m01, m02), scene), dot(vec3<f32>(m00, m01, m02), scene),
dot(vec3<f32>(m10, m11, m12), scene), dot(vec3<f32>(m10, m11, m12), scene),
dot(vec3<f32>(m20, m21, m22), scene), dot(vec3<f32>(m20, m21, m22), scene),
@@ -331,12 +424,16 @@ let exposure = vec3<f32>(
// the curve, and the toe is where it belongs. // the curve, and the toe is where it belongs.
let log_exposure = log10(max(exposure, vec3<f32>(0.0)) + 1e-10); let log_exposure = log10(max(exposure, vec3<f32>(0.0)) + 1e-10);
// The characteristic curve: what density each layer develops to. Clamped, not // The characteristic curve: what density each layer develops to, at this
// extrapolated — past the shoulder a real emulsion stops responding, and // pixel's push. Clamped, not extrapolated — past the shoulder a real emulsion
// extrapolating would turn a blown highlight into a colour cast that grows the // stops responding, and extrapolating would turn a blown highlight into a
// more it is overexposed. // colour cast that grows the more it is overexposed.
let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min), let density = film_curve_pushed(
vec3<f32>(0.0), vec3<f32>(1.0))); clamp((log_exposure - log_min) / (log_max - log_min), vec3<f32>(0.0), vec3<f32>(1.0)),
push,
array<f32, 8>(ps0, ps1, ps2, ps3, ps4, ps5, ps6, ps7),
u32(rows),
);
// TRACES: FR-DEV-3f // TRACES: FR-DEV-3f
// Grain, on the density and before the dye. // Grain, on the density and before the dye.
@@ -346,16 +443,40 @@ let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min),
// through whatever density resulted. Adding noise to the finished colour -- // through whatever density resulted. Adding noise to the finished colour --
// which is what an effect does -- tints the highlights wrong, because that // which is what an effect does -- tints the highlights wrong, because that
// noise never passes through the dye at all. // noise never passes through the dye at all.
let grained = film_grain(density, source_px, //
vec3<f32>(gn0, gn1, gn2), // The format's grain count, mixed by the one-hot weights: exactly one format's
// on the whole photograph, and the weighted average under overlapping layers.
let particles = fmt0 * vec3<f32>(gn00, gn01, gn02)
+ fmt1 * vec3<f32>(gn10, gn11, gn12)
+ fmt2 * vec3<f32>(gn20, gn21, gn22)
+ fmt3 * vec3<f32>(gn30, gn31, gn32)
+ fmt4 * vec3<f32>(gn40, gn41, gn42)
+ fmt5 * vec3<f32>(gn50, gn51, gn52);
let grained = film_grain(density, source_px, particles,
vec3<f32>(gd0, gd1, gd2), vec3<f32>(gd0, gd1, gd2),
grain_u); grain_u);
// Dye absorption, the print through the negative, the paper, the viewing // Dye absorption through to what comes next — all of it takes exactly three
// illuminant and the chromatic adaptation — all of which take exactly three // numbers in, which is why it fits in one lookup. Viewed directly, that is
// numbers in, which is why they fit in one lookup. // the picture; printed, it is the light the paper receives through the
c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_size);" // negative, in log exposure.
.into() let through = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)),
lut_size, 0);
if (printed > 0.5) {
// The enlarger: its filtration, and then its exposure, the same stops on
// every layer — which is why print exposure is an addition here and not
// a table, and so exact at any setting.
let paper_log = through + vec3<f32>(pb0, pb1, pb2) + pev * 0.30103;
let paper_density = film_curve(
clamp((paper_log - plog_min) / (plog_max - plog_min), vec3<f32>(0.0), vec3<f32>(1.0)),
u32(rows),
);
c = film_lut(clamp(paper_density / pdmax, vec3<f32>(0.0), vec3<f32>(1.0)),
lut_size, i32(lut_size));
} else {
c = through;
}"
.into()
} }
fn helpers(&self) -> &'static [crate::operation::Helper] { fn helpers(&self) -> &'static [crate::operation::Helper] {
@@ -363,7 +484,7 @@ c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_s
} }
} }
static HELPERS: [crate::operation::Helper; 5] = [ static HELPERS: [crate::operation::Helper; 6] = [
crate::operation::Helper { crate::operation::Helper {
name: "film_hash", name: "film_hash",
source: "\ source: "\
@@ -453,9 +574,9 @@ fn log10(v: vec3<f32>) -> vec3<f32> {
crate::operation::Helper { crate::operation::Helper {
name: "film_curve", name: "film_curve",
source: "\ source: "\
// Three characteristic curves, sampled from a 256-wide texture and // Three characteristic curves, one row of a 256-wide texture, interpolated by
// interpolated by hand. `t` is already normalised to the curve's domain. // hand. `t` is already normalised to the curve's domain.
fn film_curve(t: vec3<f32>) -> vec3<f32> { fn film_curve(t: vec3<f32>, row: u32) -> vec3<f32> {
let samples = u32(textureDimensions(film_curves).x); let samples = u32(textureDimensions(film_curves).x);
let last = f32(samples - 1u); let last = f32(samples - 1u);
var out = vec3<f32>(0.0); var out = vec3<f32>(0.0);
@@ -463,20 +584,45 @@ fn film_curve(t: vec3<f32>) -> vec3<f32> {
let x = t[ch] * last; let x = t[ch] * last;
let i = min(u32(floor(x)), samples - 2u); let i = min(u32(floor(x)), samples - 2u);
let f = x - f32(i); let f = x - f32(i);
let a = textureLoad(film_curves, vec2<i32>(i32(i), 0), 0); let a = textureLoad(film_curves, vec2<i32>(i32(i), i32(row)), 0);
let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, 0), 0); let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, i32(row)), 0);
out[ch] = mix(a[ch], b[ch], f); out[ch] = mix(a[ch], b[ch], f);
} }
return out; return out;
}",
},
crate::operation::Helper {
name: "film_curve_pushed",
source: "\
// The curves at a push between two measured processes. Development is
// interpolated in log time and push *is* log time, so a straight line between
// the neighbouring rows is the stock's own interpolation, not an estimate of
// it. Clamped to the first and last process, as the stock is.
fn film_curve_pushed(t: vec3<f32>, push: f32, stations: array<f32, 8>, rows: u32) -> vec3<f32> {
if (rows < 2u) {
return film_curve(t, 0u);
}
var at = stations;
var hi = rows - 1u;
for (var i = 1u; i < rows; i = i + 1u) {
if (at[i] >= push) {
hi = i;
break;
}
}
let lo = hi - 1u;
let f = clamp((push - at[lo]) / max(at[hi] - at[lo], 1e-6), 0.0, 1.0);
return mix(film_curve(t, lo), film_curve(t, hi), f);
}", }",
}, },
crate::operation::Helper { crate::operation::Helper {
name: "film_lut", name: "film_lut",
source: "\ source: "\
// Trilinear interpolation of the density lookup, by hand for the same reason // Trilinear interpolation of one cube of the lookup, by hand for the same
// the curve above is: there is no sampler bound, and the eight loads are // reason the curve above is: there is no sampler bound, and the eight loads
// cache-neighbours. // are cache-neighbours. `z0` is where the cube starts in depth: the film's at
fn film_lut(t: vec3<f32>, size: f32) -> vec3<f32> { // zero, the paper's stacked after it.
fn film_lut(t: vec3<f32>, size: f32, z0: i32) -> vec3<f32> {
let n = i32(size); let n = i32(size);
let x = t * (size - 1.0); let x = t * (size - 1.0);
let base = min(vec3<i32>(floor(x)), vec3<i32>(n - 2)); let base = min(vec3<i32>(floor(x)), vec3<i32>(n - 2));
@@ -489,7 +635,7 @@ fn film_lut(t: vec3<f32>, size: f32) -> vec3<f32> {
let wy = select(1.0 - f.y, f.y, dy == 1); let wy = select(1.0 - f.y, f.y, dy == 1);
for (var dz = 0; dz < 2; dz = dz + 1) { for (var dz = 0; dz < 2; dz = dz + 1) {
let wz = select(1.0 - f.z, f.z, dz == 1); let wz = select(1.0 - f.z, f.z, dz == 1);
let p = base + vec3<i32>(dx, dy, dz); let p = base + vec3<i32>(dx, dy, dz + z0);
out = out + wx * wy * wz out = out + wx * wy * wz
* textureLoad(film_lut_texture, p, 0).rgb; * textureLoad(film_lut_texture, p, 0).rgb;
} }
@@ -513,7 +659,9 @@ mod tests {
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0, density_max: 3.0,
lut_size: 32, lut_size: 32,
grain_particles: [0.0; 3], grain_particles: [[0.0; 3]; FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
grain_density_max: [3.0; 3], grain_density_max: [3.0; 3],
grain_uniformity: 0.97, grain_uniformity: 0.97,
} }
@@ -558,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());
} }
@@ -584,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]
+5 -1
View File
@@ -66,6 +66,7 @@
// Hand-written nodes. Each is listed in `ops/` with `rust:`, which is what // Hand-written nodes. Each is listed in `ops/` with `rust:`, which is what
// places it in the chain; these are the implementations that entry points at. // places it in the chain; these are the implementations that entry points at.
pub mod aberration; pub mod aberration;
pub mod camera_profile;
pub mod capture_sharpen; pub mod capture_sharpen;
pub mod colour_mixer; pub mod colour_mixer;
pub mod curve; pub mod curve;
@@ -74,19 +75,22 @@ 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;
pub use camera_profile::CameraProfile;
pub use capture_sharpen::CaptureSharpen; pub use capture_sharpen::CaptureSharpen;
pub use colour_mixer::ColourMixer; pub use colour_mixer::ColourMixer;
pub use curve::ToneCurve; pub use curve::ToneCurve;
pub use dehaze::Dehaze; pub use dehaze::Dehaze;
pub use distortion::Distortion; pub use distortion::Distortion;
pub use film_sim::{FilmSim, FilmTables}; pub use film_sim::{FilmSim, FilmTables, PaperTables};
// Clarity and texture are one implementation at two scales; see the module's // Clarity and texture are one implementation at two scales; see the module's
// documentation for why that is two nodes and not one. // documentation for why that is two nodes and not one.
pub use local_contrast::{Clarity, Texture}; pub use local_contrast::{Clarity, Texture};
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]

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