LR-IMP-FORMATS: decode JPEG XL compressed DNG tiles (DNG 1.7)

Compression 52546 tiles and strips decode through jxl-oxide (new
default-on `jxl` feature of lightcraft-raw): bare codestreams and
ISO-BMFF containers, 1 or 3 planes, 8-16-bit integers scaled back
exactly from the codestream's own bit depth, 16-bit floats. Edge tiles
may be coded at their nominal or in-image size. Headers are checked
against the IFD in header-only probing too. A single-chunk JPEG XL
preview IFD (RGB or grey) is offered as the embedded preview.

The decoder's memory is bounded by the tile: 16 bytes per sample (three
times that for a lossy grey tile, which the decoder expands to three
channels) plus 64 KiB; jxl-oxide 0.12 was measured at <= 10.3 bytes per
sample for lossless and <= 13.5 per colour sample for lossy tiles. A
codestream needing more (e.g. extra channels) is an error.

Lossless tiles are checked bit-exact on synthetic CFA and LinearRaw
files (zune-jpegxl as a test-only encoder); lossy tiles are unverified
on a real file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Exalted5373
2026-10-08 18:18:16 -07:00
co-authored by Claude Opus 5.5
parent b1883231bd
commit 1ed80c65bf
13 changed files with 384 additions and 20 deletions
Generated
+11
View File
@@ -3022,6 +3022,7 @@ name = "lightcraft-raw"
version = "0.4.0"
dependencies = [
"jpeg-encoder",
"jxl-oxide",
"lightcraft-color",
"lightcraft-geom",
"lightcraft-meta",
@@ -3035,6 +3036,7 @@ dependencies = [
"thiserror 2.0.21",
"zune-core",
"zune-jpeg",
"zune-jpegxl",
]
[[package]]
@@ -6752,6 +6754,15 @@ dependencies = [
"zune-core",
]
[[package]]
name = "zune-jpegxl"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.15.0"
+1
View File
@@ -61,6 +61,7 @@ tiff = "0.11"
image-webp = "0.2"
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
jxl-oxide = { version = "0.12", default-features = false }
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
+4 -3
View File
@@ -124,7 +124,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel
| Work package | Tracker rows | Agent-hours | Risk |
|---|---|---:|---|
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
@@ -174,7 +174,8 @@ private 3:2 & 4:3 examples verified through native rendering & full-resolution 1
CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations.
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files,
lossy ones decode but no real file has verified them), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
@@ -185,7 +186,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
+7
View File
@@ -20,9 +20,16 @@ rayon = { workspace = true }
miniz_oxide = { workspace = true }
zune-jpeg = { workspace = true }
zune-core = { workspace = true }
jxl-oxide = { workspace = true, optional = true }
[features]
default = ["jxl"]
## JPEG XL compressed DNGs (DNG 1.7, compression 52546) via jxl-oxide (pure Rust, MIT OR Apache-2.0).
jxl = ["dep:jxl-oxide"]
[dev-dependencies]
jpeg-encoder = { workspace = true }
proptest = { workspace = true }
zune-jpegxl = { workspace = true }
# Pinned CC0 corpus identities; not a product dependency.
sha2 = { version = "0.10", default-features = false, features = ["std"] }
-3
View File
@@ -79,9 +79,6 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
if !(1..=4).contains(&cpp) {
return Err(RawError::Unsupported(format!("{cpp} samples per pixel")));
}
if info.compression == t::compression::JPEG_XL {
return Err(RawError::Unsupported("JPEG XL DNG".into()));
}
let mut data = read_image_in(mode, bytes, &info, tiff.order, Packing::Msb)?;
let bits = info.bits() as u32;
+121
View File
@@ -0,0 +1,121 @@
//! JPEG XL tiles and strips (DNG 1.7 compression 52546), decoded with `jxl-oxide` (pure Rust).
//!
//! Per the DNG 1.7 specification a JPEG XL chunk holds N-bit unsigned integers (8 ≤ N ≤ 16) or 16-bit
//! floats, in 1 or 3 planes, as either a bare codestream or an ISO-BMFF container; jxl-oxide reads both.
//!
//! jxl-oxide renders every channel as `f32`: integer samples come out as `value / (2^bits − 1)` with
//! `bits` the codestream's own declared bit depth, so scaling back by that maximum and rounding gives the
//! stored integers exactly (an `f32` holds `v / 65535` to far better than half a code value) — lossless
//! tiles decode bit-exactly, whatever bit depth the encoder declared relative to the IFD's
//! `BitsPerSample`. Float samples are returned as decoded.
//!
//! We never request an output colour encoding, so jxl-oxide renders in the codestream's *original*
//! colour encoding: for lossless (non-XYB) data that is the identity, keeping the values in the raw IFD's
//! sample space. Lossy (XYB) tiles are converted from XYB back to that signalled encoding by jxl-oxide;
//! [INFERENCE] DNG writers signal a linear encoding matching the raw data there, so the result is the raw
//! sample space up to the codec's loss — not verified against a real lossy JXL DNG.
use crate::tiffraw::ChunkPx;
use crate::{RawError, Result};
use jxl_oxide::frame::Encoding;
use jxl_oxide::image::BitDepth;
use jxl_oxide::{AllocTracker, JxlImage, JxlThreadPool};
/// Decoder memory (bytes) allowed per sample of a tile. jxl-oxide 0.12 peaks at about three 32-bit
/// buffers per sample: measured ≤ 10.3 bytes for lossless (modular) tiles and ≤ 13.5 bytes per
/// colour sample for lossy (VarDCT) ones, photon noise included.
const BYTES_PER_SAMPLE: usize = 16;
/// Fixed decoder overhead on top (headers, entropy-coding tables, group metadata): under 10 KiB
/// on a 16 × 16 tile.
const MARGIN_BYTES: usize = 64 << 10;
fn corrupt(e: impl std::fmt::Display) -> RawError {
RawError::Corrupt(format!("JPEG XL tile: {e}"))
}
/// Parse a chunk's headers (no pixel decoding) and check them against the chunk it must fill:
/// nominal `cw × ch`, or the part of it inside the image `vw × vh` (edge tiles), `cpp` colour planes,
/// a sample type matching the IFD. Returns the decoder and the codestream's integer maximum (0 for float).
#[allow(clippy::too_many_arguments)]
fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<(JxlImage, f32)> {
if !matches!(cpp, 1 | 3) {
return Err(RawError::Unsupported(format!("JPEG XL with {cpp} samples per pixel")));
}
if float && bits != 16 || !float && !(8..=16).contains(&bits) {
return Err(RawError::Unsupported(format!("JPEG XL with {bits}-bit {} samples", if float { "float" } else { "integer" })));
}
// The decoder's memory is bounded by the tile itself: a codestream that needs more (extra
// channels, layers, …) fails to decode instead of allocating what its header asks for.
let pixels = cw.saturating_mul(ch);
let budget = |planes: usize| pixels.saturating_mul(planes).saturating_mul(BYTES_PER_SAMPLE);
let tracker = AllocTracker::with_limit(budget(cpp).saturating_add(MARGIN_BYTES));
let img = JxlImage::builder()
.pool(JxlThreadPool::none()) // chunks are already decoded in parallel
.alloc_tracker(tracker.clone())
.read(std::io::Cursor::new(src))
.map_err(corrupt)?;
// lossy (VarDCT) decoding always works on three colour channels, even for a grey tile
if cpp == 1 && img.frame_header(0).is_some_and(|f| f.encoding == Encoding::VarDct) {
tracker.expand_limit(budget(2));
}
let header = img.image_header();
let (jw, jh) = (img.width() as usize, img.height() as usize);
if (jw, jh) != (cw, ch) && (jw, jh) != (vw, vh) {
return Err(RawError::Corrupt(format!("JPEG XL tile is {jw}×{jh}, expected {cw}×{ch} (or {vw}×{vh} at the image edge)")));
}
if header.metadata.orientation != 1 {
return Err(RawError::Corrupt("JPEG XL tile with a non-identity orientation".into()));
}
let planes = if header.metadata.grayscale() { 1 } else { 3 };
if planes != cpp {
return Err(RawError::Corrupt(format!("JPEG XL tile has {planes} colour planes, the IFD {cpp} samples per pixel")));
}
let max = match header.metadata.bit_depth {
BitDepth::IntegerSample { bits_per_sample } if !float && (1..=16).contains(&bits_per_sample) => ((1u32 << bits_per_sample) - 1) as f32,
BitDepth::FloatSample { .. } if float => 0.0,
d => return Err(RawError::Corrupt(format!("JPEG XL tile sample type {d:?} does not match the IFD"))),
};
Ok((img, max))
}
/// Check the first chunk's headers ([`open`]) without decoding pixels (header-only probing).
#[allow(clippy::too_many_arguments)]
pub(crate) fn check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<()> {
open(src, cw, ch, vw, vh, cpp, bits, float).map(|_| ())
}
/// Decode one chunk into `cw × ch × cpp` samples (row-major, interleaved); a chunk coded at its
/// in-image size `vw × vh` fills the top-left of that, the rest stays 0.
#[allow(clippy::too_many_arguments)]
pub(crate) fn decode(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<ChunkPx> {
let (img, max) = open(src, cw, ch, vw, vh, cpp, bits, float)?;
if img.num_loaded_keyframes() == 0 {
return Err(corrupt("no complete frame"));
}
let render = img.render_frame(0).map_err(corrupt)?;
let fb = render.image_all_channels();
let (jw, jh, fc) = (fb.width(), fb.height(), fb.channels());
if jw == 0 || jh == 0 || jw > cw || jh > ch || fc < cpp {
return Err(corrupt(format!("rendered {jw}×{jh}×{fc}, expected {cw}×{ch}×{cpp}")));
}
let n = cw.saturating_mul(ch).saturating_mul(cpp);
let rows = fb.buf().chunks_exact(jw * fc);
Ok(if float {
let mut out = vec![0f32; n];
for (dst, src) in out.chunks_exact_mut(cw * cpp).zip(rows) {
for (d, s) in dst.chunks_exact_mut(cpp).zip(src.chunks_exact(fc)) {
d.iter_mut().zip(s).for_each(|(d, s)| *d = *s);
}
}
ChunkPx::F32(out)
} else {
let mut out = vec![0u16; n];
for (dst, src) in out.chunks_exact_mut(cw * cpp).zip(rows) {
for (d, s) in dst.chunks_exact_mut(cpp).zip(src.chunks_exact(fc)) {
// `as` saturates (NaN → 0): lossy overshoot clips to the integer range
d.iter_mut().zip(s).for_each(|(d, s)| *d = (s * max).round() as u16);
}
}
ChunkPx::U16(out)
})
}
+5 -2
View File
@@ -2,7 +2,7 @@
//!
//! - [`probe`] recognises raw containers; [`decode`] turns a file into a [`RawImage`] (sensor data + everything
//! needed to render it: CFA, black/white levels, active area, default crop, orientation, DNG colour tags,
//! opcode lists, [`Metadata`]); [`embedded_preview`] returns the largest embedded JPEG.
//! opcode lists, [`Metadata`]); [`embedded_preview`] returns the largest embedded JPEG (or DNG 1.7 JPEG XL) preview.
//! - [`RawImage::normalized`] subtracts black, scales white to 1.0 and crops to the active area (applying DNG
//! `OpcodeList1`/`OpcodeList2`); [`demosaic`] turns CFA data into camera-RGB [`Rgb32f`];
//! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop; [`RawImage::develop_binned`]
@@ -11,7 +11,8 @@
//! and produces camera → linear Rec.2020 D65 matrices; [`profile`] reads and applies a DNG's own profile
//! look tables and tone curve.
//!
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
//! 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
//! 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).
@@ -28,6 +29,8 @@ pub mod demosaic;
mod dng;
pub mod dngwrite;
pub mod highlight;
#[cfg(feature = "jxl")]
mod jxl;
pub mod ljpeg;
pub mod opcodes;
mod preview;
+25 -3
View File
@@ -1,5 +1,7 @@
//! Embedded preview extraction: the largest baseline/progressive JPEG stored in a TIFF-based raw (IFD strips
//! with JPEG compression, `JPEGInterchangeFormat` pointers in any IFD, Nikon/others' maker-note preview IFDs).
//! with JPEG compression, `JPEGInterchangeFormat` pointers in any IFD, Nikon/others' maker-note preview IFDs),
//! or a DNG 1.7 JPEG XL preview IFD stored as a single tile/strip (a standalone `.jxl` file, as the spec
//! recommends for previews).
use lightcraft_tiff::image::chunk_bytes;
use lightcraft_tiff::tags as t;
@@ -56,6 +58,11 @@ fn is_dct_jpeg(b: &[u8]) -> bool {
false
}
/// Whether `b` is a JPEG XL file: a bare codestream or the ISO-BMFF container's signature box.
fn is_jxl(b: &[u8]) -> bool {
b.starts_with(&[0xff, 0x0a]) || b.starts_with(&[0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a])
}
fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>) {
// whole JPEG files stored as an undefined-type tag value (e.g. Panasonic `JpgFromRaw` 0x002e)
for e in &ifd.entries {
@@ -83,9 +90,20 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>)
out.push(s);
}
}
// a rendered (RGB or grey, never CFA/LinearRaw/mask) JPEG XL preview in one chunk
if ifd.u16(t::COMPRESSION) == Some(t::compression::JPEG_XL)
&& matches!(ifd.u16(t::PHOTOMETRIC), Some(t::photometric::BLACK_IS_ZERO | t::photometric::RGB))
&& let Ok(info) = ifd.image()
&& let [chunk] = info.chunks(data.len() as u64).as_slice()
&& let Some(s) = chunk_bytes(data, chunk)
&& is_jxl(s)
{
out.push(s);
}
}
/// The largest embedded JPEG preview, if any.
/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file — both decode with
/// `lightcraft_codecs::decode`.
pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
if bytes.starts_with(b"FUJIFILMCCD-RAW") {
let j = crate::vendor::raf::header(bytes).ok()?.jpeg?;
@@ -121,7 +139,11 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
if let Some(p) = crate::vendor::orf::preview(bytes) {
found.push(p);
}
let best = found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec());
let best = found
.into_iter()
.filter(|s| is_dct_jpeg(s) || is_jxl(s))
.max_by_key(|s| s.len())
.map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() });
// a TIFF raw we can't decode whose preview no tag points to (Leaf MOS, Epson ERF)
best.or_else(|| format.filter(|f| !f.is_supported()).and_then(|_| scan_for_jpeg(bytes)))
}
+44 -7
View File
@@ -1,4 +1,4 @@
//! Reading the pixel data of a TIFF IFD (strips or tiles; uncompressed, lossless JPEG, Deflate), in parallel.
//! Reading the pixel data of a TIFF IFD (strips or tiles; uncompressed, lossless/lossy JPEG, Deflate, JPEG XL), in parallel.
use crate::unpack::*;
use crate::{MAX_SAMPLES, RawData, RawError, Result, ljpeg};
@@ -18,7 +18,7 @@ pub enum Packing {
Word16,
}
enum ChunkPx {
pub(crate) enum ChunkPx {
U16(Vec<u16>),
F32(Vec<f32>),
}
@@ -61,11 +61,24 @@ pub(crate) fn read_image_in(mode: crate::Mode, data: &[u8], info: &ImageInfo, or
crate::Mode::Header => {
check_image(data, info)?;
// a lossless-JPEG layout the decoder rejects (subsampled components, e.g. Sony's
// lossless M/S sizes) must fail here too, as the full decode will
if info.compression == 7
&& let Some(src) = info.chunks(data.len() as u64).first().and_then(|c| chunk_bytes(data, c))
// lossless M/S sizes) must fail here too, as the full decode will; likewise a JPEG XL
// chunk whose headers don't match the IFD
let first = info.chunks(data.len() as u64).first().copied();
if let Some(c) = first
&& let Some(src) = chunk_bytes(data, &c)
{
ljpeg::frame_info(src)?;
match info.compression {
7 => {
ljpeg::frame_info(src)?;
}
comp::JPEG_XL => {
let planar = info.planar == 2 && info.samples_per_pixel > 1;
let cpp = if planar { 1 } else { info.samples_per_pixel as usize };
let (cw, ch, vw, vh) = chunk_dims(info, &c);
jxl_chunk_check(src, cw, ch, vw, vh, cpp, info.bits() as u32, info.sample_format == 3)?;
}
_ => {}
}
}
Ok(if info.sample_format == 3 { RawData::F32(Vec::new()) } else { RawData::U16(Vec::new()) })
}
@@ -138,7 +151,7 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack
#[allow(clippy::too_many_arguments)]
fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, packing: Packing, cpp: usize, bits: u32, float: bool) -> Result<ChunkPx> {
let src = chunk_bytes(data, c).ok_or_else(|| RawError::Corrupt("chunk offset past end of file".into()))?;
let (cw, ch) = (c.width as usize, c.height as usize);
let (cw, ch, vw, vh) = chunk_dims(info, c);
let n = cw.checked_mul(ch).and_then(|v| v.checked_mul(cpp)).ok_or(RawError::Limit("chunk too large"))?;
if n > MAX_SAMPLES {
return Err(RawError::Limit("chunk too large"));
@@ -176,10 +189,34 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
let raw = inflate(src, row_bytes * ch)?;
unpack_chunk(&raw, order, packing, cw, ch, cpp, bits, float, info.predictor)
}
comp::JPEG_XL => jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float),
other => Err(RawError::Unsupported(format!("TIFF compression {other}"))),
}
}
/// A chunk's nominal size and the part of it inside the image (smaller for edge tiles).
fn chunk_dims(info: &ImageInfo, c: &Chunk) -> (usize, usize, usize, usize) {
let (cw, ch) = (c.width as usize, c.height as usize);
let vw = cw.min(info.width.saturating_sub(c.x) as usize);
let vh = ch.min(info.height.saturating_sub(c.y) as usize);
(cw, ch, vw, vh)
}
#[cfg(feature = "jxl")]
use crate::jxl::{check as jxl_chunk_check, decode as jxl_chunk};
#[cfg(not(feature = "jxl"))]
#[allow(clippy::too_many_arguments)]
fn jxl_chunk(_: &[u8], _: usize, _: usize, _: usize, _: usize, _: usize, _: u32, _: bool) -> Result<ChunkPx> {
Err(RawError::Unsupported("JPEG XL DNG (built without the `jxl` feature)".into()))
}
#[cfg(not(feature = "jxl"))]
#[allow(clippy::too_many_arguments)]
fn jxl_chunk_check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<()> {
jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float).map(|_| ())
}
/// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels).
fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
use zune_core::bytestream::ZCursor;
+3 -1
View File
@@ -43,7 +43,9 @@ fn corpus_raw_decodes() {
// DNG previews are optional (and some carry only an uncompressed RGB thumbnail); vendor raws embed a JPEG,
// except the Panasonic `.RAW` files of 2005–2007 and Canon's explicit HEVC preview tracks.
if let Some(p) = &preview {
assert!(p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]), "{name}: preview is not a JPEG");
let jpeg = p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]);
let jxl = p.starts_with(&[0xff, 0x0a]) || p.starts_with(b"\0\0\0\x0cJXL ");
assert!(jpeg || jxl, "{name}: preview is neither a JPEG nor a JPEG XL file");
} else {
let hevc_preview = fmt == RawFormat::Cr3
&& lightcraft_meta::cr3::parse_cr3(&bytes)
+159
View File
@@ -548,3 +548,162 @@ fn malformed_profile_look_tags_are_ignored() {
let plain = decode(&profile_dng(ByteOrder::Little, |_| {})).unwrap();
assert!(plain.color.profile.is_empty());
}
/// Lossless JPEG XL codestream of `px` (`w × h × cpp` interleaved), 16-bit unless `eight`.
fn jxl_encode(px: &[u16], w: usize, h: usize, cpp: usize, eight: bool) -> Vec<u8> {
use zune_core::bit_depth::BitDepth;
use zune_core::colorspace::ColorSpace;
use zune_core::options::EncoderOptions;
let cs = if cpp == 1 { ColorSpace::Luma } else { ColorSpace::RGB };
let (bytes, depth): (Vec<u8>, _) = if eight {
(px.iter().map(|&v| v as u8).collect(), BitDepth::Eight)
} else {
(px.iter().flat_map(|v| v.to_ne_bytes()).collect(), BitDepth::Sixteen)
};
let mut out = Vec::new();
zune_jpegxl::JxlSimpleEncoder::new(&bytes, EncoderOptions::new(w, h, cs, depth)).encode(&mut out).unwrap();
out
}
/// A bare codestream wrapped in the ISO-BMFF container (signature, `ftyp`, `jxlc`).
fn jxl_container(cs: &[u8]) -> Vec<u8> {
let mut b = vec![0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a, 0, 0, 0, 0x14];
b.extend_from_slice(b"ftypjxl \0\0\0\0jxl ");
b.extend_from_slice(&((cs.len() + 8) as u32).to_be_bytes());
b.extend_from_slice(b"jxlc");
b.extend_from_slice(cs);
b
}
/// A tiled JPEG XL raw IFD of `px`. Edge tiles alternate between being coded at their in-image size
/// and padded to the nominal tile size (with junk the decoder must drop); tiles alternate between bare
/// codestreams and containers.
fn jxl_tiled(px: &[u16], w: usize, h: usize, cpp: usize, bits: u16, cfa: bool, tile: (usize, usize)) -> IfdBuilder {
let (tw, th) = tile;
let mut tiles = Vec::new();
for ty in 0..h.div_ceil(th) {
for tx in 0..w.div_ceil(tw) {
let i = tiles.len();
let (vw, vh) = (tw.min(w - tx * tw), th.min(h - ty * th));
let (cw, ch) = if i % 2 == 0 { (tw, th) } else { (vw, vh) };
let mut t = vec![bits.min(8) * 7; cw * ch * cpp];
for y in 0..vh {
for x in 0..vw {
for s in 0..cpp {
t[(y * cw + x) * cpp + s] = px[((ty * th + y) * w + tx * tw + x) * cpp + s];
}
}
}
let cs = jxl_encode(&t, cw, ch, cpp, bits == 8);
tiles.push(if i % 3 == 1 { jxl_container(&cs) } else { cs });
}
}
let mut raw = base_ifd(w, h, bits, cpp, cfa);
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
raw.set_image(ImageData::Tiles { tile_width: tw as u32, tile_height: th as u32, tiles });
raw
}
#[test]
fn jpeg_xl_tiles_decode_bit_exactly() {
let (w, h) = (37, 29);
// LinearRaw 3 × 16-bit, the full code range
let px = pattern(w, h, 3, 16);
let bytes = dng(jxl_tiled(&px, w, h, 3, 16, false, (16, 16)), ByteOrder::Big);
let img = decode(&bytes).unwrap();
assert_eq!((img.width, img.height, img.cpp), (w, h, 3));
assert_eq!(img.data, RawData::U16(px));
assert_eq!(lightcraft_raw::probe_info(&bytes).unwrap(), img.info());
// CFA 1 × 8/12/14-bit
for bits in [8u32, 12, 14] {
let px = pattern(w, h, 1, bits);
let img = decode(&dng(jxl_tiled(&px, w, h, 1, bits as u16, true, (16, 8)), ByteOrder::Little)).unwrap();
assert_eq!(img.data, RawData::U16(px), "{bits} bits");
assert_eq!(img.white, vec![((1u32 << bits) - 1) as f32]);
assert_eq!(img.cfa.unwrap().name(), "GRBG");
}
// strips: a single JPEG XL strip per 8 rows (the last one shorter)
let px = pattern(w, h, 1, 12);
let strips = px.chunks(w * 8).map(|c| jxl_encode(c, w, c.len() / w, 1, false)).collect();
let mut raw = base_ifd(w, h, 12, 1, true);
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
raw.set_image(ImageData::Strips { rows_per_strip: 8, strips });
assert_eq!(decode(&dng(raw, ByteOrder::Big)).unwrap().data, RawData::U16(px));
}
#[test]
fn malformed_jpeg_xl_tiles_are_errors() {
use lightcraft_raw::RawError;
let (w, h) = (16, 16);
let px = pattern(w, h, 1, 12);
let good = jxl_encode(&px, w, h, 1, false);
let one = |tile: Vec<u8>, cpp: usize| {
let mut raw = base_ifd(w, h, 12, cpp, cpp == 1);
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
raw.set_image(ImageData::Tiles { tile_width: w as u32, tile_height: h as u32, tiles: vec![tile] });
dng(raw, ByteOrder::Little)
};
// garbage, every truncation, and flipped bytes: errors (or, for a truncation that only drops
// trailing padding, the exact samples), never panics
let mut garbage = vec![0xff, 0x0a];
garbage.extend((0..400u32).map(|i| (i.wrapping_mul(2654435761) >> 13) as u8));
assert!(matches!(decode(&one(garbage, 1)), Err(RawError::Corrupt(_))));
for n in 0..good.len() {
let bytes = one(good[..n].to_vec(), 1);
if let Ok(img) = decode(&bytes) {
assert_eq!(img.data, RawData::U16(px.clone()), "truncated to {n}");
}
let _ = lightcraft_raw::probe_info(&bytes);
}
for i in 0..good.len() {
let mut b = good.clone();
b[i] ^= 0x5a;
let _ = decode(&one(b, 1));
}
// a tile of the wrong size, or the wrong plane count for the IFD
let small = jxl_encode(&px[..w * 8], w, 8, 1, false);
assert!(matches!(decode(&one(small, 1)), Err(RawError::Corrupt(_))));
assert!(matches!(decode(&one(good.clone(), 3)), Err(RawError::Corrupt(_))));
assert!(matches!(lightcraft_raw::probe_info(&one(good, 3)), Err(RawError::Corrupt(_))));
}
/// The decoder's memory is bounded by the tile: a grey tile that also codes an alpha channel needs
/// about twice what one plane may use, and is an error rather than an allocation the header asks
/// for. The same samples without the alpha channel decode.
#[test]
fn jpeg_xl_tiles_beyond_their_decode_budget_are_errors() {
use lightcraft_raw::RawError;
use zune_core::bit_depth::BitDepth;
use zune_core::colorspace::ColorSpace;
use zune_core::options::EncoderOptions;
let (w, h) = (512, 512);
let grey: Vec<u16> = (0..w * h).map(|i| ((i % w) * 97 + (i / w) * 31) as u16 & 0x0fff).collect();
let with_alpha: Vec<u8> = grey.iter().flat_map(|&v| [v, 4095 - v]).flat_map(u16::to_ne_bytes).collect();
let mut tile = Vec::new();
zune_jpegxl::JxlSimpleEncoder::new(&with_alpha, EncoderOptions::new(w, h, ColorSpace::LumaA, BitDepth::Sixteen)).encode(&mut tile).unwrap();
let one = |tile: Vec<u8>| {
let mut raw = base_ifd(w, h, 12, 1, true);
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
raw.set_image(ImageData::Tiles { tile_width: w as u32, tile_height: h as u32, tiles: vec![tile] });
dng(raw, ByteOrder::Little)
};
assert!(matches!(decode(&one(tile)), Err(RawError::Corrupt(_))));
assert_eq!(decode(&one(jxl_encode(&grey, w, h, 1, false))).unwrap().data, RawData::U16(grey));
}
#[test]
fn jpeg_xl_preview_ifd_is_offered() {
let raw = synthetic(16, 16, Some(Cfa::bayer("RGGB").unwrap()), 1);
let RawData::U16(px) = &raw.data else { unreachable!() };
let mut sub = base_ifd(16, 16, 14, 1, true);
sub.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
sub.set_image(ImageData::Tiles { tile_width: 16, tile_height: 16, tiles: vec![jxl_encode(px, 16, 16, 1, false)] });
let preview = jxl_container(&jxl_encode(&[200; 4 * 2 * 3], 4, 2, 3, true));
let bytes = dng_with(sub, ByteOrder::Big, |ifd0| {
ifd0.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![preview.clone()] });
});
// the rendered RGB preview is offered whole; the (single-tile) CFA raw never is
assert_eq!(embedded_preview(&bytes).unwrap(), preview);
assert_eq!(decode(&bytes).unwrap().data, raw.data);
}
+1 -1
View File
@@ -137,7 +137,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones decode but no real JPEG XL DNG has verified them yet; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) |
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
+3
View File
@@ -17,6 +17,9 @@
### RAW decoding
- Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF
and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.
- JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files);
lossy tiles decode too, but no real file has checked them yet. A JPEG XL preview stored in the DNG is used like an
embedded JPEG.
- Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG
(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile
Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the