Merge PR #279
# Conflicts: # README.md # ROADMAP.md # crates/catalog/src/model.rs # crates/engine/src/camera_preview.rs # crates/raw/src/lib.rs # crates/raw/tests/corpus.rs # docs/camera-preview-colour.md # docs/parity.md # docs/whats-new.md
This commit is contained in:
commit
3f63db0a0b
|
|
@ -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 ⬜ |
|
||||
|
|
|
|||
21
ROADMAP.md
21
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<CameraLook> {
|
||||
|
|
@ -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<Cr3Framing> {
|
||||
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<f64> {
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -99,9 +99,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
|||
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<ProbeInfo, String> {
|
|||
..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<ProbeInfo, String> {
|
|||
})
|
||||
}
|
||||
|
||||
/// 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<String, String> {
|
||||
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<u8>, 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<u8> {
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
}
|
||||
|
||||
impl Cr3Compression {
|
||||
/// Read the public `CMP1` field layout. Truncated or impossible descriptors return `None`.
|
||||
pub fn parse(payload: &[u8]) -> Option<Self> {
|
||||
// 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<Self> {
|
||||
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<Cr3Compression> {
|
||||
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<Cr3ImageArea> {
|
||||
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<Cr3Track>,
|
||||
}
|
||||
|
||||
/// 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<Cr3TimedExif<'a>> {
|
||||
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<Cr3Track> {
|
|||
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<u16> {
|
||||
|
|
@ -212,6 +418,16 @@ fn be64(b: &[u8], at: usize) -> Option<u64> {
|
|||
Some(u64::from_be_bytes(a))
|
||||
}
|
||||
|
||||
fn le16(b: &[u8], at: usize) -> Option<u16> {
|
||||
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<u32> {
|
||||
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<Vec<u8>> {
|
||||
|
|
@ -308,12 +524,50 @@ mod tests {
|
|||
assert_eq!(c.xmp, Some(b"<x:xmpmeta/>".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<u8> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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::<u8>(), 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::<u8>(), 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);
|
||||
|
|
|
|||
|
|
@ -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<RawImage> {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
//! <https://github.com/lclevy/canon_cr3/blob/master/readme.md>, and ExifTool's published Canon
|
||||
//! tag-name tables (`SensorInfo`, `ColorData9`…`ColorData12`, `LensModel`),
|
||||
//! <https://exiftool.org/TagNames/Canon.html>. 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<Cfa> {
|
||||
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<Rect> {
|
||||
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<Rect> {
|
||||
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<Rect> {
|
||||
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<f32>,
|
||||
}
|
||||
|
||||
/// 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<RawImage> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8>, 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<u8> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<i32>,
|
||||
}
|
||||
|
||||
fn corrupt() -> RawError {
|
||||
RawError::Corrupt("invalid CRX wavelet subband geometry".into())
|
||||
}
|
||||
|
||||
fn checked_sample(v: i64) -> Result<i32> {
|
||||
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<i32> { high.get(index.min(high.len().saturating_sub(1))).copied().ok_or_else(corrupt) };
|
||||
let even = |i: usize| -> Result<i32> {
|
||||
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<Vec<i32>> {
|
||||
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<Band> {
|
||||
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<Vec<i32>> {
|
||||
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<i32>, Vec<i32>) {
|
||||
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<i32> = (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<Band> {
|
||||
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<Band> {
|
||||
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<i32> =
|
||||
(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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue