mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-13 09:20:07 -06:00
1a301fc2d85a4d19f1d482d9dd62ddd6059dc55c
47
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1a301fc2d8 |
LR-EDIT-LENSBLUR: camera-like Lens Blur with AI subject focus and per-mask bokeh
Part of the Lens Blur row (`LR-EDIT-LENSBLUR`, ⬜ → 🟡: no depth map yet). Related: #377 (SAM 3 subject selection, used here for the Lens Blur focus).
## Problem
Lens Blur settings existed but nothing rendered them: there was no way to add a shallow depth of field, keep the subject sharp while the rest falls out of focus, or blur only part of a photo through a mask.
## Change
**Edit → Lens Blur** (`section.lensBlur`, ⌘6)
- **Apply**, **Amount**, **Boost** (brighter bokeh highlights) and **Transition** (how gradually the blur grows away from what is in focus, so the subject doesn't look cut out).
- **In Focus**: *Subject* (a mask) or a fixed shape: **Auto / Round / Horizontal / Vertical**, buttons drawn in code that work on the light themes.
- **Move & Resize** (tool `lensBlurShape`) drags the ellipse on the photo and stretches it by its handles. Each drag is one undo step; a shape button puts the ellipse back in the middle.
- **Find Subject** (`lensBlur.findSubject`) runs only when clicked, never automatically on Apply.
- It starts from the focus point the camera recorded, when the file has one (Sony ILCE-1 and ILCE-7SM3, the models `lightcraft_meta` reads today). Otherwise it starts from the subject estimate.
- SAM 3 keeps the prominent people and animals: those at the focus point and those of a similar size. When the focus point falls on neither, it falls back to an object click at that point.
- The result is a normal Object mask ("Lens Blur Subject") that can be refined in Masking.
- It runs on the background segment worker, and on remote SAM 3 too (`Operation::Subject`). While the mask is pending, the UI shows a spinner and the render keeps the subject estimate in focus, so the photo is never blurred whole.
- Without the model, it returns the model-missing error, changes nothing, and the UI offers the SAM 3 download.
- **Brush Refinement**: Focus and Blur brushes painted on the photo (`lensBlur.brushStroke`, `lensBlur.brushReset`), with Size, Feather, Flow, Amount and Auto Mask.
- While Apply is off, In Focus, the shapes, Move & Resize and Find Subject are disabled.
**Masking: a Lens Blur block in every mask** (`section:maskLensBlur`, under Refine Edges)
- **Blur: Selection / Outside**. Outside blurs what the mask leaves out, without inverting the mask's other adjustments.
- **Amount**, **Boost** and **Transition**, per mask. The header's eye switches the block off and keeps its values; the header folds the block.
- Stored as `lens_blur`, `lens_blur_outside`, `lens_blur_boost`, `lens_blur_transition` and `lens_blur_off` on `LocalAdjustments`, set through `mask.adjust`.
**Rendering** (`crates/pipeline/src/lens_blur.rs`)
- Runs in the scene-linear stage after denoise; its settings are part of `pre_nr_key`.
- Disc (bokeh) kernel at four levels on coarse grids, with a normalized convolution weighted by the circle of confusion, so sharp areas don't bleed halos into the blur. Transition uses a chamfer distance to the in-focus area.
- The radius is relative to the long edge, so the blur doesn't depend on resolution. The work stays bounded on large exports: the disc kernel has a fixed size, and each level's coarse grid stays within a cell budget.
- Zoomed windows, both 8-bit and deep (16-bit/float), read around them and are cut back to the request. The grids follow the whole output's origin, and the automatic subject estimate is made once from the whole photo. A window therefore matches the whole render.
- GPU path: read back, apply on the CPU, upload (`crates/gpu/src/render.rs`).
- Hostile values (NaN or infinite sizes, out-of-range areas and focus points, oversized stroke lists) are sanitized or rejected, never a panic.
All new strings are in the 17 locales.
**Not in this PR:**
- Real depth. The falloff is the image distance from the focus, not scene depth; a depth model (e.g. Depth Anything V2 Small on candle) would be a separate PR.
- Rotating the ellipse.
- Aperture shapes.
- Focus points for more camera makes and models.
- Find Subject doesn't resume by itself after the SAM 3 download (the Masking tools do). Like the existing Object / Describe jobs, it shows no row in the activity stack.
## How it was checked
- [x] Full `cargo xtask ci` on Windows 11 x86_64 after the latest merge of `main` (
|
||
|
|
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 --> |
||
|
|
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> |
||
|
|
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 |
||
|
|
476f9ee86a |
catalog: history steps and versions share segmentation grids (#2154) (#2203)
* catalog: history steps and versions share segmentation grids (#2154) Every history step stored its own copy of each segmentation grid, so a photo with AI masks rewrote every unchanged grid (about 220 KB each) with every slider change: on a real library, 183.6 MB of a 187.8 MB catalog.snap was duplicated grids (830 copies of 126 distinct ones). Snapshots now keep each distinct grid once, in Catalog.grids (a new serialization detail, format v10), and the masks of the photo's settings, versions and history steps refer to it by index (SegMask.grid, with the data omitted). The table is built while the snapshot is written and is never held in the live catalog: Journal::open resolves the references back to inline data (Catalog::repair), so rendering, hash64, presets, XMP and log records are unchanged, and catalogs written before v10 load and are upgraded as before. A reference past the table (a damaged snapshot) is cleared and reported, rendering nothing. Measured on 90 history steps over one 1024x1024 grid: the snapshot drops every step's copy of the grid (1.29 MB -> 0.30 MB in the fixture; on the reporter's library, the grids shrink from 181.8 MB to the 25.9 MB of distinct grids). * catalog: the snapshot's shared grids cover the import look too (#2154) A photo's import look (Photo.import_look, the settings a user default preset gave the photo on import) can hold segmentation masks, and its grids can be the same grids that the develop settings, versions or history steps hold. The snapshot writer referred to such a grid by its table index, but the table was built from the other settings holders only and the load resolved only those, so the look's mask came back empty with a dangling reference. Photo::settings_holders lists every settings holder (develop settings, versions, history steps, import look). The writer builds the table from it and the load resolves references through the same list, so a holder added there is covered on both sides. New test import_look_masks_share_the_table_and_resolve_on_load: a look whose mask shares a grid with the develop settings and a history step, plus a grid only the look holds. It reloads exactly, and the table holds both grids once each. Before this change the look's mask came back as data "" with grid Some(0). * catalog: state what a snapshot copy shares (#2154) The snapshot writer copies the catalog to serialize it. Its photos are shared (Catalog.photos holds Arc<Photo>), so the copy is a pointer per photo, not a deep copy; the comment said so without saying which tables are copied. It now lists them, and notes that the derived indexes start empty on the copy. * develop: a segmentation grid reference is written only by a snapshot (#2154) The inline serialization of a mask wrote its `grid` whenever one was set. A `grid` can be set in memory from a document: a mask given to a command (mask.update, develop.merge) or read from a preset goes through the derived Deserialize, and such a value stayed in the live catalog. The next snapshot wrote it inline, and on load the index resolved to whatever grid sat at that position in the new table, so a mask could take another mask's grid without a repair being reported. The same stray value also changed hash64 for identical settings. The inline form no longer writes `grid`, so it is the pre-v10 form again. Only the snapshot interner writes a reference, into the table it builds, so a snapshot cannot hold an index that points elsewhere. New tests: stray_grid_is_never_written_outside_the_interner (develop) and stray_grid_from_a_document_is_not_written_as_a_reference (catalog: a stray reference is not resolved to another photo's grid after a reload). Both failed before this change: the first wrote "grid":3, the second loaded photo one's grid into photo two. * Address review for pull request 2203 * Format the files this pull request changes * catalog: don't clone a grid that is already in the snapshot's table The snapshot's grid interning indexed with `entry(m.data.clone())`: the whole grid was copied before the map said whether it was already there, so every history step that repeats a mask's grid paid a needless allocation and copy on the snapshot path. The lookup is borrowed now (`contains_key`), and a grid is cloned (into the pool and the table) only when it is new, in the same first-appearance order. The background snapshot also held two catalogs: `snapshot_in_background` clones the catalog for the worker, and `write_snapshot` cloned it again to attach the grid pool. The writer now takes the catalog by value and the background path hands over its copy, so a snapshot in flight holds one catalog; the synchronous path keeps its own single clone. Not observable in behaviour: the grid tests and the catalog suite pass unchanged. * Tests: the hostile-mask test names the new grid field 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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
d729c80a5a |
Detail: Remove Hot Pixels, on for newly imported raws (#1345) (#1350)
* WIP: suppress isolated hot photosites in the raw mosaic (#820) Noise model fitted to the image, candidates 5 sigma above every same-colour neighbour, kept when two or more direct neighbours are lit (real light); adjacent hot pairs are replaced too. LIGHTCRAFT_HOT_PIXELS=0 turns it off, LIGHTCRAFT_HP_DEBUG=1 prints statistics. Renders are cached apart from older ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * WIP: hot pixel suppression off by default (LIGHTCRAFT_HOT_PIXELS=1 turns it on) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1345: mosaic outlier filter: cache key no longer names the #858 comparison switch (merged upstream) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1345: mosaic outlier filter: fixed thresholds (no LIGHTCRAFT_HP_K / _SPOT knobs), statistics under LIGHTCRAFT_PROFILE Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1345: Remove Hot Pixels, a box in Detail ▸ Noise Reduction, on for newly imported raws `Detail::nr_outliers` replaces single outlier photosites in the raw mosaic before demosaicing. It is true in `DevelopSettings::for_raw` and false by default, so edits saved before the box existed keep their look. The loaders take the flag (no more `LIGHTCRAFT_HOT_PIXELS`), a decoded source records it, and a cached raw source decoded with the other setting is decoded again. The box shows for raws only (`check:detail.nrOutliers`). * #1345: Remove Hot Pixels off keeps old settings' JSON; smart previews fit either setting - `detail.nr_outliers` is left out of the stored JSON when off, so settings saved before it keep their hash and cached thumbnails. Copy / paste, presets (also at an amount), preset updates and Auto Sync work on `DevelopSettings::to_json_for_merge`, which states it, so turning the box off still carries over. - `DecodedSource::fits_outliers` accepts a smart preview (`from_proxy`): it has no mosaic, so an offline raw with the box on keeps reusing the decoded proxy. (review of #1350) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1345: the Detail eye and Reset Detail switch Remove Hot Pixels off `DevelopSettings::removes_hot_pixels` (the box and the Detail section's eye) decides the raw decode in the app, the web worker and the thumbnail bench, so a Detail section that is off contributes nothing here either. Reset Detail resets the box to the general default (off), like the sliders it resets. (review of #1350) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1345: the hot pixel limit counts only replaced photosites, and can be set The 0.2 % limit was checked against the candidates before real light was filtered out, so a dense star field reached it with its stars alone and kept its hot pixels too. It now counts the photosites that would be replaced, as the module docs say. The limit is now a parameter (RawImage::suppress_hot_pixels_with, default HOT_PIXEL_MAX_SHARE) and a setting: Settings > Performance > Hot pixel limit, applied with the new command app.hotPixelLimit. Changing it forgets the decoded sources and changes the render cache keys of photos that remove hot pixels; at the default every key stays as it was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1345: the app's default hot pixel limit is the default The setting is an f32: 0.2 % read back as 0.2000000029… % was a new limit, so every start forgot the decoded sources and changed the render keys of photos that remove hot pixels. A value within a millionth of the default now is the default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Format the imports * Web loader test states what it decodes with * Format the media exports --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
9423635e8c | LR-EDIT-OPTICS-PROFILE: merge main (#3550 took catalog format 10; EmbeddedLens.source moves to format 11) | ||
|
|
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> |
||
|
|
2ad7207127 |
LR-MASK-GEOMETRY: masks stay on the photo under lens corrections, Upright, Transform and Rotate
Mask coordinates lived on the corrected frame, so a Transform, Upright or lens-correction change moved the photo under them and the masked look stayed behind, offset from the person it was drawn on (4.3 px on a 240 px render under Vertical 25 + Rotate 4: about 100 px at 6000 px). Rotate Left / Right turned the crop but not the masks. A mask's coordinates are now on the photo before the warp (Mask::on_photo, catalog format 10): the CPU render, the GPU render, Auto Mask brush windows and the on-photo tools map them through the warp (Frame::for_mask, Warp::from_source). Rotate Left / Right turns masks with the photo (mask_space::turn). The AI models see the photo without lens corrections, Upright, Transform, crop or flips, so stored Object / Prompt selections stay on their content and changing those doesn't ask the model again. Older masks (no on_photo) render where they were and move onto the photo, without moving, at their next edit (mask_space::attach). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
29443e117c |
LR-EDIT-OPTICS-PROFILE: merge main (#3430 integration round 3, #3390)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ee0a627536 |
Merge track r3-t10
# Conflicts: # crates/engine/src/cmd/masks.rs # crates/ui-egui/locales/de.json # crates/ui-egui/locales/es.json # crates/ui-egui/locales/fr.json # crates/ui-egui/locales/ja.json # crates/ui-egui/locales/ka.json # crates/ui-egui/locales/pt-br.json # crates/ui-egui/locales/ru.json # crates/ui-egui/locales/uk.json # crates/ui-egui/locales/zh-hant.json # docs/gaps.md # docs/parity-checklist.md # docs/whats-new.md |
||
|
|
be6e462db4 |
Cap red eye corrections at 64 per photo (#2556)
Every output pixel goes through every eye, and nothing limited the list: a sidecar, preset or agent with 100,000 eyes made one preview take hours. The render resolves the first MAX_RED_EYES and redeye.add refuses more, as Point Color does with MAX_POINT_COLORS. |
||
|
|
06e2ec43ba |
LR-MASK-COLORRANGE: a colour-range mask keeps at most 64 samples however it is loaded (#2556)
Render cost is pixels x samples (CPU and GPU), and only mask.add / mask.update capped the list, so a sidecar's lc:settings, a preset, develop.merge, render --settings or a catalog / history load could carry thousands and stall a render or lose the GPU device. MAX_COLOR_RANGE_SAMPLES moves to lightcraft-develop and ColorRange::samples deserializes through a capped visitor (the first 64 in order, the rest skipped as IgnoredAny); the CPU and GPU renders also take at most 64 for settings built in code. |
||
|
|
2ec4add0d6 |
LR-EDIT-OPTICS-PROFILE: lensfun database downloaded on first run, library looked up, Optics names the profile
- lensfun's data/db (CC BY-SA 3.0) is fetched by the desktop app on first run into <config dir>/lensfun, pinned to commit 43f9e001 by name, size and SHA-256, staged until every file is verified, with an activity row (never bundled). LIGHTCRAFT_LENSFUN_URL lists other mirrors; LIGHTCRAFT_LENSFUN_DIR still wins. lens.downloadDatabase / lens.databaseStatus for agents. - The database is read per folder and again after a download; when it changes, library raws without a profile whose lens it knows are probed in the background and get it (untouched photos switch the correction on as import would); ticking Enable Lens Corrections looks those photos up again (library.refreshLensProfiles). - EmbeddedLens records its source (file / lens database); the Optics panel says "Profile: <lens> (lens database)", "Profile: from the file" or "No lens profile found for <lens>." (13 locales). - Tests: pinned download and refusal of a wrong file, the lookup and its edited / untouched rules, and presets / Copy-Paste / Sync carrying only the switch and amounts, each photo keeping its own lens's profile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 773740fbb22eb66e7f62bada9cb32b594b6ff4d7) |
||
|
|
32819d4900 |
LR-MASK-BRUSH: pen pressure scales brush size and flow
Brush strokes record a pen pressure (0..1) per point from egui touch force while Brush > Pen Pressure is on (default). Each dab's radius goes from a tenth to all of the brush, and its flow is proportional to the pressure, in the committed stroke and the live preview alike. A mouse stroke stores no pressure, so documents and renders are unchanged. The GPU mask kernel leaves pressure strokes to the CPU. mask.brushStroke takes `pressure`; bad lists are refused. Also satisfies clippy 1.99's chunks_exact_to_as_chunks in spots.rs. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
|
|
8c4898ec04 |
Merge PR #645
# Conflicts: # crates/engine/src/media.rs # crates/pipeline/src/finish.rs # crates/pipeline/src/local.rs |
||
|
|
a0e422ce4a |
Merge PR #752
# Conflicts: # crates/engine/src/cmd/develop.rs # crates/engine/src/files.rs # docs/mcp.md # docs/parity-checklist.md # docs/whats-new.md |
||
|
|
8339028a80 |
Merge PR #794
# Conflicts: # crates/engine/src/media.rs # crates/pipeline/src/optics.rs # docs/parity-checklist.md |
||
|
|
fe6d7b0192 |
Content-aware Remove: inpaint crate (PatchMatch completion) and a Fill spot mode
New `inpaint` crate (L1): multi-scale patch-based completion with a PatchMatch
nearest-neighbour field, seeded by a membrane fill. Deterministic, no panics on
hostile sizes, cancellable. Ported from PhotoCraft (MIT OR Apache-2.0).
Remove tool: a fourth spot mode, `fill` ("Content-Aware"), rebuilds the spot
from patches of its surroundings instead of one source; coarse-to-fine, so the
structure is chosen at a scale set by the spot size and preview and export agree
(tested). spot.add leaves its source unset.
Sources: C. Barnes et al., PatchMatch (SIGGRAPH 2009); Y. Wexler, E. Shechtman,
M. Irani, Space-Time Completion of Video (TPAMI 2007); P. Perez et al., Poisson
Image Editing (SIGGRAPH 2003) for the membrane fill; code ported from PhotoCraft
crates/algo/src/inpaint.rs + poisson.rs (a680ed22).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
||
|
|
0041aef955 | LR-EDIT-OPTICS-PROFILE: apply DNG WarpRectilinear2 lens corrections (Pixel DNGs, #635) | ||
|
|
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). |
||
|
|
76e633c9e1 |
LR-EDIT-COLOR-TEMP: Kelvin white balance for raws whose camera has colour matrices (#730)
Raws without matrices in the file (ARW, NEF, RW2, RAF, CR2, CR3, PEF, SRW,
ORF) were all edited on the relative white-balance scale (6500 K / 0 = as
shot), even when lightcraft_raw::spectral has measured matrices for the
camera. Those 52 bodies (ILCE-7M3, ILCE-7M4, D850, EOS 5D Mark III...) now
edit in Kelvin like a DNG and like Lightroom.
Design:
- One decision on both sides: spectral::find_camera("Make Model") backs
catalog::relative_wb_camera (Photo::relative_wb); files::WbScaleOf decides
the same from the decoded file. A corpus test asserts they agree.
Photo::fitted_look keeps the old predicate for smart previews.
- The matrices only convert: As Shot is the file's multipliers read through
the model's ColorMatrix1/2 (spectral::as_shot_white_through). The look
(JPEG fit / profile / spectral fallback) is unchanged, and the slider
adapts from that white with Bradford. The camera-space rebalance stays
reserved for files with their own matrices.
- Presets resolve to their Kelvin values (WbMode::preset, sources cited).
- The Color panel shows the white balance in effect (effective_wb).
- XMP: crs::Target::for_file takes the camera; matrix cameras read
crs:Temperature as Kelvin (marked "scale": "kelvin"), others keep the
#510 as-shot shift.
Migration (no look changes):
- WhiteBalance.scale: WbScale { Legacy (absent in JSON), Kelvin }. A
white balance without a scale keeps its old meaning. On a spectral-Kelvin
raw (SourceInfo.legacy_relative_wb) the pipeline composes the old
adaptation (6500 -> value) with the new one (as shot -> ...) in Bradford
cone space (local::legacy_white). Von Kries scalings compose exactly, so
the matrix matches to f32 and the pixels are identical
(pipeline legacy_relative_white_balance_renders_as_before_on_a_kelvin_raw,
media old_relative_white_balance_on_a_kelvin_camera_keeps_its_look...).
- Every white-balance writer (develop.set/adjust/quickAdjust/wb/wbPick/
reset) first runs local::normalize_wb: a legacy value becomes the Custom
Kelvin value with the same look and is marked Kelvin. Partials without a
scale (old presets, develop.merge) stay legacy.
- Catalog format 7: Op::SetAsShotWb replaces the 6500/0 as-shot recorded
by older imports when the file is next decoded or reloaded (system op,
not an undo step). Format-version test updated.
- The web wire carries legacy_relative_wb.
Docs: parity (LR-EDIT-COLOR-TEMP), camera-preview-colour, xmp-interop,
lightroom-catalog-import, control-protocol, mcp, whats-new.
|
||
|
|
e46e7ab265 |
Merge PR #646 (HDR peak threaded through #533's base_tone; V1 at peak 1 unchanged)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
88de143dd1 |
HDR: default HDR settings aren't serialized (hash64 and cached previews unchanged); NaN-safe peak; checked AVIF size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
791526aa8c |
LR-HDR-EDIT: HDR settings, HDR tone map and the float HDR render
- `DevelopSettings.hdr`: enabled, headroom limit (`max_ev`) and SDR-rendition offsets (brightness, contrast, highlights, shadows, whites, clarity), with control specs (`hdr.maxEv`, `hdr.sdr*`) and copied with the Light group. - `ToneMap::hdr` / `display_hdr`: the SDR curve through the midtones, with highlights lifted up to `2^max_ev`; identical to the SDR tables at peak 1. - `OutputDepth::F32Hdr`: unclamped linear output up to the peak. The finish stage carries the peak through highlight desaturation, gamut mapping, encoding, curves, grain and LUT profiles; with HDR off every branch is the SDR code path, bit for bit. - `settings_for`: every non-HDR render (CPU and GPU) uses the SDR rendition. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6f323e01d9 |
Merge PR #533
# Conflicts: # docs/parity.md |
||
|
|
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> |
||
|
|
fa1565d855 |
LR-PROF-CAMERACOLOR: camera local tone mapping (ProfileGainTableMap) as a per-photo opt-in
Renders a raw's DNG ProfileGainTableMap (Apple ProRAW's local tone mapping) when the photo's Profile option "Camera local tone mapping" is on (`profile.camera_local_tone`, off by default: Lightroom Classic renders ProRAW without it). Built on #427's parser; supersedes the decode-time applier of the first version of #273. - raw/gaintable: GainTableMap::gain / evaluator (GainEval) per DNG 1.7.1: bilinear between tables with edges replicated, input clamp(weights . (R,G,B,min,max))^gamma in linear ProPhoto after the baseline exposure, index input x MapPointsN clamped to the last point. Saturating cell math; maps whose sizes don't match their gains aren't evaluated. SourcePlacement: where a developed source sits in the active area. - pipeline/local_tone: the gain per output pixel in the scene-linear stage, before the white balance, mapped back through the frame (crop, user orientation, warp) and the EXIF orientation. lin_needs_cpu routes the stage to the CPU when it's on (CPU and GPU match); lin_key includes it. SourceInfo::local_tone carries the map from the decoder. - develop: Profile::camera_local_tone (omitted from JSON when off, so existing settings and their hashes are unchanged); Profile row checkbox when the source has a map; translations. - RENDER_CACHE_VERSION 17. Docs: parity row and What's New as an opt-in. Off renders pixel-identical to main on two iPhone 15 Pro ProRAWs (12 and 48 MP). On adds ~60-75 ms to the scene-linear stage at 1536x2048 on a 2-core VM, the same for both files (cost follows output pixels). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VFqow2P1EtDxt6nRjjQoTA |
||
|
|
7935999d94 |
LR-EDIT-DETAIL-DENOISE: non-destructive AI RAW denoise with pure-Rust CPU/GPU
Recover the unpublished denoise work against current upstream. Preserve later opt-in model setup, shared Rust HTTPS downloads, pending-photo resume and an Amount-independent switch. Keep original RAWs unchanged and share the current color finish between ordinary and denoised sources. Bound public tiling inputs, refuse corrupt-cache exports, and retain synthetic/real-model coverage. Include two Windows path spelling fixes in existing tests. No model weights, face code, media or Tract runtime are included. Validation: cargo xtask ci (all seven gates); installed RawNIND CPU/GPU agreement 1.38e-6; native CPU preview/export on a public 24 MP Sony RAW, unchanged original SHA-256. Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Generated with Codex. |
||
|
|
aaec05dd11 | Merge PR #262 | ||
|
|
03bc1469f9 |
LR-EDIT-OPTICS-PROFILE: apply the distortion correction Panasonic/Leica record in RW2/RWL (#256)
The RW2 reader reads PanasonicRaw tag 0x0119 (16 LE words) and expresses the camera's own correction as the equivalent DNG WarpRectilinear in OpcodeList3, so embedded_lens() turns it into the existing EmbeddedLens/EmbeddedWarp and "Enable Profile Corrections" applies it like a DNG's warp (no new warp path; Convert to DNG keeps it). Rule, from our own measurements against the embedded camera JPEGs (clean-room; ExifTool tag names only): corrected = rho*(1 + (w8 rho^2 + w4 rho^4 + w11 rho^6)/32768), rho = r/w12 (w12 = active-area half diagonal), output = corrected / (1 + w5/32768), on when (w7 & 0xf) == 1. Chosen on 38 GX85 photos, held out on 35 photos from 12 other bodies / 9 other lenses; corrected renders line up with the camera JPEG within 0.75 px at 1440 px at the corners (up to 13 % of the half diagonal before). Implausible values leave the photo uncorrected. The loader now removes lens opcodes before the RW2 camera-look fit (its binned proxy needs an empty OpcodeList3); Opcode::is_lens_correction() replaces the duplicated matches. RENDER_CACHE_VERSION 11 -> 12. |
||
|
|
d614dc72bd | M3: isolate global Tint direction correction for compatibility review | ||
|
|
d379e780cc |
M12: AI masks never require SAM 3 — background worker, consented download, review fixes
Engine (crates/engine/src/segment/):
- One worker thread owns the model and the encoded photo; the session
sends jobs (with the render job for the model input) and gets results
over channels, never locking what the model holds. In the app
(Segmenter::background) clicks, descriptions, prepares and detail
passes return at once and Session::segment_poll applies results (one
undo step per settled selection, pending clicks shown on the photo);
the CLI/MCP wait for them. Newer requests for the same selection
supersede queued ones.
- Every job runs under catch_unwind: a candle panic becomes an error and
the model is reloaded next time; busy counters are reset by drop
guards; a dead worker is restarted. The model unloads after 10 min idle.
- segment.model.status / .download {acknowledged: true} / .cancel: the
download runs on its own thread; status via atomics (try_lock only).
Without the model every AI request fails at once with "The SAM 3 model
is not installed…"; renders/exports never need it; `seg` params make
AI masks from stored segmentations without it.
- Hostile documents: at most 4 detail patches per component (capped on
deserialize and in the renderer), at most 64 Object clicks.
UI: Object / Describe without the model open "Download the SAM 3
Model?" (size, folder, SAM License link; Not Now / Download, progress,
cancel, Try Again with the reason, Continue once installed); progress in
the Masking panel when the dialog is closed. Reverted the PR's unrelated
default change (mask overlay off + hover-red) to main's behaviour.
tools/install-sam3.*: --fail so error pages never land in files, 416
restarts instead of looping, the weights are verified before they are
moved into place (a bad file is deleted, never resumed from).
Docs: docs/ai-masks.md (in-app download, mirrors, SAM License summary,
no weights in the repo), NOTICE, parity LR-MASK-OBJECTS, What's New,
ROADMAP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
6468bfc06b |
M12: SAM 3 Object and Describe masks (pure Rust, Metal)
AI masks from Meta's SAM 3, run in LightCraft itself: a new
`lightcraft-segment` crate ports the Hugging Face transformers
implementation (Apache-2.0) to candle, on the GPU through Metal on macOS
(CPU elsewhere), with no C/C++ and no `unsafe`.
- Object: click to include, ⌥-click to leave a part out (SAM 3's
interactive prompt encoder + two-way mask decoder).
- Describe: a text prompt selects every instance ("sky", "the red car";
"car, road" merges several) via the CLIP text encoder and DETR detector.
- Both as new masks and as Add / Subtract / Intersect components; + / −
buttons under the selected mask combine selections.
- The segmentation is stored with the mask (288² quantized logits, ~10-30
KB), so renders, exports and the web build never need the model.
- A zoomed-in detail pass (background, ~5 s on an M4 Pro) re-segments the
area around a selection from a sharper source: 5-10x finer edges on
small objects.
- Per-selection Edge slider (hard ↔ soft); hovering a mask in the list
shows it in red (the always-on overlay is now off by default, O toggles).
- The engine feature `sam` (on in the desktop app) gates the model; the
image is encoded once per photo in the background, clicks take ~35 ms.
- Weights are not bundled (SAM License): tools/install-sam3.sh / .ps1
download and verify facebook/sam3; docs/ai-masks.md.
- Clearer errors (missing file, missing model) and a stderr logger in the
app (LIGHTCRAFT_LOG=info).
candle is pinned at 0.9.2: later releases make candle-core depend on
tokenizers with the Oniguruma C library. Every stage is checked against
the Python reference (tests/reference.rs, tools/sam3_reference.py): on
Metal the backbone matches to 7e-5 relative error, masks to ~5e-5.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C8BkS7JDqa1EMcjW6spDGA
|
||
|
|
439cbd6ca0 |
LRC-DEV-SOFTPROOF, MENU-CTX-MASK: soft proofing with gamut warnings; mask component menu
Soft proofing (S in the loupe; S in grids stays Expand/Collapse Stack):
- pipeline Proof {space, destWarning, displayWarning}: gamut-map into the
proof space, show it in the output space; red/blue gamut warnings
(neutral clipping excluded). CPU only; the GPU path declines proofs.
- RenderJob::with_proof; paper-white surround and Proof Preview label;
Soft Proofing strip under the histogram (profile, warnings, Create
Proof Copy = a named virtual copy). view.softProof for control/MCP.
- photo.virtualCopy takes an optional name.
Mask components: MaskComponent.name; mask.component {component, action:
invert|duplicate|delete|rename|op}; right-click / … menu per component,
double-click rename, Intersect button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
5d864c0374 |
LR-MASK-FEATHER-EDGE: Refine Edges for masks
Mask.refine (0–100, mask.refine; Masking panel ▸ Refine Edges): after a mask's components are combined, a guided filter steered by the photo's luminance (window up to ~4 % of the long edge) snaps soft mask edges to the photo's edges. Rendered on the CPU (the GPU path has no kernel for it yet). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ebcc41825b |
LR-EYE-RED: red eye correction with automatic pupil detection
pipeline::redeye: inside the user's ellipse the pupil is found on the source image (a fixed grid in normalized coordinates, so previews and exports agree): weighted centroid and area of red-dominant pixels (or, for pet eyes, of the brightest ones). Pupil Size scales the detected disc; rendering in scene-linear light after white balance neutralizes the red where it is red (iris and catchlights stay) and darkens it. CPU (lin stage) and GPU kernel redeye_k with an equivalence test (both the GPU and the CPU scene-linear paths). Indexed controls redEye.<i>.pupilSize/darken, redeye.add/delete commands, the Red Eye panel (selected eye's sliders, delete) and the loupe tool (drag an ellipse, click to select). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
490fe943cd |
LR-EDIT-COLOR-POINTCOLOR: Point Color — sampled colours with shift, variance, range and visualize
Up to 8 OkLCh samples (pointColor.pick samples the colour after the mixer on a proxy render); per sample hue/saturation/luminance shift, variance (compress/expand the spread around the sample) and an overall + per-axis range with soft edges, applied after the colour mixer in ColorOps and its WGSL twin (equivalence case). Indexed control specs (pointColor.<i>.hueShift …) so develop.set/controls/MCP address each sample. Visualize range is a render overlay (RenderRequest::overlay) drawn after either renderer: the sample's own edit is left out and colours outside the range turn grey. Point Color flyout in the Color section with swatches, eyedropper, sliders, visualize and delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
37d9ecf0e9 |
LR-EDIT-LIGHT-CURVE-REFINESAT: curve Refine Saturation
curve.refineSaturation (0..100, default 100 = the curve's own saturation): after the tone curves, the chroma around luma is rescaled so saturation moves from the curve's towards the pre-curve value, keeping the curve's tone. CPU (finish::refine_saturation) and GPU twin, equivalence case, slider under the curve editor, crs:CurveRefineSaturation import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
48efc6df52 |
LR-EDIT-CALIB: Calibration — shadows tint and R/G/B primary hue/saturation
White-preserving primaries matrix (OkLCh hue rotation / chroma scale of each Rec.2020 primary) plus a luminance-preserving green/magenta shadows tint, applied in scene-linear light before the tone map. CPU (colorops::calibrate) and GPU (finish.wgsl) with an equivalence case; seven control specs (calibration.*), a settings group, crs: import (ShadowTint, Red/Green/BlueHue/Saturation) and a Calibration section at the bottom of the Edit panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b00dda4cec |
M4.4: Upright — Auto/Level/Vertical/Full from detected lines + Guided upright (up to 4 guides)
- pipeline::upright: own gradient-based line-segment detector (level-line region growing seeded from the
strongest gradients, weighted principal-axis fit, elongation test); vanishing points by RANSAC over
segment pairs + least-squares refinement (smallest eigenvector of Σ w·l·lᵀ)
- corrections as a virtual camera rotation K·R·K⁻¹ (vertical VP → y axis; Full also horizontal VP → x axis
with focal length from VP orthogonality when plausible); Level = rotation; Auto = Full/Vertical when
moderate, else Level; all re-centred
- Guided: guides stored in normalized lens-corrected coordinates; 2+ vertical/horizontal guides define the
vanishing points, a single guide levels
- the analysis runs on the lens-corrected image at 1024 px and the result is stored in
geometry.upright_transform (identical preview/export); settings naming a mode without a transform
(XMP, CLI) are resolved at render time
- commands geometry.upright {mode} (Update = run again) and geometry.guides {guides, add}
- UI: Upright mode buttons, Update, Draw/Clear Guides in the crop panel; on-canvas guide drawing that
follows the warp
- tests: segment detector on known lines; Vertical/Auto/Full fix converging verticals; Level fixes a tilted
horizon; Full fixes both families; guided from two guides; analysis resolution independence; lazy vs
stored transform; engine commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
a0da99660d |
M4.2: optics — manual distortion/vignetting, lateral CA (auto + manual), defringe, DNG-embedded profile corrections
- pipeline::optics::Warp: one inverse map (lens-corrected → source) inside the single geometry resample: manual radial distortion, embedded DNG WarpRectilinear (per plane), per-channel lateral CA scale, radial vignetting gain (manual + embedded FixVignetteRadial); all in normalized radii - automatic lateral CA estimate: radial edge profiles matched sub-pixel per plane, robust LSQ fit of displacement = α·r (cached per source) - defringe: purple/green hue ranges (OkLab hue) desaturated next to high-contrast edges, width relative to the image long edge - "Enable Lens Corrections" applies only lens data embedded in DNGs (opcodes are no longer baked at decode; stored on the photo record, on by default at import when present). No LCP data anywhere. - new controls: optics.caRed/caBlue, defringe hue ranges; Optics panel: Manual sub-section, Defringe flyout - tests: barrel grid straightened, CA removal (auto + manual), embedded corrections toggle/scale, preview vs export resolution independence, CA estimator accuracy, orientation of embedded lens data Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3f54765bd0 |
M0.3/M0.5: develop settings model + control specs + presets; procedural demo scenes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |