Commit Graph
1188 Commits
Author SHA1 Message Date
dtourolle 555ec0efb3 Let ep_probe name the OpenVINO GPU
On the hybrid laptop OpenVINO's `GPU` was the RTX 3050 through NVIDIA's
OpenCL, not the Iris Xe; DARKROOM_OV_GPU picks GPU.0, GPU.1 and so on.
2026-10-04 21:00:15 -04:00
dtourolle 0291b80672 Feed every input in ep_probe
The denoiser takes `mosaic` and `sigma`; with only the first fed, every
provider reported the same failure and the model went unmeasured.
2026-10-04 21:00:15 -04:00
dtourolle ef71bb3289 Time OpenVINO and WebGPU in ep_probe
The Intel and vendor-neutral rungs need a measurement before they join
the ladder (docs/dev/inference.md §2). Both register through the generic
key/value entry point with the option names ONNX Runtime reads at the
wheel's version: OpenVINO 1.24 (`openvino_provider_factory.cc`), WebGPU
1.27 (`webgpu_provider_options.h`, prefixed by the runtime).
DARKROOM_EPS narrows the list to the families a runtime carries.
2026-10-04 21:00:15 -04:00
dtourolle 08b7d23e86 Release 0.22.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 8m35s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m22s
Build and test / Android (aarch64) (push) Successful in 46m43s
Build and test / android-image (push) Successful in 2s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 1h4m43s
Build and test / windows-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / Layer separation (push) Successful in 34s
Build and test / Windows (x86_64, cross) (push) Successful in 29m53s
Build and test / Publish the release (push) Successful in 1m24s
v0.22.1
2026-10-04 20:35:01 -04:00
dtourolle a116325991 Declare the DSP's RPC library, so the app can reach the Hexagon
QNN's Hexagon stub loads libcdsprpc.so, a vendor library, and from API 31
an app's linker namespace refuses a vendor library its manifest does not
name. QNN then fails to create its device - QNN_DEVICE_ERROR_INVALID_CONFIG,
before it reaches the DSP - and every model ran on the CPU on 0.22.0: AI
denoise took 30-131 s a photograph on the tablet.

The same engine code ran on the HTP from adb's shell, whose namespace has
no such rule, which is what hid it. With the declaration the app's probe
chose the Hexagon on the tablet and compiled every model for it. Not
required, so a device without the library still installs and runs on the
CPU. A test holds the line in the manifest.
2026-10-04 20:19:04 -04:00
dtourolle f20e481358 Probe the Hexagon strictly, and probe again after falling back to the CPU
0.22.0's first launch on the tablet: QNN could not create its device
(QNN_DEVICE_ERROR_INVALID_CONFIG), the session built anyway with every
node on the CPU behind the provider, and the probe timed that - 28.5 ms
against the CPU's own 19.4 - and rejected the Hexagon. The verdict was
cached under the fingerprint, so every model stayed on the CPU on every
later launch: AI denoise took 30-131 s a photograph instead of seconds.
The same A16W8 detector with the APK's own libraries runs on the HTP in
4.4 ms.

