mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
LR-IMP-FORMATS: decode Epson ERF (12-bit 16-byte blocks; WB from the preview's neutrals)
Sources: TIFF 6.0 and own black-box analysis of the three CC0 samples; white-balance estimate from the embedded preview: own design. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
76b5377868
commit
a6d048be8d
+4
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Epson ERF decoded, 3 bodies verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
|
||||
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
|
||||
@@ -114,6 +114,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-12: Epson ERF (R-D1, R-D1s, R-D1x) decodes from sensor data, white balance measured from each
|
||||
file's preview; 3 bodies verified on CC0 files. 238 models verified; headline numbers unchanged.
|
||||
- 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a
|
||||
preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files
|
||||
pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models
|
||||
@@ -161,6 +163,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (238); headline numbers unchanged |
|
||||
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged |
|
||||
| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points |
|
||||
| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away |
|
||||
|
||||
@@ -313,7 +313,7 @@ pub struct Analysis {
|
||||
/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera:
|
||||
/// [`relative_wb_camera`].
|
||||
pub fn relative_wb_format(format: &str) -> bool {
|
||||
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f))
|
||||
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF"].iter().any(|f| format.eq_ignore_ascii_case(f))
|
||||
}
|
||||
|
||||
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
|
||||
@@ -564,6 +564,7 @@ mod edited_tests {
|
||||
("a.cr2", "CR2", true),
|
||||
("a.pef", "pef", true),
|
||||
("a.orf", "ORF", true),
|
||||
("a.erf", "ERF", 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");
|
||||
|
||||
@@ -58,6 +58,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
| RawFormat::Pef
|
||||
| RawFormat::Srw
|
||||
| RawFormat::Orf
|
||||
| RawFormat::Erf
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1885,6 +1886,7 @@ mod tests {
|
||||
RawFormat::Pef,
|
||||
RawFormat::Srw,
|
||||
RawFormat::Orf,
|
||||
RawFormat::Erf,
|
||||
];
|
||||
assert!(local.into_iter().all(file_local_look));
|
||||
// DNG carries its own colour model
|
||||
|
||||
@@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh
|
||||
let flat: Vec<f32> = tgt.iter().flatten().copied().collect();
|
||||
let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default())
|
||||
.unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole));
|
||||
for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) {
|
||||
*d = [v[0], v[1], v[2]];
|
||||
for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) {
|
||||
*d = *v;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ pub mod spectral;
|
||||
mod tiffraw;
|
||||
mod unpack;
|
||||
mod vendor;
|
||||
mod wb_estimate;
|
||||
|
||||
pub use demosaic::{Method, demosaic};
|
||||
pub use dngwrite::{DngCompression, DngWriteOptions, write_dng};
|
||||
@@ -110,6 +111,8 @@ pub enum RawFormat {
|
||||
CfaTiff,
|
||||
/// Kodak DCS520C TIFF: a two-component lossless JPEG CFA sub-IFD with a response curve (`vendor::kodak`).
|
||||
KodakDcs,
|
||||
/// Epson ERF: a CFA sub-IFD of 12-bit samples in 16-byte blocks (`vendor::erf`).
|
||||
Erf,
|
||||
/// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …).
|
||||
OtherTiff,
|
||||
}
|
||||
@@ -131,6 +134,7 @@ impl RawFormat {
|
||||
| RawFormat::CfaTiff
|
||||
| RawFormat::Srw
|
||||
| RawFormat::KodakDcs
|
||||
| RawFormat::Erf
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -202,6 +206,9 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
|
||||
if make.starts_with("SAMSUNG") && samsung_raw {
|
||||
return Some(RawFormat::Srw);
|
||||
}
|
||||
if vendor::erf::raw_ifd(&t, bytes.len()).is_some() {
|
||||
return Some(RawFormat::Erf);
|
||||
}
|
||||
if vendor::kodak::raw_ifd(&t, bytes).is_some() {
|
||||
return Some(RawFormat::KodakDcs);
|
||||
}
|
||||
@@ -370,6 +377,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff).map(lift_clipped),
|
||||
RawFormat::Srw => vendor::srw::decode(bytes, mode),
|
||||
RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode),
|
||||
RawFormat::Erf => vendor::erf::decode(bytes, mode),
|
||||
RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))),
|
||||
other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))),
|
||||
}
|
||||
|
||||
@@ -178,8 +178,8 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
.filter(|s| is_dct_jpeg(s) || is_jxl(s))
|
||||
.max_by_key(|s| s.len())
|
||||
.map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() });
|
||||
// a TIFF raw we can't decode whose preview no tag points to (Leaf MOS, Epson ERF)
|
||||
best.or_else(|| format.filter(|f| !f.is_supported()).and_then(|_| scan_for_jpeg(bytes)))
|
||||
// a TIFF raw whose preview no tag points to: one we can't decode (Leaf MOS), or Epson ERF (in its maker note)
|
||||
best.or_else(|| format.filter(|f| !f.is_supported() || *f == crate::RawFormat::Erf).and_then(|_| scan_for_jpeg(bytes)))
|
||||
}
|
||||
|
||||
/// The end of the DCT JPEG whose SOI is at `soi` (exclusive index after its EOI) and its component count,
|
||||
|
||||
@@ -227,7 +227,7 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan {
|
||||
return plan;
|
||||
}
|
||||
// (the Make is usually in the first kilobytes: a NEF, PEF or ORF is known before the rest is read)
|
||||
if matches!(crate::probe(bytes), Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef)) {
|
||||
if matches!(crate::probe(bytes), Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf)) {
|
||||
return HeaderPlan::Whole("its header probe reads samples");
|
||||
}
|
||||
let Ok(tiff) = Tiff::parse(bytes) else { return HeaderPlan::Whole("TIFF structure") };
|
||||
@@ -260,7 +260,9 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan {
|
||||
holes.insert(off.saturating_add(HEAD), len);
|
||||
}
|
||||
finish(len, loaded, &holes, &meta, || match crate::probe(bytes) {
|
||||
Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef) => HeaderPlan::Whole("its header probe reads samples"),
|
||||
Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf) => {
|
||||
HeaderPlan::Whole("its header probe reads samples")
|
||||
}
|
||||
Some(f) if f.is_supported() => HeaderPlan::Ready,
|
||||
_ => HeaderPlan::Whole("not a supported raw"),
|
||||
})
|
||||
|
||||
@@ -225,7 +225,7 @@ fn jxl_chunk_check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp:
|
||||
}
|
||||
|
||||
/// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels).
|
||||
fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
|
||||
pub(crate) fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
|
||||
use zune_core::bytestream::ZCursor;
|
||||
use zune_core::colorspace::ColorSpace;
|
||||
use zune_core::options::DecoderOptions;
|
||||
|
||||
Vendored
+197
@@ -0,0 +1,197 @@
|
||||
//! Epson ERF (R-D1, R-D1s, R-D1x): a big-endian TIFF whose `SubIFDs` child is a 12-bit CFA image in 16-byte blocks.
|
||||
//!
|
||||
//! Sources: TIFF 6.0 (container, `CFARepeatPatternDim` / `CFAPattern`) and our own black-box analysis of the three
|
||||
//! CC0 ERF samples on raw.pixls.us (one per body); every number below was measured on them.
|
||||
//!
|
||||
//! - IFD0 is a 160 x 120 RGB thumbnail; its `SubIFDs` child is the raw: 3040 x 2024, 12 bits, `Compression` 32769,
|
||||
//! photometric CFA, one strip of exactly `width * 8 / 5` bytes per row. Byte 15 of every 16 is zero in all rows:
|
||||
//! a row is 16-byte blocks, each 15 bytes of ten 12-bit samples, most significant bits first (`b0 << 4 | b1 >> 4`,
|
||||
//! `(b1 & 15) << 8 | b2`), then a pad byte. Read the other way (least significant first, as the Olympus blocks are)
|
||||
//! the frame is noise: the mean step between same-colour neighbours is 150..199 codes against 11..18.
|
||||
//! - `CFAPattern` `00 01 01 02`: RGGB.
|
||||
//! - The maker note (`EPSON\0\x01\0`, then a big-endian IFD with offsets from the file start) holds four black levels
|
||||
//! as `u32` in tag `0x0401` (61..65; in Bayer-cell order), above which the darkest 0.01% of samples sit; `0x020b` x
|
||||
//! `0x020c` is the picture size, 3008 x 2000. Registered against the 640 x 424 preview JPEG (correlation of the
|
||||
//! green channel over every even offset), that picture starts at column 20, row 12 in all three files.
|
||||
//! - White: the saturation plateau (4095 on the clipped R-D1x frame), else 4095.
|
||||
//! - No maker-note tag tracks the as-shot white balance across the three files, so it is measured from the
|
||||
//! preview's neutral pixels ([`crate::wb_estimate`]); on the R-D1s sample (54,000 neutral preview pixels) that
|
||||
//! gives red 2.68 and blue 1.31.
|
||||
//! - The preview is the maker note's tag `0x0280`, a JPEG whose first byte is stored as `ee` (found by the
|
||||
//! structural scan in `preview::scan_for_jpeg`).
|
||||
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::{Ifd, Tiff, tags as t};
|
||||
|
||||
/// Epson's private compression code for the 16-byte block layout.
|
||||
const BLOCKS16: u16 = 32769;
|
||||
/// Maker-note tags.
|
||||
const MN_BLACK: u16 = 0x0401;
|
||||
const MN_WIDTH: u16 = 0x020b;
|
||||
const MN_HEIGHT: u16 = 0x020c;
|
||||
/// Where the picture starts in the sensor frame (measured, see the module notes).
|
||||
const CROP_ORIGIN: (usize, usize) = (20, 12);
|
||||
|
||||
/// The raw IFD of an Epson ERF: CFA, 12 bits, compression 32769, one strip of 16-byte blocks of ten samples.
|
||||
pub(crate) fn raw_ifd(tiff: &Tiff, len: usize) -> Option<&Ifd> {
|
||||
let make = tiff.find(t::MAKE).and_then(|e| e.value.as_str())?;
|
||||
if !make.to_ascii_uppercase().contains("EPSON") {
|
||||
return None;
|
||||
}
|
||||
tiff.all_ifds().into_iter().find(|i| {
|
||||
let Ok(info) = i.image() else { return false };
|
||||
let (w, h) = (info.width as u64, info.height as u64);
|
||||
i.u16(t::PHOTOMETRIC) == Some(t::photometric::CFA)
|
||||
&& i.u16(t::COMPRESSION) == Some(BLOCKS16)
|
||||
&& i.u16(t::BITS_PER_SAMPLE) == Some(12)
|
||||
&& w > 0
|
||||
&& w % 10 == 0
|
||||
&& matches!(info.chunks(len as u64).as_slice(), [c] if Some(c.len) == (w * 8 / 5).checked_mul(h))
|
||||
})
|
||||
}
|
||||
|
||||
/// The maker-note IFD (`EPSON\0\x01\0` then an IFD in the file's byte order, offsets from the file start).
|
||||
fn maker_note(bytes: &[u8], tiff: &Tiff) -> Option<Ifd> {
|
||||
let e = tiff.exif()?.get(t::MAKER_NOTE)?;
|
||||
let start = usize::try_from(e.offset).ok()?;
|
||||
if !bytes.get(start..)?.starts_with(b"EPSON\0") {
|
||||
return None;
|
||||
}
|
||||
let opts = lightcraft_tiff::ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() };
|
||||
lightcraft_tiff::parse_ifd_at(bytes, e.offset.checked_add(8)?, tiff.order, 0, false, &opts).ok().map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Unpack a strip of 16-byte blocks (15 bytes of ten MSB-first 12-bit samples, one pad byte).
|
||||
fn unpack(src: &[u8], out: &mut [u16]) {
|
||||
let (blocks, _) = src.as_chunks::<16>();
|
||||
let (pixels, _) = out.as_chunks_mut::<10>();
|
||||
for (b, px) in blocks.iter().zip(pixels) {
|
||||
crate::unpack::unpack_msb(&b[..15], 12, px);
|
||||
}
|
||||
}
|
||||
|
||||
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("Erf TIFF without IFDs".into()))?;
|
||||
let raw = raw_ifd(&tiff, bytes.len()).ok_or_else(|| RawError::Unsupported("Erf 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 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("Erf raw without a 2x2 CFAPattern".into())),
|
||||
};
|
||||
let mn = maker_note(bytes, &tiff);
|
||||
let black = match mn.as_ref().and_then(|m| m.u64s(MN_BLACK)).as_deref() {
|
||||
Some(v @ [_, _, _, _]) if v.iter().all(|&b| b < 4095) => {
|
||||
BlackLevel { repeat_rows: 2, repeat_cols: 2, values: v.iter().map(|&b| b as f32).collect(), delta_h: vec![], delta_v: vec![] }
|
||||
}
|
||||
_ => BlackLevel::uniform(0.0),
|
||||
};
|
||||
let size = mn.as_ref().and_then(|m| Some((m.u32(MN_WIDTH)? as usize, m.u32(MN_HEIGHT)? as usize)));
|
||||
let crop = match size {
|
||||
Some((cw, ch)) if cw > 0 && ch > 0 && CROP_ORIGIN.0 + cw <= w && CROP_ORIGIN.1 + ch <= h => Rect::new(CROP_ORIGIN.0, CROP_ORIGIN.1, cw, ch),
|
||||
_ => Rect::new(0, 0, w, h),
|
||||
};
|
||||
// the white balance is measured from the samples, so the header probe reads them too
|
||||
let chunks = info.chunks(bytes.len() as u64);
|
||||
let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Erf strip outside file".into()))?;
|
||||
let mut d = vec![0u16; n];
|
||||
unpack(src, &mut d);
|
||||
let white = super::white_from_data(&d, 12);
|
||||
let wb = crate::wb_estimate::as_shot(bytes, &crate::wb_estimate::Frame { data: &d, width: w, crop, cfa: &cfa, black: &black, white });
|
||||
let data = if mode == Mode::Full { RawData::U16(d) } else { RawData::U16(Vec::new()) };
|
||||
let mut metadata = lightcraft_meta::from_tiff(&tiff);
|
||||
metadata.width = Some(crop.width as u32);
|
||||
metadata.height = Some(crop.height as u32);
|
||||
let img = RawImage {
|
||||
format: RawFormat::Erf,
|
||||
width: w,
|
||||
height: h,
|
||||
cpp: 1,
|
||||
data,
|
||||
cfa: Some(cfa),
|
||||
bits: 12,
|
||||
black,
|
||||
white: vec![white],
|
||||
active_area: Rect::new(0, 0, w, h),
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
|
||||
|
||||
/// Pack ten-sample groups into 16-byte blocks, MSB first, zero pad.
|
||||
fn pack(px: &[u16]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for g in px.chunks(10) {
|
||||
for p in g.chunks(2) {
|
||||
let (a, b) = (p[0], p[1]);
|
||||
out.extend_from_slice(&[(a >> 4) as u8, ((a & 15) << 4) as u8 | (b >> 8) as u8, b as u8]);
|
||||
}
|
||||
out.push(0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sample(w: u32, h: u32, px: &[u16]) -> 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![w]));
|
||||
raw.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||||
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12]));
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![BLOCKS16]));
|
||||
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
|
||||
raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
|
||||
raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![pack(px)] });
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("SEIKO EPSON CORP.".into()));
|
||||
ifd0.set(t::MODEL, Value::Ascii("R-D1".into()));
|
||||
ifd0.set(t::ORIENTATION, Value::Short(vec![1]));
|
||||
ifd0.add_sub_ifd(raw);
|
||||
TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_the_block_layout() {
|
||||
let (w, h) = (20u32, 6u32);
|
||||
let px: Vec<u16> = (0..w * h).map(|i| (i * 37 % 4096) as u16).collect();
|
||||
let f = sample(w, h, &px);
|
||||
assert_eq!(crate::probe(&f), Some(RawFormat::Erf));
|
||||
let r = crate::decode(&f).unwrap();
|
||||
assert_eq!(r.data, RawData::U16(px));
|
||||
assert_eq!(r.cfa.as_ref().map(Cfa::name).as_deref(), Some("RGGB"));
|
||||
assert!(r.wb_multipliers.is_some());
|
||||
assert_eq!(crate::probe_info(&f).unwrap(), r.info());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_files_never_panic() {
|
||||
let px: Vec<u16> = (0..200).map(|i| (i * 13 % 4096) as u16).collect();
|
||||
let f = sample(20, 10, &px);
|
||||
for n in (0..f.len()).step_by(3) {
|
||||
let _ = crate::decode(&f[..n]);
|
||||
}
|
||||
for i in 0..f.len() {
|
||||
for v in [0u8, 0xff] {
|
||||
let mut g = f.clone();
|
||||
g[i] = v;
|
||||
let _ = crate::decode(&g);
|
||||
let _ = crate::probe_info(&g);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -10,6 +10,7 @@ pub mod cr2;
|
||||
pub mod cr3;
|
||||
pub mod crx;
|
||||
pub(crate) mod crx_wavelet;
|
||||
pub mod erf;
|
||||
pub mod kodak;
|
||||
pub mod nef;
|
||||
pub mod nefc;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! As-shot white balance for raws that record none we can read: measured from the file's own embedded preview.
|
||||
//!
|
||||
//! Sources: own design. The camera rendered its preview with the as-shot white balance, so pixels the preview shows
|
||||
//! as neutral grey were neutral for that white balance. The mosaic is averaged per colour over the preview's pixel
|
||||
//! grid (inside the default crop); over the preview pixels whose channels lie within a few codes of each other
|
||||
//! (not near black, not near clipping, no clipped sensor sample under them) the median green-over-red and
|
||||
//! green-over-blue ratios are the gains. A colour matrix maps neutral to neutral, so its absence doesn't bias the
|
||||
//! result. Without enough neutral pixels the mean of the unclipped mosaic is used instead (grey world).
|
||||
use crate::{BlackLevel, Cfa, Rect};
|
||||
|
||||
/// Preview pixels whose largest and smallest channel differ by less than this (8-bit codes) count as neutral.
|
||||
const NEUTRAL_SPREAD: u8 = 12;
|
||||
/// At least this many neutral preview pixels are needed.
|
||||
const MIN_NEUTRAL: usize = 64;
|
||||
/// Previews larger than this (pixels) are not decoded for the estimate.
|
||||
const MAX_PREVIEW_PIXELS: usize = 1 << 24;
|
||||
|
||||
/// The raw frame the estimate looks at.
|
||||
pub(crate) struct Frame<'a> {
|
||||
pub data: &'a [u16],
|
||||
pub width: usize,
|
||||
pub crop: Rect,
|
||||
pub cfa: &'a Cfa,
|
||||
pub black: &'a BlackLevel,
|
||||
pub white: f32,
|
||||
}
|
||||
|
||||
/// Gains `[R/G, 1, B/G]`: from the neutral pixels of the embedded preview, else grey world. `None` for an empty or
|
||||
/// degenerate frame.
|
||||
pub(crate) fn as_shot(bytes: &[u8], f: &Frame) -> Option<[f32; 3]> {
|
||||
from_preview(bytes, f).or_else(|| grey_world(f))
|
||||
}
|
||||
|
||||
/// Per preview-grid cell means of R, G, B (black subtracted) and whether a clipped sample lies in the cell.
|
||||
fn cell_means(f: &Frame, pw: usize, ph: usize) -> Option<Vec<([f64; 3], bool)>> {
|
||||
let c = f.crop;
|
||||
if pw == 0 || ph == 0 || c.width < pw || c.height < ph || f.width == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut cells = vec![([0f64; 3], [0u32; 3], false); pw.checked_mul(ph)?];
|
||||
for y in c.y..c.y.saturating_add(c.height) {
|
||||
let row = f.data.get(y.checked_mul(f.width)?..y.checked_mul(f.width)?.checked_add(f.width)?)?;
|
||||
let cy = (y - c.y) * ph / c.height;
|
||||
for x in c.x..c.x.saturating_add(c.width).min(f.width) {
|
||||
let v = f32::from(*row.get(x)?);
|
||||
let cx = (x - c.x) * pw / c.width;
|
||||
let col = usize::from(f.cfa.color_at(x, y)).min(2);
|
||||
let cell = cells.get_mut(cy * pw + cx)?;
|
||||
if v >= f.white * 0.98 {
|
||||
cell.2 = true;
|
||||
}
|
||||
cell.0[col] += f64::from(v - f.black.at(x, y, 0, 1));
|
||||
cell.1[col] += 1;
|
||||
}
|
||||
}
|
||||
Some(cells.into_iter().map(|(s, n, clip)| ([0, 1, 2].map(|k| if n[k] > 0 { s[k] / f64::from(n[k]) } else { 0.0 }), clip)).collect())
|
||||
}
|
||||
|
||||
fn median(v: &mut [f64]) -> Option<f64> {
|
||||
if v.is_empty() {
|
||||
return None;
|
||||
}
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.get(v.len() / 2).copied()
|
||||
}
|
||||
|
||||
fn from_preview(bytes: &[u8], f: &Frame) -> Option<[f32; 3]> {
|
||||
let jpeg = crate::preview::embedded_preview(bytes)?;
|
||||
let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3).ok()?;
|
||||
if ch != 3 || pw.checked_mul(ph)? > MAX_PREVIEW_PIXELS {
|
||||
return None;
|
||||
}
|
||||
// the preview must show the crop: aspect ratios within 3%
|
||||
let (ra, pa) = (f.crop.width as f64 / f.crop.height.max(1) as f64, pw as f64 / ph as f64);
|
||||
if (ra / pa - 1.0).abs() > 0.03 {
|
||||
return None;
|
||||
}
|
||||
let cells = cell_means(f, pw, ph)?;
|
||||
let (mut gr, mut gb) = (Vec::new(), Vec::new());
|
||||
for (i, (m, clip)) in cells.iter().enumerate() {
|
||||
let p = px.get(i * 3..i * 3 + 3)?;
|
||||
let (lo, hi) = (p.iter().min().copied().unwrap_or(0), p.iter().max().copied().unwrap_or(255));
|
||||
if *clip || lo < 30 || hi > 240 || hi - lo >= NEUTRAL_SPREAD || m.iter().any(|v| *v <= 1.0) {
|
||||
continue;
|
||||
}
|
||||
gr.push(m[1] / m[0]);
|
||||
gb.push(m[1] / m[2]);
|
||||
}
|
||||
if gr.len() < MIN_NEUTRAL {
|
||||
return None;
|
||||
}
|
||||
gains(median(&mut gr)?, median(&mut gb)?)
|
||||
}
|
||||
|
||||
fn gains(gr: f64, gb: f64) -> Option<[f32; 3]> {
|
||||
let ok = |g: f64| g.is_finite() && (0.1..=10.0).contains(&g);
|
||||
(ok(gr) && ok(gb)).then_some([gr as f32, 1.0, gb as f32])
|
||||
}
|
||||
|
||||
fn grey_world(f: &Frame) -> Option<[f32; 3]> {
|
||||
let c = f.crop;
|
||||
let (mut s, mut n) = ([0f64; 3], [0u64; 3]);
|
||||
for y in c.y..c.y.saturating_add(c.height) {
|
||||
for x in c.x..c.x.saturating_add(c.width).min(f.width) {
|
||||
let Some(&v) = f.data.get(y.checked_mul(f.width)?.checked_add(x)?) else { continue };
|
||||
let v = f32::from(v);
|
||||
if v >= f.white * 0.98 {
|
||||
continue;
|
||||
}
|
||||
let k = usize::from(f.cfa.color_at(x, y)).min(2);
|
||||
s[k] += f64::from((v - f.black.at(x, y, 0, 1)).max(0.0));
|
||||
n[k] += 1;
|
||||
}
|
||||
}
|
||||
if n.contains(&0) {
|
||||
return None;
|
||||
}
|
||||
let m = [0, 1, 2].map(|k| s[k] / n[k] as f64);
|
||||
if m.iter().any(|v| *v <= 0.0) {
|
||||
return None;
|
||||
}
|
||||
gains(m[1] / m[0], m[1] / m[2])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grey_world_balances_a_tinted_flat_frame() {
|
||||
// RGGB, R at 400, G at 800, B at 200 above a black of 100
|
||||
let (w, h) = (8, 8);
|
||||
let d: Vec<u16> = (0..w * h).map(|i| [500, 900, 900, 300][(i / w % 2) * 2 + i % 2]).collect();
|
||||
let cfa = Cfa::bayer_static("RGGB");
|
||||
let black = BlackLevel::uniform(100.0);
|
||||
let f = Frame { data: &d, width: w, crop: Rect::new(0, 0, w, h), cfa: &cfa, black: &black, white: 4095.0 };
|
||||
assert_eq!(as_shot(b"not a raw", &f), Some([2.0, 1.0, 4.0]));
|
||||
// degenerate: everything clipped
|
||||
let d = vec![4095u16; w * h];
|
||||
let f = Frame { data: &d, ..f };
|
||||
assert_eq!(as_shot(b"", &f), None);
|
||||
// a crop outside the data never panics
|
||||
let f = Frame { crop: Rect::new(4, 4, 100, 100), ..f };
|
||||
let _ = as_shot(b"", &f);
|
||||
}
|
||||
}
|
||||
@@ -1133,3 +1133,36 @@ fn fuji_embedded_lens_tables_follow_the_validated_models() {
|
||||
}
|
||||
eprintln!("Fuji lens tables: {checked} RAFs, {warped} warps, {vignetted} vignette gains");
|
||||
}
|
||||
|
||||
/// Epson ERF (`crates/raw/src/vendor/erf.rs`): the 16-byte block strip decodes to a smooth 3040 x 2024 RGGB
|
||||
/// mosaic with the maker note's black levels, the 3008 x 2000 crop and a white balance measured from the
|
||||
/// preview's neutral pixels. Skips files that are absent.
|
||||
#[test]
|
||||
fn corpus_epson_erf() {
|
||||
let root = corpus_root().join("raw");
|
||||
let mut checked = 0;
|
||||
for name in ["erf-epson-rd1.erf", "erf-epson-rd1s.erf", "erf-epson-rd1x.erf"] {
|
||||
let Ok(bytes) = std::fs::read(root.join(name)) else {
|
||||
eprintln!("skip: {name} absent");
|
||||
continue;
|
||||
};
|
||||
assert_eq!(probe(&bytes), Some(RawFormat::Erf), "{name}");
|
||||
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!((img.width, img.height, img.bits), (3040, 2024, 12), "{name}");
|
||||
assert_eq!((img.crop.x, img.crop.y, img.crop.width, img.crop.height), (20, 12, 3008, 2000), "{name}");
|
||||
assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some("RGGB"), "{name}");
|
||||
assert!(img.black.values.len() == 4 && img.black.values.iter().all(|b| (55.0..=70.0).contains(b)), "{name}: {:?}", img.black.values);
|
||||
let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no white balance"));
|
||||
eprintln!("{name}: wb {wb:?}");
|
||||
if name == "erf-epson-rd1s.erf" {
|
||||
assert!((wb[0] - 2.68).abs() < 0.1 && (wb[2] - 1.31).abs() < 0.1, "{name}: {wb:?}");
|
||||
}
|
||||
// same-colour neighbours differ little: the bit order is right
|
||||
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
|
||||
let step: f64 = d.chunks(3040).take(400).flat_map(|r| r.windows(3).map(|w| f64::from(w[0].abs_diff(w[2])))).sum::<f64>() / (400.0 * 3038.0);
|
||||
assert!(step < 40.0, "{name}: rough {step}");
|
||||
assert!(embedded_preview(&bytes).is_some(), "{name}: no preview");
|
||||
checked += 1;
|
||||
}
|
||||
eprintln!("ERF: {checked} corpus files checked");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW and Olympus ORF starting look
|
||||
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW, Olympus ORF and Epson ERF starting look
|
||||
|
||||
ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF 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, SRW, ORF and ERF 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
|
||||
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# Gaps: where LightCraft falls short of Lightroom
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 20: Epson ERF decodes; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
|
||||
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
|
||||
@@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
|
||||
| 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) |
|
||||
| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) |
|
||||
| 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) |
|
||||
| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) |
|
||||
| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 238 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) |
|
||||
| 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) |
|
||||
| 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) |
|
||||
| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) |
|
||||
@@ -34,7 +34,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
|
||||
| 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) |
|
||||
| 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) |
|
||||
| 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) |
|
||||
| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) |
|
||||
| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, KDC, DCR, MEF, GPR, old FinePix (ERF decodes since 2026-10-12) | raw-parity.md | small user share each | 43–83 | [raw-parity](raw-parity.md) |
|
||||
| 21 | **Localization**: 9 of 15 shipped languages partial (74–99% of catalog messages), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 55–90 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) |
|
||||
| 22 | **Pen pressure**, OS monitor profiles, follow-window colour | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) |
|
||||
| 23 | **Accessibility** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) |
|
||||
@@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Gap 20 shrinks: Epson ERF decodes (`vendor/erf.rs`), 3 bodies verified; gap 10 count 235 → 238 |
|
||||
| 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap |
|
||||
| 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed |
|
||||
| 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h |
|
||||
|
||||
File diff suppressed because one or more lines are too long
+11
-9
@@ -1,6 +1,6 @@
|
||||
# RAW parity: cameras, formats, demosaic, colour and lens profiles
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Epson ERF decoded: 3 bodies verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
|
||||
|
||||
Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera
|
||||
does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by
|
||||
@@ -14,9 +14,9 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md),
|
||||
| Measure | Value | Kind |
|
||||
|---|---:|---|
|
||||
| Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) |
|
||||
| Decoded from sensor data and **verified** on a real file | **235** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) |
|
||||
| Decoded from sensor data and **verified** on a real file | **238** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) |
|
||||
| Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) |
|
||||
| **Preview-only** (embedded JPEG) | **233** (16%) | measured |
|
||||
| **Preview-only** (embedded JPEG) | **230** (16%) | measured |
|
||||
| **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured |
|
||||
| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 82 / 640 / 24 / 8 of 754 | measured |
|
||||
| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence |
|
||||
@@ -41,7 +41,7 @@ committed table is stale.
|
||||
`lightcraft_engine::import::EXTENSIONS`, and `KNOWN_UNSUPPORTED` in `crates/raw/tests/corpus.rs`. Doc
|
||||
claims were not used.
|
||||
- **Verified** means a real file from that body decoded from sensor data in: the corpus test over the
|
||||
`RAW_SAMPLES` manifest (`xtask/src/main.rs`, 101 CC0 files; bodies whose sample is in
|
||||
`RAW_SAMPLES` manifest (`xtask/src/main.rs`, 104 CC0 files; bodies whose sample is in
|
||||
`KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder;
|
||||
the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference
|
||||
check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue
|
||||
@@ -80,8 +80,8 @@ committed table is stale.
|
||||
| Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) |
|
||||
| DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) |
|
||||
| OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** |
|
||||
| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview |
|
||||
| **All** | **1,446** | **235 (16%)** | **942 (65%)** | **233 (16%)** | **36 (2%)** | |
|
||||
| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 3 | 14 | 24 | 15 | DCR, GPR not imported; KDC, MRW, X3F preview (ERF decodes) |
|
||||
| **All** | **1,446** | **238 (16%)** | **942 (65%)** | **230 (16%)** | **36 (2%)** | |
|
||||
|
||||
## By use (estimated)
|
||||
|
||||
@@ -97,7 +97,7 @@ committed table is stale.
|
||||
| Pentax / Ricoh | 1.5% | 94% | 14% | |
|
||||
| Leica | 1% | 95% | 5% | |
|
||||
| DJI | 1% | 95% | 0% | |
|
||||
| Medium format and others | 0.5% | 23% | 0% | IIQ, 3FR, X3F |
|
||||
| Medium format and others | 0.5% | 24% | 2% | IIQ, 3FR, X3F |
|
||||
| **Weighted** | 100% | **85%** | **42%** | |
|
||||
|
||||
About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually
|
||||
@@ -144,7 +144,8 @@ checked, and Canon, the largest bucket, is the weakest of the big four.
|
||||
| DNG | GoPro GPR (VC-5) | ⬜ | refused |
|
||||
| 3FR / FFF | DNG-like layouts | 🟡 | decoded when uncompressed or LJ92 |
|
||||
| 3FR / FFF | Hasselblad compression | ⬜ | preview |
|
||||
| IIQ, MOS, MRW, ERF, KDC, X3F | all | ⬜ | preview |
|
||||
| ERF | 12-bit in 16-byte blocks (R-D1, R-D1s, R-D1x) | ✅ | 3 CC0 corpus files; crop registered and WB measured against the embedded preview (no WB tag found); `vendor/erf.rs` |
|
||||
| IIQ, MOS, MRW, KDC, X3F | all | ⬜ | preview |
|
||||
| DCR, MEF, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported |
|
||||
|
||||
### Compressed ORF: a clean-room starting point
|
||||
@@ -251,7 +252,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv).
|
||||
| 7 | Sony A100 and DSC-F828 regression (#702) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us |
|
||||
| 8 | Canon CRW (2000–2004 bodies) | +1.2 by the uniform tail weight, far less by real use | 6–12 | ≤ 0.1 | samples |
|
||||
| 9 | Nikon HE / HE★ NEF (Z 8, Z 9, Z 6III, Z f, Z50II, Z5II set to HE) | +1.1 | 20–40 | ~0.04 | a pure-Rust JPEG XS decoder |
|
||||
| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, ERF, DCR, MEF, GPR | +0.6 together | 45–85 | ~0.01 | samples; low use |
|
||||
| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, DCR, MEF, GPR (ERF done) | +0.6 together | 45–85 | ~0.01 | samples; low use |
|
||||
|
||||
Beyond decoding:
|
||||
|
||||
@@ -266,6 +267,7 @@ Beyond decoding:
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Epson ERF decoded (`vendor/erf.rs`): R-D1, R-D1s, R-D1x verified; 238 verified, 230 preview |
|
||||
| 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point |
|
||||
| 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% |
|
||||
| 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts |
|
||||
|
||||
@@ -83,7 +83,9 @@ def classify(maker, camera, ext_field):
|
||||
return "preview", "SRW", "compressed SRW is not decoded"
|
||||
return "unverified", "SRW", "uncompressed / packed SRW"
|
||||
e = es[0] if es else ""
|
||||
if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "ERF", "KDC", "MRW", "X3F", "RAW", "ORI"):
|
||||
if e == "ERF":
|
||||
return "unverified", "ERF", "ERF decoder (12-bit 16-byte blocks)"
|
||||
if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "KDC", "MRW", "X3F", "RAW", "ORI"):
|
||||
return "preview", e, f"{e} is opened from its embedded JPEG only"
|
||||
return "unsupported", e, f"{e or 'unknown'} is not imported"
|
||||
|
||||
|
||||
@@ -296,9 +296,9 @@ DJI DJI Mavic 4 Pro (DJI FC9284) DNG 2025 unverified DNG decoder (uncompressed,
|
||||
DJI DJI Mavic 4 Pro (DJI FC9287) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw)
|
||||
DJI DJI Mavic 4 Pro (Hasselblad L3D-100c) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw)
|
||||
DxO DxO ONE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw)
|
||||
Epson R-D1 ERF 2003 preview ERF is opened from its embedded JPEG only
|
||||
Epson R-D1s ERF 2005 preview ERF is opened from its embedded JPEG only
|
||||
Epson R-D1x ERF 2008 preview ERF is opened from its embedded JPEG only
|
||||
Epson R-D1 ERF 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
|
||||
Epson R-D1s ERF 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
|
||||
Epson R-D1x ERF 2008 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
|
||||
Fujifilm GFX 50R RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans)
|
||||
Fujifilm GFX 50S RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
|
||||
Fujifilm GFX 50S II RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 86 and column 19.
|
@@ -248,3 +248,6 @@ Sony RX1R II (DSC-RX1RM2) verified issue #535: raw.pixls.us bulk check of all 10
|
||||
Sony ZV-1 (DCZV1/B) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
|
||||
Sony ZV-E1 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
|
||||
Sony ZV-E10 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool)
|
||||
Epson R-D1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
|
||||
Epson R-D1s verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
|
||||
Epson R-D1x verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 3.
|
@@ -1,6 +1,6 @@
|
||||
# Target-app parity: LightCraft vs Adobe Lightroom
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified: RAW decoding 76% → 77%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Epson ERF decoded: 3 models verified; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop)
|
||||
|
||||
The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is
|
||||
[parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas
|
||||
@@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Epson ERF decoded (raw-parity.md): 238 models verified, 230 preview-only; shares and rounded numbers unchanged |
|
||||
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged |
|
||||
| 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points |
|
||||
| 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points |
|
||||
|
||||
@@ -885,6 +885,9 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[
|
||||
"https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL",
|
||||
"4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c",
|
||||
),
|
||||
("erf-epson-rd1.erf", "https://raw.pixls.us/data/Epson/R-D1/_EPS0672.ERF", "09d8e533d93116294a9f3e161ed868e929454c7b946e083b644bd6bb75bcb5e4"),
|
||||
("erf-epson-rd1s.erf", "https://raw.pixls.us/data/Epson/R-D1s/EPSN3575.ERF", "45bb0081baf1c813c7cd9e05b3e6ae734b90aba408dd1195118dc94dc6abbbb9"),
|
||||
("erf-epson-rd1x.erf", "https://raw.pixls.us/data/Epson/R-D1x/_EPS1919.ERF", "fa1bff3e0b9b9a14f83b8db9fae022c2aca8d455896bf4f25f2c1a0034e295e6"),
|
||||
];
|
||||
|
||||
fn cmd_corpus(download: bool) -> Result<(), String> {
|
||||
|
||||
Reference in New Issue
Block a user