mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-31 05:17:12 -06:00
* raw: clean-room camera raw decoder and developer (photocraft-raw) A new standalone crate (no workspace deps, wasm-safe, no unsafe) written from TIFF 6.0, TIFF/EP, the DNG 1.7 spec, ITU-T T.81 Annex H and published demosaicing papers: - Lossless JPEG (LJ92) decoder: predictors 1-7, point transform, restart intervals, combined Huffman+extra-bits fast table. - DNG: uncompressed (8-16 bit, packed) and LJ92, strips and tiles, CFA and LinearRaw, linearization, black level (repeat, deltas), white level, active area, default crop, ColorMatrix/CameraCalibration/ForwardMatrix interpolation by CCT, AnalogBalance, AsShotNeutral/WhiteXY, BaselineExposure, orientation. - CR2 (LJ92 slices, SensorInfo borders, ColorData as-shot WB, measured black) and TIFF/EP raws (NEF/ARW/PEF uncompressed or LJ92, incl. Sony lossless ARW tiles, Nikon/Sony documented black and WB tags). - Develop: black/white scaling, WB with highlight clipping, bilinear / Malvar-He-Cutler / AHD demosaic (rayon bands, sequential on wasm), camera -> XYZ D50 -> ProPhoto, exposure, gamma 1.8, 16-bit, orientation. - Embedded JPEG preview finder; clear Unsupported errors for CR3, RAF, ORF, RW2, vendor-compressed NEF/ARW/PEF, lossy/JXL/float DNG, X-Trans. - Limits checked before every allocation; testgen feature with an LJ92 encoder and DNG/CR2/TIFF-EP writers; round-trip, colour-reference, truncation and corruption tests. Registered as a standalone crate in xtask layers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * io: open camera raws through photocraft-raw (16-bit ProPhoto), preview fallback Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * raw: DNG GainMap opcode, fuzz targets, stress tools; open raws from the apps - Apply DNG OpcodeList2 GainMap (lens shading, e.g. phone DNGs); report other opcodes. - cargo-fuzz targets (develop, ljpeg) and a mutation stress example. - Raw extensions in the desktop/web open dialogs and the batch folder list. - file.open returns the import notes (how a raw was developed, preview fallback). - codecs: the camera raw refusal points at photocraft-raw. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * raw: clippy 1.99 (is_multiple_of, clamp) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
220 lines
7.9 KiB
Rust
220 lines
7.9 KiB
Rust
//! DNG opcode lists (DNG 1.7, chapter 7 "Opcode List Processing").
|
||
//!
|
||
//! An opcode list is big-endian whatever the file's byte order: a count, then
|
||
//! per opcode its ID, DNG version, flags (bit 0 = optional), parameter size
|
||
//! and parameters. Only GainMap (ID 9), the lens-shading correction phones
|
||
//! write in OpcodeList2, is applied; other opcodes are reported.
|
||
|
||
/// One GainMap opcode: a grid of gains, bilinearly interpolated, applied to
|
||
/// the pixels of an area (in active-area coordinates) at a row / column pitch.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct GainMap {
|
||
pub top: usize,
|
||
pub left: usize,
|
||
pub bottom: usize,
|
||
pub right: usize,
|
||
pub plane: usize,
|
||
pub planes: usize,
|
||
pub row_pitch: usize,
|
||
pub col_pitch: usize,
|
||
pub points_v: usize,
|
||
pub points_h: usize,
|
||
/// Spacing and origin of the map points, in image-relative coordinates (0..1).
|
||
pub spacing_v: f64,
|
||
pub spacing_h: f64,
|
||
pub origin_v: f64,
|
||
pub origin_h: f64,
|
||
pub map_planes: usize,
|
||
/// `points_v × points_h × map_planes` gains.
|
||
pub gains: Vec<f32>,
|
||
}
|
||
|
||
impl GainMap {
|
||
/// Gains along the map's horizontal points for image-relative row `rv`
|
||
/// and map plane `mp` (vertical interpolation done).
|
||
pub fn row(&self, rv: f64, mp: usize) -> Vec<f32> {
|
||
let mp = mp.min(self.map_planes.saturating_sub(1));
|
||
let fv = ((rv - self.origin_v) / self.spacing_v).clamp(0.0, (self.points_v - 1) as f64);
|
||
let i0 = (fv.floor() as usize).min(self.points_v - 1);
|
||
let i1 = (i0 + 1).min(self.points_v - 1);
|
||
let t = (fv - i0 as f64) as f32;
|
||
(0..self.points_h)
|
||
.map(|j| {
|
||
let a = self.gains.get((i0 * self.points_h + j) * self.map_planes + mp).copied().unwrap_or(1.0);
|
||
let b = self.gains.get((i1 * self.points_h + j) * self.map_planes + mp).copied().unwrap_or(1.0);
|
||
a + (b - a) * t
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Interpolates a [`GainMap::row`] at image-relative column `rh`.
|
||
#[inline]
|
||
pub fn at(&self, row: &[f32], rh: f64) -> f32 {
|
||
let fh = ((rh - self.origin_h) / self.spacing_h).clamp(0.0, (self.points_h - 1) as f64);
|
||
let j0 = (fh.floor() as usize).min(self.points_h - 1);
|
||
let j1 = (j0 + 1).min(self.points_h - 1);
|
||
let t = (fh - j0 as f64) as f32;
|
||
let (a, b) = (row.get(j0).copied().unwrap_or(1.0), row.get(j1).copied().unwrap_or(1.0));
|
||
a + (b - a) * t
|
||
}
|
||
|
||
/// `true` if the map covers active-area pixel (`ax`, `ay`).
|
||
#[inline]
|
||
pub fn covers(&self, ax: usize, ay: usize) -> bool {
|
||
ay >= self.top
|
||
&& ay < self.bottom
|
||
&& ax >= self.left
|
||
&& ax < self.right
|
||
&& (ay - self.top).is_multiple_of(self.row_pitch)
|
||
&& (ax - self.left).is_multiple_of(self.col_pitch)
|
||
}
|
||
}
|
||
|
||
/// Opcode names (DNG 1.7 table) for warnings.
|
||
fn name(id: u32) -> String {
|
||
match id {
|
||
1 => "WarpRectilinear".into(),
|
||
2 => "WarpFisheye".into(),
|
||
3 => "FixVignetteRadial".into(),
|
||
4 => "FixBadPixelsConstant".into(),
|
||
5 => "FixBadPixelsList".into(),
|
||
6 => "TrimBounds".into(),
|
||
7 => "MapTable".into(),
|
||
8 => "MapPolynomial".into(),
|
||
9 => "GainMap".into(),
|
||
10 => "DeltaPerRow".into(),
|
||
11 => "DeltaPerColumn".into(),
|
||
12 => "ScalePerRow".into(),
|
||
13 => "ScalePerColumn".into(),
|
||
14 => "WarpRectilinear2".into(),
|
||
id => format!("opcode {id}"),
|
||
}
|
||
}
|
||
|
||
fn be32(b: &[u8], at: usize) -> Option<u32> {
|
||
let s: [u8; 4] = b.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||
Some(u32::from_be_bytes(s))
|
||
}
|
||
|
||
fn be64f(b: &[u8], at: usize) -> Option<f64> {
|
||
let s: [u8; 8] = b.get(at..at.checked_add(8)?)?.try_into().ok()?;
|
||
Some(f64::from_be_bytes(s)).filter(|v| v.is_finite())
|
||
}
|
||
|
||
fn gain_map(p: &[u8]) -> Option<GainMap> {
|
||
let u = |i: usize| be32(p, 4 * i).map(|v| v as usize);
|
||
let (top, left, bottom, right) = (u(0)?, u(1)?, u(2)?, u(3)?);
|
||
let (plane, planes, row_pitch, col_pitch) = (u(4)?, u(5)?, u(6)?, u(7)?);
|
||
let (points_v, points_h) = (u(8)?, u(9)?);
|
||
let (spacing_v, spacing_h, origin_v, origin_h) = (be64f(p, 40)?, be64f(p, 48)?, be64f(p, 56)?, be64f(p, 64)?);
|
||
let map_planes = u(18)?;
|
||
let n = points_v.checked_mul(points_h)?.checked_mul(map_planes)?;
|
||
if n == 0 || n > 1 << 20 || row_pitch == 0 || col_pitch == 0 || spacing_v <= 0.0 || spacing_h <= 0.0 || bottom <= top || right <= left {
|
||
return None;
|
||
}
|
||
let gains = (0..n).map(|i| be32(p, 76 + 4 * i).map(f32::from_bits).filter(|g| g.is_finite())).collect::<Option<Vec<f32>>>()?;
|
||
Some(GainMap {
|
||
top,
|
||
left,
|
||
bottom,
|
||
right,
|
||
plane,
|
||
planes,
|
||
row_pitch,
|
||
col_pitch,
|
||
points_v,
|
||
points_h,
|
||
spacing_v,
|
||
spacing_h,
|
||
origin_v,
|
||
origin_h,
|
||
map_planes,
|
||
gains,
|
||
})
|
||
}
|
||
|
||
/// Parses an opcode list: the gain maps, and the names of opcodes not applied.
|
||
pub(crate) fn parse(list: &[u8]) -> (Vec<GainMap>, Vec<String>) {
|
||
let mut maps = Vec::new();
|
||
let mut skipped = Vec::new();
|
||
let Some(count) = be32(list, 0) else { return (maps, skipped) };
|
||
let mut at = 4usize;
|
||
for _ in 0..count.min(4096) {
|
||
let (Some(id), Some(size)) = (be32(list, at), be32(list, at.saturating_add(12))) else { break };
|
||
let start = at.saturating_add(16);
|
||
let Some(params) = list.get(start..start.saturating_add(size as usize)) else { break };
|
||
match (id, gain_map(params)) {
|
||
(9, Some(m)) => maps.push(m),
|
||
_ => {
|
||
let n = name(id);
|
||
if !skipped.contains(&n) {
|
||
skipped.push(n);
|
||
}
|
||
}
|
||
}
|
||
at = at.saturating_add(16).saturating_add(size as usize);
|
||
}
|
||
(maps, skipped)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn list(ops: &[(u32, Vec<u8>)]) -> Vec<u8> {
|
||
let mut b = (ops.len() as u32).to_be_bytes().to_vec();
|
||
for (id, p) in ops {
|
||
b.extend_from_slice(&id.to_be_bytes());
|
||
b.extend_from_slice(&0x0103_0000u32.to_be_bytes());
|
||
b.extend_from_slice(&1u32.to_be_bytes());
|
||
b.extend_from_slice(&(p.len() as u32).to_be_bytes());
|
||
b.extend_from_slice(p);
|
||
}
|
||
b
|
||
}
|
||
|
||
pub(crate) fn gain_map_params(area: [u32; 4], pitch: u32, gains: &[[f32; 2]; 2]) -> Vec<u8> {
|
||
let mut p = Vec::new();
|
||
for v in [area[0], area[1], area[2], area[3], 0, 1, pitch, pitch, 2, 2] {
|
||
p.extend_from_slice(&v.to_be_bytes());
|
||
}
|
||
for v in [1.0f64, 1.0, 0.0, 0.0] {
|
||
p.extend_from_slice(&v.to_be_bytes());
|
||
}
|
||
p.extend_from_slice(&1u32.to_be_bytes());
|
||
for row in gains {
|
||
for g in row {
|
||
p.extend_from_slice(&g.to_bits().to_be_bytes());
|
||
}
|
||
}
|
||
p
|
||
}
|
||
|
||
#[test]
|
||
fn parses_gain_maps_and_names_others() {
|
||
let b = list(&[(9, gain_map_params([0, 0, 10, 10], 1, &[[1.0, 2.0], [3.0, 4.0]])), (1, vec![0; 8])]);
|
||
let (maps, skipped) = parse(&b);
|
||
assert_eq!(maps.len(), 1);
|
||
assert_eq!(skipped, vec!["WarpRectilinear".to_string()]);
|
||
let m = &maps[0];
|
||
// Bilinear: centre of the 2×2 grid.
|
||
let row = m.row(0.5, 0);
|
||
assert!((m.at(&row, 0.5) - 2.5).abs() < 1e-6);
|
||
assert!((m.at(&m.row(0.0, 0), 0.0) - 1.0).abs() < 1e-6);
|
||
assert!((m.at(&m.row(1.0, 0), 1.0) - 4.0).abs() < 1e-6);
|
||
// Outside the grid clamps.
|
||
assert!((m.at(&m.row(-3.0, 0), 9.0) - 2.0).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_lists_do_not_panic() {
|
||
let b = list(&[(9, gain_map_params([0, 0, 10, 10], 2, &[[1.0, 2.0], [3.0, 4.0]]))]);
|
||
for n in 0..b.len() {
|
||
let _ = parse(&b[..n]);
|
||
}
|
||
let mut huge = b.clone();
|
||
huge[4 + 16 + 32..4 + 16 + 40].copy_from_slice(&[0xFF; 8]); // points
|
||
assert!(parse(&huge).0.is_empty());
|
||
}
|
||
}
|