The probe's Hexagon session now sets session.disable_cpu_ep_fallback, so
a device that cannot take the graph fails the probe instead of being timed
as the CPU. Only the probe: shipped graphs may keep nodes on the CPU on
purpose. And a selection that fell back to the CPU after an accelerator
failed or lost is probed again on the next launches, up to three probes
per fingerprint; a cache written by 0.22.0 reads as never retried, so the
tablet probes again once this is installed.
2026-10-04 19:42:15 -04:00
dtourolle 16a5957aa7 Compile dr-ui on sixteen codegen units in release builds
Slint expands the .slint files into ~27 MB of Rust, and at the workspace's
single codegen unit LLVM optimised all of it on one thread: 13.5 minutes
of a release build with the other cores idle. The override applies to
dr-ui alone; the image crates keep one unit, and thin LTO still runs at
link time.
2026-10-04 10:45:43 -04:00
dtourolle 5736a21a3a Release 0.22.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 8m36s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m31s
Build and test / Android (aarch64) (push) Successful in 48m34s
Build and test / android-image (push) Successful in 4s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / Desktop (Linux) (push) Successful in 1h32m33s
Build and test / windows-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / Layer separation (push) Successful in 29s
Build and test / Windows (x86_64, cross) (push) Successful in 31m14s
Build and test / Publish the release (push) Successful in 1m9s
v0.22.0
2026-10-04 08:13:35 -04:00
dtourolle b6ca7be185 Show each denoise method in the manual as a close-up
The manual's AI denoise section names the four methods and their measured
times, and shows the lamp and railing of the ISO 8000 frame at 1:1 by each
in place of the film and the before/after pair. The scene clicks each
method and waits for that network's result: the repair now logs its own
"learned denoise:" line first, so the wait matches the result's.
2026-10-04 08:11:24 -04:00
dtourolle 06422a07db Offer three denoise networks and a method to choose between them
AI Denoise's Apply switch becomes Method: Bilinear, Fast, Medium, Best,
default Best, so an untouched raw writes nothing and develops through the
mixture. `apply` is still read and never written: 0 is Bilinear, 1 keeps
a network already chosen.

- Best is the mixture of a flat and an edge expert with a learned gate;
  Medium and Fast are students distilled from it. 2.48 s, 0.79 s and
  0.57 s for a 20 MP frame on TensorRT fp16.
- Each network carries its own tile border (256 for the mixture, 192 for
  the students) through `dr_denoise::Shipped` and `TileNet::halo`.
- The file is hashed once at open and each network keys its own cached
  result; Bilinear keeps the result in memory for the way back.
- Each has an .a16w16 sibling for the Hexagon: 0.00 dB on the 6D gate,
  at most 0.11 dB with the noise scaled x0.5 to x4.
- APK BUNDLED 19 -> 23; the PKGBUILD installs all three.
2026-10-04 08:02:25 -04:00
dtourolle 14f08a565f Feed the denoise network without making it wait for the CPU
A 20 MP frame spent 0.32 s outside the network: each tile's mosaic and
sigma gathered on one thread, then its 24 MB output copied out of the
runtime and back into the frame, all in series with the device. Tiles are
now gathered on every core by a producer thread one tile ahead, so the
gather overlaps the run; the centre is written back across cores; and the
tile interface hands its inputs over and lends its output, so neither
side is copied. With a stand-in network that does nothing, the tiler's own
time falls to 0.14 s at the 1408 tile and 0.09 s at 2048. The exactness
and Bayer-phase tests are unchanged and pass.
2026-10-04 07:30:09 -04:00
dtourolle 0c9d564586 Repair photosites beyond 8 sigma of every neighbour before the network
The app's hot-pixel pass takes gross defects only; at ISO 6400-25600 a 6D
frame keeps 1000-2000 photosites more than 8 sigma beyond all their
same-colour and adjacent neighbours, which the network turned into specks.
The same two tests with the threshold in the photosite's own sigma, plus
the factor of two that keeps a bright point of light (where 8 sigma is a
sliver of the signal). The next model is trained behind exactly this; on
an ISO 25600 frame the Rust and training code both repair 935.
2026-10-04 07:30:09 -04:00
dtourolle 75e12441fd Share Lightroom's saturation bands across ours at measured strengths
Photographs opened with an earlier Lightroom edit now import its HSL
saturation as fitted against the library's own Lightroom 6 exports, rather
than one band to one band.

