mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
craft-raw: Canon CR2 with sRAW and mRAW (#921)
* craft-raw: the file-format layer of camera raw files, with DNG and CRW A new crate for #587 and #778: bytes in, the sensor data out as a neutral RawImage (samples, CFA, levels, active area, crop, orientation, colour tags, opcode lists, profile tables), and back for DNG. One Cargo feature per format: dng (read and write), jxl (JPEG XL DNG), crw (through craft-crw). The base is LightCraft's crates/raw at 5ff567a4: dng, dngwrite, tiffraw, unpack, ljpeg, jxl, semantic, preview, the parsing half of opcodes, profile and gaintable, the model and probe. Demosaicing and everything after it stay with the develop pipeline. Ported from PhotoCraft's and FilmCraft's DNG readers, each with a test: the default crop's per-tag defaults, an unusable crop falling back to the active area, non-finite white levels, the white default of a linearized image, the AsShotWhiteXY range, 8-bit-only lossy JPEG, lossy tiles that must cover their part of the image, and the CinemaDNG frame tags. * craft-raw: Hasselblad 3FR and FFF (feature hasselblad) RawFormat::CfaTiff decodes: the DNG-layout reader plus what these files add. Uncompressed data or Hasselblad's lossless JPEG (selection value 8), RGGB when no CFAPattern is stated, an FFF's colour matrix from the 3FR header it embeds, and the 8-bit RGB preview handed on as a baseline TIFF. Moved from LightCraft's vendor/hasselblad.rs at 5ff567a4 with its tests. Ported from PhotoCraft's reader: four BlackLevel values without a BlackLevelRepeatDim are one per CFA site in a file that is not a DNG. * craft-raw: Minolta MRW, Epson ERF, Mamiya MEF and Kodak DCS520C Four formats only LightCraft decodes, one feature each (mrw, erf, mef, kodak), moved from its vendor modules at 5ff567a4 with their tests; the MRW block container comes from lightcraft-meta. src/shared.rs holds the level helpers the maker decoders share. ERF and MEF state no white balance: the estimate LightCraft made from the preview is develop-side work, so wb_multipliers is None for them here. * craft-raw: shared scan budget for tagged previews, tile-sized lossy JPEG budget, bound on Deflate tile padding Ported from storytold/lightcraft#3695. Candidate preview ranges are deduplicated and charged to one byte budget and one header-walk budget; a lossy JPEG decoded for a tile may hold no more samples than the tile rounded up to whole MCUs (and never more than Limits::max_lossy_tile_samples); a Deflate chunk whose nominal size is mostly padding outside the image is refused before it is inflated. * craft-raw: lossless JPEG sample bound per referenced byte; previews larger than the file are passed over Ported from storytold/lightcraft#3910 (the parts whose code is in craft-raw today). check_image refuses a lossless JPEG image that declares more than 32 samples per referenced byte, counted once per chunk that points at the bytes so identical tiles stored once still decode. The Hasselblad RGB preview search passes over an image whose pixel bytes exceed the file and still takes a smaller complete one. * craft-raw: test for the Deflate padding bound * craft-raw: Pentax PEF and Samsung SRW Moved from LightCraft crates/raw (vendor/pef.rs, vendor/srw.rs) at 5ff567a4. The Huffman difference decoder and bit reader that PEF shares with the Nikon, Panasonic and Sony codings become the huffman module. * craft-raw: Canon CR2, with sRAW and mRAW Moved from LightCraft crates/raw (vendor/cr2.rs, vendor/cr2/sraw.rs) at 5ff567a4. The white-balance presets a CR2 lists come back as data in ColorData::kelvin_presets (the ordering from LightCraft's kelvin_presets.rs; the temperature and tint arithmetic stays with the developer). * craft-raw: an sRAW preview larger than the file is refused before its buffer is reserved Ported from storytold/lightcraft#3910 (cr2::sraw::read_preview). --------- Co-authored-by: radialmonster <radial@gmail.com>
This commit is contained in:
1 parent
9e9c044fab
commit
44363b5da4
10 files changed
+1390
-4
No files matched your search
@@ -93,7 +93,7 @@ crates/raw (craft-raw) is moved from LightCraft, with behaviour ported from two
|
||||
- LightCraft, crates/raw (src/lib.rs, dng.rs, dngwrite.rs, tiffraw.rs, unpack.rs, ljpeg.rs, jxl.rs,
|
||||
opcodes.rs, profile.rs, gaintable.rs, semantic.rs, preview.rs, vendor/crw.rs, vendor/hasselblad.rs,
|
||||
vendor.rs (shared level helpers), vendor/mrw.rs, vendor/erf.rs, vendor/mef.rs, vendor/kodak.rs, vendor/pef.rs,
|
||||
vendor/srw.rs, tests/it/vendor_robust.rs and their tests) and crates/meta/src/mrw.rs (the MRW block container),
|
||||
vendor/srw.rs, vendor/cr2.rs, vendor/cr2/sraw.rs, kelvin_presets.rs (the preset ordering), tests/it/vendor_robust.rs and their tests) and crates/meta/src/mrw.rs (the MRW block container),
|
||||
revision 5ff567a4a2e067bef1b6bce152c11e7f727ccbaf.
|
||||
https://github.com/storytold/lightcraft
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors.
|
||||
|
||||
@@ -121,6 +121,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
what PhotoCraft's and FilmCraft's readers added: container parsing and decompression to a neutral
|
||||
raw image (samples, CFA, black and white levels, active area, crop, orientation, colour tags,
|
||||
opcode lists, embedded previews), one Cargo feature per format. DNG reading and writing,
|
||||
Hasselblad 3FR/FFF, Minolta MRW, Epson ERF, Mamiya MEF, Kodak DCS, Pentax PEF, Samsung SRW, Canon CR2 and Canon CRW (through
|
||||
Hasselblad 3FR/FFF, Minolta MRW, Epson ERF, Mamiya MEF, Kodak DCS, Pentax PEF, Samsung SRW and Canon CRW (through
|
||||
`craft-crw`) so far; demosaicing and everything after stay with the develop
|
||||
pipeline.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "craft-raw"
|
||||
description = "Camera raw files, the file-format layer: container parsing, decompression and the sensor data out as a neutral raw image with its levels, crop, orientation, colour tags and embedded previews. DNG (read and write), Hasselblad 3FR/FFF, MRW, ERF, MEF, Kodak DCS, Pentax PEF, Samsung SRW and Canon CRW so far."
|
||||
description = "Camera raw files, the file-format layer: container parsing, decompression and the sensor data out as a neutral raw image with its levels, crop, orientation, colour tags and embedded previews. DNG (read and write), Hasselblad 3FR/FFF, MRW, ERF, MEF, Kodak DCS, Pentax PEF, Samsung SRW, Canon CR2 and Canon CRW so far."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -11,7 +11,7 @@ repository.workspace = true
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["dng", "jxl", "crw", "hasselblad", "mrw", "erf", "mef", "kodak", "pef", "srw"]
|
||||
default = ["dng", "jxl", "crw", "hasselblad", "mrw", "erf", "mef", "kodak", "pef", "srw", "cr2"]
|
||||
## Adobe DNG, reading and writing: uncompressed, lossless JPEG, lossy JPEG, Deflate.
|
||||
dng = ["dep:miniz_oxide", "dep:zune-jpeg", "dep:zune-core"]
|
||||
## JPEG XL compressed DNGs (DNG 1.7, compression 52546) via jxl-oxide (pure Rust, MIT OR Apache-2.0).
|
||||
@@ -30,6 +30,8 @@ kodak = []
|
||||
pef = []
|
||||
## Samsung SRW: the uncompressed variants.
|
||||
srw = []
|
||||
## Canon CR2: sliced lossless JPEG, and sRAW / mRAW (subsampled YCbCr) as linear RGB.
|
||||
cr2 = []
|
||||
## Canon CRW (CIFF) through craft-crw, the one CRW decoder.
|
||||
crw = ["dep:craft-crw", "dep:chrono"]
|
||||
|
||||
|
||||
+12
-1
@@ -53,6 +53,7 @@ One module and one Cargo feature per maker or container family. All are on by de
|
||||
| `dng` | Adobe DNG | Uncompressed at 1 to 16 bits (packed MSB-first, or 8/16-bit in file byte order), lossless JPEG (all seven predictors, 1 to 4 components, point transform, restart intervals), lossy JPEG (compression 34892, 8-bit baseline tiles or strips), Deflate with predictors 1, 2 and the floating-point predictors (16, 24 and 32-bit floats), strips and tiles, planar or interleaved, CFA of any period up to 16 × 16 (Bayer, X-Trans) and LinearRaw with 1, 3 or 4 samples, DNG 1.7 row and column interleave, reduced-resolution LinearRaw copies (`decode_reduced`), semantic masks, CinemaDNG frame tags. Writing: uncompressed, lossless JPEG tiles, Deflate tiles (integer, half and single floats) |
|
||||
| `jxl` | JPEG XL DNG (DNG 1.7, compression 52546) | Lossless and lossy tiles through `jxl-oxide`; implies `dng` |
|
||||
| `crw` | Canon CRW (CIFF) | Decoded by [`craft-crw`](../crw/README.md), the one CRW decoder, and returned through this crate's types |
|
||||
| `cr2` | Canon CR2 | The lossless-JPEG frame of IFD 3 de-sliced (`0xc640`); the layout from `CR2CFAPattern` (`0xc5e0`); image area from `SensorInfo`; black measured on the masked columns; white from the saturation plateau; as-shot white balance from `ColorData` or `WBInfo`; the white-balance presets at their temperatures (`ColorData::kelvin_presets`). sRAW / mRAW (subsampled YCbCr, 4:2:2 and 4:2:0) come out as linear white-balanced RGB with three samples per pixel, the chroma gains measured against the file's own 16-bit RGB image in IFD 2 |
|
||||
| `mrw` | Minolta / Konica Minolta MRW | The `\0MRM` block container (`PRD`, `WBG`, `TTW`), 12-bit packed and 16-bit word mosaics, as-shot white balance from `WBG`, white from the saturation plateau |
|
||||
| `erf` | Epson ERF (R-D1, R-D1s, R-D1x) | 12-bit samples in 16-byte blocks, black levels from the maker note, the measured picture origin. No white balance (the file states none) |
|
||||
| `mef` | Mamiya MEF (ZD) | Uncompressed 12-bit packed; default crop from the RGB preview's measured window. No white balance (the file states none) |
|
||||
@@ -62,7 +63,7 @@ One module and one Cargo feature per maker or container family. All are on by de
|
||||
| `hasselblad` | Hasselblad 3FR and FFF, and other CFA TIFFs in the DNG layout without a DNG version tag (`RawFormat::CfaTiff`) | Uncompressed and Hasselblad's lossless JPEG (predictor selection value 8); RGGB from the sensor origin when the file states no `CFAPattern`; an FFF's colour matrix from the 3FR header it embeds; the 8-bit RGB preview handed on as a baseline TIFF. Implies `dng` |
|
||||
|
||||
Not decoded: GoPro VC-5 DNG (GPR), CFA with more than three colours, non-rectangular CFA layouts.
|
||||
`probe` names every container it can tell apart whatever the features (CR2, CR3, NEF, NRW, ARW,
|
||||
`probe` names every container it can tell apart whatever the features (CR3, NEF, NRW, ARW,
|
||||
RAF, ORF, RW2, X3F, other TIFF-based raws);
|
||||
their decoders are still in the apps and move here one maker family at a time.
|
||||
`RawFormat::is_supported` says what this build decodes.
|
||||
@@ -121,6 +122,16 @@ Left as LightCraft has it, with the reason:
|
||||
- Pentax PEF and Samsung SRW are LightCraft's readers unchanged. PhotoCraft has no reader for either: it sends
|
||||
them down its generic TIFF/EP path, which reads an uncompressed CFA IFD and refuses Pentax's Huffman coding
|
||||
and Samsung's private compressions, so there was nothing of PhotoCraft's to fold in.
|
||||
- Canon CR2 is LightCraft's reader. Two things PhotoCraft's reader does differently were not taken over:
|
||||
- Colour filter phase. PhotoCraft measures the row phase from the green diagonal of the samples, with a table of
|
||||
five model IDs for flat frames, because it found no tag for it. LightCraft reads `CR2CFAPattern` (`0xc5e0`)
|
||||
from the raw IFD, which states the layout per file, and falls back to RGGB without it; both end at RGGB when
|
||||
nothing else is known. The tag is kept: it needs no samples, so `probe_info` and `decode` agree.
|
||||
- sRAW / mRAW. PhotoCraft multiplies the stored RGB by a gain set from `ColorData` (word `0x80`, version 15 only,
|
||||
observed on one body) to get camera RGB and then applies the as-shot white balance. LightCraft leaves the
|
||||
camera's white balance in (`wb_multipliers` is `[1, 1, 1]`) and measures the chroma scale per file against the
|
||||
IFD 2 image, on 31 files of 16 models including the 2008 to 2010 bodies whose chroma scale differs. That reading
|
||||
is kept; a developer that wants camera RGB for version 15 can divide by the same word.
|
||||
- Lossy JPEG is accepted for CFA data (one grey component) as in PhotoCraft; FilmCraft refuses it
|
||||
because DNG 1.4 allows the coding for LinearRaw only.
|
||||
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
//! Canon CR2.
|
||||
//!
|
||||
//! Source: Laurent Clévy, "Understanding what is stored in a Canon RAW .CR2 file" (prose sections on the file
|
||||
//! structure, IFD#3, the `0xc640` slice layout and the maker-note `SensorInfo` table), plus the ExifTool Canon
|
||||
//! tag-name documentation for maker-note tag meanings. Only the structural description was used.
|
||||
//!
|
||||
//! - The raw data is IFD#3's single strip: a lossless-JPEG frame (usually 2 or 4 components).
|
||||
//! - The decoded sample stream fills vertical slices left to right: `cr2_slice = [n, w, last]` means `n` slices of
|
||||
//! width `w` then one of width `last`; each slice is filled top to bottom, row by row.
|
||||
//! - Colour filter layout: IFD#3 tag `0xc5e0` (`CR2CFAPattern` in the ExifTool EXIF tag-name docs): 1 = RGGB,
|
||||
//! 2 = BGGR, 3 = GBRG, 4 = GRBG, anchored at the top-left of the full decoded sensor (masked borders included).
|
||||
//! It differs by model (e.g. 3 on the 50D/60D/7D/550D/5D Mark II, 1 on the 40D/5D Mark III/6D/5DS R), so it is
|
||||
//! read per file (no model table); files without the tag fall back to RGGB (storytold/lightcraft#85). The parity of the
|
||||
//! `SensorInfo` borders does *not* predict it (RGGB files come with both even and odd top borders).
|
||||
//! - `SensorInfo` (maker note `0x00e0`): sensor width/height and the left/top/right/bottom borders of the image
|
||||
//! area. Light already reaches some columns left of that border (EOS 6D: masked columns 0–70, border 84; 10–36
|
||||
//! such columns on every corpus body), so the masked columns that give the black level are measured from the
|
||||
//! data ([`masked_columns`]) rather than taken as the whole border.
|
||||
//! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known
|
||||
//! offsets and accept the first plausible quadruple. The array is 16-bit words; some models store it as UNDEFINED
|
||||
//! bytes (ColorData versions -3 and -4), which are paired up in the maker note's byte order first.
|
||||
//! - The same array lists the levels of the camera's white-balance presets, each with the colour temperature Canon
|
||||
//! gives it ([`kelvin_presets_from_color_data`]): a Kelvin scale for a body without colour matrices.
|
||||
//! - sRAW / mRAW (raw IFD tag `0xc6c5` = 4) hold subsampled YCbCr instead of a mosaic: see [`sraw`].
|
||||
|
||||
mod sraw;
|
||||
|
||||
use crate::shared::{active_rows, black_from_columns, white_from_data};
|
||||
use crate::{
|
||||
BlackLevel, Camera, Cfa, ColorData, Limits, Mode, OpcodeLists, Orientation, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg,
|
||||
};
|
||||
use craft_tiff::image::chunk_bytes;
|
||||
use craft_tiff::{ByteOrder, Ifd, Tiff, Value, makernote, tags as t};
|
||||
use std::ops::Range;
|
||||
|
||||
const CR2_SLICE: u16 = 0xc640;
|
||||
const SRAW_TYPE: u16 = 0xc6c5;
|
||||
/// `SRAW_TYPE` value of the YCbCr frames of sRAW / mRAW (IFD2's preview image carries 3).
|
||||
const SRAW_YCC: u32 = 4;
|
||||
const CR2_CFA_PATTERN: u16 = 0xc5e0;
|
||||
const SENSOR_INFO: u16 = 0x00e0;
|
||||
const COLOR_BALANCE: u16 = 0x4001;
|
||||
/// ShotInfo, whose value 7 is the white-balance setting.
|
||||
const SHOT_INFO: u16 = 0x0004;
|
||||
/// WBInfo, the PowerShot G9's preset levels (it writes no `ColorData`).
|
||||
const WB_INFO: u16 = 0x0029;
|
||||
|
||||
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
|
||||
tiff.ifds.get(3).or_else(|| tiff.ifds.iter().rev().find(|i| i.contains(CR2_SLICE)))
|
||||
}
|
||||
|
||||
/// Bayer layout from the raw IFD's `CR2CFAPattern` (`0xc5e0`) value; `None` for missing/unknown values.
|
||||
fn cfa_from_tag(v: Option<u64>) -> Option<Cfa> {
|
||||
let name = match v? {
|
||||
1 => "RGGB",
|
||||
2 => "BGGR",
|
||||
3 => "GBRG",
|
||||
4 => "GRBG",
|
||||
_ => return None,
|
||||
};
|
||||
Some(Cfa::bayer_static(name))
|
||||
}
|
||||
|
||||
/// The 16-bit words of a maker-note array. Canon writes `ColorData` as SHORT in most models, but as UNDEFINED
|
||||
/// bytes (count = byte length, e.g. 5120) in the PowerShot / EOS M models with ColorData versions -3 and -4;
|
||||
/// those bytes are the same 16-bit words in the maker note's byte order, so they are paired up, not read one by
|
||||
/// one. Other types are returned value by value.
|
||||
fn words(v: &Value, order: ByteOrder) -> Vec<u64> {
|
||||
match v {
|
||||
Value::Byte(b) | Value::Undefined(b) => b.as_chunks::<2>().0.iter().map(|c| u64::from(order.u16(*c))).collect(),
|
||||
_ => v.to_u64_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// WB multipliers (R, G, B; G = 1) from one set of levels, when they are plausible.
|
||||
fn wb_from_levels(r: f32, g1: f32, g2: f32, b: f32) -> Option<[f32; 3]> {
|
||||
if g1 < 256.0 || g2 < 256.0 || r < 64.0 || b < 64.0 || g1 > 16384.0 || (g1 - g2).abs() > 0.05 * g1 {
|
||||
return None;
|
||||
}
|
||||
let g = (g1 + g2) / 2.0;
|
||||
let (mr, mb) = (r / g, b / g);
|
||||
((0.25..6.0).contains(&mr) && (0.25..6.0).contains(&mb)).then_some([mr, 1.0, mb])
|
||||
}
|
||||
|
||||
/// As-shot WB multipliers (R, G, B; G = 1) from the ColorBalance array.
|
||||
fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> {
|
||||
for off in [63usize, 25, 24, 34, 71, 85, 105, 69, 77] {
|
||||
let q = v.get(off..off + 4)?;
|
||||
if let Some(wb) = wb_from_levels(q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32) {
|
||||
return Some(wb);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Where the planckian white-balance presets sit in `ColorData`, by `ColorDataVersion` (word 0, signed): the word
|
||||
/// index of each preset's `R G G B` levels and of its colour temperature. Daylight, Shade, Cloudy, Tungsten and
|
||||
/// Flash, in that order. Fluorescent is left out (a green light off the locus) and so is the Kelvin row (it holds the
|
||||
/// dial's last setting, and on some bodies a temperature that doesn't match its own levels: 6036 K with the camera at
|
||||
/// 5500 K on the PowerShot G1 X Mark III, Daylight's levels at 5213 K on the EOS 2000D). Positions from the ExifTool
|
||||
/// Canon tag-name documentation's `ColorData5` / `ColorData8` tables, confirmed with ExifTool run as a black-box tool
|
||||
/// on files of each version: -4 on a PowerShot G1 X Mark III (`ColorData5`: 8-word records, the second record after
|
||||
/// Daylight a repeat of it), 13, 14 and 15 on the CC0 corpus samples of the EOS 5D Mark IV, 2000D and 6D Mark II
|
||||
/// (`ColorData8`: 5-word records). Other versions give no presets.
|
||||
fn preset_rows(version: i16) -> Option<[(usize, usize); 5]> {
|
||||
match version {
|
||||
-4 => Some([(95, 102), (111, 118), (119, 126), (127, 134), (151, 158)]),
|
||||
13..=15 => Some([(133, 137), (138, 142), (143, 147), (148, 152), (163, 167)]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera's planckian white-balance presets from the `ColorData` words ([`preset_rows`]) as a Kelvin curve;
|
||||
/// `None` for an unknown version, a short array or presets that don't make a curve.
|
||||
fn kelvin_presets_from_color_data(v: &[u64]) -> Option<crate::kelvin::KelvinPresets> {
|
||||
let version = *v.first()? as u16 as i16;
|
||||
let rows = preset_rows(version)?;
|
||||
let mut points = Vec::with_capacity(rows.len());
|
||||
for (at, temp_at) in rows {
|
||||
let q = v.get(at..at + 4)?;
|
||||
let temp = *v.get(temp_at)? as f32;
|
||||
if let Some(wb) = wb_from_levels(q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32)
|
||||
&& (1000.0..=20000.0).contains(&temp)
|
||||
{
|
||||
points.push((temp, wb));
|
||||
}
|
||||
}
|
||||
crate::kelvin::KelvinPresets::from_unordered(points)
|
||||
}
|
||||
|
||||
/// As-shot WB multipliers of a body without `ColorData` (the PowerShot G9): the WBInfo row of the preset that
|
||||
/// ShotInfo's white-balance value names. WBInfo is int32 values in the maker note's byte order, four per preset in
|
||||
/// G R B G order, at the rows the ExifTool Canon tag-name documentation gives (2 Auto, 10 Daylight, 18 Cloudy,
|
||||
/// 26 Tungsten, 34 Fluorescent, 50 Flash, 58 Underwater, 66 Custom 1, 74 Custom 2); other settings (14, Daylight
|
||||
/// Fluorescent, has no documented row) give none. Measured on the CC0 file IMG_1188.CR2 (Custom 1, row 66:
|
||||
/// 848 1747 1676 848), whose camera JPEG's neutral areas imply the same gains within 0.1 %.
|
||||
fn wb_from_wb_info(m: &makernote::MakerNote) -> Option<[f32; 3]> {
|
||||
let row = match m.ifd.u64s(SHOT_INFO)?.get(7)? {
|
||||
0 => 2,
|
||||
1 => 10,
|
||||
2 => 18,
|
||||
3 => 26,
|
||||
4 => 34,
|
||||
5 => 50,
|
||||
15 => 66,
|
||||
16 => 74,
|
||||
17 => 58,
|
||||
_ => return None,
|
||||
};
|
||||
let b = m.ifd.bytes(WB_INFO)?;
|
||||
let level = |i: usize| Some(m.order.u32(b.get(4 * (row + i)..4 * (row + i + 1))?.try_into().ok()?) as i32 as f32);
|
||||
wb_from_levels(level(1)?, level(0)?, level(3)?, level(2)?)
|
||||
}
|
||||
|
||||
/// The optically black columns left of the image area: from column 2 up to two columns before the first column
|
||||
/// whose mean (over the image rows) rises above the leftmost columns' level by more than 1/256 of the remaining
|
||||
/// range (at least 16), else up to two columns before the image area. Empty when the border is too narrow.
|
||||
fn masked_columns(data: &[u16], width: usize, active: Rect, white: f32) -> Range<usize> {
|
||||
let limit = active.x.saturating_sub(2).min(width);
|
||||
if limit < 4 || active.height == 0 {
|
||||
return 0..0;
|
||||
}
|
||||
let step = (active.height / 256).max(1);
|
||||
let column_mean = |x: usize| {
|
||||
let (mut sum, mut n) = (0.0, 0usize);
|
||||
for y in (active.y..active.y.saturating_add(active.height)).step_by(step) {
|
||||
if let Some(&v) = y.checked_mul(width).and_then(|i| data.get(i.checked_add(x)?)) {
|
||||
sum += f64::from(v);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
(n > 0).then(|| sum / n as f64)
|
||||
};
|
||||
let means: Vec<f64> = (2..limit).map_while(column_mean).collect();
|
||||
let mut first: Vec<f64> = means.iter().take(8).copied().collect();
|
||||
first.sort_by(f64::total_cmp);
|
||||
let Some(&level) = first.get(first.len() / 2) else { return 0..0 };
|
||||
let tolerance = ((f64::from(white) - level) / 256.0).max(16.0);
|
||||
let end = match means.iter().position(|&m| m > level + tolerance) {
|
||||
// `means[k]` is column `2 + k`: stop two columns before it
|
||||
Some(k) => k,
|
||||
None => 2 + means.len(),
|
||||
};
|
||||
2..end.max(2)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result<RawImage> {
|
||||
let tiff = Tiff::parse(bytes)?;
|
||||
let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("TIFF without IFDs".into()))?;
|
||||
let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("CR2 without raw IFD".into()))?;
|
||||
match raw.u32(SRAW_TYPE) {
|
||||
Some(SRAW_YCC) => return sraw::decode(bytes, &tiff, raw, mode, limits),
|
||||
Some(v) if v != 1 => return Err(RawError::Unsupported(format!("Canon raw type {v}"))),
|
||||
_ => {}
|
||||
}
|
||||
let off = raw.u64(t::STRIP_OFFSETS).ok_or(RawError::Tiff(craft_tiff::TiffError::MissingTag(t::STRIP_OFFSETS)))?;
|
||||
let len = raw.u64(t::STRIP_BYTE_COUNTS).unwrap_or(bytes.len() as u64 - off.min(bytes.len() as u64));
|
||||
let chunk = craft_tiff::image::Chunk { index: 0, x: 0, y: 0, width: 0, height: 0, plane: 0, offset: off, len };
|
||||
let src = chunk_bytes(bytes, &chunk).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?;
|
||||
let (fw, fh, nc, prec) = ljpeg::frame_info(src)?;
|
||||
let frame = match mode {
|
||||
Mode::Full => {
|
||||
let total = (fw as u64)
|
||||
.checked_mul(fh as u64)
|
||||
.and_then(|v| v.checked_mul(nc as u64))
|
||||
.filter(|t| *t > 0 && *t <= limits.samples() as u64)
|
||||
.ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?;
|
||||
Some(ljpeg::decode(src, total as usize)?)
|
||||
}
|
||||
Mode::Header => None,
|
||||
};
|
||||
let total = match &frame {
|
||||
Some(f) => f.data.len(),
|
||||
None => {
|
||||
let total = fw
|
||||
.checked_mul(fh)
|
||||
.and_then(|v| v.checked_mul(nc))
|
||||
.filter(|t| *t > 0)
|
||||
.ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?;
|
||||
if total > limits.samples() {
|
||||
return Err(RawError::Limit("lossless JPEG frame larger than expected"));
|
||||
}
|
||||
total
|
||||
}
|
||||
};
|
||||
let slices = raw.u64s(CR2_SLICE).filter(|s| s.len() == 3 && s[1] > 0 && s[2] > 0);
|
||||
let widths: Vec<usize> = match &slices {
|
||||
Some(s) => {
|
||||
let n = s[0].min(64) as usize;
|
||||
let mut v = vec![s[1] as usize; n];
|
||||
v.push(s[2] as usize);
|
||||
v
|
||||
}
|
||||
None => {
|
||||
let w = (fw as u64).checked_mul(nc as u64).ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?;
|
||||
vec![w as usize]
|
||||
}
|
||||
};
|
||||
let width: usize = widths.iter().sum();
|
||||
if width == 0 || total % width != 0 {
|
||||
return Err(RawError::Corrupt(format!("CR2 slices ({width}) do not divide the frame ({total} samples)")));
|
||||
}
|
||||
let height = total / width;
|
||||
let mut data = Vec::new();
|
||||
if let Some(frame) = &frame {
|
||||
data = vec![0u16; total];
|
||||
let mut i = 0;
|
||||
let mut x0 = 0;
|
||||
for &sw in &widths {
|
||||
for y in 0..height {
|
||||
data[y * width + x0..y * width + x0 + sw].copy_from_slice(&frame.data[i..i + sw]);
|
||||
i += sw;
|
||||
}
|
||||
x0 += sw;
|
||||
}
|
||||
}
|
||||
drop(frame);
|
||||
|
||||
// maker note: sensor borders and white balance
|
||||
let make = ifd0.string(t::MAKE).unwrap_or_default();
|
||||
let mn =
|
||||
tiff.exif().and_then(|e| e.get(t::MAKER_NOTE)).and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make));
|
||||
let mut active = Rect::new(0, 0, width, height);
|
||||
if let Some(si) = mn.as_ref().and_then(|m| m.ifd.u64s(SENSOR_INFO)).filter(|v| v.len() >= 9) {
|
||||
let (l, tp, r, b) = (si[5] as usize, si[6] as usize, si[7] as usize, si[8] as usize);
|
||||
if r > l && b > tp && r < width && b < height {
|
||||
active = Rect::new(l, tp, r - l + 1, b - tp + 1);
|
||||
}
|
||||
}
|
||||
let color_data = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order)));
|
||||
let kelvin_presets = color_data.as_deref().and_then(kelvin_presets_from_color_data);
|
||||
let wb = color_data.as_deref().and_then(wb_from_color_balance).or_else(|| wb_from_wb_info(mn.as_ref()?));
|
||||
let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB"));
|
||||
let white = white_from_data(&active_rows(&data, width, active), prec as u32);
|
||||
let black = match masked_columns(&data, width, active, white) {
|
||||
cols if cols.len() >= 2 => black_from_columns(&data, width, cols, active.y..active.y + active.height, active),
|
||||
_ => BlackLevel::uniform(0.0),
|
||||
};
|
||||
let img = RawImage {
|
||||
format: RawFormat::Cr2,
|
||||
width,
|
||||
height,
|
||||
cpp: 1,
|
||||
data: RawData::U16(data),
|
||||
cfa: Some(cfa),
|
||||
bits: prec as u32,
|
||||
black,
|
||||
white: vec![white],
|
||||
active_area: active,
|
||||
crop: Rect::new(0, 0, active.width, active.height),
|
||||
orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)),
|
||||
color: ColorData { kelvin_presets, ..Default::default() },
|
||||
wb_multipliers: wb,
|
||||
linearized: false,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: OpcodeLists::default(),
|
||||
camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(),
|
||||
cinema: Default::default(),
|
||||
};
|
||||
img.validate_for(mode)?;
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use craft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
|
||||
|
||||
/// Build a synthetic CR2: IFD0..IFD2 placeholders + IFD3 holding a sliced 2-component LJ92 frame.
|
||||
pub(crate) fn synthetic_cr2(w: usize, h: usize, slices: Option<[u16; 3]>) -> (Vec<u8>, Vec<u16>) {
|
||||
synthetic_cr2_note(w, h, slices, None)
|
||||
}
|
||||
|
||||
/// Like [`synthetic_cr2`], with `note` as the Exif maker note (bytes as stored).
|
||||
fn synthetic_cr2_note(w: usize, h: usize, slices: Option<[u16; 3]>, note: Option<Vec<u8>>) -> (Vec<u8>, Vec<u16>) {
|
||||
let img: Vec<u16> = (0..w * h).map(|i| 1024 + ((i * 7919) % 12000) as u16).collect();
|
||||
(synthetic_cr2_of(&img, w, h, slices, note), img)
|
||||
}
|
||||
|
||||
/// A synthetic CR2 whose sensor samples are `img` (`w`×`h`, 14-bit), with `note` as the Exif maker note.
|
||||
fn synthetic_cr2_of(img: &[u16], w: usize, h: usize, slices: Option<[u16; 3]>, note: Option<Vec<u8>>) -> Vec<u8> {
|
||||
let widths: Vec<usize> = match slices {
|
||||
Some([n, sw, last]) => {
|
||||
let mut v = vec![sw as usize; n as usize];
|
||||
v.push(last as usize);
|
||||
v
|
||||
}
|
||||
None => vec![w],
|
||||
};
|
||||
let mut stream = Vec::with_capacity(w * h);
|
||||
let mut x0 = 0;
|
||||
for &sw in &widths {
|
||||
for y in 0..h {
|
||||
stream.extend_from_slice(&img[y * w + x0..y * w + x0 + sw]);
|
||||
}
|
||||
x0 += sw;
|
||||
}
|
||||
let enc = crate::ljpeg::encode(&stream, w / 2, h, 2, 14, 1, 0);
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("Canon".into()));
|
||||
ifd0.set(t::MODEL, Value::Ascii("Canon EOS Test".into()));
|
||||
ifd0.set(t::ORIENTATION, Value::Short(vec![8]));
|
||||
// maker note: SensorInfo + ColorBalance, plain IFD with offsets relative to the TIFF header
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(t::ISO_SPEED, Value::Short(vec![400]));
|
||||
if let Some(n) = note {
|
||||
exif.set(t::MAKER_NOTE, Value::Undefined(n));
|
||||
}
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
let mut ifd3 = IfdBuilder::new();
|
||||
ifd3.set(t::COMPRESSION, Value::Short(vec![6]));
|
||||
ifd3.set(CR2_CFA_PATTERN, Value::Long(vec![3]));
|
||||
if let Some(s) = slices {
|
||||
ifd3.set(CR2_SLICE, Value::Short(s.to_vec()));
|
||||
}
|
||||
ifd3.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![enc] });
|
||||
// (real files also carry a "CR\x02\0" signature at offset 8; probe recognises the structure without it)
|
||||
TiffWriter::new(ByteOrder::Little, false)
|
||||
.write(&[ifd0, IfdBuilder::new().with(1, Value::Short(vec![0])), IfdBuilder::new().with(1, Value::Short(vec![0])), ifd3])
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_roundtrip() {
|
||||
for slices in [None, Some([2u16, 24, 16]), Some([1, 40, 24])] {
|
||||
let (bytes, img) = synthetic_cr2(64, 10, slices);
|
||||
assert_eq!(crate::probe(&bytes), Some(RawFormat::Cr2));
|
||||
let r = crate::decode(&bytes).unwrap();
|
||||
assert_eq!((r.width, r.height), (64, 10));
|
||||
assert_eq!(crate::probe_info(&bytes).unwrap(), r.info());
|
||||
assert_eq!(r.data, RawData::U16(img));
|
||||
assert_eq!(r.orientation, Orientation::Rotate270);
|
||||
assert_eq!(r.cfa.as_ref().map(Cfa::name).as_deref(), Some("GBRG"), "CR2CFAPattern 3");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cfa_pattern_tag() {
|
||||
let name = |v| cfa_from_tag(v).map(|c| c.name());
|
||||
assert_eq!(name(Some(1)).as_deref(), Some("RGGB"));
|
||||
assert_eq!(name(Some(2)).as_deref(), Some("BGGR"));
|
||||
assert_eq!(name(Some(3)).as_deref(), Some("GBRG"));
|
||||
assert_eq!(name(Some(4)).as_deref(), Some("GRBG"));
|
||||
assert_eq!(name(Some(0)), None);
|
||||
assert_eq!(name(Some(7)), None);
|
||||
assert_eq!(name(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wb_probe() {
|
||||
let mut v = vec![0u64; 120];
|
||||
v[63..67].copy_from_slice(&[2000, 1024, 1026, 1500]);
|
||||
let wb = wb_from_color_balance(&v).unwrap();
|
||||
assert!((wb[0] - 2000.0 / 1025.0).abs() < 1e-4 && (wb[2] - 1500.0 / 1025.0).abs() < 1e-4);
|
||||
assert!(wb_from_color_balance(&[0; 10]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wb_from_undefined_bytes() {
|
||||
// hand-coded: ColorData of an S110-like file, UNDEFINED bytes, little-endian words; word 71.. = 1988 745 745 1641
|
||||
let mut b = vec![0u8; 2 * 120];
|
||||
b[0..2].copy_from_slice(&[0xfd, 0xff]); // version -3
|
||||
b[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]);
|
||||
let le = words(&Value::Undefined(b.clone()), ByteOrder::Little);
|
||||
assert_eq!(&le[71..75], &[1988, 745, 745, 1641]);
|
||||
let wb = wb_from_color_balance(&le).unwrap();
|
||||
assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5);
|
||||
// read as single bytes (the old behaviour) nothing is plausible
|
||||
assert!(wb_from_color_balance(&b.iter().map(|&x| u64::from(x)).collect::<Vec<_>>()).is_none());
|
||||
// big-endian note: same words, bytes swapped
|
||||
let mut be = b.clone();
|
||||
be.as_chunks_mut::<2>().0.iter_mut().for_each(|c| c.swap(0, 1));
|
||||
assert_eq!(words(&Value::Undefined(be), ByteOrder::Big), le);
|
||||
// SHORT values pass through, an odd trailing byte is ignored
|
||||
assert_eq!(words(&Value::Short(vec![1, 2, 3]), ByteOrder::Little), vec![1, 2, 3]);
|
||||
assert_eq!(words(&Value::Undefined(vec![1, 0, 2]), ByteOrder::Little), vec![1]);
|
||||
}
|
||||
|
||||
/// Light reaches columns left of the `SensorInfo` border (as on the EOS 6D): the black level comes from the
|
||||
/// masked columns only, not from the image columns between them and the border.
|
||||
#[test]
|
||||
fn black_from_masked_columns_only() {
|
||||
let (w, h, border, light) = (40, 12, 20, 14);
|
||||
let data: Vec<u16> = (0..w * h).map(|i| if i % w < light { 2048 + (i % 2) as u16 } else { 6000 }).collect();
|
||||
let active = Rect::new(border, 2, w - border, h - 2);
|
||||
let cols = masked_columns(&data, w, active, 15000.0);
|
||||
assert_eq!(cols, 2..light - 2);
|
||||
let black = black_from_columns(&data, w, cols, 2..h, active);
|
||||
assert!(black.values.iter().all(|v| (2048.0..=2049.0).contains(v)), "{:?}", black.values);
|
||||
// the whole border masked: every column but the two next to the image
|
||||
let dark: Vec<u16> = (0..w * h).map(|i| if i % w < border { 2048 } else { 6000 }).collect();
|
||||
assert_eq!(masked_columns(&dark, w, active, 15000.0), 2..border - 2);
|
||||
// too narrow a border, or no samples (headers-only decode): none
|
||||
assert!(masked_columns(&dark, w, Rect::new(5, 0, 30, h), 15000.0).is_empty());
|
||||
assert!(masked_columns(&[], w, active, 15000.0).is_empty());
|
||||
}
|
||||
|
||||
/// A maker note holding only `tag` as `ty` (3 = SHORT, 7 = UNDEFINED) with `data`, a plain IFD whose value
|
||||
/// offset is relative to the TIFF header (patched by [`patch_note`] once the note's position is known).
|
||||
fn note_with(tag: u16, ty: u16, data: &[u8]) -> Vec<u8> {
|
||||
let count = if ty == 3 { data.len() / 2 } else { data.len() } as u32;
|
||||
let mut note = Vec::new();
|
||||
note.extend_from_slice(&1u16.to_le_bytes());
|
||||
note.extend_from_slice(&tag.to_le_bytes());
|
||||
note.extend_from_slice(&ty.to_le_bytes());
|
||||
note.extend_from_slice(&count.to_le_bytes());
|
||||
note.extend_from_slice(&0u32.to_le_bytes()); // value offset, patched below
|
||||
note.extend_from_slice(&0u32.to_le_bytes()); // next IFD
|
||||
note.extend_from_slice(data);
|
||||
note
|
||||
}
|
||||
|
||||
fn patch_note(bytes: &mut [u8]) {
|
||||
let tiff = Tiff::parse(bytes).unwrap();
|
||||
let e = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap();
|
||||
let off = e.offset as usize;
|
||||
bytes[off + 10..off + 14].copy_from_slice(&(e.offset as u32 + 18).to_le_bytes());
|
||||
}
|
||||
|
||||
fn decode_with_color_data(ty: u16, data: &[u8]) -> RawImage {
|
||||
let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note_with(COLOR_BALANCE, ty, data)));
|
||||
patch_note(&mut bytes);
|
||||
crate::decode(&bytes).unwrap()
|
||||
}
|
||||
|
||||
/// A maker note of `entries` (tag, TIFF type, value bytes), each value stored after the IFD, decoded; the value
|
||||
/// offsets (relative to the TIFF header) are patched once the note's position is known.
|
||||
fn decode_with_note(entries: &[(u16, u16, Vec<u8>)]) -> RawImage {
|
||||
let ifd_len = 2 + 12 * entries.len() + 4;
|
||||
let (mut note, mut data, mut at) = (Vec::new(), Vec::new(), Vec::new());
|
||||
note.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
for (tag, ty, value) in entries {
|
||||
let count = if *ty == 3 { value.len() / 2 } else { value.len() } as u32;
|
||||
note.extend_from_slice(&tag.to_le_bytes());
|
||||
note.extend_from_slice(&ty.to_le_bytes());
|
||||
note.extend_from_slice(&count.to_le_bytes());
|
||||
at.push((note.len(), ifd_len + data.len()));
|
||||
note.extend_from_slice(&0u32.to_le_bytes());
|
||||
data.extend_from_slice(value);
|
||||
}
|
||||
note.extend_from_slice(&0u32.to_le_bytes());
|
||||
note.extend_from_slice(&data);
|
||||
let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note));
|
||||
let tiff = Tiff::parse(&bytes).unwrap();
|
||||
let off = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize;
|
||||
for (field, value) in at {
|
||||
bytes[off + field..off + field + 4].copy_from_slice(&((off + value) as u32).to_le_bytes());
|
||||
}
|
||||
crate::decode(&bytes).unwrap()
|
||||
}
|
||||
|
||||
/// storytold/lightcraft#1273: the PowerShot G9 has no `ColorData`; its preset levels are WBInfo (`0x0029`: BYTE, int32 values,
|
||||
/// four per preset in G R B G order), and ShotInfo (`0x0004`) value 7 says which preset was used (15 = Custom 1,
|
||||
/// row 66: 848 1747 1676 848 on the CC0 file IMG_1188.CR2).
|
||||
#[test]
|
||||
fn white_balance_from_wb_info_when_there_is_no_color_data() {
|
||||
let shot_info = |wb: u16| {
|
||||
let mut v = [0u16; 34];
|
||||
v[7] = wb;
|
||||
v.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<u8>>()
|
||||
};
|
||||
let mut rows = vec![0i32; 512];
|
||||
rows[2..6].copy_from_slice(&[850, 1805, 1684, 850]);
|
||||
rows[66..70].copy_from_slice(&[848, 1747, 1676, 848]);
|
||||
rows[74..78].copy_from_slice(&[820, 1901, 4000, 820]);
|
||||
rows[18..22].copy_from_slice(&[850, 1805, 6000, 850]);
|
||||
let wb_info: Vec<u8> = rows.iter().flat_map(|x| x.to_le_bytes()).collect();
|
||||
let wb = |si: u16| decode_with_note(&[(0x0004, 3, shot_info(si)), (0x0029, 1, wb_info.clone())]).wb_multipliers;
|
||||
assert_eq!(wb(15), Some([1747.0 / 848.0, 1.0, 1676.0 / 848.0]));
|
||||
assert_eq!(wb(0), Some([1805.0 / 850.0, 1.0, 1684.0 / 850.0]));
|
||||
// each preset its own row (Custom 2: row 74); an empty row (Daylight here), an implausible one (Cloudy:
|
||||
// B 7.1) or a value without a documented row (14) gives none
|
||||
assert_eq!(wb(16), Some([1901.0 / 820.0, 1.0, 4000.0 / 820.0]));
|
||||
assert_eq!(wb(1), None);
|
||||
assert_eq!(wb(2), None);
|
||||
assert_eq!(wb(14), None);
|
||||
// without ShotInfo, or with WBInfo too short for the row
|
||||
assert_eq!(decode_with_note(&[(0x0029, 1, wb_info.clone())]).wb_multipliers, None);
|
||||
assert_eq!(decode_with_note(&[(0x0004, 3, shot_info(15)), (0x0029, 1, wb_info[..4 * 68].to_vec())]).wb_multipliers, None);
|
||||
// ColorData, when it gives levels, wins
|
||||
let mut color_data = vec![0u8; 2 * 120];
|
||||
color_data[0..2].copy_from_slice(&[0xfd, 0xff]);
|
||||
color_data[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]);
|
||||
let both = decode_with_note(&[(0x0004, 3, shot_info(15)), (0x0029, 1, wb_info.clone()), (COLOR_BALANCE, 7, color_data)]);
|
||||
assert_eq!(both.wb_multipliers, Some([1988.0 / 745.0, 1.0, 1641.0 / 745.0]));
|
||||
}
|
||||
|
||||
/// `ColorData` stored as UNDEFINED bytes (versions -3 / -4: PowerShot, EOS M) gives the as-shot WB from word 71,
|
||||
/// exactly as the same words stored as SHORT do.
|
||||
#[test]
|
||||
fn color_data_stored_as_bytes() {
|
||||
let mut bytes = vec![0u8; 2 * 120];
|
||||
bytes[0..2].copy_from_slice(&[0xfd, 0xff]); // version -3
|
||||
bytes[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]); // 1988 745 745 1641
|
||||
for ty in [7, 3] {
|
||||
let wb = decode_with_color_data(ty, &bytes).wb_multipliers.unwrap_or_else(|| panic!("type {ty}: no WB"));
|
||||
assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5, "type {ty}: {wb:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `ColorData` words of `version` with the planckian presets (R, B over G = 1024, Kelvin) at its
|
||||
/// [`preset_rows`], a Fluorescent-like green row between them, and the as-shot levels at word 71 / 63.
|
||||
fn color_data_with_presets(version: i16, presets: &[(u16, u16, u16)]) -> Vec<u64> {
|
||||
let mut v = vec![0u64; 400];
|
||||
v[0] = u64::from(version as u16);
|
||||
for ((at, temp_at), &(r, b, k)) in preset_rows(version).unwrap().into_iter().zip(presets) {
|
||||
v[at..at + 4].copy_from_slice(&[r.into(), 1024, 1024, b.into()]);
|
||||
v[temp_at] = k.into();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// storytold/lightcraft#157 of a downstream fork: the PowerShot G1 X Mark III (`ColorData` -4) gets a Kelvin curve from its
|
||||
/// Daylight, Shade, Cloudy, Tungsten and Flash presets (levels and temperatures as its files carry them), which
|
||||
/// puts a preset's own levels at the preset's temperature; the EOS bodies of versions 13–15 likewise.
|
||||
#[test]
|
||||
fn kelvin_presets_from_color_data_presets() {
|
||||
let g1x3 = [(1418, 1357, 5651), (1653, 1171, 7505), (1533, 1259, 6594), (963, 2016, 3174), (1807, 1080, 8529)];
|
||||
let v = color_data_with_presets(-4, &g1x3);
|
||||
let p = kelvin_presets_from_color_data(&v).unwrap();
|
||||
// every preset is on the curve at its own temperature, in Kelvin order
|
||||
let mut want: Vec<(f32, [f32; 3])> = g1x3.iter().map(|&(r, b, k)| (k as f32, [r as f32 / 1024.0, 1.0, b as f32 / 1024.0])).collect();
|
||||
want.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
assert_eq!(p.points(), want);
|
||||
let eos = [(2013, 1479, 5200), (2300, 1280, 7000), (2153, 1374, 6000), (1442, 2089, 3200), (2203, 1371, 6156)];
|
||||
for version in [13, 14, 15] {
|
||||
assert!(kelvin_presets_from_color_data(&color_data_with_presets(version, &eos)).is_some(), "{version}");
|
||||
}
|
||||
// a preset that would turn the curve back (Flash cooler than Shade's levels say) is dropped, not fatal
|
||||
let mut odd = g1x3;
|
||||
odd[4] = (1500, 1300, 9000);
|
||||
assert!(kelvin_presets_from_color_data(&color_data_with_presets(-4, &odd)).is_some());
|
||||
}
|
||||
|
||||
/// Unknown versions, short or garbled arrays give no presets and never panic.
|
||||
#[test]
|
||||
fn kelvin_presets_from_hostile_color_data() {
|
||||
let g1x3 = [(1418, 1357, 5651), (1653, 1171, 7505), (1533, 1259, 6594), (963, 2016, 3174), (1807, 1080, 8529)];
|
||||
let good = color_data_with_presets(-4, &g1x3);
|
||||
let mut other = good.clone();
|
||||
other[0] = u64::from(-3i16 as u16);
|
||||
assert!(kelvin_presets_from_color_data(&other).is_none(), "unverified version");
|
||||
assert!(kelvin_presets_from_color_data(&[]).is_none());
|
||||
for len in [1, 96, 120, 157, 158] {
|
||||
assert!(kelvin_presets_from_color_data(&good[..len]).is_none(), "{len}");
|
||||
}
|
||||
let mut zeros = good.clone();
|
||||
for at in [102, 118, 126] {
|
||||
zeros[at] = 0;
|
||||
}
|
||||
assert!(kelvin_presets_from_color_data(&zeros).is_none(), "two points left");
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
for _ in 0..2000 {
|
||||
let v: Vec<u64> = (0..200)
|
||||
.map(|i| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
if i == 0 { [0xfffc, 13, 14, 15][(seed % 4) as usize] } else { seed % 70000 }
|
||||
})
|
||||
.collect();
|
||||
let _ = kelvin_presets_from_color_data(&v);
|
||||
}
|
||||
// through a whole decode, ColorData as UNDEFINED bytes
|
||||
let bytes: Vec<u8> = good.iter().flat_map(|&w| (w as u16).to_le_bytes()).collect();
|
||||
assert!(decode_with_color_data(7, &bytes).color.kelvin_presets.is_some());
|
||||
assert!(decode_with_color_data(7, &bytes[..200]).color.kelvin_presets.is_none());
|
||||
}
|
||||
|
||||
/// Masked rows above the image full of samples at 16383 (EOS 50D, storytold/lightcraft#1272): the white level is the image's
|
||||
/// own saturation plateau, not the edge of that strip, so clipped highlights still reach 1.0.
|
||||
#[test]
|
||||
fn white_from_active_area_only() {
|
||||
let (w, h, top, left) = (64, 40, 4, 8);
|
||||
let img: Vec<u16> = (0..w * h)
|
||||
.map(|i| match (i % w, i / w) {
|
||||
(_, y) if y < top => 16383,
|
||||
(x, _) if x < left => 1024,
|
||||
_ if i % 7 == 0 => 13433,
|
||||
_ => 1024 + ((i * 7919) % 11000) as u16,
|
||||
})
|
||||
.collect();
|
||||
// SensorInfo: width, height, three unused words, then the inclusive left/top/right/bottom borders
|
||||
let si: Vec<u8> = [w, h, 0, 0, 0, left, top, w - 1, h - 1].iter().flat_map(|&v| (v as u16).to_le_bytes()).collect();
|
||||
let mut bytes = synthetic_cr2_of(&img, w, h, None, Some(note_with(SENSOR_INFO, 3, &si)));
|
||||
patch_note(&mut bytes);
|
||||
let r = crate::decode(&bytes).unwrap();
|
||||
assert_eq!(r.active_area, Rect::new(left, top, w - left, h - top));
|
||||
assert!((13300.0..=13433.0).contains(&r.white[0]), "{:?}", r.white);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
//! Canon sRAW / mRAW: reduced-resolution YCbCr "raw" frames in CR2 (raw IFD tag `0xc6c5` = 4).
|
||||
//!
|
||||
//! What the files contain (measured on the 31 CC0 sRAW / mRAW samples of 16 models, see the evidence below):
|
||||
//!
|
||||
//! - The raw strip is one lossless-JPEG frame with three components. Luma is sampled 2×1 (sRAW, 4:2:2, `0x21`) or
|
||||
//! 2×2 (mRAW, 4:2:0, `0x22`), both chroma components 1×1; precision 15, predictor 1, no restart markers.
|
||||
//! The frame's width and height are only a factorisation of the sample count: they are not the image size.
|
||||
//! - The decoded sample stream (MCU after MCU: `Y Y Cb Cr` or `Y Y Y Y Cb Cr`) is cut into vertical slices exactly
|
||||
//! like the CFA data (`cr2_slice = [n, w, last]`, widths counted in samples). A slice row is a whole number of
|
||||
//! MCUs, so the image is `Σw / MCU-size × 2` pixels wide, and one slice row is one pixel row (4:2:2) or two (4:2:0).
|
||||
//! - Prediction: each component is a one-dimensional chain in decoding order (so the second luma row of a 4:2:0 MCU
|
||||
//! continues from the end of the first), and the first sample of every frame row starts from the first sample of
|
||||
//! the previous one ([`ljpeg::Prediction::Sequential`]). The Sony prediction (geometric) gives vertical stripes on
|
||||
//! these files.
|
||||
//! - The samples are linear. Luma is the BT.601 luma (0.299, 0.587, 0.114; also the TIFF 6.0 default
|
||||
//! `YCbCrCoefficients`) of white-balanced camera RGB, about 7/8 of the black-subtracted sensor counts; chroma is
|
||||
//! centred on 16384. White balance is baked in (neutral surfaces have neutral chroma); which of the maker note's
|
||||
//! white-balance sets was used is not always the as-shot one, so it is not undone.
|
||||
//! - Chroma is stored as a scaled colour difference, `R − Y = a·(Cr − 16384)` and `B − Y = b·(Cb − 16384)`, with
|
||||
//! `G` from the luma equation. `a` and `b` are about 1.0 on bodies from 2012 on, but about 5.7 and 7.4 (the TIFF
|
||||
//! 6.0 YCbCr ratio 1.402 : 1.772, scaled by about 4.1) on the 2008–2010 bodies (5D Mark II, 50D, 7D, 60D, 1D Mark IV).
|
||||
//! Rather than keying a table on the model, the gains are measured per file against the 16-bit RGB image the
|
||||
//! camera stores in IFD2 for these files (see [`chroma_gains`]).
|
||||
//!
|
||||
//! Only these files were used as evidence: the sRAW samples themselves, the same cameras' CFA files of the same
|
||||
//! scenes (raw counts against the decoded samples), and the IFD2 image. The ExifTool tag documentation was used for
|
||||
//! tag names.
|
||||
|
||||
use super::{COLOR_BALANCE, CR2_SLICE, SENSOR_INFO, words};
|
||||
use crate::{BlackLevel, Camera, ColorData, Limits, Mode, OpcodeLists, Orientation, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg};
|
||||
use craft_tiff::image::{Chunk, chunk_bytes};
|
||||
use craft_tiff::{ByteOrder, Ifd, Tiff, makernote, tags as t};
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// Chroma samples are centred on this value.
|
||||
const CHROMA_CENTRE: f64 = 16384.0;
|
||||
/// BT.601 luma weights, the TIFF 6.0 default `YCbCrCoefficients`.
|
||||
const KR: f64 = 0.299;
|
||||
const KG: f64 = 0.587;
|
||||
const KB: f64 = 0.114;
|
||||
/// Decoded luma relative to black-subtracted sensor counts (white-balanced): 0.877, 0.877, 0.896, 0.880, 0.871, 0.851
|
||||
/// and 0.846 on the 7D Mark II, 5DS R, 6D Mark II, 5DS, 5D Mark II, 1D X Mark II and 50D (CFA and sRAW files of the
|
||||
/// same scene), so 7/8. It sets the white level, so that a file renders about as bright as the CFA file of the same
|
||||
/// scene.
|
||||
const LUMA_GAIN: f32 = 0.875;
|
||||
/// Full scale of a 14-bit sensor, the white level of a CFA CR2 without a saturation plateau.
|
||||
const SENSOR_FULL: f32 = 16383.0;
|
||||
/// Mid-tone limit (IFD2 units) of the pixels used for the chroma fit: above about a quarter of full scale the
|
||||
/// camera's own RGB image rolls off.
|
||||
const FIT_LIMIT: f64 = 4096.0;
|
||||
|
||||
/// How the decoded frame maps to the image.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) struct Layout {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
/// Vertical subsampling of the chroma planes: 1 (4:2:2, sRAW) or 2 (4:2:0, mRAW).
|
||||
pub vertical: usize,
|
||||
/// Slice widths in samples.
|
||||
widths: Vec<usize>,
|
||||
/// Slice height in MCU rows.
|
||||
rows: usize,
|
||||
/// Samples per MCU.
|
||||
mcu: usize,
|
||||
/// Frame size from the SOF3 header.
|
||||
frame_width: usize,
|
||||
frame_height: usize,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// The layout of a `frame_width × frame_height` frame with `vertical` chroma subsampling and `cr2_slice` tag
|
||||
/// values `slices` (`[n, w, last]`).
|
||||
pub(super) fn new(frame_width: usize, frame_height: usize, vertical: usize, slices: &[u64], limits: &Limits) -> Result<Layout> {
|
||||
let &[n, w, last] = slices else { return Err(RawError::Corrupt("sRAW without slice layout".into())) };
|
||||
if n > 64 || w == 0 || last == 0 {
|
||||
return Err(RawError::Corrupt("sRAW slice layout".into()));
|
||||
}
|
||||
let mcu = 2 * vertical + 2;
|
||||
let mut widths = vec![w as usize; n as usize];
|
||||
widths.push(last as usize);
|
||||
if widths.iter().any(|w| w % mcu != 0) {
|
||||
return Err(RawError::Corrupt("sRAW slice is not a whole number of MCUs".into()));
|
||||
}
|
||||
let samples =
|
||||
frame_width.checked_mul(frame_height).and_then(|p| p.checked_add(p / vertical)).ok_or(RawError::Limit("sRAW frame too large"))?;
|
||||
let row = widths.iter().try_fold(0usize, |a, &w| a.checked_add(w)).ok_or(RawError::Limit("sRAW slice widths"))?;
|
||||
if samples == 0 || row == 0 || samples % row != 0 {
|
||||
return Err(RawError::Corrupt(format!("sRAW slices ({row} samples per row) do not divide the frame ({samples} samples)")));
|
||||
}
|
||||
let rows = samples / row;
|
||||
let (width, height) = (row / mcu * 2, rows * vertical);
|
||||
if width.checked_mul(height).and_then(|p| p.checked_mul(3)).is_none_or(|s| s > limits.samples()) {
|
||||
return Err(RawError::Limit("sRAW image too large"));
|
||||
}
|
||||
Ok(Layout { width, height, vertical, widths, rows, mcu, frame_width, frame_height })
|
||||
}
|
||||
}
|
||||
|
||||
/// Planar YCbCr at image geometry: luma `width × height`, chroma `width/2 × height/vertical`.
|
||||
struct Planes {
|
||||
width: usize,
|
||||
height: usize,
|
||||
vertical: usize,
|
||||
y: Vec<u16>,
|
||||
cb: Vec<u16>,
|
||||
cr: Vec<u16>,
|
||||
}
|
||||
|
||||
impl Planes {
|
||||
fn chroma_width(&self) -> usize {
|
||||
self.width / 2
|
||||
}
|
||||
|
||||
/// Re-chop the decoded MCU stream into image planes.
|
||||
fn arrange(frame: &ljpeg::FrameSubsampled, layout: &Layout) -> Planes {
|
||||
let (w, h, v, m) = (layout.width, layout.height, layout.vertical, layout.mcu);
|
||||
let cw = w / 2;
|
||||
let mut p = Planes { width: w, height: h, vertical: v, y: vec![0; w * h], cb: vec![0; cw * (h / v)], cr: vec![0; cw * (h / v)] };
|
||||
let mcus_per_frame_row = layout.frame_width / 2;
|
||||
let mut x0 = 0;
|
||||
for &sw in &layout.widths {
|
||||
let per_row = sw / m;
|
||||
let first = layout.rows * (x0 / m);
|
||||
for r in 0..layout.rows {
|
||||
for k in 0..per_row {
|
||||
let q = first + r * per_row + k;
|
||||
let (qy, qx) = (q / mcus_per_frame_row, q % mcus_per_frame_row);
|
||||
let px = (x0 / m + k) * 2;
|
||||
for dy in 0..v {
|
||||
let src = (qy * v + dy) * layout.frame_width + qx * 2;
|
||||
let dst = (r * v + dy) * w + px;
|
||||
p.y[dst..dst + 2].copy_from_slice(&frame.planes[0][src..src + 2]);
|
||||
}
|
||||
p.cb[r * cw + px / 2] = frame.planes[1][qy * mcus_per_frame_row + qx];
|
||||
p.cr[r * cw + px / 2] = frame.planes[2][qy * mcus_per_frame_row + qx];
|
||||
}
|
||||
}
|
||||
x0 += sw;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Mean luma / Cb / Cr over the pixel window `[x0, x1) × [y0, y1)` (non-empty, inside the image).
|
||||
fn window_mean(&self, x0: usize, x1: usize, y0: usize, y1: usize) -> (f64, f64, f64) {
|
||||
let (mut sy, mut ny) = (0f64, 0f64);
|
||||
for y in y0..y1 {
|
||||
let row = &self.y[y * self.width + x0..y * self.width + x1];
|
||||
sy += row.iter().map(|&v| f64::from(v)).sum::<f64>();
|
||||
ny += row.len() as f64;
|
||||
}
|
||||
let (cx0, cx1) = (x0 / 2, x1.div_ceil(2).max(x0 / 2 + 1));
|
||||
let (cy0, cy1) = (y0 / self.vertical, y1.div_ceil(self.vertical).max(y0 / self.vertical + 1));
|
||||
let (mut sb, mut sr, mut nc) = (0f64, 0f64, 0f64);
|
||||
for y in cy0..cy1.min(self.height / self.vertical) {
|
||||
for x in cx0..cx1.min(self.chroma_width()) {
|
||||
sb += f64::from(self.cb[y * self.chroma_width() + x]);
|
||||
sr += f64::from(self.cr[y * self.chroma_width() + x]);
|
||||
nc += 1.0;
|
||||
}
|
||||
}
|
||||
let nc = nc.max(1.0);
|
||||
(sy / ny.max(1.0), sb / nc - CHROMA_CENTRE, sr / nc - CHROMA_CENTRE)
|
||||
}
|
||||
|
||||
/// Linear RGB (`width × height × 3`): `R = Y + a·Cr`, `B = Y + b·Cb`, `G` from the luma equation.
|
||||
fn to_rgb(&self, a: f64, b: f64) -> Vec<u16> {
|
||||
let (gr, gb) = ((KR / KG * a) as f32, (KB / KG * b) as f32);
|
||||
let (a, b) = (a as f32, b as f32);
|
||||
let cw = self.chroma_width();
|
||||
let centre = CHROMA_CENTRE as f32;
|
||||
let mut out = vec![0u16; self.width * self.height * 3];
|
||||
out.par_chunks_mut(self.width * 3).enumerate().for_each(|(y, row)| {
|
||||
let luma = &self.y[y * self.width..(y + 1) * self.width];
|
||||
let chroma = (y / self.vertical) * cw;
|
||||
for (x, px) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() {
|
||||
let (cb, cr) = (f32::from(self.cb[chroma + x / 2]) - centre, f32::from(self.cr[chroma + x / 2]) - centre);
|
||||
let l = f32::from(luma[x]);
|
||||
px[0] = (l + a * cr).round().clamp(0.0, 65535.0) as u16;
|
||||
px[1] = (l - gr * cr - gb * cb).round().clamp(0.0, 65535.0) as u16;
|
||||
px[2] = (l + b * cb).round().clamp(0.0, 65535.0) as u16;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The 16-bit RGB image in IFD2 (`SRawType` 3): the camera's own rendering of the same frame at a fraction of the
|
||||
/// size, white-balanced and linear below about a quarter of full scale.
|
||||
struct Preview {
|
||||
width: usize,
|
||||
height: usize,
|
||||
rgb: Vec<u16>,
|
||||
}
|
||||
|
||||
fn read_preview(bytes: &[u8], tiff: &Tiff) -> Option<Preview> {
|
||||
let info = tiff.ifds.get(2)?.image().ok()?;
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
if info.samples_per_pixel != 3 || info.bits_per_sample != [16, 16, 16] || info.compression != 1 || info.planar != 1 || info.sample_format != 1 {
|
||||
return None;
|
||||
}
|
||||
let n = w.checked_mul(h)?.checked_mul(3)?;
|
||||
// uncompressed 16-bit samples: more than the whole file holds is refused before the buffer is reserved
|
||||
if n == 0 || n > 1 << 24 || n * 2 > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let mut raw: Vec<u8> = Vec::with_capacity(n * 2);
|
||||
for c in info.chunks(bytes.len() as u64) {
|
||||
raw.extend_from_slice(chunk_bytes(bytes, &c)?);
|
||||
if raw.len() >= n * 2 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if raw.len() < n * 2 {
|
||||
return None;
|
||||
}
|
||||
let order: ByteOrder = tiff.order;
|
||||
let rgb = raw.as_chunks::<2>().0.iter().take(n).map(|&c| order.u16(c)).collect();
|
||||
Some(Preview { width: w, height: h, rgb })
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Regression {
|
||||
n: f64,
|
||||
sx: f64,
|
||||
st: f64,
|
||||
sxx: f64,
|
||||
sxt: f64,
|
||||
stt: f64,
|
||||
}
|
||||
|
||||
impl Regression {
|
||||
fn add(&mut self, x: f64, t: f64) {
|
||||
self.n += 1.0;
|
||||
self.sx += x;
|
||||
self.st += t;
|
||||
self.sxx += x * x;
|
||||
self.sxt += x * t;
|
||||
self.stt += t * t;
|
||||
}
|
||||
|
||||
/// Least-squares slope of `t` on `x` (with intercept) and its residual sum of squares.
|
||||
fn slope(&self) -> Option<(f64, f64)> {
|
||||
let var = self.n * self.sxx - self.sx * self.sx;
|
||||
if self.n < 500.0 || var <= 1e-9 * self.n * self.n {
|
||||
return None;
|
||||
}
|
||||
let slope = (self.n * self.sxt - self.sx * self.st) / var;
|
||||
let icpt = (self.st - slope * self.sx) / self.n;
|
||||
let rss =
|
||||
self.stt - 2.0 * slope * self.sxt - 2.0 * icpt * self.st + slope * slope * self.sxx + 2.0 * slope * icpt * self.sx + self.n * icpt * icpt;
|
||||
Some((slope, rss.max(0.0)))
|
||||
}
|
||||
}
|
||||
|
||||
/// How the IFD2 image sits on the full-size planes: its pixel `(i, j)` covers the window starting at
|
||||
/// `(x0 + i·sx, y0 + j·sy)` and `sx × sy` pixels large.
|
||||
struct Grid {
|
||||
x0: f64,
|
||||
y0: f64,
|
||||
sx: f64,
|
||||
sy: f64,
|
||||
}
|
||||
|
||||
/// The chroma gains `(a, b)` of this file (`R − Y = a·Cr`, `B − Y = b·Cb`), by least squares of the IFD2 image
|
||||
/// against the planes averaged to its size. Where IFD2 sits on the frame depends on the model (the whole frame,
|
||||
/// the sensor's active area, or a whole or half-integer reduction of the frame that overhangs it by a few pixels),
|
||||
/// so these are tried and the best fit is kept. `None` when the file has no usable IFD2 or the fit is not well
|
||||
/// conditioned.
|
||||
fn chroma_gains(planes: &Planes, preview: &Preview, active: Rect) -> Option<(f64, f64)> {
|
||||
let (pw, ph) = (preview.width as f64, preview.height as f64);
|
||||
let (w, h) = (planes.width as f64, planes.height as f64);
|
||||
let grids = [
|
||||
Grid { x0: 0.0, y0: 0.0, sx: w / pw, sy: h / ph },
|
||||
Grid { x0: active.x as f64, y0: active.y as f64, sx: active.width as f64 / pw, sy: active.height as f64 / ph },
|
||||
Grid { x0: 0.0, y0: 0.0, sx: (2.0 * w / pw).round() / 2.0, sy: (2.0 * h / ph).round() / 2.0 },
|
||||
];
|
||||
let mut best: Option<(f64, f64, f64)> = None;
|
||||
for g in &grids {
|
||||
if !(g.sx >= 1.0 && g.sy >= 1.0) {
|
||||
continue;
|
||||
}
|
||||
let (mut fr, mut fb) = (Regression::default(), Regression::default());
|
||||
for j in 0..preview.height {
|
||||
let y0 = (g.y0 + j as f64 * g.sy) as usize;
|
||||
let y1 = ((g.y0 + (j + 1) as f64 * g.sy) as usize).min(planes.height);
|
||||
for i in 0..preview.width {
|
||||
let p = &preview.rgb[(j * preview.width + i) * 3..][..3];
|
||||
let [r, gr, b] = [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])];
|
||||
if r.max(gr).max(b) >= FIT_LIMIT || r.min(gr).min(b) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let x0 = (g.x0 + i as f64 * g.sx) as usize;
|
||||
let x1 = ((g.x0 + (i + 1) as f64 * g.sx) as usize).min(planes.width);
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
continue;
|
||||
}
|
||||
let (y, cb, cr) = planes.window_mean(x0, x1, y0, y1);
|
||||
if y < 150.0 {
|
||||
continue;
|
||||
}
|
||||
fr.add(cr, r - y);
|
||||
fb.add(cb, b - y);
|
||||
}
|
||||
}
|
||||
if let (Some((a, ra)), Some((b, rb))) = (fr.slope(), fb.slope()) {
|
||||
let cost = (ra + rb) / (fr.n + fb.n);
|
||||
if a.is_finite() && b.is_finite() && best.is_none_or(|(_, _, c)| cost < c) {
|
||||
best = Some((a, b, cost));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(a, b, _)| (a, b)).filter(|(a, b)| (0.25..=16.0).contains(a) && (0.25..=16.0).contains(b))
|
||||
}
|
||||
|
||||
/// Word index of `AverageBlackLevel` (four equal-ish values) in the maker note's `ColorData` by `ColorDataVersion`
|
||||
/// (word 0), located by searching the CC0 samples for the values ExifTool reports.
|
||||
fn average_black_at(version: i16) -> Option<usize> {
|
||||
Some(match version {
|
||||
6 | 7 | 9 => 231,
|
||||
10 | 11 => 276,
|
||||
12 | 13 | 15 => 326,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The sensor's black level in raw counts from `ColorData` (`None` for versions whose position is not known, and
|
||||
/// when the four values are not plausible and near-equal).
|
||||
fn colour_data_black(words: &[u64]) -> Option<f32> {
|
||||
let version = words.first().map(|&v| v as u16 as i16)?;
|
||||
let q = words.get(average_black_at(version)?..)?.get(..4)?;
|
||||
let (lo, hi) = (q.iter().min()?, q.iter().max()?);
|
||||
((256..=4096).contains(lo) && hi - lo <= 8).then(|| q.iter().sum::<u64>() as f32 / 4.0)
|
||||
}
|
||||
|
||||
/// Decode a Canon sRAW / mRAW CR2 into linear white-balanced camera RGB (`cpp` 3).
|
||||
pub(super) fn decode(bytes: &[u8], tiff: &Tiff, raw: &Ifd, mode: Mode, limits: &Limits) -> Result<RawImage> {
|
||||
let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("TIFF without IFDs".into()))?;
|
||||
let off = raw.u64(t::STRIP_OFFSETS).ok_or(RawError::Tiff(craft_tiff::TiffError::MissingTag(t::STRIP_OFFSETS)))?;
|
||||
let len = raw.u64(t::STRIP_BYTE_COUNTS).unwrap_or(bytes.len() as u64 - off.min(bytes.len() as u64));
|
||||
let chunk = Chunk { index: 0, x: 0, y: 0, width: 0, height: 0, plane: 0, offset: off, len };
|
||||
let src = chunk_bytes(bytes, &chunk).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?;
|
||||
let (fw, fh, vertical) = ljpeg::frame_info_subsampled(src)?;
|
||||
let slices = raw.u64s(CR2_SLICE).unwrap_or_default();
|
||||
let layout = Layout::new(fw, fh, vertical, &slices, limits)?;
|
||||
|
||||
let make = ifd0.string(t::MAKE).unwrap_or_default();
|
||||
let mn =
|
||||
tiff.exif().and_then(|e| e.get(t::MAKER_NOTE)).and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make));
|
||||
let (w, h) = (layout.width, layout.height);
|
||||
let mut active = Rect::new(0, 0, w, h);
|
||||
if let Some(si) = mn.as_ref().and_then(|m| m.ifd.u64s(SENSOR_INFO)).filter(|v| v.len() >= 9) {
|
||||
let (l, tp, r, b) = (si[5] as usize, si[6] as usize, si[7] as usize, si[8] as usize);
|
||||
if r > l && b > tp && r < w && b < h {
|
||||
active = Rect::new(l, tp, r - l + 1, b - tp + 1);
|
||||
}
|
||||
}
|
||||
let colour_data = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order)));
|
||||
let black_counts = colour_data.as_deref().and_then(colour_data_black);
|
||||
// 1.0 = a CFA CR2 of the same body at full 14-bit scale; see LUMA_GAIN
|
||||
let white = LUMA_GAIN * (SENSOR_FULL - black_counts.unwrap_or(0.0));
|
||||
|
||||
let data = match mode {
|
||||
Mode::Header => Vec::new(),
|
||||
Mode::Full => {
|
||||
let samples = fw * fh + fw * fh / vertical;
|
||||
let frame = ljpeg::decode_subsampled(src, samples.min(limits.samples()), ljpeg::Prediction::Sequential)?;
|
||||
let planes = Planes::arrange(&frame, &layout);
|
||||
drop(frame);
|
||||
let (a, b) = read_preview(bytes, tiff).and_then(|p| chroma_gains(&planes, &p, active)).unwrap_or((1.0, 1.0));
|
||||
planes.to_rgb(a, b)
|
||||
}
|
||||
};
|
||||
|
||||
let img = RawImage {
|
||||
format: RawFormat::Cr2,
|
||||
width: w,
|
||||
height: h,
|
||||
cpp: 3,
|
||||
data: RawData::U16(data),
|
||||
cfa: None,
|
||||
bits: 14,
|
||||
black: BlackLevel::uniform(0.0),
|
||||
white: vec![white],
|
||||
active_area: active,
|
||||
crop: Rect::new(0, 0, active.width, active.height),
|
||||
orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)),
|
||||
color: ColorData::default(),
|
||||
// the camera's white balance is already applied, as in Sony's linear YCbCr
|
||||
wb_multipliers: Some([1.0; 3]),
|
||||
linearized: false,
|
||||
pixel_aspect: 1.0,
|
||||
opcodes: OpcodeLists::default(),
|
||||
camera: tiff.ifds.first().map(Camera::from_ifd0).unwrap_or_default(),
|
||||
cinema: Default::default(),
|
||||
};
|
||||
img.validate_for(mode)?;
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use craft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
|
||||
|
||||
const SRAW_TYPE_TAG: u16 = 0xc6c5;
|
||||
|
||||
/// The 4:2:2 stream of `ljpeg`'s hand-coded fixture (4 × 2 frame, two MCUs per frame row) decodes to luma
|
||||
/// 1000 1001 / 1002 1003 / 1100 1101 / 1102 1103 in MCU order, Cb 16384 16385 16484 16485, Cr 16484 16486
|
||||
/// 16584 16586.
|
||||
fn stream_422() -> Vec<u8> {
|
||||
ljpeg::tests::fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2])
|
||||
}
|
||||
|
||||
/// A CR2 whose raw IFD holds `stream` cut into the slices `slices` (IFD2 = a 2 × 2 16-bit RGB image when
|
||||
/// `preview` is given).
|
||||
fn sraw_file(stream: Vec<u8>, slices: [u16; 3], sraw_type: u32, preview: Option<[u16; 12]>) -> Vec<u8> {
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("Canon".into()));
|
||||
ifd0.set(t::MODEL, Value::Ascii("Canon EOS Test".into()));
|
||||
let mut ifd2 = IfdBuilder::new();
|
||||
if let Some(p) = preview {
|
||||
ifd2.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
ifd2.set(t::PHOTOMETRIC, Value::Short(vec![2]));
|
||||
ifd2.set(t::IMAGE_WIDTH, Value::Long(vec![2]));
|
||||
ifd2.set(t::IMAGE_LENGTH, Value::Long(vec![2]));
|
||||
ifd2.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16]));
|
||||
ifd2.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
|
||||
ifd2.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![p.iter().flat_map(|v| v.to_le_bytes()).collect()] });
|
||||
} else {
|
||||
ifd2.set(1, Value::Short(vec![0]));
|
||||
}
|
||||
let mut ifd3 = IfdBuilder::new();
|
||||
ifd3.set(t::COMPRESSION, Value::Short(vec![6]));
|
||||
ifd3.set(SRAW_TYPE_TAG, Value::Long(vec![sraw_type]));
|
||||
ifd3.set(CR2_SLICE, Value::Short(slices.to_vec()));
|
||||
ifd3.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![stream] });
|
||||
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0, IfdBuilder::new().with(1, Value::Short(vec![0])), ifd2, ifd3]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slices_are_cut_back_into_image_columns_and_converted() {
|
||||
// slices [1, 4, 4]: two slices of one MCU per row; slice 0 holds MCUs 0 and 1 (image rows 0 and 1), slice 1
|
||||
// holds MCUs 2 and 3, so the image is 4 × 2 with luma 1000 1001 1100 1101 / 1002 1003 1102 1103.
|
||||
let bytes = sraw_file(stream_422(), [1, 4, 4], 4, None);
|
||||
let img = crate::decode(&bytes).unwrap();
|
||||
assert_eq!((img.width, img.height, img.cpp), (4, 2, 3));
|
||||
assert!(img.cfa.is_none());
|
||||
assert_eq!(img.wb_multipliers, Some([1.0; 3]));
|
||||
let RawData::U16(px) = &img.data else { panic!("integer data") };
|
||||
// no IFD2: chroma gains 1; R = Y + Cr', B = Y + Cb', G from the BT.601 luma
|
||||
let expect = |y: f64, cb: f64, cr: f64| {
|
||||
let (r, b) = (y + cr, y + cb);
|
||||
[r, (y - KR * r - KB * b) / KG, b].map(|v| v.round() as i32)
|
||||
};
|
||||
let cases = [
|
||||
((0, 0), expect(1000.0, 0.0, 100.0)),
|
||||
((1, 0), expect(1001.0, 0.0, 100.0)),
|
||||
((2, 0), expect(1100.0, 100.0, 200.0)),
|
||||
((3, 0), expect(1101.0, 100.0, 200.0)),
|
||||
((0, 1), expect(1002.0, 1.0, 102.0)),
|
||||
((3, 1), expect(1103.0, 101.0, 202.0)),
|
||||
];
|
||||
for ((x, y), want) in cases {
|
||||
let got: Vec<i32> = px[(y * 4 + x) * 3..][..3].iter().map(|&v| i32::from(v)).collect();
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "({x},{y}): {got:?} vs {want:?}");
|
||||
}
|
||||
// header-only decoding reports the same image
|
||||
assert_eq!(crate::probe_info(&bytes).unwrap(), img.info());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_too_small_to_fit_leaves_the_gains_at_one() {
|
||||
// IFD2 of 2 × 2 pixels cannot support a fit (it needs hundreds of samples): the file still decodes
|
||||
let with = crate::decode(&sraw_file(stream_422(), [1, 4, 4], 4, Some([900; 12]))).unwrap();
|
||||
let without = crate::decode(&sraw_file(stream_422(), [1, 4, 4], 4, None)).unwrap();
|
||||
assert_eq!(with.data, without.data);
|
||||
}
|
||||
|
||||
/// storytold/lightcraft#2561: IFD2's 16-bit RGB preview is read when its samples fit in the file, and refused before its buffer is
|
||||
/// reserved when they cannot: 64 × 2000 pixels (768000 bytes) whose one-row strips all point at the same bytes of
|
||||
/// a file a few tens of KiB long.
|
||||
#[test]
|
||||
fn a_preview_larger_than_the_file_is_refused() {
|
||||
let bytes = sraw_file(stream_422(), [1, 4, 4], 4, Some([900; 12]));
|
||||
let p = read_preview(&bytes, &Tiff::parse(&bytes).unwrap()).expect("a 2 x 2 preview that fits");
|
||||
assert_eq!((p.width, p.height, p.rgb), (2, 2, vec![900; 12]));
|
||||
let (w, rows) = (64u32, 2000u32);
|
||||
let mut ifd2 = IfdBuilder::new();
|
||||
ifd2.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
ifd2.set(t::PHOTOMETRIC, Value::Short(vec![2]));
|
||||
ifd2.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||||
ifd2.set(t::IMAGE_LENGTH, Value::Long(vec![rows]));
|
||||
ifd2.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16]));
|
||||
ifd2.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
|
||||
ifd2.set(t::ROWS_PER_STRIP, Value::Long(vec![1]));
|
||||
ifd2.set(t::STRIP_OFFSETS, Value::Long(vec![0; rows as usize]));
|
||||
ifd2.set(t::STRIP_BYTE_COUNTS, Value::Long(vec![w * 6; rows as usize]));
|
||||
let one = || IfdBuilder::new().with(1, Value::Short(vec![0]));
|
||||
let big = TiffWriter::new(ByteOrder::Little, false).write(&[one(), one(), ifd2]).unwrap();
|
||||
assert!(big.len() > (w * 6) as usize && big.len() < (w * rows * 6) as usize, "{} bytes", big.len());
|
||||
assert!(read_preview(&big, &Tiff::parse(&big).unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ycbcr_frames_take_this_path() {
|
||||
// other raw types (3 is the preview image's) stay unsupported, as before
|
||||
for kind in [0, 2, 3, 5] {
|
||||
let bytes = sraw_file(stream_422(), [1, 4, 4], kind, None);
|
||||
assert!(matches!(crate::decode(&bytes), Err(RawError::Unsupported(_))), "type {kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_layouts_must_fit_the_frame() {
|
||||
let ok = Layout::new(4, 2, 1, &[1, 4, 4], &Limits::default()).unwrap();
|
||||
assert_eq!((ok.width, ok.height), (4, 2));
|
||||
// 4:2:0: six samples per MCU, one slice row is two image rows
|
||||
let l = Layout::new(8, 4, 2, &[1, 12, 12], &Limits::default()).unwrap();
|
||||
assert_eq!((l.width, l.height), (8, 4));
|
||||
assert!(Layout::new(4, 2, 1, &[1, 3, 5], &Limits::default()).is_err()); // not whole MCUs
|
||||
assert!(Layout::new(4, 2, 1, &[1, 4, 8], &Limits::default()).is_err()); // rows do not divide the frame
|
||||
assert!(Layout::new(4, 2, 1, &[1, 0, 4], &Limits::default()).is_err());
|
||||
assert!(Layout::new(4, 2, 1, &[4, 4], &Limits::default()).is_err()); // not three values
|
||||
assert!(Layout::new(4, 2, 1, &[65, 4, 4], &Limits::default()).is_err());
|
||||
// slice widths whose sum overflows (or wraps to zero) are rejected, not divided by
|
||||
assert!(Layout::new(4, 2, 1, &[3, 1 << 62, 1 << 62], &Limits::default()).is_err());
|
||||
assert!(Layout::new(4, 2, 1, &[1, u64::MAX - 3, 4], &Limits::default()).is_err());
|
||||
let bytes = sraw_file(stream_422(), [1, 4, 8], 4, None);
|
||||
assert!(crate::decode(&bytes).is_err());
|
||||
}
|
||||
|
||||
/// Textured luma and smooth chroma on `w × h` planes.
|
||||
fn test_planes(w: usize, h: usize, vertical: usize) -> Planes {
|
||||
let y: Vec<u16> = (0..w * h).map(|i| (600 + ((i % w) * 13 + (i / w) * 29) % 1500) as u16).collect();
|
||||
let cw = w / 2;
|
||||
let ch = h / vertical;
|
||||
let cb = (0..cw * ch).map(|i| (16384 + (i % cw) as i32 * 7 - (i / cw) as i32 * 3 - 150) as u16).collect();
|
||||
let cr = (0..cw * ch).map(|i| (16384 + (i / cw) as i32 * 5 + (i % cw) as i32 * 2 - 120) as u16).collect();
|
||||
Planes { width: w, height: h, vertical, y, cb, cr }
|
||||
}
|
||||
|
||||
/// An IFD2 image of `pw × ph` pixels at `scale` per pixel, built the way the files relate it to the planes.
|
||||
fn preview_of(p: &Planes, pw: usize, ph: usize, scale: usize, a: f64, b: f64, pedestal: f64) -> Preview {
|
||||
let mut rgb = Vec::new();
|
||||
for j in 0..ph {
|
||||
for i in 0..pw {
|
||||
let (y, cb, cr) = p.window_mean(i * scale, (i + 1) * scale, j * scale, (j + 1) * scale);
|
||||
let (r, bl) = (y + a * cr, y + b * cb);
|
||||
let g = (y - KR * r - KB * bl) / KG;
|
||||
rgb.extend([r, g, bl].map(|v| (v + pedestal).round().clamp(0.0, 65535.0) as u16));
|
||||
}
|
||||
}
|
||||
Preview { width: pw, height: ph, rgb }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chroma_gains_are_measured_against_the_preview() {
|
||||
for vertical in [1, 2] {
|
||||
for (a, b) in [(1.0, 1.0), (5.7, 7.4), (1.13, 1.12)] {
|
||||
let planes = test_planes(160, 96, vertical);
|
||||
// IFD2 is a plain 1/4 reduction, optionally with a black pedestal (some bodies add one)
|
||||
for pedestal in [0.0, 520.0] {
|
||||
let preview = preview_of(&planes, 40, 24, 4, a, b, pedestal);
|
||||
let (ga, gb) =
|
||||
chroma_gains(&planes, &preview, Rect::new(0, 0, 160, 96)).unwrap_or_else(|| panic!("no fit for {a}, {b}, {vertical}"));
|
||||
assert!((ga / a - 1.0).abs() < 0.02 && (gb / b - 1.0).abs() < 0.02, "vertical {vertical}: ({a}, {b}) measured as ({ga}, {gb})");
|
||||
}
|
||||
}
|
||||
}
|
||||
// the preview overhangs the frame by a few pixels: the whole-frame scale (4.1) is wrong, 4 is right
|
||||
let planes = test_planes(164, 96, 1);
|
||||
let mut preview = preview_of(&planes, 40, 24, 4, 5.7, 7.4, 0.0);
|
||||
let (ga, gb) = chroma_gains(&planes, &preview, Rect::new(0, 0, 164, 96)).unwrap();
|
||||
assert!((ga / 5.7 - 1.0).abs() < 0.02 && (gb / 7.4 - 1.0).abs() < 0.02, "({ga}, {gb})");
|
||||
// a preview of something else gives no usable fit rather than a wild gain
|
||||
preview.rgb.iter_mut().enumerate().for_each(|(i, v)| *v = (1000 + (i * 7919) % 3000) as u16);
|
||||
assert!(
|
||||
chroma_gains(&planes, &preview, Rect::new(0, 0, 164, 96)).is_none_or(|(a, b)| (0.25..=16.0).contains(&a) && (0.25..=16.0).contains(&b))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_data_black_by_version() {
|
||||
let mut w = vec![0u64; 400];
|
||||
w[0] = 7;
|
||||
w[231..235].copy_from_slice(&[2048, 2047, 2048, 2048]);
|
||||
assert_eq!(colour_data_black(&w), Some(2047.75));
|
||||
w[0] = 12;
|
||||
assert_eq!(colour_data_black(&w), None); // the black level of another version lives elsewhere
|
||||
w[326..330].copy_from_slice(&[512, 512, 511, 512]);
|
||||
assert_eq!(colour_data_black(&w), Some(511.75));
|
||||
w[326..330].copy_from_slice(&[512, 512, 100, 512]); // implausible
|
||||
assert_eq!(colour_data_black(&w), None);
|
||||
w[0] = 3;
|
||||
assert_eq!(colour_data_black(&w), None); // no position known: black 0
|
||||
w[0] = 65533; // -3
|
||||
assert_eq!(colour_data_black(&w), None);
|
||||
assert_eq!(colour_data_black(&[]), None);
|
||||
assert_eq!(colour_data_black(&[7, 1, 2]), None);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,7 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> ColorData {
|
||||
// DNG 1.7 BaselineSharpness (IFD 0 or the enhanced IFD); implausible values are ignored
|
||||
baseline_sharpness: get(t::BASELINE_SHARPNESS).and_then(|v| v.first().copied()).filter(|v| v.is_finite() && *v > 0.0 && *v <= 16.0),
|
||||
profile: profile_look(ifd0, raw, order),
|
||||
kelvin_presets: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! A camera's white-balance presets at stated colour temperatures, as data.
|
||||
//!
|
||||
//! Some cameras record the multipliers they would apply for each of their white-balance presets, with the
|
||||
//! temperature the camera gives the preset (Canon CR2: the maker note's `ColorData`). For a raw without colour
|
||||
//! matrices they are the camera's own Kelvin scale. This crate reads and orders them; placing a white on the curve
|
||||
//! through them (temperature and tint) is the developer's arithmetic.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Most points a curve keeps (Canon writes about seven at a stated temperature).
|
||||
const MAX_POINTS: usize = 32;
|
||||
|
||||
/// A camera's multipliers at stated temperatures (at least three), ascending, green = 1.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KelvinPresets {
|
||||
points: Vec<(f32, [f32; 3])>,
|
||||
}
|
||||
|
||||
fn usable(m: [f32; 3]) -> bool {
|
||||
m.iter().all(|v| *v > 0.0 && v.is_finite())
|
||||
}
|
||||
|
||||
fn log_point(m: [f32; 3]) -> [f64; 2] {
|
||||
[(m[0] as f64 / m[1] as f64).ln(), (m[2] as f64 / m[1] as f64).ln()]
|
||||
}
|
||||
|
||||
impl KelvinPresets {
|
||||
/// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: three to 32 of
|
||||
/// them, positive multipliers, red rising and blue falling from one preset to the next.
|
||||
pub fn new(points: impl IntoIterator<Item = (f32, [f32; 3])>) -> Option<KelvinPresets> {
|
||||
let points: Vec<_> = points.into_iter().take(MAX_POINTS + 1).collect();
|
||||
let ok = (3..=MAX_POINTS).contains(&points.len())
|
||||
&& points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m))
|
||||
&& points.windows(2).all(|w| {
|
||||
let (a, b) = (log_point(w[0].1), log_point(w[1].1));
|
||||
w[0].0 < w[1].0 && b[0] > a[0] && b[1] < a[1]
|
||||
});
|
||||
ok.then(|| KelvinPresets { points: points.into_iter().map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])).collect() })
|
||||
}
|
||||
|
||||
/// A curve from presets in any order, as a camera lists them by name (Canon's Daylight, Shade, Cloudy, …):
|
||||
/// sorted by Kelvin, a repeated temperature kept once, unusable points and any that would turn the curve back
|
||||
/// (red not rising or blue not falling from the warmer neighbour kept) dropped; then [`Self::new`].
|
||||
pub fn from_unordered(points: impl IntoIterator<Item = (f32, [f32; 3])>) -> Option<KelvinPresets> {
|
||||
let mut pts: Vec<_> = points.into_iter().take(MAX_POINTS).filter(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)).collect();
|
||||
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut kept: Vec<(f32, [f32; 3])> = Vec::with_capacity(pts.len());
|
||||
for p in pts {
|
||||
let fits = kept.last().is_none_or(|q| {
|
||||
let (a, b) = (log_point(q.1), log_point(p.1));
|
||||
q.0 < p.0 && b[0] > a[0] && b[1] < a[1]
|
||||
});
|
||||
if fits {
|
||||
kept.push(p);
|
||||
}
|
||||
}
|
||||
KelvinPresets::new(kept)
|
||||
}
|
||||
|
||||
/// The presets as (Kelvin, multipliers with green = 1), ascending in Kelvin.
|
||||
pub fn points(&self) -> &[(f32, [f32; 3])] {
|
||||
&self.points
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn five() -> Vec<(f32, [f32; 3])> {
|
||||
[(2500.0, 1.0, 3.2), (3200.0, 1.3, 2.6), (4500.0, 1.8, 1.9), (6000.0, 2.3, 1.5), (8500.0, 2.8, 1.2)]
|
||||
.map(|(k, r, b)| (k, [r, 1.0, b]))
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_presets_give_nothing() {
|
||||
assert!(KelvinPresets::new(five()).is_some());
|
||||
let mut pts = five();
|
||||
pts.swap(1, 2);
|
||||
assert_eq!(KelvinPresets::new(pts), None, "out of order");
|
||||
let mut pts = five();
|
||||
pts[4].1 = pts[3].1;
|
||||
assert_eq!(KelvinPresets::new(pts), None, "no step");
|
||||
let mut pts = five();
|
||||
pts[0].1[0] = 0.0;
|
||||
assert_eq!(KelvinPresets::new(pts), None);
|
||||
assert_eq!(KelvinPresets::new(five()[..2].to_vec()), None, "too few");
|
||||
assert_eq!(KelvinPresets::new((0..33).map(|i| (2000.0 + i as f32, [1.0 + i as f32, 1.0, 40.0 - i as f32]))), None, "too many");
|
||||
}
|
||||
|
||||
/// Canon lists its presets by name, not by temperature, with repeats and an off-curve one among them: the
|
||||
/// curve takes them in Kelvin order, each temperature once, and leaves out a point that would turn it back.
|
||||
#[test]
|
||||
fn a_curve_from_presets_in_any_order() {
|
||||
let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0];
|
||||
let p = KelvinPresets::from_unordered([
|
||||
(5651.0, g(1418.0, 1357.0)),
|
||||
(7505.0, g(1653.0, 1171.0)),
|
||||
(6594.0, g(1533.0, 1259.0)),
|
||||
(3174.0, g(963.0, 2016.0)),
|
||||
(5651.0, g(1418.0, 1357.0)),
|
||||
(8000.0, g(1400.0, 1400.0)),
|
||||
(8529.0, g(1807.0, 1080.0)),
|
||||
(f32::NAN, g(1000.0, 1000.0)),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(p.points().len(), 5);
|
||||
assert!(p.points().windows(2).all(|w| w[0].0 < w[1].0));
|
||||
assert_eq!(KelvinPresets::from_unordered([(5000.0, g(1400.0, 1400.0)), (6000.0, g(1500.0, 1300.0))]), None);
|
||||
assert_eq!(KelvinPresets::from_unordered(std::iter::empty()), None);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
//! | `dng` | Adobe DNG: uncompressed (any depth, packed), lossless JPEG, lossy JPEG, Deflate (integer and floating point), strips and tiles, CFA and LinearRaw, DNG 1.7 interleaved storage; reading and writing |
|
||||
//! | `jxl` | JPEG XL compressed DNG (DNG 1.7, compression 52546) through `jxl-oxide`; implies `dng` |
|
||||
//! | `crw` | Canon CRW (CIFF), decoded by `craft-crw` and returned through this crate's types |
|
||||
//! | `cr2` | Canon CR2: the sliced lossless-JPEG mosaic, and sRAW / mRAW (subsampled YCbCr) as linear RGB |
|
||||
//! | `mrw` | Minolta / Konica Minolta MRW: 12-bit packed and 16-bit word mosaics in the `\0MRM` block container |
|
||||
//! | `erf` | Epson ERF (R-D1 family): 12-bit samples in 16-byte blocks |
|
||||
//! | `mef` | Mamiya MEF (ZD): uncompressed 12-bit packed |
|
||||
@@ -49,6 +50,8 @@
|
||||
//! and from observation of files. No other raw decoder's source was consulted; see `README.md`.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(feature = "cr2")]
|
||||
mod cr2;
|
||||
#[cfg(feature = "crw")]
|
||||
mod crw;
|
||||
#[cfg(feature = "dng")]
|
||||
@@ -65,6 +68,7 @@ mod hasselblad;
|
||||
mod huffman;
|
||||
#[cfg(feature = "jxl")]
|
||||
mod jxl;
|
||||
pub mod kelvin;
|
||||
#[cfg(feature = "kodak")]
|
||||
mod kodak;
|
||||
pub mod ljpeg;
|
||||
@@ -323,6 +327,7 @@ impl RawFormat {
|
||||
|| (self == RawFormat::Mrw && cfg!(feature = "mrw"))
|
||||
|| (self == RawFormat::Erf && cfg!(feature = "erf"))
|
||||
|| (self == RawFormat::Mef && cfg!(feature = "mef"))
|
||||
|| (self == RawFormat::Cr2 && cfg!(feature = "cr2"))
|
||||
|| (self == RawFormat::Pef && cfg!(feature = "pef"))
|
||||
|| (self == RawFormat::Srw && cfg!(feature = "srw"))
|
||||
}
|
||||
@@ -605,6 +610,8 @@ fn decode_in(bytes: &[u8], mode: Mode, limits: &Limits) -> Result<RawImage> {
|
||||
RawFormat::Erf => erf::decode(bytes, mode, limits),
|
||||
#[cfg(feature = "mef")]
|
||||
RawFormat::Mef => mef::decode(bytes, mode, limits),
|
||||
#[cfg(feature = "cr2")]
|
||||
RawFormat::Cr2 => cr2::decode(bytes, mode, limits),
|
||||
#[cfg(feature = "pef")]
|
||||
RawFormat::Pef => pef::decode(bytes, mode, limits),
|
||||
#[cfg(feature = "srw")]
|
||||
@@ -848,6 +855,10 @@ pub struct ColorData {
|
||||
/// `ProfileGainTableMap*`, as data: the develop pipeline applies them, and a DNG export writes them back.
|
||||
#[serde(default)]
|
||||
pub profile: profile::ProfileLook,
|
||||
/// The camera's white-balance presets at stated temperatures (Canon CR2), which give a raw without colour
|
||||
/// matrices a Kelvin scale ([`kelvin`]). Not a DNG tag: the DNG writer does not write it.
|
||||
#[serde(default)]
|
||||
pub kelvin_presets: Option<kelvin::KelvinPresets>,
|
||||
}
|
||||
|
||||
/// A decoded raw image.
|
||||
|
||||
@@ -44,8 +44,23 @@ fn pef(compression: u16) -> Vec<u8> {
|
||||
TiffWriter::new(ByteOrder::Big, false).write(&[ifd]).unwrap()
|
||||
}
|
||||
|
||||
/// A Canon CR2: three placeholder IFDs and IFD3 with a 2-component lossless JPEG frame.
|
||||
fn cr2() -> Vec<u8> {
|
||||
let img: Vec<u16> = (0..(W * H) as usize).map(|i| 1024 + (i * 7919 % 12000) as u16).collect();
|
||||
let enc = craft_raw::ljpeg::encode(&img, W as usize / 2, H as usize, 2, 14, 1, 0);
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii("Canon".into()));
|
||||
let mut ifd3 = IfdBuilder::new();
|
||||
ifd3.set(t::COMPRESSION, Value::Short(vec![6]));
|
||||
ifd3.set(0xc640, Value::Short(vec![1, 16, 16]));
|
||||
ifd3.set_image(ImageData::Strips { rows_per_strip: H, strips: vec![enc] });
|
||||
let blank = || IfdBuilder::new().with(1, Value::Short(vec![0]));
|
||||
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0, blank(), blank(), ifd3]).unwrap()
|
||||
}
|
||||
|
||||
fn samples() -> Vec<Vec<u8>> {
|
||||
vec![
|
||||
cr2(),
|
||||
pef(1),
|
||||
pef(65535),
|
||||
cfa_tiff("SAMSUNG", 1, 12, ByteOrder::Little),
|
||||
|
||||
Reference in new issue
Block a user