Commit Graph
16 Commits
Author SHA1 Message Date
Jeremy Hooke bb414d220a LR-EDIT-LIGHT-SHADOWS: fix Highlights/Shadows block artefacts in fine detail; 4× faster GPU median
Fixes #3460
Related: #2936 (slow GPU kernel compiles on Windows/DX12). This PR builds the GPU kernels without wgpu's workgroup-memory zeroing, which took a DX12 release start from ~87 s to 1.4 s on a GTX 1070 with the new median kernels; it may shorten the compiles there too, but does not close it.

## Problem

Raising Shadows on a photo with dark lettering on a bright panel makes bright blocks grow beside the
letters. On a Canon CR3 of a bus (6960×4640) with a black fleet number on a yellow panel, Shadows +75
puts bright yellow rectangles in the gaps where the digits pinch together and inside the 4's counter.
At Shadows +5 there is none of this; at +75 it shows at any zoom. Narrow gaps between dark objects
generally can do it: the pinned corpus has the same thing in the gap between two posts under a machine
(Hasselblad, `coverage-6568.fff`).

## Cause

From process V2 the Highlights/Shadows base (`local::tone_base`) is the edge-aware filter of a
separable median of log luminance, at a radius of 0.15 % of the long edge (10 px here), so that thin
detail (twigs, lines, #632) takes its surroundings' shift. Text strokes and gaps are about that wide,
so each row and column window is close to half letter and half panel, and the median picks one tone
or the other in rectangles: it fills the gaps between the digits and the 4's counter with the letters'
tone. The guided filter keeps those blocks, so panel pixels whose base reads as deep shadow get
Shadows' full lift (`2^(1.7·s·w^1.5)`, up to ~1.3 EV at +75).

## Examples

Top: before, bottom: after.

The fleet number on the bus photo, Whites +54, Blacks +31, Shadows +75, at 300 %:

<img width="1020" height="976" alt="example_photo_pair" src="https://github.com/user-attachments/assets/cc5b164f-612d-4d7d-bda3-db6afd8b177f" />


The gap between two posts in the corpus' Hasselblad `coverage-6568.fff` (CC0, raw.pixls.us),
Shadows +100, at 300 %:

<!-- image: example_corpus_pair.png -->
<img width="900" height="1216" alt="example_corpus_pair" src="https://github.com/user-attachments/assets/feb4b240-194e-40b1-b5b9-c4419f840df6" />

Synthetic lettering at the scale of a bus number in a 7000 px photo, Shadows +75, at 200 %:

<!-- image: example_pair.png -->
<img width="880" height="820" alt="example_pair" src="https://github.com/user-attachments/assets/bd02e600-dda0-4648-85e9-f89ce046eb08" />

## Change

`local::detail_free` (CPU) and its GPU twin: each value of log luminance is clamped between its
separable median at r (as before) and the median of a 3× wider neighbourhood (the median at r of a
box-downscaled copy, enlarged bilinearly, like `guided_fast`'s coefficients). The base is the guided
filter of that plane.

- A twig or speck in an even surround has both medians on the surround's side and takes the nearer,
  as under the median alone (#632 unchanged).
- The gap between two strokes sits in a wider neighbourhood that is mostly panel: the medians
  disagree and the gap keeps its own tone. The strokes themselves count as fine dark detail on the
  panel (both medians agree) and keep their contrast, as twigs do.
- Steps between regions stay exact (the fine median keeps them).
- GPU: the coarse median reuses the box/bilinear resize, plus one `clamp_between_k` map kernel.
  CPU and GPU match (max 1 level).
- GPU median kernels rewritten (`median_h` / `median_v`): each workgroup loads its taps into
  workgroup memory and selects by rank, 4 neighbouring outputs per thread. The old partial selection
  sort over a private 49-tap array sat in scratch memory. Same selection, so bit for bit the CPU's
  (`median_matches_cpu_bit_for_bit`, every r 1–24); renders are byte-identical to the old kernels.
  On 6960×4640 at r = 10 (M2 Max) the median takes 191 → 47 ms.
- GPU kernels are built without wgpu's zeroing of workgroup memory (as `nn.rs` and `faces.rs` already
  are): on DX12 it made FXC take ~87 s over the new median kernels at every start of a release build
  (GTX 1070, 1.4 s without it). Every element of `med_tile` the kernels read is loaded before the barrier;
  renders are byte-identical.
- Cost on the 6960×4640 bus CR3 with Whites +54, Blacks +31, Shadows +75 (M2 Max): GPU planes
  231 ms on `main` → 113 ms here (257 ms with this PR's first kernels); CPU base ~388 → ~453 ms.
  The CPU path enlarges the coarse median while it clamps, into the fine median in place
  (`detail_free_matches_its_reference`, bit for bit).
- New process V8 (`Process::LATEST`). Existing edits keep their process and render as before, so `RENDER_CACHE_VERSION` is unchanged (it is 76 on `main`).
- Docs: `process-versions.md` (V8 row, a paragraph on the lettering fix, revision history), `parity-checklist.md`
  (LR-EDIT-LIGHT-HIGHLIGHTS / -SHADOWS notes).

An earlier revision of this PR clamped each value to the quartiles of its window instead. It fixed
the lettering but stopped treating isolated lines between r/2 and r wide as detail (twigs of 3–6 px
against a dark wall were lifted less than the wall, as under V1), which the corpus run below caught.
A fixed window can't tell an isolated line from a gap between strokes; a wider neighbourhood can.

## Process version

The new base applies from a new process, **V8** (V7 plus `local::detail_free`; `local::base_keeps_gaps` is true from V8, CPU and GPU). v0.19.0 shipped V3 to V6, and crystal grain (#3630) took V7 on `main`, so V1 to V7 render exactly as before: `tests_legacy` still locks V1 and V2, and `only_process_v8_on_takes_the_detail_free_base` checks that every earlier process keeps the plain median. New photos get V8; photos already edited keep the blocks until Update to Current Process.

The faster median kernels and the workgroup-memory change don't change pixels, so they apply to every process. They stay in this PR: the zeroing only costs time on the new median kernels (on `main` the kernels compile in 0.8 s on the GTX 1070), so on its own it would speed up nothing.

## How it was checked

- [x] New test `gaps_between_letters_keep_their_own_tone_in_the_base`: a row of synthetic "8"s
  sized like the bus number. With the median, 360 panel pixels sit more than 1 EV above their base
  (worst 2.25 EV); now none do (worst 0.8 EV). It fails with the median alone.
- [x] New test `isolated_lines_up_to_the_detail_radius_stay_out_of_the_base`: light lines 1–9 px wide
  on a dark ground (r = 10) stay out of the base, as under the median. It fails with the quartile
  clamp (a 6 px line 0.31 of the way into the base).
- [x] New test `detail_free_keeps_steps_and_regions`: steps and flat fields pass through unchanged;
  tiny, empty and NaN planes don't panic.
- [x] The #632 tests (twig/stalk contrast, local contrast, no halo at a strong edge, V1 unchanged)
  and the median reference tests pass unchanged.
- [x] `lightcraft-gpu` integration tests on a real adapter, including
  `highlights_shadows_base_matches_on_every_process` (max difference 1 level).
- [x] Corpus A/B: all 451 pinned raws (`cargo xtask corpus --download`) rendered at full size before
  and after, with Shadows +100 and with Highlights −100 (902 pairs, GPU). No failures. RMSE median
  0.18 % (Shadows) / 0.10 % (Highlights), max 0.86 %; pixels changed by more than 3 % at most 0.07 %
  of a photo; worst 8×8-block change median 10 / 7 levels, 99th percentile 44 / 41, max 61. I looked
  at the 22 largest local changes (old over new, at the worst block): no new artefacts. Most are a
  slightly different tone in small gaps among foliage or branches; one is the Hasselblad gap above.
- [x] User-visible change: rendered the bus photo at full size and at 2400 px with Whites +54,
  Blacks +31 and Shadows +5 / +75, and looked at the number, the plate, foliage against the sky and a
  brick lattice fence.
- [x] New tests `median_matches_cpu_bit_for_bit` (GPU, M2 Max) and `detail_free_matches_its_reference` (CPU).
- [x] After merging `main` and moving the base to V7 (Linux, Ryzen 5 7545U, RADV): `cargo test --release` of `lightcraft-develop`, `-pipeline` (including `tests_legacy`), `-catalog`, `-engine`, and `-gpu` on the real adapter (skipping `running_out_of_device_memory_falls_back_without_stopping_the_gpu`, which fills RAM on an APU; see above); `cargo fmt`, `cargo clippy --workspace --all-targets -D warnings`, `cargo xtask layers`, `roots`, `assets`, `parity` and `wasm`. Not run: the full `cargo xtask ci`, and the macOS-only `tests_light::v1_and_v2_render_as_before_v3` (V1 and V2 don't change, so it shouldn't need re-capturing).
- [x] After merging `main` again (with crystal grain as V7) and moving the base to V8 (Linux, Ryzen 5 7545U, RADV): `cargo test --release` of `lightcraft-develop`, `-pipeline` (including `tests_legacy` and the grain tests), `-catalog`, `-engine`, and `-gpu` on the real adapter (skipping `running_out_of_device_memory_falls_back_without_stopping_the_gpu`); `cargo fmt`, `cargo clippy --workspace --all-targets -D warnings`, `cargo xtask layers`, `roots`, `assets` and `parity`. Not run: `wasm` and the full `cargo xtask ci`.
- [x] `cargo xtask ci --quick` (with `CRAFT_FONTS_DIR`): all steps pass. After the median change: all steps pass again (without `CRAFT_FONTS_DIR`), and the CR3 renders
  byte-identical to the first kernels on GPU and CPU.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Highlights and Shadows adjustments better preserve clean gaps in fine details such as lettering while maintaining the existing treatment of broader image features.
  * Tone adjustments are less likely to include isolated, narrow lines in the base used for processing.
  * The updated detail handling helps retain fine structure while reducing unwanted influence from closely packed details.
* **Documentation**
  * Updated process notes to explain how fine-detail handling affects Highlights and Shadows adjustments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 07:31:45 +01:00
Jeremy HookeandClaude Opus 5.5 ed10d4486c Merge main into crystal-grain; the crystal grain moves to process V7
v0.19.0 shipped V4 to V6 with value-noise grain, so those stay frozen and
the crystal grain becomes a process of its own. V7 is V6 plus the grain:
without grain it renders as V6 (`without_grain_v7_renders_as_v6`), and the
GPU equivalence cases now cover V6's value noise.

Merged with main's float output (`deep`, `hdr`): the value noise keeps its
clamped luminance; the crystal grain already clamps, and fades out at and
above SDR white. The finish kernel's failed-render check stays ahead of
the aux upload.

The test of unknown process numbers now takes one past the latest: 7 was
hard-coded, and is a known process now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:53:18 +11:00
8f638b33bd Treat a first intersect mask component as its base selection (#3854)
* Treat a first intersect mask component as its base selection

A mask created with Intersect as its first component currently renders as empty, so the overlay and local adjustments never appear. Using that component as the base matches how Add and Subtract first components already behave.

Change evaluate_one so the first component always sets the base alpha regardless of its combine op. Add a regression test with a single Intersect linear-gradient component asserting the start side is selected.

Test: intersect_first_component_selects_its_shape failed before the change and passes after.

* Masks: the GPU treats a first Intersect as the base too, and the process doc says so

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: what changes for masks that start with Intersect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:46:11 +01:00
3943279105 Colour noise reduction: joint linear chroma filter with speck brake, CPU and GPU (#815) (#1348)
* WIP: colour NR v2 prototype (env LIGHTCRAFT_NR_V2, CPU only)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 on by default (LIGHTCRAFT_NR_V2=0 turns it off), GPU renders read back for it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: cache renders with the new colour NR apart from older ones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 on the GPU (fused down / apply kernels), coefficient grid sigma / 2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 on the CPU without full-size intermediates (row-streamed passes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: report peak device-buffer use per GPU stage with LIGHTCRAFT_PROFILE

* WIP: colour NR v2 linear mode (NRV2_LINEAR=1: c - Y, additive, noise model), Jensen term, mean-drift probe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 noise model with equal-count luminance bands and a non-negative fit; probe asserts the mean over 3 noise realisations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 linear mode by default (per channel, K 64, coarse pass smooths only the coarse part); CPU on GPU renders; real-colour probe

Known: tests_window::noise_reduction_does_not_depend_on_the_window fails (the noise is measured in the
rendered window; the square-root mode failed it too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 joint guide (NRV2_JOINT=1, opt-in), luminance guide knobs, leaf-next-to-black probe, camera-look guard from 858

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: colour NR v2 reads its env knobs once (per-pixel env reads made the linear mode 11x slower); joint stage timings with LIGHTCRAFT_PROFILE

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: joint colour NR without redundant passes (fused paths + next weights, none after the last scale, delta-1 weights from the path), two-component buffers, parallel reconstruction

Bit-identical to 8b89526d (test joint_matches_the_reference_bit_for_bit against a test-only copy).
Cat 32.7 MP, CPU: 6.2 -> 5.0 s at Color 25, 7.0 -> 5.6 s at 100; working buffers 2.36 -> 1.44 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: joint colour NR with separate H/V slice loops and zero-difference shortcuts (no division, weight 1 without exp)

Still bit-identical to 8b89526d. Interleaved 24 MP bench: 2.72 s vs 4.52 s for the reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: joint colour NR on the GPU (nrcj.wgsl), default on; whole-image noise model from samples, halo bands above 2 GB

Kernels mirror nrc_joint step for step (same scale plan, noise model fitted on the CPU from GPU samples).
Cat 32.7 MP on a Quadro M2200: wb/nr 1.2-1.3 s vs 4-5 s CPU; against the CPU max 1 LSB; bands = single.
Waits after dropping large buffers (freed memory otherwise piled up to the whole 4 GB). Cache key includes joint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: GPU joint NR plans its bands from a budget that includes the output buffer and the device buffer limit; halo 2*sum(delta) + max delta + 8; a band that cannot fit runs only this stage on the CPU

Bands vs one piece: at most 1 LSB in a few dozen pixels (running-sum boxes round differently per band start).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: GPU joint NR budgets its bands from the free device memory where it can be asked without unsafe (nvidia-smi, amdgpu sysfs), cached 3 s; fixed 2 GB otherwise

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: docs/nr-color-v2-history.md: every approach tried, measured and dropped, speed numbers, known limits (no outlier brake yet)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: history doc: speck behaviour stated qualitatively until measured on full-size renders

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: joint colour NR speck brake: compact colour specks (chroma far past the noise, small luminance step, shift not reaching the 5x5 border) lose their colour before the guide is built; weak at Color 25, full at 100; CPU and GPU

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: joint NR CPU 5.2 -> 3.5 s (33 MP), bit for bit: guide as planes with branch-free distance rows, exponentiated paths, pilot straight into planes, speck brake in place with a min/max pre-test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: joint NR GPU: path and weight pairs instead of quads (48 instead of 60 bytes per pixel, 33 MP in one piece), speck and pilot kernels without loop-indexed private arrays, noise fit cached for identical samples; Quadro M2200 2.15 -> 1.47 s, HD 630 5.66 -> 4.26 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: speck brake: compactness from the second largest border share (a line or edge crosses the 5x5 border twice; one noisy border pixel no longer protects a speck)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #815: colour noise reduction as process V3

The joint method (linear R−Y / B−Y, edge-aware guide, speck brake) is process V3 and the latest; edits on V1 and V2 keep main's blurred chromaticity. The prototype switches (LIGHTCRAFT_NR_V2, NRV2_*) and the square-root and per-channel variants they selected are gone, with their GPU kernels; nrc_params uses the former defaults as constants, so V3 renders as the prototype did. The GPU falls back to the CPU joint filter when no band fits. vram is native only (wasm build). Docs: process-versions, nr-color-joint, parity checklist, what's new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #815: process routing test initialises its settings in place (clippy field_reassign_with_default)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #815: one GPU band plan at a time, with a reserve for every render on the device

An export batch renders two photos side by side (`export_parallelism`). Each joint colour NR planned
its bands from the device memory free at that moment and counted the shared buffer pool as its own,
so two plans made at once could together ask for more than there is. Measured on a Quadro M2200
(4 GB): two 33 MP exports side by side peaked at 3761 of 4096 MiB.

The plan and its bands now run under one lock (`BANDS`), and the budget leaves `VRAM_RESERVE` for
each render on the device (`RENDERS`), not just for this one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #815: the joint colour NR's noise fit and pilot no longer switch at a hard bound

With main's preview-scaled luminance NR (#960) the CPU and GPU renders of a 640-px preview differed
by up to 10 levels (gpu equivalence `output_spaces_match`). Their images after luminance NR differ by
9e-7 rms, but one luminance band of the noise fit sat at exactly 4 times the first fit: the CPU's
samples dropped it, the GPU's kept it, and the fitted noise came out 2.6 % apart. The pilot's axis
choice then switched at a few pixels, and the edge-aware weights carried that on.

- robust_line: a band's weight fades to 0 between 4 and 5 times the fit instead of dropping at 4
  (identical below 4).
- pilot_pick: the smoother-axis mean fades in between 0.8 and 1.25 times its bound instead of
  switching at it (identical outside), on the CPU, in nrcj.wgsl and in the test reference.

All 19 CPU/GPU comparisons of the equivalence suite are within 1 level again. The new test
noise_line_does_not_jump_when_a_band_crosses_the_outlier_bound uses the bands of that preview and
fails on the old fit (5.6 % jump).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #815: the joint colour NR measures the whole frame's noise and is keyed by process

A window measured the noise model on the pixels it held, so a loupe window
could smooth differently from the same pixels of the full render or the
export. The model now comes from fixed tiles of the whole frame
(`lightcraft_pipeline::colour_noise`, `nrc_joint::tiles`), the same for every
window, for the CPU and the GPU, and is kept in the stage cache.

The linear stage's cache key now includes the colour filter in use, so a
process change (Update to Current Process, undo) no longer reuses the other
filter's denoised image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M-NR: deep windows match the whole render; the VRAM query no longer stalls renders

- A U16 / F32 window with the joint colour NR is grown by the filter's reach and
  cut back, like an 8-bit one (DeepImage::crop).
- The free device memory is refreshed in the background once the cached answer is
  stale; only the first question for an adapter waits. The joint NR asks before it
  takes the BANDS lock.
- nvidia-smi starts without a console window on Windows (CREATE_NO_WINDOW).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M-NR: the VRAM query finds the right card and keeps one answer per adapter

- A card is found by its PCI address (wgpu device_pci_bus_id, nvidia-smi pci.bus_id,
  the amdgpu sysfs directory) where both sides report one; else by a device id that
  only one card has. Two cards of one model without an address give no answer, and
  the fixed band budget applies.
- Each adapter keeps its own cached answer, so a late background refresh cannot
  replace another adapter's answer. A refresh not done after 10 s is retried.
- The cache tests wait on signals instead of fixed sleeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M-NR: the VRAM cache tests no longer depend on timing

The two cache tests used a 20 ms TTL: on a slow machine a refreshed answer could go
stale again between assertions and start an extra query. They now use a 30 s TTL and
age an answer explicitly (Cache::expire, test only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* GPU: allow joint_band's eight arguments, as color_nr_joint does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-06 09:44:58 +01:00
Alex ChambersandClaude Opus 5.5 baf079f659 LR-BEHAV-RENDER-FIDELITY: process V5 rolls raw tone curves off into white in stops, so raising Exposure keeps highlights (#1632) (#3783)
* LR-BEHAV-RENDER-FIDELITY: V3 camera tone curve rolls off in stops past its last knot (#1632)

A raw's camera tone curve is fitted only to camera JPEG tones below 0.85, so its last knot sits
1 to 3 stops below the sensor's white. Past it, CameraTone::apply closes on white exponentially in
linear light: the sensor's white renders at 8-bit 246-254 at Exposure 0 and 254-255 from +1, and
raising Exposure turns the top stop the sensor recorded white.

Process V3 extends the curve with CameraTone::apply_rolloff: same value and slope at the knot,
then gap / (1 + c (2^t - 1)) at t stops past it, the gap halving every stop in the end. On 11 CC0
corpus files with a fitted look, unclipped pixels rendering white drop from up to 22.5% to 0 at
+1 EV and from up to 35% to 0 at +2 EV; Exposure 0 renders change by at most 1 code on average.
V1 and V2, the fit itself, JPEGs and raws without a fitted look are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: V3 generic raw curve rolls off in stops too; DRO-off, DNG and GPU checks (#1632)

Raws without a camera curve take ToneMap::hdr, which reached white about 1/4 stop past the
sensor's white and clipped there (o.min(1.0)). Process V3 now uses ToneMap::rolloff_hdr: from half
a stop below the white point (tone::GENERIC_KNEE_STOPS) the extended Reinhard curve hands over to
the same stop-based shoulder as the camera curve (tone::rolloff_past, shared with
CameraTone::apply_rolloff), with the same value and slope at the join. The sensor's white renders
at about 235 / 248 / 252 at Exposure 0 / +1 / +2 where V1 and V2 render 239 / 255 / 255. On 6 CC0
DNGs on the generic curve, unclipped pixels rendering white drop from up to 8.4% to 0 at +1 EV and
from up to 17.2% to 0 at +2 EV.

The DRO-off gate (look_fit::at_most) also checks the V3 shoulder; the rate bound already implies it,
so no fit changes and LOOK_VERSION stays. Tests: the generic shoulder (join, slope, monotone, below
white, no kink), a regression test that fails with the clipping curve, a DNG ProfileToneCurve under
V3, the DRO-off property tests on the V3 shoulder, and a per-process camera-curve GPU check.
Docs: process-versions, parity checklist (LR-BEHAV-RENDER-FIDELITY, LRC-DEV-PROCESS), gap 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: what's new: process 3 keeps raw highlights when Exposure is raised (#1632)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: a flat last camera-curve segment joins the V3 shoulder with its own slope (#1632)

The V3 camera shoulder took the last segment's slope with a floor of 0.1, so a flat or shallow
segment (valid for a fitted, DNG or saved curve) joined with a kink: slope 0 on the curve, about
0.1 just past it. The shoulder now keeps the segment's own slope at the knot. Below 0.1 a quadratic
term in 2^t - 1 joins the denominator: it adds no slope at the knot and makes up the 0.1 pace
further up, so a flat curve still closes on white. Segments at 0.1 or steeper are byte-identical,
and the generic curve passes no floor. Comments now say the shoulder approaches white in real
numbers and can round to it in f32. Found in an independent review of the branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: V4's GPU test pins V4; the corpus DRO photos keep their knots under the V5 bound (#1632)

The GPU light-operator test assumed the default process is V4; it now sets V4, since V5 is the
default. The corpus DRO test checks that dro_off's V5 bound leaves the second-last knot where
the rate bound puts it on the Sony DRO samples, so their V1 to V4 renders don't change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: the DRO-off curve is main's again, so V1 to V4 keep it (#1632)

CodeRabbit found that the V5 bound in `dro_off` could move the second-last knot of a DRO
photo whose fitted last segment is flat, and V1 to V4 read that curve too. `dro_off` and
`at_most` are back to main's, so the camera-look fit is unchanged and LOOK_VERSION stays.
The DRO tests check the V5 shoulder where the lowered segment's slope over its squared gap
is at most the fitted one's (then it stays below), and on the Sony DRO corpus samples.
Also: `rolloff_hdr`'s doc names the HDR lift, and the release note names DNGs whose curve
already reaches white.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:50:13 +01:00
FredApps eab130ecee Docs: drop the Last updated / Change header line that every PR conflicted on (#3790)
* Docs: drop the Last updated / Change header line that every PR conflicted on

* xtask parity: the header's Changes field holds only the revision-history link; name the kept fields

* xtask parity: reject a field after the header's Changes link
2026-11-05 13:54:05 +01:00
Jeremy HookeandClaude Opus 5.5 3b23f25c1c Merge main into crystal-grain; the crystal grain moves to process V4
Main took V3 (sharpening) and V4 (calibrated Contrast and Blacks) while this was
open. Neither is in a release yet (v0.18.1's latest is V2), so the grain joins V4
rather than adding a process: V1 to V3 keep the value noise, V4 draws crystals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:16:35 +11:00
fc97c72864 LR-BEHAV-RENDER-FIDELITY: calibrated Contrast and Blacks in process V3 (#523) (#1383)
* LR-BEHAV-RENDER-FIDELITY: split V3 Contrast and Blacks

Port calibrated global Contrast and Blacks with shared CPU and GPU tables.
Preserve V2 Whites, local adjustments, other source paths, and all V1 and V2
renders and persistent cache keys.

Generate the constants from public Contrast and Blacks fits. Cover numeric
references, legacy renders, keys that include the resolved process, windows
and GPU parity. Document the measurements, limits and provisional number.

Co-Authored-By: Codex <noreply@openai.com>

* LR-BEHAV-RENDER-FIDELITY: reduce V3 preview cost and sanitize fits

Compose calibrated CPU preview operators over linear input and skip unused
mix endpoints. Keep high-bit-depth exports on the original operator path.

Require three distinct photos per published error group and band, regenerate
the fit hashes, and cover the sanitizer with synthetic tests. Document release
measurements and identify the separate measurement tool proposed in #1181.

Co-Authored-By: Codex <noreply@openai.com>

* LR-BEHAV-RENDER-FIDELITY: preserve V3 luminance evaluation

Retain the original operator evaluation for luminance mixes, including
preceding Contrast, so preview interpolation cannot change the cutoff.
Keep the fused per-channel path and exact deep exports.

Add CPU and GPU regressions for both uniform cutoff fixtures. Document
the measured CPU cost of positive Blacks and the retained fused path.

Co-Authored-By: Codex <noreply@openai.com>

* LR-BEHAV-RENDER-FIDELITY: finish split-module integration

Import the persistent process-key helper directly in the session module. Keep the existing V3 operator selection and evaluation order in an inline helper so the combined finish-stage closure stays within the complexity limit.

Refresh the V1/V2 golden hashes only after the exact fixture produces identical hashes on plain current main. Keep the cross-platform legacy samples unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LR-BEHAV-RENDER-FIDELITY: date the calibrated Light gap summary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 06:15:05 +01:00
d977d3e1a4 LR-EDIT-DETAIL-SHARPEN: Radius and Detail work, and sharpening acts at pixel scale (#757) (#762)
* LR-EDIT-DETAIL-SHARPEN: Radius and Detail work, and sharpening acts at pixel scale (#757)

* LR-EDIT-DETAIL-SHARPEN: an enlarging export keeps every Radius step (#757)

The sharpening blur's sigma was capped at 4 px, so a 2x export rendered
Radius 2 and 3 alike (both scaled past 4 px). The cap is now the largest
sigma the Gaussian taps support, GAUSS_SIGMA_MAX (64), which the CPU and
GPU blurs share. Test: enlarged_export_keeps_radius_steps_apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM

* LR-EDIT-DETAIL-SHARPEN: preserve process-aware previews after rebase (#757)

Use the V3 pixel-scale blur and Masking weight for the Radius, Detail and
Masking previews on both renderers. Preserve the legacy texture-band previews
for V1/V2 and compute the same Gaussian when sharpening is disabled.

Cover process selection, radius changes, and prepared/fresh plane equivalence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 05:21:13 +01:00
Jeremy HookeandClaude Opus 5.5 c81368470b LR-EDIT-EFFECTS-GRAIN: film grain from crystal layers (process V3)
Grain under the new process V3 is drawn from a simulated emulsion of crystal
layers, after Aurélien Pierre's crystallographic grain model (implemented from
the article's description). It fades out towards black and white as on a print,
is applied symmetrically in the display-encoded domain (no shadow darkening, no
grey specks in blown highlights), and matches Lightroom Classic's strength and
grain width at mid grey.

The emulsion is drawn once per Size / Roughness / seed on two periodic tiles in
frame coordinates and kept as summed-area tables, so each output pixel takes
the exact mean of the emulsion under its footprint: a preview is the export's
grain box-downscaled. CPU and GPU read the same tables (the GPU from the finish
kernel's aux buffer). V1 and V2 keep their value-noise grain exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:30:16 +11:00
Brain WangandClaude Opus 5.5 f14d166fdf Issue #3272: AI masks are enlarged along the photo's edges, no outline under a raised Exposure
An Object / Describe mask is stored as a model's logit grid (288² over the whole photo,
~20 px cells on a 24 MP export) and was enlarged by bilinear sampling alone, so its edge
spilled 1–2 px onto the background; raising the mask's Exposure turned that strip into a
bright outline in previews and exports (CPU and GPU: the GPU renderer evaluates these
shapes with the same CPU `shape_alpha`).

`sample_seg` now guided-filters the sampled logits by the photo's log luminance where the
grid is coarser than the output: the fast (subsampled) guided filter, a window of 1.5 grid
cells, so a preview and an export put the edge in the same place. Filtering logits, not
coverage, lets the confident side win (the spill goes to ≈ 0, not to a mean of ≈ 0.1).
Pixels whose window is all inside or all outside, and edges the photo has no contrast
across, keep the plain sample exactly. Refine Edges still runs afterwards on the combined
mask and finds the edges in place: no second halo, no tightening.

One implementation: `guided_cross` (Refine Edges, Auto Mask) and the new
`guided_cross_fast` share `guided_coeffs`; the subsampled form uses
`local::guided_fast_step` and a box `resize`, like `local::guided_fast`. `guided_cross`'s
output is unchanged.

Existing AI masks become 1–2 px tighter at a subject's edges, on every process; their
inside, outside and contrast-less edges are unchanged. This lands in place under the
bug-fix rule of docs/process-versions.md (output that is in neither the photo nor the
edit), noted there.

Cost per AI selection (M2 Pro, 12 cores; one shape_alpha, before → after):
24 MP 25.7 → 48.2 ms, heap peak 96.5 → 108.8 MB; 60 MP 64.1 → 109.9 ms, 239.9 → 256.7 MB;
2.7 MP preview 3.1 → 10.6 ms, 11.2 → 26.5 MB.

Tests (a dark disc on a light background, its mask on a 32² grid): ≈ 0 one and two px
outside and ≈ 1 inside, all around (1 px outside was 0.66), bit-identical away from the
edge; with Refine Edges 50 / 100 (was 0.29 outside) and the edge within 1 px of the
unrefined one; the same edge position at 256, 512 and 1024 px; a Prompt mask's window
matches the whole render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:13:04 +08:00
Gert Pellin c5cebdef39 Tone: neutral display tone map is the identity up to white (#1945)
The SDR shoulder in ToneMap::display_hdr ran at neutral Contrast, Whites
and Blacks too, so an unedited JPEG or PNG exported neutral 255, 254 and
253 one level darker and a white background never came out as 255. The
shoulder now shapes edited curves only; neutral sliders are the identity
up to white and clip above it. Edited curves render as before (V1).

Tests: the identity test runs to 1.0, a new pipeline test renders the
issue's neutral greys back exactly, the sRGB ramp round trip is exact,
and a guard keeps the shoulder on edited curves.
2026-10-16 04:27:35 +02:00
Claude 8c4898ec04 Merge PR #645
# Conflicts:
#	crates/engine/src/media.rs
#	crates/pipeline/src/finish.rs
#	crates/pipeline/src/local.rs
2026-10-11 12:03:53 +00:00
Brandon ThomasandClaude Opus 5.5 964d9338ba Progress docs: target-app parity, RAW coverage vs Adobe, gaps, alpha gate, readiness by audience (#788)
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, model-by-model RAW coverage against Adobe's camera list (docs/raw/), and format, hardware, UI and localization parity docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:25:15 -04:00
radialmonster 70103a7e55 LR-EDIT-LIGHT-HIGHLIGHTS: keep local contrast under Highlights/Shadows, as process V2 (#632)
The highlights/shadows base (fast guided filter of log luminance, eps 0.35)
followed thin high-contrast detail, so the per-pixel shift flattened twigs,
lines and texture in bright areas (and dark ones under Shadows); Exposure
kept them.

This changes how every existing edit with Highlights or Shadows renders, so
it ships as a new rendering process (docs/process-versions.md):

- Process::V2 (version 2) is added and made LATEST. New photos and Reset get
  it; existing edits stay on V1 until Update to Current Process. Settings
  saved without the field still load as V1.
- From V2 the base is the same guided filter of a separable median of log
  luminance (radius 0.15 % of the long edge, <= 24 px): fine detail takes its
  surroundings' shift and keeps its contrast, region steps stay put (no new
  halos). V1's base is untouched: local::tone_base_detail is 0 under V1 and
  tone_base then is exactly guided_fast(l, sigma, BASE_EPS), as before.
- CPU (local::prepare) and GPU prepare (median_h / median_v kernels, bit for
  bit with the CPU median) both branch on it via PlaneSigmas::base_detail.
- The base plane's cache key is now (sigma, process, median radius)
  (local::BaseKey), so a warm cache never serves one process the other's
  base. plane_at is shared with the GPU crate.
- finish::base_tone: V2 uses V1's tone map.

Tests: V2 keeps twig/local contrast (>= 0.9 of Exposure's) with no halo; V1
plane sigmas have no median and its base equals guided_fast bit for bit; V1
and V2 render alike without Highlights/Shadows; warm-cache renders across
V1/V2 switches equal fresh ones (fails if the key drops the process); GPU
matches CPU on both processes at 720 and 2400 px, cached and not.

Pentax 645D 2401 at 4000 px: main and V1 renders bit-identical (plain,
Highlights -100, Shadows +100). Highlights -100 bright rel. detail
0.095 (V1) -> 0.147 (V2), Exposure -1: 0.124; Shadows +100 dark rel. detail
0.126 -> 0.148.

RENDER_CACHE_VERSION 54 (adjust at merge).
2026-10-10 13:46:14 -04:00
Alex ChambersandClaude Opus 5.5 2e817a45f4 LRC-DEV-PROCESS: process versions, so a look change can ship without changing existing edits (#146)
Each photo's develop settings record the rendering process they are
interpreted by (`DevelopSettings::process`). There is one process, V1, the
pipeline as it is; nothing renders differently (180 CPU 8/16-bit and GPU
renders of procedural scenes, and the saved settings JSON and hash64, are
byte-identical to main).

Settings saved without the field load as V1 (a field-level serde default),
new settings get the latest (`Default`), and V1 is left out when saving, so
files and preview-cache keys stay as they were. A damaged value (anything
but a whole number up to 2^32 - 1) reads as V1 too, so it never costs the
rest of the settings (an unparsable `lc:settings` sidecar is ignored). Reset moves a photo to the
latest process, section and slider resets don't; virtual copies inherit
it; copy / paste / sync / Auto Sync / presets don't carry it; a number from
a newer LightCraft is kept and renders with the newest known process. The
process is not an edit (Edited badges, import-look checks). The base tone
map is chosen per process in one place (`finish::base_tone`, shared by CPU
and GPU). Photo > Update to Current Process (`develop.updateProcess`) moves
selected photos on an older process to the latest as one undo step. Its
menu enablement follows the selection; a call that names photos (`ids` /
`id`) is validated on those instead, through a new opt-in
`CommandSpec::explicit_targets` (execute checks enablement before the
handler sees `ids`). `develop.get` reports the process. The rule and how to add V2:
docs/process-versions.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:03:45 +02:00