Measured on two looks' exports and their raws (darkroom-lrfit, hsl_map_fit),
by encoded hue: Lightroom's saturation bands act about 45 degrees either
side on our wheel, wider than ours, and not all at our strength. Each is now
shared between two or three of our bands — Aqua mostly cyan and azure, where
skies are; Blue mostly blue and violet; Orange, where skin is, at about 0.4
of its value. Values add when two of Lightroom's bands share one of ours.
On the measured skies the import now lifts muted sky blues about 1.9× against
Lightroom's 2.1×, where it gave 1.15×. Hue and luminance still go one band to
the band of the same hue; they were not measured.
2026-10-04 05:28:33 -04:00
dtourolle 3f8f909e41 Make the colour mixer's saturation reach muted colours
Every photograph with a colour-mixer saturation edit now renders differently:
a raised band is stronger, most of all on muted colours.

The mixer matched bands and judged saturation on scene-linear values, and
scaled chroma by the same factor whatever a colour started at. Against the
photographer's earlier exports of two looks (~90 photographs, their raws, by
encoded hue), a sky band raised by 58 there lifted muted sky blues about
2.1×; here the mixer gave 1.15×, and less in the muted tones that carry most
of a sky or a shadowed snowfield.

Bands are now matched and saturation judged on display-encoded values. A
raised band pushes muted colours hardest and tapers to nothing at full
saturation, at a gain of 3.0, which at the same value lifts muted sky blues
about as those exports did. Lowering saturation still scales every colour
alike. Hue shifts work on the same encoded colour; luminance still scales in
linear light.

The bundled presets that use the mixer, and those whose colour was tuned
against the default rendering, are rescaled to the amount of colour they
had: Vivid 1.30, Vivid warm 1.30, Vivid landscape 1.38, Vivid, strong 1.45,
Vivid portrait 1.15, Punch 1.12, Blue sky 1.08, Deep blue sky 1.12, Polariser
1.26, Blue sky, golden land 1.13 — mean CIELAB chroma over the default
rendering, on 30 raws from the library. Negative values (skin protection)
are left as written.
2026-10-04 05:28:22 -04:00
dtourolle 5ffd54ba43 Leave the profile's look table off by default
Every raw rendered through a camera profile — the library's DNGs with an
embedded profile, and CR2s given one — now renders differently: more
colourful in near-neutral tones. The profile's look table is no longer
applied unless its slider is raised; PROFILE_LOOK names the strength the
profile states.

Against the photographer's earlier exports with no look applied, the default
rendering scores the same with the look table at 100, 50 or 0 (held-out MSE
140, 140, 143), and is 9 % more colourful at 0: the table lowers the
saturation of near-neutral tones, which is exactly where the default
rendering was short of those exports. The user chose more colour.
2026-10-04 05:28:15 -04:00
dtourolle 5a8c3e4c40 Run each model on the Hexagon in the form measured to hold it
The engine knew f32 and int8, and gave the Hexagon int8 for every role it
served. Measured on the tablet itself (inference.md §1.5), int8 lost
5% of the detector's faces at 40-80 px, moved the landmarks 1.5 px,
emptied the segmenter's scores and cost the denoiser 5-9 dB; fp16 the HTP
refuses outright. `Form` gains A16W8 and A16W16, and `Rung::form` now
names one per role: detectors and landmarks A16W8, the segmenter, scene
model, border filler and denoiser A16W16, XFeat int8. The embedder and
the eye classifiers stay on the CPU.

Each loader resolves its `<stem>.<form>.onnx` sibling; the segmenter and
XFeat, compiled into the binary, embed their quantised forms on Android
only and pick through `choose_embedded`. The probe, the compile step and
the cache fingerprint follow the form instead of assuming int8. Detectors
on the new form write `scrfd_*_a16+w600k_mbf`, and `model_ids` answers
for all three spellings.

