Merge PR #258
This commit is contained in:
commit
7e21d185b8
|
|
@ -2,7 +2,9 @@
|
|||
//!
|
||||
//! The Planckian locus uses the Kim et al. (2002) cubic approximation (1667–25000 K); outside that
|
||||
//! range it is extended linearly in mired space. Tint is a signed offset perpendicular to the locus in
|
||||
//! CIE 1960 (u, v): **positive tint = magenta** (below the locus), `Duv = −tint / TINT_SCALE`.
|
||||
//! CIE 1960 (u, v). Tint describes the **correction**: positive adds magenta, negative adds green.
|
||||
//! A positive correction neutralises a green illuminant above the locus, so its source white uses
|
||||
//! `Duv = tint / TINT_SCALE`. Bradford adaptation removes that source cast.
|
||||
|
||||
use crate::{Mat3, RgbSpace, Xy, bradford};
|
||||
|
||||
|
|
@ -65,11 +67,12 @@ fn locus_normal(t: f64) -> (f64, f64) {
|
|||
if ny < 0.0 { (-nx, -ny) } else { (nx, ny) }
|
||||
}
|
||||
|
||||
/// Chromaticity of the white point for temperature `t` (K) and `tint`.
|
||||
/// Source illuminant chromaticity for temperature `t` (K) and correction `tint`.
|
||||
/// Positive tint means a green source white, whose correction adds magenta to the image.
|
||||
pub fn temp_tint_to_xy(t: f64, tint: f64) -> Xy {
|
||||
let (u, v) = locus_uv(t);
|
||||
let (nu, nv) = locus_normal(t);
|
||||
let duv = -tint / TINT_SCALE;
|
||||
let duv = tint / TINT_SCALE;
|
||||
uv_to_xy(u + nu * duv, v + nv * duv)
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +107,7 @@ pub fn xy_to_temp_tint(p: Xy) -> (f64, f64) {
|
|||
let (lu, lv) = locus_uv(t);
|
||||
let (nu, nv) = locus_normal(t);
|
||||
let duv = (u - lu) * nu + (v - lv) * nv;
|
||||
(t, -duv * TINT_SCALE)
|
||||
(t, duv * TINT_SCALE)
|
||||
}
|
||||
|
||||
/// Matrix (linear RGB in `space` → same space) that white-balances a scene lit by `src` so that it
|
||||
|
|
@ -141,10 +144,10 @@ mod tests {
|
|||
let warm = temp_tint_to_xy(3000.0, 0.0);
|
||||
let cool = temp_tint_to_xy(9000.0, 0.0);
|
||||
assert!(warm.x > cool.x);
|
||||
// positive tint is magenta: lower v
|
||||
// A positive magenta correction specifies a greener source illuminant: higher v.
|
||||
let (_, v0) = xy_to_uv(temp_tint_to_xy(5000.0, 0.0));
|
||||
let (_, v1) = xy_to_uv(temp_tint_to_xy(5000.0, 50.0));
|
||||
assert!(v1 < v0);
|
||||
assert!(v1 > v0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ impl WbMode {
|
|||
pub struct WhiteBalance {
|
||||
pub mode: WbMode,
|
||||
pub temp: f64,
|
||||
/// Correction direction: negative adds green, positive adds magenta.
|
||||
pub tint: f64,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ impl SettingsHashes {
|
|||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 11;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 12;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
|
|
|
|||
|
|
@ -639,3 +639,24 @@ fn output_spaces_match() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_directions_match_cpu_for_raw_and_rendered_sources() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let src = Arc::new(Rgb32f::filled(64, 64, [0.18; 3]));
|
||||
for info in [
|
||||
SourceInfo::default(),
|
||||
SourceInfo { raw: true, relative_wb: true, ..Default::default() },
|
||||
SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() },
|
||||
] {
|
||||
for delta in [-50.0, 50.0] {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.wb.mode = WbMode::Custom;
|
||||
s.wb.temp = info.as_shot_temp;
|
||||
s.wb.tint = info.as_shot_tint + delta;
|
||||
check("tint direction", &src, &info, &s, &RenderRequest::fit(64, 64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,3 +191,28 @@ mod bw_tests {
|
|||
assert_eq!(auto_bw_mix(&grey, &SourceInfo::default(), &DevelopSettings::default()), [0.0; 8]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tint_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn auto_wb_and_picker_correct_green_with_positive_tint() {
|
||||
// The picker delegates a sampled patch to this same auto_wb implementation.
|
||||
for (rgb, sign) in [([0.18, 0.24, 0.18], 1.0), ([0.24, 0.18, 0.24], -1.0)] {
|
||||
let img = Rgb32f::filled(16, 16, rgb);
|
||||
let info = SourceInfo { raw: true, relative_wb: true, ..Default::default() };
|
||||
let (temp, tint) = auto_wb(&img, &info);
|
||||
assert!(tint * sign > 0.0, "{rgb:?}: temp {temp}, tint {tint}");
|
||||
let mut s = DevelopSettings::default();
|
||||
s.wb.mode = lightcraft_develop::WbMode::Custom;
|
||||
s.wb.temp = temp;
|
||||
s.wb.tint = tint;
|
||||
let mut corrected = img;
|
||||
crate::local::white_balance(&mut corrected, &info, &s);
|
||||
let p = corrected.get(0, 0);
|
||||
let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]);
|
||||
assert!(spread < 0.003, "{rgb:?} -> {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -330,3 +330,28 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() {
|
|||
assert!(blue(&pro) > 0);
|
||||
assert!(red(&pro) < n, "ProPhoto holds more than sRGB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_negative_is_green_and_positive_is_magenta() {
|
||||
use lightcraft_develop::WbMode;
|
||||
let src = Rgb32f::filled(16, 16, [0.18; 3]);
|
||||
// Rendered images, uncalibrated RAW and calibrated RAW with a nonzero As Shot tint.
|
||||
for info in [
|
||||
SourceInfo::default(),
|
||||
SourceInfo { raw: true, relative_wb: true, ..Default::default() },
|
||||
SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() },
|
||||
] {
|
||||
let mut s = DevelopSettings::default();
|
||||
let neutral = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8);
|
||||
assert!((neutral[0] as i16 - neutral[1] as i16).abs() <= 1);
|
||||
assert!((neutral[2] as i16 - neutral[1] as i16).abs() <= 1);
|
||||
s.wb.mode = WbMode::Custom;
|
||||
s.wb.temp = info.as_shot_temp;
|
||||
for delta in [-50.0, 50.0] {
|
||||
s.wb.tint = info.as_shot_tint + delta;
|
||||
let p = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8);
|
||||
let magenta = (p[0] as f64 + p[2] as f64) / 2.0 - p[1] as f64;
|
||||
assert!(magenta * delta > 100.0, "delta {delta} must follow the green/magenta track, got {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) |
|
||||
| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) |
|
||||
| E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) |
|
||||
| F. Edit panel — global adjustments (EDIT) | 41 | 2 | 5 | 1 | 27/28 (96%) | 13/14 (93%) |
|
||||
| G. Profiles (PROF) | 6 | 2 | 3 | 0 | 3/4 (75%) | 2/3 (67%) |
|
||||
| H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) |
|
||||
| I. Remove / healing (REM) | 7 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) |
|
||||
|
|
@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
|
||||
| Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) |
|
||||
| **Total** | 389 | 34 | 86 | 37 | 194/200 (97%) | 139/149 (93%) |
|
||||
| **Total** | 388 | 35 | 86 | 37 | 193/200 (97%) | 139/149 (93%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 509 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 41.6% of 160.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.7%** of 509 in-scope rows — P0 98.2% of 200 · P1 95.6% of 149 · P2 41.6% of 160.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
|
|
@ -66,7 +66,7 @@ Take the first one nobody is working on.
|
|||
3. **LR-IMP-CAMERA-COVERAGE** (P0): per-model verification; grow the CC0 corpus and fix per-model bugs (like the CR2
|
||||
colour-filter layout, fixed in #85 by reading the file's own tag).
|
||||
4. **LR-BEHAV-RENDER-FIDELITY** (P1): a side-by-side fidelity suite against Lightroom renders (kept local in `plan/`),
|
||||
then tune tone, highlights, texture/clarity/dehaze, NR and sharpening against it.
|
||||
then tune tone, highlights, texture/clarity/dehaze, NR and sharpening against it. Global Tint direction has isolated regression coverage; review existing-edit compatibility before landing its corrected sign.
|
||||
5. **LR-EDIT-OPTICS-PROFILE** (P1): a lens-profile database of our own (embedded DNG/maker corrections work today).
|
||||
6. **AI masks and Enhance** (LR-MASK-SUBJECT / SKY / PEOPLE / OBJECTS, LR-EDIT-DETAIL-DENOISE, SUPERRES, LENSBLUR):
|
||||
Object and Describe masks run SAM 3 in pure Rust (`crates/segment`); the weights (SAM License) are never bundled:
|
||||
|
|
@ -234,7 +234,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | |
|
||||
| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick` | no magnified loupe while picking |
|
||||
| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp` | relative scale for non-raw in the UI |
|
||||
| LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint` | |
|
||||
| LR-EDIT-COLOR-TINT | Tint | P0 | 🟡 | `ctl:wb.tint`, `docs/tint-direction.md` | Negative/left adds green and positive/right magenta; CPU/GPU regression tests. Existing-edit/preset/XMP migration policy needs review before landing |
|
||||
| LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | |
|
||||
| LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | |
|
||||
| LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
# Global Tint direction: proposed correction and compatibility
|
||||
|
||||
This change is under review. Negative/left Tint should add green; positive/right should add magenta. It is a global white-balance change, not a Nikon-specific decoder fix.
|
||||
|
||||
## Reproducible evidence
|
||||
|
||||
The existing UI already declares `Track::Tint` as green → magenta (`crates/develop/src/controls.rs`) and paints a green left endpoint and purple right endpoint (`crates/ui-egui/src/widgets.rs`). Local mask tint also implements the correction direction: positive tint reduces green (`crates/pipeline/src/finish.rs`). The old global conversion contradicts both.
|
||||
|
||||
The synthetic `tint_negative_is_green_and_positive_is_magenta` regression renders an 18% neutral patch. Before the fix, setting global Tint to -50 produces sRGB `[129, 113, 128]`: red and blue exceed green, so the result is magenta. The test asserts the output direction for rendered sources, relative-WB RAW, and calibrated RAW with a nonzero As Shot reference. The complementary auto/picker test checks that correcting a green patch returns positive Tint and neutralises the patch.
|
||||
|
||||
Run the evidence without private samples:
|
||||
|
||||
```sh
|
||||
cargo test -p lightcraft-pipeline tint_ -- --nocapture
|
||||
cargo test -p lightcraft-gpu --test equivalence tint_directions -- --nocapture
|
||||
cargo test -p lightcraft-color cct
|
||||
```
|
||||
|
||||
For an external behaviour reference, see Adobe's Lightroom Classic documentation, [Adjust image color and tone](https://helpx.adobe.com/lightroom-classic/help/tone-control-adjustment.html), in the white-balance/Tint section. The automated documentation fetch returned HTTP 403 during this split, so this PR's directly verified evidence is the existing UI convention and synthetic rendered pixels above; it does not claim a new observed Lightroom comparison or copy any Adobe asset/profile.
|
||||
|
||||
## Why the sign changes
|
||||
|
||||
`temp_tint_to_xy` describes the **source illuminant** removed by Bradford adaptation. To add magenta to the output, the source white must be greener (positive Duv), not more magenta. Therefore the correction convention requires `Duv = +tint / TINT_SCALE`, and the inverse conversion must return `tint = +Duv * TINT_SCALE`.
|
||||
|
||||
The CPU and GPU consume the same `wb_matrix_for` result. RAW white-point inference, auto WB and the picker use the inverse conversion. Both directions change together; local mask tint and calibration shadow tint are unchanged. The render-cache version increases to invalidate stale output.
|
||||
|
||||
## Existing edits: review required
|
||||
|
||||
This changes the rendering meaning of saved nonzero global Tint values, including catalog edits, presets, history and imported XMP. No values are silently rewritten and no automatic migration is included. An old Auto WB result stored as numeric settings is also an existing edit; rerunning Auto computes values in the new convention.
|
||||
|
||||
As Shot continues to apply an identity adjustment. For a source whose As Shot tint is zero, negating the previous custom Tint reproduces its previous appearance. This is not a universal migration: calibrated RAW can retain a nonzero, old-sign reference tint in catalog metadata, cached source information or smart previews. Those references and their provenance need consideration alongside the edit. Existing XMP may originate from another application using the intended correction convention, so indiscriminately negating all imported values is also wrong.
|
||||
|
||||
Before merging, decide whether to version/migrate native saved edits and reference metadata, preserve a legacy rendering mode, or accept an explicitly documented rendering change. Keep this PR a draft until that policy is reviewed. If another render-cache change lands first, rebase and increment the cache version again.
|
||||
Loading…
Reference in New Issue