mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-31 21:37:08 -06:00
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation 8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft saved Lab documents as sRGB TIFFs. - codecs: a Lab channel layout (Lab, LabA) holding a* and b* as PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD importer), with its own Lab <-> sRGB conversion (same formulas as photocraft-color; codecs stays standalone) for formats without Lab, which now warn LabConverted. - TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the tiff crate as RGB and labelled Lab; a* and b* are converted from the file's encoding (signed a / 256a, or offset). - TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab is written with 16 bits (DepthReduced). - io: a Lab TIFF opens as a Lab document; a Lab document with one plain layer is written to TIFF in Lab (other formats still use sRGB). Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its own RGB conversion matches within one level at 8 bits and exactly at 16 bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same L*a*b* values. Fixes #4936 * DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11) DNG 1.4 lossy JPEG (compression 34892) stores each tile or strip as a baseline-DCT JPEG of 8-bit samples: a gray stream for CFA data, a YCbCr stream for three-sample LinearRaw. The decoder arm from #2021 read the streams but widened every sample by 257, so the values no longer matched the file's levels: an 8-bit WhiteLevel (or its default, 255) clipped everything to white and a 256-entry LinearizationTable was indexed past its end. It also required the JPEG frame to match the clipped row count, which rejected the padded edge tiles real tiled files carry. The arm is now `lossy_jpeg()` in sensor.rs. The decoded 8-bit values are the stored samples, and BlackLevel, WhiteLevel (default 255 for 8-bit data) and the LinearizationTable apply to them exactly as for every other compression. A tile may be coded at the full tile size (edge tiles padded, as the spec says) or at the image's row count; a strip at its row count; the width must be the segment's. BitsPerSample must be 8 and the JPEG's component count must equal SamplesPerPixel (1 or 3); anything else is an error, never a silent conversion. The frame header is checked before any pixel is decoded and the JPEG decoder's own size cap is the segment the IFD declares (already charged against the limits), so a hostile SOF cannot size a buffer past the tile. testgen writes true 8-bit lossy fixtures (`LossyJpegTiles`, and strips via the new `LossyJpegStrips`), accepts pre-encoded `lossy_segments` for hostile-input tests, exposes `baseline_jpeg()`, and leaves WhiteLevel out when `white` is 0. The DNG tests cover CFA and LinearRaw RGB from tiles and strips at 37x23 (partial edge tiles, a short last strip), the default white level, black/white scaling and a LinearizationTable against the uncompressed path, and hostile inputs: a truncated tile, frame-size and component-count mismatches, a 65535x65535 SOF under default and tight limits, a tile over the allocation budget, and 12/16-bit or 4-sample files. The hostile mutation set gains a lossy CFA and a lossy LinearRaw file. The parity doc's DNG row is updated; the scorecard row is left for the coordinating branch. * Raw: measure the Nikon D90 image area (4288x2848 at (10, 10)) Aligning the decoded D90 sensor plane with the camera's own JPEG puts the image at (10, 10) in the 4352x2868 sensor plane, giving the camera's 4288x2848 frame (10-pixel margins; the right border reads the camera's flat 255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the camera's frame instead of the uncropped plane, and make a measured profile leave a file that carries its own colour calibration (a DNG) untouched. * Codecs: open Cineon (.cin) film frames read-only Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from the same Kodak header family: the generic header (big-endian, orientation at 192, channel count at 193, the channel-1 bits/dimensions), one or three channels, three 10-bit samples packed into each 32-bit word on a word boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the file's log black/white points are a display choice and are not applied. A depth other than 10 bits or a channel count other than 1/3 is Unsupported; truncation errors rather than panicking. Read-only (no Cineon writer), so .cin joins the open filters and Cineon read closes part of #1049's G10 gap. * Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195) - flatten: a path's flattened points are capped at 1 << 20; past it every curve keeps a proportional share of its pieces (at least one), so hostile handles from files (vmsk, SVG, effect outlines) cannot exhaust memory. fill_rasterizer flattens the whole path under one budget. - stroke: past the dash step budget (or below coordinate precision), dash_polyline keeps the dashes made so far and draws the rest solid instead of dropping the stroke. Open polylines are no longer copied. - raster: coordinates are clamped to +-1e15 and edges with a non-finite slope (vanishingly short) are skipped, so huge finite coordinates no longer produce NaN coverage. fill_rasterizer sorts edges once instead of after every component (stable sort, identical order). - Cleanups: dead miter `half`, dead loop-length check in trace_mask, shared mask render body, map_knots helper, shared straight-segment test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198) - Patterns: one PatternBudget (decoded bytes + zero padding) is shared by every pattern of a block, a .pat file and the pattern blocks of one document; .abr patt sections are charged to the samples' budget. - Patterns: a channel whose depth differs from the first is rejected (the parsed pattern could not be written back). - Slices: descriptor numbers no longer wrap; out-of-range ids are no id, coordinates clamp to i32, and a v6 trailing entry without a usable id attaches its outsets to no slice. - LayerInfo::unpadded_len is computed from field sizes instead of serializing every record into a scratch buffer. - abr desc presets are moved out instead of cloned; UnFl and shmd counts use the bounded preallocation; ImageData::decode_channel (decoded every plane to return one, unused outside its test) is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Vector: bound stroke outline points (#5211) - stroke_polygons dashes all lines first, then counts round caps and joins. When their full-resolution tessellation would exceed 1 << 20 outline points, every round cap and join gets the same smaller segment count (down to 4 per circle, 1 per join; area-exact radius kept). Strokes under the budget are unchanged; no piece is dropped. - The dash step budget (100000) is now shared by all lines of a stroke instead of per polyline, so many subpaths cannot multiply the dash (and cap) count. Past it the rest is drawn solid, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PSD: write tiff blocks without copies, inflate merged ZIP once - ImageSourceData::to_bytes writes the global mask and global blocks straight from the model with 4-byte alignment instead of cloning each block (data included) to set its padding. Same bytes. - The tiff transcoder's nested regions and blocks write into the parent's buffer (taken and handed back, error or not) instead of building their own output and copying it up per nesting level. Bodies that fail or are not transcoded are truncated before being copied as stored or dropped. Same bytes. - PsdFile::from_bytes_and_merged decodes the merged image with the parse when asked: a ZIP stream is inflated once (the decode's stream check is the parse's); the parse-only check runs only when the decode fails, to keep from_bytes' errors. PSD import uses it with merged_needed. - PsdBuilder::composite documents that Gray/CMYK composites stay straight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366) Removing long, thin objects (coiled guitar strings, wires) left their outline as darker and lighter blotches and copied pieces of other strings into the fill. The pyramid shrank until the hole's bounding box was four patches across: for a string across the image that is a level where the hole has swallowed its surroundings, so the matches found there copy whatever looks like the blur, and the finer levels (refine-only for wide bounds) keep them. A hole at most a quarter as thick as its bounds are long now counts as thin: the pyramid stops while it is still about two patches thick, and that level starts from a membrane across it (over a few pixels, the surroundings themselves) instead of the onion peel, whose exhaustive search is what made a shallow pyramid unaffordable. Compact holes are unchanged bit for bit. Refs #1366 * perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(history): count snapshot-only tiles against the history budget History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366) Two more causes of ghosts when removing strings and the small knobs selected with them: * Auto sampling summarises the selection's surroundings by a few clusters. Around a long selection those come from the strings' surroundings, so a small part elsewhere (a tuning knob in a differently coloured corner) lost its own surroundings and was filled from far away in another colour. Around a thin selection the Auto area now always reaches as far as the selection is thick. * The membrane that starts a thin hole now starts a patch radius out, past the anti-aliased fringe or halo that a selection drawn close to the object leaves outside it. Refs #1366 * Content-Aware Fill: peel a thin hole where that stays affordable (#1366) Starting every thin hole from a membrane smoothed Spot Healing on film grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain 9.0 against 12.1 around). The onion peel copies the grain; it was only given up because its exhaustive search is too slow on the big level a long thin hole stops at. It now starts the thin hole whenever hole cells times sources stay within 2^28 comparisons, and the membrane only beyond. Refs #1366 * Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open Users on #2788 reported Select Subject dropping clothes that are close in colour to a plain wall and bridging the gap between an arm and the torso. Both come from the same place: the saliency pass labels low-contrast parts of the subject as background, the background colour model then learns them and explains them away, and the final blind hole fill closes every enclosed gap up to 5 % of the mask. Two changes in crates/algo: - A second GrabCut pass whose background model only knows the backdrop. A colour model fitted to the image frame (minus a margin around the salient blob, in case the subject runs out of the picture) marks every pixel it explains as probable background; the blob and whatever the backdrop cannot account for start as probable foreground. Only components that touch the first pass's result survive. - Hole filling that consults the colour models: specks (<= 0.2 % of the mask) always fill, larger holes up to 5 % fill only when their pixels fit the foreground model better than the background's. Tests: two regressions (a cream-shouldered figure on a near-white wall keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus ignored timing and picture-dump helpers. The 6 MP first run goes from about 100 ms to about 200 ms on one core; the P29 repeat path is untouched. * Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2) Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults), and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes Photoshop's HTML list of every bound command by menu. edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use and whether it is modified. Bad names, keys or counts are errors that change nothing. * .kys import: tool keys and sets saved on Windows (K3) Photoshop's <tool> rows now import: each matches our tool by name (our tool names are Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own becomes its override, while its own letter keeps the tool cycling with the others (#5036). A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that would also mean Control on a Mac. The import reports `windows`. * cargo fmt the extension lists Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Gert Pellin <gert@pellin.be> Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com> Co-authored-by: vangel76 <vangel1976@googlemail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Finn Newick <finnnk@gmail.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Diego Chagas <drochachagas@gmail.com> Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
525 lines
24 KiB
Rust
525 lines
24 KiB
Rust
//! Undeveloped sensor data and the shared strip/tile reader.
|
||
|
||
use crate::color::ColorInfo;
|
||
use crate::error::{RawError, Result};
|
||
use crate::tiff::{Ifd, Tiff, tag};
|
||
use crate::{Limits, RawFormat, jxl, ljpeg, par};
|
||
|
||
/// A rectangle in sensor-data pixels.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct Rect {
|
||
pub x: usize,
|
||
pub y: usize,
|
||
pub width: usize,
|
||
pub height: usize,
|
||
}
|
||
|
||
impl Rect {
|
||
pub fn new(x: usize, y: usize, width: usize, height: usize) -> Self {
|
||
Rect { x, y, width, height }
|
||
}
|
||
|
||
/// The overlap of two rectangles (may be empty).
|
||
pub fn intersect(&self, o: &Rect) -> Rect {
|
||
let x0 = self.x.max(o.x);
|
||
let y0 = self.y.max(o.y);
|
||
let x1 = (self.x.saturating_add(self.width)).min(o.x.saturating_add(o.width));
|
||
let y1 = (self.y.saturating_add(self.height)).min(o.y.saturating_add(o.height));
|
||
Rect::new(x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0))
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.width == 0 || self.height == 0
|
||
}
|
||
}
|
||
|
||
/// Colour of one CFA cell: 0 = red, 1 = green, 2 = blue.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Cfa {
|
||
pub width: usize,
|
||
pub height: usize,
|
||
/// Row-major colours, `width * height` entries.
|
||
pub colors: Vec<u8>,
|
||
/// Data coordinates of the pattern's top-left cell.
|
||
pub origin_x: usize,
|
||
pub origin_y: usize,
|
||
}
|
||
|
||
impl Cfa {
|
||
/// A 2×2 pattern from a string such as `"RGGB"`, anchored at (0, 0).
|
||
pub fn bayer(pattern: &str) -> Option<Cfa> {
|
||
let colors: Vec<u8> = pattern
|
||
.chars()
|
||
.map(|c| match c {
|
||
'R' => Some(0),
|
||
'G' => Some(1),
|
||
'B' => Some(2),
|
||
_ => None,
|
||
})
|
||
.collect::<Option<_>>()?;
|
||
(colors.len() == 4).then_some(Cfa { width: 2, height: 2, colors, origin_x: 0, origin_y: 0 })
|
||
}
|
||
|
||
/// Colour at data coordinates.
|
||
#[inline]
|
||
pub fn color(&self, x: usize, y: usize) -> u8 {
|
||
fn relative(v: usize, origin: usize, period: usize) -> Option<usize> {
|
||
if period == 0 {
|
||
return None;
|
||
}
|
||
let (v, origin) = (v % period, origin % period);
|
||
Some(if v >= origin { v - origin } else { period - (origin - v) })
|
||
}
|
||
let Some((cx, cy)) = relative(x, self.origin_x, self.width).zip(relative(y, self.origin_y, self.height)) else {
|
||
return 1;
|
||
};
|
||
cy.checked_mul(self.width).and_then(|i| i.checked_add(cx)).and_then(|i| self.colors.get(i)).copied().unwrap_or(1)
|
||
}
|
||
|
||
/// The 2×2 colours seen from data coordinates (`x0`, `y0`), row-major.
|
||
pub fn phase(&self, x0: usize, y0: usize) -> [u8; 4] {
|
||
[self.color(x0, y0), self.color(x0.saturating_add(1), y0), self.color(x0, y0.saturating_add(1)), self.color(x0.saturating_add(1), y0.saturating_add(1))]
|
||
}
|
||
|
||
/// `true` for a Bayer pattern: a 2×2 period holding one red, one blue and two
|
||
/// diagonal greens.
|
||
pub fn is_bayer(&self) -> bool {
|
||
if self.width == 0 || self.height == 0 || self.width > 16 || self.height > 16 {
|
||
return false;
|
||
}
|
||
let (ox, oy) = (self.origin_x % self.width, self.origin_y % self.height);
|
||
let p = self.phase(ox, oy);
|
||
let periodic = (0..self.height).all(|y| (0..self.width).all(|x| self.color(ox + x, oy + y) == p[(y % 2) * 2 + x % 2]));
|
||
let greens_on_diagonal = (p[0] == 1 && p[3] == 1 && p[1] != 1 && p[2] != 1 && u16::from(p[1]) + u16::from(p[2]) == 2)
|
||
|| (p[1] == 1 && p[2] == 1 && p[0] != 1 && p[3] != 1 && u16::from(p[0]) + u16::from(p[3]) == 2);
|
||
periodic && greens_on_diagonal
|
||
}
|
||
}
|
||
|
||
/// Black levels (DNG BlackLevel / BlackLevelRepeatDim / BlackLevelDeltaH/V).
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct BlackLevels {
|
||
pub rows: usize,
|
||
pub cols: usize,
|
||
/// `rows * cols * samples` values, sample fastest.
|
||
pub values: Vec<f32>,
|
||
/// Per column of the active area.
|
||
pub delta_h: Vec<f32>,
|
||
/// Per row of the active area.
|
||
pub delta_v: Vec<f32>,
|
||
}
|
||
|
||
impl BlackLevels {
|
||
pub fn uniform(v: f32) -> Self {
|
||
BlackLevels { rows: 1, cols: 1, values: vec![v], delta_h: Vec::new(), delta_v: Vec::new() }
|
||
}
|
||
|
||
/// Black level at active-area coordinates for sample `s` of `spp`.
|
||
#[inline]
|
||
pub fn at(&self, ax: usize, ay: usize, s: usize, spp: usize) -> f32 {
|
||
let r = ay % self.rows.max(1);
|
||
let c = ax % self.cols.max(1);
|
||
let index = r.checked_mul(self.cols.max(1)).and_then(|i| i.checked_add(c)).and_then(|i| i.checked_mul(spp)).and_then(|i| i.checked_add(s));
|
||
let base = index.and_then(|i| self.values.get(i)).or_else(|| self.values.first()).copied().unwrap_or(0.0);
|
||
base + self.delta_h.get(ax).copied().unwrap_or(0.0) + self.delta_v.get(ay).copied().unwrap_or(0.0)
|
||
}
|
||
|
||
/// `true` when there is a single black value for everything.
|
||
pub fn is_uniform(&self) -> bool {
|
||
self.delta_h.iter().all(|v| *v == 0.0) && self.delta_v.iter().all(|v| *v == 0.0) && self.values.windows(2).all(|w| w[0] == w[1])
|
||
}
|
||
}
|
||
|
||
/// Undeveloped sensor data with everything needed to develop it.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Sensor {
|
||
pub format: RawFormat,
|
||
pub make: Option<String>,
|
||
pub model: Option<String>,
|
||
/// Data width and height in pixels.
|
||
pub width: usize,
|
||
pub height: usize,
|
||
/// Samples per pixel: 1 for CFA data, 3 for LinearRaw (1 for monochrome LinearRaw).
|
||
pub samples: usize,
|
||
/// Row-major interleaved samples, `width * height * samples`.
|
||
pub data: Vec<u16>,
|
||
/// The CFA pattern (CFA data only).
|
||
pub cfa: Option<Cfa>,
|
||
/// Maps stored values to linear values (DNG LinearizationTable).
|
||
pub linearization: Option<Vec<u16>>,
|
||
pub black: BlackLevels,
|
||
/// White (saturation) level per sample.
|
||
pub white: [f32; 3],
|
||
/// Sensor area holding image data (DNG ActiveArea); black-level deltas are indexed from it.
|
||
pub active: Rect,
|
||
/// The area to output (ActiveArea ∩ DefaultCrop).
|
||
pub crop: Rect,
|
||
pub color: ColorInfo,
|
||
/// White-balance multipliers from the camera's own metadata (non-DNG), R G B.
|
||
pub camera_wb: Option<[f64; 3]>,
|
||
/// TIFF orientation (1–8).
|
||
pub orientation: u16,
|
||
/// Exposure compensation baked into the default rendering (DNG BaselineExposure), in EV.
|
||
pub baseline_exposure: f64,
|
||
/// DNG OpcodeList2 gain maps (lens shading), applied to the normalized data.
|
||
pub gain_maps: Vec<crate::opcodes::GainMap>,
|
||
/// The camera profile's default tone curve (like DNG ProfileToneCurve): increasing `[input,
|
||
/// output]` pairs in linear ProPhoto, applied per channel after the colour conversion and
|
||
/// exposure. Below the first point the curve is a line through 0; empty: no curve.
|
||
pub tone_curve: Vec<[f32; 2]>,
|
||
/// Camera Raw's adaptive black point, for profiles that copy its default rendering: this
|
||
/// factor times the 0.1th percentile of the image's linear luminance is subtracted before the
|
||
/// tone curve (see `tone.rs`). 0: no black point.
|
||
pub black_point: f32,
|
||
pub warnings: Vec<String>,
|
||
}
|
||
|
||
impl Sensor {
|
||
/// `true` for monochrome data (one-sample LinearRaw: monochrome cameras, scanner DNGs), which
|
||
/// develops to neutral grey with no white balance or colour calibration.
|
||
pub fn is_monochrome(&self) -> bool {
|
||
self.samples == 1 && self.cfa.is_none()
|
||
}
|
||
}
|
||
|
||
/// Raw samples read from one IFD.
|
||
pub(crate) struct Plane {
|
||
pub width: usize,
|
||
pub height: usize,
|
||
pub samples: usize,
|
||
pub bits: u32,
|
||
pub data: Vec<u16>,
|
||
}
|
||
|
||
pub(crate) struct Segment {
|
||
pub x: usize,
|
||
pub y: usize,
|
||
/// Encoded width and height (tile size, or image width × rows per strip).
|
||
pub w: usize,
|
||
pub h: usize,
|
||
pub offset: usize,
|
||
pub len: usize,
|
||
}
|
||
|
||
/// How a lossless-JPEG tile's samples map onto the tile.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub(crate) enum JpegLayout {
|
||
/// The decoded sample sequence fills the tile row by row (DNG).
|
||
Flat,
|
||
/// A 4-component frame of half the tile's width and height: each JPEG
|
||
/// pixel holds a 2×2 block, row-major (observed in Sony lossless ARW).
|
||
Quads,
|
||
}
|
||
|
||
/// DNG 1.7 JPEG XL compression.
|
||
const JPEG_XL: u32 = 52546;
|
||
|
||
/// Inflates one zlib tile into at most `need` bytes. The stream must run to its end: a truncated
|
||
/// stream, or one that holds more than `need` bytes, is an error rather than a silent cut-off.
|
||
fn inflate_tile(src: &[u8], need: usize) -> Result<Vec<u8>> {
|
||
let mut out = vec![0u8; need];
|
||
let mut d = flate2::Decompress::new(true);
|
||
let status = d.decompress(src, &mut out, flate2::FlushDecompress::Finish).map_err(|_| RawError::malformed("deflate tile does not decompress"))?;
|
||
if status != flate2::Status::StreamEnd {
|
||
return Err(RawError::malformed("deflate tile is truncated or larger than its tile"));
|
||
}
|
||
out.truncate(usize::try_from(d.total_out()).unwrap_or(need).min(need));
|
||
Ok(out)
|
||
}
|
||
|
||
/// Reads the (uncompressed, deflate, lossless-JPEG, lossy-JPEG or JPEG XL) image of a raw IFD.
|
||
pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayout) -> Result<Plane> {
|
||
let width = t.tag_uint(ifd, tag::IMAGE_WIDTH).ok_or_else(|| RawError::malformed("raw image has no width"))? as usize;
|
||
let height = t.tag_uint(ifd, tag::IMAGE_LENGTH).ok_or_else(|| RawError::malformed("raw image has no height"))? as usize;
|
||
let samples = t.tag_uint(ifd, tag::SAMPLES_PER_PIXEL).unwrap_or(1) as usize;
|
||
let bits = t.tag_uint(ifd, tag::BITS_PER_SAMPLE).unwrap_or(1);
|
||
let compression = t.tag_uint(ifd, tag::COMPRESSION).unwrap_or(1);
|
||
if !(1..=4).contains(&samples) {
|
||
return Err(RawError::unsupported(format!("{samples} samples per pixel")));
|
||
}
|
||
if t.tag_uint(ifd, tag::SAMPLE_FORMAT) == Some(3) {
|
||
return Err(RawError::unsupported("floating-point raw data"));
|
||
}
|
||
if !(1..=16).contains(&bits) {
|
||
return Err(RawError::unsupported(format!("{bits}-bit raw samples")));
|
||
}
|
||
if samples > 1 && t.tag_uint(ifd, tag::PLANAR_CONFIGURATION) == Some(2) {
|
||
return Err(RawError::unsupported("planar raw data"));
|
||
}
|
||
match compression {
|
||
1 | 7 | 8 | 34892 | JPEG_XL => {}
|
||
34713 => return Err(RawError::unsupported("Nikon compressed NEF")),
|
||
32767 => return Err(RawError::unsupported("Sony compressed ARW")),
|
||
65535 => return Err(RawError::unsupported("Pentax compressed PEF")),
|
||
c => return Err(RawError::unsupported(format!("raw compression scheme {c}"))),
|
||
}
|
||
limits.check(width as u64, height as u64, samples as u64 * 2)?;
|
||
let segments = segments(t, ifd, width, height)?;
|
||
let le = t.le;
|
||
let tiled = t.tag_uint(ifd, tag::TILE_WIDTH).is_some();
|
||
let decoded: Vec<Result<Vec<u16>>> = par::map(segments.len(), |i| {
|
||
let s = &segments[i];
|
||
let rows = s.h.min(height - s.y);
|
||
let src = t.bytes(s.offset, s.len).ok_or_else(|| RawError::malformed("raw data lies outside the file"))?;
|
||
// Every branch below sizes at least one buffer from the segment's own geometry, which
|
||
// the file declares independently of the image: a tile can be far larger than the image
|
||
// it belongs to, so the image check above does not bound those buffers. Charge the
|
||
// segment itself before decoding it (#2925).
|
||
limits.check(s.w as u64, s.h as u64, samples as u64 * 2)?;
|
||
match compression {
|
||
1 => unpack(src, s.w, rows, samples, bits, le),
|
||
8 => {
|
||
// Adobe DNG compression 8: zlib streams (RFC 1950). The output is capped at the
|
||
// tile's size in whole bytes per sample, so a bomb cannot grow past the tile;
|
||
// `unpack` then reads it bit-packed or in 16-bit containers by its length.
|
||
let too_big = || RawError::malformed("deflate tile is too large");
|
||
let need =
|
||
s.w.checked_mul(rows).and_then(|n| n.checked_mul(samples)).and_then(|n| n.checked_mul((bits as usize).div_ceil(8))).ok_or_else(too_big)?;
|
||
let out = inflate_tile(src, need)?;
|
||
let v = unpack(&out, s.w, rows, samples, bits, le)?;
|
||
// The TIFF predictor runs after unpacking, per row, per sample plane.
|
||
match t.tag_uint(ifd, tag::PREDICTOR).unwrap_or(1) {
|
||
1 => Ok(v),
|
||
2 => Ok(undelta(&v, samples, s.w * samples, rows, bits)),
|
||
3 => Err(RawError::unsupported("floating-point predictor (compression 8, predictor 3)")),
|
||
p => Err(RawError::malformed(format!("unknown predictor {p}"))),
|
||
}
|
||
}
|
||
// A JPEG XL segment codes only the rows inside the image (strips) or the whole tile.
|
||
JPEG_XL => {
|
||
let coded_rows = if tiled { s.h } else { rows };
|
||
let mut v = jxl::decode(src, s.w, coded_rows, samples, bits, limits)?;
|
||
v.resize(s.w * s.h * samples, 0);
|
||
Ok(v)
|
||
}
|
||
34892 => lossy_jpeg(src, s, rows, samples, bits, tiled, limits),
|
||
// Compression 7 (lossless JPEG): the frame the entropy stream declares is capped
|
||
// by the budget, not only by the segment, and the `Quads` scatter buffer below
|
||
// comes out of the same budget. `max_alloc` counts bytes, a sample is two.
|
||
_ => {
|
||
let need = s.w * rows * samples;
|
||
let scatter = if layout == JpegLayout::Quads && samples == 1 { s.w.saturating_mul(s.h).saturating_mul(2) as u64 } else { 0 };
|
||
let budget = usize::try_from(limits.max_alloc.saturating_sub(scatter) / 2).unwrap_or(usize::MAX);
|
||
let geom = s.w.saturating_mul(s.h).saturating_mul(samples).saturating_mul(4);
|
||
let (f, v) = ljpeg::decode(src, geom.min(budget))?;
|
||
if v.len() < need {
|
||
return Err(RawError::malformed("lossless JPEG tile is smaller than its tile"));
|
||
}
|
||
if layout == JpegLayout::Quads && samples == 1 && f.components == 4 && f.width * 2 == s.w && f.height * 2 == s.h {
|
||
let mut tile = vec![0u16; s.w * s.h];
|
||
for (i, q) in v.as_chunks::<4>().0.iter().enumerate() {
|
||
let at = 2 * (i / f.width) * s.w + 2 * (i % f.width);
|
||
tile[at] = q[0];
|
||
tile[at + 1] = q[1];
|
||
tile[at + s.w] = q[2];
|
||
tile[at + s.w + 1] = q[3];
|
||
}
|
||
return Ok(tile);
|
||
}
|
||
Ok(v)
|
||
}
|
||
}
|
||
});
|
||
let mut data = vec![0u16; width * height * samples];
|
||
for (s, d) in segments.iter().zip(decoded) {
|
||
let d = d?;
|
||
let rows = s.h.min(height - s.y);
|
||
let cols = s.w.min(width - s.x);
|
||
for r in 0..rows {
|
||
let src = &d[r * s.w * samples..(r * s.w + cols) * samples];
|
||
let at = ((s.y + r) * width + s.x) * samples;
|
||
data[at..at + cols * samples].copy_from_slice(src);
|
||
}
|
||
}
|
||
Ok(Plane { width, height, samples, bits, data })
|
||
}
|
||
|
||
pub(crate) fn segments(t: &Tiff, ifd: &Ifd, width: usize, height: usize) -> Result<Vec<Segment>> {
|
||
let mut out = Vec::new();
|
||
if let (Some(tw), Some(th)) = (t.tag_uint(ifd, tag::TILE_WIDTH), t.tag_uint(ifd, tag::TILE_LENGTH)) {
|
||
let (tw, th) = (tw as usize, th as usize);
|
||
if tw == 0 || th == 0 || tw > 1 << 16 || th > 1 << 16 {
|
||
return Err(RawError::malformed("bad tile size"));
|
||
}
|
||
let across = width.div_ceil(tw);
|
||
let down = height.div_ceil(th);
|
||
let offsets = t.tag_uints(ifd, tag::TILE_OFFSETS);
|
||
let counts = t.tag_uints(ifd, tag::TILE_BYTE_COUNTS);
|
||
let n = across.checked_mul(down).ok_or_else(|| RawError::malformed("too many tiles"))?;
|
||
if offsets.len() < n || counts.len() < n {
|
||
return Err(RawError::malformed("missing tile offsets"));
|
||
}
|
||
for i in 0..n {
|
||
out.push(Segment { x: (i % across) * tw, y: (i / across) * th, w: tw, h: th, offset: offsets[i] as usize, len: counts[i] as usize });
|
||
}
|
||
} else {
|
||
let rps = match t.tag_uint(ifd, tag::ROWS_PER_STRIP) {
|
||
Some(r) if r > 0 => (r as usize).min(height),
|
||
_ => height,
|
||
};
|
||
let n = height.div_ceil(rps.max(1));
|
||
let offsets = t.tag_uints(ifd, tag::STRIP_OFFSETS);
|
||
let mut counts = t.tag_uints(ifd, tag::STRIP_BYTE_COUNTS);
|
||
if offsets.len() < n {
|
||
return Err(RawError::malformed("missing strip offsets"));
|
||
}
|
||
if counts.len() < n {
|
||
if n == 1 {
|
||
counts = vec![(t.data.len().saturating_sub(offsets[0] as usize)) as u32];
|
||
} else {
|
||
return Err(RawError::malformed("missing strip byte counts"));
|
||
}
|
||
}
|
||
for i in 0..n {
|
||
out.push(Segment { x: 0, y: i * rps, w: width, h: rps, offset: offsets[i] as usize, len: counts[i] as usize });
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Decodes one segment of a lossy DNG (Adobe DNG compression 34892): a complete baseline-DCT
|
||
/// JPEG stream (ITU-T T.81), 8 bits per sample, as DNG 1.4 defines it. A CFA segment is a
|
||
/// one-component (gray) JPEG; a 3-sample LinearRaw segment is a three-component one in YCbCr,
|
||
/// which the JPEG decoder turns back into the stored R, G, B. The decoded 8-bit values are the
|
||
/// stored sample values: the LinearizationTable, BlackLevel and WhiteLevel apply to them later,
|
||
/// as for every other compression, and WhiteLevel defaults to 255 for 8-bit data.
|
||
///
|
||
/// `rows` is the number of segment rows inside the image. A tile is always coded at the full
|
||
/// tile size (edge tiles are padded); a strip is coded at its row count (the last strip is
|
||
/// shorter). Either height is accepted, the width must be the segment's. The frame header is
|
||
/// checked before any pixel is decoded, and the decoder's own size cap is the segment, so a
|
||
/// hostile SOF cannot size a buffer larger than the tile the IFD declares (which `limits` has
|
||
/// already charged).
|
||
fn lossy_jpeg(src: &[u8], s: &Segment, rows: usize, samples: usize, bits: u32, tiled: bool, limits: &Limits) -> Result<Vec<u16>> {
|
||
use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};
|
||
if bits != 8 {
|
||
return Err(RawError::unsupported(format!("lossy DNG with {bits}-bit samples (lossy JPEG tiles are 8-bit)")));
|
||
}
|
||
let out = match samples {
|
||
1 => ColorSpace::Luma,
|
||
3 => ColorSpace::RGB,
|
||
n => return Err(RawError::unsupported(format!("lossy DNG with {n} samples per pixel (1 or 3 are supported)"))),
|
||
};
|
||
let options = DecoderOptions::default().jpeg_set_out_colorspace(out).set_max_width(s.w).set_max_height(s.h);
|
||
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), options);
|
||
dec.decode_headers().map_err(|e| RawError::malformed(format!("lossy JPEG tile header: {e:?}")))?;
|
||
let f = dec.info().ok_or_else(|| RawError::malformed("lossy JPEG tile has no header"))?;
|
||
let (fw, fh) = (usize::from(f.width), usize::from(f.height));
|
||
let coded_rows = if tiled { s.h } else { rows };
|
||
if fw != s.w || (fh != coded_rows && fh != rows) {
|
||
return Err(RawError::malformed(format!("lossy JPEG tile is {fw}x{fh}, expected {}x{coded_rows}", s.w)));
|
||
}
|
||
if usize::from(f.components) != samples {
|
||
return Err(RawError::malformed(format!("lossy JPEG tile has {} components, expected {samples}", f.components)));
|
||
}
|
||
let need = fw.checked_mul(fh).and_then(|n| n.checked_mul(samples)).ok_or_else(|| RawError::malformed("lossy JPEG tile is too large"))?;
|
||
limits.check(fw as u64, fh as u64, samples as u64 * 2)?;
|
||
let decoded = dec.decode().map_err(|e| RawError::malformed(format!("lossy JPEG tile: {e:?}")))?;
|
||
if decoded.len() != need {
|
||
return Err(RawError::malformed(format!("lossy JPEG tile holds {} samples, expected {need}", decoded.len())));
|
||
}
|
||
let mut v: Vec<u16> = decoded.into_iter().map(u16::from).collect();
|
||
// A strip coded at its own row count still fills the segment's declared height.
|
||
let full = s.w.checked_mul(s.h).and_then(|n| n.checked_mul(samples)).ok_or_else(|| RawError::malformed("lossy JPEG tile is too large"))?;
|
||
v.resize(full, 0);
|
||
Ok(v)
|
||
}
|
||
|
||
/// Reverses TIFF predictor 2 (horizontal differencing) on interleaved samples: each sample
|
||
/// gains its channel's predecessor in the row, wrapping at the sample's container (8 or 16 bits).
|
||
fn undelta(v: &[u16], plane: usize, row_samples: usize, rows: usize, bits: u32) -> Vec<u16> {
|
||
let mask: u16 = if bits <= 8 { 0xFF } else { 0xFFFF };
|
||
let mut out = v.to_vec();
|
||
for row in out.chunks_mut(row_samples.max(1)).take(rows) {
|
||
for i in plane..row.len() {
|
||
if let (Some(&prev), Some(&cur)) = (row.get(i - plane), row.get(i)) {
|
||
row[i] = cur.wrapping_add(prev) & mask;
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Unpacks uncompressed samples: 8-bit, 16-bit in the file's byte order, or
|
||
/// MSB-first bit-packed rows padded to a byte (TIFF 6.0). 9–15-bit data
|
||
/// stored in 16-bit containers (common in camera raws) is recognised by its
|
||
/// byte count.
|
||
fn unpack(src: &[u8], w: usize, rows: usize, samples: usize, bits: u32, le: bool) -> Result<Vec<u16>> {
|
||
let n = w * rows * samples;
|
||
let truncated = || RawError::malformed("uncompressed raw data is truncated");
|
||
if bits == 16 || (bits > 8 && src.len() >= n * 2) {
|
||
let b = src.get(..n * 2).ok_or_else(truncated)?;
|
||
return Ok(b.as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }).collect());
|
||
}
|
||
if bits == 8 {
|
||
return Ok(src.get(..n).ok_or_else(truncated)?.iter().map(|&v| u16::from(v)).collect());
|
||
}
|
||
let row_bytes = (w * samples * bits as usize).div_ceil(8);
|
||
if src.len() < row_bytes * rows {
|
||
return Err(truncated());
|
||
}
|
||
let mut out = Vec::with_capacity(n);
|
||
for r in 0..rows {
|
||
let row = &src[r * row_bytes..(r + 1) * row_bytes];
|
||
let mut acc: u32 = 0;
|
||
let mut have = 0u32;
|
||
let mut it = row.iter();
|
||
for _ in 0..w * samples {
|
||
while have < bits {
|
||
acc = (acc << 8) | u32::from(*it.next().unwrap_or(&0));
|
||
have += 8;
|
||
}
|
||
have -= bits;
|
||
out.push(((acc >> have) & ((1 << bits) - 1)) as u16);
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn bayer_patterns() {
|
||
for p in ["RGGB", "BGGR", "GRBG", "GBRG"] {
|
||
assert!(Cfa::bayer(p).unwrap().is_bayer(), "{p}");
|
||
}
|
||
for p in ["RGBG", "RRGB", "GGRB"] {
|
||
assert!(!Cfa::bayer(p).unwrap().is_bayer(), "{p}");
|
||
}
|
||
let mut c = Cfa::bayer("RGGB").unwrap();
|
||
c.origin_x = 1;
|
||
assert_eq!(c.phase(0, 0), [1, 0, 2, 1]); // GRBG
|
||
}
|
||
|
||
fn zlib(data: &[u8]) -> Vec<u8> {
|
||
use std::io::Write;
|
||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||
e.write_all(data).unwrap();
|
||
e.finish().unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn inflate_tile_requires_stream_end() {
|
||
let data: Vec<u8> = (0..64u8).collect();
|
||
let z = zlib(&data);
|
||
assert_eq!(inflate_tile(&z, 64).unwrap(), data);
|
||
// Shorter than the tile, but a complete stream: still accepted.
|
||
assert_eq!(inflate_tile(&z, 100).unwrap(), data);
|
||
// Truncated stream.
|
||
assert!(inflate_tile(&z[..z.len() - 6], 64).is_err());
|
||
// More decoded output than the tile holds.
|
||
assert!(inflate_tile(&z, 32).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn unpack_12_bit() {
|
||
// Two 12-bit samples 0xABC, 0x123 packed MSB first.
|
||
assert_eq!(unpack(&[0xAB, 0xC1, 0x23], 2, 1, 1, 12, true).unwrap(), vec![0xABC, 0x123]);
|
||
// 12-bit data in 16-bit containers.
|
||
assert_eq!(unpack(&[0xBC, 0x0A, 0x23, 0x01], 2, 1, 1, 12, true).unwrap(), vec![0xABC, 0x123]);
|
||
assert!(unpack(&[0xAB], 2, 1, 1, 12, true).is_err());
|
||
}
|
||
}
|