mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
M11.1: add CR3 decoding
Decode lossless CRX and version 0x100/0x200 C-RAW in portable Rust, including sensor geometry, timed white balance and black/white levels. Verify all sensor samples against six independent M50/R100/R8 references. Any CR3 decode error falls back to the embedded JPEG, as CR3 files did before this decoder. The full sensor buffer is allocated only after the first plane decodes, and plane buffers grow row by row. Tile planes decode in parallel with a windowed bit reader (24 MP: 424 -> 89 ms lossless, 519 -> 138 ms C-RAW on an M4). Add guarded CR3 camera-JPEG colour fitting with independent framing validation, relative white balance and cache invalidation. Validation: cargo xtask ci.
This commit is contained in:
1 parent
629e39380e
commit
f96f732baa
20 files changed
+2733
-63
No files matched your search
@@ -257,7 +257,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,7 +288,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
older-Canon raws on one machine.
|
||||
- **The biggest gaps:**
|
||||
- **camera colour calibration:** raws other than DNG develop with a neutral colour matrix today, so colour is muted;
|
||||
- **CR3 and compressed Fujifilm / Olympus raws:** these open as embedded previews only;
|
||||
- **compressed Fujifilm / Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present;
|
||||
- **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).
|
||||
@@ -301,9 +301,9 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
|
||||
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
|
||||
| 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 | ✅ · our own calibration for other raws ⬜ (top priority; ARW, NEF and RW2 start from a look fitted to their own JPEG, other raws from a neutral fallback) |
|
||||
| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; ARW, NEF, RW2 and CR3 can start from a guarded look fitted to their own JPEG, other raws from a neutral fallback) |
|
||||
| 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, 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 RAF/ORF decode ⬜ |
|
||||
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded JPEG previews across formats incl. CR3 (HEVC previews remain unsupported) | ✅ · unverified CRX variants and compressed RAF/ORF decode remain; [CR3 coverage](docs/cr3.md) |
|
||||
| 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 ⬜ |
|
||||
|
||||
+12
-11
@@ -6,7 +6,7 @@ projects (DrawCraft reached its first four milestones in ≈ 4½ h) and are revi
|
||||
|
||||
## Where we stand
|
||||
|
||||
*Honest assessment, 2026-10-05. Agents: read this before picking work. The checklist in
|
||||
*Honest assessment, 2026-10-08. Agents: read this before picking work. The checklist in
|
||||
[`docs/parity.md`](docs/parity.md) counts features that **exist**; this section is about whether a photographer can
|
||||
**switch** from Lightroom. Update it when a gap below closes.*
|
||||
|
||||
@@ -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% | 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 | 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), uncompressed RAF/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 RAF/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 and RW2 have a guarded per-file embedded-JPEG colour estimate (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), uncompressed RAF/ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; embedded JPEG previews (incl. CR3; HEVC remains unsupported) | **unverified CR3 CRX variants**, compressed RAF/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 CR3 have a guarded per-file embedded-JPEG colour estimate (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 ⬜ |
|
||||
@@ -37,7 +37,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 |
|
||||
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
|
||||
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develops sensor pixels; unverified CRX and compressed Fujifilm/Olympus use embedded JPEG previews when present. 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 |
|
||||
|
||||
@@ -48,7 +48,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 and Panasonic RW2 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 RAF / ORF, NEF
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed RAF / 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
|
||||
@@ -84,7 +84,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
|
||||
| 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, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF and RW2 preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
|
||||
| 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, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF and RW2 preview fitting; measured database still missing), CR3 lossless Bayer and version 0x100/0x200 C-RAW ✅ (M50/R100/R8 exact sensor tests), unverified CRX and compressed ORF/RAF ⬜) |
|
||||
| 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 | ⬜ |
|
||||
@@ -165,23 +165,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 (Bayer and
|
||||
X-Trans), 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**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
|
||||
**Camera colour matrices:** ARW, NEF and RW2 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 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.
|
||||
@@ -198,6 +198,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 and RW2 readers have vendor WB multipliers but no measured camera
|
||||
/// The current ARW, NEF, RW2 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"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "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`]).
|
||||
@@ -430,7 +430,7 @@ mod edited_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sony_and_nikon_raws_use_relative_white_balance() {
|
||||
fn vendor_raws_without_camera_calibration_use_relative_white_balance() {
|
||||
for (name, format, relative) in [
|
||||
("a.arw", "ARW", true),
|
||||
("a.nef", "NEF", true),
|
||||
@@ -438,6 +438,7 @@ mod edited_tests {
|
||||
("a.rw2", "RW2", true),
|
||||
("a.rwl", "RWL", true),
|
||||
("a.raw", "RAW", 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) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
|
||||
//! RW2, 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)
|
||||
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Cr3)
|
||||
}
|
||||
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||||
@@ -84,14 +84,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.
|
||||
@@ -778,11 +925,72 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sony_nikon_and_panasonic_raws_get_a_file_local_look() {
|
||||
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2].into_iter().all(file_local_look));
|
||||
fn vendor_raws_without_a_camera_matrix_get_a_file_local_look() {
|
||||
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Cr3].into_iter().all(file_local_look));
|
||||
assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].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]
|
||||
|
||||
@@ -102,9 +102,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 {
|
||||
@@ -122,7 +122,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 {
|
||||
@@ -182,6 +182,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;
|
||||
|
||||
@@ -216,13 +227,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),
|
||||
};
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||||
@@ -417,6 +428,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 = 11;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 12;
|
||||
|
||||
/// 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];
|
||||
|
||||
+405
-12
@@ -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);
|
||||
|
||||
+13
-4
@@ -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), 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/RAF, 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/RAF, 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),
|
||||
|
||||
Vendored
+470
@@ -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]);
|
||||
}
|
||||
}
|
||||
Vendored
+1032
File diff suppressed because it is too large.
Load diff
Vendored
+338
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -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,7 +15,7 @@ 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)
|
||||
"cr3-", // Unverified CRX coding variants; exact supported cases live in cr3_corpus.rs.
|
||||
"raf-fuji-xt20-compressed", // Fujifilm compressed RAF
|
||||
"orf-olympus-em", // Olympus compressed ORF
|
||||
"sraw", // Canon sRAW / mRAW
|
||||
@@ -42,11 +42,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();
|
||||
@@ -86,7 +89,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 and Panasonic RW2 starting look
|
||||
# Sony ARW, Nikon NEF, Panasonic RW2 and Canon CR3 starting look
|
||||
|
||||
ARW and NEF decoding supply a Bayer mosaic 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 mosaic; there is no JPEG replacement or uniform saturation boost.
|
||||
ARW, NEF, RW2 and CR3 decoding supply a Bayer mosaic 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 mosaic; there is no JPEG replacement or uniform saturation boost.
|
||||
|
||||
## Colour and tone are separate
|
||||
|
||||
@@ -46,11 +46,16 @@ The same fit, gates and relative WB apply to Panasonic RW2, Leica RWL and the ol
|
||||
|
||||
Public CC0 samples from raw.pixls.us (178 files from 118 Panasonic and Leica bodies; the four from 2005–2007 carry no preview), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 140 of 174 fits accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 (1.2–21.3) with matching mean lightness (median |ΔL*| 0.4); 124 of them use a hue/saturation table. (Fitted with the matrix and tone curve alone, 137 were accepted at median ΔE 6.1; every one of those is as close or closer with the table and chroma curve, by 0.75 on average.) On the 14 corpus files with a preview the neutral fallback scored ΔE 9.6–27 and rendered up to 16 L* too dark; 13 fits were accepted, ΔE 2.8–8.9, lightness within 2 L*. Of the 34 files left on the neutral fallback, 21 fail the gates, almost all compacts and kit zooms (TZ/ZS, FZ2500, D-Lux, V-Lux, GF2, GX80 with its 12–32 mm): their camera JPEG is distortion-corrected, the pixel pairs no longer line up and the held-out RMS stays above 0.10 (GX80: 0.138 → 0.107); two S1 II shots just miss it (0.103, 0.107). The other 13 never reach the gates, mostly nearly colourless scenes (grey walls, concrete: under 5% coloured pixels) or dark ones (a night sky, a near-black frame). Panasonic's embedded distortion data (tag `0x0119`) is not used yet, and `lightcraft-cli calibrate` doesn't collect RW2 files: the misaligned pairs of distortion-corrected JPEGs would distort a pooled profile.
|
||||
|
||||
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 and Nikon 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 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.
|
||||
|
||||
Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (DRO and picture styles vary per shot); the acceptance gates still apply, and a rejected fit falls back as before. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt.
|
||||
|
||||
+74
@@ -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 12 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.
|
||||
+4
-4
@@ -60,7 +60,7 @@ that decide whether a photographer can switch (see the honest assessment in [ROA
|
||||
Take the first one nobody is working on.
|
||||
|
||||
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** first (every Canon body since ~2018), then compressed RAF / ORF, Nikon
|
||||
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
|
||||
@@ -124,8 +124,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, 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, 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. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). 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 RAF/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 |
|
||||
| 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). Grow the CC0 corpus per model and test each sample decodes with plausible colour |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, 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, 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. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). 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 RAF/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 |
|
||||
| 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). Grow the CC0 corpus per model and test each sample decodes with plausible colour |
|
||||
| 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) |
|
||||
@@ -276,7 +276,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 and Panasonic RW2 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 local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW and NEF 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); 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 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 local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW and NEF 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); 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 |
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
## October 2026
|
||||
|
||||
### RAW decoding
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user