On the tablet (ORT 1.29 + QNN 2.42), each shipped file against f32 on the
same inputs, and against the CPU's f32 time:
  SCRFD 500m/2.5g/10g  A16W8   100% of faces in every band   4.2/5.1/9.0 ms vs 17/56/198
  landmarks            A16W8   0.25 px in the 192 crop        0.5 ms vs 2.8
  YOLO26n-seg          A16W16  98.2% found, mask IoU 0.994    12.9 ms vs 90
  scene model          A16W16  98.9% of cells agree           15 ms vs 151
  MI-GAN               A16W16  41 dB from f32 in the fill     87 ms vs 488
  XFeat                int8    pano alignment 0.45 px (f32's own spread 0.41)  6.5 ms vs 58
  denoiser             A16W16  0.00 dB at every ISO            95 ms vs 1510 a tile
Face numbers are over public COCO val2017 photographs, not a library.

The APK carries the siblings (BUNDLED 15 -> 19; the old int8 detectors
removed), about 43 MB more. The Windows installer and its CI count skip
them; the Arch and Flatpak packages list their files and never had them.
The ladder example takes a role per model, which is how the per-role
forms above were seen landing on the NPU from the real probe.
2026-10-04 03:45:46 -04:00
dtourolle 0e6ac09fd5 Quantise for the Hexagon with QNN's config and the app's own inputs
tools/quantise-models.sh now writes each model's Hexagon form from a
per-model table: the form its role takes on the NPU (int8, A16W8 or
A16W16), the exact graph rewrites it needs, and the nodes that must stay
float. Ranges are min/max over photographs fed exactly as the app feeds
each model -- the detector and segmenter letterboxes with their own pads
and normalisation, landmark crops from the detector's boxes, MI-GAN with a
panorama-like border, XFeat's grey proxy. The old tool used an
antialiased resize, YOLO's pad of 128 and /255 for every model that was
not a face model, none of which is what the app does.

tools/htp_graph.py holds the rewrites, each checked against the input
graph before use: the denoiser's 6-D Bayer pack and XFeat's 224-slice
unfold as SpaceToDepth (QNN stops at rank 5), computed reshape targets
folded, and bilinear Resize as two MatMuls (the HTP refuses
ResizeBilinear at XFeat's sizes). The denoiser takes ranges computed by
darkroom-denoise's gate on a smaller tile of the same network.
2026-10-04 03:45:07 -04:00
dtourolle 948f6c3ed2 Count the denoise model in the Windows installer's smoke test
package.sh stages models/denoise beside face, scene and inpaint, and
the smoke test counted only the other three, so 0.21.0's Windows job
failed with "expected 14 model files, installed 15". The count reads
the same directories package.sh copies, as its comment intends.
2026-10-04 02:50:15 -04:00
dtourolle 8f9e59b9fa Find hot photosites without repairing them, and measure a sensor's aging
The hot-pixel pass could only repair: it returned how many photosites it
changed and threw away which. find_hot_pixels runs the same pass and
returns them as sensor coordinates, leaving the frame alone, so a sensor's
defects can be tracked across frames.

sensor_scan prints each frame's candidates, and with --probe reads a list
of coordinates back out of every frame. Run over 53 6D raws from 2015 to
2026, it found 32 persistent defects, 2 in 2015 and 32 by 2026, and showed
what a defect map has to account for: a frame that does not flag a
photosite proves nothing unless its neighbourhood is dark, and the 6D
hides some of its defects itself above ISO 5000. docs/dev/sensor-health.md
records the findings and the design they argue for.
2026-10-04 02:38:48 -04:00
dtourolle 83f0461ce7 Sync develop presets through the library
Presets were the one piece of the photographer's work that never left
the device: faces, sidecars, albums, collections, keywords and camera
profiles all travel with the sync pass, the preset library did not.

It now goes to <derived>/presets/library.drpl. PresetLibrary::merge
decides each name against the base the last exchange left (kept per
library beside place.json), so presets added on two devices both
survive, a deletion reaches the other device instead of being restored
by it, and an edit outlives a deletion made elsewhere. The upload is
If-Match / If-None-Match on the server's copy, and a 412 reads and
merges again, so two devices exchanging at once cannot save over each
other. A server copy that will not parse (a newer build's) is left
alone, and a local file that will not read stops the exchange rather
than being taken for an empty library.

The develop view's save merges with the file when the sync changed it
since the view read it, and a sync that brought presets reloads and
redraws the list.

Also corrects the register, which still said camera profiles do not
sync.
2026-10-04 00:50:09 -04:00
dtourolle 26e50ae723 Save presets beside the settings, not under a raw HOME
PresetStore::open built its path from XDG_CONFIG_HOME or HOME. Android
sets neither, so the library resolved to /.config/darkroom, which is
read-only, and every preset saved on the tablet failed. Windows sets no
HOME either and got a directory relative to the working directory. The
settings store was moved to dr_sync::account::config_dir for the same
reason in 0.12.1; the presets now follow it. Linux and macOS resolve to
the same file as before.
2026-10-04 00:49:59 -04:00
dtourolle 25dc0d0179 Give the Vivid presets and Punch measured amounts of colour
On the default rendering, Vivid added 22 % more chroma than the rendering
itself, and Punch 5 % — less than the photographer's earlier exports show
with no look applied (14 % over ours) and well below their everyday look
(27 %). Each preset's colour values (vibrance, saturation, the mixer's
saturation bands) are now scaled together, tone values untouched and
negative ones — Vivid portrait's skin protection — left as written, until
the preset measures: Vivid and Vivid warm 1.30, Vivid landscape 1.38, Vivid,
strong 1.45, Vivid portrait 1.15, Punch 1.12. Measured as mean CIELAB chroma
over 30 raws from the library, as a ratio to the default rendering.
2026-10-03 23:04:46 -04:00
dtourolle a36ec98b36 Carry Lightroom's tone sliders across at measured strengths
Contrast2012 and the four recovery sliders were imported one to one. They do
not mean the same thing here: fitted on the library's Lightroom 6 exports and
their raws — each photograph's sliders carried across as slider × factor, one
factor per slider, on about 90 exports with no look applied, on the Camera Raw
default rendering — ours needed contrast at about a tenth (Lightroom's −100
imported as ours flattens a frame to grey), highlights ×1.4, shadows ×1.9 and
blacks ×1.25. Whites fitted below 1 every time without agreeing where; 0.5 is
a hedge, and says so. Vibrance stays one to one: the op itself is now
calibrated to Lightroom's.
2026-10-03 23:04:46 -04:00
dtourolle c343ac79d3 Describe AI Denoise in the manual as it now is
On for every raw, at the top of the Adjust panel, kept once computed,
and eased off with Strength rather than Keep grain. The timing line is
left as it was; the new model's measured figure replaces it when that
branch lands. The animation still shows the Keep grain slider and
wants recording again.
2026-10-03 22:18:15 -04:00
dtourolle 2e7f14dafe Develop every raw through the AI denoise by default, with a strength, cached
The learned demosaic was an option under Detail, off by default. It is
now how a Bayer raw is developed: on by default at full strength on
every device — which hardware runs it is the inference engine's choice
— and first in the Adjust panel, since it decides what every control
below is applied to.

Strength (0-100, default 100) replaces Keep grain: grain = 100 -
strength, the same luminance-only blend, so moving it is one GPU pass
and never a re-run. 0.21.0's sidecars stored grain; it is still read,
as the inverse, and never written.

With it on for every photograph, the result is now kept on disk
(denoise.md §7.1, §12): the network's output as half floats, keyed on
a SHA-256 of the file's bytes and the model, oldest first past a 5 GB
budget, beside the inference engine's cache. A reopened photograph and
an export of one already developed read it back instead of running the
network again; a damaged entry is a miss.
2026-10-03 22:16:36 -04:00
dtourolle ff0effbfe1 Count the denoise model in the APK's bundled-model list
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m17s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 56s
Build and test / Android (aarch64) (push) Successful in 47m9s
Build and test / android-image (push) Successful in 3s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 1h2m3s
Build and test / windows-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 30s
Build and test / Windows (x86_64, cross) (push) Failing after 49m14s
Build and test / Publish the release (push) Skipped
96001480 added mosaic-1408.onnx to BUNDLED as a fifteenth entry and
left the array's declared length at 14, so the Android build failed
and 0.21.0 got no release. The workspace gates never compile the
Android crate, which is why nothing before CI saw it.
2026-10-03 22:14:26 -04:00
dtourolle 1a03cb52b4 Release 0.21.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 8m38s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m27s
Build and test / Android (aarch64) (push) Failing after 29m52s
Build and test / android-image (push) Successful in 2s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 1h2m28s
Build and test / windows-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / Layer separation (push) Successful in 30s
Build and test / Windows (x86_64, cross) (push) Failing after 48m48s
Build and test / Publish the release (push) Skipped
2026-10-03 17:06:49 -04:00
dtourolle ff4b30fbaa Link the inference engine for macOS in a zig container
`docker/macos` builds for aarch64-apple-darwin from Linux with
cargo-zigbuild. Zig carries libSystem and the C headers, so tract's SIMD
kernels compile and the engine's test binaries and examples link as Mach-O
arm64 — the check `cargo check --target` could not do, because tract's
build script needs a macOS C compiler. Crates that link an Apple framework
(dr-plat's keyring, and so the app) still need the Xcode SDK and fail at
the link; macos.md says so.
2026-10-03 16:50:37 -04:00
dtourolle c73743394f Add a CoreML rung on macOS
The macOS ladder was the CPU provider alone, with CoreML listed as a gap.
It is now CoreML, then the CPU, then tract — unmeasured, since nobody here
has a Mac, and safe to ship unmeasured because the probe's clock rejects a
CoreML slower than the CPU and `attempt` refuses one that crashes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ProfileTables gains the profile's ProfileToneCurve, resampled at
decode onto 1025 points with a natural cubic spline; an identity curve
counts as none. dr-types now holds Camera Raw's ACR3 default curve,
RawTherapee's adobe_camera_raw_default_curve copied value for value,
for every raw whose profile has no curve. Nothing renders through
either yet.
2026-10-03 14:22:46 -04:00
dtourolle b58873ef57 Spec baseline exposure, Camera Raw tone and profile sync (D21)
camera-profiles.md §11-§14 close what 0.20.0 left open. Baseline
exposure is the file's plus the profile's offset, applied as a gain on
the camera matrix, and a copied profile carries the DNG's baseline so a
CR2 lands at the same brightness. The view transform gains a Camera Raw
curve — the profile's ProfileToneCurve, else the ACR3 default — applied
Camera Raw's way, on the outer channels in linear ProPhoto, and it is
the default for every raw (D21, the user's choice). Profiles sync
through .darkroom-derived/profiles on the server as a step of the
derived sync pass, not as a catalog table.
2026-10-03 14:22:45 -04:00
dtourolle ababd628ed Show AI denoise in the manual, on a night frame with no faces
A section after Looking closer: what it is for, the switch and its wait,
Keep grain, which cameras it takes and where its noise figures come from.
The scene opens the Brooklyn Bridge at ISO 8000 from the face-free demo
set at 1:1, switches it on, waits for the result to land and keeps some
grain, with a still before and after. It waits on the app's own log line
rather than a fixed time: the network takes seconds on a GPU and more on
the CPU, which is where the recording X server leaves it (13 s).
2026-10-03 12:05:26 -04:00
dtourolle 4eb7cf77f5 Record what the learned denoise shipped as, and what was measured
The grain blend replaces the Amount of §7.2, and why its objection to a
blend does not hold for brightness alone; the Hexagon is out (int8 -6 to
-9 dB); §11 holds the data, the noise model taken from the library, the
model, the validation table, the blind estimate's reach and the speed.
2026-10-03 11:51:02 -04:00