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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The thumbnail now stays up until render_now draws the new photograph's
first frame, and that first frame waits up to 400 ms for the stored edit
before drawing at the defaults. A later arrival still redraws.
v0.19.4
2026-10-02 20:46:05 -04:00
dtourolle 379dd1afcc Keep a late sidecar off the next photograph
A stored edit that arrived after the view had stepped on was applied to
whatever session was open by then — the next photograph's. The wait now
stops once the open it belongs to is no longer the current one.
2026-10-02 20:44:43 -04:00
dtourolle 825c5af20a Release 0.19.4
Benchmarks / Frame budget (on demand) (push) Canceled after 0s
Benchmarks / CPU and I/O (per commit) (push) Canceled after 5m49s
Traceability / Requirement traces (push) Canceled after 0s
Build and test / android-image (push) Canceled after 0s
🐳 Android image / Build and push (push) Canceled after 0s
Build and test / Android (aarch64) (push) Canceled after 0s
Build and test / windows-image (push) Canceled after 0s
🐳 Windows image / Build and push (push) Canceled after 0s
Build and test / Windows (x86_64, cross) (push) Canceled after 0s
Build and test / Layer separation (push) Canceled after 0s
Build and test / Publish the release (push) Canceled after 0s
Build and test / Desktop (Linux) (push) Canceled after 17s
2026-10-02 20:40:01 -04:00