Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
883b4aca10 | ||
|
|
8102234e68 | ||
|
|
757133d2a8 | ||
|
|
5e863fa718 | ||
|
|
e600dae3df | ||
|
|
330ece0abe | ||
|
|
4bd8d86c00 | ||
|
|
6c749b469d | ||
|
|
affdaecaee | ||
|
|
31bcc3a462 | ||
|
|
89b464db0c | ||
|
|
87b2599740 | ||
|
|
885864b6a0 | ||
|
|
0007fa459f | ||
|
|
1eab723c90 | ||
|
|
e601bd806c | ||
|
|
cfc1fea25a | ||
|
|
72aa7e98bf | ||
|
|
77a1925bac | ||
|
|
0d9feb0556 | ||
|
|
0682d05f95 | ||
|
|
657f8f19ff | ||
|
|
c07f81edcb | ||
|
|
92afaebd34 | ||
|
|
37a6d99dc4 | ||
|
|
db7b84795c | ||
|
|
e8898a5c38 | ||
|
|
621a6b8313 | ||
|
|
e98b1def98 | ||
|
|
f52c4cb8b6 |
Generated
+25
-27
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-android"
|
name = "darkroom-android"
|
||||||
version = "0.18.2"
|
version = "0.19.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.2"
|
version = "0.19.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.2"
|
version = "0.19.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.2"
|
version = "0.19.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.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1500,7 +1500,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-export"
|
name = "dr-export"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
@@ -1519,7 +1519,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-face"
|
name = "dr-face"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1532,7 +1532,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-film"
|
name = "dr-film"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1541,7 +1541,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-gpu"
|
name = "dr-gpu"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1559,7 +1559,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-inference-engine"
|
name = "dr-inference-engine"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -1574,7 +1574,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ingest"
|
name = "dr-ingest"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1586,7 +1586,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-lens"
|
name = "dr-lens"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lensfun",
|
"lensfun",
|
||||||
"log",
|
"log",
|
||||||
@@ -1594,7 +1594,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pano"
|
name = "dr-pano"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
@@ -1608,7 +1608,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pipeline"
|
name = "dr-pipeline"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -1617,7 +1617,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-plat"
|
name = "dr-plat"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-native-keyring-store",
|
"android-native-keyring-store",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1633,7 +1633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-preset-xmp"
|
name = "dr-preset-xmp"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-pipeline",
|
"dr-pipeline",
|
||||||
"log",
|
"log",
|
||||||
@@ -1643,7 +1643,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-segment"
|
name = "dr-segment"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1656,7 +1656,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync"
|
name = "dr-sync"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1670,7 +1670,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-folder"
|
name = "dr-sync-folder"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-sync",
|
"dr-sync",
|
||||||
@@ -1682,7 +1682,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-nextcloud"
|
name = "dr-sync-nextcloud"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1704,7 +1704,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-thumbs"
|
name = "dr-thumbs"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
@@ -1716,7 +1716,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-types"
|
name = "dr-types"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1725,7 +1725,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ui"
|
name = "dr-ui"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1773,7 +1773,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-xmp"
|
name = "dr-xmp"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -5513,8 +5513,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "rawler"
|
name = "rawler"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "04f4cc35c23969a4a834e0b117c7da41ace812eb9053b5effc3fc5c77d114677"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"bitstream-io",
|
"bitstream-io",
|
||||||
@@ -7109,7 +7107,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traceability"
|
name = "traceability"
|
||||||
version = "0.18.2"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
|||||||
+8
-6
@@ -32,7 +32,7 @@ members = [
|
|||||||
exclude = ["third_party"]
|
exclude = ["third_party"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.18.2"
|
version = "0.19.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"
|
||||||
@@ -276,11 +276,13 @@ opt-level = 0
|
|||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|
||||||
# Two upstream crates carry a local patch so that the Android build can draw
|
# Three upstream crates carry a local patch: wgpu-hal and Slint's Skia
|
||||||
# with wgpu on a rotated display (technical-debt.md TD-1). Both are exact
|
# renderer so that the Android build can draw with wgpu on a rotated display
|
||||||
# copies of the version the lockfile already resolves, plus that patch;
|
# (technical-debt.md TD-1), and rawler so that a linear DNG wider than 16 700
|
||||||
# third_party/README.md says what was changed and how to carry it forward
|
# pixels decodes. Each is an exact copy of the version the lockfile already
|
||||||
# when Slint or wgpu moves.
|
# resolves, plus its patch; third_party/README.md says what was changed and
|
||||||
|
# how to carry it forward when Slint, wgpu or rawler moves.
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
|
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
|
||||||
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
|
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
|
||||||
|
rawler = { path = "third_party/rawler-0.7.2" }
|
||||||
|
|||||||
@@ -25,26 +25,34 @@ dated folder and a backup beside it — is found, proved the same, and folded
|
|||||||
onto one copy with the spares in the trash. Face detection and identity,
|
onto one copy with the spares in the trash. Face detection and identity,
|
||||||
with the index syncing between devices.
|
with the index syncing between devices.
|
||||||
|
|
||||||
**Developing.** Eighteen declared operations fused into one compute
|
**Developing.** Nineteen declared operations, those that read one pixel
|
||||||
dispatch, plus the neighbourhood work that cannot be: clarity, texture,
|
fused into a generated shader rather than run a pass each, plus the
|
||||||
capture sharpening, noise reduction, lens correction, spectral film
|
neighbourhood work that cannot be: clarity, texture, dehaze, capture
|
||||||
simulation. Crop, straighten and correct converging verticals, spot repair,
|
sharpening, noise reduction, lens correction. Every edit works on the scene
|
||||||
and local adjustments over masks the model draws — click a subject or a
|
as the camera recorded it — linear, highlights beyond white included — and
|
||||||
category, then paint, subtract a gradient or keep only where two selections
|
one `Tone Mapping` step, last, after sharpening and noise reduction, fits it
|
||||||
agree, grow or shrink the edge. A mask's sliders add to the photograph's, the
|
to the screen, with a contrast and a white point of its own; a spectral film
|
||||||
film's among them, so a sky can be burned in on the print as a darkroom
|
stock takes its place when one is chosen. Crop, straighten and correct
|
||||||
printer would. Hot and dead photosites are mended before the demosaic, with
|
converging verticals, spot repair, and local adjustments over masks the
|
||||||
nothing to set. Focus peaking and a raw histogram for judging
|
model draws — click a subject or a category, then paint, subtract a gradient
|
||||||
what is recoverable. Presets, with a collection shipped in the application —
|
or keep only where two selections agree, grow or shrink the edge. A mask's
|
||||||
|
sliders add to the photograph's, the film's among them, so a sky can be
|
||||||
|
burned in on the print as a darkroom printer would. Hot and dead photosites
|
||||||
|
are mended before the demosaic, with nothing to set. Focus peaking and a raw
|
||||||
|
histogram for judging what is recoverable. Presets, a click away in a menu
|
||||||
|
at the foot of the tool rail, with a collection shipped in the application —
|
||||||
everyday corrections, and a look for each measured colour, cinema and
|
everyday corrections, and a look for each measured colour, cinema and
|
||||||
black-and-white stock — and Lightroom presets imported as looks that leave a
|
black-and-white stock — and Lightroom presets imported as looks that leave a
|
||||||
photograph's own corrections alone. XMP sidecars other editors read.
|
photograph's own corrections alone. XMP sidecars other editors read. A
|
||||||
|
linear DNG larger than one GPU texture — a stitched panorama twenty thousand
|
||||||
|
pixels wide — opens, develops and exports at full size.
|
||||||
|
|
||||||
[](docs/manual/README.md#local-adjustments)
|
[](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.
|
||||||
|
|
||||||
[](docs/manual/README.md#merging-a-panorama)
|
[](docs/manual/README.md#merging-a-panorama)
|
||||||
|
|
||||||
@@ -96,19 +104,19 @@ controls, its place in the chain and its tests.
|
|||||||
|
|
||||||
## Where it stands
|
## Where it stands
|
||||||
|
|
||||||
**0.18.2**, twenty-eight tagged releases in. 192 numbered requirements in
|
**0.19.0**, twenty-nine 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,14 +16,12 @@
|
|||||||
//! second decoder can be put behind them without changing any of them
|
//! second decoder can be put behind them without changing any of them
|
||||||
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
|
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
|
||||||
|
|
||||||
pub mod base_curve;
|
|
||||||
mod decoder;
|
mod decoder;
|
||||||
mod error;
|
mod error;
|
||||||
mod locate;
|
mod locate;
|
||||||
mod preview;
|
mod preview;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
|
||||||
pub use base_curve::BaseCurve;
|
|
||||||
pub use decoder::{default, Decoder, Rawler};
|
pub use decoder::{default, Decoder, Rawler};
|
||||||
pub use error::DecodeError;
|
pub use error::DecodeError;
|
||||||
pub use locate::{
|
pub use locate::{
|
||||||
@@ -125,19 +123,6 @@ pub struct RawImage {
|
|||||||
/// for the light the frame was shot under; see [`profile::CameraProfile`].
|
/// for the light the frame was shot under; see [`profile::CameraProfile`].
|
||||||
pub color_matrix: Option<[f32; 9]>,
|
pub color_matrix: Option<[f32; 9]>,
|
||||||
/// TRACES: FR-DEV-3e
|
/// TRACES: FR-DEV-3e
|
||||||
/// The per-body rendering curve, the other half of the camera profile.
|
|
||||||
///
|
|
||||||
/// The matrix above decides what the colours *are*; this decides what the
|
|
||||||
/// picture looks like. Carried on the decoded image rather than looked up
|
|
||||||
/// downstream because this is the only point in the system that knows
|
|
||||||
/// which body took the frame, and because it is not an edit: it belongs to
|
|
||||||
/// the file in the same way the masked-photosite crop does, and must never
|
|
||||||
/// reach a sidecar (FR-NC-9).
|
|
||||||
///
|
|
||||||
/// [`BaseCurve::IDENTITY`] for an unknown body with no default in the
|
|
||||||
/// database, which renders exactly as this decoder did before profiles
|
|
||||||
/// existed.
|
|
||||||
pub base_curve: BaseCurve,
|
|
||||||
/// The usable region of `data`, excluding masked and border photosites.
|
/// The usable region of `data`, excluding masked and border photosites.
|
||||||
pub crop: CropRect,
|
pub crop: CropRect,
|
||||||
/// TRACES: FR-MRG-3
|
/// TRACES: FR-MRG-3
|
||||||
@@ -592,15 +577,6 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
|||||||
profile.as_ref().map(|p| p.xyz_to_cam()).as_ref(),
|
profile.as_ref().map(|p| p.xyz_to_cam()).as_ref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The rendering half of the profile (FR-DEV-3e). rawler's cleaned strings
|
|
||||||
// are preferred where it has them — they are what the shipped database is
|
|
||||||
// written against — and the matching folds the variants either way, so a
|
|
||||||
// DNG naming the same body differently still finds its curve.
|
|
||||||
let base_curve = base_curve::for_body(
|
|
||||||
image.camera.clean_make.as_str(),
|
|
||||||
image.camera.clean_model.as_str(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// TRACES: FR-MRG-3
|
// TRACES: FR-MRG-3
|
||||||
// A linear DNG — three samples per pixel, no colour filter array — is a
|
// A linear DNG — three samples per pixel, no colour filter array — is a
|
||||||
// composite this application wrote (or any other demosaiced DNG). It
|
// composite this application wrote (or any other demosaiced DNG). It
|
||||||
@@ -683,7 +659,6 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
|||||||
.unwrap_or(u16::MAX),
|
.unwrap_or(u16::MAX),
|
||||||
wb_coeffs,
|
wb_coeffs,
|
||||||
color_matrix,
|
color_matrix,
|
||||||
base_curve,
|
|
||||||
samples_per_pixel,
|
samples_per_pixel,
|
||||||
profile,
|
profile,
|
||||||
make: image.camera.clean_make.clone(),
|
make: image.camera.clean_make.clone(),
|
||||||
|
|||||||
@@ -6,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};
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
# Film stocks
|
# Film stocks
|
||||||
|
|
||||||
One file per stock in [`profiles/`](profiles/). Adding a stock is adding a
|
One file per stock in [`profiles/`](profiles/). Adding a stock is adding a
|
||||||
file — no code change, no shader, no new operation — for the same reason
|
file — no code change, no shader, no new operation — because under the GPLv3
|
||||||
`dr-decode`'s base curves work that way: under the GPLv3 a stock should be
|
a stock should be contributable without a release.
|
||||||
contributable without a release.
|
|
||||||
|
|
||||||
## What a profile is
|
## What a profile is
|
||||||
|
|
||||||
@@ -51,6 +50,13 @@ matters — see [`src/bake.rs`](src/bake.rs) for the argument:
|
|||||||
log exposure through the negative, the enlarger's exposure is added there,
|
log exposure through the negative, the enlarger's exposure is added there,
|
||||||
and the paper's own curve row and cube take it to linear sRGB.
|
and the paper's own curve row and cube take it to linear sRGB.
|
||||||
|
|
||||||
|
The stock is the last thing that happens to the picture. It runs in the view
|
||||||
|
transform's place (D19): handed linear sRGB, scene-referred, after every other
|
||||||
|
adjustment and after sharpening and noise reduction, and handing back the
|
||||||
|
rendering the output transform encodes. So every other slider decides the
|
||||||
|
exposure the negative receives, and the default tone mapping is not applied
|
||||||
|
on top.
|
||||||
|
|
||||||
Per pixel that is a matrix multiply, a handful of curve taps and one texture
|
Per pixel that is a matrix multiply, a handful of curve taps and one texture
|
||||||
fetch — two for a print. Splitting 2 from 3, rather than baking one LUT over exposure, is measured
|
fetch — two for a print. Splitting 2 from 3, rather than baking one LUT over exposure, is measured
|
||||||
rather than assumed: the curve carries all the sharp shape and the dye mixing
|
rather than assumed: the curve carries all the sharp shape and the dye mixing
|
||||||
|
|||||||
@@ -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.
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -182,8 +182,7 @@ fn render_to(
|
|||||||
// and the example never has to know which it was handed.
|
// and the example never has to know which it was handed.
|
||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let scale = graph.render_scale(image.size(), (w, h));
|
let scale = graph.render_scale(image.size(), (w, h));
|
||||||
let detail =
|
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
|
||||||
let key = graph.invalidation().through(Affects::Colour);
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
adjust
|
adjust
|
||||||
.render_detailed(image, &shader, w, h, None, &detail, key)
|
.render_detailed(image, &shader, w, h, None, &detail, key)
|
||||||
|
|||||||
+170
-71
@@ -34,16 +34,6 @@ use crate::{DemosaicedImage, GpuContext, GpuError};
|
|||||||
/// reads them.
|
/// reads them.
|
||||||
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
|
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3e
|
|
||||||
/// The two crates must agree on how many points a base curve has.
|
|
||||||
///
|
|
||||||
/// `dr-decode` reads them from the profile database and `dr-pipeline` declares
|
|
||||||
/// the uniform slots; this file is the only place the two meet, and it packs
|
|
||||||
/// them by index. A disagreement would not fail to compile — it would upload a
|
|
||||||
/// curve with a point missing or a stale float in it, which renders as a
|
|
||||||
/// plausible-looking wrong tone response. Cheaper to catch here, at build time.
|
|
||||||
const _: () = assert!(dr_decode::base_curve::POINTS == dr_pipeline::BASE_CURVE_POINTS);
|
|
||||||
|
|
||||||
/// Runs composed operation chains against demosaiced images.
|
/// Runs composed operation chains against demosaiced images.
|
||||||
pub struct AdjustPass {
|
pub struct AdjustPass {
|
||||||
ctx: GpuContext,
|
ctx: GpuContext,
|
||||||
@@ -132,6 +122,8 @@ pub struct AdjustPass {
|
|||||||
colour_dispatches: usize,
|
colour_dispatches: usize,
|
||||||
/// Detail dispatches encoded.
|
/// Detail dispatches encoded.
|
||||||
detail_dispatches: usize,
|
detail_dispatches: usize,
|
||||||
|
/// View passes encoded — one per render with a detail stage (D19).
|
||||||
|
view_dispatches: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Target {
|
struct Target {
|
||||||
@@ -663,6 +655,7 @@ impl AdjustPass {
|
|||||||
sample: SampleCache::new(ctx),
|
sample: SampleCache::new(ctx),
|
||||||
colour_dispatches: 0,
|
colour_dispatches: 0,
|
||||||
detail_dispatches: 0,
|
detail_dispatches: 0,
|
||||||
|
view_dispatches: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1058,13 +1051,14 @@ impl AdjustPass {
|
|||||||
/// Render one frame with a neighbourhood stage.
|
/// Render one frame with a neighbourhood stage.
|
||||||
///
|
///
|
||||||
/// `shader` and `detail` must be the two halves of **one** composition —
|
/// `shader` and `detail` must be the two halves of **one** composition —
|
||||||
/// `EditGraph::compose_for` and `EditGraph::compose_detail_for` on the same
|
/// `EditGraph::compose_for` and `EditGraph::compose_detail` on the same
|
||||||
/// graph, at the same output space. The fused pass stops at linear working
|
/// graph. The fused pass stops at linear working values when a detail
|
||||||
/// values when a detail stage exists and the last detail pass performs the
|
/// stage exists, and its view pass ([`ComposedShader::view`]) performs the
|
||||||
/// output transform, so a mismatched pair either encodes twice or not at
|
/// view transform and the output transform after the last detail pass
|
||||||
/// all.
|
/// (D19), so a mismatched pair either encodes twice or not at all.
|
||||||
///
|
///
|
||||||
/// An empty `detail` falls through to [`Self::render_masked`], which is
|
/// An encoded shader with an empty `detail` falls through to
|
||||||
|
/// [`Self::render_masked`], which is
|
||||||
/// the honest thing to do rather than an optimisation: an edit with no
|
/// the honest thing to do rather than an optimisation: an edit with no
|
||||||
/// active sharpening *is* an ordinary edit, and it should cost exactly
|
/// active sharpening *is* an ordinary edit, and it should cost exactly
|
||||||
/// what one costs.
|
/// what one costs.
|
||||||
@@ -1104,17 +1098,25 @@ impl AdjustPass {
|
|||||||
detail: &ComposedDetail,
|
detail: &ComposedDetail,
|
||||||
colour_key: u64,
|
colour_key: u64,
|
||||||
) -> Result<&wgpu::Texture, GpuError> {
|
) -> Result<&wgpu::Texture, GpuError> {
|
||||||
if detail.is_empty() {
|
// An edit with no detail stage encodes in the fused pass, and is an
|
||||||
|
// ordinary render. Decided from the shader rather than from the chain:
|
||||||
|
// an active kernel too fine for this render emits no pass, and its
|
||||||
|
// fused pass has still stopped at linear values for the view pass.
|
||||||
|
if shader.output_mode == OutputMode::Encoded && detail.is_empty() {
|
||||||
return self.render_masked(source, shader, width, height, masks);
|
return self.render_masked(source, shader, width, height, masks);
|
||||||
}
|
}
|
||||||
if shader.output_mode != OutputMode::LinearWorking {
|
let view = match (shader.output_mode, shader.view.as_deref()) {
|
||||||
|
(OutputMode::LinearWorking, Some(view)) => view,
|
||||||
|
_ => {
|
||||||
return Err(GpuError::ShaderCompilation(
|
return Err(GpuError::ShaderCompilation(
|
||||||
"this detail chain expects a fused pass composed to hand on \
|
"this detail chain expects a fused pass composed to hand on \
|
||||||
linear working values, but the shader given encodes its own \
|
linear working values, with its view pass, but the shader \
|
||||||
output; compose both halves from the same graph"
|
given encodes its own output; compose both halves from the \
|
||||||
|
same graph"
|
||||||
.into(),
|
.into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let (width, height) = (width.max(1), height.max(1));
|
let (width, height) = (width.max(1), height.max(1));
|
||||||
self.ensure_target(width, height);
|
self.ensure_target(width, height);
|
||||||
@@ -1127,6 +1129,7 @@ impl AdjustPass {
|
|||||||
// both want `&mut self`, and the second holds its borrow across the
|
// both want `&mut self`, and the second holds its borrow across the
|
||||||
// encode below.
|
// encode below.
|
||||||
self.pipeline(shader)?;
|
self.pipeline(shader)?;
|
||||||
|
self.pipeline(view)?;
|
||||||
let colour_view = self
|
let colour_view = self
|
||||||
.detail
|
.detail
|
||||||
.colour_target(detail.len(), width, height)
|
.colour_target(detail.len(), width, height)
|
||||||
@@ -1217,20 +1220,12 @@ impl AdjustPass {
|
|||||||
self.colour_dispatches += 1;
|
self.colour_dispatches += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One encoder for the colour pass and every detail pass, submitted
|
// One encoder for the colour pass, every detail pass and the view pass,
|
||||||
// once — the shape `MaskPass::render` established. Submission order is
|
// submitted once — the shape `MaskPass::render` established.
|
||||||
// the whole of the synchronisation: each pass reads what the previous
|
// Submission order is the whole of the synchronisation: each pass
|
||||||
// one wrote, through the same queue.
|
// reads what the previous one wrote, through the same queue.
|
||||||
let target_view = self.targets[self.current]
|
let (ran, result) = match self.detail.encode(&mut enc, detail, width, height) {
|
||||||
.as_ref()
|
Ok(done) => done,
|
||||||
.expect("ensured above")
|
|
||||||
.view
|
|
||||||
.clone();
|
|
||||||
let ran = match self
|
|
||||||
.detail
|
|
||||||
.encode(&mut enc, detail, &target_view, width, height)
|
|
||||||
{
|
|
||||||
Ok(ran) => ran,
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Nothing is submitted, so a cache this frame was to write
|
// Nothing is submitted, so a cache this frame was to write
|
||||||
// holds nothing, and must not be read as though it did.
|
// holds nothing, and must not be read as though it did.
|
||||||
@@ -1240,6 +1235,86 @@ impl AdjustPass {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// The view pass: the view transform and the output transform, after
|
||||||
|
// every kernel (D19). Its own uniform block, filled from the source
|
||||||
|
// like the fused pass's — it reads the non-linear flag and the film
|
||||||
|
// settings there — with the sample cache off, because the colour it
|
||||||
|
// reads is the detail stage's result, bound where the cache would be.
|
||||||
|
let view_uniforms = Self::fused_uniforms(source, view);
|
||||||
|
let view_params = self
|
||||||
|
.ctx
|
||||||
|
.device
|
||||||
|
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("adjust-view-params"),
|
||||||
|
contents: bytemuck::cast_slice(&view_uniforms),
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM,
|
||||||
|
});
|
||||||
|
let (_, no_sample_out) = self.sample.views(SampleUse::Direct);
|
||||||
|
let target_view = self.targets[self.current]
|
||||||
|
.as_ref()
|
||||||
|
.expect("ensured above")
|
||||||
|
.view
|
||||||
|
.clone();
|
||||||
|
let view_bind_group = self
|
||||||
|
.ctx
|
||||||
|
.device
|
||||||
|
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("adjust-view-bg"),
|
||||||
|
layout: &self.bind_group_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: wgpu::BindingResource::TextureView(source.view()),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: view_params.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&target_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 3,
|
||||||
|
resource: wgpu::BindingResource::TextureView(
|
||||||
|
masks.map_or(&self.empty_masks, |m| m.view()),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 4,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&film_curves),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 5,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&film_lut),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 6,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&result),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 7,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&no_sample_out),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
{
|
||||||
|
let pipeline = self
|
||||||
|
.cache
|
||||||
|
.get(&view.structure_hash)
|
||||||
|
.expect("compiled above");
|
||||||
|
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||||
|
label: Some("adjust-view-pass"),
|
||||||
|
timestamp_writes: None,
|
||||||
|
});
|
||||||
|
pass.set_pipeline(pipeline);
|
||||||
|
pass.set_bind_group(0, &view_bind_group, &[]);
|
||||||
|
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
|
||||||
|
}
|
||||||
|
self.view_dispatches += 1;
|
||||||
|
|
||||||
self.ctx.queue.submit(Some(enc.finish()));
|
self.ctx.queue.submit(Some(enc.finish()));
|
||||||
self.detail_dispatches += ran;
|
self.detail_dispatches += ran;
|
||||||
self.colour_key = Some((key, width, height));
|
self.colour_key = Some((key, width, height));
|
||||||
@@ -1275,22 +1350,13 @@ impl AdjustPass {
|
|||||||
// runs. See `DemosaicedImage::is_non_linear`.
|
// runs. See `DemosaicedImage::is_non_linear`.
|
||||||
let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 };
|
let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 };
|
||||||
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]);
|
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]);
|
||||||
// TRACES: FR-DEV-3e
|
// TRACES: FR-DSP-2 | NFR-RES-2
|
||||||
// The camera profile's base curve, packed the way the generated block
|
// Which part of the photograph the texture holds. The whole of it for
|
||||||
// declares it: four x, four y, then the fifth point and the flag. The
|
// every source that fits in one texture, which writes back exactly
|
||||||
// flag is what lets one compiled shader serve a profiled body and an
|
// what the composer put there.
|
||||||
// unprofiled one, so the pipeline cache is not split in two by which
|
let w = dr_pipeline::SOURCE_WINDOW_UNIFORM_OFFSET;
|
||||||
// camera took the frame.
|
uniforms[w..w + dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS]
|
||||||
//
|
.copy_from_slice(&source.window_uniforms());
|
||||||
// Written here rather than at the call site so that *both* callers —
|
|
||||||
// the plain render and the masked one — carry the profile. Filling it
|
|
||||||
// at one of them was how the two halves of this merge each had it.
|
|
||||||
let curve = source.base_curve();
|
|
||||||
let on = if curve.is_identity() { 0.0 } else { 1.0 };
|
|
||||||
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
|
|
||||||
uniforms[b..b + 4].copy_from_slice(&curve.xs[0..4]);
|
|
||||||
uniforms[b + 4..b + 8].copy_from_slice(&curve.ys[0..4]);
|
|
||||||
uniforms[b + 8..b + 12].copy_from_slice(&[curve.xs[4], curve.ys[4], on, 0.0]);
|
|
||||||
uniforms
|
uniforms
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1404,6 +1470,13 @@ impl AdjustPass {
|
|||||||
self.detail_dispatches
|
self.detail_dispatches
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3j
|
||||||
|
/// View passes encoded since this pass was created: one for every render
|
||||||
|
/// that had a detail stage, since the view transform follows it.
|
||||||
|
pub fn view_dispatches(&self) -> usize {
|
||||||
|
self.view_dispatches
|
||||||
|
}
|
||||||
|
|
||||||
/// How many linear intermediates have been allocated. For tests: see
|
/// How many linear intermediates have been allocated. For tests: see
|
||||||
/// [`crate::MaskPass::allocations`] for the regression this catches.
|
/// [`crate::MaskPass::allocations`] for the regression this catches.
|
||||||
pub fn detail_allocations(&self) -> usize {
|
pub fn detail_allocations(&self) -> usize {
|
||||||
@@ -1447,9 +1520,9 @@ impl AdjustPass {
|
|||||||
/// one: the storage format is in the layout. The profile uniforms are
|
/// one: the storage format is in the layout. The profile uniforms are
|
||||||
/// filled neutral here rather than from the source, which is the whole
|
/// filled neutral here rather than from the source, which is the whole
|
||||||
/// point of the mode (`OutputMode::CameraLinear`): unit white balance,
|
/// point of the mode (`OutputMode::CameraLinear`): unit white balance,
|
||||||
/// identity matrix, base curve off. The non-linear flag is kept, so a
|
/// identity matrix, and no view transform composed. The non-linear flag
|
||||||
/// JPEG source is still linearised — camera space for a JPEG is the
|
/// is kept, so a JPEG source is still linearised — camera space for a
|
||||||
/// decoded values made linear, which is the best that exists.
|
/// JPEG is the decoded values made linear, which is the best that exists.
|
||||||
///
|
///
|
||||||
/// The texture stays on the device for a merge's warp to sample; see
|
/// The texture stays on the device for a merge's warp to sample; see
|
||||||
/// [`Self::camera_texture`] and [`Self::read_camera_linear`].
|
/// [`Self::camera_texture`] and [`Self::read_camera_linear`].
|
||||||
@@ -1478,8 +1551,6 @@ impl AdjustPass {
|
|||||||
uniforms[4..8].copy_from_slice(&[0.0, 1.0, 0.0, 0.0]);
|
uniforms[4..8].copy_from_slice(&[0.0, 1.0, 0.0, 0.0]);
|
||||||
uniforms[8..12].copy_from_slice(&[0.0, 0.0, 1.0, 0.0]);
|
uniforms[8..12].copy_from_slice(&[0.0, 0.0, 1.0, 0.0]);
|
||||||
uniforms[12..16].copy_from_slice(&[1.0, 1.0, 1.0, non_linear]);
|
uniforms[12..16].copy_from_slice(&[1.0, 1.0, 1.0, non_linear]);
|
||||||
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
|
|
||||||
uniforms[b + 10] = 0.0;
|
|
||||||
|
|
||||||
let params_buf = self
|
let params_buf = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -1707,7 +1778,7 @@ pub(crate) fn numbered(src: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||||
use dr_pipeline::ops::{colour_mixer, exposure, saturation};
|
use dr_pipeline::ops::{colour_mixer, exposure, saturation};
|
||||||
use dr_pipeline::EditGraph;
|
use dr_pipeline::EditGraph;
|
||||||
// For `Operation::detail`, which is how `the_whole_chain_at_once_compiles`
|
// For `Operation::detail`, which is how `the_whole_chain_at_once_compiles`
|
||||||
@@ -1745,7 +1816,6 @@ mod tests {
|
|||||||
// Identity, so the test reasons about the operations alone
|
// Identity, so the test reasons about the operations alone
|
||||||
// rather than about a camera's colour response.
|
// rather than about a camera's colour response.
|
||||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -1949,7 +2019,6 @@ mod tests {
|
|||||||
white_level: 16383,
|
white_level: 16383,
|
||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -2381,7 +2450,7 @@ mod tests {
|
|||||||
// find those operations in neither stage and fail for a reason that is
|
// find those operations in neither stage and fail for a reason that is
|
||||||
// not a defect. Shadows the smaller size deliberately.
|
// not a defect. Shadows the smaller size deliberately.
|
||||||
let (w, h) = g.output_size(512, 512);
|
let (w, h) = g.output_size(512, 512);
|
||||||
let detail = g.compose_detail_for((512, 512), (w, h), dr_types::ColourSpace::Srgb);
|
let detail = g.compose_detail((512, 512), (w, h));
|
||||||
assert!(
|
assert!(
|
||||||
!detail.is_empty(),
|
!detail.is_empty(),
|
||||||
"the detail half composed nothing, so nothing of it was compiled"
|
"the detail half composed nothing, so nothing of it was compiled"
|
||||||
@@ -2401,7 +2470,20 @@ mod tests {
|
|||||||
let mut fused_blocks = 0;
|
let mut fused_blocks = 0;
|
||||||
for desc in g.descriptors() {
|
for desc in g.descriptors() {
|
||||||
let id = desc.id.0;
|
let id = desc.id.0;
|
||||||
let point = shader.source.contains(&format!("---- {id} ----"));
|
// A stock is loaded here, and a stock is a rendering: the view
|
||||||
|
// transform it replaces is correctly in neither half (FR-DEV-3j).
|
||||||
|
if id == dr_pipeline::ops::view_transform::ID.0 {
|
||||||
|
assert!(!shader.source.contains("---- view_transform ----"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A view operation is in the view pass when a detail stage
|
||||||
|
// follows, which it does here (D19).
|
||||||
|
let block = format!("---- {id} ----");
|
||||||
|
let point = shader.source.contains(&block)
|
||||||
|
|| shader
|
||||||
|
.view
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|v| v.source.contains(&block));
|
||||||
let neighbourhood = detail
|
let neighbourhood = detail
|
||||||
.passes
|
.passes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2435,8 +2517,15 @@ mod tests {
|
|||||||
// because the two catch different faults: the XOR catches an operation
|
// because the two catch different faults: the XOR catches an operation
|
||||||
// in the wrong stage, this catches a block in the shader that nothing
|
// in the wrong stage, this catches a block in the shader that nothing
|
||||||
// in the chain asked for.
|
// in the chain asked for.
|
||||||
|
//
|
||||||
|
// The view pass repeats the prologue — framing and the warps — for
|
||||||
|
// the positions it publishes, so only its operation blocks count.
|
||||||
|
let view_blocks = shader
|
||||||
|
.view
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |v| v.source.matches("---- ").count() - (warp_blocks + 1));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
shader.source.matches("---- ").count(),
|
shader.source.matches("---- ").count() + view_blocks,
|
||||||
fused_blocks + warp_blocks + 1,
|
fused_blocks + warp_blocks + 1,
|
||||||
"the fused shader carries a block nothing in the chain asked for"
|
"the fused shader carries a block nothing in the chain asked for"
|
||||||
);
|
);
|
||||||
@@ -2538,7 +2627,6 @@ mod tests {
|
|||||||
white_level: 16383,
|
white_level: 16383,
|
||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -2642,7 +2730,6 @@ mod tests {
|
|||||||
white_level: 16383,
|
white_level: 16383,
|
||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -3227,11 +3314,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
|
fn a_jpeg_and_sensor_data_differ_by_exactly_the_view_transform() {
|
||||||
// The two producers must be interchangeable. A mid-grey that is
|
// TRACES: FR-DEV-3j
|
||||||
// linearly 0.216 (sRGB 128) arriving as sensor data and as a JPEG
|
// The two producers must be interchangeable up to the rendering. A
|
||||||
// must render the same, or an edit would mean different things
|
// mid-grey that is linearly 0.216 (sRGB 128) arriving as sensor data
|
||||||
// depending on which decoder opened the file.
|
// is scene-referred and goes through the view transform; arriving as
|
||||||
|
// a JPEG it is already a rendering and must come out as it went in.
|
||||||
|
// Before D19 the fixture's identity base curve made both unrendered
|
||||||
|
// and this asserted they matched; what it guards is unchanged — the
|
||||||
|
// linearisation of each agrees — but the rendering between them is
|
||||||
|
// now always there for sensor data.
|
||||||
let Some(ctx) = ctx() else { return };
|
let Some(ctx) = ctx() else { return };
|
||||||
let mut pass = AdjustPass::new(&ctx);
|
let mut pass = AdjustPass::new(&ctx);
|
||||||
let shader = EditGraph::default_chain().compose();
|
let shader = EditGraph::default_chain().compose();
|
||||||
@@ -3250,11 +3342,18 @@ mod tests {
|
|||||||
read_centre(&ctx, t)
|
read_centre(&ctx, t)
|
||||||
};
|
};
|
||||||
|
|
||||||
let delta = (i32::from(from_sensor[0]) - i32::from(from_jpeg[0])).abs();
|
let scene = 3537.0 / 16383.0;
|
||||||
|
let viewed = dr_pipeline::view::Sigmoid::default_curve().channel(scene);
|
||||||
|
let expected = (dr_types::Transfer::Srgb.encode(viewed) * 255.0).round() as i32;
|
||||||
|
let delta = (i32::from(from_sensor[0]) - expected).abs();
|
||||||
assert!(
|
assert!(
|
||||||
delta <= 3,
|
delta <= 3,
|
||||||
"the same scene value rendered {from_sensor:?} from sensor data \
|
"sensor data rendered {from_sensor:?}, expected about {expected}"
|
||||||
and {from_jpeg:?} from a JPEG"
|
);
|
||||||
|
let delta = (i32::from(from_jpeg[0]) - 128).abs();
|
||||||
|
assert!(
|
||||||
|
delta <= 3,
|
||||||
|
"a JPEG was rendered again: {from_jpeg:?} from sRGB 128"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-42
@@ -10,7 +10,7 @@
|
|||||||
//! pass over this texture; it does not re-demosaic, which is what keeps the
|
//! pass over this texture; it does not re-demosaic, which is what keeps the
|
||||||
//! interaction budget (NFR-P9) reachable on a 24 MP file.
|
//! interaction budget (NFR-P9) reachable on a 24 MP file.
|
||||||
|
|
||||||
use dr_decode::{BaseCurve, CfaPattern, RawImage};
|
use dr_decode::{CfaPattern, RawImage};
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
use crate::{GpuContext, GpuError};
|
use crate::{GpuContext, GpuError};
|
||||||
@@ -109,23 +109,25 @@ pub struct DemosaicedImage {
|
|||||||
color_matrix: [f32; 9],
|
color_matrix: [f32; 9],
|
||||||
/// As-shot white balance, the neutral starting point for the WB control.
|
/// As-shot white balance, the neutral starting point for the WB control.
|
||||||
as_shot_wb: [f32; 3],
|
as_shot_wb: [f32; 3],
|
||||||
/// TRACES: FR-DEV-3e
|
|
||||||
/// The camera profile's rendering curve, carried through for the adjust
|
|
||||||
/// pass exactly as `color_matrix` is.
|
|
||||||
///
|
|
||||||
/// It rides on the image rather than on the edit graph because it is not
|
|
||||||
/// an edit: it belongs to the body that took the frame, the way the
|
|
||||||
/// masked-photosite crop and the EXIF orientation do, and a sidecar shared
|
|
||||||
/// between two bodies must never carry one body's rendering onto the
|
|
||||||
/// other's file (FR-NC-9).
|
|
||||||
base_curve: BaseCurve,
|
|
||||||
/// Whether the texture holds gamma-encoded rather than linear values.
|
/// Whether the texture holds gamma-encoded rather than linear values.
|
||||||
non_linear: bool,
|
non_linear: bool,
|
||||||
/// Which upload this is, unique for the life of the process. See
|
/// Which upload this is, unique for the life of the process. See
|
||||||
/// [`Self::id`].
|
/// [`Self::id`].
|
||||||
id: u64,
|
id: u64,
|
||||||
|
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||||||
|
/// The whole frame's size in pixels — what [`Self::size`] reports.
|
||||||
|
/// The texture's own size when it holds the whole frame at full
|
||||||
|
/// resolution, which is every photograph that fits in one.
|
||||||
|
frame: (u32, u32),
|
||||||
|
/// Which part of the frame the texture holds, as origin and extent in
|
||||||
|
/// normalised frame coordinates. `[0, 0, 1, 1]` for the whole frame,
|
||||||
|
/// reduced or not. See [`Self::window_uniforms`].
|
||||||
|
window: [f32; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The window of a texture that holds the whole frame.
|
||||||
|
const WHOLE_FRAME: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
|
||||||
|
|
||||||
/// The next [`DemosaicedImage::id`].
|
/// The next [`DemosaicedImage::id`].
|
||||||
fn next_image_id() -> u64 {
|
fn next_image_id() -> u64 {
|
||||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
@@ -143,10 +145,50 @@ impl DemosaicedImage {
|
|||||||
&self.view
|
&self.view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The size of the photograph this stands for, in its own pixels.
|
||||||
|
///
|
||||||
|
/// **Not necessarily the texture's.** For a photograph larger than one
|
||||||
|
/// texture this is a reduced copy of it or a window cut from it, and
|
||||||
|
/// everything that sizes a render, a crop or a kernel has to go on
|
||||||
|
/// measuring the photograph. What indexes the texture's texels asks
|
||||||
|
/// [`Self::texture_size`] instead.
|
||||||
pub fn size(&self) -> (u32, u32) {
|
pub fn size(&self) -> (u32, u32) {
|
||||||
|
self.frame
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The texture's own size in texels.
|
||||||
|
pub fn texture_size(&self) -> (u32, u32) {
|
||||||
(self.width, self.height)
|
(self.width, self.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||||||
|
/// The source window uniforms the fused shader reads, in the order
|
||||||
|
/// `dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS` declares them.
|
||||||
|
///
|
||||||
|
/// The second `vec4` is zero for a texture that holds the whole frame at
|
||||||
|
/// full resolution, so the shader measures the texture itself exactly as
|
||||||
|
/// it did before windows existed.
|
||||||
|
pub fn window_uniforms(&self) -> [f32; dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS] {
|
||||||
|
let [x, y, w, h] = self.window;
|
||||||
|
let (fw, fh) = if self.is_whole() {
|
||||||
|
(0.0, 0.0)
|
||||||
|
} else {
|
||||||
|
(self.frame.0 as f32, self.frame.1 as f32)
|
||||||
|
};
|
||||||
|
[x, y, w, h, fw, fh, 0.0, 0.0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the texture is the whole frame at full resolution.
|
||||||
|
pub fn is_whole(&self) -> bool {
|
||||||
|
self.window == WHOLE_FRAME && self.frame == (self.width, self.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window this texture holds, as origin and extent in normalised
|
||||||
|
/// frame coordinates.
|
||||||
|
pub fn window(&self) -> [f32; 4] {
|
||||||
|
self.window
|
||||||
|
}
|
||||||
|
|
||||||
/// Which texture this is, as a number that is never reused.
|
/// Which texture this is, as a number that is never reused.
|
||||||
///
|
///
|
||||||
/// For a cache that has to know it is still looking at the same pixels
|
/// For a cache that has to know it is still looking at the same pixels
|
||||||
@@ -165,15 +207,6 @@ impl DemosaicedImage {
|
|||||||
self.color_matrix
|
self.color_matrix
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3e
|
|
||||||
/// The camera profile's base curve, as five `(x, y)` points.
|
|
||||||
///
|
|
||||||
/// [`BaseCurve::IDENTITY`] where the body is unprofiled or the source was
|
|
||||||
/// never raw, in which case the adjust pass skips the stage entirely.
|
|
||||||
pub fn base_curve(&self) -> BaseCurve {
|
|
||||||
self.base_curve
|
|
||||||
}
|
|
||||||
|
|
||||||
/// As-shot white balance multipliers, green-normalised.
|
/// As-shot white balance multipliers, green-normalised.
|
||||||
///
|
///
|
||||||
/// The white balance control is expressed *relative* to these, so its
|
/// The white balance control is expressed *relative* to these, so its
|
||||||
@@ -287,13 +320,15 @@ impl DemosaicedImage {
|
|||||||
color_matrix: IDENTITY_3X3,
|
color_matrix: IDENTITY_3X3,
|
||||||
as_shot_wb: [1.0, 1.0, 1.0],
|
as_shot_wb: [1.0, 1.0, 1.0],
|
||||||
// **The identity, and this is the whole reason the field is here
|
// **The identity, and this is the whole reason the field is here
|
||||||
// rather than resolved further down.** A JPEG has already had its
|
// rather than resolved further down.** A JPEG has already been
|
||||||
// camera's base curve baked in by the camera; applying one again
|
// rendered by the camera; the view transform skips a source
|
||||||
// would render the rendering, crushing the shadows and flattening
|
// flagged non-linear, since rendering the rendering would crush
|
||||||
// the highlights of an image that was already finished.
|
// the shadows and flatten the highlights of an image that was
|
||||||
base_curve: BaseCurve::IDENTITY,
|
// already finished.
|
||||||
non_linear: true,
|
non_linear: true,
|
||||||
id: next_image_id(),
|
id: next_image_id(),
|
||||||
|
frame: (width, height),
|
||||||
|
window: WHOLE_FRAME,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,11 +339,46 @@ impl DemosaicedImage {
|
|||||||
/// what a merge writes. No demosaic; the samples are normalised by the
|
/// what a merge writes. No demosaic; the samples are normalised by the
|
||||||
/// file's black and white levels exactly as the demosaic kernel would
|
/// file's black and white levels exactly as the demosaic kernel would
|
||||||
/// normalise a photosite, and everything else — the matrix, the
|
/// normalise a photosite, and everything else — the matrix, the
|
||||||
/// balance, the body's base curve — is carried through as for a CFA
|
/// balance, the view transform — is carried through as for a CFA
|
||||||
/// file, because the composite is developed as one photograph from the
|
/// file, because the composite is developed as one photograph from the
|
||||||
/// body that took its sources.
|
/// body that took its sources.
|
||||||
pub fn from_linear_rgb16(ctx: &GpuContext, raw: &RawImage) -> Result<Self, GpuError> {
|
pub fn from_linear_rgb16(ctx: &GpuContext, raw: &RawImage) -> Result<Self, GpuError> {
|
||||||
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||||||
|
Self::linear_rgb16_window(ctx, raw, [0, 0, width, height], 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||||||
|
/// Part of a linear DNG, or a reduced copy of it, for a photograph too
|
||||||
|
/// large to hold in one texture.
|
||||||
|
///
|
||||||
|
/// `region` is `[x, y, width, height]` in pixels of the frame (the
|
||||||
|
/// file's crop), clamped to it. `reduce` averages `reduce × reduce`
|
||||||
|
/// blocks into one texel — a box filter, which is what a reduced copy
|
||||||
|
/// that is only ever displayed smaller than itself needs, and which keeps
|
||||||
|
/// the samples in scene-linear light where an average means something.
|
||||||
|
///
|
||||||
|
/// The texture then knows where it sits ([`Self::window`]) and how large
|
||||||
|
/// the photograph is ([`Self::size`]), and the fused shader maps each
|
||||||
|
/// output pixel's position in the *photograph* into it. So a crop, a
|
||||||
|
/// rotation or a mask drawn on the reduced copy lands on the same pixels
|
||||||
|
/// of a full-resolution window, and an export in tiles is the same
|
||||||
|
/// picture as one that fitted.
|
||||||
|
///
|
||||||
|
/// Refused only if the result itself does not fit the device.
|
||||||
|
pub fn linear_rgb16_window(
|
||||||
|
ctx: &GpuContext,
|
||||||
|
raw: &RawImage,
|
||||||
|
region: [u32; 4],
|
||||||
|
reduce: u32,
|
||||||
|
) -> Result<Self, GpuError> {
|
||||||
|
let frame = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||||||
|
let k = reduce.max(1);
|
||||||
|
let x0 = region[0].min(frame.0 - 1);
|
||||||
|
let y0 = region[1].min(frame.1 - 1);
|
||||||
|
let rw = region[2].clamp(1, frame.0 - x0);
|
||||||
|
let rh = region[3].clamp(1, frame.1 - y0);
|
||||||
|
let (width, height) = (rw.div_ceil(k), rh.div_ceil(k));
|
||||||
|
|
||||||
let limits = ctx.device.limits();
|
let limits = ctx.device.limits();
|
||||||
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
|
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
|
||||||
return Err(GpuError::TooLarge(format!(
|
return Err(GpuError::TooLarge(format!(
|
||||||
@@ -328,19 +398,49 @@ impl DemosaicedImage {
|
|||||||
}
|
}
|
||||||
let black = black_per_cell(raw);
|
let black = black_per_cell(raw);
|
||||||
let inv = inv_range_per_cell(raw);
|
let inv = inv_range_per_cell(raw);
|
||||||
// Per channel rather than per CFA cell: R, G, B are the first three.
|
|
||||||
let mut half: Vec<u16> = Vec::with_capacity((width * height * 4) as usize);
|
// One output row per task: a 200-megapixel reduction is a second of
|
||||||
for y in 0..height as usize {
|
// one core, and the rows are independent.
|
||||||
let row = (raw.crop.y as usize + y) * stride + raw.crop.x as usize * 3;
|
let row_texels = width as usize * 4;
|
||||||
for x in 0..width as usize {
|
let mut half = vec![0u16; row_texels * height as usize];
|
||||||
let p = &raw.data[row + x * 3..row + x * 3 + 3];
|
let fill_row = |ty: usize, out: &mut [u16]| {
|
||||||
|
let sy0 = y0 as usize + ty * k as usize;
|
||||||
|
let sy1 = (sy0 + k as usize).min((y0 + rh) as usize);
|
||||||
|
for tx in 0..width as usize {
|
||||||
|
let sx0 = x0 as usize + tx * k as usize;
|
||||||
|
let sx1 = (sx0 + k as usize).min((x0 + rw) as usize);
|
||||||
|
let mut acc = [0f32; 3];
|
||||||
|
for sy in sy0..sy1 {
|
||||||
|
let row = (raw.crop.y as usize + sy) * stride + raw.crop.x as usize * 3;
|
||||||
|
for sx in sx0..sx1 {
|
||||||
|
let p = &raw.data[row + sx * 3..row + sx * 3 + 3];
|
||||||
for c in 0..3 {
|
for c in 0..3 {
|
||||||
let v = (f32::from(p[c]) - black[c]) * inv[c];
|
acc[c] += f32::from(p[c]);
|
||||||
half.push(f32_to_f16_bits_unclamped(v));
|
|
||||||
}
|
|
||||||
half.push(f32_to_f16_bits(1.0));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
let n = ((sy1 - sy0) * (sx1 - sx0)).max(1) as f32;
|
||||||
|
let texel = &mut out[tx * 4..tx * 4 + 4];
|
||||||
|
for c in 0..3 {
|
||||||
|
let v = (acc[c] / n - black[c]) * inv[c];
|
||||||
|
texel[c] = f32_to_f16_bits_unclamped(v);
|
||||||
|
}
|
||||||
|
texel[3] = f32_to_f16_bits(1.0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||||
|
let rows_per = (height as usize).div_ceil(threads).max(1);
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
for (chunk, rows) in half.chunks_mut(rows_per * row_texels).enumerate() {
|
||||||
|
let fill_row = &fill_row;
|
||||||
|
scope.spawn(move || {
|
||||||
|
for (i, out) in rows.chunks_mut(row_texels).enumerate() {
|
||||||
|
fill_row(chunk * rows_per + i, out);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let texture = ctx.device.create_texture_with_data(
|
let texture = ctx.device.create_texture_with_data(
|
||||||
&ctx.queue,
|
&ctx.queue,
|
||||||
&wgpu::TextureDescriptor {
|
&wgpu::TextureDescriptor {
|
||||||
@@ -361,6 +461,17 @@ impl DemosaicedImage {
|
|||||||
bytemuck::cast_slice(&half),
|
bytemuck::cast_slice(&half),
|
||||||
);
|
);
|
||||||
let view = texture.create_view(&Default::default());
|
let view = texture.create_view(&Default::default());
|
||||||
|
// The extent is the texels' own, `width × k`, not the region's: the
|
||||||
|
// last block of a reduction may run past the frame's edge, and
|
||||||
|
// stretching it to fit would put every texel slightly off the
|
||||||
|
// pixels it averaged. The shader's bounds test is on the frame, so
|
||||||
|
// nothing past the edge is ever read.
|
||||||
|
let window = [
|
||||||
|
x0 as f32 / frame.0 as f32,
|
||||||
|
y0 as f32 / frame.1 as f32,
|
||||||
|
(width * k) as f32 / frame.0 as f32,
|
||||||
|
(height * k) as f32 / frame.1 as f32,
|
||||||
|
];
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
texture,
|
texture,
|
||||||
view,
|
view,
|
||||||
@@ -368,9 +479,10 @@ impl DemosaicedImage {
|
|||||||
height,
|
height,
|
||||||
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
||||||
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
||||||
base_curve: raw.base_curve,
|
|
||||||
non_linear: false,
|
non_linear: false,
|
||||||
id: next_image_id(),
|
id: next_image_id(),
|
||||||
|
frame,
|
||||||
|
window,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -784,12 +896,13 @@ impl Demosaicer {
|
|||||||
// Whatever the profile database had for this body (FR-DEV-3e),
|
// Whatever the profile database had for this body (FR-DEV-3e),
|
||||||
// resolved at decode because that is the only place the make and
|
// resolved at decode because that is the only place the make and
|
||||||
// model are known.
|
// model are known.
|
||||||
base_curve: raw.base_curve,
|
|
||||||
// Sensor data is linear by construction — the demosaic shader
|
// Sensor data is linear by construction — the demosaic shader
|
||||||
// normalises against black and white levels and applies no
|
// normalises against black and white levels and applies no
|
||||||
// transfer function.
|
// transfer function.
|
||||||
non_linear: false,
|
non_linear: false,
|
||||||
id: next_image_id(),
|
id: next_image_id(),
|
||||||
|
frame: (width, height),
|
||||||
|
window: WHOLE_FRAME,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1284,7 +1397,6 @@ mod tests {
|
|||||||
white_level: white,
|
white_level: white,
|
||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -1400,7 +1512,6 @@ mod tests {
|
|||||||
white_level: white,
|
white_level: white,
|
||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -1697,7 +1808,6 @@ mod tests {
|
|||||||
white_level: 16383,
|
white_level: 16383,
|
||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
@@ -1782,7 +1892,6 @@ mod tests {
|
|||||||
1.0,
|
1.0,
|
||||||
],
|
],
|
||||||
color_matrix: None,
|
color_matrix: None,
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
|
|||||||
+32
-46
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ pub struct MergeOutput {
|
|||||||
pub chunk: (u32, u32),
|
pub chunk: (u32, u32),
|
||||||
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
|
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
|
||||||
pub sample_scale: f32,
|
pub sample_scale: f32,
|
||||||
|
/// The white balance the composite will be developed with — the
|
||||||
|
/// inverse of its `AsShotNeutral` — so that a blown sample can be
|
||||||
|
/// written as the camera value that balance calls grey.
|
||||||
|
pub balance: [f32; 3],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MergeOutput {
|
impl MergeOutput {
|
||||||
@@ -109,7 +113,8 @@ struct WarpParams {
|
|||||||
tile_origin: [f32; 2],
|
tile_origin: [f32; 2],
|
||||||
tile_size: [u32; 2],
|
tile_size: [u32; 2],
|
||||||
feather: f32,
|
feather: f32,
|
||||||
_pad: f32,
|
clip_onset: f32,
|
||||||
|
balance: [f32; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -334,7 +339,13 @@ impl MergePass {
|
|||||||
tile_origin: [rect.0 as f32, rect.1 as f32],
|
tile_origin: [rect.0 as f32, rect.1 as f32],
|
||||||
tile_size: [rect.2, rect.3],
|
tile_size: [rect.2, rect.3],
|
||||||
feather: output.feather,
|
feather: output.feather,
|
||||||
_pad: 0.0,
|
clip_onset: dr_pipeline::CLIP_ONSET,
|
||||||
|
balance: [
|
||||||
|
output.balance[0].max(1e-3),
|
||||||
|
output.balance[1].max(1e-3),
|
||||||
|
output.balance[2].max(1e-3),
|
||||||
|
0.0,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
self.accumulate(¶ms, tile);
|
self.accumulate(¶ms, tile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ struct Params {
|
|||||||
tile_size: vec2<u32>,
|
tile_size: vec2<u32>,
|
||||||
// Pixels over which the weight ramps from the edge to full.
|
// Pixels over which the weight ramps from the edge to full.
|
||||||
feather: f32,
|
feather: f32,
|
||||||
_pad: f32,
|
// Where a sample starts to count as blown (`CLIP_ONSET`), and the
|
||||||
|
// white balance the composite will be developed with.
|
||||||
|
clip_onset: f32,
|
||||||
|
balance: vec4<f32>,
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> p: Params;
|
@group(0) @binding(0) var<uniform> p: Params;
|
||||||
@@ -125,7 +128,19 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Colour is the alpha-weighted mean of the texels that exist.
|
// Colour is the alpha-weighted mean of the texels that exist.
|
||||||
let rgb = s.rgb / s.a * p.gain;
|
let cam = s.rgb / s.a;
|
||||||
|
// **A blown sample is written as grey, before the gain.** A clipped
|
||||||
|
// photosite arrives as (1, 1, 1), which is not a colour: balanced, it
|
||||||
|
// is magenta, and the develop's highlight desaturation only rescues it
|
||||||
|
// while it is still at the white level. A gain below one moved it off
|
||||||
|
// that level, and a feather mixed it into a neighbour's real sky, so
|
||||||
|
// the composite's blown clouds came out pink. Written instead as the
|
||||||
|
// camera value the balance maps to grey — the develop pipeline's own
|
||||||
|
// neutral, the brightest balanced channel — it survives both.
|
||||||
|
let clipped = smoothstep(p.clip_onset, 1.0, max(cam.r, max(cam.g, cam.b)));
|
||||||
|
let balanced = cam * p.balance.rgb;
|
||||||
|
let grey = vec3<f32>(max(balanced.r, max(balanced.g, balanced.b))) / p.balance.rgb;
|
||||||
|
let rgb = mix(cam, grey, clipped) * p.gain;
|
||||||
let wa = w * s.a;
|
let wa = w * s.a;
|
||||||
let i = gid.y * p.chunk_size.x + gid.x;
|
let i = gid.y * p.chunk_size.x + gid.x;
|
||||||
acc[i] = acc[i] + vec4<f32>(rgb * wa, wa);
|
acc[i] = acc[i] + vec4<f32>(rgb * wa, wa);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
//! model is checked against the reference, and the shader is checked against
|
//! model is checked against the reference, and the shader is checked against
|
||||||
//! the CPU model.
|
//! the CPU model.
|
||||||
|
|
||||||
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||||
use dr_film::bake::{bake, Recipe, Settings};
|
use dr_film::bake::{bake, Recipe, Settings};
|
||||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext, LabelField, MaskPass};
|
use dr_gpu::{AdjustPass, Demosaicer, GpuContext, LabelField, MaskPass};
|
||||||
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
||||||
@@ -48,7 +48,6 @@ fn flat_raw(level: u16) -> RawImage {
|
|||||||
// Off deliberately: a film replaces the camera's rendering, and
|
// Off deliberately: a film replaces the camera's rendering, and
|
||||||
// leaving a curve here would test the suppression rather than the
|
// leaving a curve here would test the suppression rather than the
|
||||||
// film. `dr-pipeline` asserts the suppression on the generated source.
|
// film. `dr-pipeline` asserts the suppression on the generated source.
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! anything: the repair happens on the mosaic, and what a photographer would
|
//! anything: the repair happens on the mosaic, and what a photographer would
|
||||||
//! see of a defect it missed is the coloured cross the demosaic makes of it.
|
//! see of a defect it missed is the coloured cross the demosaic makes of it.
|
||||||
|
|
||||||
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||||
use dr_pipeline::EditGraph;
|
use dr_pipeline::EditGraph;
|
||||||
|
|
||||||
@@ -32,7 +32,6 @@ fn frame(pattern: CfaPattern, level: u16, set: &[(u32, u32, u16)]) -> RawImage {
|
|||||||
white_level: WHITE,
|
white_level: WHITE,
|
||||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
base_curve: BaseCurve::IDENTITY,
|
|
||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
//! TRACES: FR-DEV-2 | FR-DEV-3j
|
||||||
|
//! Scene-referred until the view transform (D19, ARCH §6.14), on a device.
|
||||||
|
//!
|
||||||
|
//! The rule is about every operation between the camera matrix and the view
|
||||||
|
//! transform, so this runs each of them over a ramp that reaches sixteen
|
||||||
|
//! times sensor saturation and asserts the two things a clip or an early
|
||||||
|
//! encode would break: the output still increases with the input, and values
|
||||||
|
//! above 1.0 still differ from one another.
|
||||||
|
//!
|
||||||
|
//! A clip above 1.0 cannot be seen through an 8-bit display encode on its
|
||||||
|
//! own, so each operation is wrapped: a gain of sixteen ahead of it puts the
|
||||||
|
//! ramp into the range the rule is about, a gain of one sixty-fourth after it
|
||||||
|
//! brings the result back under 1.0 — with two stops to spare, for the
|
||||||
|
//! operations that brighten — and an identity in the view transform's
|
||||||
|
//! place stops the sigmoid compressing what is being measured. A fragment
|
||||||
|
//! that clamps, or encodes and decodes through a clamped range, flattens the
|
||||||
|
//! top of the ramp, and the last few steps come out equal.
|
||||||
|
//!
|
||||||
|
//! The view stage and the detail stage are excluded. The view transform and
|
||||||
|
//! film simulation clip into a display range because that is their job, and
|
||||||
|
//! a neighbourhood operation is a pass of its own that a flat frame cannot
|
||||||
|
//! exercise.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||||
|
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||||
|
use dr_pipeline::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind};
|
||||||
|
use dr_pipeline::operation::{Operation, Stage, Uniform};
|
||||||
|
|
||||||
|
const SIZE: u32 = 16;
|
||||||
|
|
||||||
|
fn ctx() -> Option<GpuContext> {
|
||||||
|
pollster::block_on(GpuContext::new_headless()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A gain, as a scene-stage operation, or an identity in the view stage.
|
||||||
|
struct Probe {
|
||||||
|
id: &'static str,
|
||||||
|
gain: f32,
|
||||||
|
stage: Stage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Operation for Probe {
|
||||||
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
|
Arc::new(OpDescriptor {
|
||||||
|
id: OpId(self.id),
|
||||||
|
label: LocalizedKey(self.id),
|
||||||
|
params: Vec::new(),
|
||||||
|
attributes: vec![Attribute::Tone],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn set_param(&mut self, _: ParamId, _: f32) {}
|
||||||
|
fn param(&self, _: ParamId) -> f32 {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn stage(&self) -> Stage {
|
||||||
|
self.stage
|
||||||
|
}
|
||||||
|
/// The identity view claims the view transform's place: while it is
|
||||||
|
/// active the composer emits it rather than the sigmoid.
|
||||||
|
fn renders(&self) -> bool {
|
||||||
|
self.stage == Stage::View
|
||||||
|
}
|
||||||
|
fn wgsl_body(&self) -> String {
|
||||||
|
"c = c * gain;".into()
|
||||||
|
}
|
||||||
|
fn uniforms(&self) -> Vec<Uniform> {
|
||||||
|
vec![Uniform {
|
||||||
|
name: "gain",
|
||||||
|
value: self.gain,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe(id: &'static str, gain: f32, stage: Stage) -> Box<dyn Operation> {
|
||||||
|
Box::new(Probe { id, gain, stage })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flat frame at `level` of sensor saturation, identity matrix, neutral
|
||||||
|
/// balance.
|
||||||
|
fn flat(ctx: &GpuContext, level: f32) -> dr_gpu::DemosaicedImage {
|
||||||
|
let raw = RawImage {
|
||||||
|
width: SIZE,
|
||||||
|
height: SIZE,
|
||||||
|
data: vec![(level * f32::from(u16::MAX)).round() as u16; (SIZE * SIZE) as usize],
|
||||||
|
cfa_pattern: CfaPattern::Rggb,
|
||||||
|
black_level: [0; 4],
|
||||||
|
white_level: u16::MAX,
|
||||||
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
|
samples_per_pixel: 1,
|
||||||
|
profile: None,
|
||||||
|
make: String::new(),
|
||||||
|
model: String::new(),
|
||||||
|
crop: CropRect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: SIZE,
|
||||||
|
height: SIZE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Demosaicer::new(ctx)
|
||||||
|
.expect("demosaicer")
|
||||||
|
.run(&raw)
|
||||||
|
.expect("demosaic")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every parameter moved off its default, a third of the way toward its
|
||||||
|
/// maximum — or toward its minimum where the default is the maximum.
|
||||||
|
///
|
||||||
|
/// The tone curve is the exception, because its neutral is a relationship:
|
||||||
|
/// its parameters are point coordinates, and moving every x and y the same
|
||||||
|
/// fraction leaves the points on the diagonal. It gets a lifted midpoint on
|
||||||
|
/// the master and on the red curve instead — the two helpers that clamped.
|
||||||
|
fn non_neutral(op: &mut dyn Operation) {
|
||||||
|
use dr_pipeline::ops::curve::{coordinate, Axis, Channel};
|
||||||
|
if op.descriptor().id == dr_pipeline::ops::curve::ID {
|
||||||
|
op.set_param(coordinate(Channel::Master, 2, Axis::Y), 0.65);
|
||||||
|
op.set_param(coordinate(Channel::Red, 2, Axis::Y), 0.6);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for p in &op.descriptor().params {
|
||||||
|
if let ParamKind::Scalar { min, max, .. } = p.kind {
|
||||||
|
let toward = if p.default < max { max } else { min };
|
||||||
|
op.set_param(p.id, p.default + (toward - p.default) / 3.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ramp, as scene values after the sixteenfold gain: 0.4 to 16.
|
||||||
|
///
|
||||||
|
/// Kept below 1.0 at the sensor, and away from its last 1.5%, because the
|
||||||
|
/// prologue's highlight desaturation fades a photosite toward neutral there —
|
||||||
|
/// a sensor fact, not an operation's, and flat grey is neutral already.
|
||||||
|
const LEVELS: [f32; 8] = [0.025, 0.05, 0.1, 0.2, 0.4, 0.6, 0.8, 0.95];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scene_referred_until_the_view() {
|
||||||
|
// TRACES: FR-DEV-2 | FR-DEV-3j
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("skipping: no GPU adapter");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let sources: Vec<_> = LEVELS.iter().map(|&l| flat(&ctx, l)).collect();
|
||||||
|
let mut adjust = AdjustPass::new(&ctx);
|
||||||
|
|
||||||
|
let mut checked = 0;
|
||||||
|
for mut op in dr_pipeline::ops::chain() {
|
||||||
|
if op.detail().is_some() || op.stage() == Stage::View {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = op.descriptor().id.0;
|
||||||
|
non_neutral(op.as_mut());
|
||||||
|
assert!(op.is_active(), "{id}: the edit above left it neutral");
|
||||||
|
let ops = vec![
|
||||||
|
probe("probe_up", 16.0, Stage::Scene),
|
||||||
|
op,
|
||||||
|
probe("probe_down", 1.0 / 64.0, Stage::Scene),
|
||||||
|
probe("probe_view", 1.0, Stage::View),
|
||||||
|
];
|
||||||
|
let shader = dr_pipeline::compose(&ops);
|
||||||
|
assert!(
|
||||||
|
!shader.source.contains("view_sigmoid"),
|
||||||
|
"the identity must take the view transform's place"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for source in &sources {
|
||||||
|
adjust.render(source, &shader, SIZE, SIZE).expect("render");
|
||||||
|
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||||
|
let centre = (((SIZE / 2) * SIZE + SIZE / 2) * 4) as usize;
|
||||||
|
out.push([pixels[centre], pixels[centre + 1], pixels[centre + 2]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for channel in 0..3 {
|
||||||
|
let ramp: Vec<u8> = out.iter().map(|p| p[channel]).collect();
|
||||||
|
assert!(
|
||||||
|
ramp.windows(2).all(|w| w[1] >= w[0]),
|
||||||
|
"{id} is not monotone in channel {channel}: {ramp:?}"
|
||||||
|
);
|
||||||
|
// The top three levels are scene 9.6, 12.8 and 15.2: all far
|
||||||
|
// above 1.0, and a clip anywhere below them makes them equal.
|
||||||
|
let top = &ramp[LEVELS.len() - 3..];
|
||||||
|
assert!(
|
||||||
|
top[0] < top[1] && top[1] < top[2],
|
||||||
|
"{id} flattens values above 1.0 in channel {channel}: {ramp:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
assert!(checked >= 10, "only {checked} operations were checked");
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
//! TRACES: FR-DSP-2 | NFR-RES-2
|
||||||
|
//! A photograph larger than one texture, developed from windows of it.
|
||||||
|
//!
|
||||||
|
//! The claim under test is that the window is invisible: a frame rendered a
|
||||||
|
//! tile at a time, each tile from only the part of the source it reads, is the
|
||||||
|
//! frame rendered whole. `dr-pipeline` can check the plan — the tiles cover
|
||||||
|
//! the frame once, each is grown by the reach — but not that the shader's
|
||||||
|
//! mapping into a window lands on the texel the whole texture would have
|
||||||
|
//! given, which only a device answers.
|
||||||
|
//!
|
||||||
|
//! The frames here are small and the "device limit" is a number passed in,
|
||||||
|
//! so the tiling is exercised on any adapter, including one whose real limit
|
||||||
|
//! a test image could never approach.
|
||||||
|
|
||||||
|
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||||
|
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||||
|
use dr_pipeline::descriptor::{OpId, ParamId};
|
||||||
|
use dr_pipeline::framing::ANGLE;
|
||||||
|
use dr_pipeline::{tiles, Affects, EditGraph};
|
||||||
|
use dr_types::ColourSpace;
|
||||||
|
|
||||||
|
fn ctx() -> Option<GpuContext> {
|
||||||
|
match pollster::block_on(GpuContext::new_headless()) {
|
||||||
|
Ok(c) => Some(c),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("skipping: no GPU adapter ({e})");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A linear RGB frame with detail at every scale: a slow gradient for the
|
||||||
|
/// tone controls and a hash for the kernels, so a tile that read one pixel
|
||||||
|
/// off would show.
|
||||||
|
fn linear_frame(w: u32, h: u32, noise: bool) -> RawImage {
|
||||||
|
let mut data = Vec::with_capacity((w * h * 3) as usize);
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let base = 4000.0 + 30000.0 * (x as f32 / w as f32) + 12000.0 * (y as f32 / h as f32);
|
||||||
|
let hash = if noise {
|
||||||
|
((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 8000) as f32
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
for c in 0..3 {
|
||||||
|
data.push((base * (0.7 + 0.15 * c as f32) + hash) as u16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RawImage {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
data,
|
||||||
|
cfa_pattern: CfaPattern::Unknown,
|
||||||
|
black_level: [512; 4],
|
||||||
|
white_level: 65535,
|
||||||
|
wb_coeffs: [2.0, 1.0, 1.5, 1.0],
|
||||||
|
color_matrix: Some([1.6, -0.5, -0.1, -0.2, 1.4, -0.2, 0.0, -0.4, 1.4]),
|
||||||
|
samples_per_pixel: 3,
|
||||||
|
profile: None,
|
||||||
|
make: String::new(),
|
||||||
|
model: String::new(),
|
||||||
|
crop: CropRect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render `graph` over `source` at `size` and read it back.
|
||||||
|
fn render(
|
||||||
|
pass: &mut AdjustPass,
|
||||||
|
graph: &EditGraph,
|
||||||
|
source: &DemosaicedImage,
|
||||||
|
size: (u32, u32),
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
|
let detail = graph.compose_detail(source.size(), size);
|
||||||
|
let key = graph.invalidation().through(Affects::Colour);
|
||||||
|
pass.render_detailed(source, &shader, size.0, size.1, None, &detail, key)
|
||||||
|
.expect("render");
|
||||||
|
pass.export_pixels().expect("readback").0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frame at full resolution, a tile at a time, each from its own window.
|
||||||
|
fn render_tiled(
|
||||||
|
ctx: &GpuContext,
|
||||||
|
pass: &mut AdjustPass,
|
||||||
|
graph: &mut EditGraph,
|
||||||
|
raw: &RawImage,
|
||||||
|
max_edge: u32,
|
||||||
|
) -> (Vec<u8>, usize) {
|
||||||
|
let frame = (raw.crop.width, raw.crop.height);
|
||||||
|
let out = graph.output_size(frame.0, frame.1);
|
||||||
|
let reach = graph.compose_detail(frame, out).reach();
|
||||||
|
let plan = tiles::plan(out, max_edge, reach).expect("a plan");
|
||||||
|
let mut pixels = vec![0u8; (out.0 * out.1 * 4) as usize];
|
||||||
|
for t in &plan {
|
||||||
|
graph.framing_mut().set_view(t.view(out));
|
||||||
|
let r = graph.source_region(frame, 0);
|
||||||
|
let x0 = (r.x * frame.0 as f32).floor() as u32;
|
||||||
|
let y0 = (r.y * frame.1 as f32).floor() as u32;
|
||||||
|
let x1 = ((r.x + r.width) * frame.0 as f32).ceil() as u32;
|
||||||
|
let y1 = ((r.y + r.height) * frame.1 as f32).ceil() as u32;
|
||||||
|
let window = DemosaicedImage::linear_rgb16_window(ctx, raw, [x0, y0, x1 - x0, y1 - y0], 1)
|
||||||
|
.expect("window");
|
||||||
|
assert_eq!(window.size(), frame, "a window measures the frame");
|
||||||
|
let tile = render(pass, graph, &window, (t.grown[2], t.grown[3]));
|
||||||
|
let (ox, oy) = t.keep_offset();
|
||||||
|
for row in 0..t.keep[3] {
|
||||||
|
let src = (((oy + row) * t.grown[2] + ox) * 4) as usize;
|
||||||
|
let dst = (((t.keep[1] + row) * out.0 + t.keep[0]) * 4) as usize;
|
||||||
|
let n = (t.keep[2] * 4) as usize;
|
||||||
|
pixels[dst..dst + n].copy_from_slice(&tile[src..src + n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graph
|
||||||
|
.framing_mut()
|
||||||
|
.set_view(dr_pipeline::CropRect::default());
|
||||||
|
(pixels, plan.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn largest_difference(a: &[u8], b: &[u8]) -> u8 {
|
||||||
|
a.iter()
|
||||||
|
.zip(b)
|
||||||
|
.map(|(x, y)| x.abs_diff(*y))
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tiles_of_windows_are_the_whole_frame() {
|
||||||
|
// Point operations only, unrotated: every output pixel is an exact load
|
||||||
|
// of one source texel, so the tiled frame has to be the whole one to
|
||||||
|
// the bit.
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let raw = linear_frame(200, 120, true);
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
graph.set_param(OpId("exposure"), ParamId("exposure"), 0.7);
|
||||||
|
let mut pass = AdjustPass::new(&ctx);
|
||||||
|
|
||||||
|
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
|
||||||
|
assert!(whole.is_whole());
|
||||||
|
let reference = render(&mut pass, &graph, &whole, (200, 120));
|
||||||
|
let (tiled, n) = render_tiled(&ctx, &mut pass, &mut graph, &raw, 64);
|
||||||
|
assert!(n > 4, "the frame should have been cut, got {n} tile(s)");
|
||||||
|
assert_eq!(largest_difference(&reference, &tiled), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_straightened_frame_with_clarity_tiles_without_seams() {
|
||||||
|
// The hard case: a free angle samples between texels, and clarity reads
|
||||||
|
// a wide neighbourhood on a reduced grid. The halo and the grid
|
||||||
|
// alignment are what keep the tiles' edges out of the picture; a code
|
||||||
|
// value of rounding is all that may differ.
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let raw = linear_frame(320, 208, true);
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
graph.set_param(OpId("clarity"), ParamId("amount"), 60.0);
|
||||||
|
graph.framing_mut().set_param(ANGLE, 3.0);
|
||||||
|
let mut pass = AdjustPass::new(&ctx);
|
||||||
|
|
||||||
|
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
|
||||||
|
let out = graph.output_size(320, 208);
|
||||||
|
let reference = render(&mut pass, &graph, &whole, out);
|
||||||
|
let (tiled, n) = render_tiled(&ctx, &mut pass, &mut graph, &raw, 160);
|
||||||
|
assert!(n > 1, "the frame should have been cut, got {n} tile(s)");
|
||||||
|
let worst = largest_difference(&reference, &tiled);
|
||||||
|
assert!(
|
||||||
|
worst <= 1,
|
||||||
|
"tiles differ from the whole frame by {worst} code values"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reduced_copy_stands_for_the_whole_frame() {
|
||||||
|
// The canvas at fit renders from a copy reduced to fit the device. It
|
||||||
|
// must measure the photograph, not itself, or a crop drawn on it lands
|
||||||
|
// somewhere else in the export; and rendered small it must look like the
|
||||||
|
// full frame rendered small.
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let raw = linear_frame(400, 240, false);
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
graph.set_crop(dr_pipeline::CropRect {
|
||||||
|
x: 0.25,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.5,
|
||||||
|
height: 0.6,
|
||||||
|
});
|
||||||
|
let mut pass = AdjustPass::new(&ctx);
|
||||||
|
|
||||||
|
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
|
||||||
|
let reduced = DemosaicedImage::linear_rgb16_window(&ctx, &raw, [0, 0, 400, 240], 3).unwrap();
|
||||||
|
assert_eq!(reduced.size(), (400, 240));
|
||||||
|
assert_eq!(reduced.texture_size(), (134, 80));
|
||||||
|
assert!(!reduced.is_whole());
|
||||||
|
|
||||||
|
let size = (50, 36);
|
||||||
|
let a = render(&mut pass, &graph, &whole, size);
|
||||||
|
let b = render(&mut pass, &graph, &reduced, size);
|
||||||
|
let worst = largest_difference(&a, &b);
|
||||||
|
assert!(
|
||||||
|
worst <= 3,
|
||||||
|
"the reduced copy renders {worst} code values away"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//! TRACES: FR-DEV-3j | FR-DEV-2
|
||||||
|
//! The view transform, end to end on a device.
|
||||||
|
//!
|
||||||
|
//! `dr-pipeline` checks the curve on the CPU and that the composer emits it in
|
||||||
|
//! the right place. Neither would notice a shader that disagreed with the CPU
|
||||||
|
//! reference, or a clamp somewhere upstream that made two highlights the same
|
||||||
|
//! number before the curve ever saw them — which is exactly what the retired
|
||||||
|
//! base curve did, and why D19 exists. So this renders real pixels.
|
||||||
|
|
||||||
|
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||||
|
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||||
|
use dr_pipeline::view::Sigmoid;
|
||||||
|
use dr_pipeline::EditGraph;
|
||||||
|
|
||||||
|
const SIZE: u32 = 16;
|
||||||
|
|
||||||
|
fn ctx() -> Option<GpuContext> {
|
||||||
|
pollster::block_on(GpuContext::new_headless()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flat RGGB frame at `level` out of 65535, with an identity matrix and a
|
||||||
|
/// neutral balance, so the only things that move a pixel are the edit and the
|
||||||
|
/// view transform.
|
||||||
|
fn flat_raw(level: u16) -> RawImage {
|
||||||
|
RawImage {
|
||||||
|
width: SIZE,
|
||||||
|
height: SIZE,
|
||||||
|
data: vec![level; (SIZE * SIZE) as usize],
|
||||||
|
cfa_pattern: CfaPattern::Rggb,
|
||||||
|
black_level: [0; 4],
|
||||||
|
white_level: u16::MAX,
|
||||||
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||||
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||||
|
samples_per_pixel: 1,
|
||||||
|
profile: None,
|
||||||
|
make: String::new(),
|
||||||
|
model: String::new(),
|
||||||
|
crop: CropRect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: SIZE,
|
||||||
|
height: SIZE,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render `graph` over a flat frame and return the centre pixel's red.
|
||||||
|
///
|
||||||
|
/// The centre rather than a corner: a demosaic has to invent its edges.
|
||||||
|
fn rendered(ctx: &GpuContext, level: u16, graph: &EditGraph) -> u8 {
|
||||||
|
let source = Demosaicer::new(ctx)
|
||||||
|
.expect("demosaicer")
|
||||||
|
.run(&flat_raw(level))
|
||||||
|
.expect("demosaic");
|
||||||
|
let shader = graph.compose();
|
||||||
|
let mut adjust = AdjustPass::new(ctx);
|
||||||
|
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||||
|
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||||
|
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
||||||
|
pixels[centre as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_shader_agrees_with_the_cpu_reference() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("skipping: no GPU adapter");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let curve = Sigmoid::default_curve();
|
||||||
|
let graph = EditGraph::default_chain();
|
||||||
|
for level in [0u16, 500, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
|
||||||
|
let scene = f32::from(level) / f32::from(u16::MAX);
|
||||||
|
let display = curve.channel(scene).min(1.0);
|
||||||
|
let expected = (dr_types::Transfer::Srgb.encode(display) * 255.0).round() as i32;
|
||||||
|
let got = i32::from(rendered(&ctx, level, &graph));
|
||||||
|
// Two 8-bit steps, for the `Rgba16Float` intermediate and the
|
||||||
|
// rounding either side of the encode.
|
||||||
|
assert!(
|
||||||
|
(got - expected).abs() <= 2,
|
||||||
|
"raw {level} rendered as {got}, expected about {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlights_above_one_stay_distinct() {
|
||||||
|
// TRACES: FR-DEV-2 | FR-DEV-3j
|
||||||
|
// The failure D19 names first. Two stops of exposure put these two
|
||||||
|
// frames at 1.0 and 1.5 of sensor saturation. The base curve was flat
|
||||||
|
// past 1.0, so both rendered as the same white; the view transform's
|
||||||
|
// shoulder still separates them.
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("skipping: no GPU adapter");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
graph.set_param(
|
||||||
|
dr_pipeline::ops::exposure::ID,
|
||||||
|
dr_pipeline::ops::exposure::EXPOSURE,
|
||||||
|
2.0,
|
||||||
|
);
|
||||||
|
let lower = rendered(&ctx, u16::MAX / 4, &graph);
|
||||||
|
let upper = rendered(&ctx, (u16::MAX / 8) * 3, &graph);
|
||||||
|
assert!(
|
||||||
|
upper > lower,
|
||||||
|
"scene 1.0 rendered {lower} and scene 1.5 rendered {upper}"
|
||||||
|
);
|
||||||
|
assert!(upper < 255, "scene 1.5 is below the default white point");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_rendering_is_monotone_through_the_whole_range() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// A dip anywhere puts a dark band across a smooth gradient — a sky, most
|
||||||
|
// visibly.
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("skipping: no GPU adapter");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let graph = EditGraph::default_chain();
|
||||||
|
let mut last = 0u8;
|
||||||
|
for step in 0..=32u32 {
|
||||||
|
let level = (step * u32::from(u16::MAX) / 32) as u16;
|
||||||
|
let got = rendered(&ctx, level, &graph);
|
||||||
|
assert!(got >= last, "raw {level} rendered {got}, below {last}");
|
||||||
|
last = got;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render `graph` over a flat frame through `render_detailed`, the path every
|
||||||
|
/// frontend takes, and return the centre pixel's red.
|
||||||
|
fn rendered_detailed(ctx: &GpuContext, level: u16, graph: &EditGraph) -> (u8, AdjustPass) {
|
||||||
|
let source = Demosaicer::new(ctx)
|
||||||
|
.expect("demosaicer")
|
||||||
|
.run(&flat_raw(level))
|
||||||
|
.expect("demosaic");
|
||||||
|
let shader = graph.compose_for(dr_types::ColourSpace::Srgb);
|
||||||
|
let detail = graph.compose_detail(source.size(), (SIZE, SIZE));
|
||||||
|
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
|
||||||
|
let mut adjust = AdjustPass::new(ctx);
|
||||||
|
adjust
|
||||||
|
.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, key)
|
||||||
|
.expect("render");
|
||||||
|
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||||
|
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
||||||
|
(pixels[centre as usize], adjust)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_detail_stage_renders_through_the_view_pass_unchanged() {
|
||||||
|
// TRACES: FR-DEV-3j | FR-DEV-2
|
||||||
|
// With a detail stage the view transform is a dispatch of its own after
|
||||||
|
// it (D19). Sharpening a flat field changes nothing, so the same frame
|
||||||
|
// with and without it must render the same: the view pass read the detail
|
||||||
|
// stage's result, applied the view transform once, and encoded once.
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("skipping: no GPU adapter");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let plain = rendered(&ctx, 8_520, &EditGraph::default_chain());
|
||||||
|
|
||||||
|
let mut sharpened = EditGraph::default_chain();
|
||||||
|
let id = dr_pipeline::ops::capture_sharpen::ID;
|
||||||
|
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::AMOUNT, 100.0);
|
||||||
|
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::RADIUS, 1.0);
|
||||||
|
let (detailed, pass) = rendered_detailed(&ctx, 8_520, &sharpened);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
pass.detail_dispatches() > 0,
|
||||||
|
"the premise: a detail stage ran"
|
||||||
|
);
|
||||||
|
assert_eq!(pass.view_dispatches(), 1);
|
||||||
|
assert!(
|
||||||
|
detailed.abs_diff(plain) <= 1,
|
||||||
|
"with a detail stage {detailed}, without {plain}"
|
||||||
|
);
|
||||||
|
}
|
||||||
+142
-6
@@ -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);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ pub mod projection;
|
|||||||
#[cfg(feature = "xfeat")]
|
#[cfg(feature = "xfeat")]
|
||||||
pub mod xfeat;
|
pub mod xfeat;
|
||||||
|
|
||||||
pub use align::{align, AlignOptions, Alignment, Link, Unaligned};
|
pub use align::{align, match_pairs, solve, AlignOptions, Alignment, Link, Pairs, Unaligned};
|
||||||
pub use bundle::Cameras;
|
pub use bundle::Cameras;
|
||||||
pub use features::{Features, Keypoint};
|
pub use features::{Features, Keypoint};
|
||||||
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
|
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ pub struct Section {
|
|||||||
/// A stable identifier, for a frontend that remembers which sections a
|
/// A stable identifier, for a frontend that remembers which sections a
|
||||||
/// photographer folded away. Never shown.
|
/// photographer folded away. Never shown.
|
||||||
pub id: &'static str,
|
pub id: &'static str,
|
||||||
/// What the section is called on screen.
|
/// What the section is called on screen, as a category path: `/`
|
||||||
|
/// separates the levels, so `Film/Colour` is a folder inside `Film`. The
|
||||||
|
/// same spelling a photographer's own preset names use for theirs.
|
||||||
pub title: &'static str,
|
pub title: &'static str,
|
||||||
/// The presets in it, every one reaching only what it names.
|
/// The presets in it, every one reaching only what it names.
|
||||||
pub presets: PresetLibrary,
|
pub presets: PresetLibrary,
|
||||||
@@ -65,17 +67,17 @@ const SECTIONS: &[(&str, &str, &str)] = &[
|
|||||||
("skies", "Skies", include_str!("../presets/skies.drpl")),
|
("skies", "Skies", include_str!("../presets/skies.drpl")),
|
||||||
(
|
(
|
||||||
"colour_film",
|
"colour_film",
|
||||||
"Colour film",
|
"Film/Colour",
|
||||||
include_str!("../presets/colour_film.drpl"),
|
include_str!("../presets/colour_film.drpl"),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"cinema_film",
|
"cinema_film",
|
||||||
"Cinema film",
|
"Film/Cinema",
|
||||||
include_str!("../presets/cinema_film.drpl"),
|
include_str!("../presets/cinema_film.drpl"),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"bw_film",
|
"bw_film",
|
||||||
"Black and white film",
|
"Film/Black and white",
|
||||||
include_str!("../presets/bw_film.drpl"),
|
include_str!("../presets/bw_film.drpl"),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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, ¶ms, &uniform_names, &helper_names)?;
|
let tests = read_tests(root, ¶ms, &uniform_names, &helper_names)?;
|
||||||
let presentation = read_presentation(root, ¶m_names)?;
|
let presentation = read_presentation(root, ¶m_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,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -659,10 +659,10 @@ impl Attribute {
|
|||||||
///
|
///
|
||||||
/// `Effect` after `Colour` is a look laid over a settled picture — and is
|
/// `Effect` after `Colour` is a look laid over a settled picture — and is
|
||||||
/// the one arguable slot. A spectral film simulation declares
|
/// the one arguable slot. A spectral film simulation declares
|
||||||
/// [`crate::Operation::renders`] and replaces the base curve, which is an
|
/// [`crate::Operation::renders`] and takes the view transform's place at
|
||||||
/// argument for treating it as foundational rather than final; an array of
|
/// the very end of the chain (D19), which is an argument for treating it as
|
||||||
/// six cannot say "last, except when it is first". The tension is recorded
|
/// the rendering rather than one effect among others; an array of six
|
||||||
/// here rather than settled.
|
/// cannot say that. The tension is recorded here rather than settled.
|
||||||
///
|
///
|
||||||
/// Both ends were wrong for as long as this list only fed a row of chips
|
/// Both ends were wrong for as long as this list only fed a row of chips
|
||||||
/// nobody reads in order. It stopped being harmless when the same list
|
/// nobody reads in order. It stopped being harmless when the same list
|
||||||
|
|||||||
+192
-252
@@ -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(¬hing), 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1298,7 +1298,8 @@ impl Framing {
|
|||||||
// count active stages, and a neutral graph must generate none.
|
// count active stages, and a neutral graph must generate none.
|
||||||
if !self.is_active() {
|
if !self.is_active() {
|
||||||
return " // Source position, normalised and centred: the whole frame, unrotated.
|
return " // Source position, normalised and centred: the whole frame, unrotated.
|
||||||
let src_dims = textureDimensions(source);
|
let tex_dims = textureDimensions(source);
|
||||||
|
let src_dims = select(tex_dims, vec2<u32>(u.source_full.xy), u.source_full.x > 0.5);
|
||||||
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
||||||
let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
||||||
var p = (uv - vec2<f32>(0.5)) * aspect;
|
var p = (uv - vec2<f32>(0.5)) * aspect;
|
||||||
@@ -1314,7 +1315,8 @@ impl Framing {
|
|||||||
// warp chain expects: the centre is (0, 0) and the radius is 1 at the
|
// warp chain expects: the centre is (0, 0) and the radius is 1 at the
|
||||||
// corner. Working here rather than in pixels is what makes the map
|
// corner. Working here rather than in pixels is what makes the map
|
||||||
// independent of the resolution being rendered at.
|
// independent of the resolution being rendered at.
|
||||||
let src_dims = textureDimensions(source);
|
let tex_dims = textureDimensions(source);
|
||||||
|
let src_dims = select(tex_dims, vec2<u32>(u.source_full.xy), u.source_full.x > 0.5);
|
||||||
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
||||||
var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
||||||
",
|
",
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ impl ParamCapability {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-2
|
||||||
|
/// How far past the framing's own footprint [`EditGraph::source_region`]
|
||||||
|
/// reaches when a lens warp is active, as a fraction of the frame on each
|
||||||
|
/// side. Distortion profiles move a corner by a few per cent of the frame; a
|
||||||
|
/// window short of what the warp reads would render the missing strip black.
|
||||||
|
pub const WARP_MARGIN: f32 = 0.04;
|
||||||
|
|
||||||
/// An ordered pipeline of operations, plus how the result is framed.
|
/// An ordered pipeline of operations, plus how the result is framed.
|
||||||
pub struct EditGraph {
|
pub struct EditGraph {
|
||||||
ops: Vec<Box<dyn Operation>>,
|
ops: Vec<Box<dyn Operation>>,
|
||||||
@@ -292,6 +299,57 @@ impl EditGraph {
|
|||||||
self.framing.output_size(width, height)
|
self.framing.output_size(width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||||||
|
/// The part of the source the visible region reads, as a rectangle in
|
||||||
|
/// normalised source coordinates, clamped to the frame.
|
||||||
|
///
|
||||||
|
/// For a photograph larger than one texture: a render of part of it —
|
||||||
|
/// the canvas zoomed in, one tile of an export — binds only this window
|
||||||
|
/// of the source (see `dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS`).
|
||||||
|
///
|
||||||
|
/// The framing is walked on the CPU with [`Framing::source_at`], along
|
||||||
|
/// the border and across the interior, so a straightened or keystoned
|
||||||
|
/// view gets the box around the quadrilateral it actually reads. The lens
|
||||||
|
/// warps have no CPU mirror, so when one is active the box is widened
|
||||||
|
/// by [`WARP_MARGIN`] of the frame on each side: a distortion profile
|
||||||
|
/// moves a corner by a few per cent of the frame at most. `halo`, in
|
||||||
|
/// source pixels, is added on top — the detail stage's reach, which reads
|
||||||
|
/// beyond the pixels it writes.
|
||||||
|
pub fn source_region(&self, source: (u32, u32), halo: u32) -> crate::framing::CropRect {
|
||||||
|
const STEPS: usize = 16;
|
||||||
|
let (sw, sh) = (source.0.max(1), source.1.max(1));
|
||||||
|
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||||
|
for j in 0..=STEPS {
|
||||||
|
for i in 0..=STEPS {
|
||||||
|
let out = (i as f32 / STEPS as f32, j as f32 / STEPS as f32);
|
||||||
|
let (x, y) = self.framing.source_at(out, sw, sh);
|
||||||
|
x0 = x0.min(x);
|
||||||
|
y0 = y0.min(y);
|
||||||
|
x1 = x1.max(x);
|
||||||
|
y1 = y1.max(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let warp = if crate::lens::compose_warps(&self.warps).is_active() {
|
||||||
|
WARP_MARGIN
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
// Two pixels beyond the halo: the bilinear tap's second texel, and
|
||||||
|
// the rounding of the box to whole pixels by the caller.
|
||||||
|
let px = (halo as f32 + 2.0) / sw as f32;
|
||||||
|
let py = (halo as f32 + 2.0) / sh as f32;
|
||||||
|
let x0 = (x0 - warp - px).clamp(0.0, 1.0);
|
||||||
|
let y0 = (y0 - warp - py).clamp(0.0, 1.0);
|
||||||
|
let x1 = (x1 + warp + px).clamp(0.0, 1.0);
|
||||||
|
let y1 = (y1 + warp + py).clamp(0.0, 1.0);
|
||||||
|
crate::framing::CropRect {
|
||||||
|
x: x0,
|
||||||
|
y: y0,
|
||||||
|
width: (x1 - x0).max(0.0),
|
||||||
|
height: (y1 - y0).max(0.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Descriptors for every operation, in order.
|
/// Descriptors for every operation, in order.
|
||||||
///
|
///
|
||||||
/// Operations only — framing is not one, and is reached through
|
/// Operations only — framing is not one, and is reached through
|
||||||
@@ -952,46 +1010,35 @@ impl EditGraph {
|
|||||||
((fw as f32 * view.width).round() as u32).max(1),
|
((fw as f32 * view.width).round() as u32).max(1),
|
||||||
((fh as f32 * view.height).round() as u32).max(1),
|
((fh as f32 * view.height).round() as u32).max(1),
|
||||||
);
|
);
|
||||||
crate::detail::RenderScale::new(render, full)
|
crate::detail::RenderScale::new(render, full).within((fw, fh))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3 | FR-DSP-1
|
/// TRACES: FR-DEV-3 | FR-DSP-1
|
||||||
/// Generate the detail stage for this edit at one resolution, to sRGB.
|
/// Generate the detail stage for this edit at one resolution.
|
||||||
///
|
///
|
||||||
/// Empty for every edit with no active neighbourhood operation, which is
|
/// Empty for every edit with no active neighbourhood operation, which is
|
||||||
/// almost all of them — and in that case [`Self::compose`] emits the
|
/// almost all of them — and in that case [`Self::compose`] emits the
|
||||||
/// single encoded dispatch it always has.
|
/// single encoded dispatch it always has.
|
||||||
pub fn compose_detail(
|
|
||||||
&self,
|
|
||||||
source: (u32, u32),
|
|
||||||
render: (u32, u32),
|
|
||||||
) -> crate::detail::ComposedDetail {
|
|
||||||
self.compose_detail_for(source, render, dr_types::ColourSpace::Srgb)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TRACES: FR-EXP-2
|
|
||||||
/// The detail stage, encoded into a chosen output space.
|
|
||||||
///
|
///
|
||||||
/// The space belongs here as well as on [`Self::compose_for`] because when
|
/// No output space: since D19 no detail pass encodes. The fused pass's
|
||||||
/// a detail stage exists it is the *last* pass that performs the output
|
/// view pass reads what the last one wrote and performs the view transform
|
||||||
/// transform — the fused pass stops at linear working values. Composing
|
/// and the output transform, so it is [`Self::compose_for`] alone that
|
||||||
/// the two halves for different spaces would encode the edit twice, or
|
/// names the space.
|
||||||
/// not at all.
|
///
|
||||||
/// `source` is the demosaiced image's size and `render` the size being
|
/// `source` is the demosaiced image's size and `render` the size being
|
||||||
/// drawn. The scale is worked out here rather than handed in, because the
|
/// drawn. The scale is worked out here rather than handed in, because the
|
||||||
/// repairs need the *source* size as well — a spot is stored in normalised
|
/// repairs need the *source* size as well — a spot is stored in normalised
|
||||||
/// source coordinates and has to be put through the framing to find out
|
/// source coordinates and has to be put through the framing to find out
|
||||||
/// where it lands on this render, and a [`crate::detail::RenderScale`]
|
/// where it lands on this render, and a [`crate::detail::RenderScale`]
|
||||||
/// describes the region on screen rather than the photograph.
|
/// describes the region on screen rather than the photograph.
|
||||||
pub fn compose_detail_for(
|
pub fn compose_detail(
|
||||||
&self,
|
&self,
|
||||||
source: (u32, u32),
|
source: (u32, u32),
|
||||||
render: (u32, u32),
|
render: (u32, u32),
|
||||||
output: dr_types::ColourSpace,
|
|
||||||
) -> crate::detail::ComposedDetail {
|
) -> crate::detail::ComposedDetail {
|
||||||
let scale = self.render_scale(source, render);
|
let scale = self.render_scale(source, render);
|
||||||
let spots = self.spots.passes(&self.framing, source, scale);
|
let spots = self.spots.passes(&self.framing, source, scale);
|
||||||
crate::detail::compose_detail_with(&self.ops, &spots, scale, output)
|
crate::detail::compose_detail_with(&self.ops, &spots, scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3d
|
/// TRACES: FR-DEV-3d
|
||||||
@@ -1122,13 +1169,21 @@ mod tests {
|
|||||||
fn a_fresh_graph_is_neutral() {
|
fn a_fresh_graph_is_neutral() {
|
||||||
// Opening an unedited image must produce the image, not an
|
// Opening an unedited image must produce the image, not an
|
||||||
// interpretation of it.
|
// interpretation of it.
|
||||||
|
//
|
||||||
|
// One block, and it is the view transform: a view operation is
|
||||||
|
// composed at its defaults, because a photograph with no view
|
||||||
|
// transform is a scan rather than a picture (FR-DEV-3j). It is still
|
||||||
|
// neutral in the sense that matters here — nothing moved, nothing is
|
||||||
|
// written — and every adjustment is absent.
|
||||||
let g = EditGraph::default_chain();
|
let g = EditGraph::default_chain();
|
||||||
assert!(g.is_neutral());
|
assert!(g.is_neutral());
|
||||||
|
let source = g.compose().source;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
g.compose().source.matches("---- ").count(),
|
source.matches("---- ").count(),
|
||||||
0,
|
1,
|
||||||
"a neutral graph must generate no operation blocks"
|
"a neutral graph must generate no adjustment blocks"
|
||||||
);
|
);
|
||||||
|
assert!(source.contains("---- view_transform ----"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1207,13 +1262,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn only_active_operations_reach_the_shader() {
|
fn only_active_operations_reach_the_shader() {
|
||||||
// The composition property, end to end: two adjustments out of seven
|
// The composition property, end to end: two adjustments out of seven
|
||||||
// available must generate a shader doing exactly two things.
|
// available must generate a shader doing exactly two things — and
|
||||||
|
// the view transform, which every render has (FR-DEV-3j).
|
||||||
let mut g = EditGraph::default_chain();
|
let mut g = EditGraph::default_chain();
|
||||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||||
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
|
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
|
||||||
|
|
||||||
let shader = g.compose();
|
let shader = g.compose();
|
||||||
assert_eq!(shader.source.matches("---- ").count(), 2);
|
assert_eq!(shader.source.matches("---- ").count(), 3);
|
||||||
|
assert!(shader.source.contains("---- view_transform ----"));
|
||||||
assert!(shader.source.contains("---- exposure ----"));
|
assert!(shader.source.contains("---- exposure ----"));
|
||||||
assert!(shader.source.contains("---- white_balance ----"));
|
assert!(shader.source.contains("---- white_balance ----"));
|
||||||
assert!(!shader.source.contains("---- saturation ----"));
|
assert!(!shader.source.contains("---- saturation ----"));
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ pub mod preset;
|
|||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
pub mod spot;
|
pub mod spot;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod tiles;
|
||||||
|
pub mod view;
|
||||||
|
|
||||||
pub use coverage::Coverage;
|
pub use coverage::Coverage;
|
||||||
pub use declared::{Declaration, DeclaredOp};
|
pub use declared::{Declaration, DeclaredOp};
|
||||||
@@ -66,8 +68,8 @@ pub use history::{Edit, Entry as HistoryEntry, History, Step};
|
|||||||
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
|
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
|
||||||
pub use operation::{
|
pub use operation::{
|
||||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
||||||
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET,
|
OutputMode, Stage, Uniform, CLIP_ONSET, RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
|
||||||
RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
|
SOURCE_WINDOW_UNIFORM_FIELDS, SOURCE_WINDOW_UNIFORM_OFFSET, WHOLE_SOURCE_WINDOW,
|
||||||
};
|
};
|
||||||
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Reach, Scope};
|
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Reach, Scope};
|
||||||
pub use sidecar::{Sidecar, Version};
|
pub use sidecar::{Sidecar, Version};
|
||||||
@@ -169,7 +171,21 @@ mod tests {
|
|||||||
let mut fused_blocks = 0;
|
let mut fused_blocks = 0;
|
||||||
for desc in g.descriptors() {
|
for desc in g.descriptors() {
|
||||||
let id = desc.id.0;
|
let id = desc.id.0;
|
||||||
let point = shader.source.contains(&format!("---- {id} ----"));
|
// The film is loaded here, and a stock is a rendering: the view
|
||||||
|
// transform it replaces is correctly in neither stage (FR-DEV-3f,
|
||||||
|
// FR-DEV-3j).
|
||||||
|
if id == crate::ops::view_transform::ID.0 {
|
||||||
|
assert!(!shader.source.contains("---- view_transform ----"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A view operation is in the view pass when a detail stage
|
||||||
|
// follows, which it does here (D19).
|
||||||
|
let block = format!("---- {id} ----");
|
||||||
|
let point = shader.source.contains(&block)
|
||||||
|
|| shader
|
||||||
|
.view
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|v| v.source.contains(&block));
|
||||||
let neighbourhood = detail
|
let neighbourhood = detail
|
||||||
.passes
|
.passes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -182,8 +198,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
fused_blocks += usize::from(point);
|
fused_blocks += usize::from(point);
|
||||||
}
|
}
|
||||||
|
let view_blocks = shader
|
||||||
|
.view
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |v| v.source.matches("---- ").count());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
shader.source.matches("---- ").count(),
|
shader.source.matches("---- ").count() + view_blocks,
|
||||||
fused_blocks,
|
fused_blocks,
|
||||||
"the fused shader carries a block nothing in the chain asked for"
|
"the fused shader carries a block nothing in the chain asked for"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+512
-329
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
//! TRACES: FR-DEV-3f
|
//! TRACES: FR-DEV-3f
|
||||||
//! Film simulation — the stock renders the picture.
|
//! Film simulation — the stock renders the picture.
|
||||||
//!
|
//!
|
||||||
//! # Why this one replaces the base curve
|
//! # Why this one is the view transform
|
||||||
//!
|
//!
|
||||||
//! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The base
|
//! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The view
|
||||||
//! curve exists because sensor data is scene-referred and nothing anybody looks
|
//! transform exists because sensor data is scene-referred and nothing anybody
|
||||||
//! at is (FR-DEV-3e); a film stock's characteristic curve does the same job,
|
//! looks at is (FR-DEV-3j); a film stock's characteristic curve does the same
|
||||||
//! from measurements, with a toe and a shoulder that were coated onto acetate
|
//! job, from measurements, with a toe and a shoulder that were coated onto
|
||||||
//! rather than drawn. Running both renders the image twice — the camera's
|
//! acetate rather than drawn. Running both renders the image twice — the
|
||||||
//! JPEG-ish rendering, and then a film's rendering of that — which is not what
|
//! default rendering, and then a film's rendering of that — which is not what
|
||||||
//! either is for and looks like neither.
|
//! either is for and looks like neither.
|
||||||
//!
|
//!
|
||||||
//! So this node declares [`Operation::renders`], and the composer answers by
|
//! So this node is in [`Stage::View`] and declares [`Operation::renders`]: when
|
||||||
//! emitting neither the base curve nor the camera matrix. Both jobs move here:
|
//! a stock is loaded the composer puts it at the end of the chain in place of
|
||||||
//! the fragment takes camera RGB, converts it to linear sRGB itself with the
|
//! the default sigmoid (D19). It is handed working-space colour — linear sRGB
|
||||||
//! matrix already in the uniform block, and returns linear sRGB. That is a
|
//! primaries, scene-referred, after every other operation and after the detail
|
||||||
//! contract worth stating plainly, because a node that got half of it wrong
|
//! stage — and returns display-referred linear sRGB for the output transform.
|
||||||
//! would produce a picture that renders perfectly and is wrong everywhere.
|
//! Before D19 it ran at order 25, after exposure and before everything else,
|
||||||
|
//! and the operations below it acted on its output. They now act on the scene
|
||||||
|
//! it is shown: an edit is a decision about the exposure the negative
|
||||||
|
//! receives, and the film is the last thing that happens to the picture.
|
||||||
//!
|
//!
|
||||||
//! # Why the tables are not parameters
|
//! # Why the tables are not parameters
|
||||||
//!
|
//!
|
||||||
@@ -34,7 +37,7 @@
|
|||||||
use std::sync::{Arc, LazyLock};
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||||
use crate::operation::{Operation, Uniform};
|
use crate::operation::{Operation, Stage, Uniform};
|
||||||
|
|
||||||
pub const ID: OpId = OpId("film_sim");
|
pub const ID: OpId = OpId("film_sim");
|
||||||
pub const EXPOSURE: ParamId = ParamId("exposure");
|
pub const EXPOSURE: ParamId = ParamId("exposure");
|
||||||
@@ -304,11 +307,17 @@ impl Operation for FilmSim {
|
|||||||
self.tables.is_some()
|
self.tables.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This node renders; the camera's own rendering must not also run.
|
/// This node renders; the default view transform must not also run.
|
||||||
fn renders(&self) -> bool {
|
fn renders(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3f | FR-DEV-3j
|
||||||
|
/// The view transform's place, at the end of the chain (D19).
|
||||||
|
fn stage(&self) -> Stage {
|
||||||
|
Stage::View
|
||||||
|
}
|
||||||
|
|
||||||
fn set_film_tables(&mut self, tables: Option<&FilmTables>) {
|
fn set_film_tables(&mut self, tables: Option<&FilmTables>) {
|
||||||
self.set_tables(tables.cloned());
|
self.set_tables(tables.cloned());
|
||||||
}
|
}
|
||||||
@@ -396,14 +405,10 @@ impl Operation for FilmSim {
|
|||||||
// sampler binding, and adding one to interpolate two lookups would
|
// sampler binding, and adding one to interpolate two lookups would
|
||||||
// cost a binding in every shader whether or not a film is loaded.
|
// cost a binding in every shader whether or not a film is loaded.
|
||||||
"\
|
"\
|
||||||
// Camera RGB to linear sRGB. The film's exposure matrix is defined against
|
// Working-space colour, which is linear sRGB primaries — what the film's
|
||||||
// sRGB primaries, and this node has taken over the conversion the composer
|
// exposure matrix is defined against. The composer converted out of camera
|
||||||
// would otherwise have emitted at the end — see `Operation::renders`.
|
// RGB before any scene-stage operation ran (D19).
|
||||||
let scene = vec3<f32>(
|
let scene = c;
|
||||||
dot(u.cam_to_srgb_0.rgb, c),
|
|
||||||
dot(u.cam_to_srgb_1.rgb, c),
|
|
||||||
dot(u.cam_to_srgb_2.rgb, c),
|
|
||||||
);
|
|
||||||
|
|
||||||
// What each emulsion layer was exposed to. A matrix, exactly: the scene
|
// What each emulsion layer was exposed to. A matrix, exactly: the scene
|
||||||
// spectrum reconstructed from an sRGB triple is linear in that triple, so the
|
// spectrum reconstructed from an sRGB triple is linear in that triple, so the
|
||||||
@@ -701,10 +706,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn it_declares_itself_a_rendering_transform() {
|
fn it_declares_itself_a_rendering_transform() {
|
||||||
// The whole reason the composer skips the base curve and the camera
|
// The whole reason the composer emits the stock in the view
|
||||||
// matrix. If this ever returned false the picture would be rendered
|
// transform's place rather than beside it. If this ever returned
|
||||||
// twice and converted twice, which looks like a colour management bug
|
// false the picture would be rendered twice, which looks like a
|
||||||
// a long way from here.
|
// colour management bug a long way from here.
|
||||||
assert!(FilmSim::new().renders());
|
assert!(FilmSim::new().renders());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,13 +732,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_fragment_converts_out_of_camera_space_itself() {
|
fn the_fragment_is_handed_working_space_colour() {
|
||||||
// It has to: it has taken over the conversion the composer would
|
// TRACES: FR-DEV-3f
|
||||||
// otherwise emit at the end.
|
// D19: the composer leaves camera space before any scene-stage
|
||||||
|
// operation, so a film converting again would apply the camera
|
||||||
|
// matrix twice.
|
||||||
let mut op = FilmSim::new();
|
let mut op = FilmSim::new();
|
||||||
op.set_tables(Some(tables()));
|
op.set_tables(Some(tables()));
|
||||||
let wgsl = op.wgsl_body();
|
let wgsl = op.wgsl_body();
|
||||||
assert!(wgsl.contains("cam_to_srgb_0"), "{wgsl}");
|
assert!(!wgsl.contains("cam_to_srgb"), "{wgsl}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ pub mod distortion;
|
|||||||
pub mod film_sim;
|
pub mod film_sim;
|
||||||
pub mod local_contrast;
|
pub mod local_contrast;
|
||||||
pub mod noise_reduction;
|
pub mod noise_reduction;
|
||||||
|
pub mod view_transform;
|
||||||
pub mod vignetting;
|
pub mod vignetting;
|
||||||
|
|
||||||
pub use aberration::Aberration;
|
pub use aberration::Aberration;
|
||||||
@@ -87,6 +88,7 @@ pub use film_sim::{FilmSim, FilmTables, PaperTables};
|
|||||||
// documentation for why that is two nodes and not one.
|
// documentation for why that is two nodes and not one.
|
||||||
pub use local_contrast::{Clarity, Texture};
|
pub use local_contrast::{Clarity, Texture};
|
||||||
pub use noise_reduction::NoiseReduction;
|
pub use noise_reduction::NoiseReduction;
|
||||||
|
pub use view_transform::ViewTransform;
|
||||||
pub use vignetting::Vignetting;
|
pub use vignetting::Vignetting;
|
||||||
|
|
||||||
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
|
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//! TRACES: FR-DEV-3j
|
||||||
|
//! The view transform as an operation: the photographer's two numbers for
|
||||||
|
//! the curve [`crate::view`] defines.
|
||||||
|
//!
|
||||||
|
//! # Why it is a node now, when the base curve could not be
|
||||||
|
//!
|
||||||
|
//! The base curve was kept out of the chain for reasons that were all about
|
||||||
|
//! the *body*: it was looked up by camera model, so as a node it would have
|
||||||
|
//! carried one camera's rendering onto another camera's file through a shared
|
||||||
|
//! sidecar, shown a dead slider on an unprofiled body, and opened a profiled
|
||||||
|
//! one reporting itself modified. D19 removed the premise. There is one view
|
||||||
|
//! transform for every body, so its settings are a decision about the picture
|
||||||
|
//! like any other, and they belong in the sidecar, the history and a mask
|
||||||
|
//! layer.
|
||||||
|
//!
|
||||||
|
//! # Why it is composed at its defaults
|
||||||
|
//!
|
||||||
|
//! A neutral operation is normally left out of the shader, and "active" means
|
||||||
|
//! "moved from its defaults". Both stay true here — an untouched photograph
|
||||||
|
//! writes no view transform parameters, and `every_node_starts_neutral` still
|
||||||
|
//! holds — but the composer emits this node whatever its state, because a
|
||||||
|
//! photograph with no view transform is a scan, not a picture. See
|
||||||
|
//! [`crate::operation::Stage::View`].
|
||||||
|
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
|
use crate::descriptor::{
|
||||||
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||||
|
};
|
||||||
|
use crate::operation::{Helper, Operation, Stage, Uniform};
|
||||||
|
use crate::view::{Sigmoid, CONTRAST_RANGE, DEFAULT_CONTRAST, DEFAULT_WHITE, WHITE_RANGE};
|
||||||
|
|
||||||
|
pub const ID: OpId = OpId("view_transform");
|
||||||
|
pub const CONTRAST: ParamId = ParamId("contrast");
|
||||||
|
pub const WHITE: ParamId = ParamId("white");
|
||||||
|
|
||||||
|
static HELPERS: [Helper; 1] = [Helper {
|
||||||
|
name: "view_sigmoid",
|
||||||
|
source: crate::view::VIEW_SIGMOID_WGSL,
|
||||||
|
}];
|
||||||
|
|
||||||
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
|
Arc::new(OpDescriptor {
|
||||||
|
// Tone: it is the tone response of the whole picture, and the panel's
|
||||||
|
// Light group is where a photographer looks for the white point.
|
||||||
|
attributes: vec![Attribute::Tone],
|
||||||
|
id: ID,
|
||||||
|
label: LocalizedKey("op.view_transform"),
|
||||||
|
params: vec![
|
||||||
|
ParamDescriptor::scalar(
|
||||||
|
"contrast",
|
||||||
|
"param.view_transform.contrast",
|
||||||
|
CONTRAST_RANGE.0,
|
||||||
|
CONTRAST_RANGE.1,
|
||||||
|
DEFAULT_CONTRAST,
|
||||||
|
Unit::None,
|
||||||
|
Scale::Linear,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
ParamDescriptor::scalar(
|
||||||
|
"white",
|
||||||
|
"param.view_transform.white",
|
||||||
|
WHITE_RANGE.0,
|
||||||
|
WHITE_RANGE.1,
|
||||||
|
DEFAULT_WHITE,
|
||||||
|
Unit::Stops,
|
||||||
|
Scale::Linear,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ViewTransform {
|
||||||
|
contrast: f32,
|
||||||
|
white: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ViewTransform {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
contrast: DEFAULT_CONTRAST,
|
||||||
|
white: DEFAULT_WHITE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViewTransform {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The curve these settings solve to.
|
||||||
|
pub fn sigmoid(&self) -> Sigmoid {
|
||||||
|
Sigmoid::new(self.contrast, self.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Operation for ViewTransform {
|
||||||
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
|
DESCRIPTOR.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
|
match id {
|
||||||
|
CONTRAST => self.contrast = value,
|
||||||
|
WHITE => self.white = value,
|
||||||
|
_ => log::warn!("view_transform: unknown parameter {id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn param(&self, id: ParamId) -> f32 {
|
||||||
|
match id {
|
||||||
|
CONTRAST => self.contrast,
|
||||||
|
WHITE => self.white,
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
self.contrast != DEFAULT_CONTRAST || self.white != DEFAULT_WHITE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stage(&self) -> Stage {
|
||||||
|
Stage::View
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wgsl_body(&self) -> String {
|
||||||
|
"\
|
||||||
|
// Skipped for an already-rendered source: a JPEG is a display rendering
|
||||||
|
// already, and rendering it again would compress it twice.
|
||||||
|
if (!non_linear) {
|
||||||
|
c = view_sigmoid(c, slope, inv_k, peak);
|
||||||
|
}"
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uniforms(&self) -> Vec<Uniform> {
|
||||||
|
let s = self.sigmoid();
|
||||||
|
vec![
|
||||||
|
Uniform {
|
||||||
|
name: "slope",
|
||||||
|
value: s.n,
|
||||||
|
},
|
||||||
|
Uniform {
|
||||||
|
name: "inv_k",
|
||||||
|
value: s.inv_k,
|
||||||
|
},
|
||||||
|
Uniform {
|
||||||
|
name: "peak",
|
||||||
|
value: s.w,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn helpers(&self) -> &[Helper] {
|
||||||
|
&HELPERS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_starts_neutral_and_says_so() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// Neutral in the sense every other node is: nothing moved, so nothing
|
||||||
|
// is written. It is still composed — see the module documentation.
|
||||||
|
let op = ViewTransform::new();
|
||||||
|
assert!(!op.is_active());
|
||||||
|
assert_eq!(op.sigmoid(), Sigmoid::default_curve());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moving_either_slider_makes_it_active() {
|
||||||
|
let mut op = ViewTransform::new();
|
||||||
|
op.set_param(WHITE, 6.0);
|
||||||
|
assert!(op.is_active());
|
||||||
|
let mut op = ViewTransform::new();
|
||||||
|
op.set_param(CONTRAST, 2.0);
|
||||||
|
assert!(op.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_uniforms_are_the_solved_curve() {
|
||||||
|
let mut op = ViewTransform::new();
|
||||||
|
op.set_param(CONTRAST, 2.0);
|
||||||
|
op.set_param(WHITE, 6.0);
|
||||||
|
let s = Sigmoid::new(2.0, 6.0);
|
||||||
|
let u = op.uniforms();
|
||||||
|
assert_eq!(
|
||||||
|
u.iter().map(|u| u.value).collect::<Vec<_>>(),
|
||||||
|
vec![s.n, s.inv_k, s.w]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_descriptor_defaults_are_the_curve_defaults() {
|
||||||
|
// The sidecar treats a value equal to the descriptor's default as
|
||||||
|
// unedited; the two disagreeing would make every photograph open
|
||||||
|
// reporting a view transform edit it never had.
|
||||||
|
let d = ViewTransform::new().descriptor();
|
||||||
|
assert_eq!(
|
||||||
|
d.param(CONTRAST).expect("contrast").default,
|
||||||
|
DEFAULT_CONTRAST
|
||||||
|
);
|
||||||
|
assert_eq!(d.param(WHITE).expect("white").default, DEFAULT_WHITE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! TRACES: FR-DSP-2 | NFR-RES-2
|
||||||
|
//! Cutting a render too large for one texture into tiles.
|
||||||
|
//!
|
||||||
|
//! The interactive path is not tiled, and on the evidence should not be
|
||||||
|
//! (`docs/dev/frame-budget.md`, TD-4): one fused dispatch over a viewport is
|
||||||
|
//! inside the frame budget, and a halo per tile nearly doubles the taps of a
|
||||||
|
//! wide kernel. What does not fit is a *file*. A 22927×8966 panorama has no
|
||||||
|
//! render target on a device whose textures stop at 16384, so its export, and
|
||||||
|
//! nothing else, is drawn a tile at a time.
|
||||||
|
//!
|
||||||
|
//! A tile is two rectangles in pixels of the framed output: the one rendered,
|
||||||
|
//! grown by the detail stage's reach ([`crate::ComposedDetail::reach`]) so
|
||||||
|
//! every kernel near its edge reads the pixels it would read untiled, and the
|
||||||
|
//! one kept, which is the tile proper. The kept rectangles cover the frame
|
||||||
|
//! exactly once.
|
||||||
|
//!
|
||||||
|
//! The rendered rectangle's origin is aligned to [`TILE_ALIGN`]. The detail
|
||||||
|
//! stage computes clarity's base on a reduced grid, and a tile starting half
|
||||||
|
//! way through a reduced texel would reduce different pixels together than
|
||||||
|
//! the untiled frame does, which shows as a faint seam.
|
||||||
|
|
||||||
|
/// A multiple of every reduced grid the detail stage uses, so a tile's
|
||||||
|
/// grids line up with the untiled frame's.
|
||||||
|
pub const TILE_ALIGN: u32 = 16;
|
||||||
|
|
||||||
|
/// One tile of a render: what to draw, and which part of it to keep.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Tile {
|
||||||
|
/// `[x, y, width, height]` in output pixels: the tile grown by the halo,
|
||||||
|
/// clamped to the frame. This is what is rendered.
|
||||||
|
pub grown: [u32; 4],
|
||||||
|
/// `[x, y, width, height]` in output pixels: the tile proper, which lies
|
||||||
|
/// inside `grown`. This is what is kept.
|
||||||
|
pub keep: [u32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tile {
|
||||||
|
/// The rendered rectangle as a view on the frame, the rectangle
|
||||||
|
/// [`crate::Framing::set_view`] takes.
|
||||||
|
pub fn view(&self, frame: (u32, u32)) -> crate::framing::CropRect {
|
||||||
|
let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32);
|
||||||
|
crate::framing::CropRect {
|
||||||
|
x: self.grown[0] as f32 / fw,
|
||||||
|
y: self.grown[1] as f32 / fh,
|
||||||
|
width: self.grown[2] as f32 / fw,
|
||||||
|
height: self.grown[3] as f32 / fh,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the kept rectangle starts inside the rendered one.
|
||||||
|
pub fn keep_offset(&self) -> (u32, u32) {
|
||||||
|
(self.keep[0] - self.grown[0], self.keep[1] - self.grown[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cut a `frame`-sized render into tiles no larger than `max_edge` once
|
||||||
|
/// grown by `halo` on every side.
|
||||||
|
///
|
||||||
|
/// Row-major, top to bottom, so a caller writing the file as it goes gets
|
||||||
|
/// its bands in order. A frame that fits whole is one tile with no halo.
|
||||||
|
/// `None` when the halo leaves no room for a tile at all — a spot heal
|
||||||
|
/// cloning from across a frame wider than the device can hold is the case,
|
||||||
|
/// and it has to be refused rather than drawn with a seam.
|
||||||
|
pub fn plan(frame: (u32, u32), max_edge: u32, halo: u32) -> Option<Vec<Tile>> {
|
||||||
|
let (fw, fh) = (frame.0.max(1), frame.1.max(1));
|
||||||
|
if fw <= max_edge && fh <= max_edge {
|
||||||
|
return Some(vec![Tile {
|
||||||
|
grown: [0, 0, fw, fh],
|
||||||
|
keep: [0, 0, fw, fh],
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
// The halo, rounded up so a grown origin lands on the grid; the tile
|
||||||
|
// proper a multiple of it for the same reason.
|
||||||
|
let halo = halo.div_ceil(TILE_ALIGN) * TILE_ALIGN;
|
||||||
|
let room = max_edge.checked_sub(2 * halo)?;
|
||||||
|
let step = room / TILE_ALIGN * TILE_ALIGN;
|
||||||
|
if step == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut y = 0;
|
||||||
|
while y < fh {
|
||||||
|
let kh = step.min(fh - y);
|
||||||
|
let mut x = 0;
|
||||||
|
while x < fw {
|
||||||
|
let kw = step.min(fw - x);
|
||||||
|
let gx = x.saturating_sub(halo);
|
||||||
|
let gy = y.saturating_sub(halo);
|
||||||
|
let gx1 = (x + kw + halo).min(fw);
|
||||||
|
let gy1 = (y + kh + halo).min(fh);
|
||||||
|
out.push(Tile {
|
||||||
|
grown: [gx, gy, gx1 - gx, gy1 - gy],
|
||||||
|
keep: [x, y, kw, kh],
|
||||||
|
});
|
||||||
|
x += kw;
|
||||||
|
}
|
||||||
|
y += kh;
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frame_that_fits_is_one_tile_with_no_halo() {
|
||||||
|
let tiles = plan((6000, 4000), 8192, 200).unwrap();
|
||||||
|
assert_eq!(tiles.len(), 1);
|
||||||
|
assert_eq!(tiles[0].grown, [0, 0, 6000, 4000]);
|
||||||
|
assert_eq!(tiles[0].keep, tiles[0].grown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_kept_rectangles_cover_the_frame_exactly_once() {
|
||||||
|
// The panorama that started this, against a 16384 device with a
|
||||||
|
// clarity-sized halo.
|
||||||
|
let frame = (22927, 8966);
|
||||||
|
let tiles = plan(frame, 16384, 230).unwrap();
|
||||||
|
let mut covered = vec![0u8; (frame.0 * frame.1) as usize];
|
||||||
|
for t in &tiles {
|
||||||
|
let [x, y, w, h] = t.keep;
|
||||||
|
for yy in y..y + h {
|
||||||
|
for xx in x..x + w {
|
||||||
|
covered[(yy * frame.0 + xx) as usize] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(covered.iter().all(|&c| c == 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_tile_fits_the_device_and_holds_its_halo() {
|
||||||
|
let frame = (22927, 8966);
|
||||||
|
let (max, halo) = (8192, 300);
|
||||||
|
for t in plan(frame, max, halo).unwrap() {
|
||||||
|
let [gx, gy, gw, gh] = t.grown;
|
||||||
|
let [kx, ky, kw, kh] = t.keep;
|
||||||
|
assert!(gw <= max && gh <= max, "{t:?} does not fit");
|
||||||
|
assert_eq!(gx % TILE_ALIGN, 0, "{t:?} starts off the grid");
|
||||||
|
assert_eq!(gy % TILE_ALIGN, 0, "{t:?} starts off the grid");
|
||||||
|
// The halo is there on every side, or the frame ends first — in
|
||||||
|
// which case the untiled render stops at the same edge.
|
||||||
|
assert!(gx == 0 || kx - gx >= halo);
|
||||||
|
assert!(gy == 0 || ky - gy >= halo);
|
||||||
|
assert!(gx + gw == frame.0 || gx + gw - (kx + kw) >= halo);
|
||||||
|
assert!(gy + gh == frame.1 || gy + gh - (ky + kh) >= halo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_halo_wider_than_the_device_is_refused() {
|
||||||
|
assert_eq!(plan((40000, 100), 16384, 9000), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
//! TRACES: FR-DEV-3j | FR-DEV-2
|
||||||
|
//! The view transform — the one stage that maps scene-linear colour to a
|
||||||
|
//! display range (D19, ARCH §6.14).
|
||||||
|
//!
|
||||||
|
//! # What it is
|
||||||
|
//!
|
||||||
|
//! A log-logistic sigmoid, per channel:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! f(x) = w · r / (1 + r), r = (x / k)^n
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `n` is the contrast — the slope in log-log terms, before the shoulder
|
||||||
|
//! bends it. `k` and `w` are solved from two conditions rather than set:
|
||||||
|
//! scene middle grey lands on display middle grey, and the scene white the
|
||||||
|
//! photographer chose lands on display white. So the curve has a toe, a
|
||||||
|
//! midtone slope and a shoulder that approaches `w` — a hair above 1.0 —
|
||||||
|
//! without ever reaching it. Everything the shoulder has not reached by the
|
||||||
|
//! white point is clipped by the output transform, which is the last moment
|
||||||
|
//! and the only place a clip belongs.
|
||||||
|
//!
|
||||||
|
//! # Why per channel, and why the middle channel is put back
|
||||||
|
//!
|
||||||
|
//! Per channel is what makes a bright saturated colour desaturate as it
|
||||||
|
//! approaches white — a blown sky rolls toward white rather than toward a
|
||||||
|
//! saturated corner of the gamut, which is what film and every camera JPEG
|
||||||
|
//! do. It also bends hue: the three channels sit at different places on the
|
||||||
|
//! curve, so their ratios change, and an orange flame drifts toward yellow.
|
||||||
|
//! So after the curve the middle channel is moved back to where it sat
|
||||||
|
//! *between the other two* before it — the same fraction of the way from the
|
||||||
|
//! smallest to the largest. The smallest and largest keep what the curve gave
|
||||||
|
//! them, which keeps the desaturation; the hue, which is decided by that
|
||||||
|
//! fraction, survives. It is the "preserve hue" step of darktable's sigmoid,
|
||||||
|
//! at full strength.
|
||||||
|
//!
|
||||||
|
//! # Why these defaults
|
||||||
|
//!
|
||||||
|
//! [`SCENE_GREY`] is where the retired default base curve put middle grey
|
||||||
|
//! (FR-DEV-3e): linear sensor data from a correctly exposed frame has it
|
||||||
|
//! near 13% of saturation, and a camera JPEG shows it at 18%. The contrast
|
||||||
|
//! and white defaults were chosen against that same retired curve: at 1.4 and
|
||||||
|
//! 4 stops the midtones stay within a quarter of a stop of it between scene
|
||||||
|
//! 0.03 and 1.0, while a highlight a stop past sensor saturation still rolls
|
||||||
|
//! into white rather than stopping dead at it. The upper midtones come out a
|
||||||
|
//! little darker than the curve had them, which is the price of that
|
||||||
|
//! headroom and what the white slider is for.
|
||||||
|
|
||||||
|
/// Scene-linear middle grey: where the retired default curve placed it.
|
||||||
|
pub const SCENE_GREY: f32 = 0.13;
|
||||||
|
|
||||||
|
/// Display-linear middle grey — what a camera JPEG shows a grey card as.
|
||||||
|
pub const DISPLAY_GREY: f32 = 0.18;
|
||||||
|
|
||||||
|
/// The default contrast, the sigmoid's log-log slope parameter `n`.
|
||||||
|
pub const DEFAULT_CONTRAST: f32 = 1.4;
|
||||||
|
|
||||||
|
/// The default white point, in stops above [`SCENE_GREY`].
|
||||||
|
pub const DEFAULT_WHITE: f32 = 4.0;
|
||||||
|
|
||||||
|
/// The contrast range a photographer is offered.
|
||||||
|
pub const CONTRAST_RANGE: (f32, f32) = (1.0, 3.0);
|
||||||
|
|
||||||
|
/// The white point range, in stops above middle grey.
|
||||||
|
///
|
||||||
|
/// The floor is not taste. The two conditions `k` and `w` are solved from
|
||||||
|
/// have a solution only while `2^(white · n)` exceeds `1 / DISPLAY_GREY`,
|
||||||
|
/// and at the lowest contrast that needs `white` above about 2.47 stops.
|
||||||
|
pub const WHITE_RANGE: (f32, f32) = (2.5, 10.0);
|
||||||
|
|
||||||
|
/// The curve's three numbers, solved from the photographer's two.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Sigmoid {
|
||||||
|
/// Contrast: the exponent.
|
||||||
|
pub n: f32,
|
||||||
|
/// `1 / k`, so the shader multiplies rather than divides.
|
||||||
|
pub inv_k: f32,
|
||||||
|
/// The asymptote the shoulder approaches, a little above 1.0.
|
||||||
|
pub w: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sigmoid {
|
||||||
|
/// Solve the curve for a contrast and a white point in stops.
|
||||||
|
///
|
||||||
|
/// Out-of-range inputs are clamped to [`CONTRAST_RANGE`] and
|
||||||
|
/// [`WHITE_RANGE`] rather than trusted: they arrive from a sidecar, which
|
||||||
|
/// may have been written by a build with other limits, and outside them
|
||||||
|
/// the solution below divides by something that is no longer positive.
|
||||||
|
///
|
||||||
|
/// With `r_g` the value of `r` at scene grey and `q = 2^(white · n)`, the
|
||||||
|
/// two conditions `f(grey) = display grey` and `f(grey · 2^white) = 1`
|
||||||
|
/// are `w·r_g/(1+r_g) = g` and `w·q·r_g/(1+q·r_g) = 1`. Dividing one by
|
||||||
|
/// the other eliminates `w` and leaves `r_g = (g·q − 1) / (q·(1 − g))`.
|
||||||
|
pub fn new(contrast: f32, white: f32) -> Self {
|
||||||
|
let n = contrast.clamp(CONTRAST_RANGE.0, CONTRAST_RANGE.1) as f64;
|
||||||
|
let white = white.clamp(WHITE_RANGE.0, WHITE_RANGE.1) as f64;
|
||||||
|
let g = f64::from(DISPLAY_GREY);
|
||||||
|
let q = (white * n).exp2();
|
||||||
|
let r_grey = (g * q - 1.0) / (q * (1.0 - g));
|
||||||
|
let w = g * (1.0 + r_grey) / r_grey;
|
||||||
|
// r = (x / k)^n, and r at scene grey is r_grey, so
|
||||||
|
// k = grey / r_grey^(1/n).
|
||||||
|
let k = f64::from(SCENE_GREY) / r_grey.powf(1.0 / n);
|
||||||
|
Self {
|
||||||
|
n: n as f32,
|
||||||
|
inv_k: (1.0 / k) as f32,
|
||||||
|
w: w as f32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default curve.
|
||||||
|
pub fn default_curve() -> Self {
|
||||||
|
Self::new(DEFAULT_CONTRAST, DEFAULT_WHITE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One channel through the curve. The CPU reference the shader is
|
||||||
|
/// tested against.
|
||||||
|
pub fn channel(&self, x: f32) -> f32 {
|
||||||
|
let r = (x.max(0.0) * self.inv_k).powf(self.n);
|
||||||
|
self.w * r / (1.0 + r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A colour through the curve, with the middle channel put back between
|
||||||
|
/// the other two. See the module documentation.
|
||||||
|
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
|
||||||
|
let x = c.map(|v| v.max(0.0));
|
||||||
|
let y = x.map(|v| self.channel(v));
|
||||||
|
let lo = x[0].min(x[1]).min(x[2]);
|
||||||
|
let hi = x[0].max(x[1]).max(x[2]);
|
||||||
|
if hi - lo <= 1e-9 {
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
let (y_lo, y_hi) = (self.channel(lo), self.channel(hi));
|
||||||
|
x.map(|v| y_lo + (y_hi - y_lo) * (v - lo) / (hi - lo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The WGSL twin of [`Sigmoid::apply`], as a helper function.
|
||||||
|
pub const VIEW_SIGMOID_WGSL: &str = "\
|
||||||
|
// The view transform (FR-DEV-3j): a log-logistic sigmoid per channel, then the
|
||||||
|
// middle channel put back between the other two so that the hue survives the
|
||||||
|
// shoulder. See `dr_pipeline::view` for the derivation and the defaults.
|
||||||
|
fn view_sigmoid(c: vec3<f32>, n: f32, inv_k: f32, w: f32) -> vec3<f32> {
|
||||||
|
// Negative components are colours outside the working primaries. They are
|
||||||
|
// floored here, at the last stage, which is the one place a gamut clip
|
||||||
|
// belongs.
|
||||||
|
let x = max(c, vec3<f32>(0.0));
|
||||||
|
let lo = min(x.r, min(x.g, x.b));
|
||||||
|
let hi = max(x.r, max(x.g, x.b));
|
||||||
|
let r_lo = pow(lo * inv_k, n);
|
||||||
|
let r_hi = pow(hi * inv_k, n);
|
||||||
|
let y_lo = w * r_lo / (1.0 + r_lo);
|
||||||
|
let y_hi = w * r_hi / (1.0 + r_hi);
|
||||||
|
// Each channel's place between the smallest and the largest. A neutral
|
||||||
|
// has no spread, and every channel then takes the one value there is.
|
||||||
|
let spread = hi - lo;
|
||||||
|
let t = select((x - vec3<f32>(lo)) / max(spread, 1e-9), vec3<f32>(0.0), spread <= 1e-9);
|
||||||
|
return vec3<f32>(y_lo) + (y_hi - y_lo) * t;
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The retired default base curve, for the acceptance comparison: five
|
||||||
|
/// points through the unit square, sampled here by straight lines between
|
||||||
|
/// them in log-log terms — close enough to the monotone spline that drew
|
||||||
|
/// it for a tolerance measured in quarters of a stop. Below its second
|
||||||
|
/// point it was a straight line from the origin.
|
||||||
|
fn retired_default(x: f32) -> f32 {
|
||||||
|
const P: [(f32, f32); 4] = [(0.04, 0.043), (0.13, 0.175), (0.45, 0.690), (1.0, 1.0)];
|
||||||
|
if x < 0.04 {
|
||||||
|
return x * (0.043 / 0.04);
|
||||||
|
}
|
||||||
|
let x = x.min(1.0);
|
||||||
|
let i = P.windows(2).position(|w| x <= w[1].0).unwrap_or(2);
|
||||||
|
let ((x0, y0), (x1, y1)) = (P[i], P[i + 1]);
|
||||||
|
let t = (x.ln() - x0.ln()) / (x1.ln() - x0.ln());
|
||||||
|
(y0.ln() + t * (y1.ln() - y0.ln())).exp()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn middle_grey_lands_on_display_grey() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
for (contrast, white) in [(1.0, 3.0), (1.4, 4.0), (2.5, 8.0), (3.0, 10.0)] {
|
||||||
|
let s = Sigmoid::new(contrast, white);
|
||||||
|
let got = s.channel(SCENE_GREY);
|
||||||
|
assert!(
|
||||||
|
(got - DISPLAY_GREY).abs() < 0.01,
|
||||||
|
"contrast {contrast}, white {white}: grey went to {got}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_white_point_reaches_display_white() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// The whole meaning of the slider: the scene value it names is where
|
||||||
|
// the picture reaches white, and not before.
|
||||||
|
for (contrast, white) in [(1.0, 3.0), (1.4, 4.0), (2.5, 8.0)] {
|
||||||
|
let s = Sigmoid::new(contrast, white);
|
||||||
|
let at = SCENE_GREY * white.exp2();
|
||||||
|
assert!((s.channel(at) - 1.0).abs() < 1e-4, "{}", s.channel(at));
|
||||||
|
assert!(s.channel(at * 0.9) < 1.0);
|
||||||
|
assert!(s.w > 1.0, "the shoulder must approach a value above white");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_curve_is_monotone_and_keeps_going_past_one() {
|
||||||
|
// TRACES: FR-DEV-3j | FR-DEV-2
|
||||||
|
// What the base curve got wrong: it was flat past 1.0, so every
|
||||||
|
// recovered highlight left it as the same number.
|
||||||
|
let s = Sigmoid::default_curve();
|
||||||
|
let mut last = -1.0;
|
||||||
|
for i in 0..=2000 {
|
||||||
|
let x = i as f32 * 0.004;
|
||||||
|
let y = s.channel(x);
|
||||||
|
assert!(y > last || (x == 0.0 && y == 0.0), "not increasing at {x}");
|
||||||
|
last = y;
|
||||||
|
}
|
||||||
|
assert!(s.channel(2.0) > s.channel(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_stays_close_to_the_retired_curve() {
|
||||||
|
// TRACES: FR-DEV-3j | FR-DEV-3e
|
||||||
|
// D19's promise to every existing photograph: the midtones do not
|
||||||
|
// move by more than a third of a stop.
|
||||||
|
let s = Sigmoid::default_curve();
|
||||||
|
let mut x = 0.03_f32;
|
||||||
|
while x <= 1.0 {
|
||||||
|
let ev = (s.channel(x) / retired_default(x)).log2();
|
||||||
|
assert!(ev.abs() < 0.3, "at scene {x} the default moved {ev:+.2} EV");
|
||||||
|
x *= 1.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_neutral_stays_neutral() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
let s = Sigmoid::default_curve();
|
||||||
|
for v in [0.0, 0.01, 0.13, 1.0, 7.0] {
|
||||||
|
let [r, g, b] = s.apply([v, v, v]);
|
||||||
|
assert_eq!(r, g);
|
||||||
|
assert_eq!(g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_hue_survives_the_shoulder() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// The middle channel's place between the other two is what decides
|
||||||
|
// the hue. Without the correction an orange at the shoulder drifts
|
||||||
|
// toward yellow as the red channel saturates first.
|
||||||
|
let s = Sigmoid::default_curve();
|
||||||
|
let orange = [2.0, 0.8, 0.1];
|
||||||
|
let out = s.apply(orange);
|
||||||
|
let before = (orange[1] - orange[2]) / (orange[0] - orange[2]);
|
||||||
|
let after = (out[1] - out[2]) / (out[0] - out[2]);
|
||||||
|
assert!((before - after).abs() < 1e-5, "{before} became {after}");
|
||||||
|
// And the extremes keep what the curve gave them — the desaturation
|
||||||
|
// toward white is the point of working per channel.
|
||||||
|
assert!((out[0] - s.channel(2.0)).abs() < 1e-6);
|
||||||
|
assert!((out[2] - s.channel(0.1)).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_range_settings_are_clamped_not_trusted() {
|
||||||
|
// A sidecar from another build may carry anything, and outside the
|
||||||
|
// range the solution divides by a value that is no longer positive.
|
||||||
|
let s = Sigmoid::new(0.0, 0.0);
|
||||||
|
assert!(s.n.is_finite() && s.inv_k.is_finite() && s.w.is_finite());
|
||||||
|
assert_eq!(s, Sigmoid::new(CONTRAST_RANGE.0, WHITE_RANGE.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,8 @@
|
|||||||
//! # What is not covered, and why that is honest
|
//! # What is not covered, and why that is honest
|
||||||
//!
|
//!
|
||||||
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
|
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
|
||||||
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze` —
|
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze`,
|
||||||
|
//! `view_transform` —
|
||||||
//! names a hand-written type and has no declaration to interpret. It is not skipped
|
//! names a hand-written type and has no declaration to interpret. It is not skipped
|
||||||
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
|
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
|
||||||
//! `ops/` between them, so a node that stops being declared cannot quietly
|
//! `ops/` between them, so a node that stops being declared cannot quietly
|
||||||
@@ -403,6 +404,7 @@ fn every_declared_node_is_checked() {
|
|||||||
"noise_reduction",
|
"noise_reduction",
|
||||||
"texture",
|
"texture",
|
||||||
"tone_curve",
|
"tone_curve",
|
||||||
|
"view_transform",
|
||||||
"vignetting",
|
"vignetting",
|
||||||
],
|
],
|
||||||
"the set of hand-written nodes changed; if that is deliberate, update \
|
"the set of hand-written nodes changed; if that is deliberate, update \
|
||||||
|
|||||||
@@ -573,6 +573,32 @@ mod tests {
|
|||||||
assert_eq!(report.failed, 0);
|
assert_eq!(report.failed, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subfolder_becomes_the_category_of_what_it_holds() {
|
||||||
|
// The folder picked is the root and names nothing; each folder under
|
||||||
|
// it is a level of category, as Lightroom's groups were.
|
||||||
|
let dir = tempdir("categories");
|
||||||
|
std::fs::write(dir.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap();
|
||||||
|
let nested = dir.join("Film").join("Colour");
|
||||||
|
std::fs::create_dir_all(&nested).unwrap();
|
||||||
|
std::fs::write(nested.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap();
|
||||||
|
|
||||||
|
let names: Vec<_> = read_path(&dir).presets.into_iter().map(|p| p.0).collect();
|
||||||
|
assert_eq!(names, ["Film/Colour/Golden Hour", "Golden Hour"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_slash_in_a_displayed_name_is_not_a_category() {
|
||||||
|
let dir = tempdir("slash");
|
||||||
|
std::fs::write(
|
||||||
|
dir.join("p.xmp"),
|
||||||
|
ATTRIBUTE_FORM.replace("Warm Portrait", "Warm / Cool"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let report = read_path(&dir);
|
||||||
|
assert_eq!(report.presets[0].0, "Warm \u{2215} Cool");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_preset_without_a_name_is_called_after_its_file() {
|
fn a_preset_without_a_name_is_called_after_its_file() {
|
||||||
// Lightroom writes the name it displays, which is not always the file
|
// Lightroom writes the name it displays, which is not always the file
|
||||||
@@ -661,21 +687,37 @@ pub struct Report {
|
|||||||
/// A folder because that is the shape a photographer's presets are in — an
|
/// A folder because that is the shape a photographer's presets are in — an
|
||||||
/// exported Lightroom preset folder, nested one level per group — and asking
|
/// exported Lightroom preset folder, nested one level per group — and asking
|
||||||
/// them to import ninety files one at a time would be asking them not to
|
/// them to import ninety files one at a time would be asking them not to
|
||||||
/// bother. Nested folders are walked, which is what makes the group structure
|
/// bother. Nested folders are walked, and each one below `path` becomes a
|
||||||
/// available to whatever wants it later.
|
/// category: a preset in `Portraits/` is named `Portraits/Warm skin`, which is
|
||||||
|
/// how the preset menu files it (see `PresetLibrary`'s note on categories).
|
||||||
///
|
///
|
||||||
/// The *name* comes from `crs:Name` where the file carries one and from the
|
/// The *name* comes from `crs:Name` where the file carries one and from the
|
||||||
/// file stem where it does not. Lightroom writes the name it displays, which
|
/// file stem where it does not. Lightroom writes the name it displays, which
|
||||||
/// is not always the file name, and the displayed name is the one the
|
/// is not always the file name, and the displayed name is the one the
|
||||||
/// photographer will look for.
|
/// photographer will look for. A `/` inside that name would read as a
|
||||||
|
/// category it never had, so it becomes `∕`, which looks the same and
|
||||||
|
/// separates nothing.
|
||||||
pub fn read_path(path: &std::path::Path) -> Report {
|
pub fn read_path(path: &std::path::Path) -> Report {
|
||||||
let mut report = Report::default();
|
let mut report = Report::default();
|
||||||
read_into(path, &mut report);
|
read_into(path, "", &mut report);
|
||||||
report.presets.sort_by(|a, b| a.0.cmp(&b.0));
|
report.presets.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
report
|
report
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_into(path: &std::path::Path, report: &mut Report) {
|
/// The category a folder below the import root files its presets under.
|
||||||
|
fn category_of(parent: &str, folder: &std::path::Path) -> String {
|
||||||
|
let Some(name) = folder.file_name() else {
|
||||||
|
return parent.to_string();
|
||||||
|
};
|
||||||
|
let name = name.to_string_lossy().replace('/', "\u{2215}");
|
||||||
|
if parent.is_empty() {
|
||||||
|
name
|
||||||
|
} else {
|
||||||
|
format!("{parent}/{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_into(path: &std::path::Path, category: &str, report: &mut Report) {
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
let Ok(entries) = std::fs::read_dir(path) else {
|
let Ok(entries) = std::fs::read_dir(path) else {
|
||||||
log::warn!("preset import: cannot read {}", path.display());
|
log::warn!("preset import: cannot read {}", path.display());
|
||||||
@@ -686,7 +728,12 @@ fn read_into(path: &std::path::Path, report: &mut Report) {
|
|||||||
let mut paths: Vec<std::path::PathBuf> = entries.flatten().map(|e| e.path()).collect();
|
let mut paths: Vec<std::path::PathBuf> = entries.flatten().map(|e| e.path()).collect();
|
||||||
paths.sort();
|
paths.sort();
|
||||||
for path in paths {
|
for path in paths {
|
||||||
read_into(&path, report);
|
let category = if path.is_dir() {
|
||||||
|
category_of(category, &path)
|
||||||
|
} else {
|
||||||
|
category.to_string()
|
||||||
|
};
|
||||||
|
read_into(&path, &category, report);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -710,6 +757,12 @@ fn read_into(path: &std::path::Path, report: &mut Report) {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
});
|
});
|
||||||
report.unsupported.extend(import.skipped);
|
report.unsupported.extend(import.skipped);
|
||||||
|
let name = name.replace('/', "\u{2215}");
|
||||||
|
let name = if category.is_empty() {
|
||||||
|
name
|
||||||
|
} else {
|
||||||
|
format!("{category}/{name}")
|
||||||
|
};
|
||||||
report.presets.push((name, import.preset));
|
report.presets.push((name, import.preset));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ document elaborates:
|
|||||||
| UI | Slint | D1, D8 |
|
| UI | Slint | D1, D8 |
|
||||||
| GPU | wgpu → Vulkan (Linux + Android) | D1 |
|
| GPU | wgpu → Vulkan (Linux + Android) | D1 |
|
||||||
| Shaders | Hand-written WGSL | D6 |
|
| Shaders | Hand-written WGSL | D6 |
|
||||||
| RAW decode | rawler; LibRaw fallback behind a trait | D2 |
|
| RAW decode | rawler (0.7.2, carried patched in `third_party/`); LibRaw fallback behind a trait | D2 |
|
||||||
| Catalog | SQLite (WAL) — a rebuildable index | D5, §6.12 |
|
| Catalog | SQLite (WAL) — a rebuildable index | D5, §6.12 |
|
||||||
| Colour | lcms2 + GPU-side matrix/LUT transforms | D5 |
|
| Colour | lcms2 + GPU-side matrix/LUT transforms | D5 |
|
||||||
| Network | reqwest + quick-xml | D7 |
|
| Network | reqwest + quick-xml | D7 |
|
||||||
@@ -140,6 +140,11 @@ is *not* demosaiced. Demosaic is a GPU pipeline stage (§5.2).
|
|||||||
> (§3.1's `read_range`), not the decoder. `dr_decode::Rawler` is the one implementation, and only
|
> (§3.1's `read_range`), not the decoder. `dr_decode::Rawler` is the one implementation, and only
|
||||||
> the places that start a job name `dr_decode::default()`; everything below them takes a
|
> the places that start a job name `dr_decode::default()`; everything below them takes a
|
||||||
> `&dyn Decoder`.
|
> `&dyn Decoder`.
|
||||||
|
>
|
||||||
|
> rawler itself is built from `third_party/rawler-0.7.2` since 0.19.0: the crate as published,
|
||||||
|
> with its allocation guard raised so that a linear DNG wider than about 16 700 pixels (a
|
||||||
|
> stitched panorama) decodes rather than being refused
|
||||||
|
> ([third_party/README.md](../../third_party/README.md)).
|
||||||
|
|
||||||
### 3.3 Operation and descriptors
|
### 3.3 Operation and descriptors
|
||||||
|
|
||||||
@@ -355,11 +360,12 @@ RawImage (sensor data, CPU)
|
|||||||
├─────────────────────┤
|
├─────────────────────┤
|
||||||
│ AI denoise │ optional; raw-domain, joint with demosaic where possible
|
│ AI denoise │ optional; raw-domain, joint with demosaic where possible
|
||||||
├─────────────────────┤
|
├─────────────────────┤
|
||||||
│ camera profile │ matrices + per-body base curve (FR-DEV-3e)
|
│ white balance │ camera RGB: as-shot, then the operation
|
||||||
├─────────────────────┤
|
├─────────────────────┤
|
||||||
│ → working space │ linear, wide-gamut, f16
|
│ camera profile │ the matrix (FR-DEV-3e) — no curve (D19)
|
||||||
|
├─────────────────────┤
|
||||||
|
│ → working space │ linear, unbounded, f16
|
||||||
├─────────────────────┤
|
├─────────────────────┤
|
||||||
│ white balance │
|
|
||||||
│ exposure/contrast │
|
│ exposure/contrast │
|
||||||
│ highlights/shadows │ ← masks apply per-op from here down
|
│ highlights/shadows │ ← masks apply per-op from here down
|
||||||
│ tone curve │
|
│ tone curve │
|
||||||
@@ -368,10 +374,11 @@ RawImage (sensor data, CPU)
|
|||||||
│ spot removal │
|
│ spot removal │
|
||||||
│ sharpen / NR │
|
│ sharpen / NR │
|
||||||
│ lens corrections │
|
│ lens corrections │
|
||||||
│ look (HaldCLUT) │ FR-DEV-3f
|
|
||||||
├─────────────────────┤
|
├─────────────────────┤
|
||||||
│ geometry │ crop, straighten, rotate
|
│ geometry │ crop, straighten, rotate
|
||||||
├─────────────────────┤
|
├─────────────────────┤
|
||||||
|
│ view transform │ sigmoid, or the film stock (FR-DEV-3j)
|
||||||
|
├─────────────────────┤
|
||||||
│ output transform │ → display or export profile
|
│ output transform │ → display or export profile
|
||||||
└─────────────────────┘
|
└─────────────────────┘
|
||||||
│
|
│
|
||||||
@@ -381,6 +388,14 @@ RawImage (sensor data, CPU)
|
|||||||
|
|
||||||
Working precision is f16 in a linear wide-gamut space, quantising once at the output transform.
|
Working precision is f16 in a linear wide-gamut space, quantising once at the output transform.
|
||||||
|
|
||||||
|
**Scene-referred until the view transform (D19, §6.14).** Everything between the matrix and the
|
||||||
|
view transform is linear and unbounded. The view transform is the one stage allowed to compress
|
||||||
|
the scene into a display range. With a detail stage it runs as a dispatch of its own after the
|
||||||
|
detail passes, generated by the same composer as the fused pass so that it gets the mask layers,
|
||||||
|
the film tables and the grain's source position. Without one it is the fused pass's tail. Both
|
||||||
|
the view transform and the output transform (primaries, gamut clip, encode) come after
|
||||||
|
everything that reads a neighbourhood.
|
||||||
|
|
||||||
**A hot or dead photosite is repaired before the demosaic, not after.** Past it, one photosite of
|
**A hot or dead photosite is repaired before the demosaic, not after.** Past it, one photosite of
|
||||||
nonsense is a coloured cross three pixels wide that no later stage can tell from detail. The pass
|
nonsense is a coloured cross three pixels wide that no later stage can tell from detail. The pass
|
||||||
(`shaders/hot_pixels.wgsl`, run by `Demosaicer::run` into a second buffer) replaces a photosite that
|
(`shaders/hot_pixels.wgsl`, run by `Demosaicer::run` into a second buffer) replaces a photosite that
|
||||||
@@ -423,6 +438,20 @@ Tile results cache keyed by `(VersionId, tile, zoom, graph_hash_prefix)`, where
|
|||||||
operations up to the first `Affects` change. Adjusting exposure reuses cached demosaic and camera
|
operations up to the first `Affects` change. Adjusting exposure reuses cached demosaic and camera
|
||||||
profile output for every tile.
|
profile output for every tile.
|
||||||
|
|
||||||
|
> **As built (0.19.0).** The interactive path does not tile: one fused dispatch over the viewport
|
||||||
|
> is inside the frame budget ([frame-budget.md](frame-budget.md)), and there is no scheduler or tile
|
||||||
|
> cache. What tiles is a source too large for one texture. A linear DNG whose long edge passes
|
||||||
|
> `PROXY_EDGE` (8192) — a stitched panorama — is held at full resolution on the CPU and opened on
|
||||||
|
> a box-reduced copy, from which the canvas at fit, the thumbnail, the histograms and the masks
|
||||||
|
> work. A render finer than the copy — the canvas zoomed in, a tile of the export — samples a
|
||||||
|
> window cut from the full resolution (`DemosaicedImage::linear_rgb16_window`), which the fused
|
||||||
|
> shader addresses through two source-window uniforms so that crops, warps and grain seeds stay
|
||||||
|
> where they are in the frame. The canvas keeps one window while the view stays inside it. The
|
||||||
|
> export is cut by `dr_pipeline::tiles::plan` into 4096-pixel tiles on a 16-pixel grid, each
|
||||||
|
> grown by the detail chain's reach (`ComposedDetail::reach`, the sum of its passes' radii), and
|
||||||
|
> reassembled; `core/dr-gpu/tests/source_window.rs` holds it to the untiled render within one code
|
||||||
|
> value. A CFA file too large for one texture is still refused.
|
||||||
|
|
||||||
### 5.4 Mask rasterisation
|
### 5.4 Mask rasterisation
|
||||||
|
|
||||||
**All masks rasterise on the GPU, including drawn brush strokes** (§6.11). Strokes arrive as
|
**All masks rasterise on the GPU, including drawn brush strokes** (§6.11). Strokes arrive as
|
||||||
@@ -442,7 +471,7 @@ exactly the stall §6.1 exists to prevent.
|
|||||||
**Two reductions, not one.** The display histogram (FR-DSP-7) counts the frame the output transform
|
**Two reductions, not one.** The display histogram (FR-DSP-7) counts the frame the output transform
|
||||||
produced: its axis is the output code value, and a clipped bin means a highlight that is gone as the
|
produced: its axis is the output code value, and a clipped bin means a highlight that is gone as the
|
||||||
image currently stands. The raw histogram for culling (FR-CULL-3) counts the **demosaiced
|
image currently stands. The raw histogram for culling (FR-CULL-3) counts the **demosaiced
|
||||||
scene-linear texture** — before white balance, the camera matrix, the base curve and the tone chain
|
scene-linear texture** — before white balance, the camera matrix, the tone chain and the view transform
|
||||||
— on an axis of stops below sensor saturation, which is how it reports headroom the embedded JPEG's
|
— on an axis of stops below sensor saturation, which is how it reports headroom the embedded JPEG's
|
||||||
histogram cannot. A culling decision needs the second, an export decision needs the first, and
|
histogram cannot. A culling decision needs the second, an export decision needs the first, and
|
||||||
neither answers for the other. Both are drawn by the same panel and chosen between.
|
neither answers for the other. Both are drawn by the same panel and chosen between.
|
||||||
@@ -1255,6 +1284,17 @@ differently, f16 rounding varies. Cache keys and graph hashes are computed over
|
|||||||
state, which is exactly deterministic. Cross-platform *rendering* equality is a bounded tolerance
|
state, which is exactly deterministic. Cross-platform *rendering* equality is a bounded tolerance
|
||||||
(R1), not a checksum.
|
(R1), not a checksum.
|
||||||
|
|
||||||
|
### 6.14 Scene-referred until the view transform
|
||||||
|
|
||||||
|
Added 2026-09-27 (D19). Between the camera matrix and the view transform, values are scene-linear
|
||||||
|
and unbounded, and no operation clamps above 1.0, applies a transfer function or maps to a display
|
||||||
|
gamut. The view transform (FR-DEV-3j) is the one stage that does, and the output transform after
|
||||||
|
it clips and encodes. This is the constraint the base curve broke and ARCH §5.2 had drawn all
|
||||||
|
along: a display-referred curve in the middle of the chain throws away what every later stage,
|
||||||
|
the neighbourhood ones above all, needs. A test (`scene_referred_until_the_view`, in `dr-gpu`)
|
||||||
|
runs every point operation over a ramp to 16.0 so that a fragment that clips fails the build rather
|
||||||
|
than the photograph.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Decisions
|
## 13. Decisions
|
||||||
@@ -1278,6 +1318,7 @@ Full rationale in [requirements.md §8](requirements.md). Summary:
|
|||||||
| D13 | Face inference runtime and model licensing | **Runtime answered**, reopened for per-device backends (docs/inference.md); licensing open |
|
| D13 | Face inference runtime and model licensing | **Runtime answered**, reopened for per-device backends (docs/inference.md); licensing open |
|
||||||
| D14 | Segmentation source for local masking | Decided — arm C (docs/segmentation.md §14) |
|
| D14 | Segmentation source for local masking | Decided — arm C (docs/segmentation.md §14) |
|
||||||
| D15 | Target devices — 12-inch tablet and desktop, no phone | Decided (requirements D15) |
|
| D15 | Target devices — 12-inch tablet and desktop, no phone | Decided (requirements D15) |
|
||||||
|
| D19 | Scene-referred pipeline, one view transform last | Decided (requirements D19) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ and the reason is that some of the work is done and untagged.
|
|||||||
| Requirement | Reality |
|
| Requirement | Reality |
|
||||||
|---|---|
|
|---|---|
|
||||||
| FR-DSP-1 proxy rendering | **Done.** The develop view renders at viewport resolution, not source. |
|
| FR-DSP-1 proxy rendering | **Done.** The develop view renders at viewport resolution, not source. |
|
||||||
| FR-DSP-2 tiled computation | **Absent, and §2 now says it should stay that way.** Measured: the fused pass is inside the budget everywhere. See [frame-budget.md](frame-budget.md). |
|
| FR-DSP-2 tiled computation | **Absent from the interactive path, and §2 now says it should stay that way.** Measured: the fused pass is inside the budget everywhere. See [frame-budget.md](frame-budget.md). *Since 0.19.0* the export of a linear DNG too large for one texture is drawn in halo-grown tiles, and the canvas renders such a file from a reduced copy and full-resolution windows (ARCH §5.3). |
|
||||||
| FR-DSP-3 interactive latency | **Measured and asserted** for the fused path — `core/dr-gpu/tests/frame_budget.rs`. Missed by one operation, clarity, for the reason recorded as TD-4. |
|
| FR-DSP-3 interactive latency | **Measured and asserted** for the fused path — `core/dr-gpu/tests/frame_budget.rs`. Missed by one operation, clarity, for the reason recorded as TD-4. |
|
||||||
| FR-DSP-4 progressive refinement | **Built** (0.15.0), although §4's condition did not fire on the fused path: a half-resolution draft while a gesture moves, one sharp frame 120 ms after it stops, the histogram dimmed while it lags, and the draft faded out over 150 ms — `ui/dr-ui/src/refine.rs`. See [frame-budget.md](frame-budget.md). |
|
| FR-DSP-4 progressive refinement | **Built** (0.15.0), although §4's condition did not fire on the fused path: a half-resolution draft while a gesture moves, one sharp frame 120 ms after it stops, the histogram dimmed while it lags, and the draft faded out over 150 ms — `ui/dr-ui/src/refine.rs`. See [frame-budget.md](frame-budget.md). |
|
||||||
| FR-DSP-5 zoom and pan | **Done and tagged**, against tests that fail if the behaviour is removed — `core/dr-gpu/tests/zoom_resolution.rs`. `Framing::view` shrinks the sampled region while the render target keeps its size, so zooming *raises* the resolution the pipeline works at. That is FR-DSP-5's requirement, arrived at without tiles. |
|
| FR-DSP-5 zoom and pan | **Done and tagged**, against tests that fail if the behaviour is removed — `core/dr-gpu/tests/zoom_resolution.rs`. `Framing::view` shrinks the sampled region while the render target keeps its size, so zooming *raises* the resolution the pipeline works at. That is FR-DSP-5's requirement, arrived at without tiles. |
|
||||||
|
|||||||
@@ -515,3 +515,26 @@ texture and dehaze; the scenes without dehaze moved within ±2%. The
|
|||||||
picture is the same bits: a minimum is exact in any order, the window is
|
picture is the same bits: a minimum is exact in any order, the window is
|
||||||
the one the split passes covered, and the rgba8 output hashed identically
|
the one the split passes covered, and the rgba8 output hashed identically
|
||||||
before and after in all 64 scene, view and size combinations measured.
|
before and after in all 64 scene, view and size combinations measured.
|
||||||
|
|
||||||
|
## The view pass after the detail stage — 2026-09-27
|
||||||
|
|
||||||
|
**Status:** Not measured. Every figure above predates it.
|
||||||
|
|
||||||
|
**A chain with a detail stage is now one dispatch longer** (`c07f81e`,
|
||||||
|
D19). The fused pass used to end in the rendering — the base curve, then
|
||||||
|
the output transform — before it stored, so every detail pass convolved
|
||||||
|
display-referred values, and the last detail pass encoded. Now the fused
|
||||||
|
pass stops before the view transform, every detail pass writes a
|
||||||
|
scene-linear `rgba16float` intermediate, the last one included, and a view
|
||||||
|
pass composed from the same inputs reads the result and runs the view
|
||||||
|
transform (or the film stock), the output transform and the mask reveal.
|
||||||
|
|
||||||
|
What that adds, per frame with a detail stage: one render-sized read and
|
||||||
|
write, and a third intermediate for a one-pass chain. By the dehaze
|
||||||
|
section's own figure that is about 4 ms at 2560 × 1600 on the laptop
|
||||||
|
RTX 3050 under its power cap. What it removed: a capture sharpening too
|
||||||
|
fine to draw at the current scale no longer emits a pass-through, and
|
||||||
|
there is no resolve pass for an active kernel with nothing to draw. A
|
||||||
|
chain with no detail operation is unchanged, one fused dispatch with the
|
||||||
|
view transform at its tail. The rows above that name a detail operation
|
||||||
|
should be re-run before they are quoted.
|
||||||
|
|||||||
@@ -446,8 +446,8 @@ reading a flag.
|
|||||||
|
|
||||||
After the output transform, immediately before the clip and the encode — not
|
After the output transform, immediately before the clip and the encode — not
|
||||||
among the layer blocks. Everything there runs on scene-referred colour in the
|
among the layer blocks. Everything there runs on scene-referred colour in the
|
||||||
working space, where a flat tint would be pushed through the base curve and
|
working space, where a flat tint would be pushed through the view transform
|
||||||
the camera matrix and arrive as some other colour, and an alpha's white on
|
(the base curve and the camera matrix, before D19) and arrive as some other colour, and an alpha's white on
|
||||||
black would arrive as neither.
|
black would arrive as neither.
|
||||||
|
|
||||||
### 6.3 Not on the graph
|
### 6.3 Not on the graph
|
||||||
|
|||||||
+25
-7
@@ -46,6 +46,13 @@ before the demosaic, for Bayer and X-Trans alike and with no setting (FR-RAW-3;
|
|||||||
reader in `dr-decode` is still not wired in, and a CR2 carries no map for it to read); and the
|
reader in `dr-decode` is still not wired in, and a CR2 carries no map for it to read); and the
|
||||||
tablet's scrollers, which §4a now describes.
|
tablet's scrollers, which §4a now describes.
|
||||||
|
|
||||||
|
**And for 0.19.0.** D19 rebuilt the develop pipeline around one rule — scene-linear from the camera
|
||||||
|
matrix to a single view transform, last ([architecture.md §6.14](architecture.md)) — which neither
|
||||||
|
closed nor opened an entry here: FR-DEV-3e's per-body base curves are retired by decision rather than
|
||||||
|
left outstanding, and its DCP half stays deferred as before. §4's FR-DSP-2 and NFR-RES-2 entries
|
||||||
|
record the one case that now tiles, a linear DNG larger than one texture, and §11 the merge's frame
|
||||||
|
choice, which changes how FR-MRG-5 is met rather than whether.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Plugins — post-v1 since 2026-09-19
|
## 1. Plugins — post-v1 since 2026-09-19
|
||||||
@@ -181,7 +188,7 @@ place.
|
|||||||
|
|
||||||
## 4. The render path — FR-DSP-2, FR-DSP-4, NFR-RES-2, NFR-ARCH-1
|
## 4. The render path — FR-DSP-2, FR-DSP-4, NFR-RES-2, NFR-ARCH-1
|
||||||
|
|
||||||
**FR-DSP-2 — Tiled computation. Unbuilt, and under challenge.** [architecture.md §6.2](architecture.md)
|
**FR-DSP-2 — Tiled computation. Built for one case, and otherwise under challenge.** [architecture.md §6.2](architecture.md)
|
||||||
calls for tiling "from day one" on the grounds that retrofitting it is a rewrite. It was not built,
|
calls for tiling "from day one" on the grounds that retrofitting it is a rewrite. It was not built,
|
||||||
and the evidence has since moved. `core/dr-gpu/tests/frame_budget.rs` carries the argument in its
|
and the evidence has since moved. `core/dr-gpu/tests/frame_budget.rs` carries the argument in its
|
||||||
own header: one fused dispatch over a viewport-sized target is comfortably inside the frame budget,
|
own header: one fused dispatch over a viewport-sized target is comfortably inside the frame budget,
|
||||||
@@ -191,15 +198,22 @@ path stops being supported, and this test is what says so."
|
|||||||
tiled convolution at clarity's radius reads nearly twice the taps that an untiled one does, so the
|
tiled convolution at clarity's radius reads nearly twice the taps that an untiled one does, so the
|
||||||
stage that looks most like it wants a tile cache is the stage that would be hurt most by one.
|
stage that looks most like it wants a tile cache is the stage that would be hurt most by one.
|
||||||
|
|
||||||
What exists is the declaration and not the mechanism: `DetailPass::radius` is documented as the halo
|
What existed until 0.19.0 was the declaration and not the mechanism: `DetailPass::radius`, the halo
|
||||||
a tile would have to be grown by, with a test that pins it, and there is no scheduler to read it.
|
a tile would have to be grown by, with a test that pins it, and nothing to read it. The export of an
|
||||||
That is deliberate plumbing, not an oversight.
|
oversized linear DNG (below) is now what reads it, through `ComposedDetail::reach`; there is still
|
||||||
|
no scheduler and no tile cache.
|
||||||
|
|
||||||
**So the open question here is not "when is tiling built" but "is FR-DSP-2 still a requirement" — and on 2026-09-19 the answer was: as written, until S6 runs.** FR-DSP-2 now carries a status note saying exactly that, and R5's note no longer claims it was rewritten.
|
**So the open question here is not "when is tiling built" but "is FR-DSP-2 still a requirement" — and on 2026-09-19 the answer was: as written, until S6 runs.** FR-DSP-2 now carries a status note saying exactly that, and R5's note no longer claims it was rewritten.
|
||||||
Two measurements say it costs more than it saves on the interactive path. Neither says anything
|
Two measurements say it costs more than it saves on the interactive path. Neither says anything
|
||||||
about the export path or about a device under memory pressure, which is where the case for it
|
about the export path or about a device under memory pressure, which is where the case for it
|
||||||
actually lives — and that is spike S6, which has not run.
|
actually lives — and that is spike S6, which has not run.
|
||||||
|
|
||||||
|
**2026-09-27:** the export path does now tile, for the one case that forced it — a linear DNG
|
||||||
|
wider than any texture, a 22 927 × 8966 Lightroom panorama in the case that prompted it. See the
|
||||||
|
note under FR-DSP-2 in [requirements.md](requirements.md) and ARCH §5.3. The interactive path
|
||||||
|
renders such a file from a reduced copy and one full-resolution window rather than tiles, and S6
|
||||||
|
is still unrun.
|
||||||
|
|
||||||
**FR-DSP-4 — Progressive refinement. Built in 0.15.0.** While a gesture moves the canvas renders
|
**FR-DSP-4 — Progressive refinement. Built in 0.15.0.** While a gesture moves the canvas renders
|
||||||
a half-resolution draft, and the sharp frame lands once, 120 ms after the last movement: the
|
a half-resolution draft, and the sharp frame lands once, 120 ms after the last movement: the
|
||||||
decision is `ui/dr-ui/src/refine.rs`, a debounce whose every draft re-arms the settle timer, driven
|
decision is `ui/dr-ui/src/refine.rs`, a debounce whose every draft re-arms the settle timer, driven
|
||||||
@@ -212,8 +226,10 @@ interface could see, and a refinement that was not a jarring swap.
|
|||||||
|
|
||||||
**NFR-RES-2 — Images larger than GPU memory.** Half answered. NFR-R8's "decide explicitly" was
|
**NFR-RES-2 — Images larger than GPU memory.** Half answered. NFR-R8's "decide explicitly" was
|
||||||
decided on 2026-09-19: there is no CPU render pipeline, the degraded mode is the viewer on
|
decided on 2026-09-19: there is no CPU render pipeline, the degraded mode is the viewer on
|
||||||
embedded previews with develop withheld, and NFR-RES-2 no longer promises a fallback render. What
|
embedded previews with develop withheld, and NFR-RES-2 no longer promises a fallback render. Since
|
||||||
remains unbuilt is the memory half: there is no headroom budget, no allocation-failure staging,
|
0.19.0 that mode no longer catches a linear DNG larger than one texture, which develops from a
|
||||||
|
reduced copy and exports in tiles; a CFA file that large still falls to it. What remains unbuilt is
|
||||||
|
the memory half: there is no headroom budget, no allocation-failure staging,
|
||||||
and no spill. Spike S6 — a tiled pipeline on a
|
and no spill. Spike S6 — a tiled pipeline on a
|
||||||
mid-range Android device with an image larger than available GPU memory — is the one that would
|
mid-range Android device with an image larger than available GPU memory — is the one that would
|
||||||
settle both this and FR-DSP-2, and there is no evidence it has run.
|
settle both this and FR-DSP-2, and there is no evidence it has run.
|
||||||
@@ -567,7 +583,9 @@ whether something *should* be built — which is the opposite of the order §9 a
|
|||||||
and focus stacking there with their data model decided. Its clauses entered the register with no
|
and focus stacking there with their data model decided. Its clauses entered the register with no
|
||||||
code behind them, which is why the coverage figure fell from 83.0% to 77.2% on that day — and the
|
code behind them, which is why the coverage figure fell from 83.0% to 77.2% on that day — and the
|
||||||
panorama was then built in the same week: alignment, projections, the chunked composite written as
|
panorama was then built in the same week: alignment, projections, the chunked composite written as
|
||||||
a DNG beside its sources, the auto-crop and the model's border fill.
|
a DNG beside its sources, the auto-crop and the model's border fill. In 0.19.0 a frame that cannot
|
||||||
|
be placed no longer ends the job: it is named on its row and the merge waits for it to be unticked,
|
||||||
|
and any frame can be left out that way without reading the rest again (panorama.md §15).
|
||||||
[panorama.md](panorama.md) §11 and §13 are where it stands.
|
[panorama.md](panorama.md) §11 and §13 are where it stands.
|
||||||
|
|
||||||
Three clauses carry no tag:
|
Three clauses carry no tag:
|
||||||
|
|||||||
+63
-6
@@ -128,7 +128,11 @@ stays hot for it.
|
|||||||
### 5.1 The tap — S15.3, answered by reading the composer
|
### 5.1 The tap — S15.3, answered by reading the composer
|
||||||
|
|
||||||
The fused shader's order, fixed by `operation.rs`'s own tests: warp → as-shot
|
The fused shader's order, fixed by `operation.rs`'s own tests: warp → as-shot
|
||||||
white balance → operations → base curve → camera matrix → store. The store is
|
white balance → operations → base curve → camera matrix → store. *(Amended
|
||||||
|
2026-09-27, D19: warp → as-shot white balance → white balance → camera matrix
|
||||||
|
→ operations → view transform → store. The tap is unaffected: it has no
|
||||||
|
operations, its caller fills the matrix with the identity, and the composer
|
||||||
|
emits no view transform in `OutputMode::CameraLinear`.)* The store is
|
||||||
either the display encode or, in `OutputMode::LinearWorking`, an unclipped
|
either the display encode or, in `OutputMode::LinearWorking`, an unclipped
|
||||||
`rgba16float` of linear sRGB. That mode exists for the detail stage and is
|
`rgba16float` of linear sRGB. That mode exists for the detail stage and is
|
||||||
selected from the operations, never by a caller flag, so that a shader and
|
selected from the operations, never by a caller flag, so that a shader and
|
||||||
@@ -139,7 +143,9 @@ composer already makes that a matter of uniforms rather than structure: the
|
|||||||
white balance, the matrix and the curve's active flag are all in the reserved
|
white balance, the matrix and the curve's active flag are all in the reserved
|
||||||
uniform block, and a fused pass with no operations, `as_shot_wb = 1`,
|
uniform block, and a fused pass with no operations, `as_shot_wb = 1`,
|
||||||
`cam_to_srgb = I` and `base_curve_last.z = 0` stores exactly camera-linear
|
`cam_to_srgb = I` and `base_curve_last.z = 0` stores exactly camera-linear
|
||||||
RGB after the warp. So the tap is:
|
RGB after the warp. *(Since D19 there is no curve flag: the base curve is
|
||||||
|
gone, and the tap composes no view transform, so the white balance and the
|
||||||
|
matrix are the only uniforms it fills neutral.)* So the tap is:
|
||||||
|
|
||||||
- `EditGraph::compose_camera_linear()` — the `LinearWorking` tail with an
|
- `EditGraph::compose_camera_linear()` — the `LinearWorking` tail with an
|
||||||
empty operation list and identity framing, paired by name with
|
empty operation list and identity framing, paired by name with
|
||||||
@@ -154,8 +160,8 @@ be re-developed deserves the sensor's precision. The cost is 2× on buffers
|
|||||||
FR-MRG-11 already bounds.
|
FR-MRG-11 already bounds.
|
||||||
|
|
||||||
**What the DNG carries as a consequence:** the first source's `Make`,
|
**What the DNG carries as a consequence:** the first source's `Make`,
|
||||||
`Model` and `UniqueCameraModel` — so `base_curve::for_body` finds the 6D's
|
`Model` and `UniqueCameraModel` — so `base_curve::for_body` found the 6D's
|
||||||
curve — its `ColorMatrix1`/`2` with illuminants, and its `AsShotNeutral`. The
|
curve, until D19 retired the per-body curves — its `ColorMatrix1`/`2` with illuminants, and its `AsShotNeutral`. The
|
||||||
composite then develops through the same profile as its sources, applied
|
composite then develops through the same profile as its sources, applied
|
||||||
once. The spike's 64 × 48 file (§8) already carries the matrix and neutral;
|
once. The spike's 64 × 48 file (§8) already carries the matrix and neutral;
|
||||||
the body name is a string.
|
the body name is a string.
|
||||||
@@ -238,7 +244,9 @@ first source with a `-pano` suffix, beside it.
|
|||||||
Three samples per pixel rather than a CFA: the warp resamples, and there is no
|
Three samples per pixel rather than a CFA: the warp resamples, and there is no
|
||||||
sensor grid to mosaic back onto. Nothing else about being a RAW is lost —
|
sensor grid to mosaic back onto. Nothing else about being a RAW is lost —
|
||||||
no white balance, no curve, no matrix, no clip has been applied — and the
|
no white balance, no curve, no matrix, no clip has been applied — and the
|
||||||
photographer develops the panorama afterwards as one photograph.
|
photographer develops the panorama afterwards as one photograph. One sample
|
||||||
|
is rewritten: a blown one, which is written as the camera value the
|
||||||
|
composite's balance calls grey rather than as the sensor's (1, 1, 1) — §15.
|
||||||
|
|
||||||
The sources are portrait frames in the 6D set: `Orientation` is applied
|
The sources are portrait frames in the 6D set: `Orientation` is applied
|
||||||
before alignment (learned features are not rotation-invariant) and the
|
before alignment (learned features are not rotation-invariant) and the
|
||||||
@@ -279,7 +287,9 @@ carrying the first source's EXIF in a sub-IFD as `dr-export` already does.
|
|||||||
- The dialog shows the aligned proxies in the chosen projection, with the
|
- The dialog shows the aligned proxies in the chosen projection, with the
|
||||||
projection, horizon and crop controls of FR-MRG-4, and the per-frame
|
projection, horizon and crop controls of FR-MRG-4, and the per-frame
|
||||||
residuals. A frame that failed to align is named there (FR-MRG-5), and the
|
residuals. A frame that failed to align is named there (FR-MRG-5), and the
|
||||||
merge cannot be confirmed with it in the set.
|
merge cannot be confirmed with it in the set. *(Since 2026-09-27 each row
|
||||||
|
has a box: an unticked frame is left out and the rest are solved again from
|
||||||
|
what the first pass measured — §15.)*
|
||||||
- Confirm starts the FR-MRG-7 job. The composite appears in the grid when the
|
- Confirm starts the FR-MRG-7 job. The composite appears in the grid when the
|
||||||
file is written and catalogued, beside its sources, with the merge as the
|
file is written and catalogued, beside its sources, with the merge as the
|
||||||
first entry in its history.
|
first entry in its history.
|
||||||
@@ -559,3 +569,50 @@ gaps between cells — built and measured in `darkroom-infill`
|
|||||||
and the next thing to port into `dr_pano::fill` (it needs the
|
and the next thing to port into `dr_pano::fill` (it needs the
|
||||||
discriminator as a second model, ~80 MB fp16). FR-MRG-4's *experimental*
|
discriminator as a second model, ~80 MB fp16). FR-MRG-4's *experimental*
|
||||||
stays.
|
stays.
|
||||||
|
|
||||||
|
## 15. Leaving a frame out, and white clouds — 2026-09-27
|
||||||
|
|
||||||
|
**A frame is left out from its row, not by starting again.** Until now a
|
||||||
|
frame that did not fit ended the job with its name, and the only way on was
|
||||||
|
Back, a smaller selection, and every frame read, demosaiced and searched for
|
||||||
|
keypoints again. Each row of the Frames table on the merge page now has a
|
||||||
|
box, ticked by default. Unticking one leaves the frame out and the rest are
|
||||||
|
solved again at once; ticking it brings it back.
|
||||||
|
|
||||||
|
What makes that cheap is a split in `dr_pano::align`. `match_pairs` does
|
||||||
|
the matching and the pairwise RANSAC once over every frame — about 5 s for
|
||||||
|
the twelve-frame fixture — and `solve` takes a subset and uses only the
|
||||||
|
links among the frames in it, about 0.1 s. Solving a subset by re-aligning
|
||||||
|
it from scratch was tried and is wrong: the RANSAC seeds are keyed on frame
|
||||||
|
position, so dropping a frame moved every seed after it, and on the fixture
|
||||||
|
that was enough to lose a marginal link and strand a neighbour of the frame
|
||||||
|
left out. A frame whose only overlap was with one left out is reported
|
||||||
|
unaligned, exactly as it would be had it never been measured with it.
|
||||||
|
|
||||||
|
**A frame that cannot be placed no longer ends the job either** (FR-MRG-5).
|
||||||
|
Its row names it and says why, and `Merge` stays off until it is unticked —
|
||||||
|
still never a silent drop. The headless example leaves such frames out the
|
||||||
|
same way, and takes `--leave-out N` to untick frame `N` once the first
|
||||||
|
alignment is in.
|
||||||
|
|
||||||
|
**Blown highlights stay white.** A clipped photosite reaches the merge as
|
||||||
|
camera (1, 1, 1), which the as-shot balance turns magenta. The alignment
|
||||||
|
preview balanced it with no highlight rule, so every blown cloud was pink
|
||||||
|
on the page. The DNG had a quieter form of the same fault: a frame's gain
|
||||||
|
below one moved a blown sample off the white level, and the feather mixed
|
||||||
|
it into a neighbour's real sky, after which the develop's own highlight
|
||||||
|
desaturation no longer recognised it. The merge shader (`merge.wgsl`) and
|
||||||
|
the preview (`grey_if_blown` in `dr_ui::merge`) now write a blown sample,
|
||||||
|
before the gain, as the camera value the composite's balance maps to grey —
|
||||||
|
the develop pipeline's neutral, fading in from `CLIP_ONSET` exactly as the
|
||||||
|
develop's does.
|
||||||
|
|
||||||
|
**A composite this wide is past two limits, both lifted the same day.**
|
||||||
|
They were found on a 22 927 × 8966 panorama from Lightroom, and the fixture's
|
||||||
|
own composite (22 993 × 5 980, §11) is past both. rawler's allocation guard,
|
||||||
|
sized in samples but worded in pixels, refuses a three-sample DNG past about
|
||||||
|
16 700 pixels wide; the copy in `third_party/` carries it raised
|
||||||
|
([README](../../third_party/README.md)). And no texture holds such a frame:
|
||||||
|
a linear DNG past 8192 pixels now opens on a box-reduced copy, a render finer
|
||||||
|
than the copy samples a window of the full resolution, and the export is drawn
|
||||||
|
in tiles (FR-DSP-2's note in requirements.md, ARCH §5.3).
|
||||||
|
|||||||
+152
-14
@@ -306,6 +306,21 @@ from source + graph.
|
|||||||
per channel in a wide-gamut linear working space. Quantisation to the output bit depth happens
|
per channel in a wide-gamut linear working space. Quantisation to the output bit depth happens
|
||||||
once, at the final export or display stage.
|
once, at the final export or display stage.
|
||||||
|
|
||||||
|
*Amended 2026-09-27 (D19):* quantisation is one of three things deferred to the end, not the only
|
||||||
|
one. Until the view transform (FR-DEV-3j), values are **scene-linear and unbounded**: nothing
|
||||||
|
clamps above 1.0, nothing applies a transfer function, and nothing maps to a display gamut. Every
|
||||||
|
operation between the camera matrix and the view transform receives and returns that. The
|
||||||
|
working space's primaries are linear Rec.709, carried unbounded, so a colour outside sRGB is a
|
||||||
|
negative component rather than a clipped one. That is wide-gamut in range, not in the primaries
|
||||||
|
the operations measure hue against; moving the primaries to Rec.2020 is deferred (D19).
|
||||||
|
|
||||||
|
*Acceptance:* every point operation at non-neutral settings, handed a ramp to 16.0, returns values
|
||||||
|
that are still monotone in the ramp and still above 1.0 where the ramp is, before the view
|
||||||
|
transform (`scene_referred_until_the_view`, `core/dr-gpu/tests/scene_referred.rs`, rendered on a
|
||||||
|
device: `dr-pipeline` has none). The view transform itself and film simulation are excluded,
|
||||||
|
because clipping into a display range is their job, and so is the detail stage, which a flat frame
|
||||||
|
cannot exercise.
|
||||||
|
|
||||||
**FR-DEV-3 — Adjustment set (v1).**
|
**FR-DEV-3 — Adjustment set (v1).**
|
||||||
|
|
||||||
- White balance (temperature/tint, and picker)
|
- White balance (temperature/tint, and picker)
|
||||||
@@ -408,21 +423,31 @@ demosaic and the working-space conversion.
|
|||||||
**v1 scope** (per D11 — good defaults rather than exhaustive colour science):
|
**v1 scope** (per D11 — good defaults rather than exhaustive colour science):
|
||||||
|
|
||||||
1. Embedded DNG `ColorMatrix1/2` and `ForwardMatrix1/2` tags
|
1. Embedded DNG `ColorMatrix1/2` and `ForwardMatrix1/2` tags
|
||||||
2. A hand-tuned base curve per launch camera body, shipped with the app
|
2. ~~A hand-tuned base curve per launch camera body, shipped with the app~~ — **retired
|
||||||
|
2026-09-27 (D19).** The tone half of "the camera's look" is the view transform's (FR-DEV-3j),
|
||||||
|
one for every body and adjustable. The colour half stays here, in the matrix and later the DCP.
|
||||||
3. HaldCLUT import (FR-DEV-3f)
|
3. HaldCLUT import (FR-DEV-3f)
|
||||||
|
|
||||||
|
The camera profile ends at the matrix, and the matrix runs **first**: white balance is applied in
|
||||||
|
camera RGB, where its multipliers are defined, and every other operation receives working-space
|
||||||
|
colour. Before D19 the edits ran in camera RGB and the matrix came after them, so a hue in the
|
||||||
|
colour mixer and the weights in `luminance()` meant something different on every body.
|
||||||
|
|
||||||
**Deferred but not foreclosed:** full `.dcp` support with `HueSatDeltas`, `ProfileLookTable`, and
|
**Deferred but not foreclosed:** full `.dcp` support with `HueSatDeltas`, `ProfileLookTable`, and
|
||||||
dual-illuminant interpolation. The stage shall be structured so these are additions rather than a
|
dual-illuminant interpolation. The stage shall be structured so these are additions rather than a
|
||||||
pipeline reordering.
|
pipeline reordering.
|
||||||
|
|
||||||
Rationale for the reduced scope: a bare 3×3 matrix produces the flat, poor-skin-tone rendering
|
Rationale for the reduced scope: a bare 3×3 matrix produces the flat, poor-skin-tone rendering
|
||||||
characteristic of dcraw defaults, which is the documented reason people abandon darktable in the
|
characteristic of dcraw defaults, which is the documented reason people abandon darktable in the
|
||||||
first hour. A per-body base curve fixes most of that at a fraction of the cost of a full DCP
|
first hour. ~~A per-body base curve fixes most of that at a fraction of the cost of a full DCP
|
||||||
implementation. The profile database ships **versioned independently of the app binary** so bodies
|
implementation.~~ The flat render is a missing *view transform*, not a missing per-body curve:
|
||||||
and curves can be added without a release — and, under D8's GPLv3, contributed by users.
|
darktable's own answer to the first-hour complaint was a scene-referred default, and Ansel's is
|
||||||
|
the same. The per-body curves this clause shipped described themselves as hand-tuned shapes, not
|
||||||
|
measurements, and their provenance was not known well enough to keep them as defaults (D19).
|
||||||
|
|
||||||
*Acceptance:* for each launch body, the default render is subjectively comparable to the camera's
|
*Acceptance:* the default render is subjectively comparable to the camera's own JPEG — through
|
||||||
own JPEG. ΔE2000 validation against ColorChecker references applies once DCP support lands.
|
FR-DEV-3j's default, for every body. ΔE2000 validation against ColorChecker references applies
|
||||||
|
once DCP support lands.
|
||||||
|
|
||||||
**FR-DEV-3f — Look emulation.** Support HaldCLUT import, which inherits the existing free film
|
**FR-DEV-3f — Look emulation.** Support HaldCLUT import, which inherits the existing free film
|
||||||
simulation ecosystem at near-zero implementation cost, plus reading the in-RAF film simulation tag
|
simulation ecosystem at near-zero implementation cost, plus reading the in-RAF film simulation tag
|
||||||
@@ -439,9 +464,13 @@ the picture along the film's own curve, shoulder and all, rather than scaling a
|
|||||||
exposure. And the **data cost inverts**: a stock is ~17 kB of published measurements where one
|
exposure. And the **data cost inverts**: a stock is ~17 kB of published measurements where one
|
||||||
HaldCLUT is ~800 kB of one person's grade.
|
HaldCLUT is ~800 kB of one person's grade.
|
||||||
|
|
||||||
A film simulation is a *rendering*, not an adjustment, so it replaces the camera profile's base
|
A film simulation is a *rendering*, not an adjustment, so it **is** the view transform when a
|
||||||
curve and the conversion out of camera space (`Operation::renders`) — applying both would render
|
stock is chosen (FR-DEV-3j): it runs last, after every adjustment and after the detail stage, in
|
||||||
the scene twice.
|
place of the default sigmoid, and never in addition to it. *Amended 2026-09-27 (D19):* it ran at
|
||||||
|
order 25 before this, after exposure and before everything else, so the edits below it acted on
|
||||||
|
the film's output. They now act on the scene the film is shown: an edit is a decision about the
|
||||||
|
exposure the negative receives, and the film is the last thing that happens to the picture.
|
||||||
|
Existing edits that combine a stock with tone or colour operations render differently.
|
||||||
|
|
||||||
*Acceptance:* a neutral scene printed through a colour negative's own paper renders neutral to
|
*Acceptance:* a neutral scene printed through a colour negative's own paper renders neutral to
|
||||||
within 0.06 in linear sRGB; the baked lookup's interpolation error stays under one 8-bit code
|
within 0.06 in linear sRGB; the baked lookup's interpolation error stays under one 8-bit code
|
||||||
@@ -518,6 +547,34 @@ also written to the sidecar, run-length coded beside the layer, because a stored
|
|||||||
that never runs a model. It is a materialisation of the identity, not the edit: it takes no part
|
that never runs a model. It is a materialisation of the identity, not the edit: it takes no part
|
||||||
in equality or merge, and the identity remains what the part means.
|
in equality or merge, and the identity remains what the part means.
|
||||||
|
|
||||||
|
**FR-DEV-3j — View transform.** The last stage of the develop pipeline maps scene-linear
|
||||||
|
colour to a display range, and it is the only stage that may. By default it is a log-logistic
|
||||||
|
sigmoid applied per channel, with the middle channel's position between the other two restored
|
||||||
|
afterwards so a hue survives the shoulder, and the result clipped only by the output transform. A
|
||||||
|
stock chosen under FR-DEV-3f replaces it.
|
||||||
|
|
||||||
|
It is an operation with two parameters, persisted in the sidecar, adjustable in the develop panel,
|
||||||
|
and held per mask layer like any other:
|
||||||
|
|
||||||
|
- **Contrast** — the sigmoid's slope. Default 1.4.
|
||||||
|
- **White** — how far above middle grey, in stops, the scene reaches display white. Default 4.0,
|
||||||
|
so a highlight a stop past sensor saturation still rolls into white rather than clipping at it.
|
||||||
|
|
||||||
|
Scene middle grey is 0.13, where the retired default curve placed it (FR-DEV-3e), and it maps to
|
||||||
|
display 0.18. A photograph with the view transform at its defaults is **unedited**: the operation
|
||||||
|
is always composed, and "active" keeps meaning "moved from the defaults", so an untouched image
|
||||||
|
writes no parameters and every other operation's neutral is still the image.
|
||||||
|
|
||||||
|
An already-rendered source — a JPEG — is not rendered again: the view transform is skipped for it,
|
||||||
|
as the base curve was, so its two sliders do not move a JPEG. A film stock is not skipped, because
|
||||||
|
choosing one is an edit.
|
||||||
|
|
||||||
|
*Acceptance:* monotone in each channel; a neutral stays neutral; middle grey lands within 0.01 of
|
||||||
|
0.18; between scene 0.03 and 1.0, the default is within 0.3 EV of the retired default curve; the
|
||||||
|
scene value `0.13 · 2^white` reaches 1.0; and the shader agrees with the CPU reference.
|
||||||
|
|
||||||
|
*Added 2026-09-27 (D19).*
|
||||||
|
|
||||||
**FR-DEV-4 — Ordered, GPU-resident execution.** The pipeline executes as a sequence of GPU
|
**FR-DEV-4 — Ordered, GPU-resident execution.** The pipeline executes as a sequence of GPU
|
||||||
compute stages. Intermediate results remain in GPU memory between stages. **Processed pixels
|
compute stages. Intermediate results remain in GPU memory between stages. **Processed pixels
|
||||||
shall reach the display without a CPU round-trip.** *(This is a hard architectural constraint —
|
shall reach the display without a CPU round-trip.** *(This is a hard architectural constraint —
|
||||||
@@ -692,6 +749,15 @@ tiles are reused.
|
|||||||
> number from the device the clause is about rather than from the one it is not. If S6 finds the
|
> number from the device the clause is about rather than from the one it is not. If S6 finds the
|
||||||
> fused pass inside budget there too, FR-DSP-2 becomes a scheduling concern for export and
|
> fused pass inside budget there too, FR-DSP-2 becomes a scheduling concern for export and
|
||||||
> thumbnailing as frame-budget.md proposes; if not, S6 names the stage to tile.
|
> thumbnailing as frame-budget.md proposes; if not, S6 names the stage to tile.
|
||||||
|
>
|
||||||
|
> **2026-09-27: the export half is built, for sources larger than one texture.** A linear DNG
|
||||||
|
> past 8192 pixels (a stitched panorama, 22927×8966 in the case that prompted it) opens on a
|
||||||
|
> reduced copy, and a render finer than the copy samples a window of the full resolution
|
||||||
|
> through the fused shader's source-window uniforms. The export is drawn in 4096-pixel tiles
|
||||||
|
> grown by the detail chain's reach (`dr_pipeline::tiles`, `ComposedDetail::reach`) and matches
|
||||||
|
> the untiled render to within one code value (`core/dr-gpu/tests/source_window.rs`). The
|
||||||
|
> interactive path is still one dispatch over the viewport: a zoomed canvas cuts one window
|
||||||
|
> and keeps it while the view stays inside, which is not the tile cache this clause describes.
|
||||||
|
|
||||||
**FR-DSP-3 — Interactive latency.** Moving a slider updates the visible region within one frame
|
**FR-DSP-3 — Interactive latency.** Moving a slider updates the visible region within one frame
|
||||||
budget at proxy resolution. When a full-resolution result is needed it is computed
|
budget at proxy resolution. When a full-resolution result is needed it is computed
|
||||||
@@ -1846,7 +1912,7 @@ with no depth to recover — so it is where the shared machinery is built.
|
|||||||
|
|
||||||
**FR-MRG-2 — What is stitched.** Each source enters the merge in **camera space**: after black
|
**FR-MRG-2 — What is stitched.** Each source enters the merge in **camera space**: after black
|
||||||
and white levels, demosaic and lens distortion correction, and before everything else — no white
|
and white levels, demosaic and lens distortion correction, and before everything else — no white
|
||||||
balance, no base curve, no camera matrix, no edit. The composite carries the first source's body,
|
balance, no camera matrix, no edit, no view transform. The composite carries the first source's body,
|
||||||
colour matrix and as-shot neutral, so that it is developed afterwards exactly as one of its
|
colour matrix and as-shot neutral, so that it is developed afterwards exactly as one of its
|
||||||
sources would be: the camera profile, the white balance and every operation in §3.3 are applied
|
sources would be: the camera profile, the white balance and every operation in §3.3 are applied
|
||||||
once, to the composite, in its own develop.
|
once, to the composite, in its own develop.
|
||||||
@@ -1855,9 +1921,11 @@ This is the clause that decides what the output *is*. Stitching the rendered edi
|
|||||||
stitcher does; the result cannot be re-developed, and any difference between the frames' edits
|
stitcher does; the result cannot be re-developed, and any difference between the frames' edits
|
||||||
becomes a seam. Stitching camera-space pixels produces a photograph the camera could have taken,
|
becomes a seam. Stitching camera-space pixels produces a photograph the camera could have taken,
|
||||||
and nothing is applied twice. The cut sits *below* the profile, not above it, for a reason S15.3
|
and nothing is applied twice. The cut sits *below* the profile, not above it, for a reason S15.3
|
||||||
found in the pipeline: the base curve is part of the profile (FR-DEV-3e) and is applied to every
|
found in the pipeline: the profile's rendering was applied to every frame of a known body, so a
|
||||||
frame of a known body, so a composite that baked it in and then developed as one would render the
|
composite that baked it in and then developed as one would render it twice. *(Amended 2026-09-27,
|
||||||
curve twice. Lens correction alone sits above the cut, because a distorted frame does not align.
|
D19: that rendering was the per-body base curve, retired; the view transform that replaces it is
|
||||||
|
applied to every develop, so the reason stands.)* Lens correction alone sits above the cut, because
|
||||||
|
a distorted frame does not align.
|
||||||
White balance sits below it because the sensor saw the same light in every frame: un-balanced
|
White balance sits below it because the sensor saw the same light in every frame: un-balanced
|
||||||
camera RGB agrees across the overlaps whether or not the camera's auto white balance drifted, and
|
camera RGB agrees across the overlaps whether or not the camera's auto white balance drifted, and
|
||||||
the balanced values would not.
|
the balanced values would not.
|
||||||
@@ -1911,6 +1979,11 @@ The same rule as `spot-removal.md`'s and D17's: a tool that quietly alters or om
|
|||||||
photograph is the failure this application must not have, and here the omission would be an
|
photograph is the failure this application must not have, and here the omission would be an
|
||||||
entire frame.
|
entire frame.
|
||||||
|
|
||||||
|
*Amended 2026-09-27:* the job no longer stops. The frame is named on its row, with why, and the
|
||||||
|
merge cannot be confirmed until the photographer unticks it; the rest are then solved again from
|
||||||
|
the pairs already measured (panorama.md §15). The omission is the photographer's, made in view,
|
||||||
|
which is what this clause asks — never a silent drop.
|
||||||
|
|
||||||
**FR-MRG-6 — Provenance.** *(general to any merge)* The composite's sidecar carries
|
**FR-MRG-6 — Provenance.** *(general to any merge)* The composite's sidecar carries
|
||||||
`derived_from`: the content hashes of its sources in order, and the merge parameters. The history
|
`derived_from`: the content hashes of its sources in order, and the merge parameters. The history
|
||||||
records the merge as the first entry, and export metadata declares the composite as one. Sources
|
records the merge as the first entry, and export metadata declares the composite as one. Sources
|
||||||
@@ -2096,6 +2169,11 @@ tiling. GPU memory headroom is configurable. Where an allocation fails, work is
|
|||||||
memory or refused with a typed error (`GpuError::TooLarge`) — never rendered by a CPU pipeline,
|
memory or refused with a typed error (`GpuError::TooLarge`) — never rendered by a CPU pipeline,
|
||||||
which does not exist (NFR-R8).
|
which does not exist (NFR-R8).
|
||||||
|
|
||||||
|
> **2026-09-27:** a linear DNG larger than one texture is no longer refused. It is developed from
|
||||||
|
> a reduced copy and full-resolution windows, and exported in tiles (FR-DSP-2's note). A CFA file
|
||||||
|
> too large for one texture, or whose photosites overrun the device's storage-buffer limits on
|
||||||
|
> upload, is still refused, and there is still no headroom budget.
|
||||||
|
|
||||||
**NFR-RES-3 — Mobile power.** On Android the app shall not render continuously when idle. Battery
|
**NFR-RES-3 — Mobile power.** On Android the app shall not render continuously when idle. Battery
|
||||||
and thermal behaviour are first-class concerns; background sync respects metered-connection and
|
and thermal behaviour are first-class concerns; background sync respects metered-connection and
|
||||||
battery-saver settings.
|
battery-saver settings.
|
||||||
@@ -2346,6 +2424,7 @@ Rationale, evidence, and the eliminated alternatives are recorded in
|
|||||||
| D11 | Product positioning | Culling-first differentiator; see below |
|
| D11 | Product positioning | Culling-first differentiator; see below |
|
||||||
| D12 | Scope versus pace | **DECIDED 2026-09-19** — settled by events; full scope stands, no v1 date |
|
| D12 | Scope versus pace | **DECIDED 2026-09-19** — settled by events; full scope stands, no v1 date |
|
||||||
| D18 | Derived images | **DECIDED 2026-09-19** — a merge writes a new source file; no multi-source Version |
|
| D18 | Derived images | **DECIDED 2026-09-19** — a merge writes a new source file; no multi-source Version |
|
||||||
|
| D19 | Scene-referred pipeline | **DECIDED 2026-09-27** — edits on unbounded scene-linear colour; one view transform, last; per-body base curves retired |
|
||||||
|
|
||||||
### D11 — product positioning
|
### D11 — product positioning
|
||||||
|
|
||||||
@@ -2358,7 +2437,7 @@ Settled by requirements calibration, 2026-08-08.
|
|||||||
| Culling | **The core differentiator** (§3.9) |
|
| Culling | **The core differentiator** (§3.9) |
|
||||||
| Focus checking | Peaking *and* zoom |
|
| Focus checking | Peaking *and* zoom |
|
||||||
| Ingest | Full workflow — template rename, checksum verify, dual-destination |
|
| Ingest | Full workflow — template rename, checksum verify, dual-destination |
|
||||||
| Colour defaults | Good, not obsessive — matrices plus per-body base curve |
|
| Colour defaults | Good, not obsessive — matrices plus one scene-referred view transform for every body (D19; the per-body base curves are retired) |
|
||||||
| Film simulation | Fujifilm explicitly targeted |
|
| Film simulation | Fujifilm explicitly targeted |
|
||||||
| AI | Denoise in v1; masking deferred. Per-face eye state and head pose are in v1 **as culling evidence, not AI** (FR-CULL-8a, FR-CULL-13); gaze deferred (§7) |
|
| AI | Denoise in v1; masking deferred. Per-face eye state and head pose are in v1 **as culling evidence, not AI** (FR-CULL-8a, FR-CULL-13); gaze deferred (§7) |
|
||||||
| Local adjustments | Full masking, GPU-rasterised |
|
| Local adjustments | Full masking, GPU-rasterised |
|
||||||
@@ -2592,6 +2671,65 @@ file, not an edit to the old one; and the composite occupies disk — a five-fra
|
|||||||
where the output is still frame A, and inherits nothing from this decision but the provenance
|
where the output is still frame A, and inherits nothing from this decision but the provenance
|
||||||
rule.
|
rule.
|
||||||
|
|
||||||
|
### D19 — scene-referred pipeline · **DECIDED 2026-09-27**
|
||||||
|
|
||||||
|
**Edits operate on scene-linear, unbounded colour in the working space, and one view transform,
|
||||||
|
last, maps it to a display range.** Range, encoding and gamut are all deferred to that point, as
|
||||||
|
quantisation already was (FR-DEV-2).
|
||||||
|
|
||||||
|
*Why now.* The spec missed [Ansel](https://ansel.photos/), Aurélien Pierre's fork of darktable 4.0,
|
||||||
|
and with it the argument he spent years making in darktable: a display-referred curve early in the
|
||||||
|
pipeline throws away what every later stage needs. Reading the code against that argument found
|
||||||
|
four places it applied:
|
||||||
|
|
||||||
|
1. **The base curve clipped.** It was a five-point spline on the unit square, flat past its last
|
||||||
|
point, so every value above 1.0 — every recovered highlight — left it at the same number, per
|
||||||
|
channel.
|
||||||
|
2. **The detail stage was handed non-linear data.** The fused pass stops at "linear working
|
||||||
|
values" when a sharpener or a blur follows, but it stopped *after* the base curve, so the
|
||||||
|
neighbourhood operations convolved curved, clipped values while their comments promised the
|
||||||
|
opposite.
|
||||||
|
3. **The edits ran in camera RGB.** The matrix came after them, so `luminance()`'s Rec.709 weights
|
||||||
|
were applied to camera primaries and a hue in the colour mixer was a different hue on each
|
||||||
|
body. ARCH §5.2 had always drawn the matrix first; the code had drifted.
|
||||||
|
4. **The tone curve clamped** to [0, 1] and applied a 2.2 gamma around its spline, mid-chain.
|
||||||
|
|
||||||
|
*What changes.* The order becomes: demosaic → as-shot white balance and the white balance
|
||||||
|
operation, in camera RGB → the camera matrix → every other point operation and every mask layer →
|
||||||
|
the detail stage → the view transform (FR-DEV-3j), or the film stock (FR-DEV-3f) when one is chosen
|
||||||
|
→ the output transform. With a detail stage the view transform is a dispatch of its own after it,
|
||||||
|
composed by the same generator as the fused pass. Nothing before the view transform clamps above
|
||||||
|
1.0 or display-encodes, and a test says so (FR-DEV-2).
|
||||||
|
|
||||||
|
*What is retired.* The per-body base curves and their database (FR-DEV-3e). Their own file called
|
||||||
|
them hand-tuned shapes rather than measurements, and not enough was known about where the shapes
|
||||||
|
came from to keep them as defaults behind sliders. Body character is the matrix's, and the DCP's
|
||||||
|
when it lands.
|
||||||
|
|
||||||
|
*What it costs.*
|
||||||
|
|
||||||
|
- **Every photograph renders differently.** The default view transform was fitted so middle grey
|
||||||
|
lands where the retired default curve put it and midtones stay within 0.3 EV of it, but the
|
||||||
|
upper midtones are darker and the highlights roll off over two more stops. Previews rendered
|
||||||
|
before the change keep the old look until they are rendered again.
|
||||||
|
- **Film edits change meaning.** A tone or colour operation beside a stock used to act on the
|
||||||
|
film's output; it now acts on the scene the film receives.
|
||||||
|
- **Tablet and desktop must be released together.** No schema changes and the sidecar gains only
|
||||||
|
ordinary parameters, but two peers on different builds render the same edit differently.
|
||||||
|
- **One more dispatch with a detail stage**, for the view transform after it.
|
||||||
|
|
||||||
|
*Rejected.* Keeping the per-body curves as the view transform's per-body defaults, for the
|
||||||
|
provenance reason above. Leaving the film at order 25 and having it suppress the view transform:
|
||||||
|
simpler, and it kept existing film edits' meaning, but it left a display-referred rendering in the
|
||||||
|
middle of the chain, which is the thing this decision removes. A fixed view transform with no
|
||||||
|
controls: it would have been smaller, but a scene-referred pipeline whose white point cannot be
|
||||||
|
moved hands the photographer a shoulder they cannot place.
|
||||||
|
|
||||||
|
*Deferred.* Working-space primaries of Rec.2020 rather than Rec.709. The range is already
|
||||||
|
unbounded, but several fragments floor at zero, which clips a colour outside sRGB, and the colour
|
||||||
|
mixer's bands and the colour grading wheel would need their hues re-measured. Gamut compression
|
||||||
|
beyond the output transform's clip goes with it.
|
||||||
|
|
||||||
### D16 — plugin licensing · **OPEN, post-v1**
|
### D16 — plugin licensing · **OPEN, post-v1**
|
||||||
|
|
||||||
> Deferred with §3.10 on 2026-09-19. Still to be answered before the format is published as
|
> Deferred with §3.10 on 2026-09-19. Still to be answered before the format is published as
|
||||||
|
|||||||
+78
-77
File diff suppressed because one or more lines are too long
+24
-24
@@ -39,7 +39,7 @@ The list is longer than it is tall, so a way to walk it that cannot be lost to t
|
|||||||
|
|
||||||
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
|
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1961`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:1977`</sub>
|
||||||
|
|
||||||
### Move a magnified photograph about
|
### Move a magnified photograph about
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
|
|||||||
|
|
||||||
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2057`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2073`</sub>
|
||||||
|
|
||||||
### Paint a mask by hand
|
### Paint a mask by hand
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ Only once there is something outside the viewport to reach, which is why the cur
|
|||||||
|
|
||||||
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2148`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2164`</sub>
|
||||||
|
|
||||||
### Open this list
|
### Open this list
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
|
|||||||
|
|
||||||
Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for.
|
Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2374`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2390`</sub>
|
||||||
|
|
||||||
### Take back the last change
|
### Take back the last change
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ Most of the keys are develop's, and a reference that could only be opened from t
|
|||||||
|
|
||||||
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2404`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2420`</sub>
|
||||||
|
|
||||||
### Do it again after taking it back
|
### Do it again after taking it back
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
|
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
|
||||||
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2418`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2434`</sub>
|
||||||
|
|
||||||
### Remove a repair
|
### Remove a repair
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
- **Pointer** — Click it, then Delete Repair
|
- **Pointer** — Click it, then Delete Repair
|
||||||
- **Keyboard** — `Delete` or `Backspace`, while repairing
|
- **Keyboard** — `Delete` or `Backspace`, while repairing
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2438`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2454`</sub>
|
||||||
|
|
||||||
### Copy the settings from this photograph
|
### Copy the settings from this photograph
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
|
|
||||||
The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2457`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2473`</sub>
|
||||||
|
|
||||||
### Paste the settings onto this photograph
|
### Paste the settings onto this photograph
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ The button is the copy that has to work: a tablet has no modifier key to hold an
|
|||||||
|
|
||||||
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2470`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2486`</sub>
|
||||||
|
|
||||||
### Choose which kinds of edit a copy carries
|
### Choose which kinds of edit a copy carries
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
|
|||||||
|
|
||||||
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
|
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2488`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2504`</sub>
|
||||||
|
|
||||||
### Export this photograph as the last one was
|
### Export this photograph as the last one was
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving e
|
|||||||
|
|
||||||
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
|
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2513`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2529`</sub>
|
||||||
|
|
||||||
### Choose how to export, then export
|
### Choose how to export, then export
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ Every export runs on the defaults in Settings, so "as the last one was" is what
|
|||||||
|
|
||||||
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
|
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2526`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2542`</sub>
|
||||||
|
|
||||||
### Keep a crop that leaves a mask outside
|
### Keep a crop that leaves a mask outside
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
|
|||||||
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
|
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
|
||||||
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
|
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2591`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2607`</sub>
|
||||||
|
|
||||||
### Go back to the grid
|
### Go back to the grid
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
|
|||||||
|
|
||||||
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
|
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2608`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2624`</sub>
|
||||||
|
|
||||||
### Nudge the control last moved
|
### Nudge the control last moved
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ Lightroom's key for the grid. Escape gets there too, but a step at a time — ou
|
|||||||
|
|
||||||
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
|
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2637`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2653`</sub>
|
||||||
|
|
||||||
### Change which group of adjustments is on screen
|
### Change which group of adjustments is on screen
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ Lightroom's keys for the selected slider. There is no focus ring on a slider her
|
|||||||
|
|
||||||
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2665`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2681`</sub>
|
||||||
|
|
||||||
### Look at the photograph at 1:1
|
### Look at the photograph at 1:1
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ The groups are whatever the operation set declares itself to be about, so there
|
|||||||
|
|
||||||
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
|
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2701`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2717`</sub>
|
||||||
|
|
||||||
### Rate this photograph
|
### Rate this photograph
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
|||||||
- **Pointer** — Click a star in the top bar
|
- **Pointer** — Click a star in the top bar
|
||||||
- **Keyboard** — `0`–`5`
|
- **Keyboard** — `0`–`5`
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2758`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2774`</sub>
|
||||||
|
|
||||||
### Pick or reject this photograph
|
### Pick or reject this photograph
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
|||||||
|
|
||||||
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
|
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2764`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2780`</sub>
|
||||||
|
|
||||||
### Give this photograph a colour label
|
### Give this photograph a colour label
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging h
|
|||||||
|
|
||||||
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
|
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2794`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2810`</sub>
|
||||||
|
|
||||||
### Move to the next or previous photograph
|
### Move to the next or previous photograph
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ The grid's keys, on the photograph that is open, so labelling while stepping thr
|
|||||||
|
|
||||||
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse.
|
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2819`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2835`</sub>
|
||||||
|
|
||||||
### See the photograph before you edited it
|
### See the photograph before you edited it
|
||||||
|
|
||||||
@@ -254,7 +254,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
|
|||||||
|
|
||||||
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2949`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2965`</sub>
|
||||||
|
|
||||||
### Put one control back to its default
|
### Put one control back to its default
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ The question a correction raises is whether it did what it was for — whether t
|
|||||||
|
|
||||||
One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past.
|
One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1021`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:1024`</sub>
|
||||||
|
|
||||||
### Do what a sheet offers
|
### Do what a sheet offers
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ One key for "up one", innermost first: a question before the sheet under it, a s
|
|||||||
- **Pointer** — Press its button — Export, or Copy
|
- **Pointer** — Press its button — Export, or Copy
|
||||||
- **Keyboard** — `Enter`, on the export and copy sheets
|
- **Keyboard** — `Enter`, on the export and copy sheets
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1031`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:1034`</sub>
|
||||||
|
|
||||||
### Scroll by the scrollbar
|
### Scroll by the scrollbar
|
||||||
|
|
||||||
|
|||||||
+47
-10
@@ -176,6 +176,18 @@ Hold `Before` to see the photograph as it was.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
`Tone Mapping`, last in the group, is how the scene is fitted onto the
|
||||||
|
screen, and it runs after every other adjustment. `White Point` says how many
|
||||||
|
stops above middle grey reach white — raise it to bring a bright sky back
|
||||||
|
from white, lower it for a brighter, punchier picture — and `Contrast` sets
|
||||||
|
the slope of the curve between. Every adjustment above it works on the scene
|
||||||
|
as the camera recorded it, highlights beyond white included, so pulling the
|
||||||
|
highlights recovers what the sensor caught rather than what the screen could
|
||||||
|
show. A JPEG has been fitted to a screen already, by the camera, so on a
|
||||||
|
JPEG these two do nothing.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### Looking closer
|
### Looking closer
|
||||||
|
|
||||||
Double-click for 1:1; drag to move about; double-click again to fit. The
|
Double-click for 1:1; drag to move about; double-click again to fit. The
|
||||||
@@ -269,7 +281,9 @@ opacity are in the panel. Heal blends; clone copies.
|
|||||||
|
|
||||||
The `Film` chooser at the head of Adjust applies a spectral simulation of a
|
The `Film` chooser at the head of Adjust applies a spectral simulation of a
|
||||||
named stock; below it, the print exposure and push controls a film has and a
|
named stock; below it, the print exposure and push controls a film has and a
|
||||||
sensor does not. The list opens over the column and scrolls on its own — by
|
sensor does not. A stock takes the place of `Tone Mapping`: it is the last
|
||||||
|
thing that happens to the picture, so every other adjustment decides the
|
||||||
|
exposure the negative receives. The list opens over the column and scrolls on its own — by
|
||||||
wheel, drag or flick, or with `Up`, `Down` and `Enter` — down to the
|
wheel, drag or flick, or with `Up`, `Down` and `Enter` — down to the
|
||||||
black-and-white stocks at its end.
|
black-and-white stocks at its end.
|
||||||
|
|
||||||
@@ -287,22 +301,32 @@ a gradient over the sky whose `Print Exposure` burns it in.
|
|||||||
### History, snapshots, presets
|
### History, snapshots, presets
|
||||||
|
|
||||||
Every change is a step; `Undo` and the History panel walk them. `Snapshot`
|
Every change is a step; `Undo` and the History panel walk them. `Snapshot`
|
||||||
keeps the current state under a name. `Presets…` saves the settings to
|
keeps the current state under a name. `Presets`, at the foot of the tool
|
||||||
apply elsewhere, and imports Lightroom presets — `Folder…` for a folder of
|
rail on the left, opens a menu of presets beside the rail, over the photograph, filed in
|
||||||
them, `.xmp file…` for one.
|
folders that start closed: your own under `Yours`, then the ones DarkRoom
|
||||||
|
ships — `Essentials`, `Skies`, and `Film`, which holds `Colour`, `Cinema`
|
||||||
|
and `Black and white`, one measured stock each. Choosing a folder opens it;
|
||||||
|
choosing a preset applies it. The menu's last row, `Save or manage…`, opens
|
||||||
|
the presets sheet, which lists the same folders and saves the settings to
|
||||||
|
apply elsewhere, renames and deletes them, and imports Lightroom presets —
|
||||||
|
`Folder…` for a folder of them, `.xmp file…` for one.
|
||||||
|
|
||||||
The sheet lists your own presets first, then the ones DarkRoom ships —
|
A `/` in a name files the preset: `Portraits/Warm skin` is `Warm skin` in a
|
||||||
Essentials, Skies, and colour, cinema and black-and-white film, one measured stock
|
`Portraits` folder under `Yours`, and renaming it is how it moves. An
|
||||||
each. A shipped preset is a look: it changes what it names and leaves the
|
imported Lightroom folder keeps its groups the same way.
|
||||||
|
|
||||||
|
A shipped preset is a look: it changes what it names and leaves the
|
||||||
photograph's own corrections alone, as an imported Lightroom preset does.
|
photograph's own corrections alone, as an imported Lightroom preset does.
|
||||||
Saving under a shipped preset's name makes your version the one that name
|
Saving under a shipped preset's name makes your version the one that name
|
||||||
applies, marked *changed*; `Revert` brings the shipped one back, and renaming
|
applies, marked *changed*; `Revert` brings the shipped one back, and renaming
|
||||||
yours makes it one of your own. A film preset carries its stock: choosing one
|
yours makes it one of your own. A film preset carries its stock: choosing one
|
||||||
sets the `Film` chooser and leaves the rest of the edit where it was.
|
sets the `Film` chooser and leaves the rest of the edit where it was.
|
||||||
|
|
||||||
|

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

|

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

|

|
||||||
|
|
||||||
### Copying settings
|
### Copying settings
|
||||||
|
|
||||||
@@ -310,7 +334,7 @@ sets the `Film` chooser and leaves the rest of the edit where it was.
|
|||||||
or Ctrl+V, puts them on another, and says what it would paste — how many
|
or Ctrl+V, puts them on another, and says what it would paste — how many
|
||||||
adjustments, and whether the crop comes too. In the grid, `Paste to N` on the
|
adjustments, and whether the crop comes too. In the grid, `Paste to N` on the
|
||||||
selection bar pastes onto every photograph selected. Which kinds of edit a
|
selection bar pastes onto every photograph selected. Which kinds of edit a
|
||||||
copy carries is chosen in `Presets…`, or with Ctrl+Shift+C: a look carried
|
copy carries is chosen in the presets sheet, or with Ctrl+Shift+C: a look carried
|
||||||
across a shoot usually leaves each frame's own crop alone.
|
across a shoot usually leaves each frame's own crop alone.
|
||||||
|
|
||||||
## Merging a panorama
|
## Merging a panorama
|
||||||
@@ -324,7 +348,20 @@ the border to be filled rather than cropped, then `Merge`. The composite is
|
|||||||
written beside its sources as a DNG and appears in the grid with the merge
|
written beside its sources as a DNG and appears in the grid with the merge
|
||||||
as the first step in its history.
|
as the first step in its history.
|
||||||
|
|
||||||

|
Each frame has a box in the `Frames` list. Untick one to leave it out, and
|
||||||
|
the rest are aligned again at once, without reading the frames again; tick
|
||||||
|
it to bring it back. A frame that cannot be placed is named there with why,
|
||||||
|
and `Merge` stays off until it is unticked. Blown sky stays white in the
|
||||||
|
preview and in the composite.
|
||||||
|
|
||||||
|
A panorama is usually far wider than a graphics card can draw in one piece.
|
||||||
|
It opens in develop all the same, on a reduced copy that is drawn from the
|
||||||
|
full resolution wherever you zoom in, and it exports at full size. The same
|
||||||
|
goes for a panorama Lightroom stitched and saved as a DNG.
|
||||||
|
|
||||||
|

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

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

|

|
||||||
|
|
||||||
|
|||||||
+40
-10
@@ -264,6 +264,16 @@ the strip at its head narrows it to one group.</p>
|
|||||||
<p>Exposure, contrast, highlights, shadows, blacks, whites and a tone curve.
|
<p>Exposure, contrast, highlights, shadows, blacks, whites and a tone curve.
|
||||||
Hold <code>Before</code> to see the photograph as it was.</p>
|
Hold <code>Before</code> to see the photograph as it was.</p>
|
||||||
<figure><img loading="lazy" src="media/develop-light.gif" alt="Raising exposure, pulling the highlights, lifting the shadows, then holding Before"><figcaption>Raising exposure, pulling the highlights, lifting the shadows, then holding Before</figcaption></figure>
|
<figure><img loading="lazy" src="media/develop-light.gif" alt="Raising exposure, pulling the highlights, lifting the shadows, then holding Before"><figcaption>Raising exposure, pulling the highlights, lifting the shadows, then holding Before</figcaption></figure>
|
||||||
|
<p><code>Tone Mapping</code>, last in the group, is how the scene is fitted onto the
|
||||||
|
screen, and it runs after every other adjustment. <code>White Point</code> says how many
|
||||||
|
stops above middle grey reach white — raise it to bring a bright sky back
|
||||||
|
from white, lower it for a brighter, punchier picture — and <code>Contrast</code> sets
|
||||||
|
the slope of the curve between. Every adjustment above it works on the scene
|
||||||
|
as the camera recorded it, highlights beyond white included, so pulling the
|
||||||
|
highlights recovers what the sensor caught rather than what the screen could
|
||||||
|
show. A JPEG has been fitted to a screen already, by the camera, so on a
|
||||||
|
JPEG these two do nothing.</p>
|
||||||
|
<figure><img loading="lazy" src="media/develop-tonemap.gif" alt="Raising the white point to bring the clouds back from white, lowering it for a brighter picture, raising the contrast, then holding Before"><figcaption>Raising the white point to bring the clouds back from white, lowering it for a brighter picture, raising the contrast, then holding Before</figcaption></figure>
|
||||||
<h3 id="looking-closer">Looking closer</h3>
|
<h3 id="looking-closer">Looking closer</h3>
|
||||||
<p>Double-click for 1:1; drag to move about; double-click again to fit. The
|
<p>Double-click for 1:1; drag to move about; double-click again to fit. The
|
||||||
wheel zooms to any amount in between. Past 1:1 the file's own pixels are
|
wheel zooms to any amount in between. Past 1:1 the file's own pixels are
|
||||||
@@ -329,7 +339,9 @@ opacity are in the panel. Heal blends; clone copies.</p>
|
|||||||
<h3 id="film">Film</h3>
|
<h3 id="film">Film</h3>
|
||||||
<p>The <code>Film</code> chooser at the head of Adjust applies a spectral simulation of a
|
<p>The <code>Film</code> chooser at the head of Adjust applies a spectral simulation of a
|
||||||
named stock; below it, the print exposure and push controls a film has and a
|
named stock; below it, the print exposure and push controls a film has and a
|
||||||
sensor does not. The list opens over the column and scrolls on its own — by
|
sensor does not. A stock takes the place of <code>Tone Mapping</code>: it is the last
|
||||||
|
thing that happens to the picture, so every other adjustment decides the
|
||||||
|
exposure the negative receives. The list opens over the column and scrolls on its own — by
|
||||||
wheel, drag or flick, or with <code>Up</code>, <code>Down</code> and <code>Enter</code> — down to the
|
wheel, drag or flick, or with <code>Up</code>, <code>Down</code> and <code>Enter</code> — down to the
|
||||||
black-and-white stocks at its end.</p>
|
black-and-white stocks at its end.</p>
|
||||||
<figure><img loading="lazy" src="media/film.gif" alt="Opening the film list, scrolling it, choosing Velvia, then holding Before"><figcaption>Opening the film list, scrolling it, choosing Velvia, then holding Before</figcaption></figure>
|
<figure><img loading="lazy" src="media/film.gif" alt="Opening the film list, scrolling it, choosing Velvia, then holding Before"><figcaption>Opening the film list, scrolling it, choosing Velvia, then holding Before</figcaption></figure>
|
||||||
@@ -342,25 +354,33 @@ a gradient over the sky whose <code>Print Exposure</code> burns it in.</p>
|
|||||||
<figure><img loading="lazy" src="media/film-local.gif" alt="Kodak Ektar 100 on the whole frame, a gradient turned to cover the sky, its Print Exposure raised to burn the sky in, then holding Before"><figcaption>Kodak Ektar 100 on the whole frame, a gradient turned to cover the sky, its Print Exposure raised to burn the sky in, then holding Before</figcaption></figure>
|
<figure><img loading="lazy" src="media/film-local.gif" alt="Kodak Ektar 100 on the whole frame, a gradient turned to cover the sky, its Print Exposure raised to burn the sky in, then holding Before"><figcaption>Kodak Ektar 100 on the whole frame, a gradient turned to cover the sky, its Print Exposure raised to burn the sky in, then holding Before</figcaption></figure>
|
||||||
<h3 id="history-snapshots-presets">History, snapshots, presets</h3>
|
<h3 id="history-snapshots-presets">History, snapshots, presets</h3>
|
||||||
<p>Every change is a step; <code>Undo</code> and the History panel walk them. <code>Snapshot</code>
|
<p>Every change is a step; <code>Undo</code> and the History panel walk them. <code>Snapshot</code>
|
||||||
keeps the current state under a name. <code>Presets…</code> saves the settings to
|
keeps the current state under a name. <code>Presets</code>, at the foot of the tool
|
||||||
apply elsewhere, and imports Lightroom presets — <code>Folder…</code> for a folder of
|
rail on the left, opens a menu of presets beside the rail, over the photograph, filed in
|
||||||
them, <code>.xmp file…</code> for one.</p>
|
folders that start closed: your own under <code>Yours</code>, then the ones DarkRoom
|
||||||
<p>The sheet lists your own presets first, then the ones DarkRoom ships —
|
ships — <code>Essentials</code>, <code>Skies</code>, and <code>Film</code>, which holds <code>Colour</code>, <code>Cinema</code>
|
||||||
Essentials, Skies, and colour, cinema and black-and-white film, one measured stock
|
and <code>Black and white</code>, one measured stock each. Choosing a folder opens it;
|
||||||
each. A shipped preset is a look: it changes what it names and leaves the
|
choosing a preset applies it. The menu's last row, <code>Save or manage…</code>, opens
|
||||||
|
the presets sheet, which lists the same folders and saves the settings to
|
||||||
|
apply elsewhere, renames and deletes them, and imports Lightroom presets —
|
||||||
|
<code>Folder…</code> for a folder of them, <code>.xmp file…</code> for one.</p>
|
||||||
|
<p>A <code>/</code> in a name files the preset: <code>Portraits/Warm skin</code> is <code>Warm skin</code> in a
|
||||||
|
<code>Portraits</code> folder under <code>Yours</code>, and renaming it is how it moves. An
|
||||||
|
imported Lightroom folder keeps its groups the same way.</p>
|
||||||
|
<p>A shipped preset is a look: it changes what it names and leaves the
|
||||||
photograph's own corrections alone, as an imported Lightroom preset does.
|
photograph's own corrections alone, as an imported Lightroom preset does.
|
||||||
Saving under a shipped preset's name makes your version the one that name
|
Saving under a shipped preset's name makes your version the one that name
|
||||||
applies, marked <em>changed</em>; <code>Revert</code> brings the shipped one back, and renaming
|
applies, marked <em>changed</em>; <code>Revert</code> brings the shipped one back, and renaming
|
||||||
yours makes it one of your own. A film preset carries its stock: choosing one
|
yours makes it one of your own. A film preset carries its stock: choosing one
|
||||||
sets the <code>Film</code> chooser and leaves the rest of the edit where it was.</p>
|
sets the <code>Film</code> chooser and leaves the rest of the edit where it was.</p>
|
||||||
|
<figure><img loading="lazy" src="media/presets-menu.png" alt="The presets menu beside the tool rail, with Film and its colour stocks open"><figcaption>The presets menu beside the tool rail, with Film and its colour stocks open</figcaption></figure>
|
||||||
<figure><img loading="lazy" src="media/presets.png" alt="The presets sheet: a name for the current edit, the shipped Essentials, importing, and what an apply carries"><figcaption>The presets sheet: a name for the current edit, the shipped Essentials, importing, and what an apply carries</figcaption></figure>
|
<figure><img loading="lazy" src="media/presets.png" alt="The presets sheet: a name for the current edit, the shipped Essentials, importing, and what an apply carries"><figcaption>The presets sheet: a name for the current edit, the shipped Essentials, importing, and what an apply carries</figcaption></figure>
|
||||||
<figure><img loading="lazy" src="media/presets-film.gif" alt="Scrolling down the shipped presets to the black-and-white films, applying Ilford HP5 Plus, then holding Before"><figcaption>Scrolling down the shipped presets to the black-and-white films, applying Ilford HP5 Plus, then holding Before</figcaption></figure>
|
<figure><img loading="lazy" src="media/presets-film.gif" alt="Opening Film, then Black and white, in the presets menu and applying Ilford HP5 Plus, then holding Before"><figcaption>Opening Film, then Black and white, in the presets menu and applying Ilford HP5 Plus, then holding Before</figcaption></figure>
|
||||||
<h3 id="copying-settings">Copying settings</h3>
|
<h3 id="copying-settings">Copying settings</h3>
|
||||||
<p><code>Copy</code> in the top bar, or Ctrl+C, takes this photograph's settings; <code>Paste</code>,
|
<p><code>Copy</code> in the top bar, or Ctrl+C, takes this photograph's settings; <code>Paste</code>,
|
||||||
or Ctrl+V, puts them on another, and says what it would paste — how many
|
or Ctrl+V, puts them on another, and says what it would paste — how many
|
||||||
adjustments, and whether the crop comes too. In the grid, <code>Paste to N</code> on the
|
adjustments, and whether the crop comes too. In the grid, <code>Paste to N</code> on the
|
||||||
selection bar pastes onto every photograph selected. Which kinds of edit a
|
selection bar pastes onto every photograph selected. Which kinds of edit a
|
||||||
copy carries is chosen in <code>Presets…</code>, or with Ctrl+Shift+C: a look carried
|
copy carries is chosen in the presets sheet, or with Ctrl+Shift+C: a look carried
|
||||||
across a shoot usually leaves each frame's own crop alone.</p>
|
across a shoot usually leaves each frame's own crop alone.</p>
|
||||||
<h2 id="merging-a-panorama">Merging a panorama</h2>
|
<h2 id="merging-a-panorama">Merging a panorama</h2>
|
||||||
<p>Select the frames, then <code>Merge to panorama</code> from the selection bar. The
|
<p>Select the frames, then <code>Merge to panorama</code> from the selection bar. The
|
||||||
@@ -371,7 +391,17 @@ what the middle of the film shows, and why cylindrical is suggested), ask for
|
|||||||
the border to be filled rather than cropped, then <code>Merge</code>. The composite is
|
the border to be filled rather than cropped, then <code>Merge</code>. The composite is
|
||||||
written beside its sources as a DNG and appears in the grid with the merge
|
written beside its sources as a DNG and appears in the grid with the merge
|
||||||
as the first step in its history.</p>
|
as the first step in its history.</p>
|
||||||
<figure><img loading="lazy" src="media/panorama.gif" alt="Twelve frames aligned, the projections tried, and the border filled"><figcaption>Twelve frames aligned, the projections tried, and the border filled</figcaption></figure>
|
<p>Each frame has a box in the <code>Frames</code> list. Untick one to leave it out, and
|
||||||
|
the rest are aligned again at once, without reading the frames again; tick
|
||||||
|
it to bring it back. A frame that cannot be placed is named there with why,
|
||||||
|
and <code>Merge</code> stays off until it is unticked. Blown sky stays white in the
|
||||||
|
preview and in the composite.</p>
|
||||||
|
<p>A panorama is usually far wider than a graphics card can draw in one piece.
|
||||||
|
It opens in develop all the same, on a reduced copy that is drawn from the
|
||||||
|
full resolution wherever you zoom in, and it exports at full size. The same
|
||||||
|
goes for a panorama Lightroom stitched and saved as a DNG.</p>
|
||||||
|
<figure><img loading="lazy" src="media/giant-pano.gif" alt="A 22 927 × 8966 panorama of a glacier at dusk: zoomed from the whole frame into the peaks, panned along the ridge and back out, then 1:1 on the peaks with a double-click"><figcaption>A 22 927 × 8966 panorama of a glacier at dusk: zoomed from the whole frame into the peaks, panned along the ridge and back out, then 1:1 on the peaks with a double-click</figcaption></figure>
|
||||||
|
<figure><img loading="lazy" src="media/panorama.gif" alt="Twelve frames aligned, the first left out and brought back, the projections tried, and the border filled"><figcaption>Twelve frames aligned, the first left out and brought back, the projections tried, and the border filled</figcaption></figure>
|
||||||
<figure><img loading="lazy" src="media/panorama-aligned.png" alt="The alignment on a cylinder, each frame outlined where it landed"><figcaption>The alignment on a cylinder, each frame outlined where it landed</figcaption></figure>
|
<figure><img loading="lazy" src="media/panorama-aligned.png" alt="The alignment on a cylinder, each frame outlined where it landed"><figcaption>The alignment on a cylinder, each frame outlined where it landed</figcaption></figure>
|
||||||
<figure><img loading="lazy" src="media/panorama-filled.png" alt="The same, with the ragged border filled by the model rather than cropped away"><figcaption>The same, with the ragged border filled by the model rather than cropped away</figcaption></figure>
|
<figure><img loading="lazy" src="media/panorama-filled.png" alt="The same, with the ragged border filled by the model rather than cropped away"><figcaption>The same, with the ragged border filled by the model rather than cropped away</figcaption></figure>
|
||||||
<h2 id="export">Export</h2>
|
<h2 id="export">Export</h2>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user