mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
commit
a628d5bc38
30 files changed
+1840
-101
No files matched your search
Generated
+12
@@ -3019,6 +3019,7 @@ dependencies = [
|
||||
"lightcraft-develop",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"proptest",
|
||||
"rayon",
|
||||
@@ -3055,6 +3056,7 @@ name = "lightcraft-raw"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
@@ -3068,6 +3070,7 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
"zune-jpegxl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6817,6 +6820,15 @@ dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpegxl"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.15.0"
|
||||
|
||||
@@ -64,6 +64,7 @@ tiff = "0.11"
|
||||
image-webp = "0.2"
|
||||
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
|
||||
jxl-oxide = { version = "0.12", default-features = false }
|
||||
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
|
||||
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
|
||||
|
||||
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
|
||||
|
||||
+5
-4
@@ -25,7 +25,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
||||
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
|
||||
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
|
||||
@@ -124,7 +124,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel
|
||||
| Work package | Tracker rows | Agent-hours | Risk |
|
||||
|---|---|---:|---|
|
||||
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
|
||||
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
|
||||
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
|
||||
@@ -174,7 +174,8 @@ private 3:2 & 4:3 examples verified through native rendering & full-resolution 1
|
||||
CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations.
|
||||
|
||||
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
|
||||
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
|
||||
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files,
|
||||
lossy ones decode but no real file has verified them), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
|
||||
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
|
||||
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
|
||||
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
|
||||
@@ -185,7 +186,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
|
||||
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
|
||||
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
|
||||
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
|
||||
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
|
||||
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
|
||||
+167
-4
@@ -245,7 +245,8 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
let camera_look = crate::camera_preview::fit_preview(&raw, &bytes, &t);
|
||||
// the source's segmentation mattes (DNG semantic masks), read while the preview is fitted
|
||||
let (camera_look, mattes) = rayon::join(|| crate::camera_preview::fit_preview(&raw, &bytes, &t), || dng_mattes(&bytes, &raw));
|
||||
drop(bytes);
|
||||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||||
@@ -306,9 +307,18 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
}
|
||||
let (temp, tint) = xy_to_temp_tint(xy);
|
||||
let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback;
|
||||
// White balance re-evaluates the file's own colour model (when it has one and no
|
||||
// file-local look matrix sits on top of it)
|
||||
let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| {
|
||||
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
|
||||
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
|
||||
});
|
||||
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
|
||||
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
|
||||
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone }));
|
||||
return Ok((
|
||||
img,
|
||||
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_color, camera_tone, mattes },
|
||||
));
|
||||
}
|
||||
let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?;
|
||||
drop(bytes);
|
||||
@@ -317,6 +327,42 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
||||
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), SourceInfo::default()))
|
||||
}
|
||||
|
||||
/// The semantic masks of a DNG that AI masks understand, over the developed image (default crop,
|
||||
/// oriented like it). Person mattes without a confidently selected pixel are left out (the
|
||||
/// Subject mask then falls back to its heuristic); a sky matte counts even when empty (no sky).
|
||||
fn dng_mattes(bytes: &[u8], raw: &lightcraft_raw::RawImage) -> Option<Arc<lightcraft_pipeline::masks::Mattes>> {
|
||||
use lightcraft_pipeline::masks::{MatteKind, Mattes};
|
||||
if raw.format != lightcraft_raw::RawFormat::Dng {
|
||||
return None;
|
||||
}
|
||||
let mut mattes = Mattes::default();
|
||||
for m in lightcraft_raw::semantic_masks(bytes) {
|
||||
let Some(kind) = matte_kind(&m.name) else { continue };
|
||||
let Some(img) = m.developed(raw.active_area, raw.crop) else { continue };
|
||||
if kind != MatteKind::Sky && !img.data.iter().any(|&v| v >= 128) {
|
||||
continue;
|
||||
}
|
||||
mattes.push(kind, img.into_oriented(raw.orientation));
|
||||
}
|
||||
(!mattes.is_empty()).then(|| Arc::new(mattes))
|
||||
}
|
||||
|
||||
/// What a semantic mask selects, by its `SemanticName`: Apple's (iPhone ProRAW) are named after
|
||||
/// their auxiliary image types, `urn:com:apple:photo:<year>:aux:<type>`. Other names: `None`.
|
||||
fn matte_kind(name: &str) -> Option<lightcraft_pipeline::masks::MatteKind> {
|
||||
use lightcraft_pipeline::masks::MatteKind::*;
|
||||
let kind = name.strip_prefix("urn:com:apple:photo:")?.rsplit(':').next()?;
|
||||
Some(match kind {
|
||||
"semanticskymatte" => Sky,
|
||||
"semanticskinmatte" => Skin,
|
||||
"semantichairmatte" => Hair,
|
||||
"semanticteethmatte" => Teeth,
|
||||
"semanticglassesmatte" => Glasses,
|
||||
"portraiteffectsmatte" => Person,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A DNG `ProfileToneCurve` (linear in, linear out, 1.0 = white after exposure compensation) as the
|
||||
/// finish stage's camera tone curve: 32 knots, log-spaced over 12 stops below white; above white
|
||||
/// the camera tone's shoulder continues it.
|
||||
@@ -684,8 +730,12 @@ mod tests {
|
||||
assert!(info.camera_tone.is_none());
|
||||
// a map that removes all saturation, and a tone curve
|
||||
let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false };
|
||||
raw.color.profile =
|
||||
ProfileLook { hue_sat_map: [Some(grey), None], look_table: None, tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]) };
|
||||
raw.color.profile = ProfileLook {
|
||||
hue_sat_map: [Some(grey), None],
|
||||
look_table: None,
|
||||
tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]),
|
||||
gain_table_map: None,
|
||||
};
|
||||
let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||||
let (after, info) = load_bytes(&with, 64).unwrap();
|
||||
assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after));
|
||||
@@ -703,6 +753,119 @@ mod tests {
|
||||
assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before));
|
||||
}
|
||||
|
||||
/// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right
|
||||
/// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky
|
||||
/// heuristic would never look) is used; a matte with an unknown name is ignored.
|
||||
#[test]
|
||||
fn dng_sky_matte_drives_the_sky_mask() {
|
||||
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
|
||||
use lightcraft_pipeline::{RenderRequest, render};
|
||||
use lightcraft_tiff::tags::{self as t, photometric};
|
||||
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
|
||||
let (w, h) = (16u32, 8u32);
|
||||
let image = |ifd: &mut IfdBuilder, w: u32, h: u32, cpp: u16, bits: u16, data: Vec<u8>| {
|
||||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||||
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; cpp as usize]));
|
||||
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![cpp]));
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![data] });
|
||||
};
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW]));
|
||||
image(&mut raw, w, h, 3, 16, 12000u16.to_le_bytes().repeat((w * h * 3) as usize));
|
||||
let matte = |name: &str, right: bool| {
|
||||
let mut m = IfdBuilder::new();
|
||||
m.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![t::SUBFILE_SEMANTIC_MASK]));
|
||||
m.set(t::PHOTOMETRIC, Value::Short(vec![photometric::MASK]));
|
||||
m.set(t::SEMANTIC_NAME, Value::Ascii(name.into()));
|
||||
image(&mut m, w / 2, h / 2, 1, 8, (0..w / 2 * h / 2).map(|i| if (i % (w / 2) >= w / 4) == right { 255 } else { 0 }).collect());
|
||||
m
|
||||
};
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
|
||||
ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0]));
|
||||
ifd0.set(t::MAKE, Value::Ascii("Apple".into()));
|
||||
ifd0.set(t::ORIENTATION, Value::Short(vec![6]));
|
||||
ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)]));
|
||||
ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21]));
|
||||
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB]));
|
||||
image(&mut ifd0, 4, 2, 3, 8, vec![128; 24]);
|
||||
ifd0.add_sub_ifd(raw);
|
||||
ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticskymatte", true));
|
||||
ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticsomethingelse", false));
|
||||
let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap();
|
||||
|
||||
let (img, info) = load_bytes(&bytes, 64).unwrap();
|
||||
assert_eq!((img.width, img.height), (8, 16));
|
||||
assert!(info.mattes.is_some());
|
||||
let sky = Mask {
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }],
|
||||
adjust: LocalAdjustments { exposure: -3.0, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let s = DevelopSettings { masks: vec![sky], ..Default::default() };
|
||||
let req = RenderRequest::fit(8, 16);
|
||||
let green = |s: &DevelopSettings, info: &SourceInfo, y: usize| render(&img, info, s, &req).image.get(4, y)[1] as i32;
|
||||
let plain = DevelopSettings::default();
|
||||
assert_eq!(green(&s, &info, 2), green(&plain, &info, 2), "no sky at the top");
|
||||
assert!(green(&s, &info, 13) + 40 < green(&plain, &info, 13), "the sky at the bottom is darkened");
|
||||
// without the matte the heuristic looks at the top of the frame instead
|
||||
let heuristic = SourceInfo { mattes: None, ..info.clone() };
|
||||
assert!(green(&s, &heuristic, 2) < green(&plain, &heuristic, 2));
|
||||
assert_eq!(green(&s, &heuristic, 13), green(&plain, &heuristic, 13));
|
||||
}
|
||||
|
||||
/// A DNG's white balance is re-evaluated through its own colour model: the decoder hands the
|
||||
/// pipeline the file's colour tags (without the profile look, applied at load) and the white
|
||||
/// the pixels were developed for, and a custom white balance renders a neutral differently
|
||||
/// from the Bradford adaptation it replaces.
|
||||
#[test]
|
||||
fn dng_white_balance_uses_the_camera_colour_model() {
|
||||
use lightcraft_pipeline::{RenderRequest, render};
|
||||
let dng = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||||
let raw = lightcraft_raw::decode(&dng).unwrap();
|
||||
let (img, info) = load_bytes(&dng, 64).unwrap();
|
||||
let cc = info.camera_color.as_ref().expect("a DNG with a colour matrix");
|
||||
assert_eq!(cc.developed_for, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
assert!(cc.tags.profile.is_empty());
|
||||
assert_eq!(cc.tags.color_matrix, raw.color.color_matrix);
|
||||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||||
(s.wb.mode, s.wb.temp, s.wb.tint) = (lightcraft_develop::WbMode::Custom, 3000.0, 20.0);
|
||||
let req = RenderRequest::fit(32, 32);
|
||||
let camera = render(&img, &info, &s, &req).image;
|
||||
let adapted = render(&img, &SourceInfo { camera_color: None, ..info.clone() }, &s, &req).image;
|
||||
assert_ne!(camera.data, adapted.data);
|
||||
}
|
||||
|
||||
/// Apple ProRAW: like Lightroom Classic, the default render ignores the file's
|
||||
/// `ProfileGainTableMap` (Apple's local tone mapping); a map that would double every pixel
|
||||
/// changes nothing.
|
||||
#[test]
|
||||
fn dng_gain_table_map_is_not_rendered() {
|
||||
use lightcraft_raw::gaintable::GainTableMap;
|
||||
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||||
let mut raw = lightcraft_raw::decode(&plain).unwrap();
|
||||
let (before, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap();
|
||||
raw.color.profile.gain_table_map = Some(GainTableMap {
|
||||
points_v: 1,
|
||||
points_h: 1,
|
||||
points_n: 1,
|
||||
spacing_v: 1.0,
|
||||
spacing_h: 1.0,
|
||||
origin_v: 0.0,
|
||||
origin_h: 0.0,
|
||||
weights: [0.2, 0.2, 0.2, 0.2, 0.2],
|
||||
gamma: 1.0,
|
||||
gains: vec![2.0],
|
||||
});
|
||||
let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||||
assert!(lightcraft_raw::decode(&with_map).unwrap().color.profile.gain_table_map.is_some(), "the map is kept");
|
||||
let (after, _) = load_bytes(&with_map, 64).unwrap();
|
||||
assert_eq!(before.data, after.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_raw_falls_back_to_embedded_preview() {
|
||||
let b = cr3_with_preview(48, 32);
|
||||
|
||||
+12
-10
@@ -143,7 +143,7 @@ impl DecodedSource {
|
||||
/// What to render these pixels against: the decoder's facts, else `header` (the catalog's)
|
||||
/// with any stored camera tone curve.
|
||||
pub fn info_or(&self, header: SourceInfo) -> SourceInfo {
|
||||
self.info.unwrap_or(SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header })
|
||||
self.info.clone().unwrap_or_else(|| SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,8 +253,9 @@ fn rendered_budget(share: usize) -> usize {
|
||||
RENDERED_MEM_BYTES.min(share / 4)
|
||||
}
|
||||
|
||||
fn source_bytes(img: &Rgb32f) -> usize {
|
||||
img.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>()
|
||||
fn source_bytes(s: &DecodedSource) -> usize {
|
||||
let mattes = s.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes());
|
||||
s.image.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>() + mattes
|
||||
}
|
||||
|
||||
impl MediaCache {
|
||||
@@ -319,8 +320,8 @@ impl MediaCache {
|
||||
let Some((tick, which)) = candidates.into_iter().flatten().min() else { break };
|
||||
let freed = match which {
|
||||
0 => self.thumbs.pop_oldest(),
|
||||
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1.image)),
|
||||
2 => self.full.take().map(|e| source_bytes(&e.1.image)),
|
||||
1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1)),
|
||||
2 => self.full.take().map(|e| source_bytes(&e.1)),
|
||||
_ => self.rendered.evict_oldest(),
|
||||
};
|
||||
match freed {
|
||||
@@ -372,7 +373,7 @@ impl MediaCache {
|
||||
let tick = lightcraft_preview::next_tick();
|
||||
match level {
|
||||
SourceLevel::Thumb => {
|
||||
let cost = source_bytes(&img.image);
|
||||
let cost = source_bytes(&img);
|
||||
self.thumbs.insert(id, img, cost);
|
||||
}
|
||||
SourceLevel::Preview => {
|
||||
@@ -415,8 +416,8 @@ impl MediaCache {
|
||||
/// Decoded sources held: (thumbnail level, preview level, full size).
|
||||
pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) {
|
||||
use crate::memory::Usage;
|
||||
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1.image)).sum());
|
||||
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1.image))).unwrap_or_default();
|
||||
let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1)).sum());
|
||||
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1))).unwrap_or_default();
|
||||
(Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full)
|
||||
}
|
||||
|
||||
@@ -1221,20 +1222,21 @@ mod tests {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let mut job = s.render_job(id, 64, 64, false, true).unwrap();
|
||||
let loaded_info = info.clone();
|
||||
job.source = SourceRef::File {
|
||||
path: "synthetic.arw".into(),
|
||||
max_edge: 64,
|
||||
loader: Some(Arc::new(move |_, _| {
|
||||
let mut image = Rgb32f::new(64, 64);
|
||||
image.data.fill([0.1; 3]);
|
||||
Ok((image, info))
|
||||
Ok((image, loaded_info.clone()))
|
||||
})),
|
||||
fallback: None,
|
||||
};
|
||||
job.info = SourceInfo::default(); // Header facts cannot override decoder facts.
|
||||
job.settings = Arc::new(DevelopSettings::default());
|
||||
let r = job.clone().run();
|
||||
assert_eq!(r.loaded.as_ref().unwrap().info, Some(info));
|
||||
assert_eq!(r.loaded.as_ref().unwrap().info.as_ref(), Some(&info));
|
||||
let expected = lightcraft_pipeline::render(&r.loaded.as_ref().unwrap().image, &info, &job.settings, &job.request);
|
||||
assert_eq!(r.rendered.as_ref().unwrap().image.data, expected.image.data);
|
||||
s.accept(&r);
|
||||
|
||||
@@ -717,7 +717,7 @@ pub fn render(
|
||||
Err(m) => {
|
||||
let img = host.lin.get_or_insert_with(|| Arc::new(cx.read_rgb(&lin, w, h))).clone();
|
||||
let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone();
|
||||
lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32)
|
||||
lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32, plan.mattes.as_deref())
|
||||
}
|
||||
});
|
||||
lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref());
|
||||
@@ -1018,7 +1018,7 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
|
||||
// no kernel: evaluate on the CPU
|
||||
let img = host.lin.get_or_insert_with(|| Arc::new(cx.read_rgb(lin, w, h))).clone();
|
||||
let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone();
|
||||
let mut v = lightcraft_pipeline::masks::shape_alpha(&comp.shape, frame, w, h, &img, &l, ev);
|
||||
let mut v = lightcraft_pipeline::masks::shape_alpha(&comp.shape, frame, w, h, &img, &l, ev, plan.mattes.as_deref());
|
||||
if comp.invert {
|
||||
v.data.iter_mut().for_each(|x| *x = 1.0 - *x);
|
||||
}
|
||||
|
||||
@@ -29,10 +29,16 @@ pub enum DngSamples {
|
||||
U16,
|
||||
}
|
||||
|
||||
/// The DNG colour tags for frames of `color`.
|
||||
/// The DNG colour tags for frames of `color`. A source's gain table map is dropped: it is
|
||||
/// positioned on that one capture's active area, which a merged image (aligned, oriented,
|
||||
/// stitched, cropped) no longer shares.
|
||||
pub fn color_data(color: &FrameColor) -> ColorData {
|
||||
match color {
|
||||
FrameColor::Camera(c) => (**c).clone(),
|
||||
FrameColor::Camera(c) => {
|
||||
let mut c = (**c).clone();
|
||||
c.profile.gain_table_map = None;
|
||||
c
|
||||
}
|
||||
FrameColor::Linear { to_xyz_d50 } => {
|
||||
let to_xyz_d65 = bradford(D50, D65).mul(to_xyz_d50);
|
||||
let cm = to_xyz_d65.inverse().unwrap_or(Mat3::IDENTITY);
|
||||
@@ -45,6 +51,7 @@ pub fn color_data(color: &FrameColor) -> ColorData {
|
||||
as_shot_neutral: Some([1.0, 1.0, 1.0]),
|
||||
as_shot_white_xy: None,
|
||||
baseline_exposure: 0.0,
|
||||
baseline_sharpness: None,
|
||||
profile: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
lightcraft-develop = { workspace = true }
|
||||
lightcraft-raw = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
|
||||
@@ -120,10 +120,17 @@ pub fn auto_wb(src: &Rgb32f, info: &SourceInfo) -> (f64, f64) {
|
||||
return (info.as_shot_temp, info.as_shot_tint);
|
||||
}
|
||||
let avg = acc.map(|v| v / wsum);
|
||||
let xyz = REC2020.to_xyz().apply(avg);
|
||||
let shot = lightcraft_color::cct::temp_tint_to_xy(info.as_shot_temp, info.as_shot_tint);
|
||||
let seen = bradford(REC2020.white, shot).apply(xyz);
|
||||
let (t, tint) = xy_to_temp_tint(Xy::from_xyz(seen));
|
||||
// the white that makes `avg` neutral, through the same model the white balance renders with
|
||||
// (`local::wb_matrix_for`): the camera's own colour model, else a Bradford adaptation
|
||||
let camera = info.camera_color.as_deref().filter(|_| info.raw && !info.relative_wb);
|
||||
let seen = match camera.and_then(|cc| lightcraft_raw::color::neutral_white(&cc.tags, cc.developed_for, avg)) {
|
||||
Some(white) => white,
|
||||
None => {
|
||||
let shot = lightcraft_color::cct::temp_tint_to_xy(info.as_shot_temp, info.as_shot_tint);
|
||||
Xy::from_xyz(bradford(REC2020.white, shot).apply(REC2020.to_xyz().apply(avg)))
|
||||
}
|
||||
};
|
||||
let (t, tint) = xy_to_temp_tint(seen);
|
||||
(t.clamp(2000.0, 50000.0).round(), tint.clamp(-150.0, 150.0).round())
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,19 @@ use lightcraft_raster::{Histogram, Plane, Rgb32f, Rgba8, par_rows};
|
||||
|
||||
pub use tone::ToneMap;
|
||||
|
||||
/// A raw source's own colour model, for white balance: like Lightroom, a white balance
|
||||
/// re-evaluates the camera's colour matrices at the chosen white (camera-space white balance, DNG
|
||||
/// spec ch. 6) instead of adapting the as-shot rendering ([`local::wb_matrix_for`]).
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct CameraColor {
|
||||
/// The file's colour tags (its profile look left out: it is applied at load).
|
||||
pub tags: lightcraft_raw::ColorData,
|
||||
/// The white the source pixels were developed for.
|
||||
pub developed_for: lightcraft_color::Xy,
|
||||
}
|
||||
|
||||
/// Facts about the source the settings are interpreted against.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SourceInfo {
|
||||
/// Lens corrections embedded in the file (DNG opcodes), relative to the EXIF-oriented source.
|
||||
pub lens: Option<lightcraft_develop::EmbeddedLens>,
|
||||
@@ -66,12 +77,26 @@ pub struct SourceInfo {
|
||||
pub as_shot_tint: f64,
|
||||
/// No measured camera illuminant: WB adjustments are relative to the camera's rendered look.
|
||||
pub relative_wb: bool,
|
||||
/// The camera's colour model (raw files with a colour matrix); `None`: white balance adapts
|
||||
/// the developed pixels (Bradford, linear Rec.2020).
|
||||
pub camera_color: Option<Arc<CameraColor>>,
|
||||
pub camera_tone: Option<tone::CameraTone>,
|
||||
/// Segmentation mattes stored in the file (DNG semantic masks): AI masks use them.
|
||||
pub mattes: Option<Arc<masks::Mattes>>,
|
||||
}
|
||||
|
||||
impl Default for SourceInfo {
|
||||
fn default() -> Self {
|
||||
Self { raw: false, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None, relative_wb: false, camera_tone: None }
|
||||
Self {
|
||||
raw: false,
|
||||
as_shot_temp: 6500.0,
|
||||
as_shot_tint: 0.0,
|
||||
lens: None,
|
||||
relative_wb: false,
|
||||
camera_color: None,
|
||||
camera_tone: None,
|
||||
mattes: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +357,8 @@ pub struct Plan<'a> {
|
||||
/// [`spots::window_for_reads`]); this is the requested window inside the rendered one, which
|
||||
/// the result is cut to.
|
||||
pub keep: Option<PixelWindow>,
|
||||
/// The source's segmentation mattes ([`SourceInfo::mattes`]).
|
||||
pub mattes: Option<Arc<masks::Mattes>>,
|
||||
}
|
||||
|
||||
/// Long edge of the small render a windowed render estimates the whole frame's airlight from.
|
||||
@@ -392,7 +419,7 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R
|
||||
[d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits),
|
||||
src_long,
|
||||
));
|
||||
Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes, fixed_air, keep }
|
||||
Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes, fixed_air, keep, mattes: info.mattes.clone() }
|
||||
}
|
||||
|
||||
/// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal).
|
||||
@@ -474,7 +501,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
|
||||
Some(p) if p.key == lin_key => p,
|
||||
_ => local::Planes { key: lin_key, ..Default::default() },
|
||||
};
|
||||
let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes);
|
||||
let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes, plan.mattes.as_deref());
|
||||
if let Some(a) = plan.fixed_air {
|
||||
prep.air = a;
|
||||
}
|
||||
@@ -543,7 +570,7 @@ fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane>
|
||||
return Some(e.alpha.clone());
|
||||
}
|
||||
let ev = plan.settings.light.exposure as f32;
|
||||
Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev))
|
||||
Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref()))
|
||||
}
|
||||
|
||||
/// Convenience: render a before/after pair side by side is up to the UI; this renders "before"
|
||||
|
||||
@@ -22,13 +22,21 @@ pub fn white_balance(img: &mut Rgb32f, info: &SourceInfo, s: &DevelopSettings) {
|
||||
wb_gain(img, info, s, 1.0);
|
||||
}
|
||||
|
||||
/// The white-balance matrix (linear Rec.2020, luminance-preserving) for the settings, or `None`
|
||||
/// when the as-shot white is kept.
|
||||
/// The white-balance matrix (linear Rec.2020) for the settings, or `None` when the as-shot white
|
||||
/// is kept. A raw source with its camera colour model ([`crate::CameraColor`]) is re-developed for
|
||||
/// the new white in camera space, as Lightroom does (so a neutral under the chosen white renders
|
||||
/// neutral and saturated colours move as the camera's matrices say); other sources are adapted
|
||||
/// with Bradford, luminance-preserving.
|
||||
pub fn wb_matrix_for(info: &SourceInfo, s: &DevelopSettings) -> Option<[[f32; 3]; 3]> {
|
||||
let (t, tint) = effective_wb(info, s);
|
||||
if (t - info.as_shot_temp).abs() < 1e-6 && (tint - info.as_shot_tint).abs() < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
if let Some(cc) = info.camera_color.as_deref().filter(|_| info.raw && !info.relative_wb)
|
||||
&& let Some(m) = lightcraft_raw::color::rebalance(&cc.tags, cc.developed_for, temp_tint_to_xy(t, tint))
|
||||
{
|
||||
return Some(m.to_f32());
|
||||
}
|
||||
let set = wb_matrix(&REC2020, temp_tint_to_xy(t, tint));
|
||||
let shot = wb_matrix(&REC2020, temp_tint_to_xy(info.as_shot_temp, info.as_shot_tint));
|
||||
let m = set.mul(&shot.inverse().unwrap_or(lightcraft_color::Mat3::IDENTITY));
|
||||
@@ -279,7 +287,16 @@ pub fn plane_sigmas(s: &DevelopSettings, px_per_long: f64, q: Quality) -> PlaneS
|
||||
/// The spatial planes the per-pixel stage needs for `img` (white-balanced, before exposure),
|
||||
/// reusing whatever `planes` already holds for it. Missing planes are computed side by side (each
|
||||
/// one alone scales poorly: the guided filters work on small subsampled grids).
|
||||
pub(crate) fn prepare(img: Arc<Rgb32f>, s: &DevelopSettings, frame: &Frame, px_per_long: f64, q: Quality, planes: &mut Planes) -> Prepared {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn prepare(
|
||||
img: Arc<Rgb32f>,
|
||||
s: &DevelopSettings,
|
||||
frame: &Frame,
|
||||
px_per_long: f64,
|
||||
q: Quality,
|
||||
planes: &mut Planes,
|
||||
mattes: Option<&masks::Mattes>,
|
||||
) -> Prepared {
|
||||
let log_l = planes.log_l.get_or_insert_with(|| Arc::new(timed("log_l", || img.map(log_lum)))).clone();
|
||||
let PlaneSigmas { base: base_sigma, clarity: clarity_sigma, texture: texture_sigma, dark: dark_sigma, chroma: chroma_sigma } =
|
||||
plane_sigmas(s, px_per_long, q);
|
||||
@@ -324,7 +341,7 @@ pub(crate) fn prepare(img: Arc<Rgb32f>, s: &DevelopSettings, frame: &Frame, px_p
|
||||
None => (None, 1.0),
|
||||
};
|
||||
let ev = s.light.exposure as f32;
|
||||
let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l, ev));
|
||||
let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l, ev, mattes));
|
||||
Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long }
|
||||
}
|
||||
|
||||
|
||||
+239
-25
@@ -2,14 +2,96 @@
|
||||
//!
|
||||
//! Shapes are defined in normalized oriented-image coordinates; sizes are in "long-edge units".
|
||||
//! Components combine with Add (max), Subtract (a·(1−c)) and Intersect (a·c).
|
||||
//!
|
||||
//! AI shapes (Sky, Subject, Background, People) use the segmentation [`Mattes`] the source carries
|
||||
//! (DNG semantic masks, e.g. iPhone ProRAW's) when it has a matching one, else a heuristic.
|
||||
|
||||
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskOp, MaskShape, SegMask};
|
||||
use lightcraft_geom::Point;
|
||||
use lightcraft_raster::{Plane, Rgb32f};
|
||||
use lightcraft_geom::{Orientation, Point};
|
||||
use lightcraft_raster::{Image, Plane, Rgb32f};
|
||||
|
||||
use crate::for_rows;
|
||||
use crate::geometry::Frame;
|
||||
|
||||
/// What an embedded segmentation matte selects.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MatteKind {
|
||||
Sky,
|
||||
/// A whole person (all people in the photo).
|
||||
Person,
|
||||
Skin,
|
||||
Hair,
|
||||
Teeth,
|
||||
Glasses,
|
||||
}
|
||||
|
||||
/// Segmentation mattes that came with the source: 0..255 alpha planes, each covering the whole
|
||||
/// (EXIF-oriented) source image at its own resolution. Several mattes of one kind (e.g. one per
|
||||
/// person) are combined with max.
|
||||
#[derive(Clone, Default, PartialEq)]
|
||||
pub struct Mattes {
|
||||
mattes: Vec<(MatteKind, Image<u8>)>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Mattes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_list().entries(self.mattes.iter().map(|(k, m)| (k, m.width, m.height))).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Mattes {
|
||||
/// Add a matte (empty ones are ignored).
|
||||
pub fn push(&mut self, kind: MatteKind, matte: Image<u8>) {
|
||||
if matte.width > 0 && matte.height > 0 && matte.data.len() == matte.width * matte.height {
|
||||
self.mattes.push((kind, matte));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.mattes.is_empty()
|
||||
}
|
||||
|
||||
/// Memory held (bytes).
|
||||
pub fn bytes(&self) -> usize {
|
||||
self.mattes.iter().map(|(_, m)| m.data.len()).sum()
|
||||
}
|
||||
|
||||
fn of(&self, kinds: &[MatteKind]) -> Vec<&Image<u8>> {
|
||||
self.mattes.iter().filter(|(k, _)| kinds.contains(k)).map(|(_, m)| m).collect()
|
||||
}
|
||||
|
||||
/// The mattes a shape uses instead of its heuristic (empty: none fits).
|
||||
fn for_shape(&self, shape: &MaskShape) -> Vec<&Image<u8>> {
|
||||
use MatteKind::*;
|
||||
// the whole person: a person matte, else the union of the parts
|
||||
let person = || {
|
||||
let p = self.of(&[Person]);
|
||||
if p.is_empty() { self.of(&[Skin, Hair, Teeth, Glasses]) } else { p }
|
||||
};
|
||||
match shape {
|
||||
MaskShape::Sky => self.of(&[Sky]),
|
||||
MaskShape::Subject => person(),
|
||||
MaskShape::People { parts, .. } => {
|
||||
let wanted: Vec<MatteKind> = parts
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let p = p.to_ascii_lowercase();
|
||||
[("skin", Skin), ("hair", Hair), ("teeth", Teeth), ("glasses", Glasses)]
|
||||
.into_iter()
|
||||
.find(|(n, _)| p.contains(n))
|
||||
.map(|(_, k)| k)
|
||||
})
|
||||
.collect();
|
||||
let whole =
|
||||
parts.is_empty() || parts.iter().any(|p| matches!(p.to_ascii_lowercase().as_str(), "person" | "entire person" | "entireperson"));
|
||||
// parts without a matte (lips, clothes, …) keep the heuristic
|
||||
if whole { person() } else { self.of(&wanted) }
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Evaluated {
|
||||
/// The mask's id ([`Mask::id`]).
|
||||
pub id: u32,
|
||||
@@ -23,22 +105,24 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// The visible masks with components, evaluated in order.
|
||||
pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Vec<Evaluated> {
|
||||
/// The visible masks with components, evaluated in order (`mattes`: the source's, see [`Mattes`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32, mattes: Option<&Mattes>) -> Vec<Evaluated> {
|
||||
masks
|
||||
.iter()
|
||||
.filter(|m| m.visible && !m.components.is_empty())
|
||||
.map(|m| Evaluated { id: m.id, alpha: evaluate_one(m, frame, w, h, img, log_l, ev), adjust: m.adjust })
|
||||
.map(|m| Evaluated { id: m.id, alpha: evaluate_one(m, frame, w, h, img, log_l, ev, mattes), adjust: m.adjust })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The alpha plane of mask `m` (whether visible or not): its components combined, inverted and
|
||||
/// scaled by its amount.
|
||||
pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Plane {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32, mattes: Option<&Mattes>) -> Plane {
|
||||
let mut alpha = Plane::new(w, h);
|
||||
let mut first = true;
|
||||
for comp in &m.components {
|
||||
let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev);
|
||||
let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev, mattes);
|
||||
if comp.invert {
|
||||
c.data.iter_mut().for_each(|v| *v = 1.0 - *v);
|
||||
}
|
||||
@@ -88,7 +172,13 @@ fn for_each_pos(frame: &Frame, w: usize, h: usize, out: &mut Plane, f: impl Fn(P
|
||||
});
|
||||
}
|
||||
|
||||
pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Plane {
|
||||
/// The alpha plane of one component shape. AI shapes use the source's matching `mattes`, if any.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32, mattes: Option<&Mattes>) -> Plane {
|
||||
let fitting = mattes.map(|m| m.for_shape(shape)).unwrap_or_default();
|
||||
if !fitting.is_empty() {
|
||||
return matte_alpha(&fitting, frame, w, h);
|
||||
}
|
||||
let mut out = Plane::new(w, h);
|
||||
let to_long = |p: Point| frame.norm_to_long(p);
|
||||
// `img`/`log_l` are before exposure: range masks select on the exposed values.
|
||||
@@ -180,7 +270,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
|
||||
smooth_plane(&mut out, 0.015 * frame_px(frame, w));
|
||||
}
|
||||
MaskShape::Background => {
|
||||
let mut s = shape_alpha(&MaskShape::Subject, frame, w, h, img, log_l, ev);
|
||||
let mut s = shape_alpha(&MaskShape::Subject, frame, w, h, img, log_l, ev, mattes);
|
||||
s.data.iter_mut().for_each(|v| *v = 1.0 - *v);
|
||||
out = s;
|
||||
}
|
||||
@@ -252,6 +342,71 @@ fn smooth_plane(p: &mut Plane, sigma: f32) {
|
||||
*p = lightcraft_raster::blur::gaussian(p, sigma.max(0.5));
|
||||
}
|
||||
|
||||
/// The union (max) of `mattes` (0..255, over the EXIF-oriented source) at output resolution,
|
||||
/// sampled like the image: user orientation, lens / perspective warp, crop and flips; area-averaged
|
||||
/// first when the output is much smaller than the matte, bilinear otherwise.
|
||||
fn matte_alpha(mattes: &[&Image<u8>], frame: &Frame, w: usize, h: usize) -> Plane {
|
||||
let mut out = Plane::new(w, h);
|
||||
let o2t = frame.out_to_oriented(w, h);
|
||||
let ppl = frame.px_per_long(w).max(1e-9);
|
||||
for m in mattes {
|
||||
let oriented =
|
||||
if frame.orient == Orientation::Normal { std::borrow::Cow::Borrowed(*m) } else { std::borrow::Cow::Owned(m.oriented(frame.orient)) };
|
||||
// matte px per output px
|
||||
let k = oriented.width as f64 / frame.ow * frame.ow.max(frame.oh) / ppl;
|
||||
let base = box_down(&oriented, if k >= 2.0 { (k.floor() as usize).min(64) } else { 1 });
|
||||
let (sx, sy) = ((base.width as f64 / frame.ow) as f32, (base.height as f64 / frame.oh) as f32);
|
||||
for_rows(&mut out.data, w, |y, row| {
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let s = match &frame.warp {
|
||||
Some(wp) => {
|
||||
let Some((_, s)) = wp.frame(&o2t, x, y) else {
|
||||
continue;
|
||||
};
|
||||
s
|
||||
}
|
||||
None => o2t.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)),
|
||||
};
|
||||
*v = v.max(bilinear(&base, s.x as f32 * sx, s.y as f32 * sy) / 255.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `m` area-averaged by `f × f` (0..255 values kept); `f == 1` converts only.
|
||||
fn box_down(m: &Image<u8>, f: usize) -> Plane {
|
||||
let f = f.max(1);
|
||||
let (bw, bh) = (m.width.div_ceil(f).max(1), m.height.div_ceil(f).max(1));
|
||||
let mut out = Plane::new(bw, bh);
|
||||
for_rows(&mut out.data, bw, |by, row| {
|
||||
let rows = by * f..((by + 1) * f).min(m.height);
|
||||
for (bx, v) in row.iter_mut().enumerate() {
|
||||
let cols = bx * f..((bx + 1) * f).min(m.width);
|
||||
let (mut sum, mut n) = (0u32, 0u32);
|
||||
for y in rows.clone() {
|
||||
for x in cols.clone() {
|
||||
sum += m.data.get(y * m.width + x).copied().unwrap_or(0) as u32;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
*v = sum as f32 / n.max(1) as f32;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Bilinear sample at continuous pixel coordinates (pixel centres at +0.5), edges extended.
|
||||
fn bilinear(p: &Plane, x: f32, y: f32) -> f32 {
|
||||
let (fx, fy) = (x - 0.5, y - 0.5);
|
||||
let (x0, y0) = (fx.floor(), fy.floor());
|
||||
let (tx, ty) = (fx - x0, fy - y0);
|
||||
let (x0, y0) = (x0 as isize, y0 as isize);
|
||||
let a = p.get_clamped(x0, y0) + (p.get_clamped(x0 + 1, y0) - p.get_clamped(x0, y0)) * tx;
|
||||
let b = p.get_clamped(x0, y0 + 1) + (p.get_clamped(x0 + 1, y0 + 1) - p.get_clamped(x0, y0 + 1)) * tx;
|
||||
a + (b - a) * ty
|
||||
}
|
||||
|
||||
/// A rough display mapping for colour picking (so samples taken on screen match).
|
||||
pub(crate) fn tonemap_for_select(c: [f32; 3]) -> [f32; 3] {
|
||||
c.map(|v| v / (1.0 + v))
|
||||
@@ -436,7 +591,7 @@ mod tests {
|
||||
let f = frame(100, 50);
|
||||
let img = Rgb32f::new(100, 50);
|
||||
let l = Plane::new(100, 50);
|
||||
let a = shape_alpha(&MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) }, &f, 100, 50, &img, &l, 0.0);
|
||||
let a = shape_alpha(&MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) }, &f, 100, 50, &img, &l, 0.0, None);
|
||||
assert!(a.get(1, 25) > 0.99 && a.get(98, 25) < 0.01);
|
||||
assert!((a.get(50, 25) - 0.5).abs() < 0.05);
|
||||
}
|
||||
@@ -452,7 +607,7 @@ mod tests {
|
||||
MaskShape::Prompt { text: "left".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 },
|
||||
] {
|
||||
for (w, h) in [(40, 20), (400, 200)] {
|
||||
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0);
|
||||
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None);
|
||||
assert!(a.get(1, h / 2) > 0.99 && a.get(w - 2, h / 2) < 0.01, "{w}×{h}");
|
||||
if w >= 400 {
|
||||
let soft = (0..w).filter(|x| (0.05..0.95).contains(&a.get(*x, h / 2))).count();
|
||||
@@ -465,7 +620,7 @@ mod tests {
|
||||
d.crop.geometry.rect = lightcraft_geom::Rect::from_center(Point::new(0.75, 0.5), 0.5, 1.0);
|
||||
let f = Frame::new(40, 20, &d, true);
|
||||
let shape = MaskShape::Prompt { text: "left".into(), seg: Some(seg), detail: vec![], edge: 0.0 };
|
||||
let a = shape_alpha(&shape, &f, 20, 20, &Rgb32f::new(20, 20), &Plane::new(20, 20), 0.0);
|
||||
let a = shape_alpha(&shape, &f, 20, 20, &Rgb32f::new(20, 20), &Plane::new(20, 20), 0.0, None);
|
||||
assert!(a.data.iter().all(|v| *v < 0.05));
|
||||
}
|
||||
|
||||
@@ -478,7 +633,7 @@ mod tests {
|
||||
let patch = SegMask::from_logits_in(side, &l, [0.5, 0.0, 1.0, 1.0]);
|
||||
let shape = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: Some(coarse), detail: vec![patch], edge: 0.0 };
|
||||
let (w, h) = (200, 100);
|
||||
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0);
|
||||
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None);
|
||||
assert!(a.get(20, 50) < 0.01, "outside the patch: the coarse mask");
|
||||
assert!(a.get(120, 50) > 0.99, "inside the patch, its selected half");
|
||||
assert!(a.get(190, 50) < 0.01, "inside the patch, its unselected half");
|
||||
@@ -494,7 +649,7 @@ mod tests {
|
||||
let shape = MaskShape::Object { hint: vec![Point::new(0.2, 0.5)], exclude: vec![], seg: None, detail: vec![patch; 20_000], edge: 0.0 };
|
||||
let (w, h) = (64, 32);
|
||||
let t = std::time::Instant::now();
|
||||
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0);
|
||||
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None);
|
||||
assert!(a.get(5, 16) > 0.99 && a.get(60, 16) < 0.01);
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
|
||||
}
|
||||
@@ -507,7 +662,7 @@ mod tests {
|
||||
let (w, h) = (400, 40);
|
||||
let soft_px = |edge: f64| {
|
||||
let shape = MaskShape::Prompt { text: "x".into(), seg: Some(seg.clone()), detail: vec![], edge };
|
||||
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0);
|
||||
let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None);
|
||||
assert!(a.get(2, h / 2) > 0.9 && a.get(w - 3, h / 2) < 0.1, "still the same selection at {edge}");
|
||||
(0..w).filter(|x| (0.1..0.9).contains(&a.get(*x, h / 2))).count()
|
||||
};
|
||||
@@ -524,7 +679,7 @@ mod tests {
|
||||
MaskShape::Prompt { text: "sky".into(), seg: None, detail: vec![], edge: 0.0 },
|
||||
MaskShape::Prompt { text: "sky".into(), seg: Some(SegMask { side: 4, data: "damaged!".into(), rect: None }), detail: vec![], edge: 0.0 },
|
||||
] {
|
||||
assert!(shape_alpha(&shape, &f, 30, 30, &img, &l, 0.0).data.iter().all(|v| *v == 0.0));
|
||||
assert!(shape_alpha(&shape, &f, 30, 30, &img, &l, 0.0, None).data.iter().all(|v| *v == 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,10 +689,10 @@ mod tests {
|
||||
let img = Rgb32f::new(100, 100);
|
||||
let l = Plane::new(100, 100);
|
||||
let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 20.0, invert: false };
|
||||
let a = shape_alpha(&shape, &f, 100, 100, &img, &l, 0.0);
|
||||
let a = shape_alpha(&shape, &f, 100, 100, &img, &l, 0.0, None);
|
||||
assert!(a.get(50, 50) > 0.99 && a.get(5, 5) < 0.01);
|
||||
let m = Mask { components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: true, shape }], ..Default::default() };
|
||||
let e = evaluate(&[m], &f, 100, 100, &img, &l, 0.0);
|
||||
let e = evaluate(&[m], &f, 100, 100, &img, &l, 0.0, None);
|
||||
assert!(e[0].alpha.get(50, 50) < 0.01);
|
||||
}
|
||||
|
||||
@@ -552,7 +707,7 @@ mod tests {
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }],
|
||||
..Default::default()
|
||||
};
|
||||
let e = evaluate(&[m], &f, 200, 100, &img, &l, 0.0);
|
||||
let e = evaluate(&[m], &f, 200, 100, &img, &l, 0.0, None);
|
||||
let a = &e[0].alpha;
|
||||
assert!(a.get(40, 50) > 0.9, "{}", a.get(40, 50));
|
||||
assert!(a.get(100, 50) < 0.05, "erased centre {}", a.get(100, 50));
|
||||
@@ -574,8 +729,8 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let comp = |auto| MaskShape::Brush { strokes: vec![stroke(auto)] };
|
||||
let plain = shape_alpha(&comp(false), &f, w, h, &img, &l, 0.0);
|
||||
let auto = shape_alpha(&comp(true), &f, w, h, &img, &l, 0.0);
|
||||
let plain = shape_alpha(&comp(false), &f, w, h, &img, &l, 0.0, None);
|
||||
let auto = shape_alpha(&comp(true), &f, w, h, &img, &l, 0.0, None);
|
||||
// without Auto Mask the brush spills over the edge; with it, it stays on the dark side
|
||||
assert!(plain.get(105, 50) > 0.9, "{}", plain.get(105, 50));
|
||||
assert!(auto.get(105, 50) < 0.05, "spill {}", auto.get(105, 50));
|
||||
@@ -585,8 +740,8 @@ mod tests {
|
||||
// on a flat area Auto Mask paints like the plain brush
|
||||
let flat = Rgb32f::from_fn(w, h, |_, _| [0.2; 3]);
|
||||
let fl = flat.map(crate::local::log_lum);
|
||||
let a = shape_alpha(&comp(true), &f, w, h, &flat, &fl, 0.0);
|
||||
let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0);
|
||||
let a = shape_alpha(&comp(true), &f, w, h, &flat, &fl, 0.0, None);
|
||||
let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0, None);
|
||||
assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50));
|
||||
}
|
||||
|
||||
@@ -609,7 +764,7 @@ mod tests {
|
||||
};
|
||||
let refined = Mask { refine: 100.0, ..soft.clone() };
|
||||
let step = |m: &Mask| {
|
||||
let a = evaluate_one(m, &f, w, h, &img, &log_l, 0.0);
|
||||
let a = evaluate_one(m, &f, w, h, &img, &log_l, 0.0, None);
|
||||
a.data[40 * w + 63] - a.data[40 * w + 56]
|
||||
};
|
||||
let (plain, sharp) = (step(&soft), step(&refined));
|
||||
@@ -640,7 +795,7 @@ mod tests {
|
||||
}
|
||||
let alpha = |refine| {
|
||||
let shape = MaskShape::ColorRange { samples: vec![sample], refine };
|
||||
let a = shape_alpha(&shape, &f, w, h, &img, &log_l, 0.0);
|
||||
let a = shape_alpha(&shape, &f, w, h, &img, &log_l, 0.0, None);
|
||||
(a.get(30, 20), a.get(90, 20))
|
||||
};
|
||||
for refine in [0.0, 50.0, 85.0] {
|
||||
@@ -653,4 +808,63 @@ mod tests {
|
||||
let (l, r) = alpha(100.0);
|
||||
assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}");
|
||||
}
|
||||
|
||||
/// A half-resolution matte selecting the left `frac` of a `w × h` source.
|
||||
fn left_matte(w: usize, h: usize, frac: f64) -> Image<u8> {
|
||||
Image::from_fn(w / 2, h / 2, |x, _| if (x as f64 + 0.5) < frac * (w / 2) as f64 { 255 } else { 0 })
|
||||
}
|
||||
|
||||
/// The Sky mask follows the source's sky matte where it has one (here: the bottom-left of a
|
||||
/// uniformly bright blue photo, where the heuristic sees no sky, and not its top right, where
|
||||
/// it does), through user orientation, at any output size; without one it keeps the heuristic.
|
||||
#[test]
|
||||
fn sky_uses_the_sources_matte() {
|
||||
let (w, h) = (200, 100);
|
||||
let img = Rgb32f::filled(w, h, [0.5, 0.7, 1.4]);
|
||||
let l = img.map(crate::local::log_lum);
|
||||
let mut mattes = Mattes::default();
|
||||
mattes.push(MatteKind::Sky, left_matte(w, h, 0.5));
|
||||
let f = frame(w, h);
|
||||
let heuristic = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, None);
|
||||
let matte = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&mattes));
|
||||
assert!(heuristic.get(150, 5) > 0.9 && heuristic.get(25, 90) < 0.1, "{} {}", heuristic.get(150, 5), heuristic.get(25, 90));
|
||||
assert!(matte.get(150, 5) < 0.01 && matte.get(25, 90) > 0.99, "{} {}", matte.get(150, 5), matte.get(25, 90));
|
||||
// mattes of other kinds leave the heuristic alone
|
||||
let mut hair = Mattes::default();
|
||||
hair.push(MatteKind::Hair, left_matte(w, h, 0.5));
|
||||
assert_eq!(shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&hair)), heuristic);
|
||||
// rotated 90° clockwise, the source's left half is the top half; a preview and a larger
|
||||
// render agree
|
||||
let s = lightcraft_develop::DevelopSettings { orientation: Orientation::Rotate90, ..Default::default() };
|
||||
let f = Frame::new(w, h, &s, true);
|
||||
for (ow, oh) in [(100, 200), (25, 50)] {
|
||||
let small = Rgb32f::filled(ow, oh, [0.5, 0.7, 1.4]);
|
||||
let a = shape_alpha(&MaskShape::Sky, &f, ow, oh, &small, &small.map(crate::local::log_lum), 0.0, Some(&mattes));
|
||||
let at = |x: f64, y: f64| a.get((x * ow as f64) as usize, (y * oh as f64) as usize);
|
||||
assert!(at(0.5, 0.2) > 0.99 && at(0.5, 0.8) < 0.01, "{ow}×{oh}: {} {}", at(0.5, 0.2), at(0.5, 0.8));
|
||||
}
|
||||
}
|
||||
|
||||
/// Subject, Background and People use person mattes: the whole person is the union of the
|
||||
/// parts; a People part without a matte keeps the heuristic.
|
||||
#[test]
|
||||
fn people_and_subject_use_person_mattes() {
|
||||
let (w, h) = (200, 100);
|
||||
let img = Rgb32f::filled(w, h, [0.2; 3]);
|
||||
let l = img.map(crate::local::log_lum);
|
||||
let mut mattes = Mattes::default();
|
||||
mattes.push(MatteKind::Skin, left_matte(w, h, 0.25));
|
||||
let right: Image<u8> = Image::from_fn(w / 2, h / 2, |x, _| if x >= 75 { 255 } else { 0 });
|
||||
mattes.push(MatteKind::Hair, right);
|
||||
let f = frame(w, h);
|
||||
let eval = |shape: &MaskShape| shape_alpha(shape, &f, w, h, &img, &l, 0.0, Some(&mattes));
|
||||
let subject = eval(&MaskShape::Subject);
|
||||
assert!(subject.get(10, 50) > 0.99 && subject.get(190, 50) > 0.99 && subject.get(100, 50) < 0.01);
|
||||
let background = eval(&MaskShape::Background);
|
||||
assert!(background.get(100, 50) > 0.99 && background.get(10, 50) < 0.01);
|
||||
let hair = eval(&MaskShape::People { person: 0, parts: vec!["Hair".into()] });
|
||||
assert!(hair.get(190, 50) > 0.99 && hair.get(10, 50) < 0.01);
|
||||
let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] };
|
||||
assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None));
|
||||
}
|
||||
}
|
||||
@@ -369,6 +369,8 @@ fn tint_negative_is_green_and_positive_is_magenta() {
|
||||
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() },
|
||||
// camera-space white balance: the same sign through the camera's own colour model
|
||||
SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, camera_color: Some(camera_color(4200.0, 15.0)), ..Default::default() },
|
||||
] {
|
||||
let mut s = DevelopSettings::default();
|
||||
let neutral = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8);
|
||||
@@ -384,3 +386,52 @@ fn tint_negative_is_green_and_positive_is_magenta() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A dual-illuminant (A / D65) camera colour model, its pixels developed for `temp` / `tint`.
|
||||
fn camera_color(temp: f64, tint: f64) -> std::sync::Arc<crate::CameraColor> {
|
||||
use lightcraft_color::Mat3;
|
||||
let tags = lightcraft_raw::ColorData {
|
||||
illuminant: [17, 21],
|
||||
color_matrix: [
|
||||
Some(Mat3([[0.9, 0.2, -0.15], [-0.3, 1.25, 0.08], [0.02, -0.12, 0.85]])),
|
||||
Some(Mat3([[0.7, 0.3, -0.1], [-0.35, 1.3, 0.1], [0.05, -0.2, 1.0]])),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
std::sync::Arc::new(crate::CameraColor { tags, developed_for: lightcraft_color::cct::temp_tint_to_xy(temp, tint) })
|
||||
}
|
||||
|
||||
/// Camera-space white balance: a raw source with its colour model is re-developed for the chosen
|
||||
/// white, so a grey card lit by that white renders neutral. Without the model, or with a relative
|
||||
/// white balance, the developed pixels are adapted (Bradford, neutral luminance kept).
|
||||
#[test]
|
||||
fn custom_white_balance_redevelops_in_camera_space() {
|
||||
use crate::local::wb_matrix_for;
|
||||
use lightcraft_color::cct::temp_tint_to_xy;
|
||||
use lightcraft_develop::WbMode;
|
||||
let cc = camera_color(5500.0, 0.0);
|
||||
let info = SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, camera_color: Some(cc.clone()), ..Default::default() };
|
||||
let mut s = DevelopSettings::default();
|
||||
(s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Custom, 3200.0, 10.0);
|
||||
// a grey card lit by 3200 K / +10, as the camera records it, developed for the as-shot white
|
||||
let a = lightcraft_raw::color::camera_transform_of(&cc.tags, cc.developed_for);
|
||||
let n = lightcraft_raw::color::camera_neutral(&cc.tags, temp_tint_to_xy(3200.0, 10.0));
|
||||
let card = a.matrix.apply(std::array::from_fn(|i| n[i] * a.wb[i] as f64)).map(|v| v as f32);
|
||||
let apply = |m: [[f32; 3]; 3], c: [f32; 3]| -> [f32; 3] { std::array::from_fn(|r| m[r][0] * c[0] + m[r][1] * c[1] + m[r][2] * c[2]) };
|
||||
let m = wb_matrix_for(&info, &s).unwrap();
|
||||
let out = apply(m, card);
|
||||
assert!(out.iter().all(|v| (v / out[1] - 1.0).abs() < 1e-4), "{card:?} -> {out:?}");
|
||||
// the adaptation path for sources without a colour model, and for relative white balance
|
||||
let luma = |c: [f32; 3]| lightcraft_color::luminance_2020(c);
|
||||
for other in [SourceInfo { camera_color: None, ..info.clone() }, SourceInfo { relative_wb: true, ..info.clone() }] {
|
||||
let b = wb_matrix_for(&other, &s).unwrap();
|
||||
assert_ne!(b, m);
|
||||
assert!((luma(apply(b, [1.0; 3])) - 1.0).abs() < 1e-4);
|
||||
}
|
||||
// the white-balance picker / auto white balance invert the same model: the card names its white
|
||||
let picked = crate::auto::auto_wb(&Rgb32f::filled(4, 4, card), &info);
|
||||
assert_eq!(picked, (3200.0, 10.0));
|
||||
// the as-shot white needs nothing
|
||||
s.wb.mode = WbMode::AsShot;
|
||||
assert!(wb_matrix_for(&info, &s).is_none());
|
||||
}
|
||||
@@ -20,9 +20,16 @@ rayon = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
zune-jpeg = { workspace = true }
|
||||
zune-core = { workspace = true }
|
||||
jxl-oxide = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["jxl"]
|
||||
## JPEG XL compressed DNGs (DNG 1.7, compression 52546) via jxl-oxide (pure Rust, MIT OR Apache-2.0).
|
||||
jxl = ["dep:jxl-oxide"]
|
||||
|
||||
[dev-dependencies]
|
||||
jpeg-encoder = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
zune-jpegxl = { workspace = true }
|
||||
# Pinned CC0 corpus identities; not a product dependency.
|
||||
sha2 = { version = "0.10", default-features = false, features = ["std"] }
|
||||
+57
-1
@@ -181,7 +181,11 @@ pub struct CameraTransform {
|
||||
|
||||
/// Camera transform for white `wb_xy`: `rec2020 = matrix · (wb ⊙ camera)`.
|
||||
pub fn camera_transform(raw: &RawImage, wb_xy: Xy) -> CameraTransform {
|
||||
let color = &raw.color;
|
||||
camera_transform_of(&raw.color, wb_xy)
|
||||
}
|
||||
|
||||
/// [`camera_transform`] from the colour tags alone.
|
||||
pub fn camera_transform_of(color: &ColorData, wb_xy: Xy) -> CameraTransform {
|
||||
let fallback = !has_matrix(color);
|
||||
let wb = wb_multipliers(color, wb_xy);
|
||||
let to_d50 = camera_to_xyz_d50(color, wb_xy);
|
||||
@@ -192,6 +196,34 @@ pub fn camera_transform(raw: &RawImage, wb_xy: Xy) -> CameraTransform {
|
||||
CameraTransform { matrix, wb: wb.map(|v| v as f32), white_xy: wb_xy, matrix_is_fallback: fallback, baseline_exposure: color.baseline_exposure }
|
||||
}
|
||||
|
||||
/// Linear Rec.2020 → linear Rec.2020 matrix that re-develops pixels developed with the camera
|
||||
/// transform for white `from` as if developed for white `to`: the camera's own colour model is
|
||||
/// evaluated at the new white (camera-space white balance, DNG spec ch. 6), as Lightroom does, so
|
||||
/// a neutral lit by `to` comes out neutral. `None` when the tags have no colour matrix or the
|
||||
/// transform is singular.
|
||||
pub fn rebalance(color: &ColorData, from: Xy, to: Xy) -> Option<Mat3> {
|
||||
if !has_matrix(color) {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (camera_transform_of(color, from), camera_transform_of(color, to));
|
||||
let ratio = Mat3::diag(f64::from(b.wb[0] / a.wb[0]), f64::from(b.wb[1] / a.wb[1]), f64::from(b.wb[2] / a.wb[2]));
|
||||
Some(b.matrix.mul(&ratio).mul(&a.matrix.inverse()?))
|
||||
}
|
||||
|
||||
/// The white under which `rgb` (linear Rec.2020, developed with the camera transform for white
|
||||
/// `developed_for`) is a neutral: the white [`rebalance`] must re-develop for to make it neutral
|
||||
/// (white-balance picker, auto white balance). `None` without a colour matrix, or when `rgb` maps
|
||||
/// to a non-positive camera value.
|
||||
pub fn neutral_white(color: &ColorData, developed_for: Xy, rgb: [f64; 3]) -> Option<Xy> {
|
||||
if !has_matrix(color) {
|
||||
return None;
|
||||
}
|
||||
let a = camera_transform_of(color, developed_for);
|
||||
let balanced = a.matrix.inverse()?.apply(rgb);
|
||||
let camera: [f64; 3] = std::array::from_fn(|i| balanced[i] / f64::from(a.wb[i]));
|
||||
camera.iter().all(|v| v.is_finite() && *v > 0.0).then(|| neutral_to_xy(color, camera))
|
||||
}
|
||||
|
||||
/// `(matrix, multipliers)` with `rec2020 = matrix · (multipliers ⊙ camera)`, linear Rec.2020 D65.
|
||||
pub fn camera_to_rec2020(raw: &RawImage, wb_xy: Xy) -> (Mat3, [f32; 3]) {
|
||||
let t = camera_transform(raw, wb_xy);
|
||||
@@ -373,6 +405,30 @@ mod tests {
|
||||
assert_eq!(illuminant_temperature(255), None);
|
||||
}
|
||||
|
||||
/// Camera-space white balance: re-developing for another white turns a neutral lit by that
|
||||
/// white neutral (the camera's own matrices, interpolated for the new white, say how); the same
|
||||
/// white is the identity; without a colour matrix there is nothing to re-evaluate.
|
||||
#[test]
|
||||
fn rebalance_redevelops_for_the_new_white() {
|
||||
let color = ColorData { illuminant: [17, 21], color_matrix: [Some(cam_a()), Some(cam_b())], ..Default::default() };
|
||||
let (from, to) = (cct::temp_tint_to_xy(5500.0, 0.0), cct::temp_tint_to_xy(3200.0, 10.0));
|
||||
let a = camera_transform_of(&color, from);
|
||||
// a grey card lit by `to`, as the camera records it, developed for `from`: warm
|
||||
let n = camera_neutral(&color, to);
|
||||
let developed = a.matrix.apply(std::array::from_fn(|i| n[i] * a.wb[i] as f64));
|
||||
assert!(developed[0] > 1.2 * developed[2], "{developed:?}");
|
||||
let out = rebalance(&color, from, to).unwrap().apply(developed);
|
||||
assert!(close(out.map(|v| v / out[1]), [1.0; 3], 1e-6), "{out:?}");
|
||||
let same = rebalance(&color, from, from).unwrap();
|
||||
assert!((0..3).all(|r| close(same.0[r], Mat3::IDENTITY.0[r], 1e-9)), "{same:?}");
|
||||
assert!(rebalance(&ColorData::default(), from, to).is_none());
|
||||
// and back: the developed grey card names the white it was lit by
|
||||
let white = neutral_white(&color, from, developed).unwrap();
|
||||
assert!((white.x - to.x).abs() < 1e-6 && (white.y - to.y).abs() < 1e-6, "{white:?} vs {to:?}");
|
||||
assert!(neutral_white(&color, from, [-1.0, 0.5, 0.5]).is_none());
|
||||
assert!(neutral_white(&ColorData::default(), from, developed).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grey_world_balances() {
|
||||
let img = lightcraft_raster::Rgb32f::from_fn(20, 20, |x, _| {
|
||||
|
||||
+25
-10
@@ -1,13 +1,14 @@
|
||||
//! DNG (Adobe Digital Negative Specification 1.7): raw IFD selection, pixel data (via [`crate::tiffraw`]),
|
||||
//! linearization, black/white levels, active area, default crop, CFA description, colour tags, opcode lists.
|
||||
|
||||
use crate::gaintable::GainTableMap;
|
||||
use crate::profile::{HsvTable, ProfileLook, ToneCurve};
|
||||
use crate::tiffraw::{Packing, read_image_in};
|
||||
use crate::{BlackLevel, Cfa, ColorData, Mat3, Mode, RawData, RawError, RawFormat, RawImage, Rect, Result, opcodes};
|
||||
use lightcraft_color::Xy;
|
||||
use lightcraft_geom::Orientation;
|
||||
use lightcraft_tiff::tags::{self as t, photometric};
|
||||
use lightcraft_tiff::{Ifd, Tiff};
|
||||
use lightcraft_tiff::{ByteOrder, Ifd, Tiff};
|
||||
|
||||
/// The main raw IFD: full-resolution (NewSubfileType 0) CFA or LinearRaw image with the most pixels.
|
||||
pub(crate) fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
|
||||
@@ -33,7 +34,8 @@ fn vec3(v: Option<Vec<f64>>) -> Option<[f64; 3]> {
|
||||
}
|
||||
|
||||
/// Colour tags: DNG puts them in IFD0, but some writers use the raw IFD; prefer the raw IFD.
|
||||
pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd) -> ColorData {
|
||||
/// `order` is the file's byte order (for the binary gain-table-map tags).
|
||||
pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> ColorData {
|
||||
let get = |tag: u16| raw.f64s(tag).or_else(|| ifd0.f64s(tag));
|
||||
let geti = |tag: u16| raw.u16(tag).or_else(|| ifd0.u16(tag));
|
||||
ColorData {
|
||||
@@ -46,13 +48,16 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd) -> ColorData {
|
||||
as_shot_white_xy: get(t::AS_SHOT_WHITE_XY).filter(|v| v.len() == 2 && v[0] > 0.0 && v[1] > 0.0).map(|v| Xy::new(v[0], v[1])),
|
||||
baseline_exposure: get(t::BASELINE_EXPOSURE).and_then(|v| v.first().copied()).filter(|v| v.is_finite()).unwrap_or(0.0)
|
||||
+ get(t::BASELINE_EXPOSURE_OFFSET).and_then(|v| v.first().copied()).filter(|v| v.is_finite()).unwrap_or(0.0),
|
||||
profile: profile_look(ifd0, raw),
|
||||
// DNG 1.7 BaselineSharpness (IFD 0 or the enhanced IFD); implausible values are ignored
|
||||
baseline_sharpness: get(t::BASELINE_SHARPNESS).and_then(|v| v.first().copied()).filter(|v| v.is_finite() && *v > 0.0 && *v <= 16.0),
|
||||
profile: profile_look(ifd0, raw, order),
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile look tags (`ProfileHueSatMap*`, `ProfileLookTable*`, `ProfileToneCurve`); malformed
|
||||
/// ones are ignored. Like the colour tags, read from the raw IFD first, else IFD 0.
|
||||
pub(crate) fn profile_look(ifd0: &Ifd, raw: &Ifd) -> ProfileLook {
|
||||
/// The profile look tags (`ProfileHueSatMap*`, `ProfileLookTable*`, `ProfileToneCurve`,
|
||||
/// `ProfileGainTableMap*`); malformed ones are ignored. Like the colour tags, read from the raw IFD
|
||||
/// first, else IFD 0.
|
||||
pub(crate) fn profile_look(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> ProfileLook {
|
||||
let pick = |tag: u16| if raw.contains(tag) { raw } else { ifd0 };
|
||||
let table = |dims: u16, data: u16, enc: u16| {
|
||||
let ifd = pick(dims);
|
||||
@@ -67,9 +72,22 @@ pub(crate) fn profile_look(ifd0: &Ifd, raw: &Ifd) -> ProfileLook {
|
||||
],
|
||||
look_table: table(t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_DATA, t::PROFILE_LOOK_TABLE_ENCODING),
|
||||
tone_curve: pick(t::PROFILE_TONE_CURVE).f64s(t::PROFILE_TONE_CURVE).and_then(|v| ToneCurve::from_tag(&v)),
|
||||
gain_table_map: gain_table_map(ifd0, raw, order),
|
||||
}
|
||||
}
|
||||
|
||||
/// The gain table map (DNG 1.7 precedence): `ProfileGainTableMap2` from IFD 0 (the embedded
|
||||
/// camera profile), else from the raw IFD; else `ProfileGainTableMap` from the raw IFD (where DNG
|
||||
/// 1.6 put it, and where Apple writes it), else from IFD 0 (where DNG 1.7 says it was meant to be).
|
||||
/// A malformed tag falls through to the next one.
|
||||
fn gain_table_map(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> Option<GainTableMap> {
|
||||
let parse = |ifd: &Ifd, tag: u16| ifd.bytes(tag).and_then(|b| GainTableMap::parse(b, order, tag == t::PROFILE_GAIN_TABLE_MAP_2));
|
||||
parse(ifd0, t::PROFILE_GAIN_TABLE_MAP_2)
|
||||
.or_else(|| parse(raw, t::PROFILE_GAIN_TABLE_MAP_2))
|
||||
.or_else(|| parse(raw, t::PROFILE_GAIN_TABLE_MAP))
|
||||
.or_else(|| parse(ifd0, t::PROFILE_GAIN_TABLE_MAP))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
let tiff = Tiff::parse(bytes)?;
|
||||
let ifd0 = &tiff.ifds[0];
|
||||
@@ -79,9 +97,6 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
if !(1..=4).contains(&cpp) {
|
||||
return Err(RawError::Unsupported(format!("{cpp} samples per pixel")));
|
||||
}
|
||||
if info.compression == t::compression::JPEG_XL {
|
||||
return Err(RawError::Unsupported("JPEG XL DNG".into()));
|
||||
}
|
||||
let mut data = read_image_in(mode, bytes, &info, tiff.order, Packing::Msb)?;
|
||||
let bits = info.bits() as u32;
|
||||
|
||||
@@ -175,7 +190,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
active_area,
|
||||
crop,
|
||||
orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)),
|
||||
color: color_data(ifd0, raw),
|
||||
color: color_data(ifd0, raw, tiff.order),
|
||||
wb_multipliers: None,
|
||||
linearized,
|
||||
opcodes,
|
||||
|
||||
@@ -182,6 +182,9 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
|
||||
ifd.set(t::AS_SHOT_NEUTRAL, rat_vec(&m.map(|v| 1.0 / v as f64)));
|
||||
}
|
||||
ifd.set(t::BASELINE_EXPOSURE, Value::SRational(vec![srational(c.baseline_exposure)]));
|
||||
if let Some(v) = c.baseline_sharpness {
|
||||
ifd.set(t::BASELINE_SHARPNESS, rat_vec(&[v]));
|
||||
}
|
||||
// the source's own profile look travels with its data (conversions, smart previews, merges)
|
||||
let p = &c.profile;
|
||||
let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| {
|
||||
@@ -214,6 +217,11 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
|
||||
if let Some(curve) = &p.tone_curve {
|
||||
ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect()));
|
||||
}
|
||||
if let Some(map) = &p.gain_table_map {
|
||||
// the raw IFD is IFD 0 here, where both tag versions are valid
|
||||
let (version2, bytes) = map.to_bytes(opts.order);
|
||||
ifd.set(if version2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP }, Value::Undefined(bytes));
|
||||
}
|
||||
for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] {
|
||||
if !list.is_empty() {
|
||||
ifd.set(tag, Value::Undefined(opcodes::write_list(list)));
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
//! DNG `ProfileGainTableMap` (tag 52525, DNG 1.6) and `ProfileGainTableMap2` (tag 52544, DNG 1.7):
|
||||
//! a coarse grid of 1D gain tables carrying the maker's local tone mapping. Apple ProRAW has one.
|
||||
//!
|
||||
//! We read and write the tag (a DNG export keeps it) but don't render it: Lightroom Classic
|
||||
//! renders Apple ProRAW without the map (see [`crate::profile`]), and so do we.
|
||||
//!
|
||||
//! - The grid has `MapPointsV × MapPointsH` tables of `MapPointsN` gains. Its origin and spacing
|
||||
//! are relative to the active area (1.0 = the active area's height or width).
|
||||
//! - The table input is the dot product of (R, G, B, min, max) with the tag's five weights;
|
||||
//! version 2 then raises it to the tag's `Gamma`.
|
||||
//! - Version 2 stores gains as u8 or u16 (mapped linearly onto `GainMin..=GainMax`), f16 or f32.
|
||||
//! It takes precedence over version 1 when both are present.
|
||||
//!
|
||||
//! Malformed tags are ignored: a wrong size, zero or huge dimensions, non-finite or negative gains,
|
||||
//! non-positive spacing, or gamma outside 0.25..=4.
|
||||
//!
|
||||
//! Byte order: the tag's fields are TIFF types (LONG, DOUBLE, FLOAT, …), and neither DNG 1.6 nor
|
||||
//! 1.7.1 gives this tag a byte order of its own, as they do for opcode lists and
|
||||
//! `OriginalRawFileData` (always big-endian). What applies is the general rule of the DNG 1.6
|
||||
//! specification ("DNG Format Overview" → "Byte Order", worded the same in 1.7.1):
|
||||
//!
|
||||
//! > DNG readers are required to support either byte order, even for files from a particular
|
||||
//! > camera model.
|
||||
//!
|
||||
//! (<https://helpx.adobe.com/camera-raw/digital-negative.html>, "DNG Specification".) So we read
|
||||
//! the fields in the file's byte order, and fall back to big-endian for writers that followed the
|
||||
//! opcode-list convention. A read in the wrong order can't pass for a valid map: one of
|
||||
//! `MapPointsV` / `MapPointsH` is below 256 (their product is at most [`MAX_TABLES`]), so swapped
|
||||
//! it is at least 2^24 and the size checks reject it. We write in the file's byte order.
|
||||
|
||||
use lightcraft_tiff::ByteOrder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Upper bound on `MapPointsV × MapPointsH` (Apple uses 6 × 8).
|
||||
const MAX_TABLES: usize = 1 << 12;
|
||||
/// Upper bound on the total number of gains (Apple: 6 × 8 × 257 = 12 336).
|
||||
const MAX_GAINS: usize = 1 << 22;
|
||||
|
||||
/// A parsed `ProfileGainTableMap` / `ProfileGainTableMap2`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GainTableMap {
|
||||
/// Tables vertically (`MapPointsV`) and horizontally (`MapPointsH`), and points per table (`MapPointsN`).
|
||||
pub points_v: usize,
|
||||
pub points_h: usize,
|
||||
pub points_n: usize,
|
||||
/// Grid spacing and origin, relative to the active area's height (`_v`) and width (`_h`).
|
||||
pub spacing_v: f64,
|
||||
pub spacing_h: f64,
|
||||
pub origin_v: f64,
|
||||
pub origin_h: f64,
|
||||
/// Weights of (R, G, B, min, max) for the table input.
|
||||
pub weights: [f32; 5],
|
||||
/// Exponent for the table input (1.0 for the version-1 tag).
|
||||
pub gamma: f32,
|
||||
/// `points_v × points_h × points_n` gains, row-major with the table points innermost. Integer
|
||||
/// storage has already been mapped onto `GainMin..=GainMax`. Mapping before interpolating is
|
||||
/// the same as mapping after, because the mapping is linear.
|
||||
pub gains: Vec<f32>,
|
||||
}
|
||||
|
||||
impl GainTableMap {
|
||||
/// Parse the tag's bytes from a file in byte order `order` (`version2`: `ProfileGainTableMap2`):
|
||||
/// in that order, else big-endian (see the module docs). `None` when malformed.
|
||||
pub fn parse(b: &[u8], order: ByteOrder, version2: bool) -> Option<GainTableMap> {
|
||||
Self::parse_in(b, order, version2).or_else(|| if order == ByteOrder::Big { None } else { Self::parse_in(b, ByteOrder::Big, version2) })
|
||||
}
|
||||
|
||||
/// Parse the tag's bytes with every multi-byte field in `order`.
|
||||
fn parse_in(b: &[u8], order: ByteOrder, version2: bool) -> Option<GainTableMap> {
|
||||
let take = |o: usize, n: usize| b.get(o..o.checked_add(n)?);
|
||||
let u32_at = |o: usize| take(o, 4).and_then(|s| s.try_into().ok()).map(|a: [u8; 4]| order.u32(a));
|
||||
let f32_at = |o: usize| u32_at(o).map(f32::from_bits);
|
||||
let f64_at = |o: usize| take(o, 8).and_then(|s| s.try_into().ok()).map(|a: [u8; 8]| f64::from_bits(order.u64(a)));
|
||||
let (points_v, points_h) = (u32_at(0)? as usize, u32_at(4)? as usize);
|
||||
let (spacing_v, spacing_h, origin_v, origin_h) = (f64_at(8)?, f64_at(16)?, f64_at(24)?, f64_at(32)?);
|
||||
let points_n = u32_at(40)? as usize;
|
||||
let mut weights = [0f32; 5];
|
||||
for (i, w) in weights.iter_mut().enumerate() {
|
||||
*w = f32_at(44 + 4 * i)?;
|
||||
}
|
||||
let tables = points_v.checked_mul(points_h).filter(|&t| t > 0 && t <= MAX_TABLES)?;
|
||||
let count = tables.checked_mul(points_n).filter(|&c| c > 0 && c <= MAX_GAINS)?;
|
||||
let spacing_ok = |s: f64, points: usize| s.is_finite() && (s > 0.0 || points == 1);
|
||||
if !spacing_ok(spacing_v, points_v)
|
||||
|| !spacing_ok(spacing_h, points_h)
|
||||
|| !origin_v.is_finite()
|
||||
|| !origin_h.is_finite()
|
||||
|| !weights.iter().all(|w| w.is_finite())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (header, data_type, gamma, gain_min, gain_max) =
|
||||
if version2 { (80, u32_at(64)?, f32_at(68)?, f32_at(72)?, f32_at(76)?) } else { (64, 3, 1.0, 0.0, 1.0) };
|
||||
if !(0.25..=4.0).contains(&gamma) {
|
||||
return None;
|
||||
}
|
||||
let width = match data_type {
|
||||
0 => 1,
|
||||
1 | 2 => 2,
|
||||
3 => 4,
|
||||
_ => return None,
|
||||
};
|
||||
if b.len() != count.checked_mul(width)?.checked_add(header)? {
|
||||
return None;
|
||||
}
|
||||
let data = b.get(header..)?;
|
||||
let scaled = |q: f32, max: f32| gain_min + q / max * (gain_max - gain_min);
|
||||
let gains: Vec<f32> = match data_type {
|
||||
0 => data.iter().map(|&q| scaled(q as f32, 255.0)).collect(),
|
||||
1 => data.as_chunks::<2>().0.iter().map(|c| scaled(order.u16(*c) as f32, 65535.0)).collect(),
|
||||
2 => data.as_chunks::<2>().0.iter().map(|c| crate::unpack::f16_to_f32(order.u16(*c))).collect(),
|
||||
_ => data.as_chunks::<4>().0.iter().map(|c| f32::from_bits(order.u32(*c))).collect(),
|
||||
};
|
||||
if gains.len() != count || !gains.iter().all(|g| g.is_finite() && *g >= 0.0) {
|
||||
return None;
|
||||
}
|
||||
Some(GainTableMap { points_v, points_h, points_n, spacing_v, spacing_h, origin_v, origin_h, weights, gamma, gains })
|
||||
}
|
||||
|
||||
/// The tag's bytes in `order`: version 1 (DNG 1.6, f32 gains) when the gamma is 1, else
|
||||
/// version 2 with f32 gains. Returns `(version2, bytes)`.
|
||||
pub fn to_bytes(&self, order: ByteOrder) -> (bool, Vec<u8>) {
|
||||
let version2 = self.gamma != 1.0;
|
||||
let mut out = Vec::with_capacity(80 + 4 * self.gains.len());
|
||||
let u32b = |v: u32| match order {
|
||||
ByteOrder::Big => v.to_be_bytes(),
|
||||
ByteOrder::Little => v.to_le_bytes(),
|
||||
};
|
||||
let f64b = |v: f64| match order {
|
||||
ByteOrder::Big => v.to_be_bytes(),
|
||||
ByteOrder::Little => v.to_le_bytes(),
|
||||
};
|
||||
let dim = |v: usize| u32b(u32::try_from(v).unwrap_or(u32::MAX));
|
||||
out.extend_from_slice(&dim(self.points_v));
|
||||
out.extend_from_slice(&dim(self.points_h));
|
||||
for v in [self.spacing_v, self.spacing_h, self.origin_v, self.origin_h] {
|
||||
out.extend_from_slice(&f64b(v));
|
||||
}
|
||||
out.extend_from_slice(&dim(self.points_n));
|
||||
for w in self.weights {
|
||||
out.extend_from_slice(&u32b(w.to_bits()));
|
||||
}
|
||||
if version2 {
|
||||
// DataType 3 (f32); GainMin/GainMax are ignored for float data
|
||||
for v in [3, self.gamma.to_bits(), 0f32.to_bits(), 1f32.to_bits()] {
|
||||
out.extend_from_slice(&u32b(v));
|
||||
}
|
||||
}
|
||||
for g in &self.gains {
|
||||
out.extend_from_slice(&u32b(g.to_bits()));
|
||||
}
|
||||
(version2, out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Tag bytes: the header fields (`v2`: data type, gamma, gain min, gain max), then `data`.
|
||||
fn tag(
|
||||
order: ByteOrder,
|
||||
dims: [u32; 3],
|
||||
spacing: [f64; 2],
|
||||
origin: [f64; 2],
|
||||
weights: [f32; 5],
|
||||
v2: Option<(u32, f32, f32, f32)>,
|
||||
data: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let be = order == ByteOrder::Big;
|
||||
let u = |v: u32| if be { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let d = |v: f64| if be { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(&u(dims[0]));
|
||||
b.extend_from_slice(&u(dims[1]));
|
||||
for v in [spacing[0], spacing[1], origin[0], origin[1]] {
|
||||
b.extend_from_slice(&d(v));
|
||||
}
|
||||
b.extend_from_slice(&u(dims[2]));
|
||||
for w in weights {
|
||||
b.extend_from_slice(&u(w.to_bits()));
|
||||
}
|
||||
if let Some((dt, gamma, min, max)) = v2 {
|
||||
for v in [dt, gamma.to_bits(), min.to_bits(), max.to_bits()] {
|
||||
b.extend_from_slice(&u(v));
|
||||
}
|
||||
}
|
||||
b.extend_from_slice(data);
|
||||
b
|
||||
}
|
||||
|
||||
fn floats(order: ByteOrder, g: &[f32]) -> Vec<u8> {
|
||||
g.iter().flat_map(|v| if order == ByteOrder::Big { v.to_be_bytes() } else { v.to_le_bytes() }).collect()
|
||||
}
|
||||
|
||||
const GREEN: [f32; 5] = [0.0, 1.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// 2 × 2 tables of N = 4 points: table (r, c) = base · [1, 2, 3, 4] with base 1, 2, 3, 4.
|
||||
fn grid() -> Vec<f32> {
|
||||
(0..4).flat_map(|t| (1..=4).map(move |i| (t + 1) as f32 * i as f32)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_2_storage_types_and_gamma() {
|
||||
let order = ByteOrder::Little;
|
||||
let v2 = |dt: u32, gamma: f32, data: &[u8]| {
|
||||
GainTableMap::parse(&tag(order, [1, 1, 4], [1.0, 1.0], [0.0, 0.0], GREEN, Some((dt, gamma, 0.5, 2.5)), data), order, true)
|
||||
};
|
||||
// u8: 0 → GainMin, 255 → GainMax
|
||||
let m = v2(0, 1.0, &[0, 51, 255, 255]).unwrap();
|
||||
let want = [0.5, 0.9, 2.5, 2.5];
|
||||
assert!(m.gains.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-6), "{:?}", m.gains);
|
||||
// u16
|
||||
let m = v2(1, 1.0, &[0, 0, 0xff, 0xff, 0, 0, 0xff, 0xff]).unwrap();
|
||||
assert_eq!(m.gains, vec![0.5, 2.5, 0.5, 2.5]);
|
||||
// f16 ignores GainMin/GainMax: 1.0, 2.0, 0.5, 0
|
||||
let m = v2(2, 1.0, &[0x00, 0x3c, 0x00, 0x40, 0x00, 0x38, 0, 0]).unwrap();
|
||||
assert_eq!(m.gains, vec![1.0, 2.0, 0.5, 0.0]);
|
||||
// f32 with gamma 2
|
||||
let m = v2(3, 2.0, &floats(order, &[1.0, 2.0, 3.0, 4.0])).unwrap();
|
||||
assert_eq!((m.gains.clone(), m.gamma), (vec![1.0, 2.0, 3.0, 4.0], 2.0));
|
||||
// unknown data type, gamma out of range, size for another type: ignored
|
||||
assert!(v2(4, 1.0, &[0; 4]).is_none());
|
||||
assert!(v2(0, 0.1, &[0; 4]).is_none());
|
||||
assert!(v2(0, 5.0, &[0; 4]).is_none());
|
||||
assert!(v2(1, 1.0, &[0; 4]).is_none());
|
||||
// negative integer range
|
||||
let neg = tag(order, [1, 1, 4], [1.0, 1.0], [0.0, 0.0], GREEN, Some((0, 1.0, -1.0, 1.0)), &[0; 4]);
|
||||
assert!(GainTableMap::parse(&neg, order, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_tags_are_rejected() {
|
||||
let order = ByteOrder::Big;
|
||||
let good = tag(order, [2, 2, 4], [0.5, 0.5], [0.25, 0.25], GREEN, None, &floats(order, &grid()));
|
||||
assert!(GainTableMap::parse(&good, order, false).is_some());
|
||||
// every truncation (and an extra byte)
|
||||
for n in 0..good.len() {
|
||||
assert!(GainTableMap::parse(&good[..n], order, false).is_none(), "{n} bytes");
|
||||
}
|
||||
let mut long = good.clone();
|
||||
long.push(0);
|
||||
assert!(GainTableMap::parse(&long, order, false).is_none());
|
||||
// the wrong byte order reads absurd dimensions
|
||||
assert!(GainTableMap::parse_in(&good, ByteOrder::Little, false).is_none());
|
||||
// read as version 2 the header is too short for the data
|
||||
assert!(GainTableMap::parse(&good, order, true).is_none());
|
||||
let bad = |dims: [u32; 3], spacing: [f64; 2], origin: [f64; 2], gains: &[f32]| {
|
||||
GainTableMap::parse(&tag(order, dims, spacing, origin, GREEN, None, &floats(order, gains)), order, false)
|
||||
};
|
||||
let mut g = grid();
|
||||
g[5] = f32::NAN;
|
||||
assert!(bad([2, 2, 4], [0.5, 0.5], [0.0, 0.0], &g).is_none(), "NaN gain");
|
||||
g[5] = -1.0;
|
||||
assert!(bad([2, 2, 4], [0.5, 0.5], [0.0, 0.0], &g).is_none(), "negative gain");
|
||||
g[5] = f32::INFINITY;
|
||||
assert!(bad([2, 2, 4], [0.5, 0.5], [0.0, 0.0], &g).is_none(), "infinite gain");
|
||||
assert!(bad([2, 2, 4], [0.0, 0.5], [0.0, 0.0], &grid()).is_none(), "zero spacing");
|
||||
assert!(bad([2, 2, 4], [0.5, f64::NAN], [0.0, 0.0], &grid()).is_none(), "NaN spacing");
|
||||
assert!(bad([2, 2, 4], [0.5, 0.5], [f64::INFINITY, 0.0], &grid()).is_none(), "infinite origin");
|
||||
assert!(bad([0, 2, 4], [0.5, 0.5], [0.0, 0.0], &[]).is_none(), "no rows");
|
||||
assert!(bad([1, 1, 0], [0.5, 0.5], [0.0, 0.0], &[]).is_none(), "no points");
|
||||
// huge dimensions (whose product overflows or exceeds the cap) are rejected before allocating
|
||||
assert!(bad([u32::MAX, u32::MAX, u32::MAX], [0.5, 0.5], [0.0, 0.0], &grid()).is_none());
|
||||
assert!(bad([4096, 4096, 1], [0.5, 0.5], [0.0, 0.0], &grid()).is_none());
|
||||
assert!(bad([1, 1, u32::MAX], [0.5, 0.5], [0.0, 0.0], &grid()).is_none());
|
||||
// a single table needs no spacing
|
||||
assert!(bad([1, 1, 2], [0.0, 0.0], [0.0, 0.0], &[1.0, 2.0]).is_some());
|
||||
let mut w = GREEN;
|
||||
w[3] = f32::NAN;
|
||||
assert!(GainTableMap::parse(&tag(order, [1, 1, 1], [1.0, 1.0], [0.0, 0.0], w, None, &floats(order, &[1.0])), order, false).is_none());
|
||||
}
|
||||
|
||||
/// The payload is read in the file's byte order, else big-endian; a little-endian payload in
|
||||
/// a big-endian file is malformed.
|
||||
#[test]
|
||||
fn byte_order_is_the_files_with_a_big_endian_fallback() {
|
||||
use ByteOrder::{Big, Little};
|
||||
let v1 = |o: ByteOrder| tag(o, [2, 2, 4], [0.5, 0.5], [0.25, 0.25], GREEN, None, &floats(o, &grid()));
|
||||
let v2 = |o: ByteOrder| {
|
||||
let mut gains = Vec::new();
|
||||
for q in [0u16, 0x1234, 0xffff, 0x8000] {
|
||||
gains.extend_from_slice(&if o == Big { q.to_be_bytes() } else { q.to_le_bytes() });
|
||||
}
|
||||
tag(o, [1, 1, 4], [1.0, 1.0], [0.0, 0.0], GREEN, Some((1, 2.0, 0.5, 2.5)), &gains)
|
||||
};
|
||||
let (want1, want2) = (GainTableMap::parse_in(&v1(Big), Big, false).unwrap(), GainTableMap::parse_in(&v2(Big), Big, true).unwrap());
|
||||
assert_eq!(want1.gains, grid());
|
||||
assert_eq!((want2.gains[0], want2.gains[2], want2.gamma), (0.5, 2.5, 2.0));
|
||||
// in the file's order
|
||||
assert_eq!(GainTableMap::parse(&v1(Little), Little, false).as_ref(), Some(&want1));
|
||||
assert_eq!(GainTableMap::parse(&v2(Little), Little, true).as_ref(), Some(&want2));
|
||||
assert_eq!(GainTableMap::parse(&v1(Big), Big, false).as_ref(), Some(&want1));
|
||||
// a big-endian payload in a little-endian file: the fallback
|
||||
assert_eq!(GainTableMap::parse(&v1(Big), Little, false).as_ref(), Some(&want1));
|
||||
assert_eq!(GainTableMap::parse(&v2(Big), Little, true).as_ref(), Some(&want2));
|
||||
// no little-endian fallback in a big-endian file
|
||||
assert!(GainTableMap::parse(&v1(Little), Big, false).is_none());
|
||||
assert!(GainTableMap::parse(&v2(Little), Big, true).is_none());
|
||||
// whatever the grid, a read in the wrong order fails the size checks
|
||||
for dims in [[1, 1, 1], [6, 8, 257], [48, 64, 2], [255, 16, 1], [256, 16, 1], [1, 4096, 1]] {
|
||||
for (o, wrong) in [(Big, Little), (Little, Big)] {
|
||||
let g = vec![1.0; dims.iter().product::<u32>() as usize];
|
||||
let b = tag(o, dims, [0.5, 0.5], [0.0, 0.0], GREEN, None, &floats(o, &g));
|
||||
assert!(GainTableMap::parse_in(&b, o, false).is_some(), "{dims:?}");
|
||||
assert!(GainTableMap::parse_in(&b, wrong, false).is_none(), "{dims:?} read {wrong:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_what_it_reads() {
|
||||
for order in [ByteOrder::Big, ByteOrder::Little] {
|
||||
let m =
|
||||
GainTableMap::parse(&tag(order, [2, 2, 4], [0.5, 0.5], [0.25, 0.25], GREEN, None, &floats(order, &grid())), order, false).unwrap();
|
||||
let (v2, b) = m.to_bytes(order);
|
||||
assert!(!v2);
|
||||
assert_eq!(GainTableMap::parse(&b, order, false).unwrap(), m);
|
||||
let g = GainTableMap { gamma: 2.0, ..m };
|
||||
let (v2, b) = g.to_bytes(order);
|
||||
assert!(v2);
|
||||
assert_eq!(GainTableMap::parse(&b, order, true).unwrap(), g);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! JPEG XL tiles and strips (DNG 1.7 compression 52546), decoded with `jxl-oxide` (pure Rust).
|
||||
//!
|
||||
//! Per the DNG 1.7 specification a JPEG XL chunk holds N-bit unsigned integers (8 ≤ N ≤ 16) or 16-bit
|
||||
//! floats, in 1 or 3 planes, as either a bare codestream or an ISO-BMFF container; jxl-oxide reads both.
|
||||
//!
|
||||
//! jxl-oxide renders every channel as `f32`: integer samples come out as `value / (2^bits − 1)` with
|
||||
//! `bits` the codestream's own declared bit depth, so scaling back by that maximum and rounding gives the
|
||||
//! stored integers exactly (an `f32` holds `v / 65535` to far better than half a code value) — lossless
|
||||
//! tiles decode bit-exactly, whatever bit depth the encoder declared relative to the IFD's
|
||||
//! `BitsPerSample`. Float samples are returned as decoded.
|
||||
//!
|
||||
//! We never request an output colour encoding, so jxl-oxide renders in the codestream's *original*
|
||||
//! colour encoding: for lossless (non-XYB) data that is the identity, keeping the values in the raw IFD's
|
||||
//! sample space. Lossy (XYB) tiles are converted from XYB back to that signalled encoding by jxl-oxide;
|
||||
//! [INFERENCE] DNG writers signal a linear encoding matching the raw data there, so the result is the raw
|
||||
//! sample space up to the codec's loss — not verified against a real lossy JXL DNG.
|
||||
|
||||
use crate::tiffraw::ChunkPx;
|
||||
use crate::{RawError, Result};
|
||||
use jxl_oxide::frame::Encoding;
|
||||
use jxl_oxide::image::BitDepth;
|
||||
use jxl_oxide::{AllocTracker, JxlImage, JxlThreadPool};
|
||||
|
||||
/// Decoder memory (bytes) allowed per sample of a tile. jxl-oxide 0.12 peaks at about three 32-bit
|
||||
/// buffers per sample: measured ≤ 10.3 bytes for lossless (modular) tiles and ≤ 13.5 bytes per
|
||||
/// colour sample for lossy (VarDCT) ones, photon noise included.
|
||||
const BYTES_PER_SAMPLE: usize = 16;
|
||||
/// Fixed decoder overhead on top (headers, entropy-coding tables, group metadata): under 10 KiB
|
||||
/// on a 16 × 16 tile.
|
||||
const MARGIN_BYTES: usize = 64 << 10;
|
||||
|
||||
fn corrupt(e: impl std::fmt::Display) -> RawError {
|
||||
RawError::Corrupt(format!("JPEG XL tile: {e}"))
|
||||
}
|
||||
|
||||
/// Parse a chunk's headers (no pixel decoding) and check them against the chunk it must fill:
|
||||
/// nominal `cw × ch`, or the part of it inside the image `vw × vh` (edge tiles), `cpp` colour planes,
|
||||
/// a sample type matching the IFD. Returns the decoder and the codestream's integer maximum (0 for float).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<(JxlImage, f32)> {
|
||||
if !matches!(cpp, 1 | 3) {
|
||||
return Err(RawError::Unsupported(format!("JPEG XL with {cpp} samples per pixel")));
|
||||
}
|
||||
if float && bits != 16 || !float && !(8..=16).contains(&bits) {
|
||||
return Err(RawError::Unsupported(format!("JPEG XL with {bits}-bit {} samples", if float { "float" } else { "integer" })));
|
||||
}
|
||||
// The decoder's memory is bounded by the tile itself: a codestream that needs more (extra
|
||||
// channels, layers, …) fails to decode instead of allocating what its header asks for.
|
||||
let pixels = cw.saturating_mul(ch);
|
||||
let budget = |planes: usize| pixels.saturating_mul(planes).saturating_mul(BYTES_PER_SAMPLE);
|
||||
let tracker = AllocTracker::with_limit(budget(cpp).saturating_add(MARGIN_BYTES));
|
||||
let img = JxlImage::builder()
|
||||
.pool(JxlThreadPool::none()) // chunks are already decoded in parallel
|
||||
.alloc_tracker(tracker.clone())
|
||||
.read(std::io::Cursor::new(src))
|
||||
.map_err(corrupt)?;
|
||||
// lossy (VarDCT) decoding always works on three colour channels, even for a grey tile
|
||||
if cpp == 1 && img.frame_header(0).is_some_and(|f| f.encoding == Encoding::VarDct) {
|
||||
tracker.expand_limit(budget(2));
|
||||
}
|
||||
let header = img.image_header();
|
||||
let (jw, jh) = (img.width() as usize, img.height() as usize);
|
||||
if (jw, jh) != (cw, ch) && (jw, jh) != (vw, vh) {
|
||||
return Err(RawError::Corrupt(format!("JPEG XL tile is {jw}×{jh}, expected {cw}×{ch} (or {vw}×{vh} at the image edge)")));
|
||||
}
|
||||
if header.metadata.orientation != 1 {
|
||||
return Err(RawError::Corrupt("JPEG XL tile with a non-identity orientation".into()));
|
||||
}
|
||||
let planes = if header.metadata.grayscale() { 1 } else { 3 };
|
||||
if planes != cpp {
|
||||
return Err(RawError::Corrupt(format!("JPEG XL tile has {planes} colour planes, the IFD {cpp} samples per pixel")));
|
||||
}
|
||||
let max = match header.metadata.bit_depth {
|
||||
BitDepth::IntegerSample { bits_per_sample } if !float && (1..=16).contains(&bits_per_sample) => ((1u32 << bits_per_sample) - 1) as f32,
|
||||
BitDepth::FloatSample { .. } if float => 0.0,
|
||||
d => return Err(RawError::Corrupt(format!("JPEG XL tile sample type {d:?} does not match the IFD"))),
|
||||
};
|
||||
Ok((img, max))
|
||||
}
|
||||
|
||||
/// Check the first chunk's headers ([`open`]) without decoding pixels (header-only probing).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<()> {
|
||||
open(src, cw, ch, vw, vh, cpp, bits, float).map(|_| ())
|
||||
}
|
||||
|
||||
/// Decode one chunk into `cw × ch × cpp` samples (row-major, interleaved); a chunk coded at its
|
||||
/// in-image size `vw × vh` fills the top-left of that, the rest stays 0.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn decode(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<ChunkPx> {
|
||||
let (img, max) = open(src, cw, ch, vw, vh, cpp, bits, float)?;
|
||||
if img.num_loaded_keyframes() == 0 {
|
||||
return Err(corrupt("no complete frame"));
|
||||
}
|
||||
let render = img.render_frame(0).map_err(corrupt)?;
|
||||
let fb = render.image_all_channels();
|
||||
let (jw, jh, fc) = (fb.width(), fb.height(), fb.channels());
|
||||
if jw == 0 || jh == 0 || jw > cw || jh > ch || fc < cpp {
|
||||
return Err(corrupt(format!("rendered {jw}×{jh}×{fc}, expected {cw}×{ch}×{cpp}")));
|
||||
}
|
||||
let n = cw.saturating_mul(ch).saturating_mul(cpp);
|
||||
let rows = fb.buf().chunks_exact(jw * fc);
|
||||
Ok(if float {
|
||||
let mut out = vec![0f32; n];
|
||||
for (dst, src) in out.chunks_exact_mut(cw * cpp).zip(rows) {
|
||||
for (d, s) in dst.chunks_exact_mut(cpp).zip(src.chunks_exact(fc)) {
|
||||
d.iter_mut().zip(s).for_each(|(d, s)| *d = *s);
|
||||
}
|
||||
}
|
||||
ChunkPx::F32(out)
|
||||
} else {
|
||||
let mut out = vec![0u16; n];
|
||||
for (dst, src) in out.chunks_exact_mut(cw * cpp).zip(rows) {
|
||||
for (d, s) in dst.chunks_exact_mut(cpp).zip(src.chunks_exact(fc)) {
|
||||
// `as` saturates (NaN → 0): lossy overshoot clips to the integer range
|
||||
d.iter_mut().zip(s).for_each(|(d, s)| *d = (s * max).round() as u16);
|
||||
}
|
||||
}
|
||||
ChunkPx::U16(out)
|
||||
})
|
||||
}
|
||||
+16
-4
@@ -2,16 +2,18 @@
|
||||
//!
|
||||
//! - [`probe`] recognises raw containers; [`decode`] turns a file into a [`RawImage`] (sensor data + everything
|
||||
//! needed to render it: CFA, black/white levels, active area, default crop, orientation, DNG colour tags,
|
||||
//! opcode lists, [`Metadata`]); [`embedded_preview`] returns the largest embedded JPEG.
|
||||
//! opcode lists, [`Metadata`]); [`embedded_preview`] returns the largest embedded JPEG (or DNG 1.7 JPEG XL) preview;
|
||||
//! [`semantic_masks`] reads a DNG's semantic masks (segmentation mattes, e.g. iPhone ProRAW's sky matte).
|
||||
//! - [`RawImage::normalized`] subtracts black, scales white to 1.0 and crops to the active area (applying DNG
|
||||
//! `OpcodeList1`/`OpcodeList2`); [`demosaic`] turns CFA data into camera-RGB [`Rgb32f`];
|
||||
//! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop; [`RawImage::develop_binned`]
|
||||
//! produces the same at 1/k of the size straight from the mosaic (previews, thumbnails).
|
||||
//! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance)
|
||||
//! and produces camera → linear Rec.2020 D65 matrices; [`profile`] reads and applies a DNG's own profile
|
||||
//! look tables and tone curve.
|
||||
//! look tables and tone curve, and [`gaintable`] its gain table map (Apple ProRAW's local tone mapping).
|
||||
//!
|
||||
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
|
||||
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, JPEG XL (DNG 1.7,
|
||||
//! `jxl` feature, on by default), tiled/stripped, CFA and LinearRaw),
|
||||
//! Canon CR2 / CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
|
||||
//! and X-Trans, lossless and lossy compressed), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8,
|
||||
//! packed 2/5/7, the 16-bit words of the oldest bodies), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed).
|
||||
@@ -27,11 +29,15 @@ pub mod color;
|
||||
pub mod demosaic;
|
||||
mod dng;
|
||||
pub mod dngwrite;
|
||||
pub mod gaintable;
|
||||
pub mod highlight;
|
||||
#[cfg(feature = "jxl")]
|
||||
mod jxl;
|
||||
pub mod ljpeg;
|
||||
pub mod opcodes;
|
||||
mod preview;
|
||||
pub mod profile;
|
||||
pub mod semantic;
|
||||
mod tiffraw;
|
||||
mod unpack;
|
||||
mod vendor;
|
||||
@@ -44,6 +50,7 @@ pub use lightcraft_meta::Metadata;
|
||||
pub use lightcraft_raster::Rgb32f;
|
||||
pub use opcodes::{Opcode, OpcodeLists};
|
||||
pub use preview::{PreviewColorSpace, embedded_preview, embedded_preview_color_space};
|
||||
pub use semantic::{SemanticMask, semantic_masks};
|
||||
|
||||
use lightcraft_color::Xy;
|
||||
use lightcraft_tiff::{Tiff, TiffError};
|
||||
@@ -509,8 +516,13 @@ pub struct ColorData {
|
||||
pub as_shot_white_xy: Option<Xy>,
|
||||
/// EV to add for a "normal" rendering (`BaselineExposure` + `BaselineExposureOffset`).
|
||||
pub baseline_exposure: f64,
|
||||
/// `BaselineSharpness`: sharpening relative to a reference camera (`None`: the DNG default,
|
||||
/// 1). Read and written back by DNG export; rendering doesn't use it yet.
|
||||
#[serde(default)]
|
||||
pub baseline_sharpness: Option<f64>,
|
||||
/// The file's own camera-profile look (`ProfileHueSatMap*`, `ProfileLookTable*`,
|
||||
/// `ProfileToneCurve`), applied by [`color`]'s users at render time.
|
||||
/// `ProfileToneCurve`), applied by [`color`]'s users at render time, and its
|
||||
/// `ProfileGainTableMap*`, kept (a DNG export writes it back) but not rendered.
|
||||
#[serde(default)]
|
||||
pub profile: profile::ProfileLook,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Embedded preview extraction: the largest baseline/progressive JPEG stored in a TIFF-based raw (IFD strips
|
||||
//! with JPEG compression, `JPEGInterchangeFormat` pointers in any IFD, Nikon/others' maker-note preview IFDs).
|
||||
//! with JPEG compression, `JPEGInterchangeFormat` pointers in any IFD, Nikon/others' maker-note preview IFDs),
|
||||
//! or a DNG 1.7 JPEG XL preview IFD stored as a single tile/strip (a standalone `.jxl` file, as the spec
|
||||
//! recommends for previews).
|
||||
|
||||
use lightcraft_tiff::image::chunk_bytes;
|
||||
use lightcraft_tiff::tags as t;
|
||||
@@ -56,6 +58,11 @@ fn is_dct_jpeg(b: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether `b` is a JPEG XL file: a bare codestream or the ISO-BMFF container's signature box.
|
||||
fn is_jxl(b: &[u8]) -> bool {
|
||||
b.starts_with(&[0xff, 0x0a]) || b.starts_with(&[0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a])
|
||||
}
|
||||
|
||||
fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>) {
|
||||
// whole JPEG files stored as an undefined-type tag value (e.g. Panasonic `JpgFromRaw` 0x002e)
|
||||
for e in &ifd.entries {
|
||||
@@ -83,9 +90,20 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>)
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
// a rendered (RGB or grey, never CFA/LinearRaw/mask) JPEG XL preview in one chunk
|
||||
if ifd.u16(t::COMPRESSION) == Some(t::compression::JPEG_XL)
|
||||
&& matches!(ifd.u16(t::PHOTOMETRIC), Some(t::photometric::BLACK_IS_ZERO | t::photometric::RGB))
|
||||
&& let Ok(info) = ifd.image()
|
||||
&& let [chunk] = info.chunks(data.len() as u64).as_slice()
|
||||
&& let Some(s) = chunk_bytes(data, chunk)
|
||||
&& is_jxl(s)
|
||||
{
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest embedded JPEG preview, if any.
|
||||
/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file — both decode with
|
||||
/// `lightcraft_codecs::decode`.
|
||||
pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
if bytes.starts_with(b"FUJIFILMCCD-RAW") {
|
||||
let j = crate::vendor::raf::header(bytes).ok()?.jpeg?;
|
||||
@@ -121,7 +139,11 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
if let Some(p) = crate::vendor::orf::preview(bytes) {
|
||||
found.push(p);
|
||||
}
|
||||
let best = found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec());
|
||||
let best = found
|
||||
.into_iter()
|
||||
.filter(|s| is_dct_jpeg(s) || is_jxl(s))
|
||||
.max_by_key(|s| s.len())
|
||||
.map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() });
|
||||
// a TIFF raw we can't decode whose preview no tag points to (Leaf MOS, Epson ERF)
|
||||
best.or_else(|| format.filter(|f| !f.is_supported()).and_then(|_| scan_for_jpeg(bytes)))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! DNG camera-profile look data carried by the file itself (Adobe DNG Specification 1.6/1.7,
|
||||
//! chapter 6 and the `ProfileHueSatMap*`, `ProfileLookTable*` and `ProfileToneCurve` tags).
|
||||
//! chapter 6 and the `ProfileHueSatMap*`, `ProfileLookTable*`, `ProfileToneCurve` and
|
||||
//! `ProfileGainTableMap*` tags).
|
||||
//!
|
||||
//! - A hue/saturation/value table holds, for a grid of (value, hue, saturation) inputs, a hue shift
|
||||
//! in degrees, a saturation scale and a value scale (value-major, hue-middle, saturation-minor).
|
||||
@@ -10,11 +11,17 @@
|
||||
//! - `ProfileHueSatMapData1/2` follow the colour matrices (interpolated by the same illuminant
|
||||
//! weight); `ProfileLookTableData` comes after exposure compensation and before any tone curve.
|
||||
//! - `ProfileToneCurve`: (input, output) pairs in linear gamma from (0, 0) to (1, 1).
|
||||
//! - `ProfileGainTableMap*` ([`crate::gaintable`]) is read and kept (a DNG export writes it back)
|
||||
//! but not rendered: Lightroom Classic renders Apple ProRAW without it (measured on the iPhone
|
||||
//! 12 Pro corpus file against Lightroom Classic 15.6: with the map the default render is far
|
||||
//! brighter than Lightroom's, mean ΔE00 19; without it Lightroom's output is a tight function of
|
||||
//! the hue/saturation map, exposure compensation and tone curve alone).
|
||||
//!
|
||||
//! The data is read from the user's own file at run time; nothing here ships profile data.
|
||||
//! Values above 1.0 (highlight headroom) keep their headroom: the table's value scale only ever
|
||||
//! lowers them, so a lookup can't clip what the scene-referred pipeline still needs.
|
||||
|
||||
use crate::gaintable::GainTableMap;
|
||||
use lightcraft_color::transfer::{linear_to_srgb, srgb_to_linear};
|
||||
use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, bradford};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -207,11 +214,14 @@ pub struct ProfileLook {
|
||||
pub look_table: Option<HsvTable>,
|
||||
/// `ProfileToneCurve`.
|
||||
pub tone_curve: Option<ToneCurve>,
|
||||
/// `ProfileGainTableMap2`, else `ProfileGainTableMap`.
|
||||
#[serde(default)]
|
||||
pub gain_table_map: Option<GainTableMap>,
|
||||
}
|
||||
|
||||
impl ProfileLook {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.hue_sat_map.iter().all(Option::is_none) && self.look_table.is_none() && self.tone_curve.is_none()
|
||||
self.hue_sat_map.iter().all(Option::is_none) && self.look_table.is_none() && self.tone_curve.is_none() && self.gain_table_map.is_none()
|
||||
}
|
||||
|
||||
/// The hue/saturation map for calibration-1 weight `g` (one table: used for any white).
|
||||
@@ -360,7 +370,7 @@ mod tests {
|
||||
let m = HsvTable::blend(&a, &b, 0.25).unwrap();
|
||||
assert_eq!(m.data[1], [7.5, 1.75, 1.0]);
|
||||
assert!(HsvTable::blend(&a, &identity(3, 2, 1), 0.5).is_none());
|
||||
let look = ProfileLook { hue_sat_map: [Some(a.clone()), None], look_table: Some(a), tone_curve: None };
|
||||
let look = ProfileLook { hue_sat_map: [Some(a.clone()), None], look_table: Some(a), tone_curve: None, gain_table_map: None };
|
||||
let t = ProfileTables::new(&look, 0.5).unwrap();
|
||||
let c = [0.3, 0.2, 0.1];
|
||||
assert!(close(t.apply(c, 2.0), [0.6, 0.4, 0.2], 1e-4), "{:?}", t.apply(c, 2.0));
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! DNG semantic masks (DNG 1.6+, chapter "Semantic Masks"): single-channel segmentation mattes
|
||||
//! stored in their own IFDs (NewSubFileType 10004.H, PhotometricInterpretation 52527) with a
|
||||
//! `SemanticName`, an optional `SemanticInstanceID` and an optional `MaskSubArea`. iPhone ProRAW
|
||||
//! stores e.g. a sky matte this way.
|
||||
//!
|
||||
//! [`semantic_masks`] reads them separately from [`crate::decode`] (only callers that use them
|
||||
//! pay for them); malformed or unsupported mask IFDs are skipped.
|
||||
|
||||
use crate::Rect;
|
||||
use crate::tiffraw::{Packing, read_image};
|
||||
use crate::{RawData, Result};
|
||||
use lightcraft_raster::Image;
|
||||
use lightcraft_tiff::Tiff;
|
||||
use lightcraft_tiff::tags::{self as t, photometric};
|
||||
|
||||
/// At most this many mask IFDs are read from one file.
|
||||
pub const MAX_MASKS: usize = 16;
|
||||
/// Masks with more samples than this are skipped (a mask is a low-resolution matte; this is
|
||||
/// 8192 × 4096).
|
||||
pub const MAX_MASK_SAMPLES: usize = 1 << 25;
|
||||
|
||||
/// Where a cropped mask sits in the uncropped mask (DNG `MaskSubArea`). The uncropped mask
|
||||
/// (`full_width × full_height`) spans the main image's active area.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MaskSubArea {
|
||||
pub top: u32,
|
||||
pub left: u32,
|
||||
pub full_width: u32,
|
||||
pub full_height: u32,
|
||||
}
|
||||
|
||||
/// One semantic mask: `width × height` values, 0 = not part of the attribute, 65535 = fully part.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SemanticMask {
|
||||
/// `SemanticName`, e.g. `urn:com:apple:photo:2020:aux:semanticskymatte`.
|
||||
pub name: String,
|
||||
pub instance_id: Option<String>,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
/// `None`: the mask covers the whole active area.
|
||||
pub sub_area: Option<MaskSubArea>,
|
||||
pub data: Vec<u16>,
|
||||
}
|
||||
|
||||
/// The semantic masks of a DNG, in file order (none for other files). Only the first
|
||||
/// [`MAX_MASKS`] mask IFDs are looked at, malformed ones included, so a file full of mask IFDs
|
||||
/// costs at most that many decodes.
|
||||
pub fn semantic_masks(bytes: &[u8]) -> Vec<SemanticMask> {
|
||||
let Ok(tiff) = Tiff::parse(bytes) else { return Vec::new() };
|
||||
tiff.all_ifds()
|
||||
.into_iter()
|
||||
.filter(|i| i.u16(t::PHOTOMETRIC) == Some(photometric::MASK) || i.u32(t::NEW_SUBFILE_TYPE) == Some(t::SUBFILE_SEMANTIC_MASK))
|
||||
.take(MAX_MASKS)
|
||||
.filter_map(|ifd| read_mask(bytes, &tiff, ifd).ok().flatten())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_mask(bytes: &[u8], tiff: &Tiff, ifd: &lightcraft_tiff::Ifd) -> Result<Option<SemanticMask>> {
|
||||
let Some(name) = ifd.string(t::SEMANTIC_NAME).filter(|n| !n.is_empty()) else { return Ok(None) };
|
||||
let info = ifd.image()?;
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
if info.samples_per_pixel != 1 || w.checked_mul(h).is_none_or(|n| n > MAX_MASK_SAMPLES) {
|
||||
return Ok(None);
|
||||
}
|
||||
let bits = info.bits() as u32;
|
||||
let data: Vec<u16> = match read_image(bytes, &info, tiff.order, Packing::Msb)? {
|
||||
RawData::U16(v) if (1..=16).contains(&bits) => {
|
||||
let max = ((1u32 << bits) - 1) as f32;
|
||||
if bits == 16 { v } else { v.into_iter().map(|s| (s as f32 / max * 65535.0).round().min(65535.0) as u16).collect() }
|
||||
}
|
||||
RawData::F32(v) => v.into_iter().map(|s| if s.is_finite() { (s.clamp(0.0, 1.0) * 65535.0).round() as u16 } else { 0 }).collect(),
|
||||
RawData::U16(_) => return Ok(None),
|
||||
};
|
||||
if data.len() != w * h {
|
||||
return Ok(None);
|
||||
}
|
||||
// the tag is ignored when the stored mask does not fit inside the uncropped one
|
||||
let sub_area = match ifd.u64s(t::MASK_SUB_AREA).as_deref() {
|
||||
Some(&[top, left, fw, fh]) => {
|
||||
let fits = fw > 0 && fh > 0 && left.saturating_add(w as u64) <= fw && top.saturating_add(h as u64) <= fh && fw.max(fh) <= u32::MAX as u64;
|
||||
fits.then_some(MaskSubArea { top: top as u32, left: left as u32, full_width: fw as u32, full_height: fh as u32 })
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Ok(Some(SemanticMask { name, instance_id: ifd.string(t::SEMANTIC_INSTANCE_ID).filter(|s| !s.is_empty()), width: w, height: h, sub_area, data }))
|
||||
}
|
||||
|
||||
impl SemanticMask {
|
||||
/// The mask value (0..1) at a continuous position of the uncropped mask (pixel centres at
|
||||
/// +0.5); 0 outside the stored (cropped) mask.
|
||||
pub fn value_at(&self, x: f64, y: f64) -> f32 {
|
||||
let (left, top) = self.sub_area.map_or((0.0, 0.0), |a| (a.left as f64, a.top as f64));
|
||||
let (x, y) = (x - left, y - top);
|
||||
if self.width == 0 || self.height == 0 || !(x >= 0.0 && y >= 0.0 && x <= self.width as f64 && y <= self.height as f64) {
|
||||
return 0.0;
|
||||
}
|
||||
let (fx, fy) = ((x - 0.5).max(0.0), (y - 0.5).max(0.0));
|
||||
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
|
||||
let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32);
|
||||
let at = |x: usize, y: usize| {
|
||||
let (x, y) = (x.min(self.width - 1), y.min(self.height - 1));
|
||||
self.data.get(y * self.width + x).copied().unwrap_or(0) as f32 / 65535.0
|
||||
};
|
||||
let top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * tx;
|
||||
let bottom = at(x0, y0 + 1) + (at(x0 + 1, y0 + 1) - at(x0, y0 + 1)) * tx;
|
||||
top + (bottom - top) * ty
|
||||
}
|
||||
|
||||
/// The mask over the developed image ([`crate::RawImage::develop`]: the default `crop` of the
|
||||
/// `active_area`, before orientation), at the mask's own resolution, as 0..255 alpha. `None`
|
||||
/// for an empty crop.
|
||||
pub fn developed(&self, active_area: Rect, crop: Rect) -> Option<Image<u8>> {
|
||||
let c = crop.clipped(active_area.width, active_area.height);
|
||||
if c.width == 0 || c.height == 0 || self.width == 0 || self.height == 0 {
|
||||
return None;
|
||||
}
|
||||
let (fw, fh) = self.sub_area.map_or((self.width, self.height), |a| (a.full_width as usize, a.full_height as usize));
|
||||
// uncropped-mask px per active-area px
|
||||
let (sx, sy) = (fw as f64 / active_area.width as f64, fh as f64 / active_area.height as f64);
|
||||
let ow = ((c.width as f64 * sx).round() as usize).clamp(1, self.width.max(fw));
|
||||
let oh = ((c.height as f64 * sy).round() as usize).clamp(1, self.height.max(fh));
|
||||
if ow.checked_mul(oh).is_none_or(|n| n > MAX_MASK_SAMPLES) {
|
||||
return None;
|
||||
}
|
||||
let (kx, ky) = (c.width as f64 / ow as f64, c.height as f64 / oh as f64);
|
||||
let mut out = Image::new(ow, oh);
|
||||
lightcraft_raster::par_rows(&mut out.data, ow, |y, row| {
|
||||
let ay = c.y as f64 + (y as f64 + 0.5) * ky;
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let ax = c.x as f64 + (x as f64 + 0.5) * kx;
|
||||
*v = (self.value_at(ax * sx, ay * sy) * 255.0).round() as u8;
|
||||
}
|
||||
});
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Reading the pixel data of a TIFF IFD (strips or tiles; uncompressed, lossless JPEG, Deflate), in parallel.
|
||||
//! Reading the pixel data of a TIFF IFD (strips or tiles; uncompressed, lossless/lossy JPEG, Deflate, JPEG XL), in parallel.
|
||||
|
||||
use crate::unpack::*;
|
||||
use crate::{MAX_SAMPLES, RawData, RawError, Result, ljpeg};
|
||||
@@ -21,7 +21,7 @@ pub enum Packing {
|
||||
Words32Msb,
|
||||
}
|
||||
|
||||
enum ChunkPx {
|
||||
pub(crate) enum ChunkPx {
|
||||
U16(Vec<u16>),
|
||||
F32(Vec<f32>),
|
||||
}
|
||||
@@ -64,11 +64,24 @@ pub(crate) fn read_image_in(mode: crate::Mode, data: &[u8], info: &ImageInfo, or
|
||||
crate::Mode::Header => {
|
||||
check_image(data, info)?;
|
||||
// a lossless-JPEG layout the decoder rejects (subsampled components, e.g. Sony's
|
||||
// lossless M/S sizes) must fail here too, as the full decode will
|
||||
if info.compression == 7
|
||||
&& let Some(src) = info.chunks(data.len() as u64).first().and_then(|c| chunk_bytes(data, c))
|
||||
// lossless M/S sizes) must fail here too, as the full decode will; likewise a JPEG XL
|
||||
// chunk whose headers don't match the IFD
|
||||
let first = info.chunks(data.len() as u64).first().copied();
|
||||
if let Some(c) = first
|
||||
&& let Some(src) = chunk_bytes(data, &c)
|
||||
{
|
||||
ljpeg::frame_info(src)?;
|
||||
match info.compression {
|
||||
7 => {
|
||||
ljpeg::frame_info(src)?;
|
||||
}
|
||||
comp::JPEG_XL => {
|
||||
let planar = info.planar == 2 && info.samples_per_pixel > 1;
|
||||
let cpp = if planar { 1 } else { info.samples_per_pixel as usize };
|
||||
let (cw, ch, vw, vh) = chunk_dims(info, &c);
|
||||
jxl_chunk_check(src, cw, ch, vw, vh, cpp, info.bits() as u32, info.sample_format == 3)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(if info.sample_format == 3 { RawData::F32(Vec::new()) } else { RawData::U16(Vec::new()) })
|
||||
}
|
||||
@@ -141,7 +154,7 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, packing: Packing, cpp: usize, bits: u32, float: bool) -> Result<ChunkPx> {
|
||||
let src = chunk_bytes(data, c).ok_or_else(|| RawError::Corrupt("chunk offset past end of file".into()))?;
|
||||
let (cw, ch) = (c.width as usize, c.height as usize);
|
||||
let (cw, ch, vw, vh) = chunk_dims(info, c);
|
||||
let n = cw.checked_mul(ch).and_then(|v| v.checked_mul(cpp)).ok_or(RawError::Limit("chunk too large"))?;
|
||||
if n > MAX_SAMPLES {
|
||||
return Err(RawError::Limit("chunk too large"));
|
||||
@@ -179,10 +192,34 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
|
||||
let raw = inflate(src, row_bytes * ch)?;
|
||||
unpack_chunk(&raw, order, packing, cw, ch, cpp, bits, float, info.predictor)
|
||||
}
|
||||
comp::JPEG_XL => jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float),
|
||||
other => Err(RawError::Unsupported(format!("TIFF compression {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A chunk's nominal size and the part of it inside the image (smaller for edge tiles).
|
||||
fn chunk_dims(info: &ImageInfo, c: &Chunk) -> (usize, usize, usize, usize) {
|
||||
let (cw, ch) = (c.width as usize, c.height as usize);
|
||||
let vw = cw.min(info.width.saturating_sub(c.x) as usize);
|
||||
let vh = ch.min(info.height.saturating_sub(c.y) as usize);
|
||||
(cw, ch, vw, vh)
|
||||
}
|
||||
|
||||
#[cfg(feature = "jxl")]
|
||||
use crate::jxl::{check as jxl_chunk_check, decode as jxl_chunk};
|
||||
|
||||
#[cfg(not(feature = "jxl"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn jxl_chunk(_: &[u8], _: usize, _: usize, _: usize, _: usize, _: usize, _: u32, _: bool) -> Result<ChunkPx> {
|
||||
Err(RawError::Unsupported("JPEG XL DNG (built without the `jxl` feature)".into()))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "jxl"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn jxl_chunk_check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<()> {
|
||||
jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float).map(|_| ())
|
||||
}
|
||||
|
||||
/// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels).
|
||||
fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
|
||||
use zune_core::bytestream::ZCursor;
|
||||
|
||||
@@ -43,7 +43,9 @@ fn corpus_raw_decodes() {
|
||||
// DNG previews are optional (and some carry only an uncompressed RGB thumbnail); vendor raws embed a JPEG,
|
||||
// except the Panasonic `.RAW` files of 2005–2007 and Canon's explicit HEVC preview tracks.
|
||||
if let Some(p) = &preview {
|
||||
assert!(p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]), "{name}: preview is not a JPEG");
|
||||
let jpeg = p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]);
|
||||
let jxl = p.starts_with(&[0xff, 0x0a]) || p.starts_with(b"\0\0\0\x0cJXL ");
|
||||
assert!(jpeg || jxl, "{name}: preview is neither a JPEG nor a JPEG XL file");
|
||||
} else {
|
||||
let hevc_preview = fmt == RawFormat::Cr3
|
||||
&& lightcraft_meta::cr3::parse_cr3(&bytes)
|
||||
@@ -250,9 +252,11 @@ fn corpus_nef_12_bit_black_level_matches_14_bit() {
|
||||
}
|
||||
|
||||
/// Issue #138: DNGs converted by Adobe software carry their camera profile's hue/saturation map and
|
||||
/// look table; we read them (and render with them). Camera-written DNGs here carry none.
|
||||
/// look table; we read them (and render with them). Apple ProRAW carries a tone curve and a gain
|
||||
/// table map (its local tone mapping, read and kept but not rendered, as in Lightroom Classic).
|
||||
/// Other camera-written DNGs here carry none.
|
||||
#[test]
|
||||
fn corpus_adobe_dngs_carry_profile_looks() {
|
||||
fn corpus_dngs_carry_profile_looks() {
|
||||
let dir = corpus_root().join("raw");
|
||||
let Ok(rd) = std::fs::read_dir(&dir) else {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
@@ -276,14 +280,51 @@ fn corpus_adobe_dngs_carry_profile_looks() {
|
||||
let g = t.apply([0.18; 3], 1.0);
|
||||
assert!(g.iter().all(|v| (v - g[0]).abs() < 0.01 * g[0].max(0.01)), "{name}: grey → {g:?}");
|
||||
}
|
||||
if name.starts_with("dng-apple-") {
|
||||
seen += 1;
|
||||
let map = look.gain_table_map.as_ref().unwrap_or_else(|| panic!("{name}: no gain table map"));
|
||||
assert!(map.points_v > 1 && map.points_h > 1 && map.points_n > 1, "{name}");
|
||||
assert!(look.tone_curve.is_some(), "{name}: no tone curve");
|
||||
// the map lifts dark tones (Apple's local tone mapping), most at the darkest input
|
||||
let first = &map.gains[..map.points_n];
|
||||
assert!(first[0] > 1.5 && first[0] >= first[map.points_n - 1], "{name}: first table {first:?}");
|
||||
}
|
||||
eprintln!(
|
||||
"{name:44} profile look: hsm {} look {} tone {}",
|
||||
"{name:44} profile look: hsm {} look {} tone {} gain map {}",
|
||||
look.hue_sat_map[0].is_some(),
|
||||
look.look_table.is_some(),
|
||||
look.tone_curve.is_some()
|
||||
look.tone_curve.is_some(),
|
||||
look.gain_table_map.is_some()
|
||||
);
|
||||
}
|
||||
eprintln!("{seen} Adobe-converted DNGs checked");
|
||||
eprintln!("{seen} DNGs with profile looks checked");
|
||||
}
|
||||
|
||||
/// iPhone ProRAW (`dng-apple-iphone12pro-proraw.dng`, iPhone 12 Pro, CC0 from raw.pixls.us, see
|
||||
/// `cargo xtask corpus`) carries a lossy-JPEG sky matte at half resolution as a DNG semantic mask:
|
||||
/// it decodes, and it is a sky. The matte is stored like the raw (landscape; the photo is shown
|
||||
/// rotated 90° clockwise), so the sky is on its left and the lake and shore on its right.
|
||||
#[test]
|
||||
fn corpus_proraw_sky_matte() {
|
||||
let path = corpus_root().join("raw/dng-apple-iphone12pro-proraw.dng");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
eprintln!("skip: {} absent", path.display());
|
||||
return;
|
||||
};
|
||||
let t0 = Instant::now();
|
||||
let masks = lightcraft_raw::semantic_masks(&bytes);
|
||||
eprintln!("semantic masks read in {:.1} ms", t0.elapsed().as_secs_f64() * 1e3);
|
||||
let names: Vec<&str> = masks.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(names, ["urn:com:apple:photo:2020:aux:semanticskymatte"]);
|
||||
let m = &masks[0];
|
||||
assert_eq!((m.width, m.height, m.sub_area), (2016, 1512, None));
|
||||
let cols = |x0: usize, x1: usize| {
|
||||
let sum: f64 = (0..m.height).flat_map(|y| m.data[y * m.width + x0..y * m.width + x1].iter()).map(|&v| v as f64 / 65535.0).sum();
|
||||
sum / ((x1 - x0) * m.height) as f64
|
||||
};
|
||||
let (left, right) = (cols(0, 100), cols(1916, 2016));
|
||||
eprintln!("sky matte: left columns {left:.3}, right columns {right:.3}");
|
||||
assert!(left > 0.5 && right < 0.02, "left {left}, right {right}");
|
||||
}
|
||||
|
||||
/// Issue #148: Sony ARWs from before ~2017 carry no plain white-balance, black-level or crop tags in the raw IFD.
|
||||
|
||||
@@ -39,6 +39,7 @@ fn synthetic(w: usize, h: usize, cfa: Option<Cfa>, cpp: usize) -> RawImage {
|
||||
],
|
||||
as_shot_neutral: Some([0.5, 1.0, 0.7]),
|
||||
baseline_exposure: 0.35,
|
||||
baseline_sharpness: Some(1.5),
|
||||
..Default::default()
|
||||
};
|
||||
let metadata = lightcraft_meta::Metadata { make: Some("Synth".into()), model: Some("Cam 1".into()), rating: Some(3), ..Default::default() };
|
||||
@@ -84,6 +85,7 @@ fn assert_same(a: &RawImage, b: &RawImage) {
|
||||
let (n1, n2) = (a.color.as_shot_neutral.unwrap(), b.color.as_shot_neutral.unwrap());
|
||||
assert!((0..3).all(|i| (n1[i] - n2[i]).abs() < 1e-6));
|
||||
assert!((a.color.baseline_exposure - b.color.baseline_exposure).abs() < 1e-6);
|
||||
assert!(b.color.baseline_sharpness.is_some_and(|v| (v - 1.5).abs() < 1e-6), "{:?}", b.color.baseline_sharpness);
|
||||
assert_eq!(b.metadata.make.as_deref(), Some("Synth"));
|
||||
assert_eq!(b.metadata.rating, Some(3));
|
||||
}
|
||||
@@ -548,3 +550,422 @@ fn malformed_profile_look_tags_are_ignored() {
|
||||
let plain = decode(&profile_dng(ByteOrder::Little, |_| {})).unwrap();
|
||||
assert!(plain.color.profile.is_empty());
|
||||
}
|
||||
|
||||
/// Lossless JPEG XL codestream of `px` (`w × h × cpp` interleaved), 16-bit unless `eight`.
|
||||
fn jxl_encode(px: &[u16], w: usize, h: usize, cpp: usize, eight: bool) -> Vec<u8> {
|
||||
use zune_core::bit_depth::BitDepth;
|
||||
use zune_core::colorspace::ColorSpace;
|
||||
use zune_core::options::EncoderOptions;
|
||||
let cs = if cpp == 1 { ColorSpace::Luma } else { ColorSpace::RGB };
|
||||
let (bytes, depth): (Vec<u8>, _) = if eight {
|
||||
(px.iter().map(|&v| v as u8).collect(), BitDepth::Eight)
|
||||
} else {
|
||||
(px.iter().flat_map(|v| v.to_ne_bytes()).collect(), BitDepth::Sixteen)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
zune_jpegxl::JxlSimpleEncoder::new(&bytes, EncoderOptions::new(w, h, cs, depth)).encode(&mut out).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A bare codestream wrapped in the ISO-BMFF container (signature, `ftyp`, `jxlc`).
|
||||
fn jxl_container(cs: &[u8]) -> Vec<u8> {
|
||||
let mut b = vec![0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a, 0, 0, 0, 0x14];
|
||||
b.extend_from_slice(b"ftypjxl \0\0\0\0jxl ");
|
||||
b.extend_from_slice(&((cs.len() + 8) as u32).to_be_bytes());
|
||||
b.extend_from_slice(b"jxlc");
|
||||
b.extend_from_slice(cs);
|
||||
b
|
||||
}
|
||||
|
||||
/// A tiled JPEG XL raw IFD of `px`. Edge tiles alternate between being coded at their in-image size
|
||||
/// and padded to the nominal tile size (with junk the decoder must drop); tiles alternate between bare
|
||||
/// codestreams and containers.
|
||||
fn jxl_tiled(px: &[u16], w: usize, h: usize, cpp: usize, bits: u16, cfa: bool, tile: (usize, usize)) -> IfdBuilder {
|
||||
let (tw, th) = tile;
|
||||
let mut tiles = Vec::new();
|
||||
for ty in 0..h.div_ceil(th) {
|
||||
for tx in 0..w.div_ceil(tw) {
|
||||
let i = tiles.len();
|
||||
let (vw, vh) = (tw.min(w - tx * tw), th.min(h - ty * th));
|
||||
let (cw, ch) = if i % 2 == 0 { (tw, th) } else { (vw, vh) };
|
||||
let mut t = vec![bits.min(8) * 7; cw * ch * cpp];
|
||||
for y in 0..vh {
|
||||
for x in 0..vw {
|
||||
for s in 0..cpp {
|
||||
t[(y * cw + x) * cpp + s] = px[((ty * th + y) * w + tx * tw + x) * cpp + s];
|
||||
}
|
||||
}
|
||||
}
|
||||
let cs = jxl_encode(&t, cw, ch, cpp, bits == 8);
|
||||
tiles.push(if i % 3 == 1 { jxl_container(&cs) } else { cs });
|
||||
}
|
||||
}
|
||||
let mut raw = base_ifd(w, h, bits, cpp, cfa);
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
|
||||
raw.set_image(ImageData::Tiles { tile_width: tw as u32, tile_height: th as u32, tiles });
|
||||
raw
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_xl_tiles_decode_bit_exactly() {
|
||||
let (w, h) = (37, 29);
|
||||
// LinearRaw 3 × 16-bit, the full code range
|
||||
let px = pattern(w, h, 3, 16);
|
||||
let bytes = dng(jxl_tiled(&px, w, h, 3, 16, false, (16, 16)), ByteOrder::Big);
|
||||
let img = decode(&bytes).unwrap();
|
||||
assert_eq!((img.width, img.height, img.cpp), (w, h, 3));
|
||||
assert_eq!(img.data, RawData::U16(px));
|
||||
assert_eq!(lightcraft_raw::probe_info(&bytes).unwrap(), img.info());
|
||||
// CFA 1 × 8/12/14-bit
|
||||
for bits in [8u32, 12, 14] {
|
||||
let px = pattern(w, h, 1, bits);
|
||||
let img = decode(&dng(jxl_tiled(&px, w, h, 1, bits as u16, true, (16, 8)), ByteOrder::Little)).unwrap();
|
||||
assert_eq!(img.data, RawData::U16(px), "{bits} bits");
|
||||
assert_eq!(img.white, vec![((1u32 << bits) - 1) as f32]);
|
||||
assert_eq!(img.cfa.unwrap().name(), "GRBG");
|
||||
}
|
||||
// strips: a single JPEG XL strip per 8 rows (the last one shorter)
|
||||
let px = pattern(w, h, 1, 12);
|
||||
let strips = px.chunks(w * 8).map(|c| jxl_encode(c, w, c.len() / w, 1, false)).collect();
|
||||
let mut raw = base_ifd(w, h, 12, 1, true);
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: 8, strips });
|
||||
assert_eq!(decode(&dng(raw, ByteOrder::Big)).unwrap().data, RawData::U16(px));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_jpeg_xl_tiles_are_errors() {
|
||||
use lightcraft_raw::RawError;
|
||||
let (w, h) = (16, 16);
|
||||
let px = pattern(w, h, 1, 12);
|
||||
let good = jxl_encode(&px, w, h, 1, false);
|
||||
let one = |tile: Vec<u8>, cpp: usize| {
|
||||
let mut raw = base_ifd(w, h, 12, cpp, cpp == 1);
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
|
||||
raw.set_image(ImageData::Tiles { tile_width: w as u32, tile_height: h as u32, tiles: vec![tile] });
|
||||
dng(raw, ByteOrder::Little)
|
||||
};
|
||||
// garbage, every truncation, and flipped bytes: errors (or, for a truncation that only drops
|
||||
// trailing padding, the exact samples), never panics
|
||||
let mut garbage = vec![0xff, 0x0a];
|
||||
garbage.extend((0..400u32).map(|i| (i.wrapping_mul(2654435761) >> 13) as u8));
|
||||
assert!(matches!(decode(&one(garbage, 1)), Err(RawError::Corrupt(_))));
|
||||
for n in 0..good.len() {
|
||||
let bytes = one(good[..n].to_vec(), 1);
|
||||
if let Ok(img) = decode(&bytes) {
|
||||
assert_eq!(img.data, RawData::U16(px.clone()), "truncated to {n}");
|
||||
}
|
||||
let _ = lightcraft_raw::probe_info(&bytes);
|
||||
}
|
||||
for i in 0..good.len() {
|
||||
let mut b = good.clone();
|
||||
b[i] ^= 0x5a;
|
||||
let _ = decode(&one(b, 1));
|
||||
}
|
||||
// a tile of the wrong size, or the wrong plane count for the IFD
|
||||
let small = jxl_encode(&px[..w * 8], w, 8, 1, false);
|
||||
assert!(matches!(decode(&one(small, 1)), Err(RawError::Corrupt(_))));
|
||||
assert!(matches!(decode(&one(good.clone(), 3)), Err(RawError::Corrupt(_))));
|
||||
assert!(matches!(lightcraft_raw::probe_info(&one(good, 3)), Err(RawError::Corrupt(_))));
|
||||
}
|
||||
|
||||
/// The decoder's memory is bounded by the tile: a grey tile that also codes an alpha channel needs
|
||||
/// about twice what one plane may use, and is an error rather than an allocation the header asks
|
||||
/// for. The same samples without the alpha channel decode.
|
||||
#[test]
|
||||
fn jpeg_xl_tiles_beyond_their_decode_budget_are_errors() {
|
||||
use lightcraft_raw::RawError;
|
||||
use zune_core::bit_depth::BitDepth;
|
||||
use zune_core::colorspace::ColorSpace;
|
||||
use zune_core::options::EncoderOptions;
|
||||
let (w, h) = (512, 512);
|
||||
let grey: Vec<u16> = (0..w * h).map(|i| ((i % w) * 97 + (i / w) * 31) as u16 & 0x0fff).collect();
|
||||
let with_alpha: Vec<u8> = grey.iter().flat_map(|&v| [v, 4095 - v]).flat_map(u16::to_ne_bytes).collect();
|
||||
let mut tile = Vec::new();
|
||||
zune_jpegxl::JxlSimpleEncoder::new(&with_alpha, EncoderOptions::new(w, h, ColorSpace::LumaA, BitDepth::Sixteen)).encode(&mut tile).unwrap();
|
||||
let one = |tile: Vec<u8>| {
|
||||
let mut raw = base_ifd(w, h, 12, 1, true);
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
|
||||
raw.set_image(ImageData::Tiles { tile_width: w as u32, tile_height: h as u32, tiles: vec![tile] });
|
||||
dng(raw, ByteOrder::Little)
|
||||
};
|
||||
assert!(matches!(decode(&one(tile)), Err(RawError::Corrupt(_))));
|
||||
assert_eq!(decode(&one(jxl_encode(&grey, w, h, 1, false))).unwrap().data, RawData::U16(grey));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_xl_preview_ifd_is_offered() {
|
||||
let raw = synthetic(16, 16, Some(Cfa::bayer("RGGB").unwrap()), 1);
|
||||
let RawData::U16(px) = &raw.data else { unreachable!() };
|
||||
let mut sub = base_ifd(16, 16, 14, 1, true);
|
||||
sub.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
|
||||
sub.set_image(ImageData::Tiles { tile_width: 16, tile_height: 16, tiles: vec![jxl_encode(px, 16, 16, 1, false)] });
|
||||
let preview = jxl_container(&jxl_encode(&[200; 4 * 2 * 3], 4, 2, 3, true));
|
||||
let bytes = dng_with(sub, ByteOrder::Big, |ifd0| {
|
||||
ifd0.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
|
||||
ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![preview.clone()] });
|
||||
});
|
||||
// the rendered RGB preview is offered whole; the (single-tile) CFA raw never is
|
||||
assert_eq!(embedded_preview(&bytes).unwrap(), preview);
|
||||
assert_eq!(decode(&bytes).unwrap().data, raw.data);
|
||||
}
|
||||
|
||||
/// `ProfileGainTableMap` / `ProfileGainTableMap2` bytes in `order`: a `dims` = (V, H, N) grid with
|
||||
/// spacing 0.5 and origin 0.25, green-only input weights, `v2` = (data type, gamma, gain min,
|
||||
/// gain max), then `data` (the stored gains, already in `order`).
|
||||
fn gain_map_tag(order: ByteOrder, dims: [u32; 3], v2: Option<(u32, f32, f32, f32)>, data: &[u8]) -> Vec<u8> {
|
||||
let be = order == ByteOrder::Big;
|
||||
let u = |v: u32| if be { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(&u(dims[0]));
|
||||
b.extend_from_slice(&u(dims[1]));
|
||||
for v in [0.5f64, 0.5, 0.25, 0.25] {
|
||||
b.extend_from_slice(&if be { v.to_be_bytes() } else { v.to_le_bytes() });
|
||||
}
|
||||
b.extend_from_slice(&u(dims[2]));
|
||||
for w in [0.0f32, 1.0, 0.0, 0.0, 0.0] {
|
||||
b.extend_from_slice(&u(w.to_bits()));
|
||||
}
|
||||
if let Some((dt, gamma, min, max)) = v2 {
|
||||
for v in [dt, gamma.to_bits(), min.to_bits(), max.to_bits()] {
|
||||
b.extend_from_slice(&u(v));
|
||||
}
|
||||
}
|
||||
b.extend_from_slice(data);
|
||||
b
|
||||
}
|
||||
|
||||
fn f32_bytes(v: &[f32], order: ByteOrder) -> Vec<u8> {
|
||||
v.iter().flat_map(|f| if order == ByteOrder::Big { f.to_be_bytes() } else { f.to_le_bytes() }).collect()
|
||||
}
|
||||
|
||||
/// [`profile_dng`] with binary tags in the raw IFD (where Apple writes `ProfileGainTableMap`) and
|
||||
/// in IFD 0.
|
||||
fn gain_map_dng(order: ByteOrder, raw_tags: &[(u16, &[u8])], ifd0_tags: &[(u16, &[u8])]) -> Vec<u8> {
|
||||
let (w, h) = (8, 6);
|
||||
let px = pattern(w, h, 3, 16);
|
||||
let mut raw = base_ifd(w, h, 16, 3, false);
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![u16_bytes(&px, order)] });
|
||||
for (tag, b) in raw_tags {
|
||||
raw.set(*tag, Value::Undefined(b.to_vec()));
|
||||
}
|
||||
dng_with(raw, order, |ifd0| {
|
||||
for (tag, b) in ifd0_tags {
|
||||
ifd0.set(*tag, Value::Undefined(b.to_vec()));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gain_table_maps_follow_dng_precedence() {
|
||||
const V1: u16 = t::PROFILE_GAIN_TABLE_MAP;
|
||||
const V2: u16 = t::PROFILE_GAIN_TABLE_MAP_2;
|
||||
for order in [ByteOrder::Big, ByteOrder::Little] {
|
||||
let v1 = gain_map_tag(order, [2, 2, 4], None, &f32_bytes(&(1..=16).map(|i| i as f32 * 0.25).collect::<Vec<_>>(), order));
|
||||
// Apple's layout: version 1 in the raw IFD, in the file's byte order
|
||||
let apple = gain_map_dng(order, &[(V1, &v1[..])], &[]);
|
||||
let img = decode(&apple).unwrap();
|
||||
let map = img.color.profile.gain_table_map.clone().expect("gain table map");
|
||||
assert_eq!((map.points_v, map.points_h, map.points_n, map.gamma), (2, 2, 4, 1.0));
|
||||
assert_eq!((map.spacing_v, map.origin_h, map.weights[1]), (0.5, 0.25, 1.0));
|
||||
assert_eq!(map.gains[5], 1.5);
|
||||
assert_eq!(lightcraft_raw::probe_info(&apple).unwrap().color.profile, img.color.profile);
|
||||
// our DNG writer keeps it
|
||||
let again = decode(&write_dng(&img, &DngWriteOptions::default()).unwrap()).unwrap();
|
||||
assert_eq!(again.color.profile.gain_table_map.as_ref(), Some(&map));
|
||||
// version 1 in IFD 0 (where DNG 1.7 says it was meant to go) is read too
|
||||
let in_ifd0 = decode(&gain_map_dng(order, &[], &[(V1, &v1[..])])).unwrap();
|
||||
assert_eq!(in_ifd0.color.profile.gain_table_map.as_ref(), Some(&map));
|
||||
// version 2 in IFD 0 supersedes version 1: 8-bit gains over 0.5..=1.5, gamma 2
|
||||
let v2 = gain_map_tag(order, [1, 1, 2], Some((0, 2.0, 0.5, 1.5)), &[0, 255]);
|
||||
let img = decode(&gain_map_dng(order, &[(V1, &v1[..])], &[(V2, &v2[..])])).unwrap();
|
||||
let map2 = img.color.profile.gain_table_map.clone().unwrap();
|
||||
assert_eq!((map2.points_n, map2.gamma, map2.gains.clone()), (2, 2.0, vec![0.5, 1.5]));
|
||||
// a gamma ≠ 1 survives our writer (as version 2)
|
||||
let again = decode(&write_dng(&img, &DngWriteOptions::default()).unwrap()).unwrap();
|
||||
assert_eq!(again.color.profile.gain_table_map, Some(map2));
|
||||
// a malformed version 2 (gamma outside 0.25..=4) falls back to version 1
|
||||
let bad_v2 = gain_map_tag(order, [1, 1, 2], Some((0, 9.0, 0.5, 1.5)), &[0, 255]);
|
||||
assert_eq!(decode(&gain_map_dng(order, &[(V1, &v1[..])], &[(V2, &bad_v2[..])])).unwrap().color.profile.gain_table_map, Some(map));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_gain_table_maps_are_ignored() {
|
||||
let order = ByteOrder::Little;
|
||||
let ok = gain_map_tag(order, [1, 1, 2], None, &f32_bytes(&[1.0, 2.0], order));
|
||||
let cases: Vec<(&str, Vec<u8>, bool)> = vec![
|
||||
("valid", ok.clone(), false),
|
||||
("empty", vec![], false),
|
||||
("header only", ok[..64].to_vec(), false),
|
||||
("one byte short", ok[..ok.len() - 1].to_vec(), false),
|
||||
("NaN gain", gain_map_tag(order, [1, 1, 2], None, &f32_bytes(&[1.0, f32::NAN], order)), false),
|
||||
("negative gain", gain_map_tag(order, [1, 1, 2], None, &f32_bytes(&[1.0, -2.0], order)), false),
|
||||
("huge dimensions", gain_map_tag(order, [u32::MAX, u32::MAX, u32::MAX], None, &f32_bytes(&[1.0, 2.0], order)), false),
|
||||
("zero points", gain_map_tag(order, [1, 0, 2], None, &[]), false),
|
||||
("unknown data type", gain_map_tag(order, [1, 1, 2], Some((9, 1.0, 0.0, 1.0)), &[0, 0]), true),
|
||||
("NaN gain range", gain_map_tag(order, [1, 1, 2], Some((0, 1.0, f32::NAN, 1.0)), &[0, 255]), true),
|
||||
];
|
||||
for (what, bytes, v2) in cases {
|
||||
let tag = if v2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP };
|
||||
let img = decode(&gain_map_dng(order, &[(tag, &bytes[..])], &[])).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(img.color.profile.gain_table_map.is_some(), what == "valid", "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The map is read in the file's byte order, else big-endian (`lightcraft_raw::gaintable`): a
|
||||
/// big-endian payload in a little-endian file is read, a little-endian one in a big-endian file
|
||||
/// is not.
|
||||
#[test]
|
||||
fn gain_table_map_byte_orders() {
|
||||
use ByteOrder::{Big, Little};
|
||||
let payload = |o: ByteOrder| gain_map_tag(o, [1, 1, 2], None, &f32_bytes(&[1.0, 2.0], o));
|
||||
for (file, data, read) in [(Little, Little, true), (Little, Big, true), (Big, Big, true), (Big, Little, false)] {
|
||||
let img = decode(&gain_map_dng(file, &[(t::PROFILE_GAIN_TABLE_MAP, &payload(data)[..])], &[])).unwrap();
|
||||
assert_eq!(img.color.profile.gain_table_map.map(|m| m.gains), read.then(|| vec![1.0, 2.0]), "{data:?} payload, {file:?} file");
|
||||
}
|
||||
}
|
||||
|
||||
/// A semantic mask IFD (8-bit, uncompressed) named `name`.
|
||||
fn mask_ifd(w: usize, h: usize, name: &str, px: Vec<u8>) -> IfdBuilder {
|
||||
let mut m = IfdBuilder::new();
|
||||
m.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![t::SUBFILE_SEMANTIC_MASK]));
|
||||
m.set(t::IMAGE_WIDTH, Value::Long(vec![w as u32]));
|
||||
m.set(t::IMAGE_LENGTH, Value::Long(vec![h as u32]));
|
||||
m.set(t::BITS_PER_SAMPLE, Value::Short(vec![8]));
|
||||
m.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
|
||||
m.set(t::PHOTOMETRIC, Value::Short(vec![photometric::MASK]));
|
||||
m.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
if !name.is_empty() {
|
||||
m.set(t::SEMANTIC_NAME, Value::Ascii(name.into()));
|
||||
}
|
||||
m.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![px] });
|
||||
m
|
||||
}
|
||||
|
||||
/// Semantic masks: a cropped mask is placed by its `MaskSubArea` (zero outside it), mapped onto the
|
||||
/// developed image (default crop of the active area), and doesn't disturb the raw decode.
|
||||
#[test]
|
||||
fn semantic_masks_are_read_and_placed() {
|
||||
use lightcraft_raw::semantic::MaskSubArea;
|
||||
for order in [ByteOrder::Little, ByteOrder::Big] {
|
||||
let bytes = profile_dng(order, |ifd0| {
|
||||
// a 4 × 2 crop at (top 1, left 2) of an 8 × 4 mask over the 8 × 6 image
|
||||
let mut sky = mask_ifd(4, 2, "sky", vec![10, 20, 30, 40, 50, 60, 70, 255]);
|
||||
sky.set(t::MASK_SUB_AREA, Value::Long(vec![1, 2, 8, 4]));
|
||||
ifd0.add_sub_ifd(sky);
|
||||
let mut skin = mask_ifd(2, 2, "skin", vec![0, 255, 255, 0]);
|
||||
skin.set(t::SEMANTIC_INSTANCE_ID, Value::Ascii("person_a".into()));
|
||||
ifd0.add_sub_ifd(skin);
|
||||
});
|
||||
let raw = decode(&bytes).unwrap();
|
||||
assert_eq!((raw.width, raw.height, raw.cpp), (8, 6, 3), "the masks are not the raw image");
|
||||
let masks = lightcraft_raw::semantic_masks(&bytes);
|
||||
assert_eq!(masks.len(), 2);
|
||||
let (sky, skin) = (&masks[0], &masks[1]);
|
||||
assert_eq!((sky.name.as_str(), sky.instance_id.as_deref(), sky.width, sky.height), ("sky", None, 4, 2));
|
||||
assert_eq!(sky.sub_area, Some(MaskSubArea { top: 1, left: 2, full_width: 8, full_height: 4 }));
|
||||
assert_eq!((sky.data[0], sky.data[7]), (10 * 257, 65535));
|
||||
assert_eq!((skin.name.as_str(), skin.instance_id.as_deref(), skin.sub_area), ("skin", Some("person_a"), None));
|
||||
// positions are in uncropped-mask pixels: zero outside the stored crop
|
||||
assert!((sky.value_at(2.5, 1.5) - 10.0 / 255.0).abs() < 1e-4);
|
||||
assert!((sky.value_at(5.5, 2.5) - 1.0).abs() < 1e-4);
|
||||
assert_eq!((sky.value_at(1.5, 1.5), sky.value_at(2.5, 0.5), sky.value_at(6.5, 1.5)), (0.0, 0.0, 0.0));
|
||||
// over the whole image: the uncropped mask's 8 × 4 grid
|
||||
let full = sky.developed(Rect::new(0, 0, 8, 6), Rect::new(0, 0, 8, 6)).unwrap();
|
||||
assert_eq!((full.width, full.height), (8, 4));
|
||||
assert_eq!((full.get(2, 1), full.get(5, 2), full.get(1, 1), full.get(6, 2), full.get(2, 0)), (10, 255, 0, 0, 0));
|
||||
// a default crop keeping columns 2..6 of the image: the stored crop fills it
|
||||
let cropped = sky.developed(Rect::new(0, 0, 8, 6), Rect::new(2, 0, 4, 6)).unwrap();
|
||||
assert_eq!((cropped.width, cropped.height), (4, 4));
|
||||
assert_eq!((cropped.get(0, 1), cropped.get(3, 2), cropped.get(0, 0)), (10, 255, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Malformed semantic masks are skipped (never a panic), and so is the `MaskSubArea` of a mask it
|
||||
/// doesn't fit; the raw image still decodes.
|
||||
#[test]
|
||||
fn malformed_semantic_masks_are_skipped() {
|
||||
let bytes = profile_dng(ByteOrder::Little, |ifd0| {
|
||||
ifd0.add_sub_ifd(mask_ifd(2, 2, "", vec![1, 2, 3, 4])); // no SemanticName
|
||||
let mut rgb = mask_ifd(2, 2, "rgb", vec![0; 12]);
|
||||
rgb.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
|
||||
rgb.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
|
||||
ifd0.add_sub_ifd(rgb);
|
||||
let mut huge = mask_ifd(2, 2, "huge", vec![0; 4]);
|
||||
huge.set(t::IMAGE_WIDTH, Value::Long(vec![100_000]));
|
||||
huge.set(t::IMAGE_LENGTH, Value::Long(vec![100_000]));
|
||||
ifd0.add_sub_ifd(huge);
|
||||
let mut tiny = mask_ifd(2, 2, "tiny data", vec![0; 4]);
|
||||
tiny.set(t::IMAGE_WIDTH, Value::Long(vec![4000]));
|
||||
tiny.set(t::IMAGE_LENGTH, Value::Long(vec![3000]));
|
||||
ifd0.add_sub_ifd(tiny);
|
||||
let mut jxl = mask_ifd(2, 2, "unknown coding", vec![0; 4]);
|
||||
jxl.set(t::COMPRESSION, Value::Short(vec![60_000]));
|
||||
ifd0.add_sub_ifd(jxl);
|
||||
let mut zero = mask_ifd(2, 2, "zero size", vec![0; 4]);
|
||||
zero.set(t::IMAGE_WIDTH, Value::Long(vec![0]));
|
||||
ifd0.add_sub_ifd(zero);
|
||||
let mut short = mask_ifd(4, 4, "short strip", vec![200; 3]);
|
||||
short.set(t::MASK_SUB_AREA, Value::Long(vec![u32::MAX, u32::MAX, u32::MAX, u32::MAX]));
|
||||
ifd0.add_sub_ifd(short);
|
||||
let mut outside = mask_ifd(2, 2, "sub area too small", vec![255; 4]);
|
||||
outside.set(t::MASK_SUB_AREA, Value::Long(vec![3, 0, 2, 4]));
|
||||
ifd0.add_sub_ifd(outside);
|
||||
});
|
||||
assert_eq!(decode(&bytes).unwrap().cpp, 3);
|
||||
let masks = lightcraft_raw::semantic_masks(&bytes);
|
||||
let names: Vec<&str> = masks.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(names, ["short strip", "sub area too small"]);
|
||||
assert!(masks.iter().all(|m| m.sub_area.is_none()), "sub areas the mask doesn't fit are ignored");
|
||||
for m in &masks {
|
||||
let d = m.developed(Rect::new(0, 0, 8, 6), Rect::new(0, 0, 8, 6)).unwrap();
|
||||
assert_eq!((d.width, d.height), (m.width, m.height));
|
||||
assert!(m.developed(Rect::new(0, 0, 8, 6), Rect::new(8, 6, 0, 0)).is_none());
|
||||
}
|
||||
// not a TIFF at all
|
||||
assert!(lightcraft_raw::semantic_masks(b"not a dng").is_empty());
|
||||
}
|
||||
|
||||
/// Only the first `MAX_MASKS` mask IFDs are decoded, malformed ones included, so a file full of
|
||||
/// mask IFDs costs a bounded number of decodes: here the first `MAX_MASKS` can't be decoded, and
|
||||
/// the valid masks after them are never looked at.
|
||||
#[test]
|
||||
fn semantic_mask_ifds_past_the_cap_are_not_decoded() {
|
||||
use lightcraft_raw::semantic::MAX_MASKS;
|
||||
let with = |undecodable: usize, valid: usize| {
|
||||
profile_dng(ByteOrder::Little, |ifd0| {
|
||||
for i in 0..undecodable {
|
||||
let mut m = mask_ifd(2, 2, &format!("undecodable {i}"), vec![0; 4]);
|
||||
m.set(t::COMPRESSION, Value::Short(vec![60_000]));
|
||||
ifd0.add_sub_ifd(m);
|
||||
}
|
||||
for i in 0..valid {
|
||||
ifd0.add_sub_ifd(mask_ifd(2, 2, &format!("valid {i}"), vec![255; 4]));
|
||||
}
|
||||
})
|
||||
};
|
||||
let bytes = with(MAX_MASKS, 3 * MAX_MASKS);
|
||||
assert_eq!(decode(&bytes).unwrap().cpp, 3);
|
||||
assert!(lightcraft_raw::semantic_masks(&bytes).is_empty());
|
||||
// one fewer undecodable mask leaves room for exactly one valid one
|
||||
let names: Vec<String> = lightcraft_raw::semantic_masks(&with(MAX_MASKS - 1, 3 * MAX_MASKS)).into_iter().map(|m| m.name).collect();
|
||||
assert_eq!(names, ["valid 0"]);
|
||||
}
|
||||
|
||||
/// `BaselineSharpness` is read from IFD 0; a zero, negative or absurd value is ignored.
|
||||
#[test]
|
||||
fn baseline_sharpness_is_read_when_plausible() {
|
||||
for (value, want) in [((3, 2), Some(1.5)), ((0, 1), None), ((1, 0), None), ((100, 1), None)] {
|
||||
let bytes = profile_dng(ByteOrder::Big, |ifd0| {
|
||||
ifd0.set(t::BASELINE_SHARPNESS, Value::Rational(vec![value]));
|
||||
});
|
||||
assert_eq!(decode(&bytes).unwrap().color.baseline_sharpness, want, "{value:?}");
|
||||
}
|
||||
let bytes = profile_dng(ByteOrder::Big, |ifd0| {
|
||||
ifd0.set(t::BASELINE_SHARPNESS, Value::SRational(vec![(-1, 1)]));
|
||||
});
|
||||
assert_eq!(decode(&bytes).unwrap().color.baseline_sharpness, None);
|
||||
assert_eq!(decode(&profile_dng(ByteOrder::Big, |_| {})).unwrap().color.baseline_sharpness, None);
|
||||
}
|
||||
@@ -172,6 +172,7 @@ pub const OPCODE_LIST_2: u16 = 51009;
|
||||
pub const OPCODE_LIST_3: u16 = 51022;
|
||||
pub const NOISE_PROFILE: u16 = 51041;
|
||||
pub const DEFAULT_USER_CROP: u16 = 51125;
|
||||
pub const PROFILE_GAIN_TABLE_MAP: u16 = 52525;
|
||||
pub const CALIBRATION_ILLUMINANT_3: u16 = 52529;
|
||||
pub const CAMERA_CALIBRATION_3: u16 = 52530;
|
||||
pub const COLOR_MATRIX_3: u16 = 52531;
|
||||
@@ -179,6 +180,13 @@ pub const FORWARD_MATRIX_3: u16 = 52532;
|
||||
pub const ILLUMINANT_DATA_1: u16 = 52533;
|
||||
pub const ILLUMINANT_DATA_2: u16 = 52534;
|
||||
pub const ILLUMINANT_DATA_3: u16 = 52535;
|
||||
// DNG 1.6 semantic masks (spec chapter 4, "Semantic Masks")
|
||||
pub const SEMANTIC_NAME: u16 = 52526;
|
||||
pub const SEMANTIC_INSTANCE_ID: u16 = 52528;
|
||||
pub const MASK_SUB_AREA: u16 = 52536;
|
||||
/// NewSubFileType of a semantic mask IFD.
|
||||
pub const SUBFILE_SEMANTIC_MASK: u32 = 0x10004;
|
||||
pub const PROFILE_GAIN_TABLE_MAP_2: u16 = 52544;
|
||||
|
||||
// --- Compression codes (TIFF 6.0, DNG, and widely used registrations)
|
||||
pub mod compression {
|
||||
@@ -206,4 +214,6 @@ pub mod photometric {
|
||||
pub const YCBCR: u16 = 6;
|
||||
pub const CFA: u16 = 32803;
|
||||
pub const LINEAR_RAW: u16 = 34892;
|
||||
/// DNG 1.6 semantic mask.
|
||||
pub const MASK: u16 = 52527;
|
||||
}
|
||||
+7
-7
@@ -142,7 +142,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
|
||||
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
|
||||
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones decode but no real JPEG XL DNG has verified them yet; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) |
|
||||
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. |
|
||||
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
|
||||
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
|
||||
@@ -251,7 +251,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | |
|
||||
| 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-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`), `crates/raw/src/color.rs` (`rebalance`) | relative scale for non-raw in the UI; raws with a colour matrix (DNG and other raws without a file-local look) are re-developed for the chosen white through the camera's own matrices (camera-space white balance, as Lightroom), and the eyedropper / Auto invert the same model; other photos are adapted (Bradford) |
|
||||
| LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests |
|
||||
| LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | |
|
||||
| LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | |
|
||||
@@ -295,7 +295,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
|
||||
| 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/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); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2 and Pentax PEF 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), kept with smart previews (see docs/camera-preview-colour.md); 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) (`docs/camera-preview-colour.md`); 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 |
|
||||
| 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 but not rendered, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2 and Pentax PEF 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), kept with smart previews (see docs/camera-preview-colour.md); 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) (`docs/camera-preview-colour.md`); 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 |
|
||||
| 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 |
|
||||
@@ -346,11 +346,11 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| LR-MASK-PANEL | Masks panel | P0 | ✅ | `cmd:panel.masking`, `cmd:mask.add`, `cmd:mask.select`, `cmd:mask.rename`, `cmd:mask.duplicate` (`invert`), `cmd:mask.move`, `cmd:mask.visible`, `cmd:mask.delete`, `crates/ui-egui/src/panels/masking.rs` | list with per-mask show/hide eye, double-click rename, right-click menu (duplicate, duplicate and invert, invert, hide, move up/down, rename, delete); fits any panel width (create tiles go to three columns, mask actions wrap, long component names are cut short); no drag-to-reorder |
|
||||
| LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model |
|
||||
| LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`) | heuristic |
|
||||
| LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject heuristic |
|
||||
| LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model; a photo that carries person mattes (DNG semantic masks: iPhone ProRAW portrait / skin / hair / teeth / glasses mattes) uses them instead (synthetic files only: no corpus file carries person mattes) |
|
||||
| LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`), `crates/pipeline/src/masks.rs` (`Mattes`), `crates/raw/src/semantic.rs` | heuristic; a photo that carries a sky matte (DNG semantic mask, e.g. iPhone ProRAW) uses it instead, through orientation / crop / lens corrections, preview = export (checked on one CC0 iPhone 12 Pro ProRAW and synthetic files) |
|
||||
| LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject mask (heuristic, or the photo's person mattes) |
|
||||
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, shows the model folder and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
|
||||
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | |
|
||||
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic |
|
||||
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
|
||||
| LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
|
||||
| LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | |
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
### RAW decoding
|
||||
- Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF
|
||||
and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.
|
||||
- JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files);
|
||||
lossy tiles decode too, but no real file has checked them yet. A JPEG XL preview stored in the DNG is used like an
|
||||
embedded JPEG.
|
||||
- Apple ProRAW's gain table map (its local tone mapping, `ProfileGainTableMap`) is now read and kept when a photo is
|
||||
exported or converted to DNG. It is not applied to the render: Lightroom Classic renders ProRAW without it.
|
||||
- iPhone ProRAW and other DNGs that carry their own segmentation mattes (DNG semantic masks) use them for the Select
|
||||
Sky, Subject and Background masks instead of our heuristics, so the sky is selected where the camera found it
|
||||
(checked on one CC0 iPhone 12 Pro ProRAW). Photos without mattes are unchanged.
|
||||
- Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG
|
||||
(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile
|
||||
Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the
|
||||
@@ -64,6 +72,10 @@
|
||||
- The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track
|
||||
shows and as in Lightroom, and Tint values in Lightroom XMP sidecars now render as they do there. A custom Tint
|
||||
saved in an earlier version now shifts the other way; set it again (or re-run Auto / the white-balance picker).
|
||||
- White balance on DNGs (and other raws with a colour matrix) re-develops the photo for the new white through the
|
||||
camera's own colour matrices, as Lightroom does, instead of shifting the colours of the as-shot rendering: a grey
|
||||
lit by the chosen white comes out grey and saturated colours move as the camera records them. The eyedropper and
|
||||
Auto use the same model. Custom white balances on these photos render slightly differently than before.
|
||||
- Crop (issue #295): a Lock toggle keeps the aspect ratio on every handle, Custom takes your own ratio (Apply), and
|
||||
dragging a handle into the image edge stops there instead of pushing the crop out of shape.
|
||||
|
||||
|
||||
@@ -353,6 +353,9 @@ const RAW_SAMPLES: &[(&str, &str)] = &[
|
||||
"dng-adobe-canon-5d3-lossy.dng",
|
||||
"https://raw.pixls.us/getfile.php/1023/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossy%20JPEG%20compression%20%283:2%29.DNG",
|
||||
),
|
||||
// Apple ProRAW (iPhone 12 Pro, iOS 14.3): LinearRaw LJ92 tiles, ProfileToneCurve, ProfileGainTableMap, a sky matte.
|
||||
// sha256 e91e77a4533ed7cce551d83330676ea5c47dd5e55fb38adda7819366afdbdfc2
|
||||
("dng-apple-iphone12pro-proraw.dng", "https://raw.pixls.us/data/Apple/iPhone%2012%20Pro/IMG_1361.DNG"),
|
||||
(
|
||||
"dng-canon-5d3-14bit-small.dng",
|
||||
"https://raw.pixls.us/getfile.php/2204/nice/Canon%20-%20EOS%205D%20Mark%20III%20-%2014bit%2014bit%20%282.3471882640587%29.dng",
|
||||
|
||||
Reference in new issue
Block a user