Use crw craftlib (#3503)

* CRW-LIB: decode Canon CRW through the shared craft-crw crate

LightCraft decodes Canon CRW (the CIFF raws of the EOS D30, D60, 10D, 300D and the
PowerShot G1/G6) through craft-libs' `craft-crw`, the clean-room decoder derived and
verified on the `clean-room-crw-format-decode` branch and moved there with its frozen
tables and derivation record. `crates/raw/src/vendor/crw.rs` is now the adapter: it maps
the crate's header and samples onto `RawImage` (CFA, levels, active area, orientation,
as-shot white balance, the make/model/time records a CRW keeps instead of Exif) and
converts the capture time through chrono. The dependency is a local path
(`../craft-libs/crates/crw`) until the craft-libs branch merges; then it becomes a git
revision.

Everything LightCraft-side from that branch comes along unchanged: `probe_bytes` fills
metadata gaps from the decoder's records so CRW imports name their camera; the EOS D60
bundled camera profile and its chart-measured dual-illuminant matrices (`chart_table.rs`,
`spectral::Camera` carries its illuminant pair) give the D60 a Kelvin white-balance scale;
CRW joins the JPEG-fitted starting look, the catalog's raw formats and `lightcraft-cli
calibrate`; `RENDER_CACHE_VERSION` 71; the CC0 CRW corpus entries for `cargo xtask corpus`;
coverage, parity, gaps, raw-parity, target-app-parity and ROADMAP updates. The G5 stays
preview-only (the crate refuses it with the reason) and the robustness test keeps its CRW
sample.

What does not come along: the decoder, its tables and `crw_oracle.rs` (now craft-crw's
digest-pinned sample test) and the derivation record, which lives in craft-libs
`crates/crw/clean-room/`. `clean-room/crw/` here keeps only the EOS D60 chart-calibration
document and its capture list, with a README pointing at the record.

Gates: fmt, clippy -D warnings (raw all targets; engine, catalog, cli), lightcraft-raw tests
(475 + 134, the corpus files decoding through the adapter), catalog tests, engine lib tests
(1,300 pass; the two failures, `tests_import::recursive_discovery_…` and
`originals::tests::write_targets_pin_absent_paths_…`, are in files unchanged from main),
`cargo xtask parity`, `assets`, `layers`, `coverage.py --check`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* no message

* no message

* Update documentation after moving code to craft-libs

* Review coment fixes

* CRW: pin craft-crw f3b62ec, a truncated entropy stream is Corrupt

craft-libs #472 makes craft_crw::decode refuse a stream that ends more than
Limits::max_stream_end_fill (64 samples, one block) before the frame does,
instead of filling the rest with predictor values and returning success.
The adapter already maps craft_crw::Error::Corrupt to RawError::Corrupt;
a regression test proves a truncated CRW now fails at this boundary (it
decoded as a flat frame on the old pin) while its header still reads.

Only craft-crw moves; craft-ui/docpath/update/window stay on c5a1cbf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Code review comment fix

* Add some tests

---------

Co-authored-by: Robert Woldberg <314159+rwoldberg@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Robert
2026-11-02 20:41:34 -05:00
committed by GitHub
co-authored by Claude Opus 5.5 Robert Woldberg
parent 32d569ad5d
commit 06c1b9ae07
40 changed files with 893 additions and 92 deletions
Generated
+10
View File
@@ -1181,6 +1181,14 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-crw"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f3b62ec61f45534392376492fc5ad33047375205#f3b62ec61f45534392376492fc5ad33047375205"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
@@ -3902,6 +3910,8 @@ dependencies = [
name = "lightcraft-raw"
version = "0.18.0"
dependencies = [
"chrono",
"craft-crw",
"dhat",
"jpeg-encoder",
"jxl-oxide",
+2
View File
@@ -12,6 +12,8 @@ repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
# Canon CRW decoder shared with the other Craft apps
craft-crw = { git = "https://github.com/storytold/craft-libs", rev = "f3b62ec61f45534392376492fc5ad33047375205" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
+1 -1
View File
@@ -349,7 +349,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
- **The biggest gaps:**
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, ILCE-7RM5, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 and CRW raws have guarded estimates from their camera JPEGs, with built-in Canon EOS D60, ILCA-99M2, ILCE-7CR, ILCE-7M4, ILCE-7RM5, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration covers one model so far (chart-measured matrices for the Canon EOS D60), and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an
optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and
+9 -2
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 9: CI runs the tests on Linux x64 since #2667; macOS, Windows and the GPU equivalence tests still don't; the lens profile database priority links its tracking issue #329; Print: Japanese photo and postcard paper sizes — L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺; Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Canon CRW decoded clean-room, 6 bodies verified, 557 models verified; gap 9: CI runs the tests on Linux x64 since #2667; macOS, Windows and the GPU equivalence tests still don't; the lens profile database priority links its tracking issue #329; Print: Japanese photo and postcard paper sizes — L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺; Minolta MRW decoded, 6 bodies verified; RAW: 85% of Adobe's models decode, 39% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
@@ -45,7 +45,7 @@ Hours are calibrated on this repo's PR history; see
| Dimension | Ready | Breadth | Remaining | Doc |
|---|---:|---:|---:|---|
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
| ↳ RAW cameras, colour, lens profiles | ~60% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| ↳ RAW cameras, colour, lens profiles | ~60% | 85% of 1,446 Adobe models decode, 39% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
| UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) |
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
@@ -119,6 +119,12 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
六切 (203 × 254), 四切 (254 × 305) and 名刺 (55 × 91) — at their exact metric sizes. The Print dialog
and `print.set` take their customary names, and names are matched case-, space- and
punctuation-insensitively (`L判`, `l`, `L-size` are one paper).
- 2026-10-13: Canon CRW (the 2000–2004 CIFF bodies) decodes from sensor data: compression
derived clean-room against a black-box reference and frozen (`craft-libs crates/crw/clean-room/02-crw-format.md`);
EOS D30/D60/10D/300D and PowerShot G1/G6 verified pixel-exact on 28 of 29 corpus files (18 of
them the maintainer's D60 shots); as-shot white balance, black level, rotation and capture time
from the CIFF records; the PowerShot G5's anomalous coding stays preview-only. With the
camera-coverage sweep and MRW merged, 557 models verified; Canon bucket 95% → 98%.
- 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor
hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking
identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277.
@@ -194,6 +200,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| 2026-10-24 | patch | Alpha rationale: tests run in CI on Linux x64 (#2667) and FreeBSD nightly, not on macOS or Windows; ranked item 8 now reads "Tests in CI on macOS and Windows, and the GPU tests in CI"; percentages and hours unchanged |
| 2026-10-22 | minor | Print: Japanese photo and postcard paper sizes (L判, 2L判, KG判, はがき, 往復はがき, 六切, 四切, 名刺) in the page setup, Print dialog and PDF output; headline numbers unchanged |
| 2026-10-19 | minor | Indonesian interface (`id`): every plain and formats message, plus 69 command labels, locale, menu command and tests; What's New left in English; Localization ~55% → ~64% (mean of the 12 key languages), 8 of 12 present |
| 2026-10-13 | minor | Canon CRW decoded (clean-room): 6 bodies verified pixel-exact, G5 preview; 551 models verified after merging the coverage sweep |
| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged |
| 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged |
| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled |
+4 -3
View File
@@ -106,7 +106,7 @@ USAGE:
lightcraft-cli controls [--json] list every develop control id with its range
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
(Sony ARW, Nikon NEF, Fujifilm RAF): up to N files spread over the folders (default 300; 0 = all), pooled per
(Sony ARW, Nikon NEF, Fujifilm RAF, Canon CRW): up to N files spread over the folders (default 300; 0 = all), pooled per
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
take their colour from the profile and only their tone from their own JPEG.
@@ -197,8 +197,9 @@ fn main() -> ExitCode {
/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders.
fn raw_files(path: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) {
let is_raw =
|p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf"].iter().any(|x| e.eq_ignore_ascii_case(x)));
let is_raw = |p: &Path| {
p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf", "crw"].iter().any(|x| e.eq_ignore_ascii_case(x)))
};
if path.is_file() {
if is_raw(path) {
out.push(path.to_path_buf());
+2
View File
@@ -19,11 +19,13 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `assets/camera-profiles/ILCA-99M2.json` | Sony ILCA-99M2 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @PiMinusMax | original work: fitted by `lightcraft-cli calibrate` to 144 usable ILCA-99M2 ARWs (of 150, seven shooting days, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-11 | none |
| `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none |
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
| `assets/camera-profiles/Canon_EOS_D60.json` | Canon EOS D60 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Robert Woldberg | original work: fitted by `lightcraft-cli calibrate` to 1,424 usable EOS D60 CRWs (of 1,520, 2005 to 2014, Auto white balance) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
| `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
| `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
| `assets/camera-profiles/ILCE-7RM5.json` | Sony ILCE-7RM5 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Yule (Brian) Wang (@peaperfish) | original work: fitted by `lightcraft-cli calibrate` to 220 usable ILCE-7RM5 ARWs of my own (of 241, 2025 to 2026, Standard creative style; Sigma 24-70mm F2.8 DG DN II Art and Sony FE 200-600mm F5.6-6.3 G OSS) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-26 | none |
| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix |
| `crates/raw/src/chart_table.rs` | Chart-measured camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D50) for the Canon EOS D60 | LightCraft contributors; ColorChecker photographs taken by Robert Woldberg on the maintainer's EOS D60 | original work: fitted to our own CRWs of a ColorChecker Classic (post-November-2014 formulation) under a ~2,900 K tungsten bulb (11 frames) and direct sun (~4,850 K, 20 frames); reference colours: X-Rite's published L*a*b* (D50) for the chart, as packaged by [colour-science](https://github.com/colour-science/colour) (BSD-3-Clause), used for fitting only and not shipped; method in `clean-room/crw/10-chart-calibration.md` | MIT OR Apache-2.0 | 2026-10-25 | kept by hand: a 3×3 matrix per illuminant fitted on chromaticity with neutral held at D50; no Adobe or third-party camera matrix, no number from a GPL raw decoder |
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
File diff suppressed because one or more lines are too long
+97
View File
@@ -0,0 +1,97 @@
# EOS D60 colour matrices from a ColorChecker (2026-10-25)
The Canon EOS D60 has no published spectral sensitivities, so it is not in `spectral_table.rs`.
These are its DNG-style matrices (`ColorMatrix1/2`, `ForwardMatrix1/2`), fitted to our own
photographs of a ColorChecker Classic under two lights. They are in
`crates/raw/src/chart_table.rs`. Measurements come from the maintainer's own captures (names,
sizes and SHA-256 in `logs/chart-captures.tsv`; no image content recorded) and the chart's
published reference colours (craft-libs `crates/crw/clean-room/sources.md`). Scripts and measurements are local-only, in
`plan/crw/chart/`.
## Captures
| Set | Frames used | Light | Notes |
|---|---:|---|---|
| `SunlightColorCard/` | 20 of 22 | Direct afternoon sun, ISO 100 | Calibrite ColorChecker Classic (50th-anniversary edition, post-2014 formulation) leaning on a wall, filling most of the frame. Auto and Daylight white balance, a shutter bracket. Two frames dropped: a white-patch sample ≥ 3900 (clip ≈ 4000 at ISO 100, craft-libs `crates/crw/clean-room/04-color.md` §2). |
| `TungstonColorChart/` 2696–2706 | 11 | One household tungsten bulb, ISO 100 | Chart flat on a wall, a shutter bracket. Brightest white patch 2657 of ~4000. |
| `TungstonColorChart/` 2688–2691 | not used | Same bulb, 8 minutes earlier | Steady within the set, but its neutral reads ~3,040 K against ~2,900 K for the later set: the light changed (window light or bulb warm-up). The later, warmer and better-exposed set is used. |
All 37 frames decode sample-exact against the black-box reference (`craft-crw`'s digest-pinned sample test, formerly `crw_oracle.rs` here).
An earlier set on a ColorChecker-layout clone (Pixel Perfect "24 Standard Color Calibration
Chart", 2026-10-15) was not used. The maker publishes no reference values, and its grey patches
are not neutral: against an 18 % grey board they read R/G 0.88–0.94 in shade and B/G up to
1.29 under warm LED light, the darker patches drifting most. That is the sign of greys printed
from coloured inks.
## Measurement
- Patches are located on the embedded JPEG (four corner patch centres, bilinear grid), tracked
per frame by phase correlation, and checked visually on drawn boxes. The tungsten-set chart
was upside down in the first four frames; patch order is mapped accordingly.
- Each patch is measured in a central box of about 40 % of its width in the linear raw (black
from the far masked border per CFA site; R, mean of both G, B per 2×2 site). Uniformity
across the box (3×3 sub-blocks): ≤ 4 % in sun, ≤ 7 % under the bulb (light falloff across
the closer chart).
- Frames are normalised by the four middle neutrals (patches 20–23), averaged, and white
balanced on those neutrals. White balance (R, B multipliers, G = 1): sun 2.362, 1.298; bulb
1.353, 2.213. The neutrals agree on it to ±2 %.
## Fit
- The reference is X-Rite's published L\*a\*b\* (D50) for charts made after November 2014.
- The model is a 3×3 matrix from white-balanced camera RGB to XYZ (D50), with neutral (1, 1, 1)
held exactly at the engine's D50 white (`lightcraft_color::D50`): the third column is D50
minus the first two. That is a DNG `ForwardMatrix`.
- **Chromaticity only.** Each prediction is scaled to the patch's reference luminance before
the CIEDE2000 residual. In the sun the chart warped as it heated: the grey row's brightness
slope swung from +4 % to −7 % across the 70 s of shooting, in time order, and the per-patch
brightness needed was not smooth across the chart. A fit including lightness would have
absorbed that into the matrix. Chromaticity still determines the whole 3×3 up to scale,
which the white constraint fixes. The cost is that luminance of saturated blues and purples
rests on the model, not the measurement.
- `ColorMatrix` per light = diag(1 / multipliers) · ForwardMatrix⁻¹ · Bradford(light → D50),
scaled so the light's white maps to camera values with maximum 1. By construction it maps
the filed illuminant's white to the measured camera neutral.
| Light | CIEDE2000 on the 24 patches | Leaving each patch out | Worst |
|---|---|---|---|
| Sun (filed as D50) | mean 1.70, median 1.46 | mean 1.97 | white 4.5 |
| Bulb (filed as A) | mean 2.42, median 1.79 | mean 2.74 | bluish green 6.7 |
- One matrix does not serve both lights: the sun's matrix on the bulb's chart gives mean 3.6
(cyan 9.0), the bulb's on the sun's 3.4 (red 7.7). Hence the dual-illuminant entry.
- The neutral row reads a\* within ±0.4 for the middle greys; white and the darkest patches
are off by up to 2 (white patch sheen, flare).
## Which illuminants
The two lights' colour temperatures come from the camera's own white-balance presets
(ColorBalance slots, craft-libs `crates/crw/clean-room/04-color.md` §3) at the temperatures Canon's manuals give them (Daylight
5200 K, Tungsten 3200 K, Fluorescent 4000 K), interpolated in mired on ln(R/B): sun ~4,850 K,
bulb ~2,900 K. They are filed under the nearest DNG illuminants the engine knows, **D50
(5003 K) and Standard Illuminant A (2856 K)**, so `Camera` now carries its own illuminant
pair (spectral rows: A and D65).
## Checks in the app
- `spectral::tests::the_chart_measured_eos_d60_reads_its_presets_in_kelvin`: the camera's
presets, which the fit never saw, read through the matrices as Tungsten 3,264 K, Daylight
5,603 K, Cloudy 6,613 K, Flash 7,085 K, near the locus. Canon's nominal values are 3200, 5200,
6000 and 6000 K. Order and spacing hold; the daylight presets read 8–15 % high, as Sony's
dial does against its spectral matrices (#730).
- Real files through the headless app (As Shot): WBModes Fluorescent 4,103 K, tint +26 (green
compensation); LED-lit room, Auto 3,761 K; open shade, Auto 6,596 K; sunlit chart, Auto
4,762 K.
- `files::tests::crw_probes_name_their_camera_from_the_ciff_records`: CRW imports now take make,
model and capture time from the decoder's CIFF records (no Exif in a CRW; the catalog had an
empty camera, so a D60 stayed on the relative white-balance scale).
## Limits
- One body, one bulb, one afternoon. The chart is 24 patches; a 3×3 matrix cannot follow the
sensor's departures from the CIE observer (the bulb fit's worst patches are 5–7 ΔE).
- The lights are filed under standard illuminants they only approximate (~50 K for the bulb,
~150 K for the sun).
- The D60's look is still the per-file JPEG fit and the pooled profile; these matrices give the
white-balance scale and the fallback colour only.
+18
View File
@@ -0,0 +1,18 @@
# Canon CRW: where the record is
The Canon CRW (CIFF) decoder and its clean-room record (the frozen format, the verification
summary, the colour, geometry and metadata notes, the private-corpus summary and the source log)
live in craft-libs:
[`crates/crw/clean-room/`](https://github.com/storytold/craft-libs/tree/main/crates/crw/clean-room).
The derivation trail and the frozen table-generation materials stay in LightCraft PR #3021.
LightCraft uses the crate `craft-crw` through the adapter `crates/raw/src/vendor/crw.rs`.
What stays here is LightCraft's own colour work on these cameras:
- [`10-chart-calibration.md`](10-chart-calibration.md): the EOS D60 colour matrices fitted to
ColorChecker captures, implemented in `crates/raw/src/chart_table.rs`.
- [`logs/chart-captures.tsv`](logs/chart-captures.tsv): names, sizes and SHA-256 of the chart
frames (no image content).
The capture statistics those documents cite (`04-color.md`, `05-private-corpus.md`, `sources.md`)
are in the craft-libs record.
+39
View File
@@ -0,0 +1,39 @@
# EOS D60 ColorChecker Classic captures (maintainer, local only; never committed). Names, sizes, SHA-256; no image content. Generated 2026-10-25.
file bytes sha256
SunlightColorCard/CRW_2666.CRW 7791628 371d8e18c12801c72754dd7b277163a866afc422c8edf6ad1ea822009e4170f8
SunlightColorCard/CRW_2667.CRW 6638578 188d625c31ab44475a6cf54a5dae00fd861e26aafc90f4b5d18a1905c0a3cef5
SunlightColorCard/CRW_2668.CRW 6832770 1189b8dc79642c57a8c9496b5629e36a1741c545d2b0b864d6e6e20d901510c1
SunlightColorCard/CRW_2669.CRW 7079102 baf1686b27a9e0a04bf87c0b6cf55168f398d85247f8115a0af69b5e9ea1884c
SunlightColorCard/CRW_2670.CRW 7225298 067ba3f4d3026b8009cfe4babb5e4c3b28f988d95c48024a75c6715de32afccf
SunlightColorCard/CRW_2671.CRW 7504700 cf81cbb96389624318e890b5506221d49f5fba8e2d45447d87e0f3ff3e9bdc72
SunlightColorCard/CRW_2672.CRW 7830852 7a59446bfb1f00d6eabd0115b8e5451c34f5d3d48f483cb27608ce9508e43ee5
SunlightColorCard/CRW_2673.CRW 8053180 feeb8f818325d6ddb4e88b5eabe4a74f4cb5e1da9f70135ae3f85938c86695dc
SunlightColorCard/CRW_2674.CRW 8312060 45e7402c888582adadb607599eae80644d657d61c9607b426c411ee306e42daf
SunlightColorCard/CRW_2675.CRW 8565950 be28d2fa049aa4158ae9e3ec49dd28eb56fcd0ffa7af060235b8bda0d89dfd0a
SunlightColorCard/CRW_2676.CRW 7795508 76f7418a82e45e1034b3adacacf4bc756cb65a0d683e5bb3631992784e654f93
SunlightColorCard/CRW_2677.CRW 7747450 a5e7bc618e6b80ea70f9bad6a1bf41ee63eadbc51df28b82d0864a73434f7964
SunlightColorCard/CRW_2678.CRW 6815054 28824f0f64b087488d8dc5a66a8bdf717e83a4d4e1660676d07991bf32dffb96
SunlightColorCard/CRW_2679.CRW 7050606 b5c915fa89542265deb4fa16da3ca7ffda01b1d961903836b0059d024316673a
SunlightColorCard/CRW_2680.CRW 7231410 a5780dfe99a2df2a3ed9de1888e91baa614b2e009a2b6e0a84d442b6bf1af9fd
SunlightColorCard/CRW_2681.CRW 7438088 eb4475919c2770f816d47787656ab7ecb5396f30d2c03628c3ab167ebbc47c65
SunlightColorCard/CRW_2682.CRW 7584262 a6fe417b90ce41b03552963dee6b21e4f02b59a5c51148edc04af7224e8c59e9
SunlightColorCard/CRW_2683.CRW 7811474 5402ad40e2689d78ade083d8855ecced5caea9368b9d77ecc4a61a16dc55aa36
SunlightColorCard/CRW_2684.CRW 8043372 bbef5da457c0b6d6c45c74a99f3e318e6c80a7d3a0ad5c7cb7e13d24205f64fa
SunlightColorCard/CRW_2685.CRW 8242344 8d68d592a5c8933f0f35630114fe7017be808b007867beaf1a8d6d2bd1441f70
SunlightColorCard/CRW_2686.CRW 8462380 79f7db32b18a834d5408500185c65d316387d1440aa61e26a11be85883fcc382
SunlightColorCard/CRW_2687.CRW 7690732 0333d8d18390e77128f186239e6de71c0432ab6ee9669a71c210255e4d915d19
TungstonColorChart/CRW_2688.CRW 6269390 d9c4334edfe3636a8a7e69f29c1d1e89f820b7b704697684d7996faee41f5d43
TungstonColorChart/CRW_2689.CRW 6001348 1e8b47645d76a71fd9fb4111c4dce805d6dcd41ac82e6b4ce52debbf71565455
TungstonColorChart/CRW_2690.CRW 6114056 f63fe49a9ed13af33ddb8d8ed8d75de4e304a3bc2c1f1d4275ba7edbc4f459f3
TungstonColorChart/CRW_2691.CRW 6299730 ad8a2844c00965fb6a12e8fc85a6e8bfbdf1924e6d1a68b55472701cb8ffa115
TungstonColorChart/CRW_2696.CRW 6169684 105ee515c94da78dedc1f8d777982c4a1e62781b160428524fbe922f62b3ef5c
TungstonColorChart/CRW_2697.CRW 5565982 327eebf702185127feb8a5f4bfac5d7ebad5532f7417b302997b19b97d7ff4a8
TungstonColorChart/CRW_2698.CRW 5709092 f0fca20f8dcd51046e53bebf9fb9f624677ee674ad739622cdb2578977669705
TungstonColorChart/CRW_2699.CRW 5873492 57724a1ff507cf4e78c80a34cd4e0b77eac397322228d32e5215fc3869853dd1
TungstonColorChart/CRW_2700.CRW 5988146 6c97ae2c72114034e2fbe6aed0ef97f475696e43b56ed62273eccbfbba4cbaa0
TungstonColorChart/CRW_2701.CRW 6202756 c4032e46b753a718d8748e269a9da5d124891d07912542368ea867529143aa62
TungstonColorChart/CRW_2702.CRW 6409582 89b65c1c07e0685841046db85923b0bf80e747fed141ddf0335ba12dc75f3f3c
TungstonColorChart/CRW_2703.CRW 6407270 50179d0a330c967a74d76bc8342eea1052689c5c544aa1a913769b241ac6f736
TungstonColorChart/CRW_2704.CRW 6483154 6bac4d8229a8d0fff1445d212ca63e53911dc7bda988743215009257b87787fa
TungstonColorChart/CRW_2705.CRW 7170488 629f94131e7f598964d2635e3039a20c441563201c4f3cbf177d83ad6b061f78
TungstonColorChart/CRW_2706.CRW 6383026 67657e7a21f6fd8580012fccd2210afccf285e747bc4d39b8ea775811528ce83
1 # EOS D60 ColorChecker Classic captures (maintainer, local only; never committed). Names, sizes, SHA-256; no image content. Generated 2026-10-25.
2 file bytes sha256
3 SunlightColorCard/CRW_2666.CRW 7791628 371d8e18c12801c72754dd7b277163a866afc422c8edf6ad1ea822009e4170f8
4 SunlightColorCard/CRW_2667.CRW 6638578 188d625c31ab44475a6cf54a5dae00fd861e26aafc90f4b5d18a1905c0a3cef5
5 SunlightColorCard/CRW_2668.CRW 6832770 1189b8dc79642c57a8c9496b5629e36a1741c545d2b0b864d6e6e20d901510c1
6 SunlightColorCard/CRW_2669.CRW 7079102 baf1686b27a9e0a04bf87c0b6cf55168f398d85247f8115a0af69b5e9ea1884c
7 SunlightColorCard/CRW_2670.CRW 7225298 067ba3f4d3026b8009cfe4babb5e4c3b28f988d95c48024a75c6715de32afccf
8 SunlightColorCard/CRW_2671.CRW 7504700 cf81cbb96389624318e890b5506221d49f5fba8e2d45447d87e0f3ff3e9bdc72
9 SunlightColorCard/CRW_2672.CRW 7830852 7a59446bfb1f00d6eabd0115b8e5451c34f5d3d48f483cb27608ce9508e43ee5
10 SunlightColorCard/CRW_2673.CRW 8053180 feeb8f818325d6ddb4e88b5eabe4a74f4cb5e1da9f70135ae3f85938c86695dc
11 SunlightColorCard/CRW_2674.CRW 8312060 45e7402c888582adadb607599eae80644d657d61c9607b426c411ee306e42daf
12 SunlightColorCard/CRW_2675.CRW 8565950 be28d2fa049aa4158ae9e3ec49dd28eb56fcd0ffa7af060235b8bda0d89dfd0a
13 SunlightColorCard/CRW_2676.CRW 7795508 76f7418a82e45e1034b3adacacf4bc756cb65a0d683e5bb3631992784e654f93
14 SunlightColorCard/CRW_2677.CRW 7747450 a5e7bc618e6b80ea70f9bad6a1bf41ee63eadbc51df28b82d0864a73434f7964
15 SunlightColorCard/CRW_2678.CRW 6815054 28824f0f64b087488d8dc5a66a8bdf717e83a4d4e1660676d07991bf32dffb96
16 SunlightColorCard/CRW_2679.CRW 7050606 b5c915fa89542265deb4fa16da3ca7ffda01b1d961903836b0059d024316673a
17 SunlightColorCard/CRW_2680.CRW 7231410 a5780dfe99a2df2a3ed9de1888e91baa614b2e009a2b6e0a84d442b6bf1af9fd
18 SunlightColorCard/CRW_2681.CRW 7438088 eb4475919c2770f816d47787656ab7ecb5396f30d2c03628c3ab167ebbc47c65
19 SunlightColorCard/CRW_2682.CRW 7584262 a6fe417b90ce41b03552963dee6b21e4f02b59a5c51148edc04af7224e8c59e9
20 SunlightColorCard/CRW_2683.CRW 7811474 5402ad40e2689d78ade083d8855ecced5caea9368b9d77ecc4a61a16dc55aa36
21 SunlightColorCard/CRW_2684.CRW 8043372 bbef5da457c0b6d6c45c74a99f3e318e6c80a7d3a0ad5c7cb7e13d24205f64fa
22 SunlightColorCard/CRW_2685.CRW 8242344 8d68d592a5c8933f0f35630114fe7017be808b007867beaf1a8d6d2bd1441f70
23 SunlightColorCard/CRW_2686.CRW 8462380 79f7db32b18a834d5408500185c65d316387d1440aa61e26a11be85883fcc382
24 SunlightColorCard/CRW_2687.CRW 7690732 0333d8d18390e77128f186239e6de71c0432ab6ee9669a71c210255e4d915d19
25 TungstonColorChart/CRW_2688.CRW 6269390 d9c4334edfe3636a8a7e69f29c1d1e89f820b7b704697684d7996faee41f5d43
26 TungstonColorChart/CRW_2689.CRW 6001348 1e8b47645d76a71fd9fb4111c4dce805d6dcd41ac82e6b4ce52debbf71565455
27 TungstonColorChart/CRW_2690.CRW 6114056 f63fe49a9ed13af33ddb8d8ed8d75de4e304a3bc2c1f1d4275ba7edbc4f459f3
28 TungstonColorChart/CRW_2691.CRW 6299730 ad8a2844c00965fb6a12e8fc85a6e8bfbdf1924e6d1a68b55472701cb8ffa115
29 TungstonColorChart/CRW_2696.CRW 6169684 105ee515c94da78dedc1f8d777982c4a1e62781b160428524fbe922f62b3ef5c
30 TungstonColorChart/CRW_2697.CRW 5565982 327eebf702185127feb8a5f4bfac5d7ebad5532f7417b302997b19b97d7ff4a8
31 TungstonColorChart/CRW_2698.CRW 5709092 f0fca20f8dcd51046e53bebf9fb9f624677ee674ad739622cdb2578977669705
32 TungstonColorChart/CRW_2699.CRW 5873492 57724a1ff507cf4e78c80a34cd4e0b77eac397322228d32e5215fc3869853dd1
33 TungstonColorChart/CRW_2700.CRW 5988146 6c97ae2c72114034e2fbe6aed0ef97f475696e43b56ed62273eccbfbba4cbaa0
34 TungstonColorChart/CRW_2701.CRW 6202756 c4032e46b753a718d8748e269a9da5d124891d07912542368ea867529143aa62
35 TungstonColorChart/CRW_2702.CRW 6409582 89b65c1c07e0685841046db85923b0bf80e747fed141ddf0335ba12dc75f3f3c
36 TungstonColorChart/CRW_2703.CRW 6407270 50179d0a330c967a74d76bc8342eea1052689c5c544aa1a913769b241ac6f736
37 TungstonColorChart/CRW_2704.CRW 6483154 6bac4d8229a8d0fff1445d212ca63e53911dc7bda988743215009257b87787fa
38 TungstonColorChart/CRW_2705.CRW 7170488 629f94131e7f598964d2635e3039a20c441563201c4f3cbf177d83ad6b061f78
39 TungstonColorChart/CRW_2706.CRW 6383026 67657e7a21f6fd8580012fccd2210afccf285e747bc4d39b8ea775811528ce83
+3 -1
View File
@@ -332,7 +332,9 @@ pub struct Analysis {
/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera:
/// [`relative_wb_camera`].
pub fn relative_wb_format(format: &str) -> bool {
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF", "MRW"].iter().any(|f| format.eq_ignore_ascii_case(f))
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF", "MRW", "CRW"]
.iter()
.any(|f| format.eq_ignore_ascii_case(f))
}
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
@@ -27,6 +27,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
| RawFormat::Pef
| RawFormat::Srw
| RawFormat::Orf
| RawFormat::Crw
| RawFormat::Mrw
| RawFormat::Erf
)
+5 -1
View File
@@ -1,6 +1,6 @@
//! Camera colour profiles of our own, pooled from many photos of one camera model.
//!
//! A raw file without colour matrices (Sony ARW, Nikon NEF, Fujifilm RAF) gets its look fitted to its own embedded camera
//! A raw file without colour matrices (Sony ARW, Nikon NEF, Fujifilm RAF, Canon CRW) gets its look fitted to its own embedded camera
//! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a
//! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate`
//! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value
@@ -25,6 +25,7 @@ const MAX_FILE: u64 = 4 << 20;
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
/// `(model, JSON)`.
pub const BUNDLED: &[(&str, &str)] = &[
("Canon EOS D60", include_str!("../../../assets/camera-profiles/Canon_EOS_D60.json")),
("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")),
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
@@ -295,6 +296,9 @@ mod tests {
assert_eq!(bundled(model), Some(p));
}
assert!(bundled("No Such Camera").is_none());
// a model name with spaces is stored under its safe file name
assert!(bundled("Canon EOS D60").is_some_and(|p| p.files > 1000));
assert_eq!(file_name("Canon EOS D60"), "Canon_EOS_D60.json");
}
#[test]
+7
View File
@@ -149,6 +149,13 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
}
Err(Err(e)) => return Err(e),
};
// what the Exif reader can't see, the decoder may have read from the raw's own records (a CRW keeps make,
// model and capture time in CIFF records, no Exif): it fills the gaps, so the catalog knows the camera
let (meta, captured) = {
let mut m = m.clone();
m.fill_missing(&raw.metadata);
meta_of(&m)
};
let (w, h) = raw.developed_size();
let (mut w, mut h) = (w.max(1) as u32, h.max(1) as u32);
if raw.orientation.swaps_axes() {
+35
View File
@@ -44,6 +44,30 @@ fn psd_probe_reads_embedded_capture_time_gps_and_title() {
use super::*;
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
#[test]
fn crw_probes_name_their_camera_from_the_ciff_records() {
// no Exif in a CRW: the camera and capture time come from the decoder's records, and the catalog's camera
// string decides the white-balance scale (the D60 has chart-measured matrices, the 10D none)
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
for (name, camera, kelvin) in [("crw-canon-eos-d60.crw", "Canon Canon EOS D60", true), ("crw-canon-eos-10d-a.crw", "Canon Canon EOS 10D", false)]
{
let Ok(bytes) = std::fs::read(dir.join(name)) else {
lightcraft_testkit::skip!(Corpus, "{name} absent");
continue;
};
assert!(lightcraft_meta::extract(&bytes).model.is_none(), "{name}: no Exif to read");
let info = probe_bytes(name, &bytes).unwrap();
assert_eq!(info.meta.camera, camera, "{name}");
assert!(info.captured.is_some(), "{name}");
assert_eq!(lightcraft_catalog::relative_wb_camera(&info.format, &info.meta.camera), !kelvin, "{name}");
let (t, _) = info.as_shot_wb.unwrap();
assert_eq!(t != 6500.0, kelvin, "{name}: {t}");
}
}
/// Which white-balance scale a raw gets (issue #730): a file with matrices of its own is absolute from them; a
/// fitted-look file is absolute through its camera's measured matrices, which only read the as-shot
/// multipliers as Kelvin; any other raw stays relative (6500 / 0 = as shot). The catalog's decision from the
@@ -110,6 +134,17 @@ fn white_balance_scale_follows_the_files_matrices_then_the_cameras() {
assert!(other.relative && other.scale_white.is_none());
let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default());
assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0));
// CRW: no matrices in the file. The EOS D60 has chart-measured matrices: Kelvin through them, its multipliers
// for the sun its D50 matrix was measured under reading as D50; another body stays relative, so its
// JPEG-fitted look applies
let d60 = WbScaleOf::new(RawFormat::Crw, &ColorData::default(), Some([2.362, 1.0, 1.298]), &meta("Canon", "Canon EOS D60"));
assert!(!d60.own_matrix && !d60.relative && d60.legacy_relative && d60.scale_white.is_some());
let ((t, tint), (t50, tint50)) = (d60.as_shot(d65), xy_to_temp_tint(lightcraft_color::D50));
assert!((t - t50).abs() < 50.0 && (tint - tint50).abs() < 2.0, "{t} {tint} vs D50 {t50} {tint50}");
assert!(!lightcraft_catalog::relative_wb_camera("CRW", "Canon Canon EOS D60"));
let crw = WbScaleOf::new(RawFormat::Crw, &ColorData::default(), mul, &meta("Canon", "Canon EOS 10D"));
assert!(crw.relative && !crw.legacy_relative && crw.scale_white.is_none());
assert!(lightcraft_catalog::relative_wb_camera("CRW", "Canon Canon EOS 10D"));
// a file with matrices of its own (DNG): absolute from them, no matter whose camera
let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() };
let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3"));
+1 -1
View File
@@ -1139,7 +1139,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co
number(&src.image, "fileHeight").max(0) as u32,
&now,
);
if ["RAW", "DNG", "ARW", "CR2", "CR3", "NEF", "NRW", "RAF", "ORF", "RW2", "PEF"].contains(&p.format.to_uppercase().as_str()) {
if ["RAW", "DNG", "ARW", "CR2", "CR3", "CRW", "NEF", "NRW", "RAF", "ORF", "RW2", "PEF"].contains(&p.format.to_uppercase().as_str()) {
p.kind = lightcraft_catalog::MediaKind::Raw;
}
p
+1 -1
View File
@@ -1,7 +1,7 @@
use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 72;
pub const RENDER_CACHE_VERSION: u64 = 73;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
+2
View File
@@ -15,6 +15,8 @@ lightcraft-raster = { workspace = true }
lightcraft-color = { workspace = true }
lightcraft-geom = { workspace = true }
serde = { workspace = true }
chrono = { workspace = true }
craft-crw = { workspace = true }
thiserror = { workspace = true }
rayon = { workspace = true }
miniz_oxide = { workspace = true }
+21
View File
@@ -0,0 +1,21 @@
// Camera colour matrices fitted to our own photographs of a ColorChecker Classic (post-November-2014 formulation),
// for cameras with no published spectral sensitivities. Kept by hand: each row's capture, fit and checks are in
// clean-room/crw/10-chart-calibration.md (scripts local-only). Reference colours: X-Rite's published L*a*b* (D50) for
// the chart, as packaged by colour-science (BSD-3-Clause). No number comes from Adobe or from a GPL raw decoder.
use super::Camera;
use lightcraft_color::Mat3;
#[rustfmt::skip]
pub(super) const CAMERAS: &[Camera] = &[
// Canon EOS D60 (CRW), the maintainer's body, ISO 100. Matrix 1: one ~2,900 K tungsten bulb, 11 frames, filed as
// Standard Illuminant A; matrix 2: direct afternoon sun, ~4,850 K, 20 frames, filed as D50 (both temperatures from
// the camera's own white-balance presets). Fitted on chromaticity with neutral held at D50 (the sunlit chart was
// unevenly lit): CIEDE2000 mean 2.42 / 1.70 on the 24 patches, 2.74 / 1.97 leaving each patch out.
Camera {
make: "Canon",
model: "EOS D60",
illuminant: [17, 23],
color_matrix: [Mat3([[0.950961, -0.345297, 0.112165], [-0.545579, 1.400621, 0.558365], [-0.123107, 0.222004, 1.026236]]), Mat3([[0.609694, -0.127035, -0.045387], [-0.461201, 1.16644, 0.337283], [-0.115992, 0.229363, 0.791647]])],
forward_matrix: [Mat3([[0.763921, 0.183562, 0.016813], [0.268545, 0.944022, -0.212567], [0.100742, -0.431647, 1.15601]]), Mat3([[0.757277, 0.190573, 0.016446], [0.291782, 1.009128, -0.300909], [0.026419, -0.264451, 1.063137]])],
},
];
+4 -1
View File
@@ -15,7 +15,8 @@
//!
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, JPEG XL (DNG 1.7,
//! `jxl` feature, on by default), tiled/stripped, CFA and LinearRaw),
//! Canon CR2 / CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
//! Canon CR2 / CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW) / CRW (the CIFF bodies of 2000–2004; clean-room-derived
//! compression, `vendor::crw`; the PowerShot G5's anomalous coding is refused), Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
//! and X-Trans, lossless and lossy compressed), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8,
//! packed 2/5/7, the 16-bit words of the oldest bodies), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed: 16-bit words, 12-bit XZ-2 words, 12-bit 16-byte blocks of the E-300/E-330/E-500).
//! [`embedded_preview`] covers these containers' JPEG previews. Variants we can't decode yet (compressed ORF, CR3 unverified marker families / C-RAW configurations) return [`RawError::Unsupported`]; each vendor module documents its sources
@@ -140,6 +141,7 @@ impl RawFormat {
RawFormat::Dng
| RawFormat::Cr2
| RawFormat::Cr3
| RawFormat::Crw
| RawFormat::Nef
| RawFormat::Nrw
| RawFormat::Arw
@@ -413,6 +415,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
RawFormat::Dng => dng::decode(bytes, mode).map(lift_clipped),
RawFormat::Cr2 => vendor::cr2::decode(bytes, mode),
RawFormat::Cr3 => vendor::cr3::decode(bytes, mode),
RawFormat::Crw => vendor::crw::decode(bytes, mode),
RawFormat::Nef | RawFormat::Nrw => vendor::nef::decode(bytes, mode),
RawFormat::Arw => vendor::arw::decode(bytes, mode),
RawFormat::Raf => vendor::raf::decode(bytes, mode),
+2 -2
View File
@@ -485,8 +485,8 @@ fn scanned_containers_give_the_largest_colour_jpeg() {
f.extend_from_slice(part);
f.extend_from_slice(&[0u8; 7]);
}
// MRW decodes; its preview is still found by the scan
assert!(crate::probe(&f).is_some_and(|p| !p.is_supported() || p == crate::RawFormat::Mrw), "{head:?}");
// CRW and MRW decode; their previews are still found by the scan, the others stay preview-only containers
assert!(crate::probe(&f).is_some_and(|p| !p.is_supported() || p == crate::RawFormat::Crw || p == crate::RawFormat::Mrw), "{head:?}");
assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}");
for n in (0..f.len()).step_by(11) {
let _ = embedded_preview(&f[..n]);
+52 -17
View File
@@ -1,28 +1,34 @@
//! Camera colour matrices from measured spectral sensitivities, for raws that carry no colour matrix.
//! Measured camera colour matrices, for raws that carry no colour matrix.
//!
//! [`find`] looks a camera up by its Exif make and model in a table generated by
//! `tools/spectral_camera_table.py` from rawtoaces-data (Academy Software Foundation, Apache-2.0): per model, DNG
//! `ColorMatrix1/2` and `ForwardMatrix1/2` for Standard Illuminant A and D65, fitted to the camera's measured spectral
//! sensitivities. [`Camera::fill`] writes them into a file's [`ColorData`], and [`crate::color`]'s DNG model does the
//! rest. Other names of a covered camera (Rebel T3i for EOS 600D) come from rawtoaces-data's alias list, and other
//! bodies built on a covered camera's sensor from [`SAME_SENSOR`]. No number
//! [`find`] looks a camera up by its Exif make and model in two tables, per model DNG `ColorMatrix1/2` and
//! `ForwardMatrix1/2` for two calibration illuminants:
//! - generated by `tools/spectral_camera_table.py` from rawtoaces-data (Academy Software Foundation, Apache-2.0),
//! fitted to the camera's measured spectral sensitivities, for Standard Illuminant A and D65. Other names of a
//! covered camera (Rebel T3i for EOS 600D) come from rawtoaces-data's alias list, and other bodies built on a
//! covered camera's sensor from [`SAME_SENSOR`];
//! - `chart_table.rs`: fitted to our own photographs of a ColorChecker Classic under two lights, for cameras with no
//! published sensitivities (`clean-room/crw/10-chart-calibration.md`).
//!
//! [`Camera::fill`] writes them into a file's [`ColorData`], and [`crate::color`]'s DNG model does the rest. No number
//! comes from Adobe or from a GPL raw decoder. Which colour a raw starts from is the engine's choice
//! (`docs/camera-preview-colour.md`).
use crate::ColorData;
use lightcraft_color::{Mat3, Xy};
#[path = "chart_table.rs"]
mod chart;
#[path = "spectral_table.rs"]
mod table;
/// DNG `CalibrationIlluminant` codes of the two matrices: Standard Illuminant A, D65.
const ILLUMINANTS: [u16; 2] = [17, 21];
/// One camera's colour matrices.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera {
pub make: &'static str,
pub model: &'static str,
/// XYZ → camera under Standard Illuminant A and D65 (`ColorMatrix1/2`).
/// DNG `CalibrationIlluminant` codes of the two matrices: Standard Illuminant A (17) and D65 (21) for the spectral
/// table, A and D50 (23) for a chart-measured camera.
pub illuminant: [u16; 2],
/// XYZ → camera under the two illuminants (`ColorMatrix1/2`).
pub color_matrix: [Mat3; 2],
/// White-balanced camera → XYZ D50 (`ForwardMatrix1/2`).
pub forward_matrix: [Mat3; 2],
@@ -34,7 +40,7 @@ impl Camera {
if crate::color::has_matrix(color) {
return false;
}
color.illuminant = ILLUMINANTS;
color.illuminant = self.illuminant;
color.color_matrix = self.color_matrix.map(Some);
color.forward_matrix = self.forward_matrix.map(Some);
true
@@ -88,7 +94,7 @@ pub fn find(make: Option<&str>, model: &str) -> Option<&'static Camera> {
};
match table::ALIASES.iter().chain(SAME_SENSOR).find(|(m, alias, _)| same(m, alias)) {
Some((m, _, target)) => table::CAMERAS.iter().find(|c| brand(c.make) == brand(m) && c.model == *target),
None => table::CAMERAS.iter().find(|c| same(c.make, c.model)),
None => cameras().find(|c| same(c.make, c.model)),
}
}
@@ -120,9 +126,9 @@ pub fn as_shot_white_through(camera: &Camera, color: &ColorData, wb_multipliers:
Some(crate::color::as_shot_white_of(&c, wb_multipliers))
}
/// Every camera in the table.
pub fn cameras() -> &'static [Camera] {
table::CAMERAS
/// Every camera in the tables: the spectral one, then the chart-measured one.
pub fn cameras() -> impl Iterator<Item = &'static Camera> {
table::CAMERAS.iter().chain(chart::CAMERAS)
}
#[cfg(test)]
@@ -240,7 +246,7 @@ mod tests {
#[test]
fn every_row_is_a_usable_dng_profile() {
assert_eq!(cameras().len(), 52);
assert_eq!(cameras().count(), 53);
for c in cameras() {
assert_eq!(find(Some(c.make), c.model), Some(c), "{} {}", c.make, c.model);
for m in c.color_matrix.iter().chain(&c.forward_matrix) {
@@ -253,7 +259,36 @@ mod tests {
let d50 = lightcraft_color::D50.to_xyz();
assert!((0..3).all(|i| (w[i] - d50[i]).abs() < 1e-4), "{} {w:?}", c.model);
}
assert!(c.illuminant.iter().all(|&i| crate::color::illuminant_temperature(i).is_some()), "{}", c.model);
}
assert!(table::CAMERAS.iter().all(|c| c.illuminant == [17, 21]));
}
#[test]
fn the_chart_measured_eos_d60_reads_its_presets_in_kelvin() {
// Exif "Canon" / "Canon EOS D60" (CRW), and the catalog's joined string; not the Nikon D60
let c = find(Some("Canon"), "Canon EOS D60").unwrap();
assert_eq!((c.model, c.illuminant), ("EOS D60", [17, 23]));
assert_eq!(find_camera("Canon Canon EOS D60"), Some(c));
assert_eq!(find(Some("NIKON CORPORATION"), "NIKON D60"), None);
// the camera's own white-balance presets (ColorBalance multipliers of a D60 file), which the fit never saw,
// land near the temperatures Canon names for them, on the locus
let kelvin = |r: f32, b: f32| {
let xy = as_shot_white_through(c, &ColorData::default(), Some([r, 1.0, b])).unwrap();
lightcraft_color::cct::xy_to_temp_tint(xy)
};
let (tungsten, tint) = kelvin(1.568, 1.918);
assert!((2900.0..3500.0).contains(&tungsten) && tint.abs() < 15.0, "Tungsten {tungsten} {tint}");
let (daylight, tint) = kelvin(2.541, 1.219);
assert!((4800.0..5800.0).contains(&daylight) && tint.abs() < 15.0, "Daylight {daylight} {tint}");
let (cloudy, _) = kelvin(2.799, 1.131);
let (flash, _) = kelvin(2.911, 1.103);
eprintln!("EOS D60 presets: Tungsten {tungsten:.0} K, Daylight {daylight:.0} K, Cloudy {cloudy:.0} K, Flash {flash:.0} K");
assert!(cloudy > daylight + 300.0 && flash > daylight + 300.0, "{daylight} {cloudy} {flash}");
// the two calibration lights read as the illuminants they were filed under
let (bulb, _) = kelvin(1.353, 2.213);
let (sun, _) = kelvin(2.362, 1.298);
assert!((bulb - 2856.0).abs() < 30.0 && (sun - 5003.0).abs() < 50.0, "{bulb} {sun}");
}
#[test]
+52
View File
@@ -9,312 +9,364 @@ pub(super) const CAMERAS: &[Camera] = &[
Camera {
make: "ARRI",
model: "D21",
illuminant: [17, 21],
color_matrix: [Mat3([[0.933065, -0.03997, 0.042177], [-0.321253, 0.916394, 0.250116], [-0.047929, 0.258088, 0.770888]]), Mat3([[0.841118, 0.11685, -0.05716], [-0.416513, 1.06087, 0.169491], [-0.121062, 0.361236, 0.692345]])],
forward_matrix: [Mat3([[0.913988, -0.102353, 0.152661], [0.315506, 0.849769, -0.165276], [-0.134707, -0.698389, 1.6582]]), Mat3([[1.01361, -0.108757, 0.059444], [0.405694, 0.811177, -0.216871], [-0.027612, -0.326433, 1.17915]])],
},
Camera {
make: "Arashi Vision",
model: "Insta360 X5",
illuminant: [17, 21],
color_matrix: [Mat3([[1.0899, -0.436896, 0.023525], [-0.482767, 1.39879, 0.369626], [-0.056659, 0.169421, 0.72407]]), Mat3([[0.700283, -0.158934, -0.077847], [-0.543955, 1.30291, 0.196622], [-0.148964, 0.25165, 0.516509]])],
forward_matrix: [Mat3([[0.710808, 0.160362, 0.093126], [0.231838, 0.891164, -0.123002], [0.020454, -0.498248, 1.3029]]), Mat3([[0.706099, 0.209581, 0.048616], [0.287186, 0.8971, -0.184286], [0.044158, -0.279999, 1.06095]])],
},
Camera {
make: "Canon",
model: "Digital Rebel XTi",
illuminant: [17, 21],
color_matrix: [Mat3([[0.984721, -0.317618, -0.03823], [-0.536205, 1.41139, 0.499196], [-0.019493, 0.0898, 0.993219]]), Mat3([[0.654378, -0.152128, -0.077165], [-0.504141, 1.17532, 0.279049], [-0.071499, 0.132862, 0.672873]])],
forward_matrix: [Mat3([[0.735947, 0.130604, 0.097745], [0.271991, 0.870186, -0.142177], [-0.013289, -0.229681, 1.06808]]), Mat3([[0.693623, 0.247958, 0.022715], [0.295094, 0.978904, -0.273998], [0.00951, -0.117049, 0.932644]])],
},
Camera {
make: "Canon",
model: "EOS-1D X Mark II",
illuminant: [17, 21],
color_matrix: [Mat3([[0.841821, -0.109388, -0.029826], [-0.469467, 1.34341, 0.484215], [-0.074495, 0.204633, 0.843881]]), Mat3([[0.559829, 0.012974, -0.076841], [-0.474068, 1.17573, 0.252424], [-0.137973, 0.263217, 0.557436]])],
forward_matrix: [Mat3([[0.867342, -0.069209, 0.166163], [0.282942, 0.879178, -0.162119], [0.042308, -0.560464, 1.34326]]), Mat3([[0.866644, -0.0379, 0.135551], [0.340507, 0.913959, -0.254466], [0.035971, -0.324047, 1.11318]])],
},
Camera {
make: "Canon",
model: "EOS-1Ds Mark III",
illuminant: [17, 21],
color_matrix: [Mat3([[0.933464, -0.245919, -0.026807], [-0.571496, 1.49286, 0.379177], [-0.048229, 0.100118, 0.927528]]), Mat3([[0.623037, -0.073582, -0.085177], [-0.54689, 1.28786, 0.213006], [-0.105037, 0.156191, 0.659429]])],
forward_matrix: [Mat3([[0.785005, 0.07525, 0.10404], [0.285685, 0.799015, -0.084701], [0.044611, -0.231741, 1.01223]]), Mat3([[0.764142, 0.106772, 0.093381], [0.318478, 0.835999, -0.154477], [0.03106, -0.128764, 0.922809]])],
},
Camera {
make: "Canon",
model: "EOS-1Ds Mark II",
illuminant: [17, 21],
color_matrix: [Mat3([[0.945891, -0.245875, -0.023592], [-0.58518, 1.49586, 0.412994], [-0.049647, 0.124624, 1.04996]]), Mat3([[0.633076, -0.080316, -0.073882], [-0.552992, 1.27833, 0.227086], [-0.10768, 0.180208, 0.730732]])],
forward_matrix: [Mat3([[0.78531, 0.073316, 0.10567], [0.29637, 0.804671, -0.101041], [0.021036, -0.254656, 1.05872]]), Mat3([[0.780605, 0.119757, 0.063934], [0.332784, 0.851961, -0.184745], [0.021321, -0.137163, 0.940946]])],
},
Camera {
make: "Canon",
model: "EOS 100D",
illuminant: [17, 21],
color_matrix: [Mat3([[0.823379, -0.153284, -0.011827], [-0.451822, 1.34611, 0.422153], [-0.063154, 0.17914, 0.866791]]), Mat3([[0.537713, -0.001778, -0.071702], [-0.479729, 1.20511, 0.230384], [-0.135862, 0.245077, 0.608091]])],
forward_matrix: [Mat3([[0.839709, -0.013024, 0.137611], [0.260264, 0.869968, -0.130232], [0.040567, -0.473274, 1.25781]]), Mat3([[0.852444, -0.005311, 0.117164], [0.33135, 0.880249, -0.211599], [0.038232, -0.260159, 1.04703]])],
},
Camera {
make: "Canon",
model: "EOS 200D",
illuminant: [17, 21],
color_matrix: [Mat3([[0.882798, -0.11491, -0.016841], [-0.443662, 1.31235, 0.491834], [-0.062151, 0.206192, 0.798346]]), Mat3([[0.570466, 0.029473, -0.079371], [-0.461712, 1.15876, 0.257222], [-0.118587, 0.24972, 0.538517]])],
forward_matrix: [Mat3([[0.867592, -0.074026, 0.17073], [0.27684, 0.904905, -0.181745], [0.007997, -0.615366, 1.43247]]), Mat3([[0.884048, -0.067045, 0.147293], [0.347584, 0.917256, -0.26484], [0.021454, -0.322825, 1.12647]])],
},
Camera {
make: "Canon",
model: "EOS 200D II",
illuminant: [17, 21],
color_matrix: [Mat3([[0.820814, -0.08112, -0.025232], [-0.449049, 1.31524, 0.500348], [-0.063522, 0.20839, 0.807016]]), Mat3([[0.534782, 0.039491, -0.075304], [-0.461016, 1.15287, 0.26203], [-0.117011, 0.248633, 0.539767]])],
forward_matrix: [Mat3([[0.879874, -0.096427, 0.180849], [0.283378, 0.900453, -0.183831], [0.006798, -0.61219, 1.4305]]), Mat3([[0.897644, -0.089573, 0.156225], [0.354938, 0.914467, -0.269405], [0.020307, -0.323555, 1.12835]])],
},
Camera {
make: "Canon",
model: "EOS 5DS",
illuminant: [17, 21],
color_matrix: [Mat3([[0.784071, -0.14278, -0.018109], [-0.493528, 1.36672, 0.49299], [-0.067845, 0.178082, 0.792129]]), Mat3([[0.501948, -0.005161, -0.069927], [-0.500698, 1.19504, 0.25793], [-0.125608, 0.228657, 0.525639]])],
forward_matrix: [Mat3([[0.838414, -0.019978, 0.145859], [0.285256, 0.874752, -0.160008], [0.038705, -0.507361, 1.29376]]), Mat3([[0.840997, -0.003166, 0.126464], [0.342942, 0.904091, -0.247033], [0.034419, -0.289165, 1.07985]])],
},
Camera {
make: "Canon",
model: "EOS 5D",
illuminant: [17, 21],
color_matrix: [Mat3([[0.961758, -0.26807, -0.006664], [-0.565407, 1.47679, 0.405541], [-0.067833, 0.12912, 0.951998]]), Mat3([[0.616895, -0.064661, -0.085183], [-0.554321, 1.28625, 0.22097], [-0.137616, 0.197613, 0.656267]])],
forward_matrix: [Mat3([[0.79062, 0.078103, 0.095573], [0.287453, 0.812941, -0.100394], [0.068474, -0.279714, 1.03635]]), Mat3([[0.77434, 0.091394, 0.098562], [0.324069, 0.841224, -0.165293], [0.044245, -0.169979, 0.950838]])],
},
Camera {
make: "Canon",
model: "EOS 5D Mark III",
illuminant: [17, 21],
color_matrix: [Mat3([[0.798227, -0.156522, -0.024001], [-0.479014, 1.37953, 0.412187], [-0.067815, 0.178094, 0.795854]]), Mat3([[0.536462, -0.020759, -0.074508], [-0.493492, 1.22724, 0.222071], [-0.136505, 0.242956, 0.547031]])],
forward_matrix: [Mat3([[0.829015, -0.007278, 0.142558], [0.266702, 0.857593, -0.124295], [0.048011, -0.488746, 1.26584]]), Mat3([[0.818929, 0.023372, 0.121994], [0.319742, 0.879191, -0.198933], [0.040465, -0.281513, 1.06615]])],
},
Camera {
make: "Canon",
model: "EOS 5D Mark II",
illuminant: [17, 21],
color_matrix: [Mat3([[0.765281, -0.126067, -0.028016], [-0.539891, 1.44802, 0.407637], [-0.058008, 0.128461, 0.861097]]), Mat3([[0.508976, -0.002163, -0.072663], [-0.524812, 1.25489, 0.224018], [-0.11344, 0.183978, 0.599963]])],
forward_matrix: [Mat3([[0.84396, -0.009524, 0.12986], [0.296711, 0.799464, -0.096175], [0.052873, -0.321603, 1.09383]]), Mat3([[0.8407, 0.001511, 0.122085], [0.343565, 0.82753, -0.171095], [0.035331, -0.182192, 0.971966]])],
},
Camera {
make: "Canon",
model: "EOS 5D Mark IV",
illuminant: [17, 21],
color_matrix: [Mat3([[0.763394, -0.085035, -0.016567], [-0.442907, 1.31347, 0.486351], [-0.080725, 0.247335, 0.884784]]), Mat3([[0.49672, 0.034424, -0.068025], [-0.461551, 1.16055, 0.255439], [-0.154521, 0.307914, 0.579555]])],
forward_matrix: [Mat3([[0.880649, -0.094084, 0.177732], [0.27666, 0.906137, -0.182797], [0.024429, -0.659213, 1.45989]]), Mat3([[0.898336, -0.087734, 0.153694], [0.347611, 0.924078, -0.271689], [0.035769, -0.378779, 1.16812]])],
},
Camera {
make: "Canon",
model: "EOS 600D",
illuminant: [17, 21],
color_matrix: [Mat3([[0.800214, -0.150575, -0.010842], [-0.462694, 1.36091, 0.41413], [-0.054076, 0.17327, 0.853835]]), Mat3([[0.528239, -0.007001, -0.067784], [-0.485979, 1.21691, 0.225002], [-0.13144, 0.244539, 0.592949]])],
forward_matrix: [Mat3([[0.836952, -0.011114, 0.138458], [0.267123, 0.859868, -0.12699], [0.017395, -0.463764, 1.27147]]), Mat3([[0.849461, 0.005947, 0.108888], [0.332065, 0.875561, -0.207627], [0.033552, -0.262503, 1.05405]])],
},
Camera {
make: "Canon",
model: "EOS M",
illuminant: [17, 21],
color_matrix: [Mat3([[0.858133, -0.188655, -0.015316], [-0.477476, 1.37657, 0.415737], [-0.066593, 0.173487, 0.870331]]), Mat3([[0.559337, -0.024286, -0.076489], [-0.498439, 1.22646, 0.227107], [-0.13663, 0.237788, 0.612213]])],
forward_matrix: [Mat3([[0.818185, 0.015661, 0.130449], [0.263684, 0.85776, -0.121444], [0.046738, -0.442459, 1.22083]]), Mat3([[0.820106, 0.031315, 0.112875], [0.32549, 0.874009, -0.199499], [0.03847, -0.243276, 1.02991]])],
},
Camera {
make: "Canon",
model: "EOS R10",
illuminant: [17, 21],
color_matrix: [Mat3([[0.900395, -0.120204, -0.023257], [-0.450784, 1.32079, 0.490112], [-0.060508, 0.207987, 0.757971]]), Mat3([[0.586274, 0.022629, -0.081629], [-0.465757, 1.16444, 0.255537], [-0.114546, 0.247787, 0.505639]])],
forward_matrix: [Mat3([[0.860416, -0.07424, 0.178119], [0.279245, 0.90614, -0.185385], [-0.005252, -0.64772, 1.47808]]), Mat3([[0.874599, -0.057155, 0.146852], [0.346428, 0.920831, -0.267259], [0.018292, -0.341711, 1.14852]])],
},
Camera {
make: "Canon",
model: "EOS R5",
illuminant: [17, 21],
color_matrix: [Mat3([[0.906792, -0.181028, -0.039982], [-0.497007, 1.37995, 0.46653], [-0.056558, 0.135904, 0.790883]]), Mat3([[0.599617, -0.03146, -0.086837], [-0.500836, 1.20715, 0.246929], [-0.122485, 0.204473, 0.529195]])],
forward_matrix: [Mat3([[0.825544, 0.000874, 0.137878], [0.27921, 0.851443, -0.130653], [0.053855, -0.393303, 1.16455]]), Mat3([[0.80269, 0.040879, 0.120727], [0.321664, 0.899552, -0.221215], [0.039913, -0.245426, 1.03062]])],
},
Camera {
make: "Canon",
model: "EOS R5m2",
illuminant: [17, 21],
color_matrix: [Mat3([[0.925997, -0.201878, -0.03247], [-0.514263, 1.39694, 0.472057], [-0.067213, 0.159605, 0.932813]]), Mat3([[0.603973, -0.037874, -0.08705], [-0.520173, 1.22523, 0.247204], [-0.147012, 0.240988, 0.619754]])],
forward_matrix: [Mat3([[0.816853, 0.016859, 0.130583], [0.284699, 0.847793, -0.132492], [0.049678, -0.384186, 1.15961]]), Mat3([[0.796684, 0.049994, 0.117618], [0.326624, 0.8894, -0.216024], [0.040655, -0.243309, 1.02776]])],
},
Camera {
make: "Canon",
model: "EOS R6",
illuminant: [17, 21],
color_matrix: [Mat3([[0.887235, -0.176902, -0.018642], [-0.462429, 1.33965, 0.473061], [-0.070097, 0.19962, 0.862666]]), Mat3([[0.571385, -0.014759, -0.079163], [-0.485367, 1.18708, 0.251862], [-0.145333, 0.265796, 0.580543]])],
forward_matrix: [Mat3([[0.830203, -0.011614, 0.145706], [0.268315, 0.891706, -0.16002], [0.03391, -0.533608, 1.3248]]), Mat3([[0.829826, 0.011021, 0.123449], [0.329107, 0.920067, -0.249174], [0.037653, -0.307101, 1.09455]])],
},
Camera {
make: "Canon",
model: "EOS R6m2",
illuminant: [17, 21],
color_matrix: [Mat3([[0.904651, -0.185945, -0.032292], [-0.490303, 1.37329, 0.46456], [-0.076482, 0.19622, 0.939437]]), Mat3([[0.595351, -0.032062, -0.086354], [-0.499687, 1.20659, 0.246445], [-0.151602, 0.266204, 0.633694]])],
forward_matrix: [Mat3([[0.822515, -0.0028, 0.14458], [0.275534, 0.86495, -0.140483], [0.04532, -0.468236, 1.24802]]), Mat3([[0.80207, 0.038555, 0.123671], [0.322259, 0.905003, -0.227262], [0.03728, -0.271382, 1.05921]])],
},
Camera {
make: "Canon",
model: "EOS RP",
illuminant: [17, 21],
color_matrix: [Mat3([[0.843269, -0.158656, -0.026198], [-0.497933, 1.37758, 0.476056], [-0.070882, 0.179269, 0.839769]]), Mat3([[0.548578, -0.014251, -0.077893], [-0.504763, 1.20691, 0.250579], [-0.139008, 0.243991, 0.560854]])],
forward_matrix: [Mat3([[0.836529, -0.017241, 0.145008], [0.285362, 0.860694, -0.146057], [0.049163, -0.481077, 1.25702]]), Mat3([[0.825922, 0.011858, 0.126516], [0.334357, 0.898585, -0.232942], [0.038621, -0.283664, 1.07015]])],
},
Camera {
make: "Canon",
model: "EOS R",
illuminant: [17, 21],
color_matrix: [Mat3([[0.732886, -0.080769, -0.010303], [-0.454757, 1.32829, 0.481287], [-0.083062, 0.251827, 0.895746]]), Mat3([[0.473635, 0.038459, -0.063761], [-0.474141, 1.17757, 0.250797], [-0.157456, 0.313293, 0.589901]])],
forward_matrix: [Mat3([[0.884185, -0.093978, 0.174089], [0.284166, 0.893986, -0.178152], [0.023242, -0.654769, 1.45663]]), Mat3([[0.908841, -0.095987, 0.151442], [0.356864, 0.902145, -0.259009], [0.034581, -0.371735, 1.16226]])],
},
Camera {
make: "Canon",
model: "PowerShot S90",
illuminant: [17, 21],
color_matrix: [Mat3([[1.37542, -0.592063, 0.037432], [-0.219062, 1.12456, 0.326212], [-0.021707, 0.09788, 0.58587]]), Mat3([[0.90732, -0.244506, -0.087152], [-0.314903, 1.06475, 0.215416], [-0.11223, 0.179775, 0.439924]])],
forward_matrix: [Mat3([[0.67794, 0.203129, 0.083227], [0.105177, 1.01192, -0.1171], [0.055684, -0.464622, 1.23404]]), Mat3([[0.655349, 0.321862, -0.012915], [0.180096, 1.10102, -0.281111], [0.064028, -0.26917, 1.03025]])],
},
Camera {
make: "Fujifilm",
model: "GFX 100",
illuminant: [17, 21],
color_matrix: [Mat3([[1.37208, -0.633889, 0.081365], [-0.430548, 1.32101, 0.427017], [-0.039908, 0.114944, 0.782345]]), Mat3([[0.834968, -0.233255, -0.07589], [-0.475844, 1.18948, 0.241344], [-0.126239, 0.194812, 0.541708]])],
forward_matrix: [Mat3([[0.673446, 0.25104, 0.039809], [0.200798, 0.950696, -0.151494], [0.031013, -0.340478, 1.13457]]), Mat3([[0.679917, 0.304542, -0.020163], [0.261808, 1.00662, -0.268426], [0.044444, -0.214012, 0.994673]])],
},
Camera {
make: "Fujifilm",
model: "X-T3",
illuminant: [17, 21],
color_matrix: [Mat3([[1.29933, -0.598524, 0.075033], [-0.403354, 1.28265, 0.450876], [-0.037817, 0.112326, 0.810909]]), Mat3([[0.785701, -0.221043, -0.071522], [-0.456611, 1.15627, 0.255052], [-0.124341, 0.191243, 0.562458]])],
forward_matrix: [Mat3([[0.671025, 0.255703, 0.037568], [0.190434, 0.97306, -0.163493], [0.03182, -0.334086, 1.12737]]), Mat3([[0.676828, 0.316449, -0.028982], [0.256753, 1.03712, -0.293871], [0.04319, -0.207191, 0.989106]])],
},
Camera {
make: "Fujifilm",
model: "X-T4",
illuminant: [17, 21],
color_matrix: [Mat3([[1.27406, -0.587718, 0.074639], [-0.371617, 1.24808, 0.450058], [-0.038746, 0.113215, 0.780014]]), Mat3([[0.771728, -0.215706, -0.070731], [-0.439625, 1.14183, 0.253491], [-0.125229, 0.192512, 0.542394]])],
forward_matrix: [Mat3([[0.668116, 0.258486, 0.037694], [0.175163, 0.992049, -0.167213], [0.040145, -0.355105, 1.14006]]), Mat3([[0.675162, 0.317733, -0.028599], [0.248581, 1.05006, -0.298641], [0.04702, -0.219918, 0.998002]])],
},
Camera {
make: "Hasselblad",
model: "L1D-20c",
illuminant: [17, 21],
color_matrix: [Mat3([[0.697908, -0.225515, 0.018865], [-0.403519, 1.29608, 0.413637], [-0.048508, 0.167167, 0.74727]]), Mat3([[0.466971, -0.076456, -0.040872], [-0.471572, 1.21777, 0.21163], [-0.133006, 0.246905, 0.516655]])],
forward_matrix: [Mat3([[0.754419, 0.110524, 0.099352], [0.21061, 0.943846, -0.154456], [0.016488, -0.523808, 1.33242]]), Mat3([[0.780313, 0.168783, 0.0152], [0.295267, 0.942833, -0.2381], [0.041, -0.301041, 1.08515]])],
},
Camera {
make: "Hasselblad",
model: "L2D-20c",
illuminant: [17, 21],
color_matrix: [Mat3([[0.787283, -0.249155, 0.044752], [-0.415734, 1.29586, 0.451949], [-0.060535, 0.144901, 0.709626]]), Mat3([[0.506449, -0.071884, -0.038243], [-0.435817, 1.14056, 0.251334], [-0.129637, 0.209508, 0.474062]])],
forward_matrix: [Mat3([[0.776836, 0.120396, 0.067064], [0.218196, 0.951802, -0.169998], [0.071832, -0.4598, 1.21307]]), Mat3([[0.807201, 0.167509, -0.010414], [0.291699, 1.01528, -0.306978], [0.062694, -0.296911, 1.05932]])],
},
Camera {
make: "Nikon",
model: "D200",
illuminant: [17, 21],
color_matrix: [Mat3([[1.37569, -0.560621, 0.01677], [-0.606594, 1.52996, 0.38327], [-0.024329, 0.069398, 0.99135]]), Mat3([[0.898945, -0.249527, -0.086342], [-0.568281, 1.29839, 0.222009], [-0.098369, 0.139456, 0.687094]])],
forward_matrix: [Mat3([[0.708545, 0.18748, 0.068271], [0.274935, 0.818921, -0.093855], [0.012224, -0.169279, 0.98216]]), Mat3([[0.685079, 0.274473, 0.004744], [0.294469, 0.898619, -0.193087], [0.026107, -0.10149, 0.900488]])],
},
Camera {
make: "Nikon",
model: "D3300",
illuminant: [17, 21],
color_matrix: [Mat3([[1.01047, -0.365965, 0.032301], [-0.473579, 1.3513, 0.474736], [-0.075188, 0.164522, 1.02012]]), Mat3([[0.644589, -0.136221, -0.056505], [-0.47509, 1.16499, 0.26318], [-0.15352, 0.237111, 0.681108]])],
forward_matrix: [Mat3([[0.738138, 0.162349, 0.063809], [0.234836, 0.925124, -0.15996], [0.059989, -0.351996, 1.11711]]), Mat3([[0.742449, 0.23561, -0.013763], [0.290358, 1.00587, -0.29623], [0.04507, -0.217376, 0.99741]])],
},
Camera {
make: "Nikon",
model: "D5100",
illuminant: [17, 21],
color_matrix: [Mat3([[1.34772, -0.48474, 0.012024], [-0.466856, 1.29899, 0.44042], [-0.076248, 0.161652, 0.981853]]), Mat3([[0.929942, -0.213704, -0.081538], [-0.481178, 1.17186, 0.262186], [-0.14942, 0.233631, 0.699016]])],
forward_matrix: [Mat3([[0.732398, 0.159354, 0.072543], [0.240376, 0.912366, -0.152741], [0.06111, -0.349037, 1.11303]]), Mat3([[0.727572, 0.254229, -0.017505], [0.287762, 1.00467, -0.292432], [0.040205, -0.206009, 0.990908]])],
},
Camera {
make: "Nikon",
model: "D5300",
illuminant: [17, 21],
color_matrix: [Mat3([[0.943297, -0.302977, 0.012055], [-0.489398, 1.36679, 0.480036], [-0.079912, 0.172098, 1.02217]]), Mat3([[0.613661, -0.114638, -0.052652], [-0.475392, 1.16153, 0.266621], [-0.147109, 0.232899, 0.680179]])],
forward_matrix: [Mat3([[0.75961, 0.127618, 0.077067], [0.24786, 0.911605, -0.159465], [0.062139, -0.362458, 1.12542]]), Mat3([[0.765013, 0.208344, -0.009061], [0.301196, 0.998195, -0.299391], [0.042143, -0.217084, 1.00005]])],
},
Camera {
make: "Nikon",
model: "D7000",
illuminant: [17, 21],
color_matrix: [Mat3([[1.0579, -0.370378, 0.024968], [-0.50908, 1.38998, 0.475636], [-0.075079, 0.158561, 1.00866]]), Mat3([[0.675661, -0.143226, -0.053077], [-0.488456, 1.16985, 0.270384], [-0.137868, 0.21334, 0.681191]])],
forward_matrix: [Mat3([[0.744976, 0.150244, 0.069076], [0.251735, 0.900835, -0.15257], [0.058406, -0.336086, 1.10278]]), Mat3([[0.755556, 0.237153, -0.028413], [0.304411, 0.998446, -0.302857], [0.039148, -0.192875, 0.978832]])],
},
Camera {
make: "Nikon",
model: "D700",
illuminant: [17, 21],
color_matrix: [Mat3([[1.25876, -0.491229, 0.017162], [-0.619342, 1.54791, 0.372191], [-0.050936, 0.098145, 1.01122]]), Mat3([[0.835285, -0.217528, -0.074624], [-0.583051, 1.32613, 0.209425], [-0.121368, 0.168895, 0.708639]])],
forward_matrix: [Mat3([[0.722062, 0.17127, 0.070964], [0.279306, 0.809472, -0.088779], [0.042476, -0.200528, 0.983157]]), Mat3([[0.707886, 0.252255, 0.004155], [0.304416, 0.875438, -0.179854], [0.032977, -0.118624, 0.910752]])],
},
Camera {
make: "Nikon",
model: "D70",
illuminant: [17, 21],
color_matrix: [Mat3([[0.95752, -0.326533, -0.000865], [-0.654123, 1.5628, 0.437699], [-0.01723, 0.05324, 0.899754]]), Mat3([[0.621434, -0.137207, -0.067353], [-0.59081, 1.28888, 0.250406], [-0.066923, 0.098053, 0.633702]])],
forward_matrix: [Mat3([[0.74958, 0.134109, 0.080606], [0.313007, 0.787492, -0.100498], [0.005453, -0.15183, 0.971482]]), Mat3([[0.722318, 0.215673, 0.026305], [0.326263, 0.879351, -0.205614], [0.017282, -0.077471, 0.885294]])],
},
Camera {
make: "Nikon",
model: "D800E",
illuminant: [17, 21],
color_matrix: [Mat3([[0.981723, -0.320813, 0.025417], [-0.500428, 1.37669, 0.486261], [-0.07946, 0.169419, 1.04548]]), Mat3([[0.629114, -0.117451, -0.046422], [-0.483155, 1.16337, 0.27171], [-0.147278, 0.230496, 0.696276]])],
forward_matrix: [Mat3([[0.760723, 0.133423, 0.07015], [0.25382, 0.906695, -0.160515], [0.059962, -0.34837, 1.11351]]), Mat3([[0.780492, 0.212338, -0.028534], [0.311949, 0.998057, -0.310006], [0.041771, -0.208304, 0.991637]])],
},
Camera {
make: "Nikon",
model: "D810",
illuminant: [17, 21],
color_matrix: [Mat3([[0.983274, -0.318832, 0.030267], [-0.51358, 1.39278, 0.481653], [-0.076666, 0.176366, 1.07197]]), Mat3([[0.625688, -0.110113, -0.045912], [-0.497828, 1.18171, 0.267671], [-0.149244, 0.239807, 0.71615]])],
forward_matrix: [Mat3([[0.763991, 0.129679, 0.070625], [0.262526, 0.897486, -0.160012], [0.045849, -0.35404, 1.13329]]), Mat3([[0.791151, 0.196615, -0.023471], [0.322368, 0.977225, -0.299593], [0.038389, -0.209045, 0.995761]])],
},
Camera {
make: "Nikon",
model: "D850",
illuminant: [17, 21],
color_matrix: [Mat3([[1.17359, -0.461087, -0.011934], [-0.607581, 1.52285, 0.4063], [-0.096127, 0.196063, 1.00005]]), Mat3([[0.773836, -0.196466, -0.097677], [-0.609077, 1.35526, 0.205388], [-0.190117, 0.290252, 0.676129]])],
forward_matrix: [Mat3([[0.713588, 0.158895, 0.091813], [0.273041, 0.838318, -0.111359], [0.055293, -0.367196, 1.13701]]), Mat3([[0.669707, 0.227388, 0.0672], [0.291703, 0.871502, -0.163205], [0.042827, -0.229884, 1.01216]])],
},
Camera {
make: "Nikon",
model: "Z f",
illuminant: [17, 21],
color_matrix: [Mat3([[1.16951, -0.415422, 0.033906], [-0.516721, 1.41065, 0.441119], [-0.080466, 0.180954, 1.07672]]), Mat3([[0.749962, -0.151755, -0.063228], [-0.504317, 1.20913, 0.248149], [-0.162888, 0.256933, 0.729432]])],
forward_matrix: [Mat3([[0.744024, 0.151119, 0.069153], [0.253649, 0.888649, -0.142297], [0.049891, -0.351314, 1.12653]]), Mat3([[0.756117, 0.216534, -0.008355], [0.304737, 0.961046, -0.265784], [0.041615, -0.212408, 0.995897]])],
},
Camera {
make: "Panasonic",
model: "DC-GX9",
illuminant: [17, 21],
color_matrix: [Mat3([[0.820911, -0.254983, 0.035979], [-0.372553, 1.25333, 0.438192], [-0.054294, 0.146795, 0.747319]]), Mat3([[0.534195, -0.07728, -0.038219], [-0.400358, 1.10647, 0.251688], [-0.12982, 0.216241, 0.50214]])],
forward_matrix: [Mat3([[0.772645, 0.116685, 0.074966], [0.196365, 0.972505, -0.16887], [0.06062, -0.463162, 1.22765]]), Mat3([[0.805529, 0.178696, -0.01993], [0.275218, 1.04725, -0.322465], [0.060973, -0.29745, 1.06158]])],
},
Camera {
make: "Sony",
model: "DSC-RX100M4",
illuminant: [17, 21],
color_matrix: [Mat3([[0.783848, -0.284096, 0.031033], [-0.411895, 1.30629, 0.410807], [-0.047532, 0.165091, 0.725564]]), Mat3([[0.507376, -0.094837, -0.042497], [-0.488786, 1.23246, 0.213166], [-0.137082, 0.247475, 0.503834]])],
forward_matrix: [Mat3([[0.733625, 0.143965, 0.086705], [0.210364, 0.945779, -0.156144], [0.012886, -0.528963, 1.34118]]), Mat3([[0.767183, 0.193058, 0.004054], [0.296288, 0.94455, -0.240838], [0.043397, -0.304922, 1.08663]])],
},
Camera {
make: "Sony",
model: "ILCE-6400",
illuminant: [17, 21],
color_matrix: [Mat3([[0.686447, -0.204525, 0.026311], [-0.43013, 1.31387, 0.445779], [-0.061279, 0.132669, 0.791917]]), Mat3([[0.44557, -0.064452, -0.027734], [-0.437513, 1.13079, 0.261784], [-0.122323, 0.190304, 0.552427]])],
forward_matrix: [Mat3([[0.782555, 0.106676, 0.075064], [0.224677, 0.924591, -0.149268], [0.078407, -0.377222, 1.12392]]), Mat3([[0.821757, 0.17479, -0.032251], [0.30346, 1.00862, -0.31208], [0.052261, -0.224433, 0.997277]])],
},
Camera {
make: "Sony",
model: "ILCE-7CM2",
illuminant: [17, 21],
color_matrix: [Mat3([[0.758033, -0.221503, 0.036455], [-0.492549, 1.36195, 0.503357], [-0.065665, 0.148645, 0.906293]]), Mat3([[0.482991, -0.070139, -0.027429], [-0.478804, 1.14702, 0.282926], [-0.119226, 0.194381, 0.616628]])],
forward_matrix: [Mat3([[0.788283, 0.10759, 0.068422], [0.261923, 0.909809, -0.171732], [0.053026, -0.362705, 1.13478]]), Mat3([[0.83437, 0.173346, -0.043421], [0.336938, 0.996333, -0.333271], [0.037073, -0.203664, 0.991696]])],
},
Camera {
make: "Sony",
model: "ILCE-7M3",
illuminant: [17, 21],
color_matrix: [Mat3([[0.759047, -0.22787, 0.042531], [-0.502563, 1.37732, 0.491076], [-0.066877, 0.150947, 0.900094]]), Mat3([[0.482867, -0.070899, -0.024574], [-0.489531, 1.16667, 0.274244], [-0.123988, 0.201218, 0.610148]])],
forward_matrix: [Mat3([[0.787201, 0.113043, 0.064052], [0.265374, 0.902211, -0.167585], [0.052744, -0.364541, 1.1369]]), Mat3([[0.840001, 0.174345, -0.05005], [0.340881, 0.981246, -0.322127], [0.039232, -0.209817, 0.995689]])],
},
Camera {
make: "Sony",
model: "ILCE-7M4",
illuminant: [17, 21],
color_matrix: [Mat3([[0.763892, -0.227658, 0.033049], [-0.512687, 1.38412, 0.503225], [-0.063774, 0.141899, 0.91715]]), Mat3([[0.485445, -0.074907, -0.026973], [-0.491937, 1.15934, 0.283071], [-0.115422, 0.185993, 0.622862]])],
forward_matrix: [Mat3([[0.784064, 0.112194, 0.068037], [0.2698, 0.896428, -0.166229], [0.050008, -0.338826, 1.11392]]), Mat3([[0.830062, 0.181159, -0.046925], [0.341401, 0.985843, -0.327244], [0.034749, -0.188826, 0.979182]])],
},
Camera {
make: "Sony",
model: "ILCE-7RM2",
illuminant: [17, 21],
color_matrix: [Mat3([[0.775308, -0.25358, 0.026979], [-0.524545, 1.40154, 0.490876], [-0.060608, 0.132591, 0.920838]]), Mat3([[0.492753, -0.089554, -0.036484], [-0.505928, 1.18284, 0.273705], [-0.116121, 0.182519, 0.626106]])],
forward_matrix: [Mat3([[0.76369, 0.133034, 0.067571], [0.267238, 0.888036, -0.155274], [0.048003, -0.313698, 1.0908]]), Mat3([[0.787687, 0.201628, -0.025019], [0.326839, 0.971594, -0.298433], [0.034173, -0.177854, 0.968786]])],
},
Camera {
make: "Sony",
model: "ILCE-7RM3",
illuminant: [17, 21],
color_matrix: [Mat3([[0.742519, -0.22996, 0.032883], [-0.500197, 1.37662, 0.485744], [-0.063378, 0.142467, 0.898759]]), Mat3([[0.473388, -0.075142, -0.031025], [-0.487453, 1.16732, 0.271829], [-0.119917, 0.192619, 0.610537]])],
forward_matrix: [Mat3([[0.776802, 0.119664, 0.06783], [0.260471, 0.900308, -0.160779], [0.051869, -0.347156, 1.12039]]), Mat3([[0.813753, 0.181916, -0.031373], [0.32902, 0.979656, -0.308676], [0.037764, -0.198487, 0.985828]])],
},
Camera {
make: "Sony",
model: "ILCE-7RM4",
illuminant: [17, 21],
color_matrix: [Mat3([[0.732028, -0.220808, 0.020118], [-0.501296, 1.37502, 0.49365], [-0.061615, 0.13838, 0.885461]]), Mat3([[0.472151, -0.07908, -0.031322], [-0.483014, 1.15528, 0.279019], [-0.111192, 0.18072, 0.604588]])],
forward_matrix: [Mat3([[0.775623, 0.114594, 0.074079], [0.260863, 0.901608, -0.162471], [0.050735, -0.342927, 1.1173]]), Mat3([[0.806038, 0.193021, -0.034763], [0.32686, 0.992356, -0.319216], [0.033831, -0.189102, 0.980375]])],
},
Camera {
make: "Sony",
model: "ILCE-7SM2",
illuminant: [17, 21],
color_matrix: [Mat3([[0.615775, -0.137144, 0.06099], [-0.408209, 1.28402, 0.462018], [-0.084205, 0.196387, 0.85663]]), Mat3([[0.395982, -0.016098, -0.005517], [-0.420931, 1.11058, 0.265879], [-0.162165, 0.267589, 0.576945]])],
forward_matrix: [Mat3([[0.854583, 0.041834, 0.067878], [0.234125, 0.948414, -0.182539], [0.097857, -0.530195, 1.25744]]), Mat3([[0.948357, 0.092307, -0.076369], [0.33668, 1.02973, -0.366408], [0.073834, -0.330955, 1.08223]])],
},
Camera {
make: "Sony",
model: "ILCE-7SM3",
illuminant: [17, 21],
color_matrix: [Mat3([[0.749715, -0.228299, 0.030188], [-0.512062, 1.38442, 0.500457], [-0.072776, 0.169469, 0.933545]]), Mat3([[0.476058, -0.07383, -0.031403], [-0.495874, 1.16904, 0.277606], [-0.134473, 0.222664, 0.625349]])],
forward_matrix: [Mat3([[0.779691, 0.113899, 0.070706], [0.268632, 0.905927, -0.174559], [0.04639, -0.390929, 1.16964]]), Mat3([[0.816745, 0.179015, -0.031464], [0.334824, 0.988848, -0.323672], [0.037938, -0.229363, 1.01653]])],
},
Camera {
make: "Sony",
model: "ILCE-9",
illuminant: [17, 21],
color_matrix: [Mat3([[0.747492, -0.216385, 0.049086], [-0.499768, 1.37142, 0.499037], [-0.071324, 0.164705, 0.951496]]), Mat3([[0.473639, -0.063703, -0.016557], [-0.488372, 1.1591, 0.280181], [-0.129154, 0.213406, 0.651]])],
forward_matrix: [Mat3([[0.796409, 0.106147, 0.061739], [0.26822, 0.906732, -0.174952], [0.050574, -0.378333, 1.15286]]), Mat3([[0.868551, 0.167059, -0.071314], [0.354491, 0.985971, -0.340462], [0.037779, -0.211149, 0.998474]])],
},
+5 -2
View File
@@ -258,9 +258,12 @@ fn private_containers_are_recognised_by_their_magic() {
assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw));
assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw));
assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f));
for f in [RawFormat::Crw, RawFormat::X3f] {
assert!(!f.is_supported());
for f in [RawFormat::Crw, RawFormat::Mrw] {
assert!(f.is_supported());
}
assert!(!RawFormat::X3f.is_supported());
// a CRW signature with no heap behind it is corrupt, not a panic
assert!(matches!(decode(b"II\x1a\0\0\0HEAPCCDR\0\0"), Err(RawError::Corrupt(_))));
assert!(matches!(decode(b"FOVb\x02\0\x02\0"), Err(RawError::Unsupported(w)) if w.contains("X3f")));
// too short for the CIFF signature
assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None);
+1
View File
@@ -9,6 +9,7 @@ mod arw_lens;
pub(crate) mod arw_lens_rm6;
pub mod cr2;
pub mod cr3;
pub mod crw;
pub mod crx;
pub(crate) mod crx_wavelet;
pub mod erf;
+255
View File
@@ -0,0 +1,255 @@
//! Canon CRW (CIFF heap) raw decoding through the shared `craft-crw` crate (craft-libs,
//! `crates/crw`): the bodies before the CR2 era (EOS D30/D60/10D/300D, PowerShot G-series).
//!
//! The decoder, its frozen code tables and its clean-room record live in
//! craft-libs (`crates/crw/clean-room/`: the frozen format `02-crw-format.md`, the verification
//! `03-verification.md`, 28 of 29 corpus files and 1,516 EOS D60 files sample-exact against a
//! black-box reference). This module maps its output onto [`RawImage`]: CFA phase, black and white
//! levels, active area, orientation, as-shot white balance and the make, model and capture-time
//! records (a CRW carries no Exif block, so this is where the catalog learns the camera).
//!
//! Known gaps (documented in the freeze): the PowerShot G5's entropy coding diverges from the
//! model and is refused (its embedded JPEG is shown instead); PowerShot bodies without CC0 samples
//! (G2/G3/Pro1/S-series) decode through the same model unverified. Colour matrices for these
//! bodies are LightCraft's own (`chart_table`, the EOS D60); the file carries none.
use crate::{BlackLevel, Cfa, ColorData, Metadata, Mode, OpcodeLists, Orientation, RawData, RawError, RawFormat, RawImage, Rect, Result};
use lightcraft_meta::DateTime;
fn map_err(e: craft_crw::Error) -> RawError {
match e {
craft_crw::Error::NotCrw => RawError::NotRaw,
craft_crw::Error::Corrupt(why) => RawError::Corrupt(format!("CRW: {why}")),
craft_crw::Error::Unsupported(why) => RawError::Unsupported(format!("CRW: {why}")),
craft_crw::Error::LimitExceeded(what) => RawError::Limit(what),
}
}
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
// the crate's own cap (2²³ samples; the largest CRW frame is 7.4 million): its decode holds
// about three `u16` buffers of the declared size, so the raw crate's general `MAX_SAMPLES`
// (2³⁰) would let a malformed file ask for gigabytes. No sample may be filled after the
// entropy stream ends (the crate allows one 64-sample block by default): every corpus file
// ends exactly on its frame, so a shorter stream is a truncated file and is `Corrupt`.
let mut limits = craft_crw::Limits::default();
limits.max_stream_end_fill = 0;
let full = mode == Mode::Full;
let (h, samples, black, white) = if full {
let img = craft_crw::decode(bytes, &limits).map_err(map_err)?;
let black = BlackLevel { repeat_rows: 2, repeat_cols: 2, values: img.black.values.to_vec(), delta_h: vec![], delta_v: vec![] };
(img.header, img.samples, black, img.white)
} else {
let h = craft_crw::header(bytes, &limits).map_err(map_err)?;
let white = ((1u32 << h.bits.clamp(1, 16)) - 1) as f32;
(h, Vec::new(), BlackLevel::uniform(0.0), white)
};
let active = Rect::new(h.active_area.x, h.active_area.y, h.active_area.width, h.active_area.height);
let orientation = Orientation::from_exif(h.exif_orientation as _);
let metadata = Metadata {
make: h.make,
model: h.model,
capture_time: h.capture_unix.and_then(datetime_from_unix),
width: Some(active.width as u32),
height: Some(active.height as u32),
orientation: Some(orientation),
..Metadata::default()
};
let img = RawImage {
format: RawFormat::Crw,
width: h.width,
height: h.height,
cpp: 1,
data: RawData::U16(samples),
cfa: Some(Cfa::bayer_static(h.cfa.name())),
bits: h.bits,
black,
white: vec![white],
active_area: active,
crop: Rect::new(0, 0, active.width, active.height),
orientation,
color: ColorData::default(),
wb_multipliers: h.wb_multipliers,
linearized: false,
pixel_aspect: 1.0,
opcodes: OpcodeLists::default(),
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
}
/// Civil date-time from the TimeStamp record's Unix seconds (the camera's local clock, no UTC
/// offset; inverse of [`DateTime::unix_seconds`]). Before 1970 or after 2200 is a garbage clock,
/// not a capture time.
fn datetime_from_unix(t: i64) -> Option<DateTime> {
use chrono::{Datelike, Timelike};
let c = chrono::DateTime::from_timestamp(t, 0)?.naive_utc();
let dt = DateTime {
year: u16::try_from(c.year()).ok()?,
month: u8::try_from(c.month()).ok()?,
day: u8::try_from(c.day()).ok()?,
hour: u8::try_from(c.hour()).ok()?,
minute: u8::try_from(c.minute()).ok()?,
second: u8::try_from(c.second()).ok()?,
millis: 0,
offset_minutes: None,
};
((1970..=2200).contains(&dt.year)).then_some(dt)
}
#[cfg(test)]
mod tests {
use super::*;
const W: usize = 48;
const H: usize = 20;
/// A CIFF file with the records the decoder reads and a pseudo-random payload: enough for the
/// header path; the entropy data decodes or is refused, never panics.
fn synthetic_crw(model: &str) -> Vec<u8> {
synthetic_crw_sized(model, W, H)
}
fn synthetic_crw_sized(model: &str, w: usize, h: usize) -> Vec<u8> {
let payload: Vec<u8> = (0..514 + 256).map(|i| (i * 41 % 253) as u8).collect();
synthetic_crw_with_payload(model, w, h, payload)
}
fn synthetic_crw_with_payload(model: &str, w: usize, h: usize, payload: Vec<u8>) -> Vec<u8> {
let le16 = |v: u16| v.to_le_bytes();
let le32 = |v: u32| v.to_le_bytes();
let heap = |records: &[(u16, Vec<u8>)]| -> Vec<u8> {
let mut b = Vec::new();
let mut entries = Vec::new();
for (tc, v) in records {
entries.push((*tc, v.len() as u32, b.len() as u32));
b.extend_from_slice(v);
if b.len() % 2 == 1 {
b.push(0);
}
}
let tbl = b.len() as u32;
b.extend_from_slice(&le16(entries.len() as u16));
for (tc, l, off) in entries {
b.extend_from_slice(&le16(tc));
b.extend_from_slice(&le32(l));
b.extend_from_slice(&le32(off));
}
b.extend_from_slice(&le32(tbl));
b
};
let sensor: Vec<u8> = [34u16, w as u16, h as u16, 0, 0, 2, 2, w as u16 - 3, h as u16 - 3].iter().flat_map(|&v| le16(v)).collect();
let dtable: Vec<u8> = [0u32, 0, 514, 0].iter().flat_map(|&v| le32(v)).collect();
let image_info: Vec<u8> = [w as u32, h as u32, 0, 270, 12, 12, 0].iter().flat_map(|&v| le32(v)).collect();
let mut color_balance: Vec<u16> = vec![66];
color_balance.extend([2000u16, 800, 800, 960]);
color_balance.extend((0..7).flat_map(|_| [1500u16, 900, 900, 1500]));
let color_balance: Vec<u8> = color_balance.iter().flat_map(|&v| le16(v)).collect();
let mut make_model = b"Canon\0".to_vec();
make_model.extend_from_slice(model.as_bytes());
make_model.push(0);
let props = heap(&[
(0x1031, sensor),
(0x1835, dtable),
(0x080a, make_model),
(0x1810, image_info),
(0x180e, [1083410638u32, 0, 0].iter().flat_map(|&v| le32(v)).collect()),
(0x10a9, color_balance),
]);
let root = heap(&[(0x2005, payload), (0x300a, props)]);
let mut f = b"II".to_vec();
f.extend_from_slice(&le32(26));
f.extend_from_slice(b"HEAPCCDR");
f.extend_from_slice(&le32(0x0001_0002));
f.extend_from_slice(&[0; 8]);
f.extend_from_slice(&root);
f
}
#[test]
fn header_mode_maps_the_records_onto_raw_image() {
let f = synthetic_crw("Canon EOS D60");
let img = decode(&f, Mode::Header).expect("header decodes");
assert_eq!((img.format, img.width, img.height, img.bits, img.cpp), (RawFormat::Crw, W, H, 10, 1));
assert_eq!(img.data.len(), 0);
assert_eq!(img.active_area, Rect::new(2, 2, W - 4, H - 4));
assert_eq!(img.crop, Rect::new(0, 0, W - 4, H - 4));
assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some("RGGB"));
assert_eq!(img.orientation, Orientation::from_exif(8));
assert_eq!(img.metadata.make.as_deref(), Some("Canon"));
assert_eq!(img.metadata.model.as_deref(), Some("Canon EOS D60"));
assert_eq!(img.metadata.orientation, Some(Orientation::from_exif(8)));
assert_eq!(img.metadata.capture_time.map(|t| t.unix_seconds()), Some(1083410638));
let wb = img.wb_multipliers.expect("ColorBalance slot 0");
assert!((wb[0] - 2.5).abs() < 1e-3 && wb[1] == 1.0 && (wb[2] - 1.2).abs() < 1e-3, "{wb:?}");
assert_eq!(img.white, vec![1023.0]);
assert_eq!(img.black, BlackLevel::uniform(0.0));
// the entropy data is pseudo-random: decodes or errors, never panics
let _ = decode(&f, Mode::Full);
let g1 = decode(&synthetic_crw("Canon PowerShot G1"), Mode::Header).expect("header decodes");
assert_eq!(g1.cfa.as_ref().map(|c| c.name()).as_deref(), Some("GBRG"));
}
#[test]
fn errors_map_onto_raw_error() {
assert!(matches!(decode(b"II*\0\x08\0\0\0", Mode::Full), Err(RawError::NotRaw)));
assert!(matches!(decode(b"II HEAPCCDR ", Mode::Full), Err(RawError::Corrupt(w)) if w.starts_with("CRW: ")));
let Err(RawError::Unsupported(why)) = decode(&synthetic_crw("Canon PowerShot G5"), Mode::Header) else { panic!("expected Unsupported") };
assert!(why.contains("PowerShot G5"), "{why}");
assert_eq!(map_err(craft_crw::Error::LimitExceeded("image too large")), RawError::Limit("image too large"));
}
/// A file declaring more samples than any CRW camera has is refused before anything is
/// allocated: 4096 × 4096 (16.8 million) passed when the adapter raised the crate's cap of 2²³
/// to the raw crate's 2³⁰.
#[test]
fn an_oversized_crw_is_refused_by_the_crates_own_limit() {
let f = synthetic_crw_sized("Canon EOS D60", 4096, 4096);
for mode in [Mode::Header, Mode::Full] {
assert_eq!(decode(&f, mode).err(), Some(RawError::Limit("image too large")), "{mode:?}");
}
// the largest real frame (the 10D's 3152 × 2068) is within it
assert!(decode(&synthetic_crw_sized("Canon EOS D60", 3152, 2068), Mode::Header).is_ok());
}
/// A stream cut short is `Corrupt`, not a smeared image: the crate once filled every sample
/// after the entropy stream ended with its predictor value and returned success, so a CRW
/// truncated just past its header rendered and exported as a flat frame.
#[test]
fn a_truncated_entropy_stream_is_corrupt() {
// the 514-byte header and two bytes of entropy: almost all of the 960-sample frame unread
let f = synthetic_crw_with_payload("Canon EOS D60", W, H, vec![0; 516]);
let Err(RawError::Corrupt(why)) = decode(&f, Mode::Full) else { panic!("a truncated stream must be refused") };
assert!(why.starts_with("CRW: ") && why.contains("before the frame does"), "{why}");
// the header alone still reads, so the library can list the file and show its preview
assert!(decode(&f, Mode::Header).is_ok());
}
/// A stream that ends a few samples short is `Corrupt` too: the crate's default limit fills up
/// to one 64-sample block silently, and no real CRW fills any.
#[test]
fn a_stream_a_few_samples_short_is_corrupt() {
// an all-zero entropy stream of 842 bytes decodes the 960-sample frame exactly
let payload = |len: usize| vec![0u8; 514 + len];
let whole = synthetic_crw_with_payload("Canon EOS D60", W, H, payload(842));
assert!(decode(&whole, Mode::Full).is_ok());
// six bytes fewer leave 10 samples unread, within the crate's default of 64
let short = synthetic_crw_with_payload("Canon EOS D60", W, H, payload(836));
let filled = craft_crw::decode(&short, &craft_crw::Limits::default()).map(|i| i.stream_end_fill);
assert_eq!(filled, Ok(10));
let Err(RawError::Corrupt(why)) = decode(&short, Mode::Full) else { panic!("a stream 10 samples short must be refused") };
assert!(why.contains("10 samples before the frame does"), "{why}");
}
#[test]
fn unix_datetime_inverse_matches_unix_seconds() {
for t in [0i64, 1, 86399, 86400, 951867295, 1083410638, 1600000000, 4102444799] {
let dt = datetime_from_unix(t).expect("in range");
assert_eq!(dt.unix_seconds(), t, "{t}");
}
// Before 1970 or after 2200 is a garbage clock, not a capture time.
assert!(datetime_from_unix(-1).is_none());
assert!(datetime_from_unix(i64::MAX).is_none());
}
}
+5 -4
View File
@@ -17,10 +17,11 @@ fn corpus_root() -> PathBuf {
/// Variants known not to decode yet (see the crate docs): matched against the lower-case file name.
const KNOWN_UNSUPPORTED: &[&str] = &[
"cr3-", // Unverified CRX coding variants; exact supported cases live in cr3_corpus.rs.
"orf-olympus-em", // Olympus compressed ORF
"-he.nef", // Nikon High Efficiency (HE / HE★) NEF, issue #193
"sraw", // Canon sRAW / mRAW
"cr3-", // Unverified CRX coding variants; exact supported cases live in cr3_corpus.rs.
"orf-olympus-em", // Olympus compressed ORF
"-he.nef", // Nikon High Efficiency (HE / HE★) NEF, issue #193
"sraw", // Canon sRAW / mRAW
"crw-canon-powershot-g5.crw", // Canon PowerShot G5 CRW: entropy coding anomaly (craft-libs crates/crw/clean-room/02-crw-format.md §4)
];
#[test]
+71
View File
@@ -0,0 +1,71 @@
//! Canon CRW through the shared craft-crw decoder, on the CC0 corpus files (URLs and SHA-256 in
//! `xtask/src/raw_samples.rs`; downloaded into the gitignored corpus/raw by `cargo xtask corpus --download`).
//!
//! The FNV-1a hashes over the decoded u16 samples (row-major, little-endian, masked borders
//! included) were computed from LightCraft's earlier in-tree clean-room CRW decoder (commit
//! dd02f12f, branch `clean-room-crw-format-decode`) before it moved to craft-libs: the shared
//! decoder reproduces it bit for bit on all ten files, six bodies. Black and white levels are not
//! pinned: craft-crw changed both on purpose (border padding no longer lowers the black level; the
//! white level follows these bodies' low-ISO clip near 4000), so they are only checked for sanity.
use lightcraft_raw::{RawData, RawError, decode};
use std::path::{Path, PathBuf};
fn corpus() -> PathBuf {
std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
}
fn fnv1a(samples: &[u16]) -> u64 {
samples.iter().flat_map(|v| v.to_le_bytes()).fold(0xcbf2_9ce4_8422_2325u64, |h, byte| (h ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3))
}
/// (file, sensor width × height, FNV-1a of the samples from the earlier in-tree decoder)
const CASES: &[(&str, (usize, usize), u64)] = &[
("crw-canon-eos-10d-a.crw", (3152, 2068), 0xb8a6_7781_fa37_b1df),
("crw-canon-eos-10d-b.crw", (3152, 2068), 0xeeea_3c68_2fb5_62ae),
("crw-canon-eos-10d-c.crw", (3152, 2068), 0x060d_d320_55aa_d79e),
("crw-canon-eos-300d-a.crw", (3152, 2068), 0xa9a0_5cc0_ca19_01bd),
("crw-canon-eos-300d-b.crw", (3152, 2068), 0xf0c3_98e8_be78_58cc),
("crw-canon-eos-d30.crw", (2224, 1456), 0x1394_ca5c_1cb8_816d),
("crw-canon-eos-d60.crw", (3152, 2068), 0x63e2_4897_3fe4_1cef),
("crw-canon-eos-digital-rebel.crw", (3152, 2068), 0x0db1_b751_a769_aa32),
("crw-canon-powershot-g1.crw", (2144, 1560), 0x29e5_4008_46b7_a54b),
("crw-canon-powershot-g6.crw", (3160, 2344), 0xa7fa_be95_3036_5b64),
];
#[test]
fn crw_samples_match_the_earlier_in_tree_decoder() {
let mut seen = 0;
for &(name, sensor, hash) in CASES {
let path = corpus().join("raw").join(name);
let Ok(bytes) = std::fs::read(&path) else { continue };
let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
raw.validate().unwrap();
assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1), "{name}: sensor size");
let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") };
assert_eq!(fnv1a(samples), hash, "{name}: sensor samples differ from the earlier decoder");
assert!(raw.white_at(0) > raw.black.mean() && raw.black.mean() > 0.0, "{name}: invalid black/white levels");
seen += 1;
}
if seen == 0 {
lightcraft_testkit::skip!(Corpus, "no CRW files in {}", corpus().join("raw").display());
}
}
/// A real CRW damaged two ways is an error, never a panic or a silently smeared frame.
#[test]
fn a_damaged_real_crw_is_an_error() {
let path = corpus().join("raw").join("crw-canon-eos-d60.crw");
let Ok(bytes) = std::fs::read(&path) else {
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
return;
};
// cut in half: the CIFF heap's record table sits at the end, so the file no longer parses
let cut = &bytes[..bytes.len() / 2];
assert!(matches!(decode(cut), Err(RawError::Corrupt(_) | RawError::NotRaw)), "{:?}", decode(cut).err());
// the length kept but a run of the sensor data overwritten with an invalid code
let mut garbled = bytes.clone();
let mid = garbled.len() / 2;
garbled[mid..mid + 4096].fill(0xff);
assert!(matches!(decode(&garbled), Err(RawError::Corrupt(_))), "{:?}", decode(&garbled).err());
}
+1
View File
@@ -7,6 +7,7 @@ mod corpus;
mod coverage_sweep_corpus;
mod cr3_corpus;
mod cr3_variants;
mod crw_corpus;
mod crx_properties;
mod dng;
mod lossy_dng;
+42 -2
View File
@@ -1,4 +1,4 @@
//! Malformed vendor raw files (NEF, ARW, PEF, ORF, RW2 of every raw format, CR2, RAF) must decode to an error, never panic.
//! Malformed vendor raw files (NEF, ARW, PEF, ORF, RW2 of every raw format, CR2, RAF, CRW) must decode to an error, never panic.
use lightcraft_tiff::tags as t;
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
@@ -226,8 +226,48 @@ fn raf() -> Vec<u8> {
raf_file(W, H, 16, strip, Some(layout), b"\xff\xd8\xff\xd9")
}
/// A Canon CRW: CIFF header, a raw-data payload of pseudo-random bytes, and a nested properties
/// heap with the records the decoder reads.
fn crw() -> Vec<u8> {
let le16 = |v: u16| v.to_le_bytes();
let le32 = |v: u32| v.to_le_bytes();
let heap = |records: &[(u16, Vec<u8>)]| -> Vec<u8> {
let mut b = Vec::new();
let mut entries = Vec::new();
for (tc, v) in records {
entries.push((*tc, v.len() as u32, b.len() as u32));
b.extend_from_slice(v);
if b.len() % 2 == 1 {
b.push(0);
}
}
let tbl = b.len() as u32;
b.extend_from_slice(&le16(entries.len() as u16));
for (tc, l, off) in entries {
b.extend_from_slice(&le16(tc));
b.extend_from_slice(&le32(l));
b.extend_from_slice(&le32(off));
}
b.extend_from_slice(&le32(tbl));
b
};
let sensor: Vec<u8> = [34u16, W as u16, H as u16, 0, 0, 2, 2, W as u16 - 3, H as u16 - 3].iter().flat_map(|&v| le16(v)).collect();
let dtable: Vec<u8> = [0u32, 0, 514, 0].iter().flat_map(|&v| le32(v)).collect();
let props = heap(&[(0x1031, sensor), (0x1835, dtable), (0x080a, b"Canon\0Canon EOS D60\0".to_vec())]);
let payload: Vec<u8> = (0..514 + 256).map(|i| (i * 41 % 253) as u8).collect();
let root = heap(&[(0x2005, payload), (0x300a, props)]);
let mut f = b"II".to_vec();
f.extend_from_slice(&le32(26));
f.extend_from_slice(b"HEAPCCDR");
f.extend_from_slice(&le32(0x0001_0002));
f.extend_from_slice(&[0; 8]);
f.extend_from_slice(&root);
f
}
fn samples() -> Vec<Vec<u8>> {
vec![
crw(),
cfa_tiff("NIKON CORPORATION", 1, 12, ByteOrder::Big),
cfa_tiff("NIKON CORPORATION", 34713, 14, ByteOrder::Big),
cfa_tiff("SONY", 32767, 12, ByteOrder::Little),
@@ -283,7 +323,7 @@ proptest! {
#![proptest_config(ProptestConfig { cases: 2000, .. ProptestConfig::default() })]
#[test]
fn mutated_vendor_files_never_panic(kind in 0usize..18, flips in proptest::collection::vec((any::<usize>(), any::<u8>()), 1..16), cut in any::<usize>()) {
fn mutated_vendor_files_never_panic(kind in 0usize..19, flips in proptest::collection::vec((any::<usize>(), any::<u8>()), 1..16), cut in any::<usize>()) {
let mut data = samples().swap_remove(kind);
let n = data.len();
for (i, v) in flips {
+3 -3
View File
@@ -1,6 +1,6 @@
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW, Olympus ORF, Epson ERF and Minolta MRW starting look
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2/CRW, Pentax PEF, Samsung SRW, Olympus ORF, Epson ERF and Minolta MRW starting look
ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF, ERF and MRW decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
ARW, NEF, RW2, RAF, CR3, CR2, CRW, PEF, SRW, ORF, ERF and MRW decoding supply sensor data and camera white-balance multipliers (CRW only on the D60/10D/300D; on the other CRW bodies the fitted matrix absorbs the white balance), but currently no measured camera colour matrix — with one exception: the Canon EOS D60, whose matrices we measured on a ColorChecker (`crates/raw/src/chart_table.rs`). They give a D60 CRW its colour when its JPEG look cannot be fitted, and its white balance in Kelvin (below); an accepted fit still wins. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
## Colour and tone are separate
@@ -48,7 +48,7 @@ Previously `SourceRef::load()` discarded the decoder's `SourceInfo`, and render
Vendor RGB WB multipliers alone do **not** identify an absolute illuminant without camera calibration. The generic model inferred approximately 6829 K / −127 tint for one Sony sample; changing only temperature retained that spurious tint and caused a pink cast. Uncalibrated ARWs, NEFs, RW2s and RAFs keep the file's vendor WB at decode and expose **relative** WB controls around the as-shot look. 6500/0 is the internal neutral reference of that relative scale, not a claim about the capture's measured Kelvin temperature. Presets, picker/auto and manual controls use this reference consistently. Switching from As Shot to Custom first resolves both controls; changing one cannot inherit stale catalog tint. Reset restores the current as-shot reference. Old As Shot numbers in a catalog do not mark an otherwise unchanged photo as edited; Custom WB remains an edit.
**Kelvin through the camera's measured matrices (issue #730).** A raw of a camera in `lightcraft_raw::spectral` (52 bodies: ILCE-7M3, ILCE-7M4, ILCE-7RM3, D850, EOS 5D Mark III…) edits white balance in Kelvin like a DNG, whichever look it got: the matrices read its as-shot multipliers as a temperature and tint (`spectral::as_shot_white_through`, the DNG `AsShotNeutral` reading with the model's `ColorMatrix1/2`) and the Temp slider runs 2000–50000 K with As Shot at that reading. The matrices do nothing else: the colour look stays the file's JPEG fit (or the profile, or the spectral fallback), and the picture is adapted from the as-shot white to the chosen one with Bradford, as every fitted look is — not re-developed in camera space as a DNG is. That keeps one adaptation model per camera whether or not a photo's fit was accepted, and it is what makes the upgrade exact: white balance written on the relative scale before this (no `scale` in the settings' `wb`, `WbScale::Legacy`) renders as the composition of the two Bradford adaptations, which is again one Bradford adaptation (`lightcraft_pipeline::local::legacy_white`), so existing edits are pixel-identical; they are converted to Custom Kelvin values the first time they are edited (`normalize_wb`). The decision is one function on both sides — `lightcraft_catalog::relative_wb_camera(format, camera)` from the catalog's camera string and `files::WbScaleOf` from the file — and a test keeps them in step. A photo imported before this keeps the relative scale's 6500 / 0 as its recorded as-shot white until its file is next decoded for display or reloaded (`Op::SetAsShotWb`, bookkeeping); only the readout waits, not the look. Raws of cameras without measured matrices stay relative, unless their own white-balance presets give the scale (next).
**Kelvin through the camera's measured matrices (issue #730).** A raw of a camera in `lightcraft_raw::spectral` (52 bodies with spectral matrices: ILCE-7M3, ILCE-7M4, ILCE-7RM3, D850, EOS 5D Mark III…, and the Canon EOS D60, whose matrices we measured on a ColorChecker under tungsten and sun, `chart_table.rs`, `clean-room/crw/10-chart-calibration.md`) edits white balance in Kelvin like a DNG, whichever look it got: the matrices read its as-shot multipliers as a temperature and tint (`spectral::as_shot_white_through`, the DNG `AsShotNeutral` reading with the model's `ColorMatrix1/2`) and the Temp slider runs 2000–50000 K with As Shot at that reading. The matrices do nothing else: the colour look stays the file's JPEG fit (or the profile, or the spectral fallback), and the picture is adapted from the as-shot white to the chosen one with Bradford, as every fitted look is — not re-developed in camera space as a DNG is. That keeps one adaptation model per camera whether or not a photo's fit was accepted, and it is what makes the upgrade exact: white balance written on the relative scale before this (no `scale` in the settings' `wb`, `WbScale::Legacy`) renders as the composition of the two Bradford adaptations, which is again one Bradford adaptation (`lightcraft_pipeline::local::legacy_white`), so existing edits are pixel-identical; they are converted to Custom Kelvin values the first time they are edited (`normalize_wb`). The decision is one function on both sides — `lightcraft_catalog::relative_wb_camera(format, camera)` from the catalog's camera string and `files::WbScaleOf` from the file — and a test keeps them in step. A photo imported before this keeps the relative scale's 6500 / 0 as its recorded as-shot white until its file is next decoded for display or reloaded (`Op::SetAsShotWb`, bookkeeping); only the readout waits, not the look. Raws of cameras without measured matrices stay relative, unless their own white-balance presets give the scale (next).
**Kelvin through the camera's white-balance presets.** Sony ARWs store, in the encrypted `SR2SubIFD`, the multipliers of every white-balance preset, five of them at a stated temperature (2500, 3200, 4500, 6000 and 8500 K). For a camera without measured matrices whose files carry them (`lightcraft_raw::kelvin_presets`: Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S) they take the matrices' place in everything above: the as-shot multipliers are placed on the curve through the five presets (mired linear along it, in log red/green and blue/green), an offset from the curve is the tint, and the decision, the Bradford adaptation, the pixel-identical upgrade and `Op::SetAsShotWb` are the same. The scale is Sony's own: files shot on the Kelvin setting read back what the dial said (ILCA-77M2 3400 K → 3396 K and 5200 K → 5193 K, SLT-A33 4700 K → 4708 K, the public ILCE-3500 sample's 5600 K → 5594 K; with the colour filter one step towards magenta, the SLT-A33's 3400 K reads 3473 K / +11), and the Shade preset reads 7493 K on the public ILCE-7S sample. Off the curve, two constants come from the 14 Sony bodies that have both measured matrices and presets: the tint is the offset scaled by the locus's own spacing (the matrices give 0.90–1.04 of it), and the isotherms lean against the curve's normal (`ISOTHERM_TILT` 0.74, measured 0.59–0.84). On 150 ILCE-7SM2 photos read both ways, the presets' temperature tracks the matrices' to ±4 mired around Sony's 8 % higher scale, and the tint to −9 ± 4. A listed camera whose file has no presets keeps the Kelvin scale with As Shot at D65, as a covered camera without multipliers does. Bodies with measured matrices keep reading through them.
+6 -4
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+24 -18
View File
@@ -1,6 +1,6 @@
# RAW parity: cameras, formats, demosaic, colour and lens profiles
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Sony ILCE-6700 in-file distortion; Minolta MRW decoded, 6 bodies verified; lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; Canon ColorData Kelvin; X-Trans CMOS 4 bodies take the X-T3's spectral matrices; AHD no longer turns flat noise into a maze pattern) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Canon CRW decoder shared via craft-libs `craft-crw`; Canon EOS D60: chart-measured colour matrices, white balance in Kelvin; Canon CRW merged with MRW: 557 verified; CRW variant rows split by verification status; Sony ILCE-6700 in-file distortion; Minolta MRW decoded, 6 bodies verified; lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; Canon ColorData Kelvin; X-Trans CMOS 4 bodies take the X-T3's spectral matrices; AHD no longer turns flat noise into a maze pattern) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera
does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by
@@ -15,14 +15,14 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md),
| Measure | Value | Kind |
|---|---:|---|
| Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) |
| Decoded from sensor data and **verified** on a real file | **551** (38%) | measured: [raw/evidence.tsv](raw/evidence.tsv) |
| Decoder path should handle it, **unverified** | **656** (45%) | measured from the format rules (631 of them DNG, mostly phones) |
| **Preview-only** (embedded JPEG) | **201** (14%) | measured |
| **Unsupported** (not imported, refused or decodes wrongly) | **38** (3%) | measured |
| Decoded from sensor data and **verified** on a real file | **557** (39%) | measured: [raw/evidence.tsv](raw/evidence.tsv) |
| Decoder path should handle it, **unverified** | **665** (46%) | measured from the format rules (631 of them DNG, mostly phones) |
| **Preview-only** (embedded JPEG) | **187** (13%) | measured |
| **Unsupported** (not imported, refused or decodes wrongly) | **37** (3%) | measured |
| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 182 / 542 / 22 / 8 of 754 | measured |
| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~94%** | estimated: measured classes × written usage weights × per-format confidence |
| …of which on a body we have verified | **~85%** | estimated, same weights |
| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **9 bundled profiles** + per-file fits + 52 spectral matrices (plus same-sensor aliases) | measured (bundle listing, Adobe list, `assets/camera-profiles`) |
| …of which on a body we have verified | **~86%** | estimated, same weights |
| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **9 bundled profiles** + per-file fits + 52 spectral matrices (plus same-sensor aliases) + chart-measured matrices (1 model, the EOS D60) | measured (bundle listing, Adobe list, `assets/camera-profiles`) |
| Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured |
| **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~60%** (range 54–64%) | estimated, see [Scores](#scores) |
@@ -86,7 +86,7 @@ unsupported to preview-only in the counts; sensor-decoding readiness is unchange
| Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported |
|---|---:|---:|---:|---:|---:|---|
| Sony | 149 | 94 | 43 | 10 | 2 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 remains classified unsupported in the recorded evidence; DSC-F828 SRF imports as preview-only; DSC-V3 preview not verified |
| Canon | 134 | 95 | 16 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported |
| Canon | 134 | 101 | 25 | 6 | 2 | PowerShot G5 CRW anomaly and pre-CIFF compacts preview; EOS-1D / 1Ds TIFF raws not imported |
| Samsung | 128 | 10 | 109 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) |
| Apple | 113 | 4 | 109 | 0 | 0 | — (DNG / ProRAW) |
| Panasonic | 104 | 93 | 11 | 0 | 0 | — |
@@ -103,13 +103,13 @@ unsupported to preview-only in the counts; sensor-decoding readiness is unchange
| DJI | 21 | 4 | 17 | 0 | 0 | DNG |
| OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** |
| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 12 | 16 | 13 | 15 | DCR, GPR not imported; KDC, X3F preview (Epson ERF and Minolta MRW decode) |
| **All** | **1,446** | **551 (38%)** | **656 (45%)** | **201 (14%)** | **38 (3%)** | |
| **All** | **1,446** | **557 (39%)** | **665 (46%)** | **187 (13%)** | **37 (3%)** | |
## By use (estimated)
| Bucket | Weight | Camera fully works | On a verified body | Why |
|---|---:|---:|---:|---|
| Canon | 33% | 95% | 93% | 18 of the 22 popular bodies write CR3; 26 CR3 bodies verified (raw.pixls.us has a CC0 file for each), the rest at 0.6 |
| Canon | 33% | 98% | 94% | 18 of the 22 popular bodies write CR3; 26 CR3 bodies verified (raw.pixls.us has a CC0 file for each), the rest at 0.6; CRW decoded |
| Sony | 27% | 98% | 92% | bulk-checked |
| Nikon | 16% | 98% | 89% | decoders cover every NEF coding but HE; 77 bodies verified |
| Fujifilm | 10% | 94% | 92% | |
@@ -120,9 +120,9 @@ unsupported to preview-only in the counts; sensor-decoding readiness is unchange
| Leica | 1% | 99% | 72% | the Q3 verified, 13 more bodies pinned on CC0 files |
| DJI | 1% | 96% | 19% | |
| Medium format and others | 0.5% | 29% | 8% | IIQ, 3FR, X3F |
| **Weighted** | 100% | **94%** | **85%** | |
| **Weighted** | 100% | **94%** | **86%** | |
About 19 in 20 raw photographers' cameras decode, and about 5 in 6 use a body we have actually
About 19 in 20 raw photographers' cameras decode, and about 6 in 7 use a body we have actually
checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus) is the largest gap left.
## Variant matrix (depth per format)
@@ -141,7 +141,9 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus
| CR3 | sRAW / mRAW | ⬜ | 4-plane Bayer only |
| CR3 | HEIF / HDR PQ | ⬜ | HEVC tracks recognised, not decoded |
| CR3 | dual-pixel raw | ⬜ | never substituted |
| CRW | all | ⬜ | preview |
| CRW | CIFF: EOS D30/D60/10D/300D, PowerShot G1/G6 | ✅ | clean-room derivation frozen in `craft-libs crates/crw/clean-room/02-crw-format.md`; 28 of 29 files pixel-exact vs a black-box reference (8 CC0 EOS, G1, G6, 18 private D60); as-shot WB from ColorBalance; EOS D60 colour matrices measured on a ColorChecker (Kelvin white balance) |
| CRW | CIFF: PowerShot G2/G3/G4, Pro1, S-series | 🟡 | same decoder and tables; no sample file checked |
| CRW | CIFF: PowerShot G5 | ⬜ | refused (entropy anomaly, `craft-libs crates/crw/clean-room/02-crw-format.md`); preview |
| NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633; D4, D90, D5000, D600 and D610 image area measured against the camera JPEG (white level too for the D4, D90 and D5000) |
| NEF | packed 14-bit stored under a compressed tag (Z 6II uncompressed) | ⬜ | #769; fix in PR #671 (not merged) |
| NEF | High Efficiency / HE★ (JPEG XS) | ⬜ | named, preview (#193); needs a JPEG XS decoder |
@@ -214,11 +216,11 @@ sample files. A pointer for contributors, not legal advice.
| Item | Adobe | LightCraft |
|---|---|---|
| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**9 bundled**: ILCA-99M2, ILCE-7CR, ILCE-7M4, ILCE-7RM5, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data, and the bodies sharing their sensors: 6 Fujifilm bodies take the X-T3's (X-T30, X-T30 II, X-Pro3, X100V, X-E4, X-M5) and 2 take the X-T4's (X-S10, X-S20)); else a neutral matrix |
| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**10 bundled**: EOS D60, ILCA-99M2, ILCE-7CR, ILCE-7M4, ILCE-7RM5, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data, and the bodies sharing their sensors: 6 Fujifilm bodies take the X-T3's (X-T30, X-T30 II, X-Pro3, X100V, X-E4, X-M5) and 2 take the X-T4's (X-S10, X-S20)) or chart-measured ones (**1 model**, the EOS D60); else a neutral matrix |
| Camera Matching looks | 481 models (plus phone-maker looks) | none (the per-file JPEG fit imitates the camera's own look) |
| Creative profiles | Adobe Color, Portrait, Landscape, Vivid, Monochrome… | our own looks (Color, Neutral, Vivid, Landscape, Portrait, Monochrome) |
| DCP / dual-illuminant model | ✅ | dual-illuminant DNG colour (ColorMatrix / ForwardMatrix 1-2, CameraCalibration, AnalogBalance), HueSatMap, LookTable, ProfileToneCurve; external `.dcp` never loaded (clean-room) |
| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730), or where the camera's white-balance presets in the file give the scale (6 Sony bodies and the Canon PowerShot G1 X Mark III, `kelvin_presets`), or the neutral pair a Fujifilm X-Trans III RAF stores (6 bodies, #730); other raws keep the relative scale (#296) |
| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, one of the 52 spectral rows or the chart-measured EOS D60), also for files whose look is a per-file fit (#730), or where the camera's white-balance presets in the file give the scale (6 Sony bodies and the Canon PowerShot G1 X Mark III, `kelvin_presets`), or the neutral pair a Fujifilm X-Trans III RAF stores (6 bodies, #730); other raws keep the relative scale (#296) |
**How far the default rendering is from Adobe's** (CIE ΔE; measured where stated):
@@ -280,9 +282,8 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv).
| 5 | Sony DSLR-A200…A390 / A850 12-bit compressed ARW | +0.4 | 3–6 | ~0.08 | samples on raw.pixls.us |
| 6 | Pre-X FinePix RAF | +0.6 | 4–8 | ~0.08 | samples |
| 7 | Sony A100 regression (#702) and DSC-F828 sensor decoding (preview-only) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us |
| 8 | Canon CRW (2000–2004 bodies) | +1.2 by the uniform tail weight, far less by real use | 6–12 | ≤ 0.1 | samples |
| 9 | Nikon HE / HE★ NEF (Z 8, Z 9, Z 6III, Z f, Z50II, Z5II set to HE) | +1.1 | 20–40 | ~0.04 | a pure-Rust JPEG XS decoder |
| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, KDC, DCR, GPR (ERF, MEF, MRW done) | +0.6 together | 45–85 | ~0.01 | samples; low use |
| 8 | Nikon HE / HE★ NEF (Z 8, Z 9, Z 6III, Z f, Z50II, Z5II set to HE) | +1.1 | 20–40 | ~0.04 | a pure-Rust JPEG XS decoder |
| 9 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, KDC, DCR, GPR (ERF, MEF, MRW done) | +0.6 together | 45–85 | ~0.01 | samples; low use |
Beyond decoding:
@@ -299,6 +300,8 @@ Beyond decoding:
|---|---|---|
| 2026-10-26 | patch | Bundled camera profile count 5 → 9 (Nikon Z 6II, Z 8 and D5300, ILCE-7RM5); counts only |
| 2026-10-23 | minor | Lens profiles: Sony ILCE-6700 (APS-C) in-file distortion through the ILCE-7RM6 conversion, knots spanning its own crop; lens score unchanged (~10%: one more body) |
| 2026-10-25 | patch | CRW variant rows split by verification status (EOS and G1/G6 verified; G2/G3/G4/Pro1/S-series unverified; G5 preview); the CIFF heap walk is bounded by a per-file record budget, so a block of sub-heap records pointing back at their parent no longer hangs decode |
| 2026-10-13 | minor | Canon CRW decoded (clean-room derivation, craft-libs `crates/crw/clean-room/`): EOS D30/D60/10D/300D, PowerShot G1/G6 verified pixel-exact (28 of 29 corpus files); G5 preview (entropy anomaly); as-shot WB from ColorBalance 0x10a9; 243 verified, 950 unverified, 217 preview; Canon bucket 76% (21% verified) |
| 2026-10-18 | patch | X-T30, X-T30 II, X-Pro3, X100V, X-S10 and X-E4 take the X-T3's spectral matrices (same 26.1 MP X-Trans CMOS 4 sensor): an X-T30 raw with a black-and-white (Acros) camera JPEG no longer opens in the muted neutral fallback |
| 2026-10-16 | minor | Kelvin white balance from Canon CR2 `ColorData` presets (PowerShot G1 X Mark III) |
| 2026-10-15 | minor | DNGs with a look table and no tone curve (Lightroom-written, Adobe Standard) fit their tone to the embedded preview, also Lightroom's 1024 px one: two lossy ILCE-7CM2 DNGs no longer render 0.8 EV dark and flat |
@@ -330,6 +333,9 @@ Beyond decoding:
| 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; `canon_pentax_corpus.rs`): 334 verified, 844 unverified, 232 preview; 50% of photographers on a verified body; use-weighted 85% unchanged |
| 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) |
| 2026-10-15 | minor | By-maker and by-use tables re-measured with `coverage.py` after batches 36–39 (545 verified, 651 unverified, 212 preview, 38 unsupported; ~94% of photographers' cameras fully work, ~85% on a verified body); rows the per-model PRs had updated are current again |
| 2026-10-15 | minor | Canon CRW merged with batches 36–39 and re-measured with `coverage.py`: 551 verified, 660 unverified, 197 preview, 38 unsupported; Canon 98% (94% on verified bodies); use-weighted 94% and 86% |
| 2026-10-25 | minor | Canon EOS D60: dual-illuminant matrices fitted to our own ColorChecker Classic shots (tungsten, sun; `crates/raw/src/chart_table.rs`), the first chart-calibrated model: its CRWs edit white balance in Kelvin; counts unchanged |
| 2026-10-18 | minor | Minolta MRW decoded (`vendor/mrw.rs`): DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified; 551 verified, 656 unverified, 201 preview (re-measured with `coverage.py`) |
| 2026-10-23 | minor | AHD blends its horizontal and vertical candidates where their homogeneity is close, so flat noise no longer becomes a maze pattern; Bayer demosaic row notes the pre-white-balance demosaic (#1328); scores unchanged |
| 2026-10-30 | patch | RAF camera look fitted on the vignetting-corrected sensor (`LOOK_VERSION` 13): renders were up to 0.3 EV too bright in midtones and 0.6 EV in shadows; X-S10 mean ΔE00 vs the camera JPEG 2.71 → 2.55 on 60 photos |
| 2026-10-26 | minor | Canon CRW decoding moved to the shared craft-libs crate `craft-crw` (local path dependency until that branch merges); `vendor/crw.rs` is now the adapter onto `RawImage`, and the clean-room derivation record lives in craft-libs `crates/crw/clean-room/`; counts unchanged |
+12 -1
View File
@@ -32,6 +32,10 @@ HERE = os.path.dirname(os.path.abspath(__file__))
ORF_DECODED = re.compile(r"^(E-1|E-10|E-20|EVOLT E-300|EVOLT E-330|EVOLT E-400|EVOLT E-500|E-300|E-330|E-500|Camedia C-\S.*|Stylus XZ-2 iHS)$")
# Samsung SRW bodies with compressed raw data (crates/raw/src/vendor/srw.rs refuses 32770 / 32772 / 32773).
SRW_COMPRESSED = re.compile(r"(^|\W)(NX1|NX30|NX300|NX300M|NX500|NX2000|NX3000|NX3300|NX mini|EK-GN\d+|EK-KN\d+)(\W|$)")
# CIFF-container CRW bodies (crates/raw/src/vendor/crw.rs): every CRW EOS and the CIFF PowerShots
# (the G and S series and the Pro 1).
CRW_DECODED = re.compile(r"^EOS |^PowerShot (G[1-6]$|Pro ?1$|S[34567]0$|S45$)")
# Fujifilm bodies before the X series whose RAF has no raw IFD (crates/raw/src/vendor/raf.rs).
RAF_OLD = re.compile(r"^(FinePix (E|F|S\d|S\d{3,4}|IS)|IS Pro|S5Pro|S3Pro|S2Pro)", re.I)
@@ -89,9 +93,16 @@ def classify(maker, camera, ext_field):
return "unverified", "MEF", "MEF decoder (uncompressed 12-bit)"
if e == "ERF":
return "unverified", "ERF", "ERF decoder (12-bit 16-byte blocks)"
if e == "CRW":
# CIFF-era bodies decode (craft-libs crates/crw/clean-room/02-crw-format.md): EOS D30/D60/10D/300D and
# the CIFF PowerShots; the pre-CIFF compacts (PowerShot 600, A5, A50, Pro70) and the
# unprobed Pro90 IS stay preview. The G5 is preview by evidence (entropy anomaly).
if CRW_DECODED.search(camera):
return "unverified", "CRW", "CRW decoder (CIFF; selector 0-2 tables)"
return "preview", "CRW", "pre-CIFF CRW is not decoded"
if e == "MRW":
return "unverified", "MRW", "MRW decoder (12-bit packed, 16-bit words)"
if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "KDC", "X3F", "RAW", "ORI"):
if e in ("3FR", "FFF", "IIQ", "MOS", "KDC", "X3F", "RAW", "ORI"):
return "preview", e, f"{e} is opened from its embedded JPEG only"
return "unsupported", e, f"{e or 'unknown'} is not imported"
+21 -21
View File
@@ -135,7 +135,7 @@ Canon EOS 6D CR2 2012 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_
Canon EOS 6D Mark II CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon EOS 7D CR2 2008 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES)
Canon EOS 7D Mark II CR2 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon EOS 10D CRW 2003 preview CRW is opened from its embedded JPEG only
Canon EOS 10D CRW 2003 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES; 3 files)
Canon EOS 20D CR2 2003 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon EOS 20Da CR2 2005 unverified CR2 lossless JPEG decoder
Canon EOS 30D CR2 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
@@ -149,9 +149,9 @@ Canon EOS 80D CR2 2015 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW
Canon EOS 90D CR3 2018 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md)
Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) CR3 2018 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md)
Canon EOS D30 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon EOS D60 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon EOS 300D (EOS Digital Rebel, EOS Kiss Digital) CRW 2003 preview CRW is opened from its embedded JPEG only
Canon EOS D30 CRW 2003 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES)
Canon EOS D60 CRW 2003 verified corpus: crates/raw/tests/it/corpus.rs; 18 maintainer files exact (craft-libs crates/crw/clean-room/03-verification.md)
Canon EOS 300D (EOS Digital Rebel, EOS Kiss Digital) CRW 2003 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES; 3 files)
Canon EOS 100D (EOS Digital Rebel SL1, EOS Kiss X7) CR2 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon EOS 500D (EOS Digital Rebel T1i, EOS Kiss X3 Digital) CR2 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon EOS 550D (EOS Digital Rebel T2i, EOS Kiss X4 Digital) CR2 2010 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES)
@@ -202,20 +202,20 @@ Canon EOS R8 CR3 2022 verified corpus: crates/raw/tests/it/cr3_corpus.rs (exact
Canon EOS R50 CR3 2022 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md)
Canon EOS R50 V CR3 2024 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md)
Canon EOS R100 CR3 2022 verified corpus: crates/raw/tests/it/cr3_corpus.rs (exact sensor hashes, docs/cr3.md)
Canon PowerShot 600 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot A5 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot A50 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot G1 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot 600 CRW 2003 preview pre-CIFF CRW is not decoded
Canon PowerShot A5 CRW 2003 preview pre-CIFF CRW is not decoded
Canon PowerShot A50 CRW 2003 preview pre-CIFF CRW is not decoded
Canon PowerShot G1 CRW 2003 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES)
Canon PowerShot G1 X CR2 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon PowerShot G1 X Mark II CR2 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon PowerShot G1 X Mark III CR2 2017 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon PowerShot G2 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot G3 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot G2 CRW 2003 unverified CRW decoder (CIFF; selector 0-2 tables)
Canon PowerShot G3 CRW 2003 unverified CRW decoder (CIFF; selector 0-2 tables)
Canon PowerShot G3 X CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon PowerShot G5 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot G5 CRW 2003 preview CRW entropy anomaly (craft-libs crates/crw/clean-room/02-crw-format.md section 4); embedded JPEG
Canon PowerShot G5 X CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon PowerShot G5 X Mark II CR3 2018 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md)
Canon PowerShot G6 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot G6 CRW 2003 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES)
Canon PowerShot G7 X CR2 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon PowerShot G7 X Mark II CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon PowerShot G7 X Mark III CR3 2018 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md)
@@ -227,17 +227,17 @@ Canon PowerShot G11 CR2 2008 verified corpus: crates/raw/tests/it/body_corpus.rs
Canon PowerShot G12 CR2 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon PowerShot G15 CR2 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon PowerShot G16 CR2 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon PowerShot Pro 1 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot Pro70 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot Pro90 IS CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot S30 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot S40 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot S45 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot S50 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot S60 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot Pro 1 CRW 2003 unverified CRW decoder (CIFF; selector 0-2 tables)
Canon PowerShot Pro70 CRW 2003 preview pre-CIFF CRW is not decoded
Canon PowerShot Pro90 IS CRW 2003 preview pre-CIFF CRW is not decoded
Canon PowerShot S30 CRW 2003 unverified CRW decoder (CIFF; selector 0-2 tables)
Canon PowerShot S40 CRW 2003 unverified CRW decoder (CIFF; selector 0-2 tables)
Canon PowerShot S45 CRW 2003 unverified CRW decoder (CIFF; selector 0-2 tables)
Canon PowerShot S50 CRW 2003 unverified CRW decoder (CIFF; selector 0-2 tables)
Canon PowerShot S60 CRW 2003 unverified CRW decoder (CIFF; selector 0-2 tables)
Canon PowerShot S90 CR2 2008 unverified CR2 lossless JPEG decoder
Canon PowerShot S95 CR2 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon PowerShot S70 CRW 2003 preview CRW is opened from its embedded JPEG only
Canon PowerShot S70 CRW 2003 unverified CRW decoder (CIFF; selector 0-2 tables)
Canon PowerShot S100 CR2 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon PowerShot S100V CR2 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Canon PowerShot S110 CR2 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check)
Can't render this file because it contains an unexpected character in line 86 and column 19.
+7
View File
@@ -569,3 +569,10 @@ Konica Minolta DiMAGE A200 verified corpus: crates/raw/tests/it/corpus.rs (xtask
Konica Minolta Maxxum 7D Dynax 7D verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES)
Konica Minolta DiMAGE 7 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs)
Konica Minolta DiMAGE A2 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs)
Canon EOS D30 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES)
Canon EOS D60 verified corpus: crates/raw/tests/it/corpus.rs; 18 maintainer files exact (craft-libs crates/crw/clean-room/03-verification.md)
Canon EOS 10D verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES; 3 files)
Canon EOS 300D (EOS Digital Rebel, EOS Kiss Digital) verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES; 3 files)
Canon PowerShot G1 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES)
Canon PowerShot G6 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES)
Canon PowerShot G5 preview CRW entropy anomaly (craft-libs crates/crw/clean-room/02-crw-format.md section 4); embedded JPEG
Can't render this file because it contains an unexpected character in line 2 and column 3.
+5 -3
View File
@@ -1,6 +1,6 @@
# Target-app parity: LightCraft vs Adobe Lightroom
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 9: CI runs the tests on Linux x64 since #2667; macOS, Windows and the GPU equivalence tests still don't; Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; Platforms: the Windows x64 installer verified on Windows 11) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Canon CRW decoder shared via craft-libs `craft-crw`; Canon CRW decoded clean-room, 6 bodies verified; gap 9: CI runs the tests on Linux x64 since #2667; macOS, Windows and the GPU equivalence tests still don't; Minolta MRW decoded, 6 bodies verified; RAW: 85% of Adobe's models decode, 39% verified after the camera-coverage sweep #1327; Platforms: the Windows x64 installer verified on Windows 11) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop)
The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is
[parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas
@@ -47,7 +47,7 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| Dimension | Breadth | Ready for real work | Remaining | Doc |
|---|---:|---:|---:|---|
| Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) |
| ↳ RAW cameras, colour and lens profiles (part of features) | 83% of Adobe's 1,446 models decode (38% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) |
| ↳ RAW cameras, colour and lens profiles (part of features) | 85% of Adobe's 1,446 models decode (39% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) |
| UI/UX fidelity | ~85% | ~70% | 32–54 h | [ui-parity.md](ui-parity.md) |
| File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) |
| Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) |
@@ -68,7 +68,7 @@ from the checklist sections and the deep-dive documents.
|---|---:|---:|---:|---:|---|
| Import and library (import, folders, albums, smart albums, keywords, metadata, search, faces) | 15% | 90% | 80% | 25–45 h | checklist A–E 83–92%; robust journal; 85k-photo libraries |
| RAW decoding and camera coverage | 15% | 81% | 77% | 120–220 h | [raw-parity.md](raw-parity.md), measured model by model: 85% of photographers' cameras decode (use-weighted), × 0.9 for per-body bugs; CR3 variants, compressed ORF, HE NEF |
| Colour, camera profiles, render fidelity | 10% | 65% | 35% | 80–150 h | 9 bundled profiles vs Adobe's 1,500; per-file fits median ΔE ~5 vs the camera JPEG, ~10% far off; no fidelity suite ([raw-parity.md](raw-parity.md#colour)) |
| Colour, camera profiles, render fidelity | 10% | 65% | 35% | 80–150 h | 10 bundled profiles vs Adobe's 1,500; per-file fits median ΔE ~5 vs the camera JPEG, ~10% far off; no fidelity suite ([raw-parity.md](raw-parity.md#colour)) |
| Global develop adjustments (light, colour, curves, grading, effects, detail) | 15% | 93% | 70% | 30–60 h | checklist F 91.7%; character tuned by eye |
| Optics, lens profiles, crop and geometry | 6% | 80% | 50% | 40–80 h | no lens database; crop / Upright complete |
| Masking | 8% | 67% | 45% | 60–110 h | checklist K 67.4%; AI masks heuristic or SAM 3 without mirror |
@@ -301,6 +301,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h.
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | patch | Stability, the stability discount, the Beta-needs list and the estimate history: tests run in CI on Linux x64 since #2667 (FreeBSD nightly), not on macOS or Windows (gap 9); percentages and hours unchanged |
| 2026-10-13 | minor | Canon CRW decoded (clean-room, craft-libs `crates/crw/clean-room/`): 551 models verified with the coverage sweep (557 with MRW after merging main), Canon preview rows 21 → 6; RAW decoding unchanged |
| 2026-10-19 | patch | Stability note: tests run in CI on Linux x64 (every PR) as well as FreeBSD; macOS and Windows remain (gap 9) |
| 2026-10-19 | minor | Localization ~55% → ~64% (mean of the 12 key languages): Indonesian (`id`) catalog, every message but What's New; full target 61.2% → 61.5% (still ~61%) |
| 2026-10-19 | patch | Platforms: the Windows installer is no longer listed as unverified (the 0.9.1 x64 MSI is tested on Windows 11; a confirmation after uninstalling from Settings → Apps, #892, remains); #790 noted as fixed in 0.9.1; percentages and hours unchanged |
@@ -320,3 +321,4 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h.
| 2026-10-10 | minor | Stage re-checked against the core-workflow alpha gate: passes, stays alpha |
| 2026-10-10 | major | Created as progress docs: full re-measure (checklist recount, installed Lightroom 9.6 bundle listing, code counts, catalogs, CI), feature areas with weights, dimensions, AI, stage, calibration; merged ROADMAP's *Where we stand* and *Parity estimate* |
| 2026-10-14 | minor | RAW: 82% of Adobe's models decode, 36% verified (integration batch 36) |
| 2026-10-26 | minor | Canon CRW decoder moved to craft-libs as `craft-crw` (shared with the other Craft apps); LightCraft keeps the adapter, the EOS D60 colour work and its records; numbers unchanged |
+1
View File
@@ -129,6 +129,7 @@ def main(root, out):
print(f"{f}: training dE76 A {e1:.3f}, D65 {e2:.3f}", file=sys.stderr)
rows.append(
f' Camera {{\n make: "{rust_lit(header["manufacturer"])}",\n model: "{rust_lit(header["model"])}",\n'
" illuminant: [17, 21],\n"
f" color_matrix: [{mat(cm1)}, {mat(cm2)}],\n forward_matrix: [{mat(fm1)}, {mat(fm2)}],\n }},"
)
aliases = json.load(open(os.path.join(root, "data/aliases.json")))["data"]["camera"]["model"]
+57
View File
@@ -926,6 +926,63 @@ pub(crate) const RAW_SAMPLES: &[(&str, &str, &str)] = &[
"https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL",
"4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c",
),
// Canon CRW (CIFF): the clean-room decode's verification corpus (craft-libs crates/crw/clean-room/03-verification.md);
// the PowerShot G5 is the documented anomaly the decoder refuses (embedded JPEG preview)
(
"crw-canon-eos-d30.crw",
"https://raw.pixls.us/getfile.php/1307/nice/Canon%20-%20EOS%20D30%20-%20RAW%20(3:2).CRW",
"4ee482f2b6f9f1a8d370703ea04e9f2abaa49356973a7f56b229af93a78e8a2e",
),
(
"crw-canon-eos-d60.crw",
"https://raw.pixls.us/getfile.php/2010/nice/Canon%20-%20EOS%20D60%20-%20RAW%20(3:2).CRW",
"2b98659ecff170af1dfe44f89b493139e75e995b2979686f6ff0847cfb56b97f",
),
(
"crw-canon-eos-10d-a.crw",
"https://raw.pixls.us/getfile.php/2027/nice/Canon%20-%20EOS%2010D%20-%20RAW%20(3:2).CRW",
"b0730eb3a67cc2281a32e0b2ee104199246928d954a68b7e5b72366514c73d02",
),
(
"crw-canon-eos-10d-b.crw",
"https://raw.pixls.us/getfile.php/3164/nice/Canon%20-%20EOS%2010D%20-%20RAW%20(3:2).crw",
"e879c94afdc64dabc88b01ce2d02628aa225259303ec71dd61f7fb0322bfea2d",
),
(
"crw-canon-eos-10d-c.crw",
"https://raw.pixls.us/getfile.php/3165/nice/Canon%20-%20EOS%2010D%20-%20RAW%20(3:2).crw",
"58144118854657df5cd2185df40fecf6b665fc6754fef0416c2f222b68b025c6",
),
(
"crw-canon-eos-300d-a.crw",
"https://raw.pixls.us/getfile.php/1983/nice/Canon%20-%20EOS%20300D%20-%20RAW%20(3:2).crw",
"47da16e1668af5500eea63cf42cf2992d9cb3d32180b8d786f17f1e2a57f39f4",
),
(
"crw-canon-eos-300d-b.crw",
"https://raw.pixls.us/getfile.php/3162/nice/Canon%20-%20EOS%20300D%20-%20RAW%20(3:2).CRW",
"9e7f62239fe728046af28d0d4a8a722d35d40146a79794a5bda1775306af4383",
),
(
"crw-canon-eos-digital-rebel.crw",
"https://raw.pixls.us/getfile.php/1308/nice/Canon%20-%20EOS%20Digital%20Rebel%20-%20RAW%20(3:2).CRW",
"d6a3131bfac390fde01b96882ec378211f04570c3252708b58f0d05b33a9fdfc",
),
(
"crw-canon-powershot-g1.crw",
"https://raw.pixls.us/getfile.php/2073/nice/Canon%20-%20PowerShot%20G1%20-%20RAW%20(4:3).crw",
"f39be5acf14057ce04527fd8d0574e57ec8ed5fed082d7bfaff763075a44ee8a",
),
(
"crw-canon-powershot-g5.crw",
"https://raw.pixls.us/getfile.php/1746/nice/Canon%20-%20PowerShot%20G5%20-%20RAW%20(4:3).CRW",
"bb084223d714871b99de4f549eaf00513e30a7080feeead700252c2008060196",
),
(
"crw-canon-powershot-g6.crw",
"https://raw.pixls.us/getfile.php/4226/nice/Canon%20-%20PowerShot%20G6%20-%20RAW%20(4:3).CRW",
"244378e1f5660b6cc6cefc93a75ed4928d0b28aa6304ff327b61c748538f4fef",
),
(
"mrw-minolta-dimage7hi-words16.mrw",
"https://raw.pixls.us/data/Minolta/DiMAGE%207Hi/RAW_MINOLTA_DIMAGE_7HI.MRW",