From 6bd64e4ad47bca44f9019cb8f242a6628662bc02 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:00:39 +0000 Subject: [PATCH] LR-IMP-FORMATS: Panasonic RW2 / Leica RWL / Panasonic RAW in every raw format Decode every raw encoding Panasonic and Leica bodies write, from the DMC-LX1 (2005) to the DC-S1RM2 (2025): compressed formats 4 (most bodies, 2008-2022) and 6 (S1 / S1R / S1H / S5, GH5M2), the prefix-coded strips of format 8 (GH6 and later), packed formats 2, 5 and 7, and the 16-bit words of the 2005-2007 .RAW files. Until now only format 5 decoded; every other Panasonic file opened as preview only. Clean-room: no raw-decoder source was read. Tag names come from tag documentation; every encoding was recovered by black-box analysis of 178 CC0 files from raw.pixls.us (118 bodies), and the module docs note the measurement that settled each rule. Format-8 tags of unknown meaning must carry the values every sample has, otherwise the file is reported as unsupported rather than guessed at. Levels and geometry: formats 4 and 6 store samples 15 above the recorded black level (files without one use 15); white is the active area's saturation plateau (the linearity limits are wrong on extended low-ISO shots, the GF1 and the Digilux 3); the in-camera aspect ratio is the default crop; mapped-out defect pixels are filled from same-colour neighbours; the CFA pattern applies at sample (0, 0). .rwl and .raw files are imported (engine, MCP folder expansion, file dialog, Linux MIME types). One file per encoding joins the never-panic proptest; 11 CC0 files join `cargo xtask corpus`, and corpus_panasonic_encodings checks each encoding's bit depth, black level, crop and CFA layout against the camera's own JPEG. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QtQmZ6peZXGn3wiqeowV2v --- README.md | 6 +- ROADMAP.md | 22 +- apps/lightcraft/src/main.rs | 4 +- crates/engine/src/import.rs | 4 +- crates/mcp/src/headless.rs | 6 +- crates/raw/src/lib.rs | 5 +- crates/raw/src/vendor/rw2.rs | 1089 +++++++++++++++-- crates/raw/tests/corpus.rs | 150 ++- crates/raw/tests/vendor_robust.rs | 87 +- docs/parity.md | 6 +- docs/whats-new.md | 5 + .../linux/ai.storyteller.lightcraft.desktop | 2 +- xtask/src/assets.rs | 3 +- xtask/src/main.rs | 11 + 14 files changed, 1234 insertions(+), 166 deletions(-) diff --git a/README.md b/README.md index 9692b636..7e475368 100644 --- a/README.md +++ b/README.md @@ -258,8 +258,8 @@ 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, - Panasonic RW2, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP, PSD - composites and JPEG XL open today. + 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 instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched. - **Resolution-independent edits.** Radii and brush sizes are relative to the image, so a 400 px preview, your @@ -303,7 +303,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the | Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ | | Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; neutral fallback today) | | 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, Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ | +| 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 ⬜ | | Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ | | Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 | | Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ | diff --git a/ROADMAP.md b/ROADMAP.md index f2dcb9bc..a924fae4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,7 +23,7 @@ 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) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, 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 (~40 corpus files vs >1,000 models) | +| **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 and NEF 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 | @@ -36,7 +36,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | User | Readiness | What blocks them | |---|---:|---| | JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks | -| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW and NEF preview estimates are only a starting point) | +| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW and NEF 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) | | 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 and Nikon NEF have the file-local fit (matrix + tone curve from their own JPEG); generalise it to the other makes' raws (RW2, PEF, ORF…), then validate fidelity. -2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF +2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then 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 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW and NEF 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 and NEF preview fitting; measured database still missing), CR3, 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 | ⬜ | @@ -118,7 +118,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel | Work package | Tracker rows | Agent-hours | Risk | |---|---|---:|---| | Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low | -| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, RW2 v4, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs | +| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs | | Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection | | Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium | | AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training | @@ -165,7 +165,9 @@ The milestone estimates in the table above were made before work started and are 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 packed 12/14-bit, PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every +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. @@ -174,14 +176,11 @@ Not decoded yet — preview only (no permissively licensed description; black-bo files): rows above the split decode with the regular lossy table; from the split row on a different code is used that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis). -- **Panasonic RW2 raw format 4** (quantised): the block layout is known (0x4000-byte chunks rotated by 0x1ff8; 128-bit - blocks of two 12-bit seeds plus four groups of a 2-bit scale and three 8-bit codes; scales 0/1 are ×1/×2 differences - from the same-colour pixel two to the left), the reconstruction rule for scales 2/3 is not established. - **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG. **Camera colour matrices:** ARW 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/Panasonic equivalents) and our +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. ## Log @@ -193,6 +192,9 @@ own chart-based calibration (M11.4). Adobe matrices are never used. Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added tracker rows for camera colour, camera coverage and render fidelity. +- 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. ## Chinese and Japanese interfaces, and text watermarks diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index e46fd2ee..4bb1c6ff 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -286,8 +286,8 @@ fn services() -> Services { .add_filter( lightcraft_ui_egui::i18n::tr("Photos"), &[ - "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd", - "jxl", "gif", "bmp", + "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", + "pef", "psd", "jxl", "gif", "bmp", ], ) .pick_files() diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 6f35b670..2da1a083 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -32,8 +32,8 @@ use crate::media::ProbeInfo; /// File extensions LightCraft imports (lower case). pub const EXTENSIONS: &[&str] = &[ - "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp", - "heic", "avif", + "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", + "gif", "bmp", "heic", "avif", ]; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 2ae21b25..162ccce4 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -11,8 +11,10 @@ use serde_json::{Value, json}; use crate::backend::Backend; /// File extensions recognised as photos when expanding folders. -pub const PHOTO_EXTENSIONS: &[&str] = - &["jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp", "avif"]; +pub const PHOTO_EXTENSIONS: &[&str] = &[ + "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", "gif", + "bmp", "avif", +]; /// Headless backend: a [`Session`] with filesystem hooks. pub struct Headless { diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index d4791e7f..f67649de 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -13,9 +13,10 @@ //! //! 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 -//! and X-Trans), Panasonic RW2 (packed 12/14-bit), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed). +//! 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, -//! Panasonic quantised RW2, compressed ORF/RAF, CR3) return [`RawError::Unsupported`]; each vendor module documents its sources +//! compressed ORF/RAF, CR3) 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)] diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index efd515f2..10ade627 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -1,28 +1,80 @@ -//! Panasonic RW2 — the packed (unquantised) variant. +//! Panasonic RW2, Leica RWL and the older Panasonic / Leica `.RAW` files: every raw encoding these cameras write. //! -//! Sources: the ExifTool PanasonicRaw tag-name documentation (`0x0002/0x0003` sensor size, `0x0004–0x0007` -//! sensor borders, `0x0009` CFA pattern, `0x000a` bits per sample, `0x000e` linearity limit, `0x001c–0x001e` -//! black levels, `0x0024–0x0026` WB levels, `0x0017` ISO, `0x002d` raw format, `0x002e` JpgFromRaw, `0x0118` raw data -//! offset) and our own black-box analysis of CC0 samples from raw.pixls.us (DC-G9 12-bit, DC-GH5S 14-bit): +//! **Clean-room.** No raw-decoder source code (dcraw, LibRaw, rawspeed, rawloader/rawler, darktable, RawTherapee, +//! libopenraw, ExifTool's Perl code, …) was read or consulted. What we used: //! -//! - The raw data is a sequence of 0x4000-byte chunks; each chunk is stored rotated: its logical content is -//! bytes `0x1ff8..0x4000` followed by bytes `0..0x1ff8`. (Found by trying every 8-byte rotation and keeping -//! the one under which the block at each chunk seam is as smooth as its neighbours.) -//! - The logical stream is made of 16-byte blocks, each a little-endian 128-bit word read LSB-first from bit 0: -//! 12-bit data packs 10 samples (8 padding bits at the top), 14-bit data 9 samples (2 padding bits at the top). -//! Found from the per-bit-position probability of a 1 (padding is always 0; the top bits of 12-bit samples and -//! the two lowest bits of the GH5S's 14-bit samples are almost always 0), confirmed by image smoothness and by -//! the decoded masked-free dark level matching the recorded black levels. -//! - Pixels fill rows left to right, rows top to bottom. +//! - Tag documentation: ExifTool's PanasonicRaw tag-name tables (IFD0 `0x0002`–`0x0007` sensor size and borders, +//! `0x0009` CFAPattern with its four values, `0x000a` BitsPerSample, `0x000b` Compression, `0x000e`–`0x0010` +//! LinearityLimit, `0x0017`/`0x0037` ISO, `0x001c`–`0x001e` black levels, `0x0024`–`0x0026` WB levels, `0x002d` +//! RawFormat, `0x002e` JpgFromRaw, `0x002f`–`0x0032` crop, `0x0118` RawDataOffset) and the libopenraw format +//! notes (prose: the `IIU` magic; Leica RWL files are RW2). +//! - Our own black-box analysis of 178 CC0 files from raw.pixls.us, about 120 bodies from the DMC-LX1 (2005) to +//! the DC-S1RM2 (2025), every encoding below. The measurement that settled each rule is noted in brackets. //! -//! Panasonic's quantised variant (raw format 4: 14 pixels per block, two 12-bit seeds plus four groups of a 2-bit -//! scale and three 8-bit codes) is not decoded: our black-box analysis identified the block layout but not the -//! reconstruction rule for every scale, and no permissively licensed description exists. Those files report -//! [`RawError::Unsupported`]; their embedded preview still works. +//! Encodings, by raw format (tag `0x002d`; the oldest files have none and are told apart by compression `0x000b`): +//! +//! - **16-bit words** (compression 34828 and 34830: DMC-LX1, DMC-FZ50, Digilux 3): little-endian words at +//! `StripOffsets`, the sample left-justified [every value is a multiple of 2^(16 − bits)]. +//! - **Packed** (formats 2, 5 and 7): 16-byte blocks read as a little-endian 128-bit number holding ⌊128 / bits⌋ +//! samples from bit 0 up (12-bit: 10, 14-bit: 9). Format 5 stores every whole 0x4000-byte chunk rotated (its +//! logical content is bytes `0x1ff8..0x4000` followed by `0..0x1ff8`); formats 2 (DMC-FZ8) and 7 (DC-S5 with +//! later firmware) are not rotated [vertical same-colour differences 20–180× smaller with the right choice]. +//! - **Format 4** (most bodies, 2008–2022): chunks rotated as format 5; each 16-byte block, read as a little-endian +//! 128-bit number from bit 127 down, codes 14 pixels of a row. Before pixels 2, 5, 8 and 11 comes a 2-bit scale +//! `s` for the next three pixels, step `m` = 1, 2, 4 or 16 [per-bit statistics show the field boundaries]. The +//! first pixel of each colour (parity of the pixel index `i`) is a seed: 8 bits `h`, then, if `h ≠ 0` or +//! `i ≥ 12`, 4 bits `l`, value `16h + l`. `h = 0` (with `i < 12`) marks a mapped-out defective pixel, decoded as +//! 0, and the next pixel of that colour is read as the seed instead [these blocks decode smoothly only this way, +//! 10× closer to the rows above and below, and they sit at the same sensor sites in every shot of a body]. Every +//! later pixel is an 8-bit code `j` against the colour's previous value `p`: `j = 0` keeps `p` [3–4× closer to +//! the neighbouring rows than reading it as a difference]; otherwise `p + (j − 128)·m` when `s < 3` and +//! `p ≥ 128·m`, else `j·m + (p mod m)` [scale-3 codes track the absolute level, ≈ level / 16; values stay on the +//! camera's 4k + 3 lattice (GX80, 99.9–100 % per branch) only when the absolute reading keeps the low bits of `p`]. +//! - **Format 6** (DC-S1, S1R, S1H, S5; DC-GH5M2): not rotated. 14-bit: two 14-bit seeds, then three groups of a +//! 2-bit scale and three 10-bit codes (11 pixels; the 4 lowest bits are unused); 12-bit: two 12-bit seeds and four +//! groups of a 2-bit scale and three 8-bit codes (14 pixels). Read from bit 127 down and reconstructed as format 4 +//! with the bias 512 (14-bit) or 128 (12-bit) [code statistics per scale as in format 4; flat saturated blocks +//! decode flat only when the absolute reading keeps the low bits]. Seeds are never deferred and zero codes don't +//! occur in any sample; a zero code keeps the previous value as in format 4. +//! - **Format 8** (DC-GH6 and later: GH6, GH7, G9M2, S5M2, S5M2X, S9, S1RM2, S1M2): the image is split into vertical +//! strips listed in `u16` arrays (a count, then the values; 32-bit values as low/high pairs): `0x0044` absolute +//! offsets, `0x0045` left columns, `0x0046` lengths in bits, `0x0047` widths, `0x0048` heights. Each strip is an +//! independent bit stream whose bits are taken from each byte starting at bit 0 [a constant run at a strip's end +//! reads as repeats of one code only in this order; MSB-first reading gives code frequencies of exactly +//! 2^−length, the signature of random bits]. Tag `0x0040` lists (length, code) for 17 prefix codes, the difference +//! categories 0–16 of ITU-T T.81 (category `c`, then `c` additional bits, a leading 0 meaning negative). Pixels +//! are coded two rows at a time, cell by 2×2 cell (top-left, top-right, bottom-left, bottom-right); each is +//! predicted from the same site of the previous cell, the first cell of a row pair from the first cell of the +//! previous pair, 0 at the top [the strips decode to the camera JPEG's image without seams]. The streams end a +//! few padding rows before their stated length. Tags `0x0039`–`0x003f`, `0x0041` and `0x0043` carry the same +//! values in every sample; files with other values are reported as unsupported rather than guessed at. +//! +//! Levels and geometry: +//! +//! - Black: per colour from `0x001c`–`0x001e`. Formats 4 and 6 store every sample 15 above it [flat dark regions +//! (σ ≈ 1–3) of ~100 files sit 13–20 above the tag, e.g. 143.1 on a D-Lux 7 tagged 128], the other formats +//! don't [131.4 on a format-5 G9 tagged 127.5; 128.7 on a format-8 S9 tagged 128]. Files that record no black +//! (tags 0: bodies up to ~2010) use 15 [their darkest flat regions sit at 15–22]. +//! - White: the saturation plateau of the active area's samples, else the full range of the bit depth. The linearity +//! limits (`0x000e`–`0x0010`) are not used: extended low-ISO shots record limits far below their samples (TZ200D +//! at ISO 100: 3277, samples up to 3529) while the camera's own JPEG renders them against the full range [the +//! JPEG/raw brightness ratio matches normal shots of the same bodies only then], the Digilux 3 records 4095 but +//! saturates at 15871 of 16383, and the GF1 records 16383 for 12-bit samples. +//! - Samples of 0 mark mapped-out defects (format 4 above; isolated zeros between normal neighbours in the 16-bit +//! files) and are interpolated from the same colour two pixels away. +//! - The CFA pattern applies at sample (0, 0) of the data, not at the sensor borders [the two files with odd borders +//! show the greens on the diagonal this predicts]; red and blue were checked against every file's JPEG. +//! - Active area: the sensor borders. Default crop: `0x002f`–`0x0032`, the in-camera aspect ratio (e.g. 4:3 or 1:1 +//! on a 3:2 sensor; the embedded JPEG always shows the whole sensor). Some bodies write zeros there: ignored. +//! +//! Not supported ([`RawError::Unsupported`], the embedded preview is shown instead): compression 34826, raw formats +//! 1 and 3, and bit depths other than the ones above (no samples of any of them). -use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use super::pef::{Bits, Huffman, diff}; +use super::white_from_data; +use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; -use lightcraft_tiff::{Tiff, tags as t}; +use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, tags as t}; use rayon::prelude::*; const SENSOR_WIDTH: u16 = 0x0002; @@ -33,96 +85,444 @@ const BORDER_BOTTOM: u16 = 0x0006; const BORDER_RIGHT: u16 = 0x0007; const CFA_PATTERN: u16 = 0x0009; const BITS: u16 = 0x000a; -const LINEARITY_LIMIT: u16 = 0x000e; +const COMPRESSION: u16 = 0x000b; +const LINEARITY_LIMIT: [u16; 3] = [0x000e, 0x000f, 0x0010]; +const ISO: u16 = 0x0017; const BLACK_RGB: [u16; 3] = [0x001c, 0x001d, 0x001e]; const WB_RGB: [u16; 3] = [0x0024, 0x0025, 0x0026]; const RAW_FORMAT: u16 = 0x002d; -const ISO: u16 = 0x0017; +/// The in-camera aspect-ratio crop: top, left, bottom, right in sensor coordinates. +const CROP: [u16; 4] = [0x002f, 0x0030, 0x0031, 0x0032]; +const ISO_32: u16 = 0x0037; const RAW_OFFSET: u16 = 0x0118; +const HUFFMAN_CODES: u16 = 0x0040; +const STRIP_OFFSETS: u16 = 0x0044; +const STRIP_LEFT: u16 = 0x0045; +const STRIP_BITS: u16 = 0x0046; +const STRIP_WIDTHS: u16 = 0x0047; +const STRIP_HEIGHTS: u16 = 0x0048; +/// Format-8 tags of unknown meaning and the values every sample carries (as `u16` arrays). +const FORMAT8_FIXED: [(u16, &[u16]); 9] = [ + (0x0039, &[6, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]), + (0x003a, &[6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + (0x003b, &[65535]), + (0x003c, &[0]), + (0x003d, &[0]), + (0x003e, &[0]), + (0x003f, &[0]), + (0x0041, &[17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + (0x0043, &[1]), +]; +/// Most strips seen in a file is 3 (the 12008 × 8008 high-resolution mode). +const MAX_STRIPS: usize = 64; const CHUNK: usize = 0x4000; const ROTATE: usize = 0x1ff8; +/// Formats 4 and 6 store samples this far above the recorded black level. +const CODEC_BLACK_OFFSET: f32 = 15.0; +/// Black level of files that record none. +const DEFAULT_BLACK: f32 = 15.0; -/// The logical byte stream of the raw data (chunks un-rotated). A trailing partial chunk is kept as is. -pub(crate) fn unrotate(src: &[u8]) -> Vec { - let mut out = Vec::with_capacity(src.len()); - for c in src.chunks(CHUNK) { - if c.len() == CHUNK { - out.extend_from_slice(&c[ROTATE..]); - out.extend_from_slice(&c[..ROTATE]); - } else { - out.extend_from_slice(c); +/// How the samples are stored. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Encoding { + /// Little-endian 16-bit words, samples left-justified. + Words, + /// Continuous 16-byte blocks; `rotated`: whole 0x4000-byte chunks are stored rotated by 0x1ff8. + Blocks { kind: Block, rotated: bool }, + /// Format 8: prefix-coded strips. + Strips, +} + +/// What one 16-byte block holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Block { + /// ⌊128 / bits⌋ samples from bit 0 up. + Packed { + bits: u32, + }, + Format4, + Format6 { + bits14: bool, + }, +} + +impl Block { + fn per(self) -> usize { + match self { + Block::Packed { bits } => (128 / bits.max(1)) as usize, + Block::Format4 | Block::Format6 { bits14: false } => 14, + Block::Format6 { bits14: true } => 11, } } - out -} -/// Samples per 16-byte block and the bit position of the first sample (kept for other layouts). -fn layout(bits: u32) -> Option<(usize, u32)> { - match bits { - 12 => Some((10, 0)), - 14 => Some((9, 0)), - _ => None, + fn decode(self, b: &[u8; 16], out: &mut [u16]) { + match self { + Block::Packed { bits } => packed(b, bits, out), + Block::Format4 => format4(b, out), + Block::Format6 { bits14 } => format6(b, bits14, out), + } } } -/// Unpack `out.len()` samples from the logical stream. -pub(crate) fn unpack(stream: &[u8], bits: u32, out: &mut [u16]) -> Result<()> { - let (per, start) = layout(bits).ok_or_else(|| RawError::Unsupported(format!("RW2 packed {bits}-bit data")))?; - let mask = (1u128 << bits) - 1; - out.par_chunks_mut(per * 1024).enumerate().for_each(|(ci, o)| { - let first_block = ci * 1024; - for (bi, px) in o.chunks_mut(per).enumerate() { - let at = (first_block + bi) * 16; - let Some(b) = stream.get(at..at + 16).and_then(|b| <[u8; 16]>::try_from(b).ok()) else { - px.fill(0); - continue; - }; - let word = u128::from_le_bytes(b); - for (i, p) in px.iter_mut().enumerate() { - *p = ((word >> (start + i as u32 * bits)) & mask) as u16; +fn encoding(ifd: &Ifd, bits: u32) -> Result { + let blocks = |kind, rotated| Ok(Encoding::Blocks { kind, rotated }); + match (ifd.u16(RAW_FORMAT), ifd.u16(COMPRESSION)) { + (Some(2), _) if bits == 12 => blocks(Block::Packed { bits }, false), + (Some(4), _) if bits == 12 => blocks(Block::Format4, true), + (Some(5), _) if bits == 12 || bits == 14 => blocks(Block::Packed { bits }, true), + (Some(6), _) if bits == 12 || bits == 14 => blocks(Block::Format6 { bits14: bits == 14 }, false), + (Some(7), _) if bits == 14 => blocks(Block::Packed { bits }, false), + (Some(8), _) if (12..=16).contains(&bits) => Ok(Encoding::Strips), + (None, Some(34828 | 34830)) if (12..=16).contains(&bits) => Ok(Encoding::Words), + (Some(f), _) => Err(RawError::Unsupported(format!("Panasonic raw format {f} with {bits}-bit samples"))), + (None, c) => Err(RawError::Unsupported(format!("Panasonic raw without a raw format (compression {c:?})"))), + } +} + +/// Bit fields of a 16-byte block taken as a little-endian 128-bit number, read from bit 127 down. +struct Fields { + word: u128, + left: u32, +} + +impl Fields { + fn new(b: &[u8; 16]) -> Fields { + Fields { word: u128::from_le_bytes(*b), left: 128 } + } + + /// The next `n` (1..=16) bits. + #[inline] + fn take(&mut self, n: u32) -> i32 { + self.left = self.left.saturating_sub(n); + ((self.word >> self.left) & ((1u128 << n) - 1)) as i32 + } +} + +/// Step and absolute flag of the 2-bit scales of formats 4 and 6. +const STEPS: [(i32, bool); 4] = [(1, false), (2, false), (4, false), (16, true)]; + +/// Formats 4 and 6: the next value of a colour from its previous value `p` and code `j` (see the module docs). +#[inline] +fn next(p: i32, j: i32, (m, absolute): (i32, bool), bias: i32) -> i32 { + if j == 0 { + p + } else if !absolute && p >= bias * m { + p + (j - bias) * m + } else { + j * m + (p & (m - 1)) + } +} + +#[inline] +fn put(out: &mut [u16], i: usize, v: i32) { + if let Some(o) = out.get_mut(i) { + *o = v.clamp(0, u16::MAX as i32) as u16; + } +} + +fn packed(b: &[u8; 16], bits: u32, out: &mut [u16]) { + let word = u128::from_le_bytes(*b); + let mask = (1u128 << bits.min(16)) - 1; + for (i, o) in out.iter_mut().enumerate().take((128 / bits.max(1)) as usize) { + *o = ((word >> (i as u32 * bits)) & mask) as u16; + } +} + +fn format4(b: &[u8; 16], out: &mut [u16]) { + let mut f = Fields::new(b); + let (mut prev, mut seeded, mut step) = ([0i32; 2], [false; 2], STEPS[0]); + for i in 0..14 { + if i % 3 == 2 { + step = STEPS[f.take(2) as usize & 3]; + } + let c = i & 1; + let v = if seeded[c] { + prev[c] = next(prev[c], f.take(8), step, 128); + prev[c] + } else { + let hi = f.take(8); + if hi != 0 || i >= 12 { + prev[c] = hi << 4 | f.take(4); + seeded[c] = hi != 0; + prev[c] + } else { + 0 // a defect marker; this colour's seed comes with its next pixel + } + }; + put(out, i, v); + } +} + +fn format6(b: &[u8; 16], bits14: bool, out: &mut [u16]) { + let (seed, code, groups, bias) = if bits14 { (14, 10, 3, 512) } else { (12, 8, 4, 128) }; + let mut f = Fields::new(b); + let mut prev = [f.take(seed), f.take(seed)]; + put(out, 0, prev[0]); + put(out, 1, prev[1]); + for g in 0..groups { + let step = STEPS[f.take(2) as usize & 3]; + for k in 0..3 { + let i = 2 + 3 * g + k; + prev[i & 1] = next(prev[i & 1], f.take(code), step, bias); + put(out, i, prev[i & 1]); + } + } +} + +/// Decode a continuous stream of 16-byte blocks into `out`, in parallel by 0x4000-byte chunks (whole chunks +/// rotated when `rotated`). Blocks missing from `src` give zeros. +fn decode_blocks(src: &[u8], kind: Block, rotated: bool, out: &mut [u16]) { + let per = kind.per(); + out.par_chunks_mut(CHUNK / 16 * per).enumerate().for_each(|(c, o)| { + let raw = src.get(c * CHUNK..).unwrap_or_default(); + let raw = raw.get(..CHUNK).unwrap_or(raw); + let mut logical = Vec::new(); + let chunk = if rotated && raw.len() == CHUNK { + logical.extend_from_slice(raw.get(ROTATE..).unwrap_or_default()); + logical.extend_from_slice(raw.get(..ROTATE).unwrap_or_default()); + &logical + } else { + raw + }; + for (i, px) in o.chunks_mut(per).enumerate() { + match chunk.get(i * 16..i * 16 + 16).and_then(|s| <&[u8; 16]>::try_from(s).ok()) { + Some(b) => kind.decode(b, px), + None => px.fill(0), } } }); +} + +fn decode_words(src: &[u8], shift: u32, out: &mut [u16]) { + out.par_chunks_mut(1 << 16).enumerate().for_each(|(k, o)| { + for (i, v) in o.iter_mut().enumerate() { + let at = ((k << 16) + i) * 2; + *v = src.get(at..at + 2).and_then(|b| <[u8; 2]>::try_from(b).ok()).map_or(0, |b| u16::from_le_bytes(b) >> shift); + } + }); +} + +/// A tag as a `u16` array: SHORT values, or the little-endian contents of an UNDEFINED / BYTE value. +fn u16_array(ifd: &Ifd, tag: u16, order: ByteOrder) -> Option> { + match ifd.value(tag)? { + Value::Undefined(b) | Value::Byte(b) => Some(b.as_chunks::<2>().0.iter().map(|c| order.u16(*c)).collect()), + Value::Short(v) => Some(v.clone()), + other => Some(other.to_u64_vec().into_iter().map(|v| v.min(u16::MAX as u64) as u16).collect()), + } +} + +/// A format-8 `u16` list (count, then the values; `wide`: 32-bit values as low/high pairs). +fn list(ifd: &Ifd, tag: u16, order: ByteOrder, wide: bool) -> Result> { + let a = u16_array(ifd, tag, order).ok_or_else(|| RawError::Corrupt(format!("RW2 format 8 without tag {tag:#06x}")))?; + let n = a.first().map_or(0, |&n| n as usize); + let at = |i: usize| a.get(i).map(|&v| v as u64); + let values: Option> = (0..n).map(|i| if wide { Some(at(1 + 2 * i)? | at(2 + 2 * i)? << 16) } else { at(1 + i) }).collect(); + values.filter(|v| !v.is_empty() && v.len() <= MAX_STRIPS).ok_or_else(|| RawError::Corrupt(format!("RW2 format 8: bad list in tag {tag:#06x}"))) +} + +/// One format-8 strip: its bit stream and placement. +struct Strip<'a> { + data: &'a [u8], + width: usize, + height: usize, + bits: u64, +} + +/// The format-8 strips (tiling the full width, top to bottom) and code table. +fn format8<'a>(ifd: &Ifd, order: ByteOrder, bytes: &'a [u8], w: usize, h: usize) -> Result<(Vec>, Huffman)> { + for (tag, want) in FORMAT8_FIXED { + if let Some(v) = u16_array(ifd, tag, order) + && v != want + { + return Err(RawError::Unsupported(format!("Panasonic raw format 8 with tag {tag:#06x} = {v:?}"))); + } + } + let (offsets, lefts, bits) = (list(ifd, STRIP_OFFSETS, order, true)?, list(ifd, STRIP_LEFT, order, true)?, list(ifd, STRIP_BITS, order, true)?); + let (widths, heights) = (list(ifd, STRIP_WIDTHS, order, false)?, list(ifd, STRIP_HEIGHTS, order, false)?); + let n = offsets.len(); + if [lefts.len(), bits.len(), widths.len(), heights.len()].iter().any(|&l| l != n) { + return Err(RawError::Corrupt("RW2 format 8: strip lists of different lengths".into())); + } + let mut strips = Vec::with_capacity(n); + let mut x = 0usize; + for i in 0..n { + let (left, width, height) = (lefts[i] as usize, widths[i] as usize, heights[i] as usize); + let len = usize::try_from(bits[i].div_ceil(8)).unwrap_or(usize::MAX); + let data = usize::try_from(offsets[i]).ok().and_then(|o| bytes.get(o..o.checked_add(len)?)); + let Some(data) = data.filter(|_| left == x && width > 0 && height == h) else { + return Err(RawError::Corrupt(format!("RW2 format 8: strip {i} outside the image or file"))); + }; + x += width; + strips.push(Strip { data, width, height, bits: bits[i] }); + } + if x != w { + return Err(RawError::Corrupt(format!("RW2 format 8: strips cover {x} of {w} columns"))); + } + let codes = u16_array(ifd, HUFFMAN_CODES, order).unwrap_or_default(); + let pairs: Vec<(u32, u32)> = match (codes.first(), codes.get(1..35)) { + (Some(17), Some(c)) => c.as_chunks::<2>().0.iter().map(|&[l, c]| (l as u32, c as u32)).collect(), + _ => return Err(RawError::Corrupt("RW2 format 8: bad code table".into())), + }; + Ok((strips, code_table(&pairs)?)) +} + +/// The prefix-code table from (length, code) per difference category 0..=16. In 12- and 14-bit files the codes of the +/// categories the bit depth can't produce start with a shorter code (8 ones): read bit by bit, the shorter code wins +/// and the longer ones are unreachable. +fn code_table(pairs: &[(u32, u32)]) -> Result { + let mut table = Vec::with_capacity(pairs.len()); + for (i, &(la, ca)) in pairs.iter().enumerate() { + if la > 12 { + return Err(RawError::Unsupported(format!("RW2 format 8: {la}-bit code"))); + } + if la > 0 && ca >> la != 0 { + return Err(RawError::Corrupt("RW2 format 8: code longer than its length".into())); + } + let shadowed = pairs.iter().any(|&(lb, cb)| lb > 0 && lb < la && ca >> (la - lb) == cb); + let duplicate = pairs.iter().enumerate().any(|(j, &(lb, cb))| j != i && la > 0 && (lb, cb) == (la, ca)); + if duplicate { + return Err(RawError::Corrupt("RW2 format 8: two categories with the same code".into())); + } + table.push(if la == 0 || shadowed { (0, 0) } else { ((ca << (12 - la)) as u16, la as u8) }); + } + Huffman::new(&table) +} + +/// Decode one strip into its column range of each output row. +fn decode_strip(s: &Strip, codes: &Huffman, mut rows: Vec<&mut [u16]>) -> Result<()> { + // the stream's bits are taken from each byte starting at bit 0: reversed, it is an ordinary MSB-first stream + let reversed: Vec = s.data.iter().map(|b| b.reverse_bits()).collect(); + let mut bits = Bits::new(&reversed); + let mut first = [0i32; 4]; + for pair in 0..s.height.div_ceil(2) { + let mut cell = first; + for cx in 0..s.width.div_ceil(2) { + for (k, v) in cell.iter_mut().enumerate() { + let d = diff(&mut bits, codes).ok_or_else(|| RawError::Corrupt(format!("RW2 format 8: invalid code in row {}", 2 * pair)))?; + *v = v.saturating_add(d); + if let Some(px) = rows.get_mut(2 * pair + k / 2).and_then(|r| r.get_mut(2 * cx + k % 2)) { + *px = (*v).clamp(0, u16::MAX as i32) as u16; + } + } + if cx == 0 { + first = cell; + } + } + if bits.consumed_bits() as u64 > s.bits { + return Err(RawError::Corrupt(format!("RW2 format 8: strip data ends at row {} of {}", 2 * pair, s.height))); + } + } Ok(()) } +fn decode_strips(strips: &[Strip], codes: &Huffman, w: usize, out: &mut [u16]) -> Result<()> { + let mut parts: Vec> = strips.iter().map(|s| Vec::with_capacity(s.height)).collect(); + for row in out.chunks_mut(w) { + let mut rest = row; + for (s, rows) in strips.iter().zip(parts.iter_mut()) { + let (part, tail) = rest.split_at_mut(s.width.min(rest.len())); + rows.push(part); + rest = tail; + } + } + strips.par_iter().zip(parts).try_for_each(|(s, rows)| decode_strip(s, codes, rows)) +} + +/// Replace samples of 0 inside `area` (mapped-out defects) by the mean of the non-zero samples of the same colour two +/// pixels away. +fn fill_defects(data: &mut [u16], w: usize, area: Rect) { + for y in area.y..area.y + area.height { + let Some(row) = data.get(y * w + area.x..y * w + area.x + area.width) else { break }; + if !row.contains(&0) { + continue; + } + for x in area.x..area.x + area.width { + let i = y * w + x; + if data.get(i) != Some(&0) { + continue; + } + let near = [(x.checked_sub(2), Some(y)), (Some(x + 2).filter(|&x| x < w), Some(y)), (Some(x), y.checked_sub(2)), (Some(x), Some(y + 2))]; + let (mut sum, mut n) = (0u32, 0u32); + for (nx, ny) in near { + if let (Some(nx), Some(ny)) = (nx, ny) + && let Some(&v) = data.get(ny * w + nx).filter(|&&v| v != 0) + { + sum += v as u32; + n += 1; + } + } + if n > 0 + && let Some(v) = data.get_mut(i) + { + *v = (sum / n) as u16; + } + } + } +} + +/// Samples of up to 512 evenly spaced rows of the active area (the borders hold junk such as 0xffff runs). +fn active_rows(data: &[u16], w: usize, a: Rect) -> Vec { + let step = (a.height / 512).max(1); + (a.y..a.y + a.height).step_by(step).filter_map(|y| data.get(y * w + a.x..y * w + a.x + a.width)).flatten().copied().collect() +} + fn cfa(code: u16) -> Cfa { - let s = match code { + Cfa::bayer_static(match code { 2 => "GRBG", 3 => "GBRG", 4 => "BGGR", _ => "RGGB", - }; - Cfa::bayer_static(s) + }) } pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; - let ifd0 = &tiff.ifds[0]; - let get = |tag: u16| ifd0.u64(tag).map(|v| v as usize); + let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("RW2 without IFD0".into()))?; + let get = |tag: u16| ifd0.u64(tag).and_then(|v| usize::try_from(v).ok()); let (w, h) = (get(SENSOR_WIDTH).unwrap_or(0), get(SENSOR_HEIGHT).unwrap_or(0)); - let bits = get(BITS).unwrap_or(12) as u32; - let format = get(RAW_FORMAT).unwrap_or(0); if w == 0 || h == 0 { return Err(RawError::Corrupt("RW2 without sensor size".into())); } - let n = w.checked_mul(h).filter(|n| *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; - if format != 5 { - return Err(RawError::Unsupported(format!("Panasonic RW2 raw format {format} (quantised)"))); - } - let (per, _) = layout(bits).ok_or_else(|| RawError::Unsupported(format!("RW2 packed {bits}-bit data")))?; - let off = get(RAW_OFFSET).or_else(|| get(t::STRIP_OFFSETS)).ok_or_else(|| RawError::Corrupt("RW2 without raw data offset".into()))?; - let src = bytes.get(off..).ok_or_else(|| RawError::Corrupt("RW2 raw data outside file".into()))?; - let need = n.div_ceil(per) * 16; - if src.len() < need { - return Err(RawError::Corrupt(format!("RW2 raw data truncated ({} of {need} bytes)", src.len()))); - } + let n = w.checked_mul(h).filter(|n| *n <= MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let bits = ifd0.u32(BITS).unwrap_or(12); + let encoding = encoding(ifd0, bits)?; + let src = || { + let off = get(RAW_OFFSET).or_else(|| get(t::STRIP_OFFSETS)).ok_or_else(|| RawError::Corrupt("RW2 without raw data offset".into()))?; + bytes.get(off..).ok_or_else(|| RawError::Corrupt("RW2 raw data outside file".into())) + }; + let truncated = |have: usize, need: usize| RawError::Corrupt(format!("RW2 raw data truncated ({have} of {need} bytes)")); let mut data = Vec::new(); - if mode == Mode::Full { - let stream = unrotate(&src[..(need.div_ceil(CHUNK) * CHUNK).min(src.len())]); - data = vec![0u16; n]; - unpack(&stream, bits, &mut data)?; + match encoding { + Encoding::Words => { + let src = src()?; + let need = n * 2; + if src.len() < need { + return Err(truncated(src.len(), need)); + } + if mode == Mode::Full { + data = vec![0u16; n]; + decode_words(src, 16u32.saturating_sub(bits), &mut data); + } + } + Encoding::Blocks { kind, rotated } => { + let src = src()?; + let need = n.div_ceil(kind.per()) * 16; + if src.len() < need { + return Err(truncated(src.len(), need)); + } + if mode == Mode::Full { + data = vec![0u16; n]; + decode_blocks(src, kind, rotated, &mut data); + } + } + Encoding::Strips => { + let (strips, codes) = format8(ifd0, tiff.order, bytes, w, h)?; + if mode == Mode::Full { + data = vec![0u16; n]; + decode_strips(&strips, &codes, w, &mut data)?; + } + } } let active = match (get(BORDER_TOP), get(BORDER_LEFT), get(BORDER_BOTTOM), get(BORDER_RIGHT)) { @@ -130,26 +530,45 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { _ => Rect::new(0, 0, w, h), }; let active = if active.width == 0 || active.height == 0 { Rect::new(0, 0, w, h) } else { active }; - let cfa = cfa(ifd0.u16(CFA_PATTERN).unwrap_or(1)); - let black = match BLACK_RGB.map(|tag| ifd0.f64(tag)) { - [Some(r), Some(g), Some(b)] => { - // per CFA position, anchored at the active area - let a = cfa.shifted(active.x, active.y); - let values = a.pattern.iter().map(|&c| [r, g, b][c as usize] as f32).collect(); - BlackLevel { repeat_rows: 2, repeat_cols: 2, values, ..Default::default() } + let crop = match CROP.map(get) { + [Some(tp), Some(l), Some(b), Some(r)] + if b > tp && r > l && l >= active.x && tp >= active.y && r <= active.x + active.width && b <= active.y + active.height => + { + Rect::new(l - active.x, tp - active.y, r - l, b - tp) } - _ => BlackLevel::uniform(0.0), + _ => Rect::new(0, 0, active.width, active.height), }; - let white = ifd0.f64(LINEARITY_LIMIT).filter(|v| *v > 0.0).map(|v| v as f32).unwrap_or(((1u32 << bits) - 1) as f32); + if !data.is_empty() { + fill_defects(&mut data, w, active); + } + + let cfa = cfa(ifd0.u16(CFA_PATTERN).unwrap_or(1)); + let tags = BLACK_RGB.map(|tag| ifd0.f64(tag).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0) as f32); + let offset = match encoding { + Encoding::Blocks { kind: Block::Format4 | Block::Format6 { .. }, .. } => CODEC_BLACK_OFFSET, + _ => 0.0, + }; + let rgb = if tags.iter().all(|&v| v == 0.0) { [DEFAULT_BLACK; 3] } else { tags.map(|v| v + offset) }; + // per CFA position, anchored at the active area + let values = cfa.shifted(active.x, active.y).pattern.iter().map(|&c| rgb[c as usize]).collect(); + let black = BlackLevel { repeat_rows: 2, repeat_cols: 2, values, ..Default::default() }; + let full = ((1u32 << bits.clamp(1, 16)) - 1) as f32; + let white = if data.is_empty() { + // headers only (white isn't part of `RawInfo`): the recorded limit as a placeholder + LINEARITY_LIMIT.iter().filter_map(|&tag| ifd0.f64(tag)).map(|v| v as f32).filter(|v| *v > 0.0 && *v <= full).fold(full, f32::min) + } else { + white_from_data(&active_rows(&data, w, active), bits) + }; + let white = if white > black.mean() { white } else { full }; let wb = match WB_RGB.map(|tag| ifd0.f64(tag)) { [Some(r), Some(g), Some(b)] if r > 0.0 && g > 0.0 && b > 0.0 => Some([(r / g) as f32, 1.0, (b / g) as f32]), _ => None, }; let mut metadata = lightcraft_meta::from_tiff(&tiff); - metadata.width = Some(active.width as u32); - metadata.height = Some(active.height as u32); + metadata.width = Some(crop.width as u32); + metadata.height = Some(crop.height as u32); if metadata.iso.is_none() { - metadata.iso = ifd0.u32(ISO).filter(|v| *v > 0); + metadata.iso = ifd0.u32(ISO).or_else(|| ifd0.u32(ISO_32)).filter(|v| *v > 0); } let img = RawImage { format: RawFormat::Rw2, @@ -162,7 +581,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { black, white: vec![white], active_area: active, - crop: Rect::new(0, 0, active.width, active.height), + crop, orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), color: ColorData::default(), wb_multipliers: wb, @@ -177,79 +596,483 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { #[cfg(test)] mod tests { use super::*; - use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + use lightcraft_tiff::{IfdBuilder, TiffWriter}; - /// Inverse of the decoder: pack, then rotate every whole chunk. - pub(crate) fn encode(px: &[u16], bits: u32) -> Vec { - let (per, start) = layout(bits).unwrap(); - let mut stream = Vec::new(); - for blk in px.chunks(per) { - let mut word = 0u128; - for (i, &v) in blk.iter().enumerate() { - word |= (v as u128) << (start + i as u32 * bits); - } - stream.extend_from_slice(&word.to_le_bytes()); + /// Writes bit fields into a 16-byte block from bit 127 down (the inverse of [`Fields`]). + struct FieldWriter { + word: u128, + left: u32, + } + + impl FieldWriter { + fn new() -> Self { + FieldWriter { word: 0, left: 128 } } - stream.resize(stream.len().div_ceil(CHUNK) * CHUNK, 0); - let mut out = Vec::with_capacity(stream.len()); - for c in stream.chunks(CHUNK) { - // logical = file[ROTATE..] ++ file[..ROTATE] => file = logical[CHUNK-ROTATE..] ++ logical[..CHUNK-ROTATE] - out.extend_from_slice(&c[CHUNK - ROTATE..]); - out.extend_from_slice(&c[..CHUNK - ROTATE]); + fn put(&mut self, n: u32, v: u32) -> &mut Self { + assert!(v < 1 << n, "{v} doesn't fit {n} bits"); + self.left -= n; + self.word |= (v as u128) << self.left; + self } + fn block(&self) -> [u8; 16] { + self.word.to_le_bytes() + } + } + + /// A seed of format 4: 8 bits `h`, 4 bits `l`. + fn seed(f: &mut FieldWriter, v: u32) { + f.put(8, v >> 4).put(4, v & 15); + } + + fn decode4(b: [u8; 16]) -> [u16; 14] { + let mut out = [0u16; 14]; + format4(&b, &mut out); out } - pub(crate) fn rw2(w: u16, h: u16, bits: u16, format: u16, payload: &[u8]) -> Vec { + #[test] + fn format4_known_blocks() { + // every branch: differences at each scale, a zero code, absolute scale-3 codes keeping the low bits + let mut f = FieldWriter::new(); + seed(&mut f, 1000); + seed(&mut f, 2000); + f.put(2, 0).put(8, 130).put(8, 120).put(8, 0); // 1002, 1992, 1002 (kept) + f.put(2, 1).put(8, 129).put(8, 100).put(8, 128); // 1994, 946, 1994 + f.put(2, 2).put(8, 200).put(8, 64).put(8, 131); // 1234, 1738, 1246 + f.put(2, 3).put(8, 50).put(8, 255).put(8, 0); // 50·16 + 1738 % 16 = 810, 4080 + 14 = 4094, 810 (kept) + assert_eq!(f.left, 0); + assert_eq!(decode4(f.block()), [1000, 2000, 1002, 1992, 1002, 1994, 946, 1994, 1234, 1738, 1246, 810, 4094, 810]); + + // a deferred seed (defect marker, 8 bits only) and absolute codes because the previous value is too small + let mut f = FieldWriter::new(); + f.put(8, 0); // pixel 0: defect; the next even pixel brings the seed + seed(&mut f, 325); + f.put(2, 1); + seed(&mut f, 481); // pixel 2: the even seed + f.put(8, 140).put(8, 10); // 325 + 24 = 349, 481 − 236 = 245 + f.put(2, 2).put(8, 100).put(8, 129).put(8, 129); // < 512: 400 + 349 % 4 = 401, 516 + 245 % 4 = 517, 517 + f.put(2, 0).put(8, 127).put(8, 0).put(8, 255); // 516, 517 (kept), 643 + f.put(2, 0).put(8, 1).put(8, 128).put(8, 128); // 390, 643, 390 + assert_eq!(f.left, 0); + assert_eq!(decode4(f.block()), [0, 325, 481, 349, 245, 401, 517, 517, 516, 517, 643, 390, 643, 390]); + + // a colour without a seed until pixel 12, which then always reads its 4 low bits + let mut f = FieldWriter::new(); + f.put(8, 0); + seed(&mut f, 1600); + for g in 0..4 { + f.put(2, 0); + for k in 0..3 { + let i = 2 + 3 * g + k; + if i % 2 == 1 { + f.put(8, 128); + } else if i < 12 { + f.put(8, 0); + } else { + f.put(8, 0).put(4, 7); + } + } + } + assert_eq!(f.left, 0); + assert_eq!(decode4(f.block()), [0, 1600, 0, 1600, 0, 1600, 0, 1600, 0, 1600, 0, 1600, 7, 1600]); + } + + #[test] + fn format6_known_blocks() { + let mut f = FieldWriter::new(); + f.put(14, 5000).put(14, 6000); + f.put(2, 0).put(10, 520).put(10, 500).put(10, 512); // 5008, 5988, 5008 + f.put(2, 3).put(10, 1023).put(10, 300).put(10, 0); // 16368 + 5988 % 16, 4800 + 5008 % 16, kept + f.put(2, 1).put(10, 600).put(10, 400).put(10, 10); // 4976, 16148, 3972 + let mut out = [0u16; 11]; + format6(&f.block(), true, &mut out); + assert_eq!(out, [5000, 6000, 5008, 5988, 5008, 16372, 4800, 16372, 4976, 16148, 3972]); + + let mut f = FieldWriter::new(); + f.put(12, 300).put(12, 4000); + f.put(2, 2).put(8, 140).put(8, 100).put(8, 130); // 300 < 512: 560 + 0; 3888; 568 + f.put(2, 0).put(8, 129).put(8, 127).put(8, 128); // 3889, 567, 3889 + f.put(2, 3).put(8, 10).put(8, 255).put(8, 20); // 160 + 7, 4080 + 1, 320 + 7 + f.put(2, 1).put(8, 128).put(8, 130).put(8, 0); // 4081, 331, 4081 (kept) + assert_eq!(f.left, 0); + let mut out = [0u16; 14]; + format6(&f.block(), false, &mut out); + assert_eq!(out, [300, 4000, 560, 3888, 568, 3889, 567, 3889, 167, 4081, 327, 4081, 331, 4081]); + } + + /// A camera-like format 4/6 encoder: per group the smallest scale that codes the targets exactly, else scale 3. + /// Returns the block and what a decoder must reconstruct. + fn encode_block(target: &[u16], fmt6_bits: Option) -> ([u8; 16], Vec) { + let (seed_bits, code_bits, bias) = match fmt6_bits { + Some(14) => (14, 10, 512), + Some(_) => (12, 8, 128), + None => (12, 8, 128), + }; + let n = target.len(); + let mut f = FieldWriter::new(); + let mut prev = [target[0] as i32, target[1] as i32]; + let mut out = vec![target[0], target[1]]; + for &s in &target[..2] { + if fmt6_bits.is_some() { + f.put(seed_bits, s as u32); + } else { + seed(&mut f, s as u32); + } + } + for g in 0..(n - 2) / 3 { + let idx: Vec = (0..3).map(|k| 2 + 3 * g + k).collect(); + let fits = |s: usize| -> Option> { + let (m, absolute) = STEPS[s]; + let mut p = prev; + let mut codes = vec![]; + for &i in &idx { + let (v, c) = (target[i] as i32, i & 1); + let j = if !absolute && p[c] >= bias * m { + let d = v - p[c]; + (d % m == 0 && (d / m).abs() < bias).then_some(d / m + bias)? + } else { + // nearest step, as a camera would + let j = (v - (p[c] & (m - 1)) + m / 2) / m; + (absolute || (v - (p[c] & (m - 1))) % m == 0).then_some(j.clamp(1, (1 << code_bits) - 1))? + }; + p[c] = next(p[c], j, (m, absolute), bias); + codes.push(j); + } + Some(codes) + }; + let s = (0..4).find(|&s| fits(s).is_some()).unwrap_or(3); + let codes = fits(s).unwrap(); + f.put(2, s as u32); + for (k, &i) in idx.iter().enumerate() { + f.put(code_bits, codes[k] as u32); + prev[i & 1] = next(prev[i & 1], codes[k], STEPS[s], bias); + out.push(prev[i & 1] as u16); + } + } + (f.block(), out) + } + + /// A smooth synthetic Bayer-like image with some sharp edges, values `lo..hi`. + fn image(w: usize, h: usize, lo: u32, hi: u32) -> Vec { + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let smooth = lo + (hi - lo) * ((x * 7 + y * 3) % 997) as u32 / 997 / 2; + let edge = if (x / 37 + y / 23) % 3 == 0 { (hi - lo) / 3 } else { 0 }; + (smooth + edge + (x % 2 * 40) as u32).min(hi) as u16 + }) + .collect() + } + + /// Rotate every whole 0x4000-byte chunk the way formats 4 and 5 store them. + fn rotate(mut stream: Vec) -> Vec { + stream.resize(stream.len().div_ceil(CHUNK) * CHUNK, 0); + stream.chunks(CHUNK).flat_map(|c| c[CHUNK - ROTATE..].iter().chain(&c[..CHUNK - ROTATE]).copied().collect::>()).collect() + } + + /// Pack samples LSB-first into 16-byte blocks (formats 2, 5 and 7). + fn pack(px: &[u16], bits: u32) -> Vec { + let per = (128 / bits) as usize; + px.chunks(per).flat_map(|blk| blk.iter().enumerate().fold(0u128, |w, (i, &v)| w | (v as u128) << (i as u32 * bits)).to_le_bytes()).collect() + } + + /// A minimal RW2: IFD0 with `tags` plus sensor size, borders (2 rows, 4 columns at the top/left) and Make, the + /// payload at the end. `at_payload` adds tags that hold the payload's offset. + fn file(w: u16, h: u16, tags: &[(u16, Value)], payload: &[u8], at_payload: &dyn Fn(u32) -> Vec<(u16, Value)>) -> Vec { let build = |off: u32| { let mut ifd = IfdBuilder::new(); for (tag, v) in [(SENSOR_WIDTH, w), (SENSOR_HEIGHT, h), (BORDER_TOP, 2), (BORDER_LEFT, 4), (BORDER_BOTTOM, h), (BORDER_RIGHT, w)] { ifd.set(tag, Value::Short(vec![v])); } - ifd.set(CFA_PATTERN, Value::Short(vec![1])); - ifd.set(BITS, Value::Short(vec![bits])); - ifd.set(RAW_FORMAT, Value::Short(vec![format])); - ifd.set(LINEARITY_LIMIT, Value::Short(vec![(1 << bits) - 1])); - for (i, tag) in BLACK_RGB.iter().enumerate() { - ifd.set(*tag, Value::Short(vec![100 + i as u16])); - } - for (tag, v) in WB_RGB.iter().zip([512u16, 256, 384]) { - ifd.set(*tag, Value::Short(vec![v])); - } ifd.set(t::MAKE, Value::Ascii("Panasonic".into())); - ifd.set(RAW_OFFSET, Value::Long(vec![off])); + for (tag, v) in tags.iter().cloned().chain(at_payload(off)) { + ifd.set(tag, v); + } let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap(); b[2] = 0x55; b }; - let head = build(0); - let off = head.len().div_ceil(16) * 16; + let off = build(0).len().div_ceil(16) * 16; let mut b = build(off as u32); b.resize(off, 0); b.extend_from_slice(payload); b } + fn short(tag: u16, v: u16) -> (u16, Value) { + (tag, Value::Short(vec![v])) + } + + /// Tags of a format-`format`, `bits`-bit file with black levels `black` and the payload at `RawDataOffset`. + fn rw2(w: u16, h: u16, bits: u16, format: u16, black: u16, payload: &[u8]) -> Vec { + let mut tags = vec![short(CFA_PATTERN, 1), short(BITS, bits), short(RAW_FORMAT, format)]; + tags.extend(BLACK_RGB.iter().enumerate().map(|(i, &tag)| short(tag, black + i as u16))); + tags.extend(WB_RGB.iter().zip([512, 256, 384]).map(|(&tag, v)| short(tag, v))); + file(w, h, &tags, payload, &|off| vec![(RAW_OFFSET, Value::Long(vec![off]))]) + } + + fn samples(r: &RawImage) -> &[u16] { + match &r.data { + RawData::U16(d) => d, + RawData::F32(_) => panic!("float samples"), + } + } + #[test] - fn packed_12_and_14_bit() { - let (w, h) = (90usize, 200usize); // > one chunk of data - for bits in [12u32, 14] { - let px: Vec = (0..w * h).map(|i| ((i * 2654435761usize) >> 7) as u16 & ((1 << bits) - 1)).collect(); - let bytes = rw2(w as u16, h as u16, bits as u16, 5, &encode(&px, bits)); - assert_eq!(crate::probe(&bytes), Some(RawFormat::Rw2)); + fn format4_and_6_round_trip_through_files() { + // format 4: 14 pixels per block, rotated chunks, blocks continuous across rows (here 70 = 5 blocks per row) + let (w, h) = (70usize, 300usize); + let target = image(w, h, 200, 4000); + let (mut stream, mut want) = (vec![], vec![]); + for px in target.chunks(14) { + let (b, out) = encode_block(px, None); + stream.extend_from_slice(&b); + want.extend(out); + } + assert!(want.iter().zip(&target).all(|(a, b)| a.abs_diff(*b) <= 8), "the encoder is too lossy for the test image"); + let bytes = rw2(w as u16, h as u16, 12, 4, 128, &rotate(stream)); + let r = crate::decode(&bytes).unwrap(); + assert_eq!(samples(&r), want); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); + assert_eq!(r.black.values, vec![143.0; 4].into_iter().zip([0., 1., 1., 2.]).map(|(b, o)| b + o).collect::>()); + + // format 6: not rotated; 11 (14-bit) or 14 (12-bit) pixels per block + for (bits, per, top) in [(14u16, 11usize, 16000u32), (12, 14, 4000)] { + let w = per * 6; + let target = image(w, 40, 600, top); + let (mut stream, mut want) = (vec![], vec![]); + for px in target.chunks(per) { + let (b, out) = encode_block(px, Some(bits as u32)); + stream.extend_from_slice(&b); + want.extend(out); + } + let bytes = rw2(w as u16, 40, bits, 6, 512, &stream); let r = crate::decode(&bytes).unwrap_or_else(|e| panic!("{bits}: {e}")); + assert_eq!(samples(&r), want, "{bits}-bit"); + assert_eq!(r.black.at(0, 0, 0, 1), 527.0, "format 6 stores samples 15 above the black level"); + } + } + + #[test] + fn packed_and_word_formats() { + let (w, h) = (90usize, 200usize); // > one chunk of data + for (format, bits, rotated) in [(5u16, 12u32, true), (5, 14, true), (7, 14, false), (2, 12, false)] { + let px: Vec = (0..w * h).map(|i| (((i * 2654435761usize) >> 7) as u16 & ((1 << bits) - 1)).max(1)).collect(); + let stream = pack(&px, bits); + let bytes = rw2(w as u16, h as u16, bits as u16, format, 100, &if rotated { rotate(stream) } else { stream }); + assert_eq!(crate::probe(&bytes), Some(RawFormat::Rw2)); + let r = crate::decode(&bytes).unwrap_or_else(|e| panic!("format {format}, {bits}-bit: {e}")); assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); - assert_eq!(r.data, RawData::U16(px), "{bits}-bit"); + assert_eq!(samples(&r), px, "format {format}, {bits}-bit"); assert_eq!(r.active_area, Rect::new(4, 2, w - 4, h - 2)); assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); - assert_eq!(r.black.at(0, 0, 0, 1), 100.0); - assert_eq!(r.black.at(1, 0, 0, 1), 101.0); - assert_eq!(r.black.at(1, 1, 0, 1), 102.0); + // packed formats store samples at the recorded black level, per colour, anchored at the active area + assert_eq!([r.black.at(0, 0, 0, 1), r.black.at(1, 0, 0, 1), r.black.at(1, 1, 0, 1)], [100.0, 101.0, 102.0]); assert_eq!(r.wb_multipliers, Some([2.0, 1.0, 1.5])); } - let bytes = rw2(28, 2, 12, 4, &[0; 64]); + // the oldest files: 16-bit words with the sample left-justified, no black level recorded + for (compression, bits) in [(34828u16, 12u32), (34830, 14)] { + let px: Vec = (0..w * h).map(|i| 100 + (i * 7 % 3000) as u16).collect(); + let payload: Vec = px.iter().flat_map(|&v| (v << (16 - bits)).to_le_bytes()).collect(); + let tags = [short(BITS, bits as u16), short(COMPRESSION, compression), short(CFA_PATTERN, 4)]; + let bytes = file(w as u16, h as u16, &tags, &payload, &|off| vec![(t::STRIP_OFFSETS, Value::Long(vec![off]))]); + let r = crate::decode(&bytes).unwrap_or_else(|e| panic!("{compression}: {e}")); + assert_eq!(samples(&r), px); + assert_eq!(r.black.mean(), DEFAULT_BLACK); + assert_eq!(r.white_at(0), ((1 << bits) - 1) as f32, "no saturation plateau: the full range"); + } + } + + #[test] + fn levels_crop_defects_and_cfa() { + let (w, h) = (90usize, 60usize); + let mut px: Vec = (0..w * h).map(|i| 300 + (i % 7) as u16).collect(); + // a saturation plateau sets the white level; zeros inside the active area are defects + px[10 * w..20 * w].fill(3900); + px[30 * w + 40] = 0; + px[1] = 0; // in the border: left alone + let mut tags = vec![short(CFA_PATTERN, 2), short(BITS, 12), short(RAW_FORMAT, 5)]; + tags.extend([0x002f, 0x0030, 0x0031, 0x0032].iter().zip([12u16, 10, 52, 80]).map(|(&tag, v)| short(tag, v))); + tags.extend(LINEARITY_LIMIT.map(|tag| short(tag, 2000))); // recorded limits are not used + let bytes = file(w as u16, h as u16, &tags, &rotate(pack(&px, 12)), &|off| vec![(RAW_OFFSET, Value::Long(vec![off]))]); + let r = crate::decode(&bytes).unwrap(); + let d = samples(&r); + assert_eq!(d[30 * w + 40], ((px[30 * w + 38] as u32 + px[30 * w + 42] as u32 + px[28 * w + 40] as u32 + px[32 * w + 40] as u32) / 4) as u16); + assert_eq!(d[1], 0); + assert!((3800.0..=3900.0).contains(&r.white_at(0)), "{}", r.white_at(0)); + assert_eq!(r.black.mean(), DEFAULT_BLACK, "no black recorded"); + assert_eq!(r.crop, Rect::new(6, 10, 70, 40), "crop tags are sensor coordinates; the crop is relative to the active area"); + assert_eq!((r.metadata.width, r.metadata.height), (Some(70), Some(40))); + // the CFA tag describes sample (0, 0) of the data, not the active area's origin + assert_eq!(r.cfa.as_ref().unwrap().name(), "GRBG"); + let mut tags = vec![short(CFA_PATTERN, 2), short(BITS, 12), short(RAW_FORMAT, 5)]; + tags.extend([0x002f, 0x0030, 0x0031, 0x0032].map(|tag| short(tag, 0))); // some bodies write zeros + let bytes = file(w as u16, h as u16, &tags, &rotate(pack(&px, 12)), &|off| vec![(RAW_OFFSET, Value::Long(vec![off]))]); + assert_eq!(crate::decode(&bytes).unwrap().crop, Rect::new(0, 0, w - 4, h - 2)); + } + + /// Format 8 code tables as two cameras write them (16-bit DC-GH6; 14-bit DC-S5M2, whose codes for categories 15 + /// and 16 start with the 8-bit code of category 14 and are unreachable). + const TABLE16: [(u32, u32); 17] = [ + (10, 1022), + (11, 2046), + (8, 254), + (9, 510), + (7, 126), + (4, 14), + (4, 12), + (3, 4), + (3, 2), + (2, 0), + (3, 3), + (3, 5), + (4, 13), + (5, 30), + (6, 62), + (12, 4094), + (12, 4095), + ]; + const TABLE14: [(u32, u32); 17] = [ + (6, 62), + (7, 126), + (6, 61), + (5, 28), + (4, 12), + (3, 4), + (3, 2), + (2, 0), + (3, 3), + (3, 5), + (4, 13), + (5, 29), + (6, 60), + (8, 254), + (8, 255), + (12, 4094), + (12, 4095), + ]; + + /// Encode a strip (the inverse of [`decode_strip`]): returns the stored bytes and the number of bits. + fn encode_strip(px: &[u16], w: usize, h: usize, table: &[(u32, u32); 17], padding_rows: usize) -> (Vec, u64) { + let mut bits: Vec = vec![]; + let mut put = |n: u32, v: u32| bits.extend((0..n).rev().map(|i| v >> i & 1 == 1)); + let mut first = [0i32; 4]; + let at = |x: usize, y: usize| if y < h { px[y * w + x] as i32 } else { 1000 }; + for pair in 0..(h / 2 + padding_rows) { + let mut cell = first; + for cx in 0..w / 2 { + for (k, p) in cell.iter_mut().enumerate() { + let v = at(2 * cx + k % 2, 2 * pair + k / 2); + let d = v - *p; + let c = 32 - d.unsigned_abs().leading_zeros(); + let (len, code) = table[c as usize]; + put(len, code); + put(c, if d >= 0 { d as u32 } else { (d + (1 << c) - 1) as u32 }); + *p = v; + } + if cx == 0 { + first = cell; + } + } + } + let n = bits.len() as u64; + // stored with each byte's bits taken from bit 0 up + (bits.chunks(8).map(|b| b.iter().enumerate().fold(0u8, |a, (i, &x)| a | (x as u8) << i)).collect(), n) + } + + /// A format-8 file with the given strip widths; `tweak` edits the tags before writing. + fn format8_file(w: usize, h: usize, px: &[u16], widths: &[usize], table: &[(u32, u32); 17], tweak: &dyn Fn(&mut Vec<(u16, Value)>)) -> Vec { + let mut payload = vec![]; + let mut strips = vec![]; // (offset in payload, bits, left, width) + let mut left = 0; + for &sw in widths { + let sub: Vec = (0..h).flat_map(|y| px[y * w + left..y * w + left + sw].to_vec()).collect(); + let (data, bits) = encode_strip(&sub, sw, h, table, 2); + strips.push((payload.len(), bits, left, sw)); + payload.extend(data); + payload.resize(payload.len().div_ceil(16) * 16, 0); + left += sw; + } + let u16s = |v: Vec| undefined(&v); + let wide = |v: Vec| u16s(std::iter::once(v.len() as u16).chain(v.iter().flat_map(|&x| [x as u16, (x >> 16) as u16])).collect()); + let narrow = |v: Vec| u16s(std::iter::once(v.len() as u16).chain(v.iter().map(|&x| x as u16)).collect()); + let mut tags = vec![short(CFA_PATTERN, 1), short(BITS, 14), short(RAW_FORMAT, 8)]; + tags.extend(BLACK_RGB.map(|tag| short(tag, 512))); + tags.push((HUFFMAN_CODES, u16s(std::iter::once(17).chain(table.iter().flat_map(|&(l, c)| [l as u16, c as u16])).collect()))); + for (tag, v) in FORMAT8_FIXED { + tags.push(if v.len() == 1 { short(tag, v[0]) } else { (tag, u16s(v.to_vec())) }); + } + tags.push((STRIP_LEFT, wide(strips.iter().map(|s| s.2 as u64).collect()))); + tags.push((STRIP_BITS, wide(strips.iter().map(|s| s.1).collect()))); + tags.push((STRIP_WIDTHS, narrow(strips.iter().map(|s| s.3 as u64).collect()))); + tags.push((STRIP_HEIGHTS, narrow(vec![h as u64; strips.len()]))); + tweak(&mut tags); + let offsets: Vec = strips.iter().map(|s| s.0).collect(); + file(w as u16, h as u16, &tags, &payload, &|off| { + vec![(RAW_OFFSET, Value::Long(vec![off])), (STRIP_OFFSETS, wide(offsets.iter().map(|&o| off as u64 + o as u64).collect()))] + }) + } + + #[test] + fn format8_strips_round_trip() { + let (w, h) = (64usize, 30usize); + let px = image(w, h, 520, 16000); + for (table, widths) in [(&TABLE14, vec![64]), (&TABLE14, vec![30, 34]), (&TABLE16, vec![20, 22, 22])] { + let bytes = format8_file(w, h, &px, &widths, table, &|_| {}); + let r = crate::decode(&bytes).unwrap_or_else(|e| panic!("{widths:?}: {e}")); + assert_eq!(samples(&r), px, "{widths:?}"); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); + assert_eq!(r.black.mean(), 512.0, "format 8 stores samples at the recorded black level"); + } + } + + fn replace(tags: &mut Vec<(u16, Value)>, tag: u16, v: Value) { + tags.retain(|(t, _)| *t != tag); + tags.push((tag, v)); + } + + fn undefined(v: &[u16]) -> Value { + Value::Undefined(v.iter().flat_map(|x| x.to_le_bytes()).collect()) + } + + #[test] + fn format8_rejects_what_it_cannot_decode() { + let (w, h) = (64usize, 30usize); + let px = image(w, h, 520, 16000); + let case = |tag: u16, v: Value| crate::decode(&format8_file(w, h, &px, &[32, 32], &TABLE14, &|t| replace(t, tag, v.clone()))); + // an auxiliary tag with a value no sample has + assert!(matches!(case(0x003b, Value::Short(vec![4095])), Err(RawError::Unsupported(_)))); + // strips that don't tile the width, or extend past the file + assert!(matches!(case(STRIP_WIDTHS, undefined(&[2, 32, 30])), Err(RawError::Corrupt(_)))); + assert!(matches!(case(STRIP_BITS, undefined(&[2, 0, 256, 0, 256])), Err(RawError::Corrupt(_)))); + // a strip whose stated length ends before its data does + assert!(matches!(case(STRIP_BITS, undefined(&[2, 64, 0, 64, 0])), Err(RawError::Corrupt(_)))); + // code tables: two categories with the same code, a code longer than its length, missing entries + let table: Vec = std::iter::once(17).chain(TABLE14.iter().flat_map(|&(l, c)| [l as u16, c as u16])).collect(); + let mut dup = table.clone(); + dup[1..3].copy_from_slice(&[2, 0]); + assert!(matches!(case(HUFFMAN_CODES, undefined(&dup)), Err(RawError::Corrupt(_)))); + let mut long = table.clone(); + long[1..3].copy_from_slice(&[3, 9]); + assert!(matches!(case(HUFFMAN_CODES, undefined(&long)), Err(RawError::Corrupt(_)))); + assert!(matches!(case(HUFFMAN_CODES, undefined(&table[..20])), Err(RawError::Corrupt(_)))); + // the shadowed 12-bit codes of the 14-bit table are fine + assert!(case(HUFFMAN_CODES, undefined(&table)).is_ok()); + } + + #[test] + fn unsupported_and_truncated_files() { + let payload = [0u8; 64]; + for (format, bits) in [(1u16, 12u16), (3, 12), (4, 14), (7, 12), (9, 12)] { + assert!(matches!(crate::decode(&rw2(28, 4, bits, format, 128, &payload)), Err(RawError::Unsupported(_))), "{format}/{bits}"); + } + let tags = [short(BITS, 12), short(COMPRESSION, 34826)]; + let bytes = file(28, 4, &tags, &payload, &|off| vec![(t::STRIP_OFFSETS, Value::Long(vec![off]))]); assert!(matches!(crate::decode(&bytes), Err(RawError::Unsupported(_)))); - let bytes = rw2(280, 20, 12, 5, &[0; 64]); - assert!(matches!(crate::decode(&bytes), Err(RawError::Corrupt(_)))); + for format in [4u16, 5, 6] { + let bytes = rw2(280, 20, 12, format, 128, &payload); + assert!(matches!(crate::decode(&bytes), Err(RawError::Corrupt(_))), "{format}"); + assert!(matches!(crate::probe_info(&bytes), Err(RawError::Corrupt(_))), "{format}"); + } } } diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index ad187032..6d497b4e 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -19,9 +19,6 @@ const KNOWN_UNSUPPORTED: &[&str] = &[ "arw-sony-a7m4-lossless-m", // Sony lossless compressed M/S: subsampled (YCbCr) lossless JPEG "arw-sony-a7m4-lossless-s", // " "raf-fuji-xt20-compressed", // Fujifilm compressed RAF - "rw2-panasonic-gh5.", // Panasonic raw format 4 (quantised) - "rw2-panasonic-gx80", // " - "rw2-panasonic-g9-b", // " "orf-olympus-em", // Olympus compressed ORF "sraw", // Canon sRAW / mRAW ]; @@ -46,11 +43,12 @@ fn corpus_raw_decodes() { let t0 = Instant::now(); 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 always embed a JPEG + // 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 if let Some(p) = &preview { assert!(p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]), "{name}: preview is not a JPEG"); } else { - assert_eq!(fmt, RawFormat::Dng, "{name}: no embedded preview"); + assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-"), "{name}: no embedded preview"); } let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024); let t1 = Instant::now(); @@ -289,3 +287,145 @@ fn corpus_sony_pre2017_colour_metadata() { } eprintln!("pre-2017 Sony ARW colour metadata checked on {seen} files"); } + +/// The embedded JPEG as linear RGB, reduced to `gw × gh` cells. +fn jpeg_cells(jpeg: &[u8], gw: usize, gh: usize) -> Vec<[f64; 3]> { + use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB); + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(jpeg), opts); + let px = d.decode().unwrap(); + let info = d.info().unwrap(); + let (w, h) = (info.width as usize, info.height as usize); + let (mut sum, mut n) = (vec![[0f64; 3]; gw * gh], vec![0f64; gw * gh]); + for y in 0..h { + for x in 0..w { + let i = (y * gh / h) * gw + x * gw / w; + for c in 0..3 { + let v = px[(y * w + x) * 3 + c] as f64 / 255.0; + sum[i][c] += if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; + } + n[i] += 1.0; + } + } + sum.iter().zip(&n).map(|(s, n)| s.map(|v| v / n)).collect() +} + +/// Black-subtracted means of the four sites of the 2×2 cells (anchored at sample (0, 0)) of the active area, +/// reduced to `gw × gh` cells. +fn raw_sites(img: &lightcraft_raw::RawImage, gw: usize, gh: usize) -> Vec<[f64; 4]> { + let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") }; + let (w, a, black) = (img.width, img.active_area, img.black.mean() as f64); + let (x0, y0) = ((a.x + 1) & !1, (a.y + 1) & !1); + let (cw, ch) = ((a.x + a.width - x0) / 2, (a.y + a.height - y0) / 2); + let (mut sum, mut n) = (vec![[0f64; 4]; gw * gh], vec![0f64; gw * gh]); + for cy in 0..ch { + for cx in 0..cw { + let i = (cy * gh / ch) * gw + cx * gw / cw; + for site in 0..4 { + sum[i][site] += d[(y0 + 2 * cy + site / 2) * w + x0 + 2 * cx + site % 2] as f64 - black; + } + n[i] += 1.0; + } + } + sum.iter().zip(&n).map(|(s, n)| s.map(|v| v / n)).collect() +} + +fn ranks(v: &[f64]) -> Vec { + let mut idx: Vec = (0..v.len()).collect(); + idx.sort_by(|&a, &b| v[a].total_cmp(&v[b])); + let mut r = vec![0f64; v.len()]; + for (rank, &i) in idx.iter().enumerate() { + r[i] = rank as f64; + } + r +} + +/// How well the raw's chromaticities (log r/g, log b/g per cell) follow the JPEG's when the mosaic is read with +/// `layout` (colour of each 2×2 site at sample (0, 0)). +fn chroma_agreement(sites: &[[f64; 4]], jpeg: &[[f64; 3]], layout: &[u8]) -> f64 { + let (mut x, mut y) = ([vec![], vec![]], [vec![], vec![]]); + for (s, j) in sites.iter().zip(jpeg) { + let (mut rgb, mut n) = ([0f64; 3], [0f64; 3]); + for (site, &c) in layout.iter().enumerate() { + rgb[c as usize] += s[site]; + n[c as usize] += 1.0; + } + let rgb = [rgb[0] / n[0], rgb[1] / n[1], rgb[2] / n[2]]; + if rgb.iter().all(|v| *v > 1.0) && j.iter().all(|v| (0.002..0.9).contains(v)) { + for (k, c) in [0, 2].into_iter().enumerate() { + x[k].push((rgb[c] / rgb[1]).ln()); + y[k].push((j[c] / j[1]).ln()); + } + } + } + (correlation(&x[0], &y[0]) + correlation(&x[1], &y[1])) / 2.0 +} + +/// Panasonic RW2 / RWL / RAW (`crates/raw/src/vendor/rw2.rs`): one file per raw encoding (formats 2, 4, 5, 6, 7 and +/// 8 — at 12, 14 and 16 bits — and the 16-bit words of the oldest bodies) decodes to the image of the camera's own +/// JPEG, with the black levels, crops and colour-filter layouts the module docs derive and no defect markers left. +#[test] +fn corpus_panasonic_encodings() { + let dir = corpus_root().join("raw"); + // (file, bits, mean black level, crop width × height, CFA layout) + let cases = [ + ("raw-panasonic-fz50.raw", 12, 15.0, (3648, 2736), "BGGR"), + ("raw-panasonic-fz8.raw", 12, 15.0, (3072, 2304), "RGGB"), + ("rw2-panasonic-fz1000m2-4x3.rw2", 12, 143.0, (4864, 3648), "GBRG"), + ("rw2-panasonic-g9-b.rw2", 12, 143.0, (5184, 3888), "RGGB"), + ("rw2-panasonic-g9.rw2", 12, 127.5, (5184, 3888), "RGGB"), + ("rw2-panasonic-gh1.rw2", 12, 15.0, (4000, 3000), "GBRG"), + ("rw2-panasonic-gh5.rw2", 12, 143.5, (5184, 3888), "RGGB"), + ("rw2-panasonic-gh5m2.rw2", 12, 144.5, (5184, 3888), "RGGB"), + ("rw2-panasonic-gh5s.rw2", 14, 509.5, (3680, 2760), "RGGB"), + ("rw2-panasonic-gh6.rw2", 16, 2048.0, (5776, 4336), "RGGB"), + ("rw2-panasonic-gx80.rw2", 12, 143.0, (4592, 3448), "BGGR"), + ("rw2-panasonic-s1.rw2", 14, 526.0, (6000, 4000), "RGGB"), + ("rw2-panasonic-s5-format7.rw2", 14, 512.0, (6000, 4000), "RGGB"), + ("rw2-panasonic-s5m2.rw2", 14, 512.0, (6000, 4000), "RGGB"), + ("rw2-panasonic-s9.rw2", 12, 128.0, (6000, 4000), "RGGB"), + ("rwl-leica-dlux7.rwl", 12, 143.0, (4736, 3552), "BGGR"), + ]; + let mut seen = 0; + for (name, bits, black, (cw, ch), cfa) in cases { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(img.bits, bits, "{name}: bits"); + assert_eq!(img.black.mean(), black, "{name}: black level"); + assert_eq!((img.crop.width, img.crop.height), (cw, ch), "{name}: crop"); + let layout = img.cfa.clone().unwrap_or_else(|| panic!("{name}: no CFA")); + assert_eq!(layout.name(), cfa, "{name}: CFA layout"); + let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") }; + let a = img.active_area; + let zeros = + (a.y..a.y + a.height).map(|y| d[y * img.width + a.x..y * img.width + a.x + a.width].iter().filter(|&&v| v == 0).count()).sum::(); + assert_eq!(zeros, 0, "{name}: defect markers left in the active area"); + let Some(jpeg) = embedded_preview(&bytes) else { + // the 2005–2007 `.RAW` files have no preview; their flat scenes make the green diagonal unambiguous + assert_eq!(green_on_main_diagonal(&img), cfa.starts_with('G'), "{name}: mosaic statistics disagree with {cfa}"); + seen += 1; + continue; + }; + // the camera's JPEG shows the whole active area (whatever the crop): the raw's green must rank like it, and + // its colours must follow the JPEG's best with the decoded colour-filter layout + let (gw, gh) = (24, 18); + let (sites, jpeg) = (raw_sites(&img, gw, gh), jpeg_cells(&jpeg, gw, gh)); + let green: Vec = sites.iter().map(|s| (0..4).filter(|&i| layout.pattern[i] == 1).map(|i| s[i]).sum::()).collect(); + let jg: Vec = jpeg.iter().map(|j| j[1]).collect(); + let rho = correlation(&ranks(&green), &ranks(&jg)); + let agree = |l: &str| chroma_agreement(&sites, &jpeg, &lightcraft_raw::Cfa::bayer(l).unwrap().pattern); + let best_other = ["RGGB", "GRBG", "GBRG", "BGGR"].into_iter().filter(|l| *l != cfa).map(agree).fold(f64::MIN, f64::max); + let own = agree(cfa); + eprintln!( + "{name:32} {}x{} {bits}-bit: green rank correlation {rho:.3}, colour agreement {own:.3} (others ≤ {best_other:.3})", + img.width, img.height + ); + assert!(rho > 0.9, "{name}: rank correlation with the camera JPEG {rho}"); + assert!(own > 0.5 && own > best_other, "{name}: colours follow the JPEG better with another layout ({own} vs {best_other})"); + seen += 1; + } + eprintln!("Panasonic encodings checked on {seen} files"); +} diff --git a/crates/raw/tests/vendor_robust.rs b/crates/raw/tests/vendor_robust.rs index 2db97c64..81a751c8 100644 --- a/crates/raw/tests/vendor_robust.rs +++ b/crates/raw/tests/vendor_robust.rs @@ -1,4 +1,4 @@ -//! Malformed vendor raw files (NEF, ARW, PEF, ORF, RW2, CR2, RAF) must decode to an error, never panic. +//! Malformed vendor raw files (NEF, ARW, PEF, ORF, RW2 of every raw format, CR2, RAF) must decode to an error, never panic. use lightcraft_tiff::tags as t; use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; @@ -68,6 +68,66 @@ fn rw2() -> Vec { b } +/// A Panasonic RW2 of raw format `format` (or, with `format` 0, the oldest 16-bit-word kind): sensor size, borders, +/// bit depth, black levels, an in-camera crop and `n` bytes of payload at the end, at `RawDataOffset`. Format 8 also +/// gets two strips covering the payload and a code table. +fn rw2_of(format: u16, bits: u16, n: usize) -> Vec { + let u16s = |v: &[u16]| Value::Undefined(v.iter().flat_map(|x| x.to_le_bytes()).collect()); + let build = |off: u32| { + let mut ifd = IfdBuilder::new(); + for (tag, v) in [(2, W as u16), (3, H as u16), (4, 2), (5, 4), (6, H as u16), (7, W as u16), (9, 2), (0x0a, bits), (0x1c, 128), (0x1d, 128)] { + ifd.set(tag, Value::Short(vec![v])); + } + for (tag, v) in [(0x2f, 2u16), (0x30, 6), (0x31, H as u16 - 2), (0x32, W as u16 - 2)] { + ifd.set(tag, Value::Short(vec![v])); + } + if format == 0 { + ifd.set(0x000b, Value::Short(vec![34828])); + ifd.set(t::STRIP_OFFSETS, Value::Long(vec![off])); + } else { + ifd.set(0x002d, Value::Short(vec![format])); + ifd.set(0x0118, Value::Long(vec![off])); + } + if format == 8 { + let table = [ + (6, 62), + (7, 126), + (6, 61), + (5, 28), + (4, 12), + (3, 4), + (3, 2), + (2, 0), + (3, 3), + (3, 5), + (4, 13), + (5, 29), + (6, 60), + (8, 254), + (8, 255), + (12, 4094), + (12, 4095), + ]; + ifd.set(0x0040, u16s(&std::iter::once(17).chain(table.iter().flat_map(|&(l, c)| [l, c])).collect::>())); + let half = (n / 2) as u32; + let (a, b) = (off, off + half); + ifd.set(0x0044, u16s(&[2, a as u16, (a >> 16) as u16, b as u16, (b >> 16) as u16])); + ifd.set(0x0045, u16s(&[2, 0, 0, W as u16 / 2, 0])); + ifd.set(0x0046, u16s(&[2, (half * 8) as u16, ((half * 8) >> 16) as u16, (half * 8) as u16, ((half * 8) >> 16) as u16])); + ifd.set(0x0047, u16s(&[2, W as u16 / 2, W as u16 / 2])); + ifd.set(0x0048, u16s(&[2, H as u16, H as u16])); + } + ifd.set(t::MAKE, Value::Ascii("Panasonic".into())); + let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap(); + b[..4].copy_from_slice(b"IIU\0"); + b + }; + let off = build(0).len(); + let mut b = build(off as u32); + b.extend((0..n).map(|i| (i * 89 % 253) as u8)); + b +} + /// A Pentax PEF: the raw image in IFD0, wide enough for the dark-border trim. fn pef(compression: u16) -> Vec { let (w, h) = (264u32, 4u32); @@ -181,6 +241,20 @@ fn samples() -> Vec> { cr2(), raf(), ] + .into_iter() + .chain(rw2_formats()) + .collect() +} + +/// One RW2 per encoding with a sample decoder of its own (formats 4, 6 at 14 and 12 bits, 8, and 16-bit words). +fn rw2_formats() -> Vec> { + vec![ + rw2_of(4, 12, (W * H).div_ceil(14) as usize * 16), + rw2_of(6, 14, (W * H).div_ceil(11) as usize * 16), + rw2_of(6, 12, (W * H).div_ceil(14) as usize * 16), + rw2_of(8, 14, (W * H) as usize * 2), + rw2_of(0, 12, (W * H) as usize * 2), + ] } fn exercise(bytes: &[u8]) { @@ -208,7 +282,7 @@ proptest! { #![proptest_config(ProptestConfig { cases: 2000, .. ProptestConfig::default() })] #[test] - fn mutated_vendor_files_never_panic(kind in 0usize..12, flips in proptest::collection::vec((any::(), any::()), 1..16), cut in any::()) { + fn mutated_vendor_files_never_panic(kind in 0usize..17, flips in proptest::collection::vec((any::(), any::()), 1..16), cut in any::()) { let mut data = samples().swap_remove(kind); let n = data.len(); for (i, v) in flips { @@ -218,3 +292,12 @@ proptest! { exercise(&data[..keep]); } } + +/// The RW2 samples decode as they are, so the mutations above reach every format's sample decoder. +#[test] +fn rw2_samples_decode_unmutated() { + for s in &rw2_formats() { + let r = lightcraft_raw::decode(s).unwrap(); + assert_eq!((r.width, r.height), (W as usize, H as usize)); + } +} diff --git a/docs/parity.md b/docs/parity.md index 82729971..c6dc27a7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 and Nikon NEF 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`; 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); 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, RW2 v4, Nikon +2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then 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), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, 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, RW2 v4, 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: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) 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), 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-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) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 00480132..25a4e5e4 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -153,6 +153,11 @@ now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the camera no longer gets baked in. Photos already imported as "preview only" switch over on Reload. (Files that Nikon splits into two differently compressed halves still use the preview for now.) +- Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in + every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and + TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They open framed in + the aspect ratio set in the camera, with mapped-out sensor defects filled in. `.rwl` and `.raw` files are imported + too. Photos already imported as "preview only" switch over on Reload. - Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right diff --git a/packaging/linux/ai.storyteller.lightcraft.desktop b/packaging/linux/ai.storyteller.lightcraft.desktop index 5ea3af99..f827c582 100644 --- a/packaging/linux/ai.storyteller.lightcraft.desktop +++ b/packaging/linux/ai.storyteller.lightcraft.desktop @@ -9,4 +9,4 @@ Terminal=false Categories=Graphics;2DGraphics;RasterGraphics;Photography; Keywords=photo;raw;camera;library;develop; StartupWMClass=ai.storyteller.lightcraft -MimeType=image/jpeg;image/png;image/tiff;image/webp;image/x-adobe-dng;image/x-sony-arw;image/x-canon-cr2;image/x-canon-cr3;image/x-nikon-nef;image/x-fuji-raf;image/x-olympus-orf;image/x-panasonic-rw2;image/x-pentax-pef; +MimeType=image/jpeg;image/png;image/tiff;image/webp;image/x-adobe-dng;image/x-sony-arw;image/x-canon-cr2;image/x-canon-cr3;image/x-nikon-nef;image/x-fuji-raf;image/x-olympus-orf;image/x-panasonic-rw2;image/x-panasonic-rw;image/x-pentax-pef; diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index 7a932945..1680e08c 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -9,7 +9,8 @@ use std::process::Command; /// Extensions treated as assets (lower-case). const ASSET_EXT: &[&str] = &[ "png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico", "icns", "bmp", "ttf", "otf", "woff", "woff2", "wav", "mp3", "ogg", "flac", "mp4", - "mov", "tif", "tiff", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "pef", "heic", "jxl", "psd", "cube", "icc", "icm", "xmp", + "mov", "tif", "tiff", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "heic", "jxl", "psd", "cube", "icc", "icm", + "xmp", ]; /// Adobe-specific formats we never ship (camera/lens profiles, Lightroom templates). const FORBIDDEN_EXT: &[&str] = &["dcp", "lcp", "lrtemplate", "lrcat", "lrsmcol", "aco", "ase", "abr"]; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 3cfcd3b8..7f7267f3 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -315,11 +315,22 @@ const RAW_SAMPLES: &[(&str, &str)] = &[ "raf-fuji-xt20-compressed.raf", "https://raw.pixls.us/getfile.php/1178/nice/Fujifilm%20-%20X-T20%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", ), + ("raw-panasonic-fz50.raw", "https://raw.pixls.us/getfile.php/2234/nice/Panasonic%20-%20DMC-FZ50%20-%204:3.RAW"), + ("raw-panasonic-fz8.raw", "https://raw.pixls.us/getfile.php/2282/nice/Panasonic%20-%20DMC-FZ8%20-%204:3.RAW"), + ("rw2-panasonic-fz1000m2-4x3.rw2", "https://raw.pixls.us/getfile.php/4706/nice/Panasonic%20-%20DC-FZ10002%20-%204:3.RW2"), ("rw2-panasonic-g9-b.rw2", "https://raw.pixls.us/getfile.php/2348/nice/Panasonic%20-%20DC-G9%20-%204:3.RW2"), ("rw2-panasonic-g9.rw2", "https://raw.pixls.us/getfile.php/2585/nice/Panasonic%20-%20DC-G9%20-%204:3.RW2"), + ("rw2-panasonic-gh1.rw2", "https://raw.pixls.us/getfile.php/1323/nice/Panasonic%20-%20DMC-GH1%20-%204:3.RW2"), ("rw2-panasonic-gh5.rw2", "https://raw.pixls.us/getfile.php/1517/nice/Panasonic%20-%20DC-GH5%20-%204:3.RW2"), + ("rw2-panasonic-gh5m2.rw2", "https://raw.pixls.us/getfile.php/5082/nice/Panasonic%20-%20DC-GH5M2%20-%204:3.RW2"), ("rw2-panasonic-gh5s.rw2", "https://raw.pixls.us/getfile.php/2603/nice/Panasonic%20-%20DC-GH5S%20-%204:3.RW2"), + ("rw2-panasonic-gh6.rw2", "https://raw.pixls.us/getfile.php/5876/nice/Panasonic%20-%20DC-GH6%20-%204:3.RW2"), ("rw2-panasonic-gx80.rw2", "https://raw.pixls.us/getfile.php/1569/nice/Panasonic%20-%20DMC-GX80%20-%204:3.RW2"), + ("rw2-panasonic-s1.rw2", "https://raw.pixls.us/getfile.php/3038/nice/Panasonic%20-%20DC-S1%20-%203:2.RW2"), + ("rw2-panasonic-s5-format7.rw2", "https://raw.pixls.us/getfile.php/6339/nice/Panasonic%20-%20DC-S5%20-%203:2.RW2"), + ("rw2-panasonic-s5m2.rw2", "https://raw.pixls.us/getfile.php/7790/nice/Panasonic%20-%20DC-S5M2%20-%2014bit%20%283:2%29.RW2"), + ("rw2-panasonic-s9.rw2", "https://raw.pixls.us/getfile.php/7702/nice/Panasonic%20-%20DC-S9%20-%203:2.RW2"), + ("rwl-leica-dlux7.rwl", "https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL"), ]; fn cmd_corpus(download: bool) -> Result<(), String> {