LR-IMP-FORMATS: import and decode Mamiya MEF (uncompressed 12-bit; WB from the RGB preview)

Sources: TIFF 6.0 and own black-box analysis of the CC0 ZD sample.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrew Frahn
2026-10-12 06:27:05 +11:00
co-authored by Claude Opus 5.5
parent a6d048be8d
commit 9181aa0b6c
17 changed files with 272 additions and 24 deletions
+4 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
> **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
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Mamiya MEF and Epson ERF decoded) · **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: Mamiya MEF (ZD) decodes from sensor data and is imported; verified on a CC0 file. 239 models
verified, 35 unsupported; headline numbers unchanged.
- 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
@@ -163,6 +165,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (239); headline numbers unchanged |
| 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 |
+1 -1
View File
@@ -39,7 +39,7 @@ pub const EXTENSIONS: &[&str] = &[
// Decoded when built with the codecs' `heif` feature; otherwise the import reports them as
// failed with the reason ("HEIC/HEIF support isn't included in this build").
"heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff",
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", "mef",
];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+1 -1
View File
@@ -14,7 +14,7 @@ use crate::backend::{Backend, ProgressHook};
pub const PHOTO_EXTENSIONS: &[&str] = &[
"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", "3fr", "fff",
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", "mef",
];
/// Headless backend: a [`Session`] with filesystem hooks.
+7
View File
@@ -113,6 +113,8 @@ pub enum RawFormat {
KodakDcs,
/// Epson ERF: a CFA sub-IFD of 12-bit samples in 16-byte blocks (`vendor::erf`).
Erf,
/// Mamiya MEF: an uncompressed 12-bit packed CFA sub-IFD (`vendor::mef`).
Mef,
/// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …).
OtherTiff,
}
@@ -135,6 +137,7 @@ impl RawFormat {
| RawFormat::Srw
| RawFormat::KodakDcs
| RawFormat::Erf
| RawFormat::Mef
)
}
}
@@ -209,6 +212,9 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> {
if vendor::erf::raw_ifd(&t, bytes.len()).is_some() {
return Some(RawFormat::Erf);
}
if vendor::mef::raw_ifd(&t, bytes.len()).is_some() {
return Some(RawFormat::Mef);
}
if vendor::kodak::raw_ifd(&t, bytes).is_some() {
return Some(RawFormat::KodakDcs);
}
@@ -378,6 +384,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
RawFormat::Srw => vendor::srw::decode(bytes, mode),
RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode),
RawFormat::Erf => vendor::erf::decode(bytes, mode),
RawFormat::Mef => vendor::mef::decode(bytes, mode),
RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))),
other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))),
}
+2 -2
View File
@@ -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 | RawFormat::Erf)) {
if matches!(crate::probe(bytes), Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf | RawFormat::Mef)) {
return HeaderPlan::Whole("its header probe reads samples");
}
let Ok(tiff) = Tiff::parse(bytes) else { return HeaderPlan::Whole("TIFF structure") };
@@ -260,7 +260,7 @@ 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 | RawFormat::Erf) => {
Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf | RawFormat::Mef) => {
HeaderPlan::Whole("its header probe reads samples")
}
Some(f) if f.is_supported() => HeaderPlan::Ready,
+185
View File
@@ -0,0 +1,185 @@
//! Mamiya MEF (ZD): a big-endian TIFF whose first `SubIFDs` child is an uncompressed 12-bit packed CFA image.
//!
//! Sources: TIFF 6.0 (container, MSB-first bit packing, `CFAPattern`) and our own black-box analysis of the one CC0
//! MEF sample on raw.pixls.us (Mamiya ZD); every number below was measured on it.
//!
//! - IFD0 is a 144 x 192 RGB thumbnail with three `SubIFDs`: the raw (4016 x 5344, 12 bits, `Compression` 1,
//! photometric CFA, one strip of exactly 1.5 bytes per pixel, two samples in three bytes most significant bits
//! first, as TIFF packs them), a 992 x 1328 and a 240 x 320 RGB preview, all uncompressed and 8 bits. No JPEG.
//! - `CFAPattern` `00 01 01 02`: RGGB; the two green sites agree (means 254 and 256 against 235 and 165).
//! - No masked border and no stored black level: the darkest 0.01% of each site's samples sit at 3..6 and the
//! minimum is 0, so black is 0. White: the saturation plateau, else 4095.
//! - The 992 x 1328 preview shows a 3968 x 5312 window (4 sensor pixels per preview pixel) that starts at column 24,
//! row 12: that offset gives the best correlation of the preview's luminance with the box-filtered mosaic (0.965,
//! against 0.83 for the full frame). It is the default crop.
//! - Orientation is the raw IFD's own `Orientation` (6 on the sample, a portrait shot).
//! - No white-balance tag was found (the maker note holds an ICC-like profile), so the as-shot gains are measured
//! from that preview's neutral pixels ([`crate::wb_estimate`]).
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};
/// Where the preview's window starts in the sensor frame, and how many sensor pixels one preview pixel covers.
const CROP_ORIGIN: (usize, usize) = (24, 12);
const PREVIEW_SCALE: usize = 4;
fn is_mamiya(tiff: &Tiff) -> bool {
tiff.find(t::MAKE).and_then(|e| e.value.as_str()).is_some_and(|m| m.to_ascii_uppercase().starts_with("MAMIYA"))
}
/// The raw IFD of a Mamiya MEF: CFA, 12 bits, uncompressed, one strip of 1.5 bytes per pixel.
pub(crate) fn raw_ifd(tiff: &Tiff, len: usize) -> Option<&Ifd> {
if !is_mamiya(tiff) {
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).unwrap_or(1) == 1
&& i.u16(t::BITS_PER_SAMPLE) == Some(12)
&& w > 0
&& w % 2 == 0
&& matches!(info.chunks(len as u64).as_slice(), [c] if Some(c.len) == (w * 3 / 2).checked_mul(h))
})
}
/// The largest uncompressed 8-bit RGB sub-IFD (the preview): its pixels, width and height.
fn rgb_preview<'a>(tiff: &Tiff, bytes: &'a [u8]) -> Option<(&'a [u8], usize, usize)> {
tiff.all_ifds()
.into_iter()
.filter_map(|i| {
let info = i.image().ok()?;
let ok = i.u16(t::PHOTOMETRIC) == Some(2)
&& i.u16(t::COMPRESSION).unwrap_or(1) == 1
&& i.u16(t::SAMPLES_PER_PIXEL) == Some(3)
&& i.u64s(t::BITS_PER_SAMPLE).is_some_and(|b| b.iter().all(|&v| v == 8));
let (w, h) = (info.width as usize, info.height as usize);
let chunks = info.chunks(bytes.len() as u64);
let [c] = chunks.as_slice() else { return None };
let px = chunk_bytes(bytes, c)?;
(ok && px.len() >= w.checked_mul(h)?.checked_mul(3)?).then_some((px, w, h))
})
.max_by_key(|(_, w, h)| w * h)
}
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let tiff = Tiff::parse(bytes)?;
let raw = raw_ifd(&tiff, bytes.len()).ok_or_else(|| RawError::Unsupported("Mef 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("Mef raw without a 2x2 CFAPattern".into())),
};
let preview = rgb_preview(&tiff, bytes);
let crop = match preview {
Some((_, pw, ph)) => {
let (cw, ch) = (pw.saturating_mul(PREVIEW_SCALE), ph.saturating_mul(PREVIEW_SCALE));
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)
} else {
Rect::new(0, 0, w, h)
}
}
None => 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("Mef strip outside file".into()))?;
let mut d = vec![0u16; n];
crate::unpack::unpack_msb(src, 12, &mut d);
let white = super::white_from_data(&d, 12);
let black = BlackLevel::uniform(0.0);
let frame = crate::wb_estimate::Frame { data: &d, width: w, crop, cfa: &cfa, black: &black, white };
let wb = match preview {
Some((px, pw, ph)) => crate::wb_estimate::as_shot_rgb(px, pw, ph, &frame),
None => crate::wb_estimate::as_shot(bytes, &frame),
};
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 orientation = raw.u16(t::ORIENTATION).or_else(|| tiff.ifds.first().and_then(|i| i.u16(t::ORIENTATION))).unwrap_or(1);
let img = RawImage {
format: RawFormat::Mef,
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(orientation),
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::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
fn pack(px: &[u16]) -> Vec<u8> {
px.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 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![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
raw.set(t::ORIENTATION, Value::Short(vec![6]));
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("Mamiya-OP Co.,Ltd.".into()));
ifd0.set(t::MODEL, Value::Ascii("MAMIYA ZD".into()));
ifd0.add_sub_ifd(raw);
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
}
#[test]
fn decodes_packed_12_bit() {
let px: Vec<u16> = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect();
let f = sample(16, 6, &px);
assert_eq!(crate::probe(&f), Some(RawFormat::Mef));
let r = crate::decode(&f).unwrap();
assert_eq!(r.data, RawData::U16(px));
assert_eq!(r.orientation, Orientation::from_exif(6));
assert_eq!(r.crop, Rect::new(0, 0, 16, 6));
assert_eq!(crate::probe_info(&f).unwrap(), r.info());
}
#[test]
fn malformed_files_never_panic() {
let px: Vec<u16> = (0..16 * 6).map(|i| (i * 7 % 4096) as u16).collect();
let f = sample(16, 6, &px);
for n in 0..f.len() {
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);
}
}
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod crx;
pub(crate) mod crx_wavelet;
pub mod erf;
pub mod kodak;
pub mod mef;
pub mod nef;
pub mod nefc;
pub mod orf;
+14 -1
View File
@@ -67,7 +67,20 @@ fn median(v: &mut [f64]) -> Option<f64> {
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 {
if ch != 3 {
return None;
}
from_rgb(&px, pw, ph, f)
}
/// Gains from an 8-bit RGB preview `pw` x `ph` (rows of RGB triplets) showing the crop: its neutral pixels, else
/// grey world. For raws whose preview is an uncompressed RGB image rather than a JPEG.
pub(crate) fn as_shot_rgb(px: &[u8], pw: usize, ph: usize, f: &Frame) -> Option<[f32; 3]> {
from_rgb(px, pw, ph, f).or_else(|| grey_world(f))
}
fn from_rgb(px: &[u8], pw: usize, ph: usize, f: &Frame) -> Option<[f32; 3]> {
if pw.checked_mul(ph)? > MAX_PREVIEW_PIXELS || px.len() < pw * ph * 3 {
return None;
}
// the preview must show the crop: aspect ratios within 3%
+28 -2
View File
@@ -42,7 +42,7 @@ fn corpus_raw_decodes() {
let preview = embedded_preview(&bytes);
let tp = t0.elapsed().as_secs_f64() * 1e3;
// DNG previews are optional (and some carry only an uncompressed RGB thumbnail); vendor raws embed a JPEG,
// except the Panasonic `.RAW` files of 2005–2007 and Canon's explicit HEVC preview tracks.
// except the Panasonic `.RAW` files of 2005–2007, Canon's explicit HEVC preview tracks and Mamiya MEF.
if let Some(p) = &preview {
let jpeg = p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]);
let jxl = p.starts_with(&[0xff, 0x0a]) || p.starts_with(b"\0\0\0\x0cJXL ");
@@ -51,7 +51,11 @@ fn corpus_raw_decodes() {
let hevc_preview = fmt == RawFormat::Cr3
&& lightcraft_meta::cr3::parse_cr3(&bytes)
.is_some_and(|c| c.tracks.iter().any(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Other(*b"HEVC") && t.data.is_some()));
assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-") || hevc_preview, "{name}: no embedded preview");
// the Mamiya ZD's previews are uncompressed RGB sub-IFDs, not JPEGs
assert!(
fmt == RawFormat::Dng || fmt == RawFormat::Mef || name.starts_with("raw-panasonic-") || hevc_preview,
"{name}: no embedded preview"
);
}
let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024);
let t1 = Instant::now();
@@ -1166,3 +1170,25 @@ fn corpus_epson_erf() {
}
eprintln!("ERF: {checked} corpus files checked");
}
/// Mamiya MEF (`crates/raw/src/vendor/mef.rs`): the uncompressed 12-bit strip decodes to a smooth 4016 x 5344 RGGB
/// mosaic, the crop is the preview's window and the white balance comes from the RGB preview. Skips when absent.
#[test]
fn corpus_mamiya_mef() {
let Ok(bytes) = std::fs::read(corpus_root().join("raw").join("mef-mamiya-zd.mef")) else {
eprintln!("skip: mef-mamiya-zd.mef absent");
return;
};
assert_eq!(probe(&bytes), Some(RawFormat::Mef));
let img = decode(&bytes).unwrap_or_else(|e| panic!("{e}"));
assert_eq!((img.width, img.height, img.bits), (4016, 5344, 12));
assert_eq!((img.crop.x, img.crop.y, img.crop.width, img.crop.height), (24, 12, 3968, 5312));
assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some("RGGB"));
assert_eq!(img.orientation, lightcraft_geom::Orientation::from_exif(6));
let wb = img.wb_multipliers.expect("white balance");
eprintln!("MEF wb {wb:?}");
assert!(wb.iter().all(|g| (0.5..=4.0).contains(g)), "{wb:?}");
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
let step: f64 = d.chunks(4016).take(400).flat_map(|r| r.windows(3).map(|w| f64::from(w[0].abs_diff(w[2])))).sum::<f64>() / (400.0 * 4014.0);
assert!(step < 40.0, "rough {step}");
}
+4 -3
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **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
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 20: Mamiya MEF and Epson ERF decode; 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**: 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) |
| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 239 of 1,446 Adobe models verified (17%), ~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, 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) |
| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, KDC, DCR, GPR, old FinePix (ERF and MEF decode since 2026-10-12) | raw-parity.md | small user share each | 42–82 | [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: Mamiya MEF decodes (`vendor/mef.rs`) and is imported; gap 10 count 238 → 239 |
| 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 |
File diff suppressed because one or more lines are too long
+11 -9
View File
@@ -1,6 +1,6 @@
# RAW parity: cameras, formats, demosaic, colour and lens profiles
> **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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Mamiya MEF decoded: ZD 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,10 +14,10 @@ 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 | **238** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) |
| Decoded from sensor data and **verified** on a real file | **239** (17%) | 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) | **230** (16%) | measured |
| **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured |
| **Unsupported** (not imported, refused or decodes wrongly) | **35** (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 |
| …of which on a body we have verified | **~42%** | estimated, same weights |
@@ -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`, 104 CC0 files; bodies whose sample is in
`RAW_SAMPLES` manifest (`xtask/src/main.rs`, 105 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
@@ -76,12 +76,12 @@ committed table is stale.
| Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) |
| Pentax / Ricoh | 60 | 4 | 56 | 0 | 0 | — |
| Hasselblad | 34 | 0 | 2 | 32 | 0 | Hasselblad-compressed 3FR / FFF |
| Phase One / Leaf / Mamiya | 59 | 0 | 0 | 43 | 16 | IIQ, MOS preview; TIFF raws and MEF not imported |
| Phase One / Leaf / Mamiya | 59 | 1 | 0 | 43 | 15 | IIQ, MOS preview; TIFF raws not imported (MEF decodes) |
| 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 | 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%)** | |
| **All** | **1,446** | **239 (17%)** | **942 (65%)** | **230 (16%)** | **35 (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% | 24% | 2% | IIQ, 3FR, X3F |
| Medium format and others | 0.5% | 25% | 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
@@ -146,7 +146,8 @@ checked, and Canon, the largest bucket, is the weakest of the big four.
| 3FR / FFF | Hasselblad compression | ⬜ | 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 |
| MEF | uncompressed 12-bit (Mamiya ZD) | ✅ | 1 CC0 corpus file; crop and WB from its uncompressed RGB preview (no JPEG, no WB tag); `vendor/mef.rs` |
| DCR, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported |
### Compressed ORF: a clean-room starting point
@@ -252,7 +253,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, DCR, MEF, GPR (ERF done) | +0.6 together | 45–85 | ~0.01 | samples; low use |
| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, DCR, GPR (ERF, MEF done) | +0.6 together | 45–85 | ~0.01 | samples; low use |
Beyond decoding:
@@ -267,6 +268,7 @@ Beyond decoding:
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Mamiya MEF decoded (`vendor/mef.rs`): ZD verified; 239 verified, 35 unsupported |
| 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% |
+2
View File
@@ -83,6 +83,8 @@ 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 == "MEF":
return "unverified", "MEF", "MEF decoder (uncompressed 12-bit)"
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"):
+1 -1
View File
@@ -673,7 +673,7 @@ Mamiya Mamiya M18 TIF 2008 unsupported TIF is not imported
Mamiya Mamiya M22 TIF 2008 unsupported TIF is not imported
Mamiya Mamiya M31 TIF 2008 unsupported TIF is not imported
Mamiya Mamiya DM40 MOS 2010 preview MOS is opened from its embedded JPEG only
Mamiya ZD MEF 2005 unsupported MEF is not imported
Mamiya ZD MEF 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
Motorola Moto G Power (2021) Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw)
Motorola Moto G Power (2021) Rear Macro Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw)
Motorola Moto G Power (2021) Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw)
Can't render this file because it contains an unexpected character in line 86 and column 19.
+1
View File
@@ -251,3 +251,4 @@ Sony ZV-E10 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies
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)
Mamiya ZD 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.
+2 -1
View File
@@ -1,6 +1,6 @@
# Target-app parity: LightCraft vs Adobe Lightroom
> **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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Mamiya MEF and Epson ERF decoded; 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 | Mamiya MEF decoded (raw-parity.md): 239 verified, 35 unsupported; rounded numbers unchanged |
| 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 |
+5
View File
@@ -888,6 +888,11 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[
("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"),
(
"mef-mamiya-zd.mef",
"https://raw.pixls.us/data/Mamiya/ZD/RAW_MAMIYA_ZD.MEF",
"bcd63507c3c4cc3ea1bad2945e3f88cb4677a1a3c762e1acc38df4445714eb7c",
),
];
fn cmd_corpus(download: bool) -> Result<(), String> {