# Conflicts:
#	docs/parity-checklist.md
#	docs/raw-parity.md
#	docs/whats-new.md
This commit is contained in:
bflatastic 2026-10-13 11:01:41 +07:00
commit 44684b7950
4 changed files with 174 additions and 19 deletions

View File

@ -167,25 +167,47 @@ pub(crate) fn decode_huffman(src: &[u8], h: &Huffman, w: usize, hgt: usize, bits
Ok(out)
}
/// The image area without all-dark leading/trailing columns (masked or empty), kept at even offsets.
fn dark_trimmed(d: &[u16], w: usize, h: usize) -> Rect {
if w < 256 || h == 0 {
/// The image area without leading/trailing lines that hold no picture (masked or empty), kept at even offsets. With
/// the maker-note `black` level, a line is dark when its mean is at most 1/256 of the remaining range (at least 16)
/// above it, the tolerance `cr2::masked_columns` uses: masked lines sit at the black level plus noise, while dark
/// real content (a night sky) is well above. Without it, below 5% of the interior's mean.
fn dark_trimmed(d: &[u16], w: usize, h: usize, black: Option<f64>, white: f32) -> Rect {
if w < 256 || h < 256 {
return Rect::new(0, 0, w, h);
}
let step = (h / 256).max(1);
let col_mean = |x: usize| (0..h).step_by(step).map(|y| d[y * w + x] as f64).sum::<f64>() / h.div_ceil(step) as f64;
let (xstep, ystep) = (w / 256, h / 256);
let at = |x: usize, y: usize| d.get(y * w + x).map_or(0.0, |&v| f64::from(v));
let col_mean = |x: usize| (0..h).step_by(ystep).map(|y| at(x, y)).sum::<f64>() / h.div_ceil(ystep) as f64;
let row_mean = |y: usize| (0..w).step_by(xstep).map(|x| at(x, y)).sum::<f64>() / w.div_ceil(xstep) as f64;
let interior = (w / 4..w * 3 / 4).step_by(w / 64).map(col_mean).sum::<f64>() / 32.0;
let dark = interior * 0.05;
let mut left = 0;
while left < 64 && col_mean(left) < dark {
left += 1;
let (base, tol) = match black {
Some(b) => (b, ((f64::from(white) - b) / 256.0).max(16.0)),
None => (0.0, interior * 0.05),
};
// a frame no brighter than masked lines (a lens-cap shot) has nothing to tell them from picture
if interior - base <= tol {
return Rect::new(0, 0, w, h);
}
let mut right = w;
while right > w - 64 && col_mean(right - 1) < dark {
right -= 1;
}
let left = left.next_multiple_of(2);
Rect::new(left, 0, (right - left) & !1, h)
let range = f64::from(white) - base;
// lines from one edge that hold no picture: within `tol` of the outermost line's level when that sits a little
// above black (K-70, K-1: 65 over BlackPoint), and saturated lines when dark ones follow (645Z)
let run = |mean: &dyn Fn(usize) -> f64| {
let saturated = (0..64).take_while(|&k| mean(k) - base >= range * 0.98).count();
let outer = mean(saturated) - base;
let level = if outer > 0.0 && outer < range * 0.01 { outer } else { 0.0 };
match (saturated..64).take_while(|&k| mean(k) - base < level + tol).count() {
0 => 0,
dark => saturated + dark,
}
};
let edges = |n: usize, mean: &dyn Fn(usize) -> f64| {
let lo = run(mean).next_multiple_of(2);
let hi = n - run(&|k| mean(n - 1 - k));
(lo, (hi - lo) & !1)
};
let (left, width) = edges(w, &col_mean);
let (top, height) = edges(h, &row_mean);
Rect::new(left, top, width, height)
}
/// Whether every strip or tile of a one-sample-per-pixel image holds exactly the bytes that its rows need when
@ -249,9 +271,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
};
let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float PEF".into())) };
let white = white_from_data(samples, bits);
let black_point = mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_POINT)).filter(|v| v.len() == 4);
let active = match tagged {
Some(r) => r,
None => dark_trimmed(samples, w, hgt),
None => dark_trimmed(samples, w, hgt, black_point.as_deref().map(|v| v.iter().sum::<f64>() / 4.0), white),
};
let cfa = match (ifd0.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), ifd0.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() },
@ -261,7 +285,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
// tag keep BGGR.
_ => cfa_from_exif(&tiff).unwrap_or_else(|| Cfa::bayer_static("BGGR")),
};
let black = match mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_POINT)).as_deref() {
let black = match black_point.as_deref() {
Some(v @ [_, _, _, _]) => {
// RGGB-ordered levels → per CFA position at the active area origin
let a = cfa.shifted(active.x, active.y);
@ -275,7 +299,6 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let g = (v[1] + v[2]) / 2.0;
[(v[0] / g) as f32, 1.0, (v[3] / g) as f32]
});
let white = white_from_data(samples, bits);
let mut metadata = lightcraft_meta::from_tiff(&tiff);
metadata.width = Some(active.width as u32);
metadata.height = Some(active.height as u32);
@ -488,4 +511,126 @@ mod tests {
let img = decode(&tiny_pef(4, 2, 1, 16, &[0; 16]), Mode::Full).unwrap();
assert_eq!(img.cfa, Some(Cfa::bayer_static("BGGR")));
}
/// A `w x h` big-endian 14-bit PEF of plain 16-bit words without area tags, whose maker note has only a
/// BlackPoint of `black`, with `px(x, y)` as the samples.
fn pef_with_black_point(w: usize, h: usize, black: u16, px: impl Fn(usize, usize) -> u16) -> Vec<u8> {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
let strip: Vec<u8> = (0..w * h).flat_map(|i| px(i % w, i / w).to_be_bytes()).collect();
let build = |levels_at: u32| {
let mut ifd = IfdBuilder::new();
ifd.set(0x0100, Value::Long(vec![w as u32]));
ifd.set(0x0101, Value::Long(vec![h as u32]));
ifd.set(0x0102, Value::Short(vec![14]));
ifd.set(0x0103, Value::Short(vec![1]));
ifd.set(0x0106, Value::Short(vec![32803]));
ifd.set(0x010f, Value::Ascii("PENTAX Corporation".into()));
ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![strip.clone()] });
// AOC\0 + "MM", one entry (BlackPoint, four shorts stored after the IFD), next-IFD 0, the four levels
let mut note = b"AOC\0MM\0\x01".to_vec();
note.extend_from_slice(&[0x02, 0x00, 0, 3, 0, 0, 0, 4]);
note.extend_from_slice(&levels_at.to_be_bytes());
note.extend_from_slice(&0u32.to_be_bytes());
(0..4).for_each(|_| note.extend_from_slice(&black.to_be_bytes()));
let mut exif = IfdBuilder::new();
exif.set(0x927c, Value::Undefined(note));
ifd.set_child(0x8769, exif);
TiffWriter::new(ByteOrder::Big, false).write(&[ifd]).unwrap()
};
let at = Tiff::parse(&build(0)).unwrap().exif().unwrap().get(0x927c).unwrap().offset;
build((at + 6 + 2 + 12 + 4) as u32)
}
/// The K-3 Mark III / KP / KF layout (issue #1271): masked columns and rows at the black level, an empty band
/// at zero, around an interior less than 20 times as bright as the black level.
#[test]
fn masked_rows_and_columns_at_the_black_level_are_trimmed_without_crop_tags() {
let (w, h) = (512, 384);
let file = pef_with_black_point(w, h, 256, |x, y| {
if x >= w - 8 {
0
} else if y >= h - 14 {
15
} else if x < 24 || x >= w - 16 || y < 28 {
256
} else {
1256 + ((x * 7 + y * 13) % 64) as u16
}
});
let img = decode(&file, Mode::Full).unwrap();
assert_eq!(img.black.values, vec![256.0; 4]);
assert_eq!(img.active_area, Rect::new(24, 28, w - 40, h - 42));
}
/// A night frame (bright centre, dark sky): 20 lines of real content 100 DN above the black level inside the
/// masked border stay in the picture, whatever the interior's brightness.
#[test]
fn dark_real_content_next_to_the_masked_border_is_kept() {
let (w, h) = (512, 384);
let file = pef_with_black_point(w, h, 256, |x, y| {
if x >= w - 8 {
0
} else if y >= h - 14 {
15
} else if x < 24 || x >= w - 16 || y < 28 {
256 + ((x + y) % 3) as u16
} else if x < 44 || x >= w - 36 || y < 48 || y >= h - 34 {
356 + ((x * 7 + y * 13) % 16) as u16
} else {
3256 + ((x * 7 + y * 13) % 64) as u16
}
});
let img = decode(&file, Mode::Full).unwrap();
assert_eq!(img.active_area, Rect::new(24, 28, w - 40, h - 42));
}
/// The K-70 (and K-1) layout: masked lines 65 DN over BlackPoint 64, just above the tolerance over black, and
/// empty rows at the bottom.
#[test]
fn masked_lines_a_little_above_the_black_level_are_trimmed() {
let (w, h) = (320, 288);
let file = pef_with_black_point(w, h, 64, |x, y| {
if y >= h - 12 {
0
} else if x < 56 || y < 28 {
128 + ((x + y) % 3) as u16
} else {
2064 + ((x * 7 + y * 13) % 64) as u16
}
});
assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(56, 28, 264, 248));
}
/// The 645Z layout: 12 saturated columns outside 46 masked ones on the right; they hold no picture either.
#[test]
fn saturated_lines_outside_masked_ones_are_trimmed() {
let (w, h) = (400, 288);
let file = pef_with_black_point(w, h, 64, |x, y| {
if x >= w - 12 {
16316
} else if x >= w - 58 {
64 + ((x + y) % 3) as u16
} else {
2064 + ((x * 7 + y * 13) % 64) as u16
}
});
assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(0, 0, w - 58, h));
}
/// A blown-out edge with the picture right behind it is picture: nothing is trimmed.
#[test]
fn a_saturated_edge_followed_by_picture_stays() {
let (w, h) = (400, 288);
let file = pef_with_black_point(w, h, 64, |x, y| if x >= w - 12 { 16316 } else { 2064 + ((x * 7 + y * 13) % 64) as u16 });
assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(0, 0, w, h));
}
/// A frame at the black level everywhere (a lens-cap shot) has no picture to tell masked lines from: it keeps
/// its whole area.
#[test]
fn a_frame_at_the_black_level_keeps_its_whole_area() {
let (w, h) = (320, 288);
let file = pef_with_black_point(w, h, 64, |x, y| 64 + ((x * 7 + y * 13) % 5) as u16);
assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(0, 0, w, h));
}
}

