M2.3: include Canon raws in reproducible camera calibration

Discover CR2/CR3 inputs and merge parallel per-file pools in sorted input order.
Cover discovery, malformed input reporting and reproducible fitting with CLI tests.

Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit 66b62124bf)
This commit is contained in:
grlessa authored and bflatastic committed 2026-10-13 12:41:27 +07:00
1 parent aedd3cbbb8
commit 859d9492f9
7 files changed
+127 -14

No files matched your search

Generated
+1
View File
@@ -3125,6 +3125,7 @@ dependencies = [
"lightcraft-merge",
"lightcraft-raster",
"lightcraft-sysmem",
"lightcraft-tiff",
"lightcraft-ui-egui",
"serde_json",
]
+3
View File
@@ -25,6 +25,9 @@ lightcraft-merge = { workspace = true }
serde_json = { workspace = true }
dhat = { workspace = true, optional = true }
[dev-dependencies]
lightcraft-tiff = { workspace = true }
[features]
default = ["denoise"]
denoise = ["lightcraft-engine/denoise"]
+21 -10
View File
@@ -104,7 +104,8 @@ USAGE:
lightcraft-cli controls [--json] list every develop control id with its range
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
(Sony ARW, Nikon NEF, Fujifilm RAF): up to N files spread over the folders (default 300; 0 = all), pooled per
(ARW, NEF/NRW, RAF, CR2/CR3): up to N files spread over the
folders (default 300; 0 = all), pooled per
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
take their colour from the profile and only their tone from their own JPEG.
@@ -186,8 +187,9 @@ fn main() -> ExitCode {
/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders.
fn raw_files(path: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) {
let is_raw =
|p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf"].iter().any(|x| e.eq_ignore_ascii_case(x)));
let is_raw = |p: &Path| {
p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf", "cr2", "cr3"].iter().any(|x| e.eq_ignore_ascii_case(x)))
};
if path.is_file() {
if is_raw(path) {
out.push(path.to_path_buf());
@@ -240,28 +242,37 @@ fn calibrate(args: &[String]) -> Result<(), String> {
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(1, 6);
let next = std::sync::atomic::AtomicUsize::new(0);
let done = std::sync::atomic::AtomicUsize::new(0);
let pools: Vec<lightcraft_engine::camera_profiles::Pool> = std::thread::scope(|scope| {
let mut pools: Vec<(usize, lightcraft_engine::camera_profiles::Pool)> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..workers)
.map(|_| {
scope.spawn(|| {
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
while let Some(path) = files.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let mut pools = Vec::new();
loop {
let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let Some(path) = files.get(index) else { break };
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
let result = std::fs::read(path).map_err(|e| e.to_string()).and_then(|bytes| pool.add(&bytes));
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
match result {
Ok(Some(model)) => eprintln!("[{n}/{}] {} ({model})", files.len(), path.display()),
Ok(Some(model)) => {
eprintln!("[{n}/{}] {} ({model})", files.len(), path.display());
pools.push((index, pool));
}
Ok(None) => eprintln!("[{n}/{}] {}: skipped (no usable camera JPEG or colour)", files.len(), path.display()),
Err(e) => eprintln!("[{n}/{}] {}: {e}", files.len(), path.display()),
}
}
pool
pools
})
})
.collect();
handles.into_iter().filter_map(|h| h.join().ok()).collect()
handles.into_iter().filter_map(|h| h.join().ok()).flatten().collect()
});
// The fitting holdout and floating-point sums depend on pair order. Keep parallel decoding,
// but merge in the sorted input order rather than worker admission/completion order.
pools.sort_by_key(|(index, _)| *index);
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
for p in pools {
for (_, p) in pools {
pool.merge(p);
}
let mut written = 0;
+94
View File
@@ -72,6 +72,100 @@ fn calibrate_reads_raf_inputs_and_reports_bad_files() {
assert!(!out.join("X-T4.json").exists(), "bad input must not produce a profile");
}
#[test]
fn calibrate_discovers_canon_inputs_without_touching_files() {
let input = tmp("calibrate-canon-inputs");
let nested = input.join("nested");
std::fs::create_dir_all(&nested).unwrap();
let extensions = ["ARW", "nef", "NRW", "raf", "CR2", "cr3"];
for extension in extensions {
std::fs::write(nested.join(format!("camera.{extension}")), b"invalid raw fixture").unwrap();
}
// DNG supplies its own colour model; rendered inputs cannot train a vendor RAW profile.
for extension in ["dng", "jpg", "png", "tif", "txt", "rw2", "rwl", "raw", "pef", "srw", "orf"] {
std::fs::write(nested.join(format!("ignored.{extension}")), b"ignored fixture").unwrap();
}
let hidden = input.join(".hidden");
std::fs::create_dir_all(&hidden).unwrap();
std::fs::write(hidden.join("hidden.CR3"), b"hidden fixture").unwrap();
let out = tmp("canon-calibration-profiles");
let result = Command::new(BIN).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap();
assert!(!result.status.success(), "invalid raws cannot produce a profile");
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(stderr.contains("6 of 6 raw files"), "Canon inputs silently ignored: {stderr}");
for extension in extensions {
let name = format!("camera.{extension}");
assert!(stderr.contains(&name), "{name} was not passed to the decoder: {stderr}");
assert_eq!(std::fs::read(nested.join(name)).unwrap(), b"invalid raw fixture");
}
assert!(stderr.contains("no profile written"), "{stderr}");
assert!(!out.exists(), "invalid inputs must not create a profile folder");
}
/// Original, uncompressed Bayer TIFF with Sony container metadata and an authored JPEG chart.
/// The varying dimensions let worker completion/admission order differ between runs.
fn calibration_arw(index: usize) -> Vec<u8> {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let (w, h) = (96 + index * 16, 64 + index * 8);
let colour = |x: usize, y: usize| {
let (x, y) = (x / 8, y / 8);
[0.06 + 0.6 * x as f32 / (w / 8) as f32, 0.07 + 0.55 * y as f32 / (h / 8) as f32, 0.08 + 0.5 * ((x * 7 + y * 3 + index) % 13) as f32 / 13.0]
};
let pixels: Vec<u8> = (0..h)
.flat_map(|y| {
(0..w).flat_map(move |x| {
let ch = [[0, 1], [1, 2]][y % 2][x % 2];
(512 + (colour(x, y)[ch] * 15871.0) as u16).to_le_bytes()
})
})
.collect();
let preview = lightcraft_raster::Rgba8::from_fn(w, h, |x, y| {
let p = colour(x, y).map(|v| (255.0 * (1.055 * v.powf(1.0 / 2.4) - 0.055)).round() as u8);
[p[0], p[1], p[2], 255]
});
let jpeg = lightcraft_codecs::encode_jpeg(
&lightcraft_codecs::EncodeImage::rgba8(&preview),
95,
lightcraft_codecs::ChromaSubsampling::S444,
&Default::default(),
)
.unwrap();
let mut raw = IfdBuilder::new();
raw.set(t::MAKE, Value::Ascii("SONY".into()));
raw.set(t::MODEL, Value::Ascii("TEST-CALIBRATION".into()));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![w as u32]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![h as u32]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set(t::WHITE_LEVEL, Value::Long(vec![16383]));
raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![pixels] });
// An undefined TIFF value is a supported camera-JPEG preview carrier.
raw.set(0xc7ff, Value::Undefined(jpeg));
TiffWriter::default().write(&[raw]).unwrap()
}
#[test]
fn calibrate_is_reproducible_with_parallel_workers() {
let input = tmp("calibration-repeatable-input");
std::fs::create_dir_all(&input).unwrap();
for i in 0..8 {
std::fs::write(input.join(format!("chart-{i}.ARW")), calibration_arw(i)).unwrap();
}
let mut first = None;
for run in 0..4 {
let out = tmp(&format!("calibration-repeatable-out-{run}"));
let result = Command::new(BIN).args(["calibrate", "--out"]).arg(&out).arg(&input).output().unwrap();
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
let bytes = std::fs::read(out.join("TEST-CALIBRATION.json")).unwrap();
match &first {
None => first = Some(bytes),
Some(expected) => assert!(&bytes == expected, "the same chart files produced a different camera profile"),
}
}
}
fn mean(path: &std::path::Path) -> f64 {
let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).unwrap();
let img = d.to_srgb8();
+4 -3
View File
@@ -1,6 +1,6 @@
//! Camera colour profiles of our own, pooled from many photos of one camera model.
//!
//! A raw file without colour matrices (Sony ARW, Nikon NEF, Fujifilm RAF) gets its look fitted to its own embedded camera
//! A supported raw file without colour matrices gets its look fitted to its own embedded camera
//! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a
//! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate`
//! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value
@@ -190,8 +190,9 @@ pub struct Pool {
const PAIRS_PER_FILE: usize = 4000;
impl Pool {
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW,
/// NEF or RAF without colour matrices, no usable camera JPEG, too little colour).
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not a vendor
/// format handled by `camera_preview::file_local_look`, has its own colour matrices, no usable
/// camera JPEG, or too little colour).
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
// colour only: geometric lens corrections would stop the sensor proxy from binning
+3 -1
View File
@@ -109,7 +109,7 @@ A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 co
## Camera profiles (pooled per model)
One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos.
One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEF/NRWs, RAFs or Canon CR2/CR3 files per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos.
Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt.
@@ -223,3 +223,5 @@ the neutral fallback): E-1 0.126 → 0.014, E-400 0.087 → 0.022, XZ-2 0.061
still open from their embedded JPEG. Against Lightroom Classic's render of a CC0 E-1 photo (2000 px, ΔE00 of the
unedited render) the starting look moved from 11.25 to 8.30. Lightroom frames that E-1 file about 4 × 11 sensor pixels
differently from the crop the file records (`CropLeft`/`CropTop` 36, 34); the file's own crop is kept.
Reviewer follow-up: calibration discovers CR2 and CR3 case-insensitively. Per-file pools merge in discovery order so worker scheduling cannot change the fitted model. Malformed inputs remain errors without profile output. CLI regression fixtures are synthetic.
+1
View File
@@ -243,6 +243,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md) |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it), kept with smart previews (see docs/camera-preview-colour.md); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today CLI calibration also discovers CR2/CR3; per-file pools merge in discovery order for reproducible parallel fitting (`apps/lightcraft-cli/tests/cli.rs`) |
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |