diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd2d9e3e..826d0e09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,14 @@ jobs: # doctests, which `cargo llvm-cov` cannot instrument on stable. - name: Clippy run: mise run lint + # `mise run lint` cannot reach the real-camera DNG conformance tier: it is excluded from the + # workspace and nothing depends on it, so no workspace command compiles it, and a gamut-dng + # API change breaks it unnoticed until the extended run on master. Its expensive half — the + # Adobe DNG SDK — is already built by this job for gamut-dng's own dev-dependency on + # `gamut-dng-oracle`, so what is added here is the tier's own Rust targets. `check` needs no + # corpus, so this job does not grow the ~178 MiB fetch that `test-dng-real` requires. + - name: Real-DNG conformance tier compiles + run: mise run check-dng-real - name: gamut-ffi feature sync run: mise run check-ffi-features - name: gamut-ffi header sync diff --git a/mise.toml b/mise.toml index 0f36cd53..7aeca4d7 100644 --- a/mise.toml +++ b/mise.toml @@ -151,6 +151,17 @@ run = "git submodule update --init --checkout third_party/gamut-dng-samples" description = "Validate gamut-dng against real camera DNGs (issue #174; needs fetch-dng-samples)" run = "cargo test --manifest-path tooling/gamut-dng-real-conformance/Cargo.toml" +# The compile half of the tier above, and the only part of it the per-PR lane can afford. That +# crate is excluded from the workspace *and* nothing depends on it — it is invoked by manifest +# path — so `clippy --workspace --all-targets` and `test --workspace` never build it, and a +# gamut-dng API change can break it while every per-PR gate stays green (#353 removed +# `DecodedDng::exif_extra` and only master's extended run noticed, at compile time, before a +# single sample was decoded). `check` needs no corpus, which is what makes this cheap enough for +# the lint job; the corpus-backed assertions stay in `test-dng-real`. +[tasks.check-dng-real] +description = "Compile the real-camera DNG conformance tier (no corpus needed)" +run = "cargo check --manifest-path tooling/gamut-dng-real-conformance/Cargo.toml --all-targets" + # Doctests only. On stable, `cargo llvm-cov` cannot instrument doctests (that needs nightly), so # the coverage gate — which CI uses as its green-test gate — silently skips them. This task is the # missing slice: CI's lint lane runs it, reusing the `--all-targets --all-features` build it just diff --git a/tooling/gamut-dng-real-conformance/examples/probe.rs b/tooling/gamut-dng-real-conformance/examples/probe.rs index b2f599ac..fb51f82c 100644 --- a/tooling/gamut-dng-real-conformance/examples/probe.rs +++ b/tooling/gamut-dng-real-conformance/examples/probe.rs @@ -8,7 +8,9 @@ //! --example probe -- third_party/gamut-dng-samples/apple/iphone-12-pro/IMG_1361.DNG //! ``` -use gamut_dng::{DngDecoder, DngRewrite, SubImageData, deconstruct}; +use gamut_dng::{ + ColorProfileInfo, DngDecoder, DngRewrite, HsvTable, NoiseProfile, SubImageData, deconstruct, +}; fn main() { for path in std::env::args().skip(1) { @@ -97,11 +99,20 @@ fn report_decode(data: &[u8]) { ), None => println!(" profile : none (no colour calibration in the file)"), } + report_color_profile(decoded.color_profile.as_ref()); + report_noise_profile(decoded.noise_profile.as_ref()); + // `exif` counts the EXIF sub-IFD's own entries, not an extras list: the whole directory is + // carried inside `DngMetadata::exif`, so there is no unmodelled EXIF residue to count. println!( " extras : ifd0={} raw={} exif={} gain_map={} gain_map2={}", decoded.ifd0_extra.len(), decoded.raw_extra.len(), - decoded.exif_extra.len(), + decoded + .metadata + .exif + .as_ref() + .and_then(|e| e.exif_ifd()) + .map_or(0, |ifd| ifd.fields().len()), decoded.gain_table_map.is_some(), decoded.gain_table_map2.is_some(), ); @@ -136,6 +147,79 @@ fn report_decode(data: &[u8]) { } } +/// The camera-profile colour tags `CameraProfile` does not model: the rendering tables and curve, +/// the profile exposure offset, the DNG 1.6 third calibration set and the reduction matrices. +/// +/// These reach a caller typed rather than as `ifd0_extra` residue, so a file's colour rendering is +/// visible here without reading raw tag numbers back by hand. +fn report_color_profile(info: Option<&ColorProfileInfo>) { + let Some(info) = info else { + println!(" color_profile : none (no rendering tables, curve or third calibration set)"); + return; + }; + println!( + " color_profile : hue_sat=[{}, {}, {}] look={} tone_curve={} exposure_offset={:?}", + hsv_table(info.hue_sat_map1.as_ref()), + hsv_table(info.hue_sat_map2.as_ref()), + hsv_table(info.hue_sat_map3.as_ref()), + hsv_table(info.look_table.as_ref()), + info.tone_curve + .as_ref() + .map_or_else(|| "-".to_string(), |c| format!("{} pts", c.len())), + info.baseline_exposure_offset, + ); + println!( + " third calibration set: matrix={} illuminant={:?} calibration={} forward={}", + info.color_matrix3.is_some(), + info.calibration_illuminant3, + info.camera_calibration3.is_some(), + info.forward_matrix3.is_some(), + ); + println!( + " reduction matrices: 1={} 2={} 3={}", + matrix_terms(info.reduction_matrix1.as_deref()), + matrix_terms(info.reduction_matrix2.as_deref()), + matrix_terms(info.reduction_matrix3.as_deref()), + ); +} + +/// One hue/saturation/value table's divisions and encoding, or `-` when the file carries none. +fn hsv_table(table: Option<&HsvTable>) -> String { + table.map_or_else( + || "-".to_string(), + |t| { + format!( + "{}x{}x{} {:?}", + t.hue_divisions, t.saturation_divisions, t.value_divisions, t.encoding + ) + }, + ) +} + +/// A reduction matrix's term count, or `-` when absent. Its shape is `3 x ColorPlanes`, so the +/// count is three times the number of colour planes it reduces from. +fn matrix_terms(matrix: Option<&[f64]>) -> String { + matrix.map_or_else(|| "-".to_string(), |m| format!("{} terms", m.len())) +} + +/// The sensor's noise model, read from the raw IFD (the spec's home for it) with an IFD 0 fallback. +fn report_noise_profile(profile: Option<&NoiseProfile>) { + let Some(profile) = profile else { + println!(" noise_profile : none"); + return; + }; + let models: Vec = profile + .planes + .iter() + .map(|m| format!("scale={:.3e} offset={:.3e}", m.scale, m.offset)) + .collect(); + println!( + " noise_profile : planes={} [{}]", + profile.planes.len(), + models.join(", "), + ); +} + /// The preserving rewrite round-trip. fn report_rewrite(data: &[u8]) { let opened = match DngRewrite::open(data) {