diff --git a/README.md b/README.md index 43e41dee..8245a5c5 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ lightcraft --control 7980 ~/Pictures/trip ## Fast, native, private -- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans, +- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans, Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP, PSD composites and JPEG XL open today. - **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping @@ -288,8 +288,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the - **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony / older-Canon raws on one machine. - **The biggest gaps:** - - **camera colour calibration:** Sony, Nikon, Panasonic and Fujifilm raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - - **CR3 and compressed Olympus raws:** these open as embedded previews only. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); + - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; + - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - **HDR, video and the Classic Print / Book / Map modules.** - **What's next:** see [where we're going](ROADMAP.md#where-were-going). @@ -304,7 +304,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the | Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | βœ… | | Camera colour: DNG files use their own matrices | βœ… DNG Β· 🟑 own Sony/Fujifilm profiles; measured calibration database missing | | Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | βœ… | -| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟑 Β· CR3, compressed ORF decode ⬜ | +| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟑 Β· CR3, compressed ORF decode ⬜ | | Detail: sharpening, luminance + colour noise reduction | βœ… Β· AI Denoise, Super Resolution ⬜ | | Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | βœ… Β· content-aware fill, spot pin editing 🚧 | | Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | βœ… Β· HDR export ⬜ | diff --git a/ROADMAP.md b/ROADMAP.md index e6650ab5..b691b9c5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,8 +23,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Estimate | What's true today | Biggest gaps | |---|---:|---|---| | **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, 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 and RAF 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) | +| **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 and CR3 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 | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **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 ⬜ | @@ -38,7 +38,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks | | Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction | | Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` | -| Canon CR3 / Olympus compressed-raw shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) | +| Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain | | Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering | | Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise | @@ -49,7 +49,7 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par 1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF have the file-local fit (matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity. -2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed ORF, NEF +2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source, no LGPL dependency); compressed NEF (#86) is the template. 3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and @@ -89,7 +89,7 @@ yet guarantee original-pixel detail; visible-region rendering remains a gap (LR- | M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye βœ…; PatchMatch remove ⬜) | | M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | βœ… | | M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs βœ…; JXL encode, HDR export ⬜) | -| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed βœ…; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) | +| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed βœ…; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) | | M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ | | M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | βœ… | | M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | @@ -170,23 +170,23 @@ The milestone estimates in the table above were made before work started and are Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB; private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export. +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 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 -118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every -supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants -it can't decode yet. +118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded. Not decoded yet β€” preview only (no permissively licensed description; black-box analysis incomplete): - **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600 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/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG. +- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG. -**Camera colour matrices:** ARW, NEF, RW2 and RAF 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 +**Camera colour matrices:** ARW, NEF, RW2, RAF and CR3 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 matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our own chart-based calibration (M11.4). Adobe matrices are never used. @@ -205,6 +205,7 @@ own chart-based calibration (M11.4). Adobe matrices are never used. - 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7, 16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118 bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview). +- 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain. ## Chinese and Japanese interfaces, and text watermarks diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 9c1050fa..ec896b4b 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -336,12 +336,12 @@ impl Photo { pub fn develops_raw(&self) -> bool { self.kind == MediaKind::Raw && self.preview_only.is_none() } - /// The current ARW, NEF, RW2 and RAF readers have vendor WB multipliers but no measured camera + /// The current ARW, NEF, RW2, RAF and CR3 readers have vendor WB multipliers but no measured camera /// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered /// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and /// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files). pub fn relative_wb(&self) -> bool { - self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF"].iter().any(|f| self.format.eq_ignore_ascii_case(f)) + self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3"].iter().any(|f| self.format.eq_ignore_ascii_case(f)) } /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's /// default preset applied on top of them ([`Photo::import_look`]). @@ -444,6 +444,7 @@ mod edited_tests { ("a.rwl", "RWL", true), ("a.raw", "RAW", true), ("a.raf", "rAf", true), + ("a.cr3", "cr3", true), ("a.dng", "DNG", false), ] { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00"); diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index c43f416c..5909b939 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1,5 +1,5 @@ //! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic -//! RW2, Fujifilm RAF) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only, +//! RW2, Fujifilm RAF, Canon CR3) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only, //! never output pixels or a replacement for RAW editing. //! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a //! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals @@ -31,7 +31,7 @@ pub(crate) const PROFILE_PROXY: usize = 192; /// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the /// same formats by file extension. pub(crate) fn file_local_look(format: RawFormat) -> bool { - matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf) + matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3) } pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { @@ -83,14 +83,161 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz } // Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution. let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2; - let sensor = sensor_proxy(raw, k, edge as usize)?; + let mut sensor = sensor_proxy(raw, k, edge as usize)?; + let gain = 2f32.powf(transform.baseline_exposure as f32); + let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)); + if raw.format == RawFormat::Cr3 { + // Orient the camera JPEG and the sensor identically before collecting pixel correspondences. + sensor = sensor.into_oriented(raw.orientation); + reference = reference.into_oriented(raw.orientation); + sensor.map_in_place(to_working); + // Canon's JPEG can be cropped differently from the sensor. Estimate only that common + // framing from edge directions, independently of the subsequent colour fit. + sensor = align_cr3_framing(sensor, &reference); + } let mut sensor = fit(&sensor, size, size, Filter::Box); let reference = fit(&reference, sensor.width, sensor.height, Filter::Box); - let gain = 2f32.powf(transform.baseline_exposure as f32); - sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain))); + if raw.format != RawFormat::Cr3 { + sensor.map_in_place(to_working); + } Some((sensor, reference)) } +#[derive(Clone, Copy, Debug)] +struct Cr3Framing { + scale: f32, + /// Translation as a fraction of the oriented frame's width and height. + offset: [f32; 2], +} + +impl Cr3Framing { + const IDENTITY: Self = Self { scale: 1.0, offset: [0.0; 2] }; + + fn source(self, x: f32, y: f32, w: usize, h: usize) -> (f32, f32) { + let (w, h) = (w as f32, h as f32); + ((x - w * 0.5) * self.scale + w * (0.5 + self.offset[0]), (y - h * 0.5) * self.scale + h * (0.5 + self.offset[1])) + } +} + +/// Register the JPEG's remaining framing on a fixed proxy. Edge directions survive different +/// white balance and monotone camera tone; no target RGB values train this three-parameter fit. +/// A separate third of the edges must confirm a substantial improvement and a close match. +/// A weak, flat or unrelated preview leaves the sensor framing unchanged. +fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option { + if sensor.width.checked_mul(sensor.height)? != sensor.data.len() || reference.width.checked_mul(reference.height)? != reference.data.len() { + return None; + } + let sensor = fit(sensor, PROXY, PROXY, Filter::Box); + let (w, h) = (sensor.width, sensor.height); + if w < 16 || h < 16 { + return None; + } + let reference = fit(reference, w, h, Filter::Box); + if (reference.width, reference.height) != (w, h) { + return None; + } + let mut gradients = Rgb32f::new(w, h); + let mut edges = Vec::new(); + for y in 1..h - 1 { + for x in 1..w - 1 { + let derivative = |image: &Rgb32f| { + [ + luminance_2020(image.data[y * w + x + 1]) - luminance_2020(image.data[y * w + x - 1]), + luminance_2020(image.data[(y + 1) * w + x]) - luminance_2020(image.data[(y - 1) * w + x]), + ] + }; + let d = derivative(&sensor); + gradients.data[y * w + x] = [d[0], d[1], 0.0]; + let d = derivative(&reference); + let magnitude = d[0].hypot(d[1]); + let value = luminance_2020(reference.data[y * w + x]); + if (0.02..0.9).contains(&value) && magnitude.is_finite() && magnitude > 0.03 { + edges.push((x as f32 + 0.5, y as f32 + 0.5, d, magnitude, (x * 17 + y * 11) % 3 == 0)); + } + } + } + if edges.len() < 192 { + return None; + } + let score = |framing: Cr3Framing, held_out: bool| -> Option { + let (mut agreement, mut weight, mut valid, mut total) = (0.0, 0.0, 0usize, 0usize); + for &(x, y, target, magnitude, holdout) in &edges { + if holdout != held_out { + continue; + } + total += 1; + let (sx, sy) = framing.source(x, y, w, h); + if sx < 1.5 || sy < 1.5 || sx > w as f32 - 1.5 || sy > h as f32 - 1.5 { + continue; + } + let sample = gradients.sample_bilinear(sx, sy); + let length = sample[0].hypot(sample[1]); + if !length.is_finite() || length <= 0.01 { + continue; + } + let k = f64::from(magnitude.min(0.25)); + agreement += k * f64::from((sample[0] * target[0] + sample[1] * target[1]) / (length * magnitude)); + weight += k; + valid += 1; + } + (valid >= 64 && valid * 5 >= total * 4 && weight > 0.0).then_some(agreement / weight) + }; + let before = score(Cr3Framing::IDENTITY, false)?; + let mut best = (before, Cr3Framing::IDENTITY); + // At most 10% zoom and two proxy pixels of translation; a coarse search followed by one + // local refinement prevents unbounded optimisation on the photograph's contents. + for s in -10..=10 { + for tx in -4..=4 { + for ty in -4..=4 { + let framing = Cr3Framing { scale: 1.0 + s as f32 * 0.01, offset: [tx as f32 * 0.5 / w as f32, ty as f32 * 0.5 / h as f32] }; + if let Some(value) = score(framing, false) + && value > best.0 + { + best = (value, framing); + } + } + } + } + let coarse = best.1; + for s in -5..=5 { + for tx in -4..=4 { + for ty in -4..=4 { + let framing = Cr3Framing { + scale: coarse.scale + s as f32 * 0.002, + offset: [coarse.offset[0] + tx as f32 * 0.125 / w as f32, coarse.offset[1] + ty as f32 * 0.125 / h as f32], + }; + if !(0.9..=1.1).contains(&framing.scale) || framing.offset[0].abs() > 2.0 / w as f32 || framing.offset[1].abs() > 2.0 / h as f32 { + continue; + } + if let Some(value) = score(framing, false) + && value > best.0 + { + best = (value, framing); + } + } + } + } + let (held_before, held_after) = (score(Cr3Framing::IDENTITY, true)?, score(best.1, true)?); + if best.0 < 0.9 || best.0 < before + 0.04 || held_after < 0.9 || held_after < held_before + 0.04 { + return None; + } + if lightcraft_pipeline::profiling() { + eprintln!("[profile] CR3 JPEG framing {:?}, held-out edge agreement {held_before:.4} -> {held_after:.4}", best.1); + } + Some(best.1) +} + +fn align_cr3_framing(sensor: Rgb32f, reference: &Rgb32f) -> Rgb32f { + let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return sensor }; + let (w, h) = (sensor.width, sensor.height); + let mut aligned = Rgb32f::new(w, h); + for (i, pixel) in aligned.data.iter_mut().enumerate() { + let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); + *pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { sensor.sample_bilinear(x, y) } else { [f32::NAN; 3] }; + } + aligned +} + /// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the /// baseline exposure) β†’ its camera JPEG (linear Rec.2020). `None` for formats with their own /// colour matrices, other files or unusable previews. @@ -870,10 +1017,71 @@ mod tests { #[test] fn supported_raws_get_a_file_local_look() { - assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf].into_iter().all(file_local_look)); + assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3].into_iter().all(file_local_look)); assert!(![RawFormat::Dng, RawFormat::Cr2].into_iter().any(file_local_look)); } + #[test] + fn cr3_camera_framing_is_measured_independently_of_tone_and_rejects_unrelated_edges() { + let (w, h) = (192, 128); + let mut sensor = Rgb32f::new(w, h); + for (i, pixel) in sensor.data.iter_mut().enumerate() { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let value = 0.35 + 0.13 * (x * 0.15 + y * 0.22).sin() + 0.1 * (x * 0.33 - y * 0.14).sin() + 0.07 * (x * 0.09 - y * 0.31).cos(); + *pixel = [value; 3]; + } + let expected = Cr3Framing { scale: 0.96, offset: [0.01, -0.006] }; + let mut reference = Rgb32f::new(w, h); + for (i, pixel) in reference.data.iter_mut().enumerate() { + let (x, y) = expected.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); + *pixel = sensor.sample_bilinear(x, y).map(|v| v.powf(0.7)); + } + let measured = estimate_cr3_framing(&sensor, &reference).expect("coherent held-out edges locate camera framing despite nonlinear tone"); + assert!((measured.scale - expected.scale).abs() < 0.006, "{measured:?}"); + assert!((measured.offset[0] - expected.offset[0]).abs() < 0.003, "{measured:?}"); + assert!((measured.offset[1] - expected.offset[1]).abs() < 0.003, "{measured:?}"); + assert!(estimate_cr3_framing(&sensor, &sensor).is_none(), "already aligned pixels stay unchanged"); + reference.data.fill([0.2; 3]); + assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "flat previews cannot establish framing"); + for (i, pixel) in reference.data.iter_mut().enumerate() { + let (x, y) = ((i % w) as f32, (i / w) as f32); + *pixel = [0.35 + 0.13 * (x * 0.41 - y * 0.07).sin() + 0.1 * (x * 0.04 + y * 0.39).cos(); 3]; + } + assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "unrelated edges cannot change the framing"); + } + + /// The guarded fit is assessed on real sensor data, never on the JPEG fallback. A corpus + /// download or an unfinished codec cannot make this test claim a successful RAW calibration. + #[test] + fn corpus_cr3_gets_a_framing_aligned_camera_look() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/cr3-canon-r100-raw.cr3"); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + return; + }; + let raw = match lightcraft_raw::decode(&bytes) { + Ok(raw) => raw, + Err(lightcraft_raw::RawError::Unsupported(why)) => { + eprintln!("skip CR3 sensor decoder: {why}"); + return; + } + Err(error) => panic!("CR3 corpus failed to decode: {error}"), + }; + assert_eq!(raw.format, RawFormat::Cr3); + assert_eq!(raw.info().developed_size(), (6000, 4000)); + let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = fit_preview(&raw, &bytes, &transform); + eprintln!("R100 guarded colour fit accepted: {}", look.is_some()); + assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment"); + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.raw && info.relative_wb); + assert_eq!((info.as_shot_temp, info.as_shot_tint), (6500.0, 0.0)); + assert_eq!(info.camera_tone.is_some(), look.is_some()); + } + /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white /// balance is relative to the as-shot look. #[test] diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 7162a04f..91be84b8 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -99,9 +99,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { let m = lightcraft_meta::extract(bytes); let (meta, captured) = meta_of(&m); if lightcraft_raw::probe(bytes).is_some() { - let raw = match lightcraft_raw::probe_info(bytes) { + let raw = match lightcraft_raw::probe_info(bytes).map_err(|e| preview_reason(bytes, e)) { Ok(r) => r, - Err(lightcraft_raw::RawError::Unsupported(why)) => { + Err(Ok(why)) => { // a raw variant we can't decode yet: describe it from its embedded preview let (w, h) = embedded_preview_size(bytes).ok_or(format!("unsupported raw ({why}) without an embedded preview"))?; return Ok(ProbeInfo { @@ -119,7 +119,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { ..Default::default() }); } - Err(e) => return Err(e.to_string()), + Err(Err(e)) => return Err(e), }; let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32); if w <= 1 || h <= 1 { @@ -179,6 +179,17 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { }) } +/// Why a raw that failed to decode should show its embedded preview instead (`Ok`), or the error +/// to report (`Err`). Variants we can't decode yet always fall back. So does any CR3 error: the CRX +/// decoder is verified on few bodies, and every CR3 opened from its embedded JPEG before it existed. +fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result { + match e { + lightcraft_raw::RawError::Unsupported(why) => Ok(why), + e if lightcraft_raw::probe(bytes) == Some(lightcraft_raw::RawFormat::Cr3) => Ok(format!("CR3 {e}")), + e => Err(e.to_string()), + } +} + /// Sensor clip level (normalised) for highlight reconstruction. const HIGHLIGHT_CLIP: f32 = 0.99; @@ -213,13 +224,13 @@ pub fn load_vec(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo), fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { if lightcraft_raw::probe(&bytes).is_some() { - let mut raw = match lightcraft_raw::decode(&bytes) { + let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) { Ok(r) => r, - Err(lightcraft_raw::RawError::Unsupported(why)) => { + Err(Ok(why)) => { // show the camera's embedded JPEG (rendered, not raw) until the variant is supported return load_embedded_preview(&bytes, max_edge).ok_or(format!("unsupported raw ({why}) without an embedded preview")); } - Err(e) => return Err(e.to_string()), + Err(Err(e)) => return Err(e), }; // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). @@ -553,6 +564,43 @@ mod tests { b } + /// [`cr3_with_preview`] plus a full-size CRX raw track whose `CMP1` coding header is garbage: + /// the container parses, the raw data doesn't (`RawError::Corrupt`, not `Unsupported`). + fn cr3_with_corrupt_raw(w: u32, h: u32) -> Vec { + let bx = |kind: &[u8; 4], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat(); + let mut craw = vec![0u8; 82]; + craw[24..26].copy_from_slice(&(w as u16).to_be_bytes()); + craw[26..28].copy_from_slice(&(h as u16).to_be_bytes()); + craw.extend(bx(b"CMP1", &[0xff; 4])); + let mut stsd = vec![0, 0, 0, 0, 0, 0, 0, 1]; + stsd.extend(bx(b"CRAW", &craw)); + let stsz = [0u32, 16, 1].map(u32::to_be_bytes).concat(); + let co64 = [&[0u8, 0, 0, 0, 0, 0, 0, 1][..], &0u64.to_be_bytes()].concat(); + let stbl = [bx(b"stsd", &stsd), bx(b"stsz", &stsz), bx(b"co64", &co64)].concat(); + let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))); + let preview = cr3_with_preview(w, h); + [&preview[..24], &bx(b"moov", &trak), &preview[24..]].concat() + } + + /// A CR3 whose raw data fails to decode, for any reason, still opens from its embedded JPEG: + /// CR3s did before the CRX decoder, which is verified on few bodies (PR #279 review). + #[test] + fn corrupt_cr3_falls_back_to_embedded_preview() { + let b = cr3_with_corrupt_raw(48, 32); + let err = lightcraft_raw::decode(&b).expect_err("the fixture's raw data is corrupt"); + assert!(!matches!(err, lightcraft_raw::RawError::Unsupported(_)), "{err}"); + let p = probe_bytes("x.cr3", &b).unwrap(); + assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3")); + assert!(p.preview_only.is_some_and(|why| why.contains("CR3"))); + let (img, src) = load_bytes(&b, 24).unwrap(); + assert_eq!((img.width, img.height), (24, 16)); + assert!(!src.raw); + // other formats still report a corrupt file as an error + let mut dng = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + dng.truncate(dng.len() / 2); + assert!(load_bytes(&dng, 24).is_err()); + } + /// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is /// applied when it's developed β€” Lightroom-converted DNGs rendered flat and muted without it. #[test] diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 7aafbf34..beb55883 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 12; +pub const RENDER_CACHE_VERSION: u64 = 13; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/meta/src/cr3.rs b/crates/meta/src/cr3.rs index 18d33ef7..5fa6437b 100644 --- a/crates/meta/src/cr3.rs +++ b/crates/meta/src/cr3.rs @@ -17,7 +17,13 @@ pub enum Cr3TrackKind { /// `CRAW` with a `JPEG` child: the full-size JPEG. Jpeg, /// `CRAW` raw image: dimensions and the `CMP1` coding header (byte range in the file). - Raw { width: u16, height: u16, cmp1: Option<(usize, usize)> }, + Raw { + width: u16, + height: u16, + cmp1: Option<(usize, usize)>, + /// `CDI1/IAD1` image-area descriptor (byte range in the file). + iad1: Option<(usize, usize)>, + }, /// Anything else (`CTMD` timed metadata, unknown entries). Other([u8; 4]), } @@ -30,6 +36,134 @@ pub struct Cr3Track { pub data: Option<(usize, usize)>, } +/// Canon's `CMP1` image coding descriptor, separate from the encoded track sample. +/// +/// Parsed from the payload (after the eight-byte box header). Geometry is the full sensor mosaic, +/// including optically masked borders. Unsupported encoding modes are retained for the decoder to +/// report explicitly, rather than confused with the full-size embedded JPEG. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cr3Compression { + pub version: u16, + pub width: u32, + pub height: u32, + pub tile_width: u32, + pub tile_height: u32, + pub bit_depth: u8, + pub planes: u8, + /// 0 = RGGB, 1 = GRBG, 2 = GBRG, 3 = BGGR. + pub cfa_pattern: u8, + pub encoding: u8, + pub levels: u8, + pub tile_flags: u8, + /// Number of bytes at the start of the sample before its entropy-coded data. + pub header_size: u32, + pub median_bit_depth: Option, +} + +impl Cr3Compression { + /// Read the public `CMP1` field layout. Truncated or impossible descriptors return `None`. + pub fn parse(payload: &[u8]) -> Option { + // The first word is reserved in the public notes; real files use FF00, although older + // descriptions guessed FFFF. It is not a reliable format signature. + let header_len = usize::from(be16(payload, 2)?).checked_add(4)?; + if header_len < 52 || header_len > payload.len() { + return None; + } + let packed = *payload.get(25)?; + let coding = *payload.get(26)?; + let mut out = Self { + version: be16(payload, 4)?, + width: be32(payload, 8)?, + height: be32(payload, 12)?, + tile_width: be32(payload, 16)?, + tile_height: be32(payload, 20)?, + bit_depth: *payload.get(24)?, + planes: packed >> 4, + cfa_pattern: packed & 15, + encoding: coding >> 4, + levels: coding & 15, + tile_flags: *payload.get(27)?, + header_size: be32(payload, 28)?, + median_bit_depth: None, + }; + if out.width == 0 + || out.height == 0 + || out.tile_width == 0 + || out.tile_height == 0 + || out.tile_width > out.width + || out.tile_height > out.height + || !(1..=16).contains(&out.bit_depth) + || !(1..=4).contains(&out.planes) + || (out.planes > 1 && out.cfa_pattern > 3) + || out.header_size == 0 + { + return None; + } + if payload.get(32).is_some_and(|flags| flags & 0x80 != 0) { + // Extended CMP1: a four-byte length, flags, reserved bytes and six words. The optional + // median precision is used by type-3 burst encodings, not ordinary Bayer raw/craw. + let extension_len = usize::try_from(be32(payload, 52)?).ok()?; + let extension_end = 52usize.checked_add(extension_len)?; + if extension_len < 32 || extension_end > payload.len() { + return None; + } + if payload.get(56).is_some_and(|flags| flags & 0x40 != 0) { + let precision = *payload.get(84).filter(|_| extension_end > 84)?; + if !(1..=16).contains(&precision) { + return None; + } + out.median_bit_depth = Some(precision); + } + } + Some(out) + } +} + +/// `IAD1` geometry. Coordinates are inclusive sensor offsets: `[left, top, right, bottom]`. +/// A full raw descriptor distinguishes valid sensor pixels from the recommended final crop. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cr3ImageArea { + pub width: u16, + pub height: u16, + pub crop: [u16; 4], + pub active: Option<[u16; 4]>, + pub masked_left: [u16; 4], + pub masked_top: Option<[u16; 4]>, +} + +impl Cr3ImageArea { + pub fn parse(payload: &[u8]) -> Option { + let bounds = |at: usize| Some([be16(payload, at)?, be16(payload, at + 2)?, be16(payload, at + 4)?, be16(payload, at + 6)?]); + let width = be16(payload, 4)?; + let height = be16(payload, 6)?; + if width == 0 || height == 0 { + return None; + } + let full = be16(payload, 10)? == 2; + Some(Self { + width, + height, + crop: bounds(16)?, + masked_left: bounds(24)?, + masked_top: if full { Some(bounds(32)?) } else { None }, + active: if full { Some(bounds(40)?) } else { None }, + }) + } +} + +impl Cr3Track { + /// Decode this track's image coding descriptor without reading its pixel data. + pub fn compression(&self, bytes: &[u8]) -> Option { + let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = self.kind else { return None }; + Cr3Compression::parse(bytes.get(at..at.checked_add(len)?)?) + } + + pub fn image_area(&self, bytes: &[u8]) -> Option { + let Cr3TrackKind::Raw { iad1: Some((at, len)), .. } = self.kind else { return None }; + Cr3ImageArea::parse(bytes.get(at..at.checked_add(len)?)?) + } +} + /// The parts of a CR3 file LightCraft reads. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Cr3<'a> { @@ -40,6 +174,57 @@ pub struct Cr3<'a> { pub tracks: Vec, } +/// One independent TIFF block from Canon timed metadata. Its offsets remain relative to this block. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cr3TimedExif<'a> { + /// CTMD record type (7, 8 or 9). + pub record_type: u16, + /// Exif IFD (`0x8769`) or Canon maker note (`0x927c`). + pub tag: u32, + pub data: &'a [u8], +} + +impl Cr3<'_> { + /// Read TIFF blocks in the first samples of CTMD tracks. As-shot white balance and sensor + /// levels often live here rather than in the static `CMT3` maker note. Both record layers use + /// little-endian byte lengths; TIFF blocks retain their own byte order and offset origin. + pub fn timed_exif<'a>(&self, bytes: &'a [u8]) -> Vec> { + let mut out = Vec::new(); + let mut budget = MAX_BOXES; + for track in &self.tracks { + if track.kind != Cr3TrackKind::Other(*b"CTMD") { + continue; + } + let Some(sample) = track.data.and_then(|(at, len)| at.checked_add(len).and_then(|end| bytes.get(at..end))) else { continue }; + let mut at = 0usize; + while budget > 0 { + budget -= 1; + let Some(len) = le32(sample, at).and_then(|v| usize::try_from(v).ok()) else { break }; + let Some(end) = at.checked_add(len).filter(|&end| len >= 12 && end <= sample.len()) else { break }; + let Some(record) = sample.get(at..end) else { break }; + let Some(kind) = le16(record, 4) else { break }; + if matches!(kind, 7..=9) { + let mut item_at = 12usize; + while budget > 0 { + budget -= 1; + let Some(item_len) = le32(record, item_at).and_then(|v| usize::try_from(v).ok()) else { break }; + let Some(item_end) = item_at.checked_add(item_len).filter(|&end| item_len >= 8 && end <= record.len()) else { break }; + let Some(item) = record.get(item_at..item_end) else { break }; + if let Some(tag @ (0x8769 | 0x927c)) = le32(item, 4) + && let Some(data) = item.get(8..) + { + out.push(Cr3TimedExif { record_type: kind, tag, data }); + } + item_at = item_end; + } + } + at = end; + } + } + out + } +} + const CANON_UUID: [u8; 16] = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; const XMP_UUID: [u8; 16] = [0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac]; const MAX_DEPTH: usize = 12; @@ -158,19 +343,21 @@ fn trak(bytes: &[u8], t: &BoxRef, budget: &mut usize) -> Option { let stbl = stbl?; let (mut kind, mut size, mut offset) = (None, None, None); for b in boxes(bytes, stbl.body, stbl.end, budget) { + let body = bytes.get(b.body..b.end)?; match &b.kind { // full box: version/flags, entry count, entries - b"stsd" => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)), + b"stsd" if be32(body, 4)? > 0 => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)), // version/flags, sample_size (0 = per-sample table), count, sizes b"stsz" => { - size = match be32(bytes, b.body + 4)? { - 0 if be32(bytes, b.body + 8)? > 0 => be32(bytes, b.body + 12), + size = match be32(body, 4)? { + 0 if be32(body, 8)? > 0 => be32(body, 12), 0 => None, - n => Some(n), + n if be32(body, 8)? > 0 => Some(n), + _ => None, } } - b"co64" if be32(bytes, b.body + 4)? > 0 => offset = be64(bytes, b.body + 8).and_then(|v| usize::try_from(v).ok()), - b"stco" if be32(bytes, b.body + 4)? > 0 => offset = be32(bytes, b.body + 8).map(|v| v as usize), + b"co64" if be32(body, 4)? > 0 => offset = be64(body, 8).and_then(|v| usize::try_from(v).ok()), + b"stco" if be32(body, 4)? > 0 => offset = be32(body, 8).map(|v| v as usize), _ => {} } } @@ -185,18 +372,37 @@ fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind { if &e.kind != b"CRAW" { return Cr3TrackKind::Other(e.kind); } - let (Some(width), Some(height)) = (be16(bytes, e.body + 24), be16(bytes, e.body + 26)) else { + let Some(body) = bytes.get(e.body..e.end) else { return Cr3TrackKind::Other(e.kind) }; + let (Some(width), Some(height)) = (be16(body, 24), be16(body, 26)) else { return Cr3TrackKind::Other(e.kind); }; - let mut cmp1 = None; + let (mut cmp1, mut iad1, mut rendered) = (None, None, None); for c in boxes(bytes, e.body.saturating_add(CRAW_CHILDREN_AT), e.end, budget) { match &c.kind { - b"JPEG" => return Cr3TrackKind::Jpeg, + b"JPEG" => rendered = Some(Cr3TrackKind::Jpeg), + // HDR-capable cameras also put an HEVC preview in a CRAW entry, + // often with the same displayed dimensions as the main raw track. + b"HEVC" => rendered = Some(Cr3TrackKind::Other(c.kind)), b"CMP1" => cmp1 = Some((c.body, c.end - c.body)), + b"CDI1" => { + for area in boxes(bytes, c.body.saturating_add(4), c.end, budget) { + if &area.kind == b"IAD1" { + iad1 = Some((area.body, area.end - area.body)); + } + } + } _ => {} } } - Cr3TrackKind::Raw { width, height, cmp1 } + // A present, malformed CMP1 must remain a raw descriptor so its corruption + // is reported. Only an explicitly identified rendered codec can establish + // that a CRAW entry without CMP1 is a preview rather than a broken raw. + if cmp1.is_none() + && let Some(kind) = rendered + { + return kind; + } + Cr3TrackKind::Raw { width, height, cmp1, iad1 } } fn be16(b: &[u8], at: usize) -> Option { @@ -212,6 +418,16 @@ fn be64(b: &[u8], at: usize) -> Option { Some(u64::from_be_bytes(a)) } +fn le16(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(2)?)?; + Some(u16::from_le_bytes([s[0], s[1]])) +} + +fn le32(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + /// Merge `CMT1` (IFD0), `CMT2` (Exif) and `CMT4` (GPS) into one TIFF stream, so the Exif readers see a CR3 /// like any TIFF-based raw. The maker note (`CMT3`) is left out: its offsets are relative to its own block. pub fn merged_exif(c: &Cr3<'_>) -> Option> { @@ -308,12 +524,50 @@ mod tests { assert_eq!(c.xmp, Some(b"".as_slice())); assert_eq!(c.tracks.len(), 2); assert_eq!(c.tracks[0], Cr3Track { kind: Cr3TrackKind::Jpeg, data: Some((4096, 16)) }); - let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)) } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) }; + let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)), .. } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) }; assert_eq!((width, height, len), (60, 40, 4)); assert_eq!(&f[at..at + 2], &[0xff, 0x10]); assert_eq!(c.tracks[1].data, Some((4112, 32))); } + #[test] + fn hevc_preview_is_distinct_from_an_equal_size_raw_track() { + let preview = trak(craw(6000, 4000, &bx(b"HEVC", &[0; 4])), 4096, 16); + let reduced = trak(craw(1624, 1080, &bx(b"CMP1", &compression_payload(0))), 4112, 16); + let sensor = trak(craw(6000, 4000, &bx(b"CMP1", &compression_payload(0))), 4128, 16); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &[preview, reduced, sensor].concat())); + file.resize(4144, 0); + let container = parse_cr3(&file).unwrap(); + assert_eq!(container.tracks[0].kind, Cr3TrackKind::Other(*b"HEVC")); + assert!(container.tracks[0].compression(&file).is_none()); + assert!(matches!(container.tracks[2].kind, Cr3TrackKind::Raw { width: 6000, height: 4000, cmp1: Some(_), .. })); + assert_eq!(container.tracks[2].compression(&file).unwrap().width, 6888); + } + + #[test] + fn rendered_child_does_not_hide_a_malformed_raw_descriptor() { + for codec in [b"JPEG", b"HEVC"] { + for first in [true, false] { + let rendered = bx(codec, &[0; 4]); + let corrupt = bx(b"CMP1", &[0xff, 0]); + let children = if first { [rendered, corrupt] } else { [corrupt, rendered] }.concat(); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &trak(craw(6000, 4000, &children), 4096, 16))); + file.resize(4112, 0); + let container = parse_cr3(&file).unwrap(); + assert!(matches!(container.tracks[0].kind, Cr3TrackKind::Raw { cmp1: Some(_), .. })); + assert!(container.tracks[0].compression(&file).is_none()); + } + } + // Absence alone is not evidence of a preview: retain a broken raw so + // main-track selection cannot quietly fall back to a reduced image. + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &trak(craw(6000, 4000, &bx(b"free", &[])), 4096, 16))); + file.resize(4112, 0); + assert!(matches!(parse_cr3(&file).unwrap().tracks[0].kind, Cr3TrackKind::Raw { cmp1: None, .. })); + } + #[test] fn merged_exif_reads_like_a_tiff_raw() { let f = sample(); @@ -322,6 +576,145 @@ mod tests { assert_eq!(m.iso, Some(400)); } + fn compression_payload(levels: u8) -> Vec { + let mut b = vec![0u8; 52]; + b[0..2].copy_from_slice(&0xff00u16.to_be_bytes()); + b[2..4].copy_from_slice(&48u16.to_be_bytes()); + b[4..6].copy_from_slice(&0x100u16.to_be_bytes()); + for (at, value) in [(8, 6888u32), (12, 4546), (16, 3444), (20, 4546), (28, 216)] { + b[at..at + 4].copy_from_slice(&value.to_be_bytes()); + } + b[24] = 14; + b[25] = 0x40; + b[26] = levels; + b + } + + #[test] + fn compression_descriptor_retains_sensor_geometry_and_cfa() { + let b = compression_payload(0); + let h = Cr3Compression::parse(&b).unwrap(); + assert_eq!((h.width, h.height, h.tile_width, h.tile_height), (6888, 4546, 3444, 4546)); + assert_eq!((h.version, h.bit_depth, h.planes, h.cfa_pattern, h.encoding, h.levels, h.header_size), (0x100, 14, 4, 0, 0, 0, 216)); + let mut craw = compression_payload(3); + craw[25] = 0x41; + craw[27] = 0x80; + let h = Cr3Compression::parse(&craw).unwrap(); + assert_eq!((h.cfa_pattern, h.levels, h.tile_flags), (1, 3, 0x80)); + for len in 0..b.len() { + assert!(Cr3Compression::parse(&b[..len]).is_none()); + } + for (at, value) in [(24, 0), (24, 17), (25, 0), (25, 0x44)] { + let mut bad = b.clone(); + bad[at] = value; + assert!(Cr3Compression::parse(&bad).is_none()); + } + } + + #[test] + fn extended_descriptor_checks_optional_median_precision() { + let mut b = compression_payload(0x30); + b[32] = 0x80; + b.extend_from_slice(&33u32.to_be_bytes()); + b.resize(85, 0); + b[56] = 0xc0; + b[84] = 14; + assert_eq!(Cr3Compression::parse(&b).unwrap().median_bit_depth, Some(14)); + assert!(Cr3Compression::parse(&b[..84]).is_none()); + b[84] = 0; + assert!(Cr3Compression::parse(&b).is_none()); + } + + #[test] + fn raw_track_reads_distinct_active_area_and_default_crop() { + let mut area = vec![0u8; 48]; + for (at, value) in [ + (4, 6888u16), + (6, 4546), + (10, 2), + (16, 156), + (18, 158), + (20, 6875), + (22, 4537), + (24, 0), + (26, 0), + (28, 143), + (30, 4545), + (32, 144), + (34, 0), + (36, 6887), + (38, 45), + (40, 144), + (42, 46), + (44, 6887), + (46, 4545), + ] { + area[at..at + 2].copy_from_slice(&value.to_be_bytes()); + } + let cmp1 = bx(b"CMP1", &compression_payload(0)); + let iad1 = full(b"CDI1", &bx(b"IAD1", &area)); + let track = trak(craw(6888, 4546, &[cmp1, iad1].concat()), 4096, 32); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &track)); + file.resize(4128, 0); + let c = parse_cr3(&file).unwrap(); + assert_eq!(c.tracks[0].compression(&file).unwrap().width, 6888); + let geometry = c.tracks[0].image_area(&file).unwrap(); + assert_eq!(geometry.crop, [156, 158, 6875, 4537]); + assert_eq!(geometry.active, Some([144, 46, 6887, 4545])); + assert_eq!(geometry.masked_left, [0, 0, 143, 4545]); + for len in 0..area.len() { + assert!(Cr3ImageArea::parse(&area[..len]).is_none()); + } + } + + #[test] + fn truncated_sample_table_does_not_read_the_next_box() { + let stbl = [ + full(b"stsd", &[1u32.to_be_bytes().to_vec(), craw(16, 16, &bx(b"CMP1", &compression_payload(0)))].concat()), + bx(b"stsz", &[]), + full(b"co64", &[1u32.to_be_bytes().to_vec(), 256u64.to_be_bytes().to_vec()].concat()), + ] + .concat(); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))))); + file.resize(512, 0); + assert!(parse_cr3(&file).unwrap().tracks.is_empty()); + } + + #[test] + fn timed_metadata_exposes_the_dynamic_maker_note() { + use lightcraft_tiff::{Value, tags as t}; + let exif = tiff_with(t::ISO_SPEED, Value::Short(vec![800])); + let maker = tiff_with(0x4001, Value::Short(vec![48, 100, 200])); + let item = |tag: u32, data: &[u8]| { + let mut b = ((data.len() + 8) as u32).to_le_bytes().to_vec(); + b.extend_from_slice(&tag.to_le_bytes()); + b.extend_from_slice(data); + b + }; + let items = [item(0x8769, &exif), item(0x927c, &maker)].concat(); + let mut sample = vec![0u8; 12]; + sample[0..4].copy_from_slice(&((12 + items.len()) as u32).to_le_bytes()); + sample[4..6].copy_from_slice(&8u16.to_le_bytes()); + sample.extend(items); + let container = Cr3 { tracks: vec![Cr3Track { kind: Cr3TrackKind::Other(*b"CTMD"), data: Some((0, sample.len())) }], ..Default::default() }; + let parts = container.timed_exif(&sample); + assert_eq!(parts.len(), 2); + assert_eq!((parts[0].record_type, parts[0].tag, parts[0].data), (8, 0x8769, exif.as_slice())); + assert_eq!((parts[1].record_type, parts[1].tag, parts[1].data), (8, 0x927c, maker.as_slice())); + for len in 0..sample.len() { + assert!(container.timed_exif(&sample[..len]).is_empty()); + } + for at in [0, 12] { + let mut corrupt = sample.clone(); + corrupt[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(container.timed_exif(&corrupt).is_empty()); + corrupt[at..at + 4].copy_from_slice(&0u32.to_le_bytes()); + assert!(container.timed_exif(&corrupt).is_empty()); + } + } + #[test] fn hostile_input_never_panics() { let f = sample(); diff --git a/crates/meta/tests/robust.rs b/crates/meta/tests/robust.rs index 6386e700..cd60e00a 100644 --- a/crates/meta/tests/robust.rs +++ b/crates/meta/tests/robust.rs @@ -5,8 +5,8 @@ proptest! { #![proptest_config(ProptestConfig { cases: 1500, .. ProptestConfig::default() })] #[test] - fn random_containers_never_panic(mut data in proptest::collection::vec(any::(), 0..600), kind in 0usize..5) { - let heads: [&[u8]; 5] = [b"\xff\xd8\xff\xe1", b"\x89PNG\r\n\x1a\n", b"RIFF\0\0\0\0WEBP", b"II*\0", b"MM\0*"]; + fn random_containers_never_panic(mut data in proptest::collection::vec(any::(), 0..600), kind in 0usize..6) { + let heads: [&[u8]; 6] = [b"\xff\xd8\xff\xe1", b"\x89PNG\r\n\x1a\n", b"RIFF\0\0\0\0WEBP", b"II*\0", b"MM\0*", b"\0\0\0\x18ftypcrx "]; let h = heads[kind]; if data.len() >= h.len() { data[..h.len()].copy_from_slice(h); diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 599a82a5..105690ae 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -12,11 +12,11 @@ //! look tables and tone curve. //! //! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw), -//! Canon CR2, Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer +//! 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). -//! [`embedded_preview`] covers all of them plus CR3. Variants we can't decode yet (Nikon "lossy after split" NEF, -//! compressed ORF, CR3) return [`RawError::Unsupported`]; each vendor module documents its sources +//! [`embedded_preview`] covers these containers' JPEG previews. Variants we can't decode yet (Nikon "lossy after split" NEF, +//! compressed ORF, CR3 unverified marker families / C-RAW configurations) return [`RawError::Unsupported`]; each vendor module documents its sources //! (public specifications, tag-name documentation, black-box analysis of CC0 samples) and gaps. Non-DNG files carry no //! colour matrix: [`color`] falls back to a documented neutral model. The decoders never panic on malformed input. #![forbid(unsafe_code)] @@ -92,7 +92,15 @@ impl RawFormat { pub fn is_supported(self) -> bool { matches!( self, - RawFormat::Dng | RawFormat::Cr2 | RawFormat::Nef | RawFormat::Nrw | RawFormat::Arw | RawFormat::Raf | RawFormat::Rw2 | RawFormat::Pef + RawFormat::Dng + | RawFormat::Cr2 + | RawFormat::Cr3 + | RawFormat::Nef + | RawFormat::Nrw + | RawFormat::Arw + | RawFormat::Raf + | RawFormat::Rw2 + | RawFormat::Pef ) } } @@ -180,6 +188,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { match probe(bytes).ok_or(RawError::NotRaw)? { RawFormat::Dng => dng::decode(bytes, mode), RawFormat::Cr2 => vendor::cr2::decode(bytes, mode), + RawFormat::Cr3 => vendor::cr3::decode(bytes, mode), RawFormat::Nef | RawFormat::Nrw => vendor::nef::decode(bytes), RawFormat::Arw => vendor::arw::decode(bytes, mode), RawFormat::Raf => vendor::raf::decode(bytes, mode), diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs new file mode 100644 index 00000000..c51f8e30 --- /dev/null +++ b/crates/raw/src/vendor/cr3.rs @@ -0,0 +1,470 @@ +//! Canon CR3 container, camera metadata and sensor geometry. +//! +//! Sources: Laurent ClΓ©vy's public CR3 format description (`CMP1`, `CDI1/IAD1`, `CMT3`), +//! , and ExifTool's published Canon +//! tag-name tables (`SensorInfo`, `ColorData9`…`ColorData12`, `LensModel`), +//! . No third-party decoder source was consulted. +//! +//! `IAD1` stores the valid sensor area separately from the recommended image crop. Both use inclusive +//! sensor offsets. The CFA is anchored at the full sensor's origin; cropping shifts its phase later. +//! Unknown camera metadata versions are ignored rather than assigned a guessed white balance. + +use super::{black_from_columns, crx, white_from_data}; +use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use lightcraft_meta::cr3::{Cr3ImageArea, Cr3Track, Cr3TrackKind, parse_cr3}; +use lightcraft_tiff::{Ifd, Tiff}; + +const SENSOR_INFO: u16 = 0x00e0; +const COLOR_DATA: u16 = 0x4001; +const LENS_MODEL: u16 = 0x0095; + +/// Pick the first largest raw track. Equal-sized later tracks can be the Dual Pixel delta, which +/// must never replace the main sensor image. An invalid full-size descriptor is reported, not +/// silently replaced with the reduced raw preview. +fn main_track(tracks: &[Cr3Track]) -> Option<&Cr3Track> { + let mut best = None; + let mut best_area = 0u64; + for track in tracks { + if let Cr3TrackKind::Raw { width, height, .. } = track.kind { + let area = u64::from(width) * u64::from(height); + if area > best_area { + best = Some(track); + best_area = area; + } + } + } + best +} + +fn cfa(pattern: u8) -> Option { + let name = match pattern { + 0 => "RGGB", + 1 => "GRBG", + 2 => "GBRG", + 3 => "BGGR", + _ => return None, + }; + Some(Cfa::bayer_static(name)) +} + +/// Inclusive offsets, clipped to the sample dimensions (some Canon crop-mode files record right +/// and bottom borders a few pixels beyond the encoded image). +fn inclusive_rect(bounds: [u16; 4], width: usize, height: usize) -> Option { + let [left, top, right, bottom] = bounds.map(usize::from); + if left >= width || top >= height || right < left || bottom < top { + return None; + } + let end_x = right.checked_add(1)?.min(width); + let end_y = bottom.checked_add(1)?.min(height); + Some(Rect::new(left, top, end_x.checked_sub(left)?, end_y.checked_sub(top)?)) +} + +fn relative_crop(crop: Rect, active: Rect) -> Option { + let x = crop.x.checked_sub(active.x)?; + let y = crop.y.checked_sub(active.y)?; + if crop.width < 2 || crop.height < 2 || x.checked_add(crop.width)? > active.width || y.checked_add(crop.height)? > active.height { + return None; + } + Some(Rect::new(x, y, crop.width, crop.height)) +} + +fn sensor_crop(maker: Option<&Ifd>, width: usize, height: usize) -> Option { + let data = maker?.u64s(SENSOR_INFO)?; + let bounds = data.get(5..9)?; + inclusive_rect( + [ + u16::try_from(*bounds.first()?).ok()?, + u16::try_from(*bounds.get(1)?).ok()?, + u16::try_from(*bounds.get(2)?).ok()?, + u16::try_from(*bounds.get(3)?).ok()?, + ], + width, + height, + ) +} + +fn geometry(area: Option<&Cr3ImageArea>, maker: Option<&Ifd>, width: usize, height: usize) -> (Rect, Rect) { + let area = area.filter(|a| usize::from(a.width) == width && usize::from(a.height) == height); + let sensor = sensor_crop(maker, width, height); + let active = area.and_then(|a| a.active).and_then(|b| inclusive_rect(b, width, height)).or(sensor).unwrap_or(Rect::new(0, 0, width, height)); + let crop = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor).and_then(|c| relative_crop(c, active)).unwrap_or(Rect::new( + 0, + 0, + active.width, + active.height, + )); + (active, crop) +} + +#[derive(Default)] +struct CameraLevels { + wb: Option<[f32; 3]>, + black: Option<[f32; 4]>, + white: Option, +} + +/// Canon's versioned word arrays. Offsets are word indices from the published tag tables, not +/// byte offsets. The four as-shot WB values are R, G, G, B, independent of the CFA layout. +fn camera_levels(maker: Option<&Ifd>, bits: u8) -> CameraLevels { + let Some(values) = maker.and_then(|m| m.u64s(COLOR_DATA)) else { return CameraLevels::default() }; + let offsets = match values.first().copied() { + Some(16..=19) => (71, 329, 797), + Some(32 | 33) => (85, 343, 811), + Some(34 | 48) => (105, 363, 641), + Some(64 | 65) => (105, 383, 661), + _ => return CameraLevels::default(), + }; + let full = ((1u32 << u32::from(bits.clamp(1, 16))) - 1) as f32; + let quadruple = |at: usize| -> Option<[f32; 4]> { + let v = values.get(at..at + 4)?; + Some([*v.first()? as f32, *v.get(1)? as f32, *v.get(2)? as f32, *v.get(3)? as f32]) + }; + let wb = quadruple(offsets.0).and_then(|[r, g1, g2, b]| { + let green = (g1 + g2) * 0.5; + if !(64.0..=32767.0).contains(&green) || (g1 - g2).abs() > green * 0.1 { + return None; + } + let red = r / green; + let blue = b / green; + ((0.1..8.0).contains(&red) && (0.1..8.0).contains(&blue)).then_some([red, 1.0, blue]) + }); + let black = quadruple(offsets.1).filter(|v| v.iter().any(|&b| b > 0.0) && v.iter().all(|&b| b >= 0.0 && b < full * 0.5)); + let max_black = black.map(|v| v.into_iter().fold(0.0f32, f32::max)).unwrap_or(0.0); + let white = values.get(offsets.2).map(|&v| v as f32).filter(|&v| v > max_black && v >= full * 0.5 && v <= full); + CameraLevels { wb, black, white } +} + +fn black_at_active(levels: [f32; 4], pattern: u8, active: Rect) -> BlackLevel { + // Convert Canon's channel order to full-sensor site order, then shift to the active origin. + let sites = match pattern { + 1 => [1, 0, 3, 2], + 2 => [2, 3, 0, 1], + 3 => [3, 2, 1, 0], + _ => [0, 1, 2, 3], + }; + let values = (0..4) + .map(|i| { + let x = (active.x + i % 2) % 2; + let y = (active.y + i / 2) % 2; + levels.get(*sites.get(y * 2 + x).unwrap_or(&0)).copied().unwrap_or(0.0) + }) + .collect(); + BlackLevel { repeat_rows: 2, repeat_cols: 2, values, delta_h: vec![], delta_v: vec![] } +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { + let container = parse_cr3(bytes).ok_or(RawError::NotRaw)?; + let track = main_track(&container.tracks).ok_or_else(|| RawError::Unsupported("CR3 without a Bayer raw track".into()))?; + let coding = track.compression(bytes).ok_or_else(|| RawError::Corrupt("CR3 raw track has an invalid CMP1 descriptor".into()))?; + if coding.planes != 4 || coding.encoding != 0 { + return Err(RawError::Unsupported(format!("Canon CR3 encoding {} with {} planes", coding.encoding, coding.planes))); + } + let (width, height) = (coding.width as usize, coding.height as usize); + let samples = width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_SAMPLES).ok_or(RawError::Limit("CR3 sensor dimensions"))?; + let cfa = cfa(coding.cfa_pattern).ok_or_else(|| RawError::Unsupported(format!("Canon CR3 CFA {}", coding.cfa_pattern)))?; + let (offset, len) = track.data.ok_or_else(|| RawError::Corrupt("CR3 raw sample outside file".into()))?; + let sample = + offset.checked_add(len).and_then(|end| bytes.get(offset..end)).ok_or_else(|| RawError::Corrupt("CR3 raw sample outside file".into()))?; + if coding.header_size as usize > sample.len() { + return Err(RawError::Corrupt("CR3 coding header outside sample".into())); + } + crx::validate(sample, &coding)?; + let data = match mode { + Mode::Full => { + let data = crx::decode(sample, &coding)?; + if data.len() != samples { + return Err(RawError::Corrupt("CR3 decoded sample count does not match CMP1".into())); + } + data + } + Mode::Header => vec![], + }; + let static_maker = container.cmt.get(2).and_then(|b| *b).and_then(|b| Tiff::parse(b).ok()); + let maker = static_maker.as_ref().and_then(|m| m.ifds.first()); + // Retain only the first useful timed maker note: repeated CTMD records must not accumulate + // independent copies of every bounded TIFF value in memory. + let timed_maker = container + .timed_exif(bytes) + .into_iter() + .filter(|b| b.tag == 0x927c) + .filter_map(|b| Tiff::parse(b.data).ok()) + .find(|t| t.ifds.first().is_some_and(|m| m.contains(COLOR_DATA))); + let color_maker = timed_maker.as_ref().and_then(|t| t.ifds.first()).or(maker); + let area = track.image_area(bytes); + let (active, crop) = geometry(area.as_ref(), maker, width, height); + let levels = camera_levels(color_maker, coding.bit_depth); + let black = match levels.black { + Some(levels) => black_at_active(levels, coding.cfa_pattern, active), + None if active.x >= 8 => black_from_columns(&data, width, 2..active.x - 2, active.y..active.y + active.height, active), + None => BlackLevel::uniform(0.0), + }; + let white = levels.white.unwrap_or_else(|| white_from_data(&data, u32::from(coding.bit_depth))); + let mut metadata = lightcraft_meta::extract(bytes); + if metadata.lens_model.is_none() { + metadata.lens_model = maker.and_then(|m| m.string(LENS_MODEL)).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); + } + metadata.width = Some(crop.width as u32); + metadata.height = Some(crop.height as u32); + let image = RawImage { + format: RawFormat::Cr3, + width, + height, + cpp: 1, + data: RawData::U16(data), + cfa: Some(cfa), + bits: u32::from(coding.bit_depth), + black, + white: vec![white], + active_area: active, + crop, + orientation: metadata.orientation.unwrap_or_default(), + color: ColorData::default(), + wb_multipliers: levels.wb, + linearized: false, + opcodes: OpcodeLists::default(), + metadata, + }; + image.validate_for(mode)?; + Ok(image) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{IfdBuilder, Value}; + + fn maker(version: u16, wb_at: usize, black_at: usize, white_at: usize) -> Ifd { + let mut words = vec![0u16; white_at + 1]; + words[0] = version; + words[wb_at..wb_at + 4].copy_from_slice(&[2048, 1024, 1024, 1536]); + words[black_at..black_at + 4].copy_from_slice(&[100, 101, 102, 103]); + words[white_at] = 15000; + let b = IfdBuilder::new().with(COLOR_DATA, Value::Short(words)); + let bytes = lightcraft_tiff::TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[b]).unwrap(); + Tiff::parse(&bytes).unwrap().ifds.remove(0) + } + + fn box_bytes(kind: &[u8; 4], body: &[u8]) -> Vec { + let mut b = ((body.len() + 8) as u32).to_be_bytes().to_vec(); + b.extend_from_slice(kind); + b.extend_from_slice(body); + b + } + + fn track(entry: &[u8], offset: u64, size: u32) -> Vec { + let mut stsd = vec![0u8; 4]; + stsd.extend(1u32.to_be_bytes()); + stsd.extend(entry); + let mut stsz = vec![0u8; 4]; + stsz.extend(size.to_be_bytes()); + stsz.extend(1u32.to_be_bytes()); + let mut co64 = vec![0u8; 4]; + co64.extend(1u32.to_be_bytes()); + co64.extend(offset.to_be_bytes()); + let tables = [box_bytes(b"stsd", &stsd), box_bytes(b"stsz", &stsz), box_bytes(b"co64", &co64)].concat(); + box_bytes(b"trak", &box_bytes(b"mdia", &box_bytes(b"minf", &box_bytes(b"stbl", &tables)))) + } + + fn synthetic_header() -> Vec { + use lightcraft_tiff::{ByteOrder, TiffWriter, tags as t}; + let tiff = |b: IfdBuilder| TiffWriter::new(ByteOrder::Little, false).write(&[b]).unwrap(); + let cmt1 = tiff( + IfdBuilder::new() + .with(t::MAKE, Value::Ascii("Canon".into())) + .with(t::MODEL, Value::Ascii("EOS Test".into())) + .with(t::ORIENTATION, Value::Short(vec![6])), + ); + let cmt2 = tiff(IfdBuilder::new().with(t::ISO_SPEED, Value::Short(vec![800]))); + let cmt3 = tiff( + IfdBuilder::new() + .with(SENSOR_INFO, Value::Short(vec![34, 64, 48, 1, 1, 6, 4, 61, 45])) + .with(LENS_MODEL, Value::Ascii("RF-S18-45mm F4.5-6.3 IS STM".into())), + ); + let mut canon = vec![0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; + canon.extend([box_bytes(b"CMT1", &cmt1), box_bytes(b"CMT2", &cmt2), box_bytes(b"CMT3", &cmt3)].concat()); + let mut coding = vec![0u8; 52]; + coding[0..2].copy_from_slice(&0xff00u16.to_be_bytes()); + coding[2..4].copy_from_slice(&48u16.to_be_bytes()); + coding[4..6].copy_from_slice(&0x100u16.to_be_bytes()); + for (at, value) in [(8, 64u32), (12, 48), (16, 32), (20, 48), (28, 216)] { + coding[at..at + 4].copy_from_slice(&value.to_be_bytes()); + } + coding[24] = 14; + coding[25] = 0x40; + let mut area = vec![0u8; 48]; + for (at, value) in [ + (4, 64u16), + (6, 48), + (10, 2), + (16, 6), + (18, 4), + (20, 61), + (22, 45), + (28, 3), + (30, 47), + (32, 4), + (36, 63), + (38, 1), + (40, 4), + (42, 2), + (44, 63), + (46, 47), + ] { + area[at..at + 2].copy_from_slice(&value.to_be_bytes()); + } + let mut craw = vec![0u8; 82]; + craw[24..26].copy_from_slice(&64u16.to_be_bytes()); + craw[26..28].copy_from_slice(&48u16.to_be_bytes()); + craw.extend(box_bytes(b"CMP1", &coding)); + craw.extend(box_bytes(b"CDI1", &[vec![0u8; 4], box_bytes(b"IAD1", &area)].concat())); + let levels = maker(48, 105, 363, 641).value(COLOR_DATA).unwrap().clone(); + let dynamic_maker = tiff(IfdBuilder::new().with(COLOR_DATA, levels)); + let mut item = ((dynamic_maker.len() + 8) as u32).to_le_bytes().to_vec(); + item.extend(0x927cu32.to_le_bytes()); + item.extend(dynamic_maker); + let mut timed = vec![0u8; 12]; + timed[0..4].copy_from_slice(&((12 + item.len()) as u32).to_le_bytes()); + timed[4..6].copy_from_slice(&8u16.to_le_bytes()); + timed.extend(item); + // Two tiles, each with four one-byte lossless plane streams. Header + // probing checks the complete marker layout without decoding entropy. + let mut sample = Vec::new(); + let marker = |out: &mut Vec, code: u16, size: u32, flags: u32| { + out.extend(code.to_be_bytes()); + out.extend(8u16.to_be_bytes()); + out.extend(size.to_be_bytes()); + out.extend(flags.to_be_bytes()); + }; + for tile in 0..2 { + marker(&mut sample, 0xff01, 4, tile << 16); + for plane in 0..4 { + marker(&mut sample, 0xff02, 1, (plane << 28) | 0x0800_0000); + marker(&mut sample, 0xff03, 1, 0x0020_0000); + } + } + assert_eq!(sample.len(), 216); + sample.extend([0x1e; 8]); + let moov = + [box_bytes(b"uuid", &canon), track(&box_bytes(b"CRAW", &craw), 4096, 224), track(&box_bytes(b"CTMD", &[]), 4320, timed.len() as u32)] + .concat(); + let mut file = box_bytes(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(box_bytes(b"moov", &moov)); + assert!(file.len() < 4096); + file.resize(4096, 0); + file.extend(sample); + file.extend(timed); + file + } + + #[test] + fn header_reads_timed_white_balance_and_distinct_geometry() { + let bytes = synthetic_header(); + let image = decode(&bytes, Mode::Header).unwrap(); + assert_eq!((image.width, image.height, image.bits), (64, 48, 14)); + assert_eq!(image.active_area, Rect::new(4, 2, 60, 46)); + assert_eq!(image.crop, Rect::new(2, 2, 56, 42)); + assert_eq!(image.wb_multipliers, Some([2.0, 1.0, 1.5])); + assert_eq!(image.black.values, vec![100.0, 101.0, 102.0, 103.0]); + assert_eq!(image.white, vec![15000.0]); + assert_eq!(image.metadata.iso, Some(800)); + assert_eq!(image.metadata.lens_model.as_deref(), Some("RF-S18-45mm F4.5-6.3 IS STM")); + assert_eq!((image.metadata.width, image.metadata.height), (Some(56), Some(42))); + assert_eq!(image.orientation, crate::Orientation::Rotate90); + assert!(image.data.is_empty()); + assert!(decode(&bytes[..4200], Mode::Header).is_err()); + } + + fn prepend_tracks(bytes: &[u8], tracks: &[u8]) -> Vec { + let ftyp_end = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize; + let moov_len = u32::from_be_bytes(bytes[ftyp_end..ftyp_end + 4].try_into().unwrap()) as usize; + assert_eq!(&bytes[ftyp_end + 4..ftyp_end + 8], b"moov"); + let moov = [tracks, &bytes[ftyp_end + 8..ftyp_end + moov_len]].concat(); + let mut out = [bytes[..ftyp_end].to_vec(), box_bytes(b"moov", &moov)].concat(); + assert!(out.len() < 4096); + out.resize(4096, 0); + out.extend_from_slice(&bytes[4096..]); + out + } + + #[test] + fn hevc_preview_with_equal_dimensions_does_not_replace_the_sensor_track() { + let bytes = synthetic_header(); + let mut preview = vec![0u8; 82]; + preview[24..26].copy_from_slice(&64u16.to_be_bytes()); + preview[26..28].copy_from_slice(&48u16.to_be_bytes()); + preview.extend(box_bytes(b"HEVC", &[0; 4])); + let bytes = prepend_tracks(&bytes, &track(&box_bytes(b"CRAW", &preview), 4096, 224)); + let container = parse_cr3(&bytes).unwrap(); + assert_eq!(container.tracks[0].kind, Cr3TrackKind::Other(*b"HEVC")); + let image = decode(&bytes, Mode::Header).unwrap(); + assert_eq!((image.width, image.height, image.bits), (64, 48, 14)); + assert_eq!(image.active_area, Rect::new(4, 2, 60, 46)); + } + + #[test] + fn corrupt_main_descriptor_is_not_replaced_by_the_reduced_raw() { + let bytes = synthetic_header(); + let container = parse_cr3(&bytes).unwrap(); + let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = main_track(&container.tracks).unwrap().kind else { panic!("raw fixture") }; + let mut reduced = vec![0u8; 82]; + reduced[24..26].copy_from_slice(&32u16.to_be_bytes()); + reduced[26..28].copy_from_slice(&24u16.to_be_bytes()); + reduced.extend(box_bytes(b"CMP1", &bytes[at..at + len])); + let bytes = prepend_tracks(&bytes, &track(&box_bytes(b"CRAW", &reduced), 4096, 224)); + let container = parse_cr3(&bytes).unwrap(); + let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = main_track(&container.tracks).unwrap().kind else { panic!("main raw fixture") }; + let mut malformed = bytes.clone(); + malformed[at..at + len].fill(0); + assert!(matches!(decode(&malformed, Mode::Header), Err(RawError::Corrupt(_)))); + let mut missing = bytes; + missing[at - 4..at].copy_from_slice(b"free"); + assert!(matches!(decode(&missing, Mode::Header), Err(RawError::Corrupt(_)))); + } + + #[test] + fn color_data_uses_versioned_as_shot_offsets() { + for (version, wb_at, black_at, white_at) in + [(16, 71, 329, 797), (19, 71, 329, 797), (33, 85, 343, 811), (48, 105, 363, 641), (64, 105, 383, 661)] + { + let m = maker(version, wb_at, black_at, white_at); + let levels = camera_levels(Some(&m), 14); + assert_eq!(levels.wb, Some([2.0, 1.0, 1.5])); + assert_eq!(levels.black, Some([100.0, 101.0, 102.0, 103.0])); + assert_eq!(levels.white, Some(15000.0)); + } + assert!(camera_levels(Some(&maker(255, 71, 329, 797)), 14).wb.is_none()); + assert!(camera_levels(None, 14).black.is_none()); + } + + #[test] + fn crop_is_relative_to_the_valid_sensor_area() { + let area = Cr3ImageArea { + width: 6888, + height: 4546, + crop: [156, 158, 6875, 4537], + active: Some([144, 46, 6887, 4545]), + masked_left: [0, 0, 143, 4545], + masked_top: None, + }; + assert_eq!(geometry(Some(&area), None, 6888, 4546), (Rect::new(144, 46, 6744, 4500), Rect::new(12, 112, 6720, 4380))); + assert_eq!(inclusive_rect([152, 46, 4353, 2851], 4352, 2850), Some(Rect::new(152, 46, 4200, 2804))); + assert_eq!(inclusive_rect([100, 100, 50, 50], 200, 200), None); + assert_eq!(relative_crop(Rect::new(0, 0, 100, 100), Rect::new(4, 4, 92, 92)), None); + } + + #[test] + fn first_full_size_track_wins_over_dual_pixel_delta() { + let raw = |w, h, offset| Cr3Track { kind: Cr3TrackKind::Raw { width: w, height: h, cmp1: None, iad1: None }, data: Some((offset, 100)) }; + let tracks = [raw(1624, 1080, 1), raw(6888, 4546, 2), raw(6888, 4546, 3)]; + assert_eq!(main_track(&tracks).unwrap().data, Some((2, 100))); + } + + #[test] + fn cfa_and_black_follow_the_full_sensor_origin() { + for (code, name) in [(0, "RGGB"), (1, "GRBG"), (2, "GBRG"), (3, "BGGR")] { + assert_eq!(cfa(code).unwrap().name(), name); + } + let b = black_at_active([100.0, 101.0, 102.0, 103.0], 0, Rect::new(3, 1, 32, 20)); + assert_eq!(b.values, vec![103.0, 102.0, 101.0, 100.0]); + } +} diff --git a/crates/raw/src/vendor/crx.rs b/crates/raw/src/vendor/crx.rs new file mode 100644 index 00000000..f0cf7328 --- /dev/null +++ b/crates/raw/src/vendor/crx.rs @@ -0,0 +1,1032 @@ +//! Canon CRX sensor sample decompression. +//! +//! Clean-room sources: Laurent ClΓ©vy's public prose CR3 format description +//! (CMP1 and marker layouts), +//! Canon patent US20160323602A1 (MED prediction, signed mapping, adaptive Rice coding), +//! Canon patent JP2017192077A (zero-run flag and MELCODE of run length minus one), and +//! ITU-T T.87 (MELCODE run index table). No third-party decoder source was consulted. +//! CRX-specific boundaries and adaptive parameters were established by comparing +//! CC0 raw.pixls.us files against an external decoder used only as a black-box oracle. +//! +//! Current support: version 0x100 lossless Bayer RAW and horizontal-tile C-RAW, +//! plus version 0x200 single-tile 14-bit C-RAW with adaptive QP quantization. +//! Both ff01/ff02/ff03 and ff11/ff12/ff13 marker families are supported. +//! Six full M50/R100/R8 sensor fixtures match an external decoder sample for sample. +//! Unverified coding variants return explicit unsupported errors. + +use super::crx_wavelet::{self, Band as WaveletBand, TileEdges}; +use crate::{MAX_SAMPLES, RawError, Result}; +use lightcraft_meta::cr3::Cr3Compression; +use rayon::prelude::*; +use std::ops::Range; + +const MAX_TILES: usize = 1024; +const MAX_HEADER_BYTES: usize = 1 << 20; +const MAX_CRX_SAMPLES: usize = 200_000_000; +const MAX_CRX_EDGE: u32 = 65_536; +const RUN_EXPONENT: [u8; 32] = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 9, 10, 11, 12, 13, 14, 15]; + +fn corrupt(reason: &str) -> RawError { + RawError::Corrupt(format!("Canon CRX: {reason}")) +} + +#[derive(Debug)] +struct Band { + data: Range, + padding: usize, + quant: Quantization, + partial: bool, +} + +#[derive(Debug)] +enum Quantization { + Uniform(u8), + Adaptive { base: u32, gain: u32 }, +} + +struct QpMap { + width: usize, + height: usize, + data: Vec, +} + +#[derive(Debug)] +struct Plane { + partial: bool, + rounded_bits: u8, + bands: Vec, +} + +#[derive(Debug)] +struct Tile { + x: usize, + y: usize, + width: usize, + height: usize, + planes: Vec, + qp: Option>, +} + +/// Decode to full sensor-order CFA samples, including masked borders. +pub(crate) fn decode(sample: &[u8], config: &Cr3Compression) -> Result> { + supports(config)?; + let width = usize::try_from(config.width).map_err(|_| RawError::Limit("CRX width"))?; + let count = image_size(config)?; + let tiles = layout(sample, config)?; + validate_planes(&tiles, config)?; + // Allocated once the first plane decodes: a tiny crafted file can claim a huge image. + let mut output = Vec::new(); + let midpoint = 1i32 << (config.bit_depth - 1); + let maximum = (1i32 << config.bit_depth) - 1; + for tile in tiles { + let qp = tile_qp(sample, &tile)?; + // Each plane is its own entropy stream: decode a tile's planes in parallel. + let planes: Vec>> = tile + .planes + .par_iter() + .enumerate() + .map(|(p, plane)| { + if config.levels == 0 { + let band = plane.bands.first().ok_or_else(|| corrupt("missing lossless band"))?; + lossless_plane(band_bytes(sample, band)?, tile.width / 2, tile.height / 2) + } else { + let edges = TileEdges { left: tile.x > 0, right: tile.x + tile.width < width, top: false, bottom: false }; + wavelet_plane(sample, plane, tile.width / 2, tile.height / 2, edges, qp.as_ref()) + } + .map_err(|error| RawError::Corrupt(format!("Canon CRX tile ({}, {}), plane {p}: {error}", tile.x, tile.y))) + }) + .collect(); + for (p, decoded) in planes.into_iter().enumerate() { + let decoded = decoded?; + if output.is_empty() { + output.try_reserve_exact(count).map_err(|_| RawError::Limit("CRX sample allocation"))?; + output.resize(count, 0); + } + for (i, &value) in decoded.iter().enumerate() { + let sensor_value = + value.checked_add(midpoint).filter(|&v| v >= 0 && v <= maximum).ok_or_else(|| corrupt("decoded sample outside its bit depth"))?; + let (x, y) = (tile.x + 2 * (i % (tile.width / 2)) + p % 2, tile.y + 2 * (i / (tile.width / 2)) + p / 2); + let slot = y + .checked_mul(width) + .and_then(|n| n.checked_add(x)) + .and_then(|n| output.get_mut(n)) + .ok_or_else(|| corrupt("tile writes outside image"))?; + *slot = sensor_value as u16; + } + } + } + Ok(output) +} + +/// Header-only probing must reject the same unsupported coding variants as full decoding. +pub(crate) fn validate(sample: &[u8], config: &Cr3Compression) -> Result<()> { + supports(config)?; + image_size(config)?; + let tiles = layout(sample, config)?; + validate_planes(&tiles, config)?; + for tile in &tiles { + tile_qp(sample, tile)?; + } + Ok(()) +} + +fn image_size(config: &Cr3Compression) -> Result { + if config.width > MAX_CRX_EDGE || config.height > MAX_CRX_EDGE { + return Err(RawError::Limit("CRX image edge")); + } + (config.width as usize) + .checked_mul(config.height as usize) + .filter(|&n| n > 0 && n <= MAX_SAMPLES.min(MAX_CRX_SAMPLES)) + .ok_or(RawError::Limit("CRX image size")) +} + +fn supports(config: &Cr3Compression) -> Result<()> { + if ![0x100, 0x200].contains(&config.version) + || config.encoding != 0 + || config.planes != 4 + || config.cfa_pattern > 3 + || ![0, 3].contains(&config.levels) + { + return Err(RawError::Unsupported(format!( + "Canon CRX version {:#x}, encoding {}, {} planes, {} wavelet levels", + config.version, config.encoding, config.planes, config.levels + ))); + } + if !(8..=16).contains(&config.bit_depth) { + return Err(RawError::Unsupported(format!("Canon CRX {}-bit samples", config.bit_depth))); + } + if config.levels == 3 && config.tile_height != config.height { + return Err(RawError::Unsupported("Canon CRX C-RAW with vertical tiles".into())); + } + if config.version == 0x200 && (config.levels != 3 || config.bit_depth != 14 || config.tile_width != config.width) { + return Err(RawError::Unsupported("Canon CRX version 0x200 requires a single-tile 14-bit Bayer C-RAW image".into())); + } + if config.version == 0x200 && !config.height.div_ceil(4).is_multiple_of(2) { + return Err(RawError::Unsupported("Canon CRX adaptive QP map with an odd height".into())); + } + if config.median_bit_depth.is_some() { + return Err(RawError::Unsupported("Canon CRX extended median-bit-depth header".into())); + } + Ok(()) +} + +fn validate_planes(tiles: &[Tile], config: &Cr3Compression) -> Result<()> { + for tile in tiles { + for plane in &tile.planes { + if !plane.partial || plane.rounded_bits != 0 || plane.bands.len() != 1 + usize::from(config.levels) * 3 { + return Err(RawError::Unsupported("Canon CRX plane coding flags".into())); + } + for (index, band) in plane.bands.iter().enumerate() { + if band.partial { + return Err(RawError::Unsupported("Canon CRX partial subband coding".into())); + } + if config.levels != 0 { + match band.quant { + Quantization::Uniform(value) => { + quant_step(value)?; + } + Quantization::Adaptive { gain, .. } => { + if index < 4 && gain != 0 { + return Err(RawError::Unsupported("Canon CRX adaptive quantization subband parameters".into())); + } + } + } + } + } + } + } + Ok(()) +} + +fn layout(sample: &[u8], config: &Cr3Compression) -> Result> { + let (width, height, tw, th) = (config.width as usize, config.height as usize, config.tile_width as usize, config.tile_height as usize); + if width == 0 + || height == 0 + || tw == 0 + || th == 0 + || tw > width + || th > height + || !width.is_multiple_of(2) + || !height.is_multiple_of(2) + || !tw.is_multiple_of(2) + || !th.is_multiple_of(2) + { + return Err(corrupt("invalid Bayer tile dimensions")); + } + let columns = width.div_ceil(tw); + let rows = height.div_ceil(th); + let tile_count = columns.checked_mul(rows).filter(|&n| n <= MAX_TILES).ok_or(RawError::Limit("CRX tile count"))?; + let header_size = config.header_size as usize; + if header_size > MAX_HEADER_BYTES { + return Err(RawError::Limit("CRX header size")); + } + let header = sample.get(..header_size).ok_or_else(|| corrupt("truncated tile headers"))?; + let mut reader = Header { src: header, at: 0 }; + let mut data_at = header_size; + let mut tiles = Vec::with_capacity(tile_count); + for index in 0..tile_count { + let modern = config.version == 0x200; + let body = reader.marker(if modern { 0xff11 } else { 0xff01 }, if modern { 16 } else { 8 })?; + let tile_size = usize::try_from(be32(body, 0)?).map_err(|_| RawError::Limit("CRX tile size"))?; + if usize::from(be16(body, 4)?) != index { + return Err(corrupt("tile index out of sequence")); + } + let tile_end = data_at.checked_add(tile_size).filter(|&end| end <= sample.len()).ok_or_else(|| corrupt("tile data outside sample"))?; + let qp = if modern { + let length = be32(body, 8)? as usize; + let padding = usize::from(be16(body, 12)?); + if be16(body, 6)? != 0x4000 || be16(body, 14)? != 0 || length == 0 || padding > 7 { + return Err(RawError::Unsupported("Canon CRX adaptive quantization tile header".into())); + } + let end = data_at.checked_add(length).filter(|&end| end <= tile_end).ok_or_else(|| corrupt("QP data outside tile"))?; + let data = data_at..end; + data_at = end.checked_add(padding).filter(|&end| end <= tile_end).ok_or_else(|| corrupt("QP padding outside tile"))?; + Some(data) + } else { + None + }; + let mut planes = Vec::with_capacity(usize::from(config.planes)); + for p in 0..config.planes { + let body = reader.marker(if modern { 0xff12 } else { 0xff02 }, 8)?; + let size = be32(body, 0)? as usize; + let flags = be32(body, 4)?; + if flags >> 28 != u32::from(p) { + return Err(corrupt("plane index out of sequence")); + } + let plane_end = data_at.checked_add(size).filter(|&end| end <= tile_end).ok_or_else(|| corrupt("plane size exceeds tile"))?; + let mut bands = Vec::with_capacity(usize::from(config.levels) * 3 + 1); + for b in 0..u32::from(config.levels) * 3 + 1 { + let body = reader.marker(if modern { 0xff13 } else { 0xff03 }, if modern { 16 } else { 8 })?; + let band_size = be32(body, 0)? as usize; + let band_flags = be32(body, 4)?; + if band_flags >> 28 != b { + return Err(corrupt("band index out of sequence")); + } + let band_end = data_at.checked_add(band_size).filter(|&end| end <= plane_end).ok_or_else(|| corrupt("band size exceeds plane"))?; + let padding = if modern { usize::from(be16(body, 12)?) } else { (band_flags & 0x7ffff) as usize }; + if padding > band_size || (modern && padding > 7) { + return Err(corrupt("invalid band padding")); + } + bands.push(Band { + data: data_at..band_end, + padding, + quant: if modern { + if be16(body, 14)? != 0 { + return Err(RawError::Unsupported("Canon CRX adaptive subband reserved field".into())); + } + Quantization::Adaptive { base: be32(body, 8)?, gain: band_flags & 0x07ff_ffff } + } else { + Quantization::Uniform(((band_flags >> 19) & 0xff) as u8) + }, + partial: band_flags & 0x0800_0000 != 0, + }); + data_at = band_end; + } + if data_at != plane_end { + return Err(corrupt("band sizes do not sum to plane size")); + } + planes.push(Plane { partial: flags & 0x0800_0000 != 0, rounded_bits: ((flags >> 25) & 3) as u8, bands }); + } + if data_at != tile_end { + return Err(corrupt("plane sizes do not sum to tile size")); + } + let (x, y) = ((index % columns) * tw, (index / columns) * th); + tiles.push(Tile { x, y, width: tw.min(width - x), height: th.min(height - y), planes, qp }); + } + if header.get(reader.at..).is_none_or(|rest| rest.iter().any(|&byte| byte != 0)) { + return Err(corrupt("unexpected bytes after tile headers")); + } + if data_at != sample.len() { + return Err(corrupt("tile sizes do not cover the sample")); + } + Ok(tiles) +} + +fn band_bytes<'a>(sample: &'a [u8], band: &Band) -> Result<&'a [u8]> { + let end = + band.data.end.checked_sub(band.padding).filter(|&end| end >= band.data.start).ok_or_else(|| corrupt("band padding exceeds its size"))?; + sample.get(band.data.start..end).ok_or_else(|| corrupt("band data outside sample")) +} + +fn tile_qp(sample: &[u8], tile: &Tile) -> Result> { + tile.qp + .as_ref() + .map(|range| { + let bytes = sample.get(range.clone()).ok_or_else(|| corrupt("QP map outside sample"))?; + qp_map(bytes, tile.width.div_ceil(16), tile.height.div_ceil(4)) + }) + .transpose() +} + +fn quant_step(value: u8) -> Result { + // Measured independently with an external decoder after modifying only the + // quantValue field: all forty values 4..43 follow this integer sequence. + if !(4..=43).contains(&value) { + return Err(RawError::Unsupported(format!("Canon CRX quantization value {value}"))); + } + let scale = [40i32, 45, 51, 57, 64, 72].get(usize::from(value % 6)).copied().ok_or_else(|| corrupt("quantization table index"))?; + Ok((scale << (value / 6)) / 64) +} + +fn split_dimension(length: usize, before: bool, after: bool) -> (usize, usize) { + (length.div_ceil(2) + usize::from(after && length.is_multiple_of(2)), length / 2 + usize::from(before) + usize::from(after)) +} + +fn wavelet_plane(sample: &[u8], plane: &Plane, width: usize, height: usize, edges: TileEdges, qp: Option<&QpMap>) -> Result> { + let mut dimensions = vec![(width, height)]; + let mut high_dimensions = Vec::new(); + for _ in 0..3 { + let &(w, h) = dimensions.last().ok_or_else(|| corrupt("missing wavelet dimensions"))?; + let (lw, hw) = split_dimension(w, edges.left, edges.right); + let (lh, hh) = split_dimension(h, edges.top, edges.bottom); + dimensions.push((lw, lh)); + high_dimensions.push([(hw, lh), (lw, hh), (hw, hh)]); + } + let mut shapes = vec![*dimensions.last().ok_or_else(|| corrupt("missing LL dimensions"))?]; + for high in high_dimensions.iter().rev() { + shapes.extend_from_slice(high); + } + let mut bands = Vec::with_capacity(10); + for (i, (&(w, h), band)) in shapes.iter().zip(&plane.bands).enumerate() { + let bytes = band_bytes(sample, band)?; + let mut data = if i == 0 { + lossless_plane(bytes, w, h)? + } else { + // Some CRX high-frequency bands retain a redundant right column + // beyond synthesis support (LH2 in the M50/R100 fixtures). Infer + // that small extension only from a complete, correctly aligned + // entropy stream, then crop each row to the synthesis support. + let mut decoded = None; + for extra in 0..=2 { + let coded_width = w.checked_add(extra).ok_or(RawError::Limit("CRX band width"))?; + if let Ok(values) = high_frequency_band(bytes, coded_width, h) { + if extra == 0 { + decoded = Some(values); + } else { + let mut cropped = Vec::new(); + cropped.try_reserve_exact(w * h).map_err(|_| RawError::Limit("CRX cropped band allocation"))?; + for row in values.chunks_exact(coded_width) { + cropped.extend_from_slice(row.get(..w).ok_or_else(|| corrupt("band crop outside row"))?); + } + decoded = Some(cropped); + } + break; + } + } + decoded.ok_or_else(|| RawError::Corrupt(format!("Canon CRX band {i}: unsupported geometry or corrupt entropy")))? + }; + for (position, value) in data.iter_mut().enumerate() { + let step = match band.quant { + Quantization::Uniform(value) => quant_step(value)?, + Quantization::Adaptive { base, gain } => { + adaptive_step(qp.ok_or_else(|| corrupt("missing QP map"))?, i, position % w, position / w, base, gain)? + } + }; + *value = value.checked_mul(step).ok_or_else(|| corrupt("dequantized coefficient overflow"))?; + } + bands.push(WaveletBand { width: w, height: h, data }); + } + crx_wavelet::reconstruct(&bands, width, height, 3, edges) +} + +fn adaptive_step(map: &QpMap, band: usize, x: usize, y: usize, base: u32, gain: u32) -> Result { + let step = if gain == 0 { + u64::from(base) + } else { + let (row, column) = if (4..=6).contains(&band) { + (y * 2, x / 2) + } else if (7..=9).contains(&band) { + (y, x / 4) + } else { + return Err(RawError::Unsupported("Canon CRX adaptive low-frequency quantization".into())); + }; + if row >= map.height || column >= map.width { + return Err(corrupt("QP coordinate outside map")); + } + let index = row.checked_mul(map.width).and_then(|n| n.checked_add(column)).ok_or(RawError::Limit("CRX QP map index"))?; + let first = *map.data.get(index).ok_or_else(|| corrupt("QP coordinate outside map"))?; + let value = if band < 7 { + let second_row = row + 1; + let second = *map.data.get(second_row * map.width + column).ok_or_else(|| corrupt("QP average outside map"))?; + (first + second) / 2 + } else { + first + }; + // QP rows are averaged before looking up the integer step. R8 field + // mutations distinguish this order from averaging dequantization steps. + let quant = u8::try_from(value - 124).map_err(|_| corrupt("QP value outside quantization table"))?; + u64::from(base) + u64::from(gain) * quant_step(quant)? as u64 / 8 + }; + i32::try_from(step.max(1)).map_err(|_| corrupt("adaptive quantization step outside precision")) +} + +struct Header<'a> { + src: &'a [u8], + at: usize, +} + +impl<'a> Header<'a> { + fn marker(&mut self, wanted: u16, wanted_size: usize) -> Result<&'a [u8]> { + let marker = be16(self.src, self.at)?; + if marker == wanted + 0x10 { + return Err(RawError::Unsupported("Canon CRX ff11/ff12/ff13 marker family".into())); + } + if marker != wanted { + return Err(corrupt("unexpected tile/plane/band marker")); + } + let size = usize::from(be16(self.src, self.at + 2)?); + if size != wanted_size { + return Err(RawError::Unsupported(format!("Canon CRX marker {marker:#x} header length {size}"))); + } + let start = self.at.checked_add(4).ok_or(RawError::Limit("CRX header offset"))?; + let end = start.checked_add(size).ok_or(RawError::Limit("CRX header offset"))?; + let body = self.src.get(start..end).ok_or_else(|| corrupt("truncated marker header"))?; + self.at = end; + Ok(body) + } +} + +fn be16(src: &[u8], at: usize) -> Result { + let end = at.checked_add(2).ok_or(RawError::Limit("CRX header offset"))?; + let bytes = src.get(at..end).and_then(|s| <[u8; 2]>::try_from(s).ok()).ok_or_else(|| corrupt("truncated header field"))?; + Ok(u16::from_be_bytes(bytes)) +} + +fn be32(src: &[u8], at: usize) -> Result { + let end = at.checked_add(4).ok_or(RawError::Limit("CRX header offset"))?; + let bytes = src.get(at..end).and_then(|s| <[u8; 4]>::try_from(s).ok()).ok_or_else(|| corrupt("truncated header field"))?; + Ok(u32::from_be_bytes(bytes)) +} + +struct Bits<'a> { + src: &'a [u8], + at: usize, +} + +impl Bits<'_> { + /// The next 32 bits, MSB first, when at least that many remain before the end of the stream. + fn peek32(&self) -> Option { + let bytes = self.src.get(self.at / 8..self.at / 8 + 5)?; + let word = u64::from_be_bytes([0, 0, 0, bytes[0], bytes[1], bytes[2], bytes[3], bytes[4]]); + Some((word >> (8 - self.at % 8)) as u32) + } + + fn take(&mut self, count: u32) -> Result { + if count > 24 { + return Err(corrupt("invalid Rice parameter")); + } + if count == 0 { + return Ok(0); + } + if let Some(window) = self.peek32() { + self.at += count as usize; + return Ok(window >> (32 - count)); + } + // Near the end of the stream: bit by bit, so truncation is reported exactly. + let mut value = 0; + for _ in 0..count { + let byte = self.src.get(self.at / 8).ok_or_else(|| corrupt("truncated entropy stream"))?; + value = (value << 1) | u32::from((byte >> (7 - self.at % 8)) & 1); + self.at += 1; + } + Ok(value) + } + + fn rice(&mut self, k: u32) -> Result { + // Common case: the unary prefix and its terminating 1 lie within the next 32 bits. + if let Some(window) = self.peek32().filter(|&w| w != 0) { + let zeros = window.leading_zeros(); + self.at += zeros as usize + 1; + return if k <= 20 { Ok((zeros << k) | self.take(k)?) } else { Err(corrupt("Rice parameter exceeds sample precision")) }; + } + let mut zeros = 0; + while self.take(1)? == 0 { + zeros += 1; + if zeros > 41 { + return Err(corrupt("Rice escape prefix exceeds 41 bits")); + } + } + if zeros == 41 { + self.take(21) + } else if k <= 20 { + Ok((zeros << k) | self.take(k)?) + } else { + Err(corrupt("Rice parameter exceeds sample precision")) + } + } + + fn qp_rice(&mut self, k: u32) -> Result { + let mut zeros = 0; + while zeros < 16 && self.take(1)? == 0 { + zeros += 1; + } + if zeros == 16 { + // QP escape has no unary terminating 1 before the 16-bit literal. + self.take(16) + } else if k <= 20 { + Ok((zeros << k) | self.take(k)?) + } else { + Err(corrupt("QP Rice parameter exceeds precision")) + } + } + + fn finish(&self) -> Result<()> { + let bits = self.src.len().checked_mul(8).ok_or(RawError::Limit("CRX entropy length"))?; + if bits.saturating_sub(self.at) > 7 { + return Err(corrupt("unconsumed entropy data")); + } + if let Some(&last) = self.src.last() { + let remaining = (bits - self.at) as u32; + if u32::from(last) & ((1 << remaining) - 1) != 0 { + return Err(corrupt("nonzero entropy alignment bits")); + } + } + Ok(()) + } +} + +fn signed(symbol: u32) -> i32 { + (symbol >> 1) as i32 ^ -((symbol & 1) as i32) +} + +fn update_rice(mut k: u32, symbol: u32) -> u32 { + if symbol >= 3 << k { + k += 1; + if symbol >= 3 << k { + k += 1; + } + } else if k > 0 && symbol < 1 << (k - 1) { + k -= 1; + } + k.min(20) +} + +fn median(left: i32, above: i32, corner: i32) -> i32 { + if corner >= left.max(above) { + left.min(above) + } else if corner <= left.min(above) { + left.max(above) + } else { + left + above - corner + } +} + +fn run(bits: &mut Bits<'_>, index: &mut usize, remaining: usize) -> Result { + let mut length = 1usize; + while length < remaining { + let exponent = u32::from(*RUN_EXPONENT.get(*index).ok_or_else(|| corrupt("run index outside table"))?); + if bits.take(1)? == 0 { + length += bits.take(exponent)? as usize; + *index = index.saturating_sub(1); + if length > remaining { + return Err(corrupt("run exceeds row")); + } + break; + } + length += 1usize << exponent; + if length <= remaining { + *index = (*index + 1).min(RUN_EXPONENT.len() - 1); + } + } + Ok(length.min(remaining)) +} + +fn lossless_plane(src: &[u8], width: usize, height: usize) -> Result> { + let count = width + .checked_mul(height) + .filter(|&n| width > 0 && n > 0 && n <= MAX_SAMPLES.min(MAX_CRX_SAMPLES) / 4) + .ok_or(RawError::Limit("CRX plane size"))?; + // Grows per decoded row: a truncated stream fails before the whole plane is reserved. + let mut output = Vec::new(); + let mut previous = Vec::new(); + previous.try_reserve_exact(width).map_err(|_| RawError::Limit("CRX row allocation"))?; + previous.resize(width, 0i32); + let mut current = Vec::new(); + current.try_reserve_exact(width).map_err(|_| RawError::Limit("CRX row allocation"))?; + current.resize(width, 0i32); + let (mut k, mut run_index) = (0u32, 0usize); + let mut bits = Bits { src, at: 0 }; + for y in 0..height { + let mut x = 0; + let mut left = previous.first().copied().ok_or_else(|| corrupt("empty plane row"))?; + while x < width { + let mut interrupted = false; + let above = *previous.get(x).ok_or_else(|| corrupt("above sample outside row"))?; + let right = previous.get(x + 1).copied().unwrap_or(above); + if x + 1 < width && left == above && left == right && bits.take(1)? != 0 { + let length = run(&mut bits, &mut run_index, width - x)?; + let end = x.checked_add(length).ok_or(RawError::Limit("CRX run length"))?; + current.get_mut(x..end).ok_or_else(|| corrupt("run outside row"))?.fill(left); + x = end; + if x == width { + continue; + } + // A run interruption always uses regular prediction; no second run flag. + interrupted = true; + } + let above = *previous.get(x).ok_or_else(|| corrupt("above sample outside row"))?; + let corner = if x == 0 { above } else { *previous.get(x - 1).ok_or_else(|| corrupt("corner sample outside row"))? }; + let right = previous.get(x + 1).copied().unwrap_or(above); + let symbol = bits.rice(k)?; + let prediction = if interrupted { above } else { median(left, above, corner) }; + let value = prediction + .checked_add(signed(symbol)) + .filter(|v| (-1_048_576..=1_048_575).contains(v)) + .ok_or_else(|| RawError::Corrupt(format!("Canon CRX prediction outside precision at row {y}, column {x}, k {k}, symbol {symbol}")))?; + *current.get_mut(x).ok_or_else(|| corrupt("decoded sample outside row"))? = value; + left = value; + let estimate = if y > 0 && x + 1 < width { + symbol.checked_add(2 * right.abs_diff(above)).ok_or_else(|| corrupt("Rice estimate overflow"))? / 2 + } else { + symbol + }; + k = update_rice(k, estimate); + x += 1; + } + output.try_reserve(width).map_err(|_| RawError::Limit("CRX plane allocation"))?; + output.extend_from_slice(¤t); + std::mem::swap(&mut current, &mut previous); + } + bits.finish()?; + debug_assert_eq!(output.len(), count); + Ok(output) +} + +fn high_frequency_band(src: &[u8], width: usize, height: usize) -> Result> { + let count = width + .checked_mul(height) + .filter(|&n| width > 0 && n > 0 && n <= MAX_SAMPLES.min(MAX_CRX_SAMPLES) / 4) + .ok_or(RawError::Limit("CRX high-frequency band size"))?; + // Grows per decoded row, as in `lossless_plane`. + let mut output = Vec::new(); + let row = || -> Result> { + let mut values = Vec::new(); + values.try_reserve_exact(width).map_err(|_| RawError::Limit("CRX row allocation"))?; + values.resize(width, 0); + Ok(values) + }; + let (mut previous, mut current) = (row()?, row()?); + let (mut previous_k, mut current_k) = (row()?, row()?); + let (mut k, mut run_index) = (0u32, 0usize); + let mut bits = Bits { src, at: 0 }; + for y in 0..height { + let (mut x, mut left) = (0, 0); + while x < width { + let above = *previous.get(x).ok_or_else(|| corrupt("above coefficient outside row"))?; + let right = previous.get(x + 1).copied().unwrap_or(above); + let mut nonzero = false; + if x + 1 < width && left == 0 && above == 0 && right == 0 { + if bits.take(1)? != 0 { + let length = run(&mut bits, &mut run_index, width - x)?; + let end = x.checked_add(length).ok_or(RawError::Limit("CRX run length"))?; + current.get_mut(x..end).ok_or_else(|| corrupt("coefficient run outside row"))?.fill(0); + // The upper-row context for a zero run is zero, although the + // current Rice parameter remains unchanged across the run. + current_k.get_mut(x..end).ok_or_else(|| corrupt("Rice context run outside row"))?.fill(0); + left = 0; + x = end; + if x == width { + continue; + } + } + nonzero = true; + } + let coded = bits.rice(k)?; + // A zero-run flag 0 or its interruption proves that the coefficient + // is nonzero. That context omits zero from the signed mapping. + let symbol = coded.checked_add(u32::from(nonzero)).ok_or_else(|| corrupt("coefficient symbol overflow"))?; + let value = signed(symbol); + if !(-1_048_576..=1_048_575).contains(&value) { + return Err(corrupt("high-frequency coefficient outside precision")); + } + *current.get_mut(x).ok_or_else(|| corrupt("coefficient outside row"))? = value; + left = value; + k = update_rice(k, coded); + if y > 0 && x + 1 < width { + let upper_right_k = *previous_k.get(x + 1).ok_or_else(|| corrupt("Rice context outside row"))? as u32; + if upper_right_k > k + 1 { + k += 1; + } + } + *current_k.get_mut(x).ok_or_else(|| corrupt("Rice context outside row"))? = k as i32; + x += 1; + } + output.try_reserve(width).map_err(|_| RawError::Limit("CRX coefficient allocation"))?; + output.extend_from_slice(¤t); + std::mem::swap(&mut current, &mut previous); + std::mem::swap(&mut current_k, &mut previous_k); + } + bits.finish()?; + debug_assert_eq!(output.len(), count); + Ok(output) +} + +fn qp_map(src: &[u8], width: usize, height: usize) -> Result { + if !height.is_multiple_of(2) { + return Err(RawError::Unsupported("Canon CRX adaptive QP map with an odd height".into())); + } + let count = width.checked_mul(height).filter(|&n| width > 0 && n > 0 && n <= MAX_CRX_SAMPLES / 32).ok_or(RawError::Limit("CRX QP map size"))?; + let mut data = Vec::new(); + data.try_reserve_exact(count).map_err(|_| RawError::Limit("CRX QP allocation"))?; + let row = || -> Result> { + let mut values = Vec::new(); + values.try_reserve_exact(width).map_err(|_| RawError::Limit("CRX QP row allocation"))?; + values.resize(width, 0); + Ok(values) + }; + let (mut previous, mut current) = (row()?, row()?); + let mut bits = Bits { src, at: 0 }; + let mut k = 0; + for y in 0..height { + let mut left = *previous.first().ok_or_else(|| corrupt("empty QP row"))?; + for x in 0..width { + let above = *previous.get(x).ok_or_else(|| corrupt("above QP outside row"))?; + let corner = if x == 0 { above } else { *previous.get(x - 1).ok_or_else(|| corrupt("corner QP outside row"))? }; + let right = previous.get(x + 1).copied().unwrap_or(above); + let symbol = bits.qp_rice(k)?; + let value = median(left, above, corner).checked_add(signed(symbol)).ok_or_else(|| corrupt("QP prediction overflow"))?; + if !(131..=167).contains(&value) { + return Err(RawError::Unsupported(format!("Canon CRX adaptive QP value {value}"))); + } + *current.get_mut(x).ok_or_else(|| corrupt("QP outside row"))? = value; + left = value; + let estimate = if y > 0 && x + 1 < width { + symbol.checked_add(2 * right.abs_diff(above)).ok_or_else(|| corrupt("QP Rice estimate overflow"))? / 2 + } else { + symbol + }; + k = update_rice(k, estimate); + } + data.extend_from_slice(¤t); + std::mem::swap(&mut current, &mut previous); + } + bits.finish()?; + Ok(QpMap { width, height, data }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn coding() -> Cr3Compression { + Cr3Compression { + version: 0x100, + width: 4, + height: 4, + tile_width: 4, + tile_height: 4, + bit_depth: 14, + planes: 4, + cfa_pattern: 0, + encoding: 0, + levels: 0, + tile_flags: 0, + header_size: 108, + median_bit_depth: None, + } + } + + fn marker(out: &mut Vec, code: u16, size: u32, flags: u32) { + out.extend(code.to_be_bytes()); + out.extend(8u16.to_be_bytes()); + out.extend(size.to_be_bytes()); + out.extend(flags.to_be_bytes()); + } + + fn flat_sample() -> Vec { + let mut out = Vec::new(); + marker(&mut out, 0xff01, 4, 0); + for p in 0..4 { + marker(&mut out, 0xff02, 1, (p << 28) | 0x0800_0000); + marker(&mut out, 0xff03, 1, 0x0020_0000); + } + // Four 2Γ—2 planes at coefficient 1. Row 0: zero-run flag 0, Rice(2,k0)=001, + // Rice(0,k0)=1. Row 1: nonzero-run flag 1, MELCODE(RLβˆ’1=1)=1. Zero alignment. + out.extend([0x1e; 4]); + out + } + + #[test] + fn sensor_mosaic_from_four_lossless_planes() { + let result = decode(&flat_sample(), &coding()).unwrap(); + assert_eq!(result, vec![8193; 16]); + } + + #[test] + fn med_signed_mapping_and_adaptation() { + assert_eq!([signed(0), signed(1), signed(2), signed(3)], [0, -1, 1, -2]); + assert_eq!(median(-7, -8, -9), -7); + assert_eq!(median(-7, -8, -6), -8); + assert_eq!(median(10, 20, 14), 16); + assert_eq!(update_rice(0, 12_289), 2); + assert_eq!(update_rice(2, 58), 4); + assert_eq!(update_rice(4, 7), 3); + assert_eq!(update_rice(4, 8), 4); + } + + #[test] + fn interrupted_run_predicts_from_above() { + // First row [5, 5, 8, 9]. The next row runs three 5s, so interruption at + // column 3 must predict the above sample 9, then add βˆ’2 to obtain 7. + assert_eq!(lossless_plane(&[0x00, 0x18, 0x2d, 0xdc], 4, 2).unwrap(), [5, 5, 8, 9, 5, 5, 5, 7]); + } + + #[test] + fn partial_final_run_segment_keeps_its_index() { + let mut bits = Bits { src: &[0xc0], at: 0 }; + let mut index = 11; + assert_eq!(run(&mut bits, &mut index, 6).unwrap(), 6); + assert_eq!(index, 12); + assert_eq!(bits.at, 2); + } + + #[test] + fn every_truncated_header_is_an_error() { + let sample = flat_sample(); + for n in 0..sample.len() { + assert!(decode(&sample[..n], &coding()).is_err(), "prefix {n}"); + } + } + + #[test] + fn inconsistent_parent_sizes_and_indices_are_errors() { + let original = flat_sample(); + for (at, mask) in [(7, 1), (9, 1), (19, 1), (20, 0x10), (31, 1), (32, 0x10)] { + let mut broken = original.clone(); + broken[at] ^= mask; + assert!(decode(&broken, &coding()).is_err(), "offset {at}"); + } + } + + #[test] + fn entropy_truncation_and_invalid_alignment_are_errors() { + assert!(lossless_plane(&[], 2, 2).is_err()); + assert!(lossless_plane(&[0x1f], 2, 2).is_err()); + assert!(lossless_plane(&[0x1e, 0], 2, 2).is_err()); + assert!(lossless_plane(&[0; 8], 2, 2).is_err()); + } + + #[test] + fn high_frequency_zero_runs_and_nonzero_mapping() { + assert_eq!(high_frequency_band(&[0xf0], 2, 2).unwrap(), [0, 0, 0, 0]); + assert_eq!(high_frequency_band(&[0x34, 0x80], 2, 2).unwrap(), [1, 0, -1, 1]); + assert!(high_frequency_band(&[0x34], 2, 2).is_err()); + assert!(high_frequency_band(&[0xf1], 2, 2).is_err()); + } + + #[test] + fn unsupported_wavelet_variant_is_explicit() { + let mut config = coding(); + config.levels = 2; + assert!(matches!(decode(&flat_sample(), &config), Err(RawError::Unsupported(_)))); + assert!(matches!(validate(&flat_sample(), &config), Err(RawError::Unsupported(_)))); + } + + #[test] + fn three_level_craw_reconstructs_sensor_samples() { + let mut config = coding(); + config.width = 16; + config.height = 16; + config.tile_width = 16; + config.tile_height = 16; + config.header_size = 540; + config.levels = 3; + let mut sample = Vec::new(); + marker(&mut sample, 0xff01, 52, 0); + for plane in 0..4 { + marker(&mut sample, 0xff02, 13, (plane << 28) | 0x0800_0000); + for band in 0..10 { + marker(&mut sample, 0xff03, if band >= 7 { 2 } else { 1 }, (band << 28) | 0x0020_0000); + } + } + for _ in 0..4 { + sample.extend([0x80; 4]); // LL3 and the three 1Γ—1 HF3 bands. + sample.extend([0xf0; 3]); // Three 2Γ—2 all-zero HF2 bands. + for _ in 0..3 { + sample.extend([0xff, 0xf8]); // Three 4Γ—4 all-zero HF1 bands. + } + } + validate(&sample, &config).unwrap(); + assert_eq!(decode(&sample, &config).unwrap(), vec![8192; 256]); + for end in 0..sample.len() { + assert!(decode(&sample[..end], &config).is_err()); + } + } + + #[test] + fn measured_quantization_and_allocation_limits() { + for (quant, expected) in [(4, 1), (9, 1), (10, 2), (16, 4), (22, 8), (26, 12), (32, 25), (36, 40), (41, 72), (42, 80), (43, 90)] { + assert_eq!(quant_step(quant).unwrap(), expected); + } + assert!(matches!(quant_step(3), Err(RawError::Unsupported(_)))); + assert!(matches!(quant_step(44), Err(RawError::Unsupported(_)))); + for (width, height) in [(20_000, 20_000), (65_538, 2), (2, 65_538)] { + let mut config = coding(); + config.width = width; + config.height = height; + assert!(matches!(decode(&[], &config), Err(RawError::Limit(_)))); + assert!(matches!(validate(&[], &config), Err(RawError::Limit(_)))); + } + } + + #[test] + fn qp_escape_and_average_precede_the_quantization_table() { + let mut bits = Bits { src: &[0, 0, 1, 0x2c], at: 0 }; + assert_eq!(bits.qp_rice(0).unwrap(), 300); + assert_eq!(bits.at, 32); + let map = qp_map(&[0, 0, 1, 0x2c, 0x94], 1, 4).unwrap(); + assert_eq!(map.data, [150; 4]); + assert!(qp_map(&[0, 0, 1, 0x2c, 0x95], 1, 4).is_err()); + let map = QpMap { width: 1, height: 2, data: vec![150, 153] }; + // floor((150+153)/2)=151; integer step 14, gain4/8 gives7. Averaging + // the nonlinear steps, or rounding QP upward, incorrectly gives8. + assert_eq!(adaptive_step(&map, 4, 0, 0, 0, 4).unwrap(), 7); + assert_eq!(adaptive_step(&map, 7, 0, 0, 1, 4).unwrap(), 7); + assert_eq!(adaptive_step(&map, 7, 0, 1, 0, 16).unwrap(), 36); + let low = QpMap { width: 1, height: 2, data: vec![131; 2] }; + assert_eq!(adaptive_step(&low, 4, 0, 0, 1, 1).unwrap(), 1); + assert_eq!(adaptive_step(&low, 4, 0, 0, 0, 1).unwrap(), 1); + assert_eq!(adaptive_step(&low, 0, 0, 0, 0, 0).unwrap(), 1); + assert!(matches!(qp_map(&[0, 0, 1, 4, 0x94], 1, 4), Err(RawError::Unsupported(_)))); + } + + #[test] + fn adaptive_v200_craw_reconstructs_sensor_samples() { + let mut config = coding(); + config.version = 0x200; + config.width = 16; + config.height = 16; + config.tile_width = 16; + config.tile_height = 16; + config.header_size = 872; + config.levels = 3; + let mut sample = Vec::new(); + sample.extend(0xff11u16.to_be_bytes()); + sample.extend(16u16.to_be_bytes()); + sample.extend(60u32.to_be_bytes()); + sample.extend(0x4000u32.to_be_bytes()); + sample.extend(5u32.to_be_bytes()); + sample.extend(3u16.to_be_bytes()); + sample.extend(0u16.to_be_bytes()); + let gains = [0, 0, 0, 0, 4, 4, 8, 8, 8, 16]; + for plane in 0..4 { + marker(&mut sample, 0xff12, 13, (plane << 28) | 0x0800_0000); + for (band, &gain) in gains.iter().enumerate() { + sample.extend(0xff13u16.to_be_bytes()); + sample.extend(16u16.to_be_bytes()); + sample.extend(if band >= 7 { 2u32 } else { 1 }.to_be_bytes()); + sample.extend(((band as u32) << 28 | gain).to_be_bytes()); + sample.extend(u32::from(band < 4).to_be_bytes()); + sample.extend([0u8; 4]); + } + } + sample.extend([0u8; 4]); + sample.extend([0, 0, 1, 0x2c, 0x94, 0, 0, 0]); + for _ in 0..4 { + sample.extend([0x80; 4]); + sample.extend([0xf0; 3]); + for _ in 0..3 { + sample.extend([0xff, 0xf8]); + } + } + validate(&sample, &config).unwrap(); + assert_eq!(decode(&sample, &config).unwrap(), vec![8192; 256]); + for end in 0..sample.len() { + assert!(decode(&sample[..end], &config).is_err()); + } + } + + #[test] + fn optional_cc0_samples_match_external_oracle() { + let Some(directory) = std::env::var_os("LIGHTCRAFT_CR3_CORPUS") else { return }; + for (name, expected) in [ + ("cr3-canon-r100-raw.cr3", 0xcbe5299ab9c52630u64), + ("cr3-canon-m50-raw.cr3", 0x62261f0ba81cfcd2u64), + ("cr3-canon-r100-craw.cr3", 0x341c706b37c38bbfu64), + ("cr3-canon-m50-craw.cr3", 0x9aacbfe66f505e1bu64), + ("cr3-canon-r8-raw.cr3", 0x63c1c6d8d1eb2312u64), + ("cr3-canon-r8-craw.cr3", 0xf836a663a795a375u64), + ] { + let path = std::path::Path::new(&directory).join(name); + let bytes = std::fs::read(path).unwrap(); + let container = lightcraft_meta::cr3::parse_cr3(&bytes).unwrap(); + let mut candidates = container + .tracks + .iter() + .filter_map(|track| { + let lightcraft_meta::cr3::Cr3TrackKind::Raw { cmp1: Some((at, length)), .. } = track.kind else { return None }; + let coding = Cr3Compression::parse(bytes.get(at..at + length)?)?; + Some((u64::from(coding.width) * u64::from(coding.height), coding, track.data?)) + }) + .collect::>(); + candidates.sort_by_key(|entry| entry.0); + let (_, coding, (at, length)) = candidates.last().unwrap(); + let sensor = decode(&bytes[*at..at + length], coding).unwrap(); + let hash = sensor + .iter() + .flat_map(|value| value.to_le_bytes()) + .fold(0xcbf29ce484222325u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)); + assert_eq!(hash, expected, "{name}"); + } + } +} diff --git a/crates/raw/src/vendor/crx_wavelet.rs b/crates/raw/src/vendor/crx_wavelet.rs new file mode 100644 index 00000000..ac5e51d1 --- /dev/null +++ b/crates/raw/src/vendor/crx_wavelet.rs @@ -0,0 +1,338 @@ +//! Reversible integer Le Gall 5/3 synthesis for Canon CRX subbands. +//! +//! Independent implementation of the lifting equations in ITU-T T.800, Annex F; +//! the equations are also printed in US20030031370A1, paragraph 0047. +//! Each tile level has even local origin. Internal boundaries retain the +//! neighbouring low/high coefficients; only outer image edges are symmetric. + +use crate::{RawError, Result}; + +#[derive(Clone, Debug)] +pub(crate) struct Band { + pub width: usize, + pub height: usize, + pub data: Vec, +} + +fn corrupt() -> RawError { + RawError::Corrupt("invalid CRX wavelet subband geometry".into()) +} + +fn checked_sample(v: i64) -> Result { + i32::try_from(v).map_err(|_| RawError::Corrupt("CRX wavelet coefficient overflow".into())) +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct TileEdges { + pub left: bool, + pub right: bool, + pub top: bool, + pub bottom: bool, +} + +fn sizes(length: usize, before: bool, after: bool) -> (usize, usize) { + (length.div_ceil(2) + usize::from(after && length.is_multiple_of(2)), length / 2 + usize::from(before) + usize::from(after)) +} + +fn inverse_line(low: &[i32], high: &[i32], out: &mut [i32], before: bool, after: bool) -> Result<()> { + let (low_count, high_count) = sizes(out.len(), before, after); + if out.is_empty() || low.len() != low_count || high.len() != high_count { + return Err(corrupt()); + } + if high.is_empty() { + let value = low.first().copied().ok_or_else(corrupt)?; + *out.first_mut().ok_or_else(corrupt)? = value; + return Ok(()); + } + // The high-pass storage starts at -1 when there is a preceding tile. + // Only absent external neighbours are reflected at the image boundary. + let high_at = |index: usize| -> Result { high.get(index.min(high.len().saturating_sub(1))).copied().ok_or_else(corrupt) }; + let even = |i: usize| -> Result { + let left = high_at((i + usize::from(before)).saturating_sub(1))?; + let right = high_at(i + usize::from(before))?; + let update = (i64::from(left) + i64::from(right) + 2).div_euclid(4); + checked_sample(i64::from(*low.get(i).ok_or_else(corrupt)?) - update) + }; + for i in 0..out.len().div_ceil(2) { + *out.get_mut(i * 2).ok_or_else(corrupt)? = even(i)?; + } + for i in 0..out.len() / 2 { + let left = out.get(i * 2).copied().ok_or_else(corrupt)?; + let right = match out.get(i * 2 + 2).copied() { + Some(value) => value, + None if after => even(i + 1)?, + None => left, + }; + let prediction = (i64::from(left) + i64::from(right)).div_euclid(2); + let value = high_at(i + usize::from(before))?; + *out.get_mut(i * 2 + 1).ok_or_else(corrupt)? = checked_sample(i64::from(value) + prediction)?; + } + Ok(()) +} + +fn valid_band(band: &Band, width: usize, height: usize) -> bool { + band.width == width && band.height == height && width.checked_mul(height) == Some(band.data.len()) +} + +/// A zeroed buffer, or a limit error instead of an abort when the allocation fails. +fn zeroed(count: usize) -> Result> { + let mut v = Vec::new(); + v.try_reserve_exact(count).map_err(|_| RawError::Limit("CRX wavelet allocation"))?; + v.resize(count, 0); + Ok(v) +} + +/// One inverse level. `HL` is horizontally high-pass, `LH` vertically high-pass. +pub(crate) fn inverse_level(ll: &Band, hl: &Band, lh: &Band, hh: &Band, width: usize, height: usize, edges: TileEdges) -> Result { + let count = width.checked_mul(height).filter(|n| *n > 0).ok_or_else(corrupt)?; + if count > crate::MAX_SAMPLES { + return Err(RawError::Limit("CRX wavelet image size")); + } + let (lw, hw) = sizes(width, edges.left, edges.right); + let (lhgt, hhgt) = sizes(height, edges.top, edges.bottom); + if !valid_band(ll, lw, lhgt) || !valid_band(hl, hw, lhgt) || !valid_band(lh, lw, hhgt) || !valid_band(hh, hw, hhgt) { + return Err(corrupt()); + } + let intermediate_count = width.checked_mul(lhgt + hhgt).filter(|n| *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("CRX wavelet image size"))?; + let mut temporary = zeroed(intermediate_count)?; + // CRX analysis is vertical then horizontal. Integer rounding means that + // synthesis must reverse that order, including for quantized subbands. + for y in 0..lhgt + hhgt { + let (lo, hi, by) = if y < lhgt { (ll, hl, y) } else { (lh, hh, y - lhgt) }; + inverse_line( + lo.data.get(by * lw..(by + 1) * lw).ok_or_else(corrupt)?, + hi.data.get(by * hw..(by + 1) * hw).ok_or_else(corrupt)?, + temporary.get_mut(y * width..(y + 1) * width).ok_or_else(corrupt)?, + edges.left, + edges.right, + )?; + } + let mut data = zeroed(count)?; + let mut low = zeroed(lhgt)?; + let mut high = zeroed(hhgt)?; + let mut column = zeroed(height)?; + for x in 0..width { + for (y, value) in low.iter_mut().enumerate() { + *value = temporary.get(y * width + x).copied().ok_or_else(corrupt)?; + } + for (y, value) in high.iter_mut().enumerate() { + *value = temporary.get((y + lhgt) * width + x).copied().ok_or_else(corrupt)?; + } + inverse_line(&low, &high, &mut column, edges.top, edges.bottom)?; + for (y, &value) in column.iter().enumerate() { + *data.get_mut(y * width + x).ok_or_else(corrupt)? = value; + } + } + Ok(Band { width, height, data }) +} + +/// Subbands in CRX order: LLn, HLn, LHn, HHn, ... HL1, LH1, HH1. +pub(crate) fn reconstruct(bands: &[Band], width: usize, height: usize, levels: u8, edges: TileEdges) -> Result> { + if levels > 3 || bands.len() != 1 + usize::from(levels) * 3 || width == 0 || height == 0 { + return Err(corrupt()); + } + let count = width.checked_mul(height).ok_or_else(corrupt)?; + if count > crate::MAX_SAMPLES { + return Err(RawError::Limit("CRX wavelet image size")); + } + let mut dimensions = vec![(width, height)]; + for _ in 0..levels { + let &(w, h) = dimensions.last().ok_or_else(corrupt)?; + dimensions.push((sizes(w, edges.left, edges.right).0, sizes(h, edges.top, edges.bottom).0)); + } + let &(low_width, low_height) = dimensions.last().ok_or_else(corrupt)?; + if !bands.first().is_some_and(|b| valid_band(b, low_width, low_height)) { + return Err(corrupt()); + } + let mut ll = bands.first().cloned().ok_or_else(corrupt)?; + for step in 0..usize::from(levels) { + let &(w, h) = dimensions.get(usize::from(levels) - step - 1).ok_or_else(corrupt)?; + let index = 1 + step * 3; + ll = inverse_level( + &ll, + bands.get(index).ok_or_else(corrupt)?, + bands.get(index + 1).ok_or_else(corrupt)?, + bands.get(index + 2).ok_or_else(corrupt)?, + w, + h, + edges, + )?; + } + if !valid_band(&ll, width, height) { + return Err(corrupt()); + } + Ok(ll.data) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn integer_lifting_preserves_signed_rounding_and_symmetric_ends() { + let mut out = [0; 9]; + inverse_line(&[6, 11, 8, 6, 3], &[2, 2, -1, -2], &mut out, false, false).unwrap(); + assert_eq!(out, [5, 9, 10, 11, 8, 6, 7, 3, 4]); + let mut out = [0; 2]; + inverse_line(&[25], &[20], &mut out, false, false).unwrap(); + assert_eq!(out, [15, 35]); + let mut out = [0]; + inverse_line(&[-27], &[], &mut out, false, false).unwrap(); + assert_eq!(out, [-27]); + } + + #[test] + fn two_dimensional_subband_order() { + let band = |value| Band { width: 1, height: 1, data: vec![value] }; + let out = reconstruct(&[band(25), band(10), band(20), band(0)], 2, 2, 1, TileEdges::default()).unwrap(); + assert_eq!(out, [10, 20, 30, 40]); + } + + #[test] + fn invalid_geometry_and_overflow_are_errors() { + let mut out = [0; 2]; + assert!(inverse_line(&[], &[0], &mut out, false, false).is_err()); + assert!(inverse_line(&[i32::MAX], &[i32::MIN], &mut out, false, false).is_err()); + let b = Band { width: 1, height: 1, data: vec![0] }; + assert!(inverse_level(&b, &b, &b, &b, usize::MAX, 2, TileEdges::default()).is_err()); + assert!(reconstruct(&[b], 2, 2, 0, TileEdges::default()).is_err()); + } + + // Independent forward lifting in tests checks all odd/even boundary + // combinations; hand-computed vectors above also establish sign/order. + fn forward_line(input: &[i32]) -> (Vec, Vec) { + let high: Vec<_> = (0..input.len() / 2) + .map(|i| input[i * 2 + 1] - (input[i * 2] + input.get(i * 2 + 2).copied().unwrap_or(input[i * 2])).div_euclid(2)) + .collect(); + let low = input + .iter() + .step_by(2) + .enumerate() + .map(|(i, &v)| { + if high.is_empty() { + v + } else { + v + (high[i.saturating_sub(1)] + high.get(i).copied().unwrap_or(high[high.len() - 1]) + 2).div_euclid(4) + } + }) + .collect(); + (low, high) + } + + #[test] + fn every_boundary_parity_round_trips_signed_coefficients() { + for width in 1..=17usize { + for height in 1..=17usize { + let input: Vec = (0..width * height).map(|i| ((i * 193 + i * i * 17) % 1001) as i32 - 500).collect(); + let (lw, hw, lhgt, hhgt) = (width.div_ceil(2), width / 2, height.div_ceil(2), height / 2); + let mut vertical = vec![0; width * height]; + for x in 0..width { + let column: Vec<_> = (0..height).map(|y| input[y * width + x]).collect(); + let (lo, hi) = forward_line(&column); + for (y, value) in lo.into_iter().chain(hi).enumerate() { + vertical[y * width + x] = value; + } + } + let mut bands = [ + Band { width: lw, height: lhgt, data: vec![0; lw * lhgt] }, + Band { width: hw, height: lhgt, data: vec![0; hw * lhgt] }, + Band { width: lw, height: hhgt, data: vec![0; lw * hhgt] }, + Band { width: hw, height: hhgt, data: vec![0; hw * hhgt] }, + ]; + for y in 0..height { + let (lo, hi) = forward_line(&vertical[y * width..(y + 1) * width]); + let (b, by) = if y < lhgt { (0, y) } else { (2, y - lhgt) }; + bands[b].data[by * lw..(by + 1) * lw].copy_from_slice(&lo); + bands[b + 1].data[by * hw..(by + 1) * hw].copy_from_slice(&hi); + } + assert_eq!(reconstruct(&bands, width, height, 1, TileEdges::default()).unwrap(), input, "{width}x{height}"); + } + } + } + // Independently generated from ITU-T T.800 integer 5/3 forward equations. + // Original40x9 samples: ((x*23+y*37+x*y*7)%101)-50. + // Tile width20 creates the same level3phase4 mismatch as R1001572. + fn left_halo_fixture() -> Vec { + vec![ + Band { width: 4, height: 2, data: vec![1, 1, -4, 1, 20, -2, -1, 2] }, + Band { width: 4, height: 2, data: vec![35, 21, -2, 19, -24, -10, -7, -2] }, + Band { width: 4, height: 1, data: vec![31, -16, -19, -7] }, + Band { width: 4, height: 1, data: vec![22, 9, 20, 18] }, + Band { width: 6, height: 3, data: vec![24, -34, -55, -21, 2, 7, 25, 5, -3, 14, 31, 14, 24, 37, 55, 19, -42, -41] }, + Band { width: 6, height: 2, data: vec![9, -21, 20, -4, 31, -6, -32, -3, 7, 31, 14, -11] }, + Band { width: 6, height: 2, data: vec![3, 62, 40, 50, 50, 51, -3, -62, -38, -45, 50, 35] }, + Band { + width: 11, + height: 5, + data: vec![ + 13, -38, -12, 13, -50, -13, 13, 39, 25, -38, 38, -44, 6, 0, -44, -50, 57, 0, -6, 38, 19, -13, 26, 26, 39, -37, -62, -37, -37, + -25, -25, -44, -44, 6, -44, -12, 57, 39, -44, 13, 32, -18, 0, 57, -38, 13, 13, -13, 51, 13, -12, -38, 13, 13, -13, + ], + }, + Band { + width: 11, + height: 4, + data: vec![ + 13, 39, 0, -25, 6, -6, 26, 0, -43, 20, -25, -50, 26, 7, -19, 0, 13, 32, -6, -25, 38, 32, 1, 26, -44, 19, 0, -12, -6, 45, -25, -6, + 32, -37, 39, -12, 26, -6, 6, -25, 0, 39, -12, 26, + ], + }, + Band { + width: 11, + height: 4, + data: vec![ + 26, -75, 76, 25, 0, -25, -75, 76, -50, -75, -25, 0, -101, -75, 0, 0, 51, 76, 101, 0, -51, -25, -101, 0, 25, 51, -50, 0, -25, 0, + 101, 76, 51, -75, 26, -75, -25, 0, 25, 76, -75, 26, -75, -25, + ], + }, + ] + } + fn right_halo_fixture() -> Vec { + vec![ + Band { width: 3, height: 2, data: vec![-12, 8, -5, 0, 4, -2] }, + Band { width: 3, height: 2, data: vec![-10, -5, -15, 8, 6, 5] }, + Band { width: 3, height: 1, data: vec![-10, -14, -10] }, + Band { width: 3, height: 1, data: vec![-17, -14, -25] }, + Band { width: 6, height: 3, data: vec![2, 7, -4, 7, 26, 38, 31, 14, 6, -12, -2, -22, -42, -41, -47, -7, 10, 44] }, + Band { width: 5, height: 2, data: vec![-6, 15, 31, -7, 12, -11, 6, 9, 5, -5] }, + Band { width: 6, height: 2, data: vec![50, 51, -46, -36, -58, -61, 50, 35, 69, 18, -36, -21] }, + Band { + width: 11, + height: 5, + data: vec![ + -38, 38, -13, 13, -38, 39, -25, -38, 63, 13, 49, 19, -13, -57, 70, 25, -6, -12, 32, 0, -44, -51, -44, -44, -50, -37, -37, -25, 1, + -31, -44, -50, -50, 0, 57, -37, -44, 13, 32, -12, 6, 57, -37, -36, 13, -13, 77, 13, -12, -38, -25, -25, -13, 77, -22, + ], + }, + Band { + width: 10, + height: 4, + data: vec![ + -25, 39, 0, -37, 25, -18, 45, -12, -37, 19, 32, -6, 13, 32, -6, 1, -25, 45, -6, 0, 32, 13, 0, -6, 45, -25, -6, 32, 13, 0, 26, 7, + 19, -25, 0, 45, -50, 32, 7, 13, + ], + }, + Band { + width: 11, + height: 4, + data: vec![ + -75, -25, -25, 25, 25, 76, 51, -75, 25, 25, 51, -51, -25, 0, 51, 76, 101, 101, 0, -25, 0, 0, 76, 51, 0, 0, -25, 0, 101, 76, 51, + 0, 0, -75, -25, 51, 25, 76, -75, 51, -50, -25, 51, 0, + ], + }, + ] + } + + #[test] + fn three_levels_keep_internal_tile_halos_and_local_phase() { + for (tile, bands, edges) in [ + (0, left_halo_fixture(), TileEdges { right: true, ..Default::default() }), + (1, right_halo_fixture(), TileEdges { left: true, ..Default::default() }), + ] { + let expected: Vec = + (0..9).flat_map(|y| (tile * 20..tile * 20 + 20).map(move |x| ((x * 23 + y * 37 + x * y * 7) % 101) - 50)).collect(); + assert_eq!(reconstruct(&bands, 20, 9, 3, edges).unwrap(), expected); + assert!(reconstruct(&bands, 20, 9, 3, TileEdges::default()).is_err()); + } + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 0ef04778..fbbc18eb 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -2,6 +2,9 @@ pub mod arw; pub mod cr2; +pub mod cr3; +pub mod crx; +pub(crate) mod crx_wavelet; pub mod nef; pub mod nefc; pub mod orf; diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index e457c3da..f4b01deb 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1,7 +1,7 @@ //! Corpus test over `corpus/raw/**` (git-ignored CC0 samples from raw.pixls.us, fetched with //! `cargo xtask corpus --download`; `LIGHTCRAFT_CORPUS` overrides the corpus root). Skips cleanly when absent. //! -//! Every file must be recognised, carry an embedded JPEG preview (DNG: optional), and either decode to a valid image or report +//! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report //! `Unsupported` for one of the variants we know we don't decode yet. Prints decode times //! (`cargo test -p lightcraft-raw --release --test corpus -- --nocapture`). @@ -15,9 +15,9 @@ fn corpus_root() -> PathBuf { /// Variants known not to decode yet (see the crate docs): matched against the lower-case file name. const KNOWN_UNSUPPORTED: &[&str] = &[ - "cr3-", // CR3 / CRX (M11.1) - "orf-olympus-em", // Olympus compressed ORF - "sraw", // Canon sRAW / mRAW + "cr3-", // Unverified CRX coding variants; exact supported cases live in cr3_corpus.rs. + "orf-olympus-em", // Olympus compressed ORF + "sraw", // Canon sRAW / mRAW ]; #[test] @@ -41,11 +41,14 @@ fn corpus_raw_decodes() { let preview = embedded_preview(&bytes); let tp = t0.elapsed().as_secs_f64() * 1e3; // 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 + // 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"); } else { - assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-"), "{name}: no embedded preview"); + let hevc_preview = fmt == RawFormat::Cr3 + && lightcraft_meta::cr3::parse_cr3(&bytes) + .is_some_and(|c| c.tracks.iter().any(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Other(*b"HEVC") && t.data.is_some())); + assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-") || hevc_preview, "{name}: no embedded preview"); } let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024); let t1 = Instant::now(); @@ -85,7 +88,7 @@ fn corpus_raw_decodes() { Err(e) => panic!("{name}: {e}"), } } - eprintln!("corpus/raw: {ok} decoded, {unsupported} known-unsupported (preview only)"); + eprintln!("corpus/raw: {ok} decoded, {unsupported} known-unsupported (embedded JPEG when available)"); } /// Green-channel means of 32Γ—32 blocks. diff --git a/crates/raw/tests/cr3_corpus.rs b/crates/raw/tests/cr3_corpus.rs new file mode 100644 index 00000000..6e270576 --- /dev/null +++ b/crates/raw/tests/cr3_corpus.rs @@ -0,0 +1,78 @@ +//! Exact sensor-pixel regressions, not embedded-JPEG comparisons. +//! +//! Public-domain inputs from raw.pixls.us, kept in the gitignored corpus/raw. +//! Reference pixels were independently obtained by running the DNGLab v0.8.0 +//! release executable as a black-box oracle (analyze --raw-pixel). Its source +//! and camera matrices were not used. Canonical FNV-1a hashes below are over +//! decoded u16 samples in row-major, little-endian order, including masked borders. +//! macOS oracle archive SHA256: +//! ee70805cb60f18d5ed62548c4e595f6cee0a33407e15358eb1766a46afc1c16e. + +use lightcraft_raw::{RawData, RawFormat, decode, probe_info}; +use std::path::{Path, PathBuf}; + +fn corpus() -> PathBuf { + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) +} + +fn check(name: &str, hash: u64, sensor: (usize, usize)) { + let path = corpus().join("raw").join(name); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + return; + }; + let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(raw.format, RawFormat::Cr3); + raw.validate().unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1)); + assert_eq!(raw.info().developed_size(), (6000, 4000)); + assert_eq!(raw.info(), probe_info(&bytes).unwrap()); + let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") }; + let actual = + samples.iter().flat_map(|v| v.to_le_bytes()).fold(0xcbf2_9ce4_8422_2325u64, |h, byte| (h ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3)); + assert_eq!(actual, hash, "{name}: sensor pixels differ from the independent reference"); + assert!(raw.wb_multipliers.is_some(), "{name}: as-shot white balance missing"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); +} + +#[test] +fn canon_m50_lossless_sensor_is_exact() { + // raw.pixls.us/getfile.php/4657; SHA256 + // 1ba9ad6b51b315b88820eb0fe5fd7f52bc136ba37b1a5ca76825ebfe25b18058 + check("cr3-canon-m50-raw.cr3", 0x6226_1f0b_a81c_fcd2, (6288, 4056)); +} + +#[test] +fn canon_r100_lossless_sensor_is_exact() { + // raw.pixls.us/getfile.php/7896; SHA256 + // 6d83217d58a5e6d2dabcf470430e91a16676b60531c025b81fa7453fc74b0521 + check("cr3-canon-r100-raw.cr3", 0xcbe5_299a_b9c5_2630, (6288, 4056)); +} + +#[test] +fn canon_m50_craw_sensor_is_exact() { + // raw.pixls.us/getfile.php/2663; SHA256 + // 15384b775867ec4c42b11882837f1e368cedc0561832ffab271221e6bb80be4c + check("cr3-canon-m50-craw.cr3", 0x9aac_bfe6_6f50_5e1b, (6288, 4056)); +} + +#[test] +fn canon_r100_craw_sensor_is_exact() { + // raw.pixls.us/getfile.php/7897; SHA256 + // 0b66842b2fe00329ebc05fe8ab9357ddbbe1a0a2d2e69a2893102de7fdf5c942 + check("cr3-canon-r100-craw.cr3", 0x341c_706b_37c3_8bbf, (6288, 4056)); +} + +#[test] +fn canon_r8_lossless_sensor_is_exact() { + // raw.pixls.us/getfile.php/6585; SHA256 + // 7d5c6dbb11ff7e6ee58715d90a20f5d801e8b103f4711ff6ea329ceafcef254c + check("cr3-canon-r8-raw.cr3", 0x63c1_c6d8_d1eb_2312, (6188, 4120)); +} + +#[test] +fn canon_r8_craw_sensor_is_exact() { + // raw.pixls.us/getfile.php/6587; SHA256 + // df33cf394573645ce03dea1b2e9f0b5cc2b7734e9e3391ee7114151414cf2812 + check("cr3-canon-r8-craw.cr3", 0xf836_a663_a795_a375, (6188, 4120)); +} diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 3d31f69e..876091e8 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -1,6 +1,6 @@ -# Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF starting look +# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF and Canon CR3 starting look -ARW, NEF, RW2 and RAF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost. +ARW, NEF, RW2, RAF and CR3 decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost. ## Colour and tone are separate @@ -62,6 +62,14 @@ RAF inputs also work with `lightcraft-cli calibrate`. Tests cover real X-Trans/1 These are camera-JPEG look estimates, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony, Nikon, Panasonic and Fujifilm model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour. +### Canon CR3 + +CR3 uses the same colour gates and relative WB. Its sensor and JPEG proxies are first oriented identically. Canon can crop its JPEG differently from the sensor; no lens distortion correction is applied to the sensor proxy, so a strongly distortion-corrected JPEG can fail the gates. A fixed 96-pixel proxy measures this remaining framing from luminance-gradient directions: scale is limited to 0.9–1.1 and translation to two proxy pixels. At least 192 edges are required; two thirds train the framing and the remaining third must independently reach direction agreement β‰₯0.90 with an improvement β‰₯0.04. At least 80% of each edge set must remain usable. Weak, flat, unrelated or already aligned references keep the sensor framing. This alignment affects training correspondences only; the RAW render still uses the actual sensor pixels. + +On the public CC0 EOS R100 / RF-S 18–45 mm sample, the measured framing scale was 0.968. Held-out edge agreement rose from 0.7291 to 0.9194, and the unchanged colour gates accepted the fit (held-out RMS 0.10625 β†’ 0.07704). The corpus test asserts acceptance after decoding the sensor RAW, and synthetic tests cover nonlinear camera tone, known crop/translation and unrelated references. The matrix, tone and hue/saturation table are estimated from the file; no third-party camera matrix or decoder calibration source is copied. + +This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony, Nikon and Canon model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour. + ## Camera profiles (pooled per model) One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68Β° against the JPEG's 82Β°; a 192-pixel proxy only reached 74Β°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model β€” each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders β€” and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos. diff --git a/docs/cr3.md b/docs/cr3.md new file mode 100644 index 00000000..a2e248c5 --- /dev/null +++ b/docs/cr3.md @@ -0,0 +1,74 @@ +# Canon CR3 decoding + +The CR3 adapter reads the CRX Bayer track, `CMP1` compression descriptor, +`CDI1`/`IAD1` sensor geometry and timed `CTMD` maker notes. White balance, +black/white levels and the default crop come from the file. A Dual Pixel delta +track is never substituted for the primary Bayer track. + +The decoder is independent Rust code shared by desktop, CLI and WebAssembly; +it has no operating-system imaging dependency or external decoder. Its sources +are the public prose descriptions and patents cited in +`crates/raw/src/vendor/crx.rs`. Unsupported coding variants retain the existing +embedded-JPEG fallback and its visible warning when a JPEG is present. So does +any other CR3 decode error (a corrupt file, or a body whose CRX stream the +decoder misreads): every CR3 opened from its embedded JPEG before this decoder, +and only three bodies are verified, so a CR3 never fails to import or load +where a preview exists. HEVC-only preview tracks are recognized but are not +decoded. + +## Verified sensor decoding + +CRX version `0x100`, four Bayer planes and `ff01`/`ff02`/`ff03` marker headers +support lossless RAW and three-level C-RAW with horizontal tiles. Uniform +quantization values 4–43 are independently verified. Version `0x200` C-RAW +supports the `ff11`/`ff12`/`ff13` marker family, a single 14-bit Bayer tile and +its adaptive quantization map. Other unverified coding variants return explicit +unsupported errors. Header probing validates the same coding variants and +tile/plane layout as full decoding. + +`crates/raw/tests/cr3_corpus.rs` checks every full-sensor sample against a +black-box reference, before cropping or colour processing. Public CC0 files +are downloaded by `cargo xtask corpus --download` and kept outside version +control. The exact inputs and reference hashes are: + +| File | Input SHA-256 | Sensor FNV-1a (u16 little-endian) | +|---|---|---| +| Canon M50 RAW, raw.pixls.us 4657 | `1ba9ad6b51b315b88820eb0fe5fd7f52bc136ba37b1a5ca76825ebfe25b18058` | `62261f0ba81cfcd2` | +| Canon M50 C-RAW, raw.pixls.us 2663 | `15384b775867ec4c42b11882837f1e368cedc0561832ffab271221e6bb80be4c` | `9aacbfe66f505e1b` | +| Canon R100 RAW, raw.pixls.us 7896 | `6d83217d58a5e6d2dabcf470430e91a16676b60531c025b81fa7453fc74b0521` | `cbe5299ab9c52630` | +| Canon R100 C-RAW, raw.pixls.us 7897 | `0b66842b2fe00329ebc05fe8ab9357ddbbe1a0a2d2e69a2893102de7fdf5c942` | `341c706b37c38bbf` | +| Canon R8 RAW, raw.pixls.us 6585 | `7d5c6dbb11ff7e6ee58715d90a20f5d801e8b103f4711ff6ea329ceafcef254c` | `63c1c6d8d1eb2312` | +| Canon R8 C-RAW, raw.pixls.us 6587 | `df33cf394573645ce03dea1b2e9f0b5cc2b7734e9e3391ee7114151414cf2812` | `f836a663a795a375` | + +Reference pixels were observed from the DNGLab 0.8.0 macOS arm64 release +binary; no decoder source was read or copied, and it is not shipped or linked. +The release ZIP SHA-256 was +`ee70805cb60f18d5ed62548c4e595f6cee0a33407e15358eb1766a46afc1c16e`. +All six sensor comparisons pass. R8 HEVC preview tracks are distinguished from +the primary Bayer RAW; the HEVC preview itself is not decoded. Missing corpus +files are reported as skipped by the tests. + +The version `0x200` QP predictor and entropy framing were checked independently. +Controlled input mutations establish floor averaging of paired QP rows before +the nonlinear integer table, then multiplication, division by eight, addition +of the subband base and a minimum step of one. QP values 131–167 are verified; +unmeasured values and an odd QP-map height are rejected. Partial-subband and +rounded-plane coding, vertical C-RAW tiling and adaptive low-frequency gains +remain unsupported. The decoder bounds image dimensions, sample count, +tile count, marker headers and temporary allocations. The full sensor buffer is +allocated only after the first plane decodes, and plane buffers grow row by row, +so a small crafted file fails before reserving memory for the image it claims. + +## Colour limits + +CR3 carries as-shot white balance but no measured camera matrix is imported +here. The existing guarded embedded-JPEG fitting path now includes CR3, with +both proxies oriented identically before gathering training pairs. A bounded +crop/translation registration of the JPEG is confirmed on held-out edge +directions before fitting colour; the existing held-out colour acceptance gates +are unchanged. No lens distortion correction is applied (LightCraft has no lens +profiles of its own yet), so lenses whose camera JPEG is strongly +distortion-corrected may fail the gates and keep the neutral matrix. Render +cache version 13 invalidates earlier thumbnails. All developed pixels still come +from the RAW sensor; a rejected colour fit retains the documented neutral +matrix fallback. See `camera-preview-colour.md` for the limits of this estimate. diff --git a/docs/parity.md b/docs/parity.md index ea3f2ec7..72401646 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -66,6 +66,8 @@ Take the first one nobody is working on. 2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, Nikon 1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW, NEF and RAF; ARW: 7 of 10 public samples accepted, mean Ξ”E vs the camera JPEG 17–26 β†’ 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, Ξ”E 13–46 β†’ 3–8, one mixed-light scene 13 β†’ 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median Ξ”E 5.0; RAF: 23 of 24 references accepted with bundled X-H2S/X-T4 profiles; 150 withheld-file tests improve mean Ξ”E slightly over per-file fitting); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. 2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed ORF, Nikon +1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF and Panasonic RW2 now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW and NEF; ARW: 7 of 10 public samples accepted, mean Ξ”E vs the camera JPEG 17–26 β†’ 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, Ξ”E 13–46 β†’ 3–8, one mixed-light scene 13 β†’ 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median Ξ”E 5.0); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. +2. **LR-IMP-FORMATS** (P0): CR3 lossless Bayer and version 0x100/0x200 C-RAW now decodes (M50/R100/R8 full sensor exact). Remaining unverified CRX variants, compressed RAF / ORF, Nikon "lossy after split" NEF, Canon sRAW; HEIC/AVIF decode. Clean-room, from prose descriptions only (see `crates/raw/src/vendor/nefc.rs` for how compressed NEF was done). Until decoded, such photos are `preview_only`. 3. **LR-IMP-CAMERA-COVERAGE** (P0): per-model verification; grow the CC0 corpus and fix per-model bugs (like the CR2 @@ -136,8 +138,8 @@ 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; 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 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), 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), HEIC/AVIF decode, 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 ~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-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), HEIC/AVIF decode, 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 | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | βœ… | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files β–Έ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | @@ -288,7 +290,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 and Fujifilm RAF 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/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 and Fujifilm RAF and Canon CR3 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 9569853a..d02e7f22 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -15,6 +15,8 @@ Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the 12–32 mm kit zoom was off by about 5 % of the image width at the corners before. Files shot with the correction off are unchanged; photos imported before this change get it when imported again. +- Canon CR3 raws now develop from their sensor data: lossless RAW and C-RAW, checked sample for sample on the EOS + M50, R100 and R8. CR3 files the decoder can't read yet still open from their embedded JPEG, as before. - Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB, preserving RAW editing & full-resolution export instead of using embedded JPEG previews. - Sony A7R II (and other) raws whose camera JPEG is lens-corrected no longer open grey and too dark (issue #232): the diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 21422d2f..5c6f5d1e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -324,6 +324,11 @@ const RAW_SAMPLES: &[(&str, &str)] = &[ ("cr2-canon-5d3.cr2", "https://raw.pixls.us/getfile.php/771/nice/Canon%20-%20EOS%205D%20Mark%20III.CR2"), ("cr2-canon-80d.cr2", "https://raw.pixls.us/getfile.php/1294/nice/Canon%20-%20EOS%2080D%20-%20RAW%20%283:2%29.CR2"), ("cr3-canon-m50-craw.cr3", "https://raw.pixls.us/getfile.php/2663/nice/Canon%20-%20EOS%20M50%20-%20CRAW%20%283:2%29.CR3"), + ("cr3-canon-m50-raw.cr3", "https://raw.pixls.us/getfile.php/4657/nice/Canon%20-%20EOS%20M50%20-%203:2.CR3"), + ("cr3-canon-r100-raw.cr3", "https://raw.pixls.us/getfile.php/7896/nice/Canon%20-%20EOS%20R100%20-%20RAW%20%283:2%29.CR3"), + ("cr3-canon-r100-craw.cr3", "https://raw.pixls.us/getfile.php/7897/nice/Canon%20-%20EOS%20R100%20-%20CRAW%20%283:2%29.CR3"), + ("cr3-canon-r8-raw.cr3", "https://raw.pixls.us/getfile.php/6585/nice/Canon%20-%20EOS%20R8%20-%203:2.CR3"), + ("cr3-canon-r8-craw.cr3", "https://raw.pixls.us/getfile.php/6587/nice/Canon%20-%20EOS%20R8%20-%203:2.CR3"), ( "dng-adobe-canon-5d3-linear-lj92.dng", "https://raw.pixls.us/getfile.php/1032/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossless%20JPEG%20compression%2C%20rgb%20%283:2%29.DNG",