# Conflicts:
#	ROADMAP.md
#	docs/parity.md
This commit is contained in:
Brandon Thomas committed 2026-10-08 23:46:58 -04:00
commit a628d5bc38
30 files changed
+1840 -101

No files matched your search

Generated
+12
View File
@@ -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"
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+2 -2
View File
@@ -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);
}
+9 -2
View File
@@ -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(),
}
}
+1
View File
@@ -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 }
+11 -4
View File
@@ -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())
}
+32 -5
View File
@@ -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"
+21 -4
View File
@@ -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
View File
@@ -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));
}
}
+51
View File
@@ -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());
}
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+8
View File
@@ -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)));
+325
View File
@@ -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);
}
}
}
+121
View File
@@ -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
View File
@@ -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,
}
+25 -3
View File
@@ -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)))
}
+13 -3
View File
@@ -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));
+136
View File
@@ -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)
}
}
+44 -7
View File
@@ -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;
+47 -6
View File
@@ -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.
+421
View File
@@ -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);
}
+10
View File
@@ -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
View File
@@ -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` | |
+12
View File
@@ -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.
+3
View File
@@ -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",