File diff suppressed because one or more lines are too long

View File

@ -135,7 +135,7 @@ checked, and Canon, the largest bucket, is the weakest of the big four.
| ORF | uncompressed 16-bit, 12-bit packed, two-field 12-bit, 12.8 bpp | ✅ | E-1, E-400, XZ-2 corpus; #651 |
| ORF | **compressed** (every Olympus / OM body since ~2008) | ⬜ | `orf.rs` refuses; the E-M1 and E-M10 III corpus files are `KNOWN_UNSUPPORTED` |
| RW2 / RWL / RAW | formats 2, 4, 5, 6, 7, 8 and 16-bit words | ✅ | 118 bodies (#215) |
| PEF | uncompressed, Huffman, packed 12-bit | ✅ | K10D, K-3, K-5 IIs |
| PEF | uncompressed, Huffman, packed 12-bit | ✅ | K10D, K-3, K-5 IIs; files without area tags lose their masked, empty and saturated border lines (#1271: KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) |
| PEF / DNG | Pentax pixel shift | ⬜ | frames not combined |
| SRW | uncompressed, 12-bit packed | ✅ | EX1 corpus; NX10, NX20 |
| SRW | compressed 32770 / 32772 / 32773 | ⬜ | refused |
@ -271,6 +271,7 @@ Beyond decoding:
| 2026-10-11 | minor | Leica Q3 verified by a recorded check of 38 private DNGs (no decode failure; mean ΔE to the camera JPEG 6.3–13.9): 236 verified, 941 unverified, Leica bucket on a verified body 5% → 21%, weighted shares unchanged |
| 2026-10-12 | patch | CR2 white level from the active area, not the saturated masked rows above it (#1272: EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1)); scores unchanged |
| 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point |
| 2026-10-12 | minor | PEF without area tags: masked border rows and columns trimmed against the BlackPoint (issue #1271; KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z); counts unchanged |
| 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 |
| 2026-10-10 | major | Created: formats, makers, demosaic, colour and lens checklists; target counts from the installed Lightroom 9.6 bundle listing |

View File

@ -14,6 +14,13 @@
- Blown highlights in CR2 files from the Canon EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i),
1200D (Rebel T5) and 100D (Rebel SL1) now count as clipped (issue #1272). They used to stop at 81–98% of white, so
highlight recovery missed them and, darkened, they took a colour cast instead of going neutral grey.
## 2026-10-12
### Pentax raws without black borders
- Raws from the Pentax KP, KF, K-3 Mark III (and Monochrome), K-70, K-1, K-1 Mark II, K-S1, K-S2 and 645Z no longer
show a black (or, on the 645Z, white) band along one or more edges (issue #1271). The sensor's masked border is
left out, so these photos come out a little smaller, closer to the camera's own JPEG. Crops and masks on photos
edited earlier may shift by a few pixels.
## 2026-10-11