LR-IMP-FORMATS: decode Samsung SRW files with uncompressed raw data
Samsung SRW raws whose raw IFD (private compression 32769 or 32770) stores the samples uncompressed are decoded from the sensor data: 12-bit samples packed two in three bytes (bit order told apart from the samples; NX5, NX10, NX11 and NX20, NX200, NX210, NX1000, NX1100) and 16-bit little-endian words (EX1, WB2000). CFA layout from the raw IFD's CFAPattern; framing, white balance, black and saturation levels from the maker note (value offsets relative to the note; the vectors are stored offset by its EncryptionKey); a one-direction Exif picture size crops to the centre of the framing; black from dark rows or columns when the file states none; saturation plateau below the stated level wins. Compressed codings (32770 with fewer bytes, 32772, 32773) stay preview only with that reason. srw is imported from folders; SRW takes the file-local camera look. RENDER_CACHE_VERSION 17.
This commit is contained in:
parent
c435d143de
commit
d9fd2f8ae0
|
|
@ -305,7 +305,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
|||
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
|
||||
| Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing |
|
||||
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
|
||||
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF decode ⬜ |
|
||||
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF and compressed SRW 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 ⬜ |
|
||||
|
|
|
|||
|
|
@ -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.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
|
||||
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
|
||||
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
|
||||
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
|
||||
|
|
@ -47,8 +47,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|
|||
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
|
||||
|
||||
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2
|
||||
and Pentax PEF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
|
||||
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2,
|
||||
Pentax PEF and Samsung SRW have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
|
||||
checked on the corpus); then ORF, then validate fidelity.
|
||||
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
|
||||
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
|
||||
|
|
@ -188,7 +188,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
|
|||
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
|
||||
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
|
||||
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF and Samsung SRW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
|
||||
own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
|
|
|||
|
|
@ -336,13 +336,13 @@ impl Photo {
|
|||
pub fn develops_raw(&self) -> bool {
|
||||
self.kind == MediaKind::Raw && self.preview_only.is_none()
|
||||
}
|
||||
/// The current ARW, NEF, RW2, RAF, CR3, CR2 and PEF readers have vendor WB multipliers but no measured camera
|
||||
/// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF and SRW readers have vendor WB multipliers but no measured camera
|
||||
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
|
||||
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
|
||||
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
|
||||
pub fn relative_wb(&self) -> bool {
|
||||
self.develops_raw()
|
||||
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
}
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]).
|
||||
|
|
@ -450,6 +450,7 @@ mod edited_tests {
|
|||
("a.raw", "RAW", true),
|
||||
("a.raf", "rAf", true),
|
||||
("a.cr3", "cr3", true),
|
||||
("a.srw", "SRW", true),
|
||||
("a.dng", "DNG", false),
|
||||
] {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
|
||||
|
|
|
|||
|
|
@ -33,7 +33,15 @@ pub(crate) const PROFILE_PROXY: usize = 192;
|
|||
pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
matches!(
|
||||
format,
|
||||
RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3 | RawFormat::Cr2 | RawFormat::Pef
|
||||
RawFormat::Arw
|
||||
| RawFormat::Nef
|
||||
| RawFormat::Nrw
|
||||
| RawFormat::Rw2
|
||||
| RawFormat::Raf
|
||||
| RawFormat::Cr3
|
||||
| RawFormat::Cr2
|
||||
| RawFormat::Pef
|
||||
| RawFormat::Srw
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1021,7 +1029,17 @@ mod tests {
|
|||
#[test]
|
||||
fn supported_raws_get_a_file_local_look() {
|
||||
// every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback)
|
||||
let local = [RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3, RawFormat::Cr2, RawFormat::Pef];
|
||||
let local = [
|
||||
RawFormat::Arw,
|
||||
RawFormat::Nef,
|
||||
RawFormat::Nrw,
|
||||
RawFormat::Rw2,
|
||||
RawFormat::Raf,
|
||||
RawFormat::Cr3,
|
||||
RawFormat::Cr2,
|
||||
RawFormat::Pef,
|
||||
RawFormat::Srw,
|
||||
];
|
||||
assert!(local.into_iter().all(file_local_look));
|
||||
// DNG carries its own colour model
|
||||
assert!(!file_local_look(RawFormat::Dng));
|
||||
|
|
|
|||
|
|
@ -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", "rwl", "raw", "pef", "psd", "jxl",
|
||||
"gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd",
|
||||
"jxl", "gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
|
||||
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ impl SettingsHashes {
|
|||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 16;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 17;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ 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", "rwl", "raw", "pef", "psd", "jxl", "gif",
|
||||
"bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", "jxl",
|
||||
"gif", "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
|
||||
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ impl RawFormat {
|
|||
| RawFormat::Raf
|
||||
| RawFormat::Rw2
|
||||
| RawFormat::Pef
|
||||
| RawFormat::Srw
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -305,6 +306,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
|||
RawFormat::Rw2 => vendor::rw2::decode(bytes, mode),
|
||||
RawFormat::Pef => vendor::pef::decode(bytes, mode),
|
||||
RawFormat::Orf => vendor::orf::decode(bytes, mode),
|
||||
RawFormat::Srw => vendor::srw::decode(bytes, mode),
|
||||
RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))),
|
||||
other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ pub mod pef;
|
|||
pub mod raf;
|
||||
mod rafc;
|
||||
pub mod rw2;
|
||||
pub mod srw;
|
||||
|
||||
use crate::{BlackLevel, Rect};
|
||||
use std::ops::Range;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,541 @@
|
|||
//! Samsung SRW: the variants that store the sensor samples uncompressed.
|
||||
//!
|
||||
//! Sources: TIFF 6.0 (container: a big- or little-endian TIFF whose IFD0 points at sub-IFDs, one of them the raw
|
||||
//! image), Exif 2.3 (`CFAPattern`, `PixelXDimension`, `PixelYDimension`; maker note `0x927c`), the ExifTool Samsung
|
||||
//! tag-name documentation (`SensorAreas` `0xa010`, `EncryptionKey` `0xa020`, `WB_RGGBLevelsUncorrected` `0xa021`,
|
||||
//! `WB_RGGBLevelsBlack` `0xa028`, `RawData` `0xa048`) and our own black-box analysis of CC0 samples from raw.pixls.us
|
||||
//! (NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1, WB2000; every number below was measured on them):
|
||||
//!
|
||||
//! - The raw image is the IFD whose `Compression` is a private value (32769..=32773); its `CFAPattern` (`0x828e`)
|
||||
//! names the 2x2 layout, anchored at the first sample of the stored array. Checked on the files above: the colours
|
||||
//! of the decoded mosaic follow the camera's JPEG best with exactly that layout (`corpus_samsung_srw`).
|
||||
//! - Compression 32769 (EX1, WB2000): one 16-bit little-endian word per sample (the TIFF is big-endian, the words are
|
||||
//! not); the strip holds exactly `width * height * 2` bytes.
|
||||
//! - Compression 32770 with exactly `width * height * 12 / 8` strip bytes (NX5, NX10, NX11, NX20, NX200, NX210, NX1000,
|
||||
//! NX1100): 12-bit samples, two in three bytes, rows without padding. The NX5/NX10/NX11 files read them MSB-first,
|
||||
//! the others LSB-first; the file doesn't say which, the image does: in the wrong order neighbouring same-colour
|
||||
//! samples differ 5 to 11 times more (10 files, every one decided by that margin), see [`lsb_is_smoother`].
|
||||
//! - Every other shape of those and of the other private values (32770 with fewer bytes: NX30, NX300, NX2000,
|
||||
//! EK-GN120; 32772: NX mini, NX3000, NX3300; 32773: NX1, NX500) is a compressed coding: reported as unsupported.
|
||||
//! - The maker note is a plain IFD whose value offsets count from the start of the note, not of the TIFF. Several
|
||||
//! vectors are stored offset by `EncryptionKey`, 11 integers in every file (element `i` of a vector uses key
|
||||
//! `i mod 11`): `WB_RGGBLevelsUncorrected` and `WB_RGGBLevelsBlack` hold `value + key`, `RawData` holds
|
||||
//! `value - key`, found by trying both signs and keeping the one that gives plausible numbers (green levels
|
||||
//! 4096 in all but one file, saturation levels 4095 or 16383, black levels 0 to 3, the dark-column level).
|
||||
//! - `SensorAreas`: the second rectangle (left, top, right, bottom) is the camera JPEG's framing (its size equals the
|
||||
//! JPEG's in every file but WB2000's), used as the active area. Registered against the JPEG its centre agrees to
|
||||
//! within 2 pixels (NX10 family; the NX20 family's JPEG is lens-corrected, edges differ).
|
||||
//! - The Exif picture size is smaller than the framing in one direction only on the WB2000 (16:9 from a 4:3 sensor):
|
||||
//! the JPEG is the centre of the framing (registration: top offset 366 = framing top 22 + (2736 - 2048) / 2).
|
||||
//! - `RawData`: four black levels, then saturation levels (R, G1, G2, B twice). The EX1 saturates at 15881, not at its
|
||||
//! stated 16383 (85786 samples sit on the plateau), so a plateau below the stated level wins.
|
||||
//! - Black: `WB_RGGBLevelsBlack` when it states one (NX5, NX10, NX11: 2 to 3, the right-hand dark columns measure
|
||||
//! 3.5 to 4.5); otherwise the dark rows or columns outside the framing (EX1: eight rows at 2.3); otherwise 0.
|
||||
//! - `WB_RGGBLevelsUncorrected` divided by its mean green is the as-shot gain: the renders of the 12 files match the
|
||||
//! camera JPEG at median ΔE76 2.2 to 7.6 (10.7 on the backlit WB2000 sample, 5.7 after registration).
|
||||
use super::{black_from_columns, white_from_data};
|
||||
use crate::unpack::{read_u16s, unpack_lsb, unpack_msb};
|
||||
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result};
|
||||
use lightcraft_geom::Orientation;
|
||||
use lightcraft_tiff::image::chunk_bytes;
|
||||
use lightcraft_tiff::{ByteOrder, Ifd, Tiff, tags as t};
|
||||
use rayon::prelude::*;
|
||||
|
||||
const SENSOR_AREAS: u16 = 0xa010;
|
||||
const ENCRYPTION_KEY: u16 = 0xa020;
|
||||
const WB_UNCORRECTED: u16 = 0xa021;
|
||||
const WB_BLACK: u16 = 0xa028;
|
||||
const RAW_DATA: u16 = 0xa048;
|
||||
|
||||
/// The private `Compression` values of the raw image IFD.
|
||||
const COMPRESSIONS: std::ops::RangeInclusive<u16> = 32769..=32773;
|
||||
|
||||
/// The maker-note IFD (offsets relative to the note's own start).
|
||||
fn maker_note(bytes: &[u8], tiff: &Tiff) -> Option<Ifd> {
|
||||
let e = tiff.exif()?.get(t::MAKER_NOTE)?;
|
||||
let opts = lightcraft_tiff::ParseOptions { max_ifds: 4, max_depth: 1, follow_children: false, ..Default::default() };
|
||||
lightcraft_tiff::parse_ifd_at(bytes, e.offset, tiff.order, e.offset, false, &opts).ok().map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// A key-offset vector: the entries of `tag` shifted by the maker note's key (`sign` is +1 or -1 times the key).
|
||||
fn keyed(mn: &Ifd, tag: u16, sign: i64) -> Option<Vec<i64>> {
|
||||
let key = mn.u64s(ENCRYPTION_KEY).filter(|k| k.len() == 11)?;
|
||||
let vals = mn.f64s(tag)?;
|
||||
// 32-bit entries: the stored value wraps modulo 2^32 (a negative number stored as a LONG)
|
||||
Some(vals.iter().enumerate().map(|(i, &v)| i64::from((v as i64).wrapping_add(sign * key[i % 11] as i64) as i32)).collect())
|
||||
}
|
||||
|
||||
/// The raw image's IFD: the one with a private compression value.
|
||||
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
|
||||
tiff.all_ifds().into_iter().find(|i| i.u16(t::COMPRESSION).is_some_and(|c| COMPRESSIONS.contains(&c)) && i.contains(t::IMAGE_WIDTH))
|
||||
}
|
||||
|
||||
/// How the samples of an IFD are stored, when uncompressed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Coding {
|
||||
/// One little-endian 16-bit word per sample.
|
||||
Words,
|
||||
/// 12-bit samples, two in three bytes: read MSB-first (the NX5, NX10 and NX11 files) or LSB-first (the later
|
||||
/// bodies' files); the file does not say which, the smoother image does ([`lsb_is_smoother`]).
|
||||
Packed12,
|
||||
}
|
||||
|
||||
fn coding(compression: u16, w: usize, h: usize, strip: u64) -> Option<Coding> {
|
||||
let n = (w as u64).checked_mul(h as u64)?;
|
||||
match compression {
|
||||
32769 if strip == n.checked_mul(2)? => Some(Coding::Words),
|
||||
32770 if w.is_multiple_of(2) && strip == n.checked_mul(12)? / 8 => Some(Coding::Packed12),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
let tiff = Tiff::parse(bytes)?;
|
||||
let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("SRW without IFDs".into()))?;
|
||||
let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("Samsung raw image not found".into()))?;
|
||||
let info = raw.image()?;
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
|
||||
let chunks = info.chunks(bytes.len() as u64);
|
||||
let strip: u64 = chunks.iter().map(|c| c.len).sum();
|
||||
let Some(coding) = coding(info.compression, w, h, strip).filter(|_| chunks.len() == 1) else {
|
||||
return Err(RawError::Unsupported(format!(
|
||||
"Samsung compressed raw (compression {}, {:.2} stored bits per sample)",
|
||||
info.compression,
|
||||
strip as f64 * 8.0 / n as f64
|
||||
)));
|
||||
};
|
||||
let data = if mode == Mode::Full {
|
||||
let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("SRW strip outside file".into()))?;
|
||||
let mut d = vec![0u16; n];
|
||||
match coding {
|
||||
Coding::Words => read_u16s(src, ByteOrder::Little, &mut d),
|
||||
Coding::Packed12 => {
|
||||
let stride = w / 2 * 3;
|
||||
let unpack: fn(&[u8], u32, &mut [u16]) = if lsb_is_smoother(src, w, h) { unpack_lsb } else { unpack_msb };
|
||||
d.par_chunks_mut(w).zip(src.par_chunks(stride)).for_each(|(row, s)| unpack(s, 12, row));
|
||||
}
|
||||
}
|
||||
RawData::U16(d)
|
||||
} else {
|
||||
RawData::U16(Vec::new())
|
||||
};
|
||||
|
||||
let mn = maker_note(bytes, &tiff);
|
||||
// the camera JPEG's framing (left, top, right, bottom), when it lies inside the stored array
|
||||
let active = mn
|
||||
.as_ref()
|
||||
.and_then(|m| m.u64s(SENSOR_AREAS))
|
||||
.filter(|v| v.len() == 8)
|
||||
.map(|v| (v[4] as usize, v[5] as usize, v[6] as usize, v[7] as usize))
|
||||
.filter(|&(l, tp, r, b)| l < r && tp < b && r <= w && b <= h)
|
||||
.map_or(Rect::new(0, 0, w, h), |(l, tp, r, b)| Rect::new(l, tp, r - l, b - tp));
|
||||
let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) {
|
||||
(Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() },
|
||||
_ => return Err(RawError::Unsupported("Samsung raw without a 2x2 CFAPattern".into())),
|
||||
};
|
||||
// saturation levels and black levels (R, G1, G2, B), in the file's own words
|
||||
let levels = mn.as_ref().and_then(|m| keyed(m, RAW_DATA, 1)).filter(|v| v.len() == 12);
|
||||
let RawData::U16(samples) = &data else { return Err(RawError::Unsupported("float SRW".into())) };
|
||||
// saturation: the file's level, or lower where the samples pile up (EX1 clips at 15882 of its stated 16383)
|
||||
let stated = levels.as_ref().filter(|v| v[4..].iter().all(|&x| x == v[4]) && (255..=65535).contains(&v[4])).map(|v| v[4] as f32);
|
||||
let bits = stated.map_or((info.bits() as u32).clamp(8, 16), |s| 32 - (s as u32).leading_zeros());
|
||||
let plateau = (!samples.is_empty()).then(|| white_from_data(&active_rows(samples, w, active), bits));
|
||||
let white = match (stated, plateau) {
|
||||
(Some(s), Some(p)) => s.min(p),
|
||||
(Some(s), None) => s,
|
||||
(None, Some(p)) => p,
|
||||
(None, None) => 4095.0,
|
||||
};
|
||||
let tagged_black =
|
||||
mn.as_ref().and_then(|m| keyed(m, WB_BLACK, -1)).filter(|v| v.len() == 4 && v.iter().all(|&x| (0..=u16::MAX as i64).contains(&x)));
|
||||
let black = match tagged_black {
|
||||
Some(v) if v.iter().any(|&x| x > 0) => {
|
||||
// RGGB-ordered levels -> per CFA position at the active area origin
|
||||
let a = cfa.shifted(active.x, active.y);
|
||||
let values = a.pattern.iter().map(|&c| [v[0], (v[1] + v[2]) / 2, v[3]][c as usize] as f32).collect();
|
||||
BlackLevel { repeat_rows: 2, repeat_cols: 2, values, ..Default::default() }
|
||||
}
|
||||
// the file states no black level: measure the masked band around the active area, when there is one
|
||||
_ => masked_black(samples, w, h, active, white),
|
||||
};
|
||||
// as-shot white balance: gains with the greens near 4096 (unity)
|
||||
let wb = mn.as_ref().and_then(|m| keyed(m, WB_UNCORRECTED, -1)).filter(|v| v.len() == 4 && v.iter().all(|&x| x > 0)).map(|v| {
|
||||
let g = (v[1] + v[2]) as f64 / 2.0;
|
||||
[(v[0] as f64 / g) as f32, 1.0, (v[3] as f64 / g) as f32]
|
||||
});
|
||||
let mut metadata = lightcraft_meta::from_tiff(&tiff);
|
||||
let crop = picture_crop(&tiff, active);
|
||||
metadata.width = Some(crop.width as u32);
|
||||
metadata.height = Some(crop.height as u32);
|
||||
let img = RawImage {
|
||||
format: RawFormat::Srw,
|
||||
width: w,
|
||||
height: h,
|
||||
cpp: 1,
|
||||
data,
|
||||
cfa: Some(cfa),
|
||||
bits,
|
||||
black,
|
||||
white: vec![white],
|
||||
active_area: active,
|
||||
crop,
|
||||
orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)),
|
||||
color: ColorData::default(),
|
||||
wb_multipliers: wb,
|
||||
linearized: false,
|
||||
opcodes: OpcodeLists::default(),
|
||||
metadata,
|
||||
};
|
||||
img.validate_for(mode)?;
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// Which bit order unpacks 12-bit samples of `w` x `h` (in `src`, `w / 2 * 3` bytes per row) into the image: in
|
||||
/// the right order neighbouring samples of one colour differ by little, in the wrong one by about the whole range.
|
||||
/// Compares the summed absolute differences of samples two apart over 64 rows from the middle of the frame.
|
||||
fn lsb_is_smoother(src: &[u8], w: usize, h: usize) -> bool {
|
||||
let stride = w / 2 * 3;
|
||||
let (mut msb, mut lsb) = (0u64, 0u64);
|
||||
let (mut a, mut b) = (vec![0u16; w], vec![0u16; w]);
|
||||
for y in (h / 5..h * 4 / 5).step_by((h * 3 / 5 / 64).max(1)) {
|
||||
let Some(row) = y.checked_mul(stride).and_then(|s| src.get(s..s + stride)) else { break };
|
||||
unpack_msb(row, 12, &mut a);
|
||||
unpack_lsb(row, 12, &mut b);
|
||||
let spread = |v: &[u16]| v.windows(3).map(|p| u64::from(p[0].abs_diff(p[2]))).sum::<u64>();
|
||||
msb += spread(&a);
|
||||
lsb += spread(&b);
|
||||
}
|
||||
lsb < msb
|
||||
}
|
||||
|
||||
/// The default crop: the whole framing, or, when the Exif picture size is the framing cut in one direction only
|
||||
/// (a 16:9 picture from a 4:3 sensor), the centred part of that size.
|
||||
fn picture_crop(tiff: &Tiff, active: Rect) -> Rect {
|
||||
let whole = Rect::new(0, 0, active.width, active.height);
|
||||
let Some(exif) = tiff.exif() else { return whole };
|
||||
let (Some(pw), Some(ph)) = (exif.u32(t::PIXEL_X_DIMENSION), exif.u32(t::PIXEL_Y_DIMENSION)) else { return whole };
|
||||
let (pw, ph) = (pw as usize, ph as usize);
|
||||
if pw == 0 || ph == 0 || pw > active.width || ph > active.height || (pw < active.width && ph < active.height) {
|
||||
return whole;
|
||||
}
|
||||
Rect::new((active.width - pw) / 2, (active.height - ph) / 2, pw, ph)
|
||||
}
|
||||
|
||||
/// Every third row of the active area (the saturation statistics need no more).
|
||||
fn active_rows(d: &[u16], w: usize, a: Rect) -> Vec<u16> {
|
||||
(a.y..a.y + a.height).step_by(3).filter_map(|y| d.get(y * w + a.x..y * w + a.x + a.width)).flatten().copied().collect()
|
||||
}
|
||||
|
||||
/// Black level from rows or columns outside the active area that are dark (mean below 1.5% of `white`): the
|
||||
/// longest run of dark lines on the right, else the left, below, above; at least four lines, the two outermost
|
||||
/// rows and columns of the array left out. Uniform 0 when there is no such run.
|
||||
fn masked_black(d: &[u16], w: usize, h: usize, a: Rect, white: f32) -> BlackLevel {
|
||||
if d.is_empty() {
|
||||
return BlackLevel::uniform(0.0);
|
||||
}
|
||||
let limit = f64::from(white) * 0.015;
|
||||
let col_mean = |x: usize| {
|
||||
(a.y..a.y + a.height).step_by(7).filter_map(|y| d.get(y * w + x)).map(|&v| f64::from(v)).sum::<f64>() / a.height.div_ceil(7) as f64
|
||||
};
|
||||
let row_mean =
|
||||
|y: usize| (a.x..a.x + a.width).step_by(7).filter_map(|x| d.get(y * w + x)).map(|&v| f64::from(v)).sum::<f64>() / a.width.div_ceil(7) as f64;
|
||||
// the longest run of consecutive dark lines within `lines`
|
||||
let longest = |lines: std::ops::Range<usize>, mean: &dyn Fn(usize) -> f64| {
|
||||
let (mut best, mut start) = (0..0, lines.start);
|
||||
for i in lines.clone() {
|
||||
if mean(i) >= limit {
|
||||
start = i + 1;
|
||||
} else if i + 1 - start > best.len() {
|
||||
best = start..i + 1;
|
||||
}
|
||||
}
|
||||
best
|
||||
};
|
||||
let cols = a.y..a.y + a.height;
|
||||
let rows = a.x..a.x + a.width;
|
||||
let right = longest(a.x + a.width..w.saturating_sub(2), &col_mean);
|
||||
let left = longest(2..a.x, &col_mean);
|
||||
let below = longest(a.y + a.height..h.saturating_sub(2), &row_mean);
|
||||
let above = longest(2..a.y, &row_mean);
|
||||
if right.len() >= 4 {
|
||||
black_from_columns(d, w, right, cols, a)
|
||||
} else if left.len() >= 4 {
|
||||
black_from_columns(d, w, left, cols, a)
|
||||
} else if below.len() >= 4 {
|
||||
black_from_columns(d, w, rows, below, a)
|
||||
} else if above.len() >= 4 {
|
||||
black_from_columns(d, w, rows, above, a)
|
||||
} else {
|
||||
BlackLevel::uniform(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{decode, probe, probe_info};
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
|
||||
|
||||
const KEY: [u32; 11] = [305, 72, 737, 456, 282, 307, 519, 724, 13, 505, 193];
|
||||
|
||||
/// A maker note in the Samsung layout: a big-endian IFD of LONG vectors, offsets relative to the note's start.
|
||||
fn note(entries: &[(u16, Vec<u32>)]) -> Vec<u8> {
|
||||
let mut head = Vec::new();
|
||||
let mut tail = Vec::new();
|
||||
head.extend_from_slice(&(entries.len() as u16).to_be_bytes());
|
||||
let values_at = 2 + 12 * entries.len() + 4;
|
||||
for (tag, v) in entries {
|
||||
head.extend_from_slice(&tag.to_be_bytes());
|
||||
head.extend_from_slice(&4u16.to_be_bytes());
|
||||
head.extend_from_slice(&(v.len() as u32).to_be_bytes());
|
||||
if v.len() == 1 {
|
||||
head.extend_from_slice(&v[0].to_be_bytes());
|
||||
} else {
|
||||
head.extend_from_slice(&((values_at + tail.len()) as u32).to_be_bytes());
|
||||
tail.extend(v.iter().flat_map(|x| x.to_be_bytes()));
|
||||
}
|
||||
}
|
||||
head.extend_from_slice(&[0; 4]);
|
||||
head.extend(tail);
|
||||
head
|
||||
}
|
||||
|
||||
/// Stored form of a vector the way the file keeps it: `plain + key` (`sign` 1) or `plain - key` (`sign` -1).
|
||||
fn stored(plain: &[i64], sign: i64) -> Vec<u32> {
|
||||
plain.iter().enumerate().map(|(i, &p)| (p + sign * i64::from(KEY[i % 11])) as u32).collect()
|
||||
}
|
||||
|
||||
fn pack12(v: &[u16]) -> Vec<u8> {
|
||||
v.chunks(2).flat_map(|p| [(p[0] >> 4) as u8, ((p[0] & 15) << 4) as u8 | (p[1] >> 8) as u8, p[1] as u8]).collect()
|
||||
}
|
||||
|
||||
fn le_words(v: &[u16]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
struct Spec {
|
||||
w: u32,
|
||||
h: u32,
|
||||
compression: u16,
|
||||
strip: Vec<u8>,
|
||||
pattern: Option<[u8; 4]>,
|
||||
note: Option<Vec<u8>>,
|
||||
picture: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
fn srw(s: Spec) -> Vec<u8> {
|
||||
let mut raw = IfdBuilder::new();
|
||||
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
|
||||
raw.set(t::IMAGE_WIDTH, Value::Long(vec![s.w]));
|
||||
raw.set(t::IMAGE_LENGTH, Value::Long(vec![s.h]));
|
||||
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12]));
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![s.compression]));
|
||||
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
|
||||
if let Some(p) = s.pattern {
|
||||
raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
|
||||
raw.set(t::CFA_PATTERN_EP, Value::Byte(p.to_vec()));
|
||||
}
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: s.h, strips: vec![s.strip] });
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("SAMSUNG".into()));
|
||||
ifd0.set(t::MODEL, Value::Ascii("NX0".into()));
|
||||
let mut exif = IfdBuilder::new();
|
||||
if let Some(n) = s.note {
|
||||
exif.set(t::MAKER_NOTE, Value::Undefined(n));
|
||||
}
|
||||
if let Some((pw, ph)) = s.picture {
|
||||
exif.set(t::PIXEL_X_DIMENSION, Value::Long(vec![pw]));
|
||||
exif.set(t::PIXEL_Y_DIMENSION, Value::Long(vec![ph]));
|
||||
}
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
ifd0.add_sub_ifd(raw);
|
||||
TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
/// The camera-stated description every test file carries: JPEG framing, white balance, levels.
|
||||
fn full_note(area: [u32; 4], white: i64, black: i64) -> Vec<u8> {
|
||||
let mut levels = vec![0i64; 4];
|
||||
levels.extend([white; 8]);
|
||||
note(&[
|
||||
(SENSOR_AREAS, vec![0, 0, 64, 32, area[0], area[1], area[2], area[3]]),
|
||||
(ENCRYPTION_KEY, KEY.to_vec()),
|
||||
(WB_UNCORRECTED, stored(&[8192, 4096, 4096, 6144], 1)),
|
||||
(WB_BLACK, stored(&[black; 4], 1)),
|
||||
(RAW_DATA, stored(&levels, -1)),
|
||||
])
|
||||
}
|
||||
|
||||
/// A smooth gradient with a little texture, `max` the first value that is not reached.
|
||||
fn ramp(w: u32, h: u32, max: u32) -> Vec<u16> {
|
||||
(0..w * h).map(|i| ((1000 + (i % w) * 7 + (i / w) * 5 + (i * 13) % 9) % max) as u16).collect()
|
||||
}
|
||||
|
||||
fn pack12_lsb(v: &[u16]) -> Vec<u8> {
|
||||
v.chunks(2).flat_map(|p| [p[0] as u8, ((p[0] >> 8) as u8) | ((p[1] & 15) << 4) as u8, (p[1] >> 4) as u8]).collect()
|
||||
}
|
||||
|
||||
/// Known answer: the samples, layout, framing, levels and white balance all come from the file's own words.
|
||||
#[test]
|
||||
fn packed_12_bit_samples_decode_with_the_maker_note() {
|
||||
let v = ramp(64, 32, 4096);
|
||||
let bytes = srw(Spec {
|
||||
w: 64,
|
||||
h: 32,
|
||||
compression: 32770,
|
||||
strip: pack12(&v),
|
||||
pattern: Some([2, 1, 1, 0]),
|
||||
note: Some(full_note([6, 4, 58, 30], 4095, 3)),
|
||||
picture: None,
|
||||
});
|
||||
assert_eq!(probe(&bytes), Some(RawFormat::Srw));
|
||||
let r = decode(&bytes).unwrap();
|
||||
assert_eq!((r.format, r.width, r.height, r.bits), (RawFormat::Srw, 64, 32, 12));
|
||||
assert_eq!(r.data, RawData::U16(v));
|
||||
assert_eq!(r.cfa.as_ref().unwrap().name(), "BGGR");
|
||||
assert_eq!(r.active_area, Rect::new(6, 4, 52, 26));
|
||||
assert_eq!(r.crop, Rect::new(0, 0, 52, 26));
|
||||
assert_eq!((r.black.mean(), r.white.clone()), (3.0, vec![4095.0]));
|
||||
assert_eq!(r.wb_multipliers, Some([2.0, 1.0, 1.5]));
|
||||
// headers only: everything but the samples
|
||||
let i = probe_info(&bytes).unwrap();
|
||||
assert_eq!((i.active_area, i.wb_multipliers, i.bits), (Rect::new(6, 4, 52, 26), Some([2.0, 1.0, 1.5]), 12));
|
||||
}
|
||||
|
||||
/// The later bodies pack the same 12-bit samples LSB-first; which of the two orders a file uses is found from
|
||||
/// the samples (the wrong order makes a noisy image), not from the model.
|
||||
#[test]
|
||||
fn lsb_first_packing_is_found_from_the_samples() {
|
||||
let v = ramp(64, 32, 4096);
|
||||
for (strip, name) in [(pack12(&v), "msb"), (pack12_lsb(&v), "lsb")] {
|
||||
let bytes = srw(Spec { w: 64, h: 32, compression: 32770, strip, pattern: Some([1, 0, 2, 1]), note: None, picture: None });
|
||||
assert_eq!(decode(&bytes).unwrap().data, RawData::U16(v.clone()), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture size narrower than the framing in one direction only (16:9 from a 4:3 sensor) is the framing's
|
||||
/// centre; any other size is ignored.
|
||||
#[test]
|
||||
fn the_exif_picture_size_centres_a_one_direction_crop() {
|
||||
let v = ramp(64, 32, 4096);
|
||||
let file = |picture| {
|
||||
srw(Spec {
|
||||
w: 64,
|
||||
h: 32,
|
||||
compression: 32770,
|
||||
strip: pack12(&v),
|
||||
pattern: Some([2, 1, 1, 0]),
|
||||
note: Some(full_note([6, 4, 58, 30], 4095, 0)),
|
||||
picture,
|
||||
})
|
||||
};
|
||||
// framing 52 x 26
|
||||
assert_eq!(decode(&file(Some((52, 18)))).unwrap().crop, Rect::new(0, 4, 52, 18));
|
||||
assert_eq!(decode(&file(Some((40, 26)))).unwrap().crop, Rect::new(6, 0, 40, 26));
|
||||
let r = decode(&file(Some((52, 18)))).unwrap();
|
||||
assert_eq!((r.metadata.width, r.metadata.height), (Some(52), Some(18)));
|
||||
for other in [None, Some((52, 26)), Some((40, 18)), Some((60, 18)), Some((52, 30)), Some((0, 18))] {
|
||||
assert_eq!(decode(&file(other)).unwrap().crop, Rect::new(0, 0, 52, 26), "{other:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 16-bit words are little-endian although the file is big-endian; a saturation plateau below the stated
|
||||
/// level wins; dark rows below the framing give the black level when the file states none.
|
||||
#[test]
|
||||
fn word_samples_decode_and_measure_what_the_file_does_not_say() {
|
||||
let (w, h) = (64u32, 40u32);
|
||||
let mut v: Vec<u16> = (0..w * h).map(|i| 3000 + (i * 91 % 5000) as u16).collect();
|
||||
// saturation: a plateau at 15882 inside the framing (rows 4..28), two rows of picture beyond it, dark rows 30..40
|
||||
for i in 0..150usize {
|
||||
v[(4 + i % 24) * 64 + 8 + i / 24] = 15882;
|
||||
}
|
||||
for x in 0..64 * 10 {
|
||||
v[30 * 64 + x] = 80;
|
||||
}
|
||||
let bytes = srw(Spec {
|
||||
w,
|
||||
h,
|
||||
compression: 32769,
|
||||
strip: le_words(&v),
|
||||
pattern: Some([0, 1, 1, 2]),
|
||||
note: Some(full_note([0, 4, 64, 28], 16383, 0)),
|
||||
picture: None,
|
||||
});
|
||||
let r = decode(&bytes).unwrap();
|
||||
assert_eq!(r.data, RawData::U16(v));
|
||||
assert_eq!(r.bits, 14);
|
||||
assert!((15800.0..=15882.0).contains(&r.white[0]), "{:?}", r.white);
|
||||
assert_eq!(r.black.mean(), 80.0);
|
||||
assert_eq!(r.cfa.unwrap().name(), "RGGB");
|
||||
}
|
||||
|
||||
/// Files whose strip is not exactly the uncompressed size (or that use another private value) are compressed
|
||||
/// codings: reported as such, still recognised as SRW.
|
||||
#[test]
|
||||
fn compressed_codings_are_reported_not_misread() {
|
||||
let plain = |compression, strip: Vec<u8>| {
|
||||
srw(Spec { w: 64, h: 32, compression, strip, pattern: Some([2, 1, 1, 0]), note: Some(full_note([0, 0, 64, 32], 4095, 0)), picture: None })
|
||||
};
|
||||
for bytes in [
|
||||
plain(32770, vec![7; 64 * 32 * 12 / 8 - 3]),
|
||||
plain(32772, vec![7; 4000]),
|
||||
plain(32773, pack12(&ramp(64, 32, 4096))),
|
||||
plain(32769, vec![0; 64 * 32]),
|
||||
] {
|
||||
assert_eq!(probe(&bytes), Some(RawFormat::Srw));
|
||||
let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") };
|
||||
assert!(why.contains("compressed"), "{why}");
|
||||
assert!(probe_info(&bytes).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a maker note the samples still decode: whole array, no white balance, depth from the TIFF tags.
|
||||
#[test]
|
||||
fn a_file_without_a_maker_note_decodes_with_defaults() {
|
||||
let v = ramp(64, 32, 4096);
|
||||
let bytes = srw(Spec { w: 64, h: 32, compression: 32770, strip: pack12(&v), pattern: Some([1, 0, 2, 1]), note: None, picture: None });
|
||||
let r = decode(&bytes).unwrap();
|
||||
assert_eq!(r.data, RawData::U16(v));
|
||||
assert_eq!((r.active_area, r.wb_multipliers, r.black.mean()), (Rect::new(0, 0, 64, 32), None, 0.0));
|
||||
assert_eq!(r.cfa.unwrap().name(), "GRBG");
|
||||
assert_eq!(r.bits, 12);
|
||||
}
|
||||
|
||||
/// A raw IFD that does not say how its mosaic is laid out is not guessed at.
|
||||
#[test]
|
||||
fn a_file_without_a_cfa_pattern_is_unsupported() {
|
||||
let bytes = srw(Spec { w: 64, h: 32, compression: 32770, strip: pack12(&ramp(64, 32, 4096)), pattern: None, note: None, picture: None });
|
||||
assert!(matches!(decode(&bytes), Err(RawError::Unsupported(w)) if w.contains("CFAPattern")));
|
||||
}
|
||||
|
||||
/// A framing rectangle outside the array, or reversed, is ignored; truncated files fail without panicking.
|
||||
#[test]
|
||||
fn hostile_framing_and_truncation_do_not_panic() {
|
||||
let v = ramp(64, 32, 4096);
|
||||
for area in [[0, 0, 65, 32], [10, 0, 10, 32], [0, 40, 64, 20]] {
|
||||
let bytes = srw(Spec {
|
||||
w: 64,
|
||||
h: 32,
|
||||
compression: 32770,
|
||||
strip: pack12(&v),
|
||||
pattern: Some([2, 1, 1, 0]),
|
||||
note: Some(full_note(area, 4095, 0)),
|
||||
picture: None,
|
||||
});
|
||||
assert_eq!(decode(&bytes).unwrap().active_area, Rect::new(0, 0, 64, 32));
|
||||
}
|
||||
let bytes = srw(Spec {
|
||||
w: 64,
|
||||
h: 32,
|
||||
compression: 32770,
|
||||
strip: pack12(&v),
|
||||
pattern: Some([2, 1, 1, 0]),
|
||||
note: Some(full_note([0, 0, 64, 32], 4095, 0)),
|
||||
picture: None,
|
||||
});
|
||||
for cut in [10, bytes.len() / 2, bytes.len() - 20] {
|
||||
let _ = decode(&bytes[..cut]);
|
||||
let _ = probe_info(&bytes[..cut]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -528,6 +528,63 @@ fn corpus_orf_cfa_patterns() {
|
|||
eprintln!("ORF CFA layouts checked on {seen} files");
|
||||
}
|
||||
|
||||
/// Samsung SRW (`crates/raw/src/vendor/srw.rs`): the 12-bit packings of the NX10 and NX20 generations (opposite bit
|
||||
/// orders, told apart from the samples) and the 16-bit words of the EX1 and WB2000 decode to the image of the camera's own
|
||||
/// JPEG, with the layout, framing, levels and white balance the file's maker note states.
|
||||
#[test]
|
||||
fn corpus_samsung_srw() {
|
||||
let dir = corpus_root().join("raw");
|
||||
// (file, bits, active area width x height, crop width x height, CFA layout, JPEG shows the whole active area)
|
||||
let cases = [
|
||||
("srw-samsung-nx10.srw", 12, (4592, 3056), (4592, 3056), "BGGR", true),
|
||||
("srw-samsung-nx20.srw", 12, (5472, 3648), (5472, 3648), "GRBG", true),
|
||||
("srw-samsung-ex1.srw", 14, (3648, 2736), (3648, 2736), "RGGB", true),
|
||||
];
|
||||
let mut seen = 0;
|
||||
for (name, bits, active, crop, cfa, whole) 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.format, RawFormat::Srw, "{name}");
|
||||
assert_eq!(img.bits, bits, "{name}: bits");
|
||||
assert_eq!((img.active_area.width, img.active_area.height), active, "{name}: active area");
|
||||
assert_eq!((img.crop.width, img.crop.height), crop, "{name}: crop");
|
||||
assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some(cfa), "{name}: CFA layout");
|
||||
let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance"));
|
||||
assert!(wb[0] > 1.0 && wb[1] == 1.0 && wb[2] > 0.5, "{name}: white balance {wb:?}");
|
||||
assert!(img.white_at(0) > (1u32 << bits) as f32 * 0.9 && img.white_at(0) <= ((1u32 << bits) - 1) as f32, "{name}: white {}", img.white_at(0));
|
||||
assert!(img.black.mean() < 8.0, "{name}: black {}", img.black.mean());
|
||||
// the mosaic inside the framing holds no marker or padding values above the saturation level
|
||||
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
|
||||
let a = img.active_area;
|
||||
let above = (a.y..a.y + a.height)
|
||||
.map(|y| d[y * img.width + a.x..y * img.width + a.x + a.width].iter().filter(|&&v| f32::from(v) > ((1u32 << bits) - 1) as f32).count())
|
||||
.sum::<usize>();
|
||||
assert_eq!(above, 0, "{name}: samples above the saturation level in the active area");
|
||||
if whole {
|
||||
let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no embedded preview"));
|
||||
let (gw, gh) = (24, 18);
|
||||
let (sites, jpeg) = (raw_sites(&img, gw, gh), jpeg_cells(&jpeg, gw, gh));
|
||||
let layout = img.cfa.clone().unwrap_or_else(|| panic!("{name}: no CFA"));
|
||||
let green: Vec<f64> = sites.iter().map(|s| (0..4).filter(|&i| layout.pattern[i] == 1).map(|i| s[i]).sum::<f64>()).collect();
|
||||
let rho = correlation(&ranks(&green), &ranks(&jpeg.iter().map(|j| j[1]).collect::<Vec<_>>()));
|
||||
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:26} {}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!("Samsung SRW checked on {seen} files");
|
||||
}
|
||||
|
||||
/// Reference sensor sums and position-weighted sums from black-box decoding, independent
|
||||
/// of this implementation. These cover complete images, padding, stripe boundaries and
|
||||
/// lossy block quantizer changes; the source files are CC0, fetched by `xtask corpus`.
|
||||
|
|
@ -598,6 +655,7 @@ fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() {
|
|||
"orf" => RawFormat::Orf,
|
||||
"pef" => RawFormat::Pef,
|
||||
"raf" => RawFormat::Raf,
|
||||
"srw" => RawFormat::Srw,
|
||||
"rw2" | "rwl" | "raw" => RawFormat::Rw2,
|
||||
_ => continue,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2 and Pentax PEF starting look
|
||||
|
||||
ARW, NEF, RW2, RAF, CR3, CR2 and PEF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
|
||||
ARW, NEF, RW2, RAF, CR3, CR2, PEF and SRW decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
|
||||
|
||||
## Colour and tone are separate
|
||||
|
||||
|
|
@ -85,6 +85,11 @@ An embedded JPEG with neither ICC nor EXIF metadata uses Nikon maker-note `Color
|
|||
|
||||
Synthetic tests cover crop bounds/CFA preservation, colour-space tags, fallback transfer decoding and metadata precedence. A private Nikon Z 8 sample reports an 8256 × 5504 default crop from an 8280 × 5520 sensor. No private sample or Adobe asset is included.
|
||||
|
||||
### Samsung SRW
|
||||
|
||||
Samsung SRW decoding (uncompressed files) supplies the as-shot white-balance multipliers from the maker note and no colour
|
||||
matrix, so SRW takes the same fit, gates and relative white balance.
|
||||
|
||||
### Canon CR2 and Pentax PEF
|
||||
|
||||
CR2 and PEF decode without a camera colour matrix too, but were left out of the fit until issue #310 (CR2 files opening
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
|
||||
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
|
||||
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones decode but no real JPEG XL DNG has verified them yet; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones decode but no real JPEG XL DNG has verified them yet; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) |
|
||||
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. |
|
||||
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
|
||||
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
|
||||
|
|
@ -295,7 +295,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
|
||||
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
|
||||
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
|
||||
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export but not rendered, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2 and Pentax PEF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
|
||||
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export but not rendered, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF and Samsung SRW get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
|
||||
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
|
||||
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
|
||||
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@
|
|||
belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.
|
||||
|
||||
### RAW decoding
|
||||
- Samsung SRW files without compression now open as raws: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1
|
||||
and WB2000. The compressed ones (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) still open from their
|
||||
camera JPEG. Photos already imported pick the change up on Reload.
|
||||
- Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF
|
||||
and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.
|
||||
- JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files);
|
||||
|
|
|
|||
|
|
@ -9,8 +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", "rwl", "raw", "pef", "heic", "jxl", "psd", "cube", "icc", "icm",
|
||||
"xmp",
|
||||
"mov", "tif", "tiff", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "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"];
|
||||
|
|
|
|||
|
|
@ -396,6 +396,10 @@ const RAW_SAMPLES: &[(&str, &str)] = &[
|
|||
("pef-pentax-k10d.pef", "https://raw.pixls.us/getfile.php/2239/nice/Pentax%20-%20K10D%20-%2012bit%2012bit%20compressed%20%283:2%29.PEF"),
|
||||
("pef-pentax-k3.pef", "https://raw.pixls.us/getfile.php/1075/nice/Pentax%20-%20K-3%20-%2014bit%20%283:2%29.PEF"),
|
||||
("pef-pentax-k5iis.pef", "https://raw.pixls.us/getfile.php/1198/nice/Pentax%20-%20K-5%20II%20s%20-%2014bit%20%283:2%29.PEF"),
|
||||
// Samsung SRW, one file per packing: 12-bit MSB-first (NX10), 12-bit LSB-first (NX20), 16-bit words (EX1).
|
||||
("srw-samsung-nx10.srw", "https://raw.pixls.us/getfile.php/5913/nice/Samsung%20-%20NX10%20-%2012bit%20%283:2%29.SRW"),
|
||||
("srw-samsung-nx20.srw", "https://raw.pixls.us/getfile.php/5370/nice/Samsung%20-%20NX20%20-%2012bit%20%283:2%29.SRW"),
|
||||
("srw-samsung-ex1.srw", "https://raw.pixls.us/getfile.php/1204/nice/Samsung%20-%20EX1%20-%2014bit%20%284:3%29.SRW"),
|
||||
(
|
||||
"raf-fuji-xa2-12bit-bayer.raf",
|
||||
"https://raw.pixls.us/getfile.php/2883/nice/Fujifilm%20-%20X-A2%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF",
|
||||
|
|
|
|||
Loading…
Reference in New Issue