Compare commits

...
Author SHA1 Message Date
A. Molzer a1426ca802 Fix benches/lzw.rs directory iteration 2026-05-16 02:28:36 +02:00
Aurelia Molzer 66e6567acc Merge pull request #392 from image-rs/upgrade-weezl
Upgrade weezl to 0.2
2026-05-15 22:51:46 +00:00
A. Molzer 5947d3b72e Make newer clippy happy 2026-05-16 00:46:02 +02:00
A. Molzer 0fdf1e0502 Upgrade weezl to 0.2 2026-05-16 00:40:00 +02:00
Aurelia Molzer cf9eee64ad Merge pull request #383 from lilith/chore/fuzz-dict-and-target
chore: replace decode_image fuzz target with comprehensive decoder exerciser
2026-04-09 22:26:34 +00:00
Lilith River a59db23341 chore: replace decode_image fuzz target with comprehensive decoder exerciser
Replace decode_image with decode_comprehensive, which exercises
per-IFD via a probe_directory helper:
- Tag iteration and value parsing for every tag
- SubIFD, EXIF, and GPS directory traversal via read_directory
- Image metadata (dimensions, colortype, color_map, chunk info)
- Layout queries (image_buffer_layout, image_chunk_buffer_layout)
- Per-chunk reads at first and last indices
- Full image decode via read_image

Three IFD traversal strategies:
- Linear iteration via next_directory (tolerates invalid
  intermediate IFDs)
- Random-access via seek_to_directory (find_nth_ifd code path)
- Image-aware random-access via seek_to_image

Dictionary covers classic TIFF and BigTIFF byte order marks, and
common IFD tags in both LE and BE (image geometry, strips, tiles,
SubIFD, EXIF, GPS, ColorMap).

Strict resource limits (512 KB decode/intermediate, 64 KB IFD).
Bounded iteration (max 8 linear, max 4 seek).
2026-04-09 14:14:35 -06:00
Aurelia Molzer e8103509fa Merge pull request #385 from lilith/fix/colormap-shl-overflow
fix: prevent shift overflow panic in ColorMap size validation
2026-04-09 12:25:51 +00:00
Lilith River 26b18c5184 fix: prevent shift overflow panic in ColorMap size validation
`1usize << bits_per_sample` panics when bits_per_sample >= 64.
Use checked_shl to return a LimitsExceeded error instead.

Found by fuzzing decode_comprehensive with a BE TIFF containing
PhotometricInterpretation=RGBPalette and BitsPerSample=83.
2026-04-09 04:46:05 -06:00
Lilith River b0712855b1 ci: add nightly fuzz workflow (60s on push, 5min nightly) 2026-04-09 04:43:12 -06:00
Lilith River ea6e2a8d80 feat: add decode_comprehensive fuzz target with safe metadata probing
New fuzz target exercises TiffHeader::parse, dimensions/colortype
probing, chunk-by-chunk reading, and multi-IFD iteration. Guards
get_chunk_type/chunk_dimensions behind dimensions() success check
to avoid documented panics on non-image IFDs.
2026-04-09 04:43:12 -06:00
Lilith River 032421634f chore: add fuzz dictionary for TIFF
Format-specific dictionary with LE/BE byte order marks, TIFF magic
number, and common IFD tag numbers as 2-byte LE and BE pairs.
Also add coverage to fuzz/.gitignore.
2026-04-09 04:43:12 -06:00
Aurelia Molzer d760bd3442 Merge pull request #384 from image-rs/renovate-decode-iteration
Fix decode example/fuzz with new iteration
2026-04-08 18:49:52 +00:00
A. Molzer a70e05e51e Fix decode example/fuzz with new iteration 2026-04-08 20:43:12 +02:00
Aurelia Molzer dc25c4eaea Merge pull request #382 from lilith/fix/fax-overflow-panic
fix: prevent u16 overflow panics in Group3 fax run-length decode
2026-04-08 05:42:03 +00:00
Lilith River d7961a2fcf fix: prevent u16 overflow panic in Group3 fax run-length decode
Fuzz testing found that crafted TIFF files with corrupt Group3 fax data
could cause a u16 addition overflow when accumulating run lengths. Use
checked_add and break on overflow (treating as corrupt data) instead of
panicking.
2026-04-07 17:45:59 -06:00
Aurelia Molzer 2166fd591a Merge pull request #380 from lilith/fix/ifd-limits-and-packbits
fix: enforce ifd_value_size limits and detect PackBits under-decode
2026-04-07 18:13:40 +00:00
Aurelia Molzer 7380611a7b Merge pull request #381 from lilith/refactor/typed-predictor-slices
refactor: use typed slices for float predictor output
2026-04-07 13:03:16 +00:00
Lilith River 149492ca6e refactor: use typed slices for float predictor output
Change predict_f32/f16/f64 to take &mut [[u8; N]] output instead of
&mut [u8], making sample alignment a compile-time guarantee rather
than a silent runtime truncation. The caller uses as_chunks_mut at the
boundary, with a debug_assert that the remainder is empty.

This follows 197g's suggestion in #376 to move alignment validation
to the caller via typed slices, keeping the predictor functions clean
for future SIMD dispatch.
2026-04-07 03:03:23 -06:00
Lilith River e4c3c1fcfd fix: enforce ifd_value_size limits and detect PackBits under-decode
Two input validation fixes:

- vec_with_capacity now checks raw IFD value bytes against
  ifd_value_size before allocating. Previously only the in-memory
  Value representation was bounded (by decoding_buffer_size), so a
  crafted IFD with a large count of small-typed values could allocate
  based on raw byte count without limit checking. Also fix ASCII value
  parsing to check ifd_value_size instead of decoding_buffer_size.

- LogLuv PackBits decoder now verifies that the expected number of
  pixels was actually decoded per plane, returning UnexpectedEof if
  the RLE stream ends short.
2026-04-07 02:53:43 -06:00
Aurelia Molzer 6a3282e9f2 Merge pull request #379 from lilith/main
Optimize horizontal and float predictor decode (4-43% faster)
2026-04-06 17:25:25 +00:00
Lilith River fa8a911f79 perf: optimize horizontal and float predictor decode
Horizontal predictor (one_byte_predict, all integer TIFFs with
Predictor=2): rewrite with explicit [u8; N] accumulator so LLVM unrolls
and keeps each channel in a register. For N=3 (RGB8), this replaces a
scalar 1-byte-per-iteration loop with a 2x-unrolled 3-register loop.

Float predictor (Predictor=3, f16/f32/f64): split deinterleave into
per-format functions with plane slicing up front; f32 batches 4 samples
through a u128 wide store to eliminate per-sample bounds checks. Thread
a reusable scratch buffer through expand_chunk to eliminate per-row
heap allocation.

Benchmarked on real 21MP photos and GeoTIFF DEMs (vs upstream main):

  Strip RGB8 deflate+hpred:      253ms -> 224ms (-11%)
  Strip RGB8 LZW:                405ms -> 370ms (-9%)
  Tiled RGB8 deflate+hpred:      241ms -> 222ms (-8%)
  Strip RGB f32 deflate+fpred:   726ms -> 527ms (-27%)
  Gray f32 tiled 512:            290ms -> 166ms (-43%)
  Copernicus DEM GeoTIFF:        239ms -> 152ms (-37%)
  USGS DEM GeoTIFF:              284ms -> 198ms (-30%)
2026-04-06 08:49:00 -06:00
Lilith River dc94cd4436 bench: add comprehensive decode benchmarks
Add decode benchmark covering 16 TIFF configurations with both a real
photo corpus (if available) and synthetic in-memory images:

Strip: uncompressed, PackBits, LZW, LZW+hpred, deflate+hpred, RGB16
deflate+hpred, gray8 LZW, gray8 deflate+hpred, RGB f32 deflate+fpred,
gray f32 deflate+fpred, palette8 LZW, bilevel fax4.
Tiled: RGB8 LZW/deflate/JPEG 256, gray f32 deflate+fpred 512.

Synthetic benchmarks isolate predictor and compression overhead with
controlled 2048x2048 images. A callgrind profiling example is included
for instruction-level analysis.
2026-04-05 22:00:22 -06:00
Aurelia Molzer a42b16ab72 Merge pull request #377 from image-rs/change-reader-of-decoder
Transfer decoder to new reader
2026-04-05 08:05:41 +00:00
Aurelia Molzer 8246b55e8f Merge pull request #378 from image-rs/forgotten-dbg-statement
Cleanup dbg! left in error path
2026-04-04 21:16:36 +00:00
A. Molzer 399469f512 Cleanup dbg! left in error path 2026-04-04 23:13:07 +02:00
18 changed files with 1182 additions and 124 deletions

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
name: Fuzz
on:
push:
branches: [main]
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
duration:
description: 'Seconds per fuzz target'
required: false
default: '300'
concurrency:
group: fuzz-${{ github.ref }}
cancel-in-progress: true
jobs:
fuzz:
name: Fuzz (${{ matrix.target }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target:
- decode_comprehensive
- decode_image
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
with:
key: fuzz-${{ matrix.target }}
cache-directories: fuzz/target
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked
- name: Determine duration
id: duration
run: |
if [ "${{ github.event_name }}" = "schedule" ]; then
echo "seconds=300" >> "$GITHUB_OUTPUT"
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "seconds=${{ github.event.inputs.duration || '300' }}" >> "$GITHUB_OUTPUT"
else
echo "seconds=60" >> "$GITHUB_OUTPUT"
fi
- name: Seed corpus
run: |
mkdir -p fuzz/corpus/${{ matrix.target }}
cp fuzz/regression/* fuzz/corpus/${{ matrix.target }}/ 2>/dev/null || true
cp fuzz/seed_corpus/${{ matrix.target }}/* fuzz/corpus/${{ matrix.target }}/ 2>/dev/null || true
- name: Fuzz ${{ matrix.target }}
run: |
cargo +nightly fuzz run ${{ matrix.target }} \
-- -max_total_time=${{ steps.duration.outputs.seconds }} \
-dict=fuzz/tiff.dict \
-rss_limit_mb=2048
- name: Upload crashes
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes-${{ matrix.target }}
path: fuzz/artifacts/
retention-days: 30
regression:
name: Fuzz regression
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build
- name: Regression seeds
run: |
if [ -d fuzz/regression ] && [ "$(ls fuzz/regression/ 2>/dev/null | wc -l)" -gt 0 ]; then
cargo test --test fuzz_regression 2>/dev/null || echo "No regression test found — seeds exist but no test harness"
fi
+1
View File
@@ -2,3 +2,4 @@
/target/
**/*.rs.bk
Cargo.lock
tests/benches/corpus/
+5 -1
View File
@@ -23,7 +23,7 @@ quick-error = "2.0.1"
# Rename to avoid confusion with the feature name.
fax34 = { package = "fax", version = "0.2.6", optional = true }
flate2 = { version = "1.0.20", optional = true }
weezl = { version = "0.1.10", optional = true }
weezl = { version = "0.2.1", optional = true }
zstd = { version = "0.13", optional = true }
zune-jpeg = { version = "0.5.1", optional = true }
image-webp = {version = "0.2.4", optional = true }
@@ -54,6 +54,10 @@ harness = false
name = "encode"
harness = false
[[bench]]
name = "decode"
harness = false
[[test]]
name = "decode_libtiffpic"
+1 -1
View File
@@ -55,4 +55,4 @@ This crate uses [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) in order t
After installing it with `cargo install cargo-fuzz` on a nightly rustc, the
fuzzing harness can be run with recommended settings using
`cargo fuzz run decode_image -snone -- -timeout=5`.
`cargo fuzz run decode_comprehensive -snone -- -dict=fuzz/tiff.dict -timeout=5`.
+461
View File
@@ -0,0 +1,461 @@
extern crate criterion;
extern crate tiff;
use criterion::{black_box, Criterion, Throughput};
use std::io::Cursor;
use tiff::decoder::Decoder;
use tiff::encoder::colortype::{Gray16, Gray8, RGB32Float, RGB16, RGB8, RGBA8};
use tiff::encoder::{Compression, Predictor, TiffEncoder};
const WIDTH: u32 = 2048;
const HEIGHT: u32 = 2048;
// === Corpus paths (real photo-based TIFFs) ===
// Set TIFF_BENCH_DIR / TIFF_GEOTIFF_DIR env vars to a directory containing the corpus files.
// If the files are not found, the corresponding benchmarks are silently skipped.
// The corpus can be generated with vips/imagemagick from any large photo; see the benchmark
// names for the expected filenames and compression settings.
const CORPUS_DIR: &str = "tests/benches/corpus";
fn bench_dir() -> String {
std::env::var("TIFF_BENCH_DIR").unwrap_or_else(|_| CORPUS_DIR.to_string())
}
fn geotiff_dir() -> String {
std::env::var("TIFF_GEOTIFF_DIR").unwrap_or_else(|_| CORPUS_DIR.to_string())
}
/// Load a file from the corpus directory, returning None if not found.
fn load_corpus(dir: &str, name: &str) -> Option<Vec<u8>> {
let path = format!("{}/{}", dir, name);
std::fs::read(&path).ok()
}
fn decode_tiff_bytes(data: &[u8]) {
let cursor = Cursor::new(data);
let mut decoder = Decoder::open(black_box(cursor)).unwrap();
decoder.next_image().unwrap();
decoder.read_image().unwrap();
}
// === Synthetic image generators ===
fn generate_rgb8(w: u32, h: u32) -> Vec<u8> {
(0..w * h * 3)
.map(|i| ((i * 7 + i / w * 3) & 0xFF) as u8)
.collect()
}
fn generate_rgba8(w: u32, h: u32) -> Vec<u8> {
(0..w * h * 4)
.map(|i| ((i * 11 + i / w * 5) & 0xFF) as u8)
.collect()
}
fn generate_gray8(w: u32, h: u32) -> Vec<u8> {
(0..w * h)
.map(|i| ((i * 7 + i / w * 3) & 0xFF) as u8)
.collect()
}
fn generate_rgb16(w: u32, h: u32) -> Vec<u16> {
(0..w * h * 3)
.map(|i| ((i * 257 + i / w * 131) & 0xFFFF) as u16)
.collect()
}
fn generate_gray16(w: u32, h: u32) -> Vec<u16> {
(0..w * h)
.map(|i| ((i * 257 + i / w * 131) & 0xFFFF) as u16)
.collect()
}
fn generate_rgb_f32(w: u32, h: u32) -> Vec<f32> {
let mut data = Vec::with_capacity((w * h * 3) as usize);
for y in 0..h {
for x in 0..w {
let fx = x as f32 / w as f32;
let fy = y as f32 / h as f32;
data.push(fx * 0.5 + fy * 0.3);
data.push(fy * 0.7 + fx * 0.2);
data.push((fx + fy) * 0.25);
}
}
data
}
// === Synthetic encoders (strip-only, encoder doesn't support tiles) ===
fn encode<C: tiff::encoder::colortype::ColorType>(
data: &[C::Inner],
w: u32,
h: u32,
compression: Compression,
predictor: Predictor,
) -> Vec<u8>
where
[C::Inner]: tiff::encoder::TiffValue,
{
let mut buf = Cursor::new(Vec::new());
TiffEncoder::new(&mut buf)
.unwrap()
.with_predictor(predictor)
.with_compression(compression)
.write_image::<C>(w, h, data)
.unwrap();
buf.into_inner()
}
fn main() {
let mut c = Criterion::default().configure_from_args();
let bd = bench_dir();
let gd = geotiff_dir();
// ========================================================================
// PART 1: Real photo corpus (5616x3744, ~21MP)
// ========================================================================
// Raw pixel sizes for throughput calculation:
// RGB8: 5616*3744*3 = 63,078,912
// RGB16: 5616*3744*6 = 126,157,824
// F32x3: 5616*3744*12 = 252,315,648
// Gray8: 5616*3744*1 = 21,026,304
// GrayF32: 5616*3744*4 = 84,105,216
// Palette8: 5616*3744*1 = 21,026,304 (output)
struct CorpusBench {
file: &'static str,
name: &'static str,
raw_bytes: u64,
}
let photo_benches = [
// Strip-based
CorpusBench {
file: "photo-rgb8-none.tif",
name: "strip-rgb8-none",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb8-packbits.tif",
name: "strip-rgb8-packbits",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb8-lzw.tif",
name: "strip-rgb8-lzw",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb8-lzw-hpred.tif",
name: "strip-rgb8-lzw-hpred",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb8-deflate-hpred.tif",
name: "strip-rgb8-deflate-hpred",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb16-deflate-hpred.tif",
name: "strip-rgb16-deflate-hpred",
raw_bytes: 126_157_824,
},
CorpusBench {
file: "photo-gray8-lzw.tif",
name: "strip-gray8-lzw",
raw_bytes: 21_026_304,
},
CorpusBench {
file: "photo-gray8-deflate-hpred.tif",
name: "strip-gray8-deflate-hpred",
raw_bytes: 21_026_304,
},
CorpusBench {
file: "photo-rgb-f32-deflate-fpred.tif",
name: "strip-rgb-f32-deflate-fpred",
raw_bytes: 252_315_648,
},
CorpusBench {
file: "photo-gray-f32-deflate-fpred.tif",
name: "strip-gray-f32-deflate-fpred",
raw_bytes: 84_105_216,
},
CorpusBench {
file: "photo-palette8-lzw.tif",
name: "strip-palette8-lzw",
raw_bytes: 21_026_304,
},
CorpusBench {
file: "scan-bilevel-fax4.tif",
name: "strip-bilevel-fax4",
raw_bytes: 5616 * 3744 / 8,
},
// Tiled
CorpusBench {
file: "photo-rgb8-lzw-hpred-tiled256.tif",
name: "tiled256-rgb8-lzw-hpred",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb8-deflate-hpred-tiled256.tif",
name: "tiled256-rgb8-deflate-hpred",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-rgb8-jpeg-tiled256.tif",
name: "tiled256-rgb8-jpeg",
raw_bytes: 63_078_912,
},
CorpusBench {
file: "photo-gray-f32-deflate-fpred-tiled512.tif",
name: "tiled512-gray-f32-deflate-fpred",
raw_bytes: 84_105_216,
},
];
{
let mut group = c.benchmark_group("photo-21mp");
group.sample_size(10);
for bench in &photo_benches {
if let Some(data) = load_corpus(&bd, bench.file) {
group.throughput(Throughput::Bytes(bench.raw_bytes));
let data_ref = &data;
group.bench_function(bench.name, |b| b.iter(|| decode_tiff_bytes(data_ref)));
}
}
group.finish();
}
// ========================================================================
// PART 2: GeoTIFF (real tiled DEM data)
// ========================================================================
let geotiff_benches = [
// Copernicus DEM: 3600x3600, tiled 1024x1024, Gray f32, deflate + fpredict
CorpusBench {
file: "Copernicus_DSM_COG_10_N40_00_W075_00_DEM.tif",
name: "copernicus-dem-f32-deflate-fpred-1024tile",
raw_bytes: 3600 * 3600 * 4,
},
// USGS DEM: 3612x3612, tiled 512x512, Gray f32, LZW + fpredict
CorpusBench {
file: "USGS_1_n40w075.tif",
name: "usgs-dem-f32-lzw-fpred-512tile",
raw_bytes: 3612 * 3612 * 4,
},
];
{
let mut group = c.benchmark_group("geotiff");
group.sample_size(10);
for bench in &geotiff_benches {
if let Some(data) = load_corpus(&gd, bench.file) {
group.throughput(Throughput::Bytes(bench.raw_bytes));
let data_ref = &data;
group.bench_function(bench.name, |b| b.iter(|| decode_tiff_bytes(data_ref)));
}
}
group.finish();
}
// ========================================================================
// PART 3: Synthetic (2048x2048, controlled comparisons)
// ========================================================================
let rgb8 = generate_rgb8(WIDTH, HEIGHT);
let rgba8 = generate_rgba8(WIDTH, HEIGHT);
let gray8 = generate_gray8(WIDTH, HEIGHT);
let rgb16 = generate_rgb16(WIDTH, HEIGHT);
let gray16 = generate_gray16(WIDTH, HEIGHT);
let rgb_f32 = generate_rgb_f32(WIDTH, HEIGHT);
let raw_rgb8 = (WIDTH * HEIGHT * 3) as u64;
let raw_rgba8 = (WIDTH * HEIGHT * 4) as u64;
let raw_gray8 = (WIDTH * HEIGHT) as u64;
let raw_rgb16 = (WIDTH * HEIGHT * 6) as u64;
let raw_gray16 = (WIDTH * HEIGHT * 2) as u64;
let raw_f32x3 = (WIDTH * HEIGHT * 12) as u64;
// --- Uncompressed baseline ---
{
let mut group = c.benchmark_group("synth-uncompressed");
group.sample_size(10);
let t = encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Uncompressed,
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGBA8>(
&rgba8,
WIDTH,
HEIGHT,
Compression::Uncompressed,
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_rgba8))
.bench_function("rgba8", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<Gray8>(
&gray8,
WIDTH,
HEIGHT,
Compression::Uncompressed,
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_gray8))
.bench_function("gray8", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB16>(
&rgb16,
WIDTH,
HEIGHT,
Compression::Uncompressed,
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_rgb16))
.bench_function("rgb16", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<Gray16>(
&gray16,
WIDTH,
HEIGHT,
Compression::Uncompressed,
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_gray16))
.bench_function("gray16", |b| b.iter(|| decode_tiff_bytes(&t)));
group.finish();
}
// --- Predictor overhead isolation (deflate-1, with vs without predictor) ---
{
let mut group = c.benchmark_group("synth-predictor");
group.sample_size(10);
let t = encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8-deflate-none", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::Horizontal,
);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8-deflate-hpred", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB16>(
&rgb16,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_rgb16))
.bench_function("rgb16-deflate-none", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB16>(
&rgb16,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::Horizontal,
);
group
.throughput(Throughput::Bytes(raw_rgb16))
.bench_function("rgb16-deflate-hpred", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB32Float>(
&rgb_f32,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::None,
);
group
.throughput(Throughput::Bytes(raw_f32x3))
.bench_function("rgb-f32-deflate-none", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB32Float>(
&rgb_f32,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::FloatingPoint,
);
group
.throughput(Throughput::Bytes(raw_f32x3))
.bench_function("rgb-f32-deflate-fpred", |b| {
b.iter(|| decode_tiff_bytes(&t))
});
group.finish();
}
// --- LZW vs Deflate comparison ---
{
let mut group = c.benchmark_group("synth-compression");
group.sample_size(10);
let t = encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::Horizontal,
);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8-deflate-hpred", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Lzw,
Predictor::Horizontal,
);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8-lzw-hpred", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB8>(&rgb8, WIDTH, HEIGHT, Compression::Lzw, Predictor::None);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8-lzw-none", |b| b.iter(|| decode_tiff_bytes(&t)));
let t = encode::<RGB8>(&rgb8, WIDTH, HEIGHT, Compression::Packbits, Predictor::None);
group
.throughput(Throughput::Bytes(raw_rgb8))
.bench_function("rgb8-packbits", |b| b.iter(|| decode_tiff_bytes(&t)));
group.finish();
}
}
+1 -1
View File
@@ -9,9 +9,9 @@ use tiff::decoder::Decoder;
fn read_image(image: &[u8]) {
let image = std::io::Cursor::new(image);
let decoder = Decoder::open(black_box(image));
let mut reader = decoder.unwrap();
while reader.more_images() {
reader.next_directory().unwrap();
reader.read_image().unwrap();
}
}
+6 -6
View File
@@ -13,17 +13,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut data = DecodingSampleBuffer::I8(vec![]);
for i in 0u32.. {
if !reader.more_images() {
break;
}
reader.next_directory()?;
let colortype = reader.colortype()?;
let dimensions = reader.dimensions()?;
let layout = reader.read_image_to_buffer(&mut data)?;
debug_planes(i, &mut data, &layout, &colortype, dimensions);
if !reader.more_images() {
break;
}
reader.next_image()?
}
Ok(())
+130
View File
@@ -0,0 +1,130 @@
//! Profiling helper: encodes and decodes several TIFF configurations.
//! Run under callgrind: `valgrind --tool=callgrind target/release/examples/profile_decode`
use std::io::Cursor;
use tiff::decoder::Decoder;
use tiff::encoder::colortype::{Gray8, RGB32Float, RGB16, RGB8};
use tiff::encoder::{Compression, Predictor, TiffEncoder};
const WIDTH: u32 = 2048;
const HEIGHT: u32 = 2048;
const ITERS: usize = 3;
fn generate_rgb8(w: u32, h: u32) -> Vec<u8> {
(0..w * h * 3).map(|i| (i % 256) as u8).collect()
}
fn generate_rgb16(w: u32, h: u32) -> Vec<u16> {
(0..w * h * 3).map(|i| (i % 65536) as u16).collect()
}
fn generate_rgb_f32(w: u32, h: u32) -> Vec<f32> {
(0..w * h * 3)
.map(|i| (i as f32) / (w * h * 3) as f32)
.collect()
}
fn generate_gray8(w: u32, h: u32) -> Vec<u8> {
(0..w * h).map(|i| (i % 256) as u8).collect()
}
fn encode<C: tiff::encoder::colortype::ColorType>(
data: &[C::Inner],
w: u32,
h: u32,
compression: Compression,
predictor: Predictor,
) -> Vec<u8>
where
[C::Inner]: tiff::encoder::TiffValue,
{
let mut buf = Cursor::new(Vec::new());
TiffEncoder::new(&mut buf)
.unwrap()
.with_predictor(predictor)
.with_compression(compression)
.write_image::<C>(w, h, data)
.unwrap();
buf.into_inner()
}
fn decode(data: &[u8]) {
let cursor = Cursor::new(data);
let mut decoder = Decoder::open(cursor).unwrap();
decoder.next_image().unwrap();
let _ = decoder.read_image().unwrap();
}
fn main() {
let rgb8 = generate_rgb8(WIDTH, HEIGHT);
let rgb16 = generate_rgb16(WIDTH, HEIGHT);
let rgb_f32 = generate_rgb_f32(WIDTH, HEIGHT);
let gray8 = generate_gray8(WIDTH, HEIGHT);
let cases: Vec<(&str, Vec<u8>)> = vec![
(
"deflate-rgb8-hpredict",
encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::Horizontal,
),
),
(
"deflate-gray8-hpredict",
encode::<Gray8>(
&gray8,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::Horizontal,
),
),
(
"deflate-rgb16-hpredict",
encode::<RGB16>(
&rgb16,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::Horizontal,
),
),
(
"deflate-rgb-f32-fpredict",
encode::<RGB32Float>(
&rgb_f32,
WIDTH,
HEIGHT,
Compression::Deflate(1),
Predictor::FloatingPoint,
),
),
(
"lzw-rgb8-no-pred",
encode::<RGB8>(&rgb8, WIDTH, HEIGHT, Compression::Lzw, Predictor::None),
),
(
"lzw-rgb8-hpredict",
encode::<RGB8>(
&rgb8,
WIDTH,
HEIGHT,
Compression::Lzw,
Predictor::Horizontal,
),
),
];
for (name, tiff_data) in &cases {
eprintln!(
"=== Profiling: {} ({} bytes encoded) ===",
name,
tiff_data.len()
);
for _ in 0..ITERS {
decode(tiff_data);
}
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ path = ".."
members = ["."]
[[bin]]
name = "decode_image"
path = "fuzz_targets/decode_image.rs"
name = "decode_comprehensive"
path = "fuzz_targets/decode_comprehensive.rs"
test = false
doc = false
+96
View File
@@ -0,0 +1,96 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use std::io::{Cursor, Read, Seek};
use tiff::decoder::{Decoder, Limits};
use tiff::tags::Tag;
fuzz_target!(|data: &[u8]| {
// Standalone header parse (separate code path from Decoder::open)
let _ = tiff::decoder::TiffHeader::parse(Cursor::new(data));
let mut decoder = match Decoder::open(Cursor::new(data)) {
Ok(d) => d,
Err(_) => return,
};
let mut limits = Limits::default();
limits.decoding_buffer_size = 512 * 1024;
limits.ifd_value_size = 64 * 1024;
limits.intermediate_buffer_size = 512 * 1024;
decoder = decoder.with_limits(limits);
// Probe first IFD (opened but image not yet parsed)
probe_directory(&mut decoder);
// Linear IFD iteration via next_directory — tolerates invalid
// intermediate IFDs that aren't decodable as images
let mut ifd_count = 1u32;
while decoder.more_images() && ifd_count < 8 {
if decoder.next_directory().is_err() {
break;
}
probe_directory(&mut decoder);
ifd_count += 1;
}
// Random-access traversal via seek (different code path: find_nth_ifd
// re-walks the IFD chain from the start each time)
for i in 0..ifd_count.min(4) as usize {
if decoder.seek_to_directory(i).is_ok() {
probe_directory(&mut decoder);
}
// seek_to_image eagerly parses the directory as an Image
let _ = decoder.seek_to_image(i);
}
});
fn probe_directory<R: Read + Seek>(decoder: &mut Decoder<R>) {
// Tag iteration — exercises value parsing for every tag in the IFD
for result in decoder.current_ifd().tag_iter() {
let _ = result;
}
// SubIFD / EXIF / GPS directory traversal — exercises read_directory
// with potentially adversarial offsets
if let Ok(Some(val)) = decoder.current_ifd().find_tag(Tag::SubIfd) {
if let Ok(ptrs) = val.into_ifd_vec() {
for ptr in ptrs.into_iter().take(4) {
let _ = decoder.read_directory(ptr);
}
}
}
for tag in [Tag::ExifDirectory, Tag::GpsDirectory] {
if let Ok(Some(val)) = decoder.current_ifd().find_tag(tag) {
if let Ok(ptr) = val.into_ifd_pointer() {
let _ = decoder.read_directory(ptr);
}
}
}
// Image-level probing — dimensions() triggers ensure_image,
// which lazily parses the directory as an Image
if decoder.dimensions().is_ok() {
let _ = decoder.colortype();
let _ = decoder.color_map();
let _ = decoder.get_chunk_type();
let _ = decoder.chunk_dimensions();
let _ = decoder.strip_count();
let _ = decoder.tile_count();
let _ = decoder.image_buffer_layout();
let _ = decoder.image_chunk_buffer_layout(0);
// Chunk reads at boundaries — first and last exercise
// different offset calculations
let _ = decoder.read_chunk(0);
let chunk_count = decoder
.strip_count()
.or_else(|_| decoder.tile_count())
.unwrap_or(0);
if chunk_count > 1 {
let _ = decoder.read_chunk(chunk_count - 1);
}
// Full image decode
let _ = decoder.read_image();
}
}
-31
View File
@@ -1,31 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let mut decoder = if let Ok(d) = tiff::decoder::Decoder::new(std::io::Cursor::new(data)) {
d
} else {
return;
};
let mut limits = tiff::decoder::Limits::default();
limits.decoding_buffer_size = 1_000_000;
limits.ifd_value_size = 1_000_000;
limits.intermediate_buffer_size = 1_000_000;
decoder = decoder.with_limits(limits);
loop {
if let Err(_) = decoder.read_image() {
break;
}
if !decoder.more_images() {
break;
}
if let Err(_) = decoder.next_image() {
break;
}
}
});
+139
View File
@@ -0,0 +1,139 @@
# === File headers ===
# Classic TIFF (LE, BE)
"\x49\x49\x2A\x00"
"\x4D\x4D\x00\x2A"
# BigTIFF (LE, BE) — version 43, 8-byte offsets, different parsing paths
"\x49\x49\x2B\x00\x08\x00\x00\x00"
"\x4D\x4D\x00\x2B\x00\x08\x00\x00"
# === IFD field type codes (2-byte LE) ===
# Decoder switches on these; wrong types exercise error paths
"\x01\x00"
"\x02\x00"
"\x03\x00"
"\x04\x00"
"\x05\x00"
"\x06\x00"
"\x07\x00"
"\x08\x00"
"\x09\x00"
"\x0A\x00"
"\x0B\x00"
"\x0C\x00"
"\x0D\x00"
"\x10\x00"
"\x11\x00"
"\x12\x00"
# === IFD tag numbers (2-byte LE) ===
# 256=ImageWidth 257=ImageLength 258=BitsPerSample
"\x00\x01"
"\x01\x01"
"\x02\x01"
# 259=Compression 262=PhotometricInterpretation
"\x03\x01"
"\x06\x01"
# 273=StripOffsets 274=Orientation
"\x11\x01"
"\x12\x01"
# 277=SamplesPerPixel 278=RowsPerStrip 279=StripByteCounts
"\x15\x01"
"\x16\x01"
"\x17\x01"
# 282=XResolution 283=YResolution 284=PlanarConfiguration
"\x1A\x01"
"\x1B\x01"
"\x1C\x01"
# 317=Predictor 320=ColorMap
"\x3D\x01"
"\x40\x01"
# 322=TileWidth 323=TileLength 324=TileOffsets 325=TileByteCounts
"\x42\x01"
"\x43\x01"
"\x44\x01"
"\x45\x01"
# 330=SubIfd 338=ExtraSamples 339=SampleFormat
"\x4A\x01"
"\x52\x01"
"\x53\x01"
# 347=JPEGTables 530=ChromaSubsampling
"\x5B\x01"
"\x12\x02"
# 34665=ExifDirectory 34675=IccProfile 34853=GpsDirectory
"\x69\x87"
"\x73\x87"
"\x25\x88"
# === IFD tag numbers (2-byte BE) ===
"\x01\x00"
"\x01\x01"
"\x01\x02"
"\x01\x03"
"\x01\x06"
"\x01\x11"
"\x01\x12"
"\x01\x15"
"\x01\x16"
"\x01\x17"
"\x01\x1A"
"\x01\x1B"
"\x01\x1C"
"\x01\x3D"
"\x01\x40"
"\x01\x42"
"\x01\x43"
"\x01\x44"
"\x01\x45"
"\x01\x4A"
"\x01\x52"
"\x01\x53"
"\x01\x5B"
"\x02\x12"
"\x87\x69"
"\x87\x73"
"\x88\x25"
# === Compression codes (4-byte LE inline SHORT values) ===
# These are the values stored in the IFD entry for tag 259
# 1=None 2=Huffman 3=Fax3 4=Fax4 5=LZW
"\x01\x00\x00\x00"
"\x02\x00\x00\x00"
"\x03\x00\x00\x00"
"\x04\x00\x00\x00"
"\x05\x00\x00\x00"
# 6=OldJPEG 7=ModernJPEG 8=Deflate
"\x06\x00\x00\x00"
"\x07\x00\x00\x00"
"\x08\x00\x00\x00"
# 0x8005=PackBits 0x80B2=OldDeflate
"\x05\x80\x00\x00"
"\xB2\x80\x00\x00"
# === Useful small values (4-byte LE) ===
# BitsPerSample=16, =32 (8 already covered as Deflate compression above)
"\x10\x00\x00\x00"
"\x20\x00\x00\x00"
# === Minimal IFD entries (12-byte LE: tag + type SHORT + count 1 + value) ===
# These let the fuzzer splice complete valid entries into an IFD
# ImageWidth=1
"\x00\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00"
# ImageLength=1
"\x01\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00"
# BitsPerSample=8
"\x02\x01\x03\x00\x01\x00\x00\x00\x08\x00\x00\x00"
# Compression=None
"\x03\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00"
# PhotometricInterpretation=BlackIsZero
"\x06\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00"
# SamplesPerPixel=1
"\x15\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00"
# RowsPerStrip=1
"\x16\x01\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00"
+27 -17
View File
@@ -583,19 +583,19 @@ impl Entry {
match self.type_ {
Type::BYTE | Type::UNDEFINED => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(bytes.iter().copied().map(Byte))
})
}
Type::SBYTE => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(bytes.iter().copied().map(|v| SignedByte(v as i8)))
})
}
Type::SHORT => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
@@ -605,7 +605,7 @@ impl Entry {
})
}
Type::SSHORT => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
@@ -615,7 +615,7 @@ impl Entry {
})
}
Type::LONG => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
@@ -625,7 +625,7 @@ impl Entry {
})
}
Type::SLONG => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
@@ -635,7 +635,7 @@ impl Entry {
})
}
Type::FLOAT => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
@@ -645,7 +645,7 @@ impl Entry {
})
}
Type::DOUBLE => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
@@ -655,7 +655,7 @@ impl Entry {
})
}
Type::RATIONAL => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(bytes.chunks_exact(8).map(|ch| {
Rational(
@@ -666,7 +666,7 @@ impl Entry {
})
}
Type::SRATIONAL => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(bytes.chunks_exact(8).map(|ch| {
SRational(
@@ -677,7 +677,7 @@ impl Entry {
})
}
Type::LONG8 => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
@@ -687,7 +687,7 @@ impl Entry {
})
}
Type::SLONG8 => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
@@ -697,7 +697,7 @@ impl Entry {
})
}
Type::IFD => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
@@ -707,7 +707,7 @@ impl Entry {
})
}
Type::IFD8 => {
v = Self::vec_with_capacity(self.count, limits)?;
v = Self::vec_with_capacity(self.count, self.type_, limits)?;
self.decode_values(self.count, self.type_, reader, |bytes| {
v.extend(
bytes
@@ -719,8 +719,8 @@ impl Entry {
Type::ASCII => {
let n = usize::try_from(self.count)?;
if n > limits.decoding_buffer_size {
return Err(dbg!(TiffError::LimitsExceeded));
if n > limits.ifd_value_size {
return Err(TiffError::LimitsExceeded);
}
let mut out = vec![0; n];
@@ -846,10 +846,20 @@ impl Entry {
fn vec_with_capacity(
value_count: u64,
type_: Type,
limits: &super::Limits,
) -> Result<Vec<Value>, TiffError> {
let value_count = usize::try_from(value_count)?;
// Check raw file bytes against ifd_value_size.
if !type_
.value_bytes(value_count)
.and_then(|b| usize::try_from(b).map_err(|_| TiffError::LimitsExceeded))
.is_ok_and(|raw_len| raw_len <= limits.ifd_value_size)
{
return Err(TiffError::LimitsExceeded);
}
// Check in-memory Value representation against decoding_buffer_size.
let value_count = usize::try_from(value_count)?;
if value_count > limits.decoding_buffer_size / mem::size_of::<Value>() {
return Err(TiffError::LimitsExceeded);
}
+20 -5
View File
@@ -454,8 +454,9 @@ impl Image {
match tag_reader.find_tag(Tag::ColorMap)? {
Some(v) => {
let map = v.into_u16_vec()?;
let expected_len = 3usize
.checked_mul(1usize << bits_per_sample)
let expected_len = 1usize
.checked_shl(bits_per_sample as u32)
.and_then(|n| n.checked_mul(3))
.ok_or(TiffError::LimitsExceeded)?;
if map.len() != expected_len {
return Err(TiffError::FormatError(
@@ -1009,6 +1010,7 @@ impl Image {
buf: &mut [u8],
layout: &ReadoutLayout,
chunk_index: u32,
scratch: &mut Vec<u8>,
) -> TiffResult<()> {
let ValueReader {
reader,
@@ -1191,6 +1193,7 @@ impl Image {
samples,
ByteOrder::native(),
predictor,
scratch,
);
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
@@ -1209,6 +1212,7 @@ impl Image {
samples,
decompressed_byte_order,
predictor,
scratch,
);
}
@@ -1224,9 +1228,18 @@ impl Image {
let row = &mut row[..data_row_bytes];
match color_type.bit_depth() {
16 => predict_f16(&mut encoded, row, samples),
32 => predict_f32(&mut encoded, row, samples),
64 => predict_f64(&mut encoded, row, samples),
16 => {
let (out, _) = row.as_chunks_mut::<2>();
predict_f16(&mut encoded, out, samples);
}
32 => {
let (out, _) = row.as_chunks_mut::<4>();
predict_f32(&mut encoded, out, samples);
}
64 => {
let (out, _) = row.as_chunks_mut::<8>();
predict_f64(&mut encoded, out, samples);
}
_ => unreachable!(),
}
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
@@ -1255,6 +1268,7 @@ impl Image {
samples,
decompressed_byte_order,
predictor,
scratch,
);
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
@@ -1296,6 +1310,7 @@ impl Image {
samples,
decompressed_byte_order,
predictor,
scratch,
);
if photometric_interpretation == PhotometricInterpretation::WhiteIsZero {
+9 -5
View File
@@ -87,15 +87,12 @@ fn packbits_decode<R: Read, const N: usize>(
reader: &mut R,
buf: &mut [[u8; N]],
) -> std::io::Result<()> {
let expected = buf.len();
for offset in (0..N).rev() {
let mut i = 0;
let mut code = [0u8; 1];
loop {
let Some(pixels) = buf.get_mut(i..) else {
break;
};
while let Some(pixels) = buf.get_mut(i..) {
if pixels.is_empty() {
break;
}
@@ -124,6 +121,13 @@ fn packbits_decode<R: Read, const N: usize>(
i += usize::from(repeat);
}
}
if i < expected {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"PackBits RLE decoded fewer pixels than expected",
));
}
}
Ok(())
+194 -53
View File
@@ -254,6 +254,9 @@ pub struct Decoder<R> {
/// Map from the ifd into the `ifd_offsets` ordered list.
seen_ifds: cycles::IfdCycles,
image: Option<Image>,
/// Reusable scratch buffer for the floating-point predictor. Stored here so that the
/// allocation persists across strips/tiles and images, avoiding per-row heap allocation.
scratch: Vec<u8>,
}
/// All the information needed to read and interpret byte slices from the underlying file, i.e. to
@@ -273,13 +276,37 @@ pub struct IfdDecoder<'lt> {
inner: tag_reader::TagReader<'lt, dyn tag_reader::EntryDecoder + 'lt>,
}
fn rev_hpredict_nsamp(buf: &mut [u8], bit_depth: u8, samples: u16) {
fn one_byte_predict<const N: usize>(buf: &mut [u8]) {
for i in N..buf.len() {
buf[i] = buf[i].wrapping_add(buf[i - N]);
/// Byte-level prefix sum with compile-time stride. Each element is the wrapping sum of itself and
/// the element N positions earlier. For N >= 2, the N interleaved channels are independent streams.
///
/// For N=3 (RGB) and N=4 (RGBA), we use an explicit `[u8; N]` accumulator so LLVM sees each
/// channel as an independent scalar chain and can unroll effectively. Without this, LLVM emits a
/// scalar byte-at-a-time loop for N=3 because 3 doesn't map cleanly to SIMD widths.
fn one_byte_predict<const N: usize>(buf: &mut [u8]) {
if N == 0 || buf.len() <= N {
return;
}
// Copy the first N bytes as the initial accumulator state.
let mut acc = [0u8; N];
acc.copy_from_slice(&buf[..N]);
// Process N bytes per iteration. Each channel's prefix sum is independent.
let mut chunks = buf[N..].chunks_exact_mut(N);
for chunk in &mut chunks {
for c in 0..N {
acc[c] = chunk[c].wrapping_add(acc[c]);
chunk[c] = acc[c];
}
}
// Handle trailing bytes (when buf.len() - N is not a multiple of N).
for (c, byte) in chunks.into_remainder().iter_mut().enumerate() {
*byte = byte.wrapping_add(acc[c]);
}
}
fn rev_hpredict_nsamp(buf: &mut [u8], bit_depth: u8, samples: u16) {
fn two_bytes_predict<const N: usize>(buf: &mut [u8]) {
for i in (2 * N..buf.len()).step_by(2) {
let v = u16::from_ne_bytes(buf[i..][..2].try_into().unwrap());
@@ -364,65 +391,145 @@ fn rev_hpredict_nsamp(buf: &mut [u8], bit_depth: u8, samples: u16) {
}
}
fn predict_f32(input: &mut [u8], output: &mut [u8], samples: u16) {
let samples = usize::from(samples);
/// Undo the floating-point predictor: byte-level prefix sum, then de-interleave byte planes back
/// into native-endian samples.
///
/// The TIFF float predictor splits each N-byte sample into N separate byte planes within the row,
/// then delta-encodes each plane. Decoding reverses this: prefix-sum to undo the deltas, then
/// transpose the N planes back into packed samples.
fn predict_f32(input: &mut [u8], output: &mut [[u8; 4]], samples: u16) {
one_byte_predict_generic(input, usize::from(samples));
deinterleave_f32_planes(input, output);
}
for i in samples..input.len() {
input[i] = input[i].wrapping_add(input[i - samples]);
}
fn predict_f16(input: &mut [u8], output: &mut [[u8; 2]], samples: u16) {
one_byte_predict_generic(input, usize::from(samples));
deinterleave_f16_planes(input, output);
}
for (i, chunk) in output.chunks_mut(4).enumerate() {
chunk.copy_from_slice(&u32::to_ne_bytes(u32::from_be_bytes([
input[i],
input[input.len() / 4 + i],
input[input.len() / 4 * 2 + i],
input[input.len() / 4 * 3 + i],
])));
fn predict_f64(input: &mut [u8], output: &mut [[u8; 8]], samples: u16) {
one_byte_predict_generic(input, usize::from(samples));
deinterleave_f64_planes(input, output);
}
/// Byte-level prefix sum: `buf[i] += buf[i - stride]` for all `i >= stride`.
///
/// Same operation as `one_byte_predict<N>` but dispatched at runtime. The most common float
/// channel counts (1, 3, 4) get dedicated loops so LLVM can see the independent channel streams
/// and vectorize each one separately.
fn one_byte_predict_generic(buf: &mut [u8], stride: usize) {
match stride {
1 => one_byte_predict::<1>(buf),
2 => one_byte_predict::<2>(buf),
3 => one_byte_predict::<3>(buf),
4 => one_byte_predict::<4>(buf),
_ => {
for i in stride..buf.len() {
buf[i] = buf[i].wrapping_add(buf[i - stride]);
}
}
}
}
fn predict_f16(input: &mut [u8], output: &mut [u8], samples: u16) {
let samples = usize::from(samples);
/// Transpose byte-planes back into packed f16/f32/f64 samples with big-endian to native conversion.
///
/// Input layout: `[plane0: n bytes][plane1: n bytes]...[planeP: n bytes]` where each plane holds
/// one byte position of each sample. Output: packed native-endian samples.
///
/// Each function splits the input into equal-sized plane slices, then gathers one byte per plane
/// per sample. Using `from_be_bytes` for endian conversion lets LLVM recognise the byte-swap idiom
/// and emit efficient code (bswap or movbe).
fn deinterleave_f32_planes(input: &[u8], output: &mut [[u8; 4]]) {
let n = output.len();
if n == 0 || input.len() < n * 4 {
return;
}
let (p0, rest) = input.split_at(n);
let (p1, rest) = rest.split_at(n);
let (p2, p3) = rest.split_at(n);
for i in samples..input.len() {
input[i] = input[i].wrapping_add(input[i - samples]);
// Batch 4 samples per iteration. Fixed-size array slicing eliminates bounds checks.
let (out_chunks, out_rest) = output.as_chunks_mut::<4>();
let (p0_chunks, _) = p0.as_chunks::<4>();
let (p1_chunks, _) = p1.as_chunks::<4>();
let (p2_chunks, _) = p2.as_chunks::<4>();
let (p3_chunks, _) = p3.as_chunks::<4>();
for ((((out, b0), b1), b2), b3) in out_chunks
.iter_mut()
.zip(p0_chunks)
.zip(p1_chunks)
.zip(p2_chunks)
.zip(p3_chunks)
{
let s0 = u32::from_be_bytes([b0[0], b1[0], b2[0], b3[0]]).to_ne_bytes();
let s1 = u32::from_be_bytes([b0[1], b1[1], b2[1], b3[1]]).to_ne_bytes();
let s2 = u32::from_be_bytes([b0[2], b1[2], b2[2], b3[2]]).to_ne_bytes();
let s3 = u32::from_be_bytes([b0[3], b1[3], b2[3], b3[3]]).to_ne_bytes();
out[0] = s0;
out[1] = s1;
out[2] = s2;
out[3] = s3;
}
for (i, chunk) in output.chunks_mut(2).enumerate() {
chunk.copy_from_slice(&u16::to_ne_bytes(u16::from_be_bytes([
input[i],
input[input.len() / 2 + i],
])));
// Remaining 0–3 samples.
let base = out_chunks.len() * 4;
for (j, out) in out_rest.iter_mut().enumerate() {
*out = u32::from_be_bytes([p0[base + j], p1[base + j], p2[base + j], p3[base + j]])
.to_ne_bytes();
}
}
fn predict_f64(input: &mut [u8], output: &mut [u8], samples: u16) {
let samples = usize::from(samples);
for i in samples..input.len() {
input[i] = input[i].wrapping_add(input[i - samples]);
fn deinterleave_f16_planes(input: &[u8], output: &mut [[u8; 2]]) {
let n = output.len();
if n == 0 || input.len() < n * 2 {
return;
}
let (p0, p1) = input.split_at(n);
for (i, chunk) in output.chunks_mut(8).enumerate() {
chunk.copy_from_slice(&u64::to_ne_bytes(u64::from_be_bytes([
input[i],
input[input.len() / 8 + i],
input[input.len() / 8 * 2 + i],
input[input.len() / 8 * 3 + i],
input[input.len() / 8 * 4 + i],
input[input.len() / 8 * 5 + i],
input[input.len() / 8 * 6 + i],
input[input.len() / 8 * 7 + i],
])));
for (out, (b0, b1)) in output.iter_mut().zip(p0.iter().zip(p1)) {
*out = u16::from_be_bytes([*b0, *b1]).to_ne_bytes();
}
}
fn deinterleave_f64_planes(input: &[u8], output: &mut [[u8; 8]]) {
let n = output.len();
if n == 0 || input.len() < n * 8 {
return;
}
let (p0, rest) = input.split_at(n);
let (p1, rest) = rest.split_at(n);
let (p2, rest) = rest.split_at(n);
let (p3, rest) = rest.split_at(n);
let (p4, rest) = rest.split_at(n);
let (p5, rest) = rest.split_at(n);
let (p6, p7) = rest.split_at(n);
for (out, (((((((b0, b1), b2), b3), b4), b5), b6), b7)) in output.iter_mut().zip(
p0.iter()
.zip(p1)
.zip(p2)
.zip(p3)
.zip(p4)
.zip(p5)
.zip(p6)
.zip(p7),
) {
*out = u64::from_be_bytes([*b0, *b1, *b2, *b3, *b4, *b5, *b6, *b7]).to_ne_bytes();
}
}
/// Fix byte order and apply the inverse predictor for one row of decoded data.
///
/// `scratch` is a reusable buffer for the floating-point predictor (which needs a working copy of
/// the input). Callers should pass the same buffer across rows to avoid per-row heap allocation.
fn fix_endianness_and_predict(
buf: &mut [u8],
bit_depth: u8,
samples: u16,
byte_order: ByteOrder,
predictor: Predictor,
scratch: &mut Vec<u8>,
) {
match predictor {
Predictor::None => {
@@ -433,11 +540,26 @@ fn fix_endianness_and_predict(
rev_hpredict_nsamp(buf, bit_depth, samples);
}
Predictor::FloatingPoint => {
let mut buffer_copy = buf.to_vec();
// The float predictor reads from scattered byte planes, so it needs a separate input
// buffer.
scratch.resize(buf.len(), 0);
scratch.copy_from_slice(buf);
match bit_depth {
16 => predict_f16(&mut buffer_copy, buf, samples),
32 => predict_f32(&mut buffer_copy, buf, samples),
64 => predict_f64(&mut buffer_copy, buf, samples),
16 => {
let (output, rest) = buf.as_chunks_mut::<2>();
debug_assert!(rest.is_empty(), "f16 row buffer not a multiple of 2 bytes");
predict_f16(scratch, output, samples);
}
32 => {
let (output, rest) = buf.as_chunks_mut::<4>();
debug_assert!(rest.is_empty(), "f32 row buffer not a multiple of 4 bytes");
predict_f32(scratch, output, samples);
}
64 => {
let (output, rest) = buf.as_chunks_mut::<8>();
debug_assert!(rest.is_empty(), "f64 row buffer not a multiple of 8 bytes");
predict_f64(scratch, output, samples);
}
_ => unreachable!("Caller should have validated arguments. Please file a bug."),
}
}
@@ -627,6 +749,7 @@ impl TiffHeader {
},
seen_ifds: cycles::IfdCycles::new(),
image: None,
scratch: Vec::new(),
}
}
}
@@ -712,13 +835,23 @@ impl<R: Read + Seek> Decoder<R> {
// FIXME: this tuple would be a good candidate for a proxy, like IfdDecoder. That has been
// requested in some form or another in a number of issues.
#[expect(clippy::unnecessary_unwrap)] // Lifetimes would overlap due to return.
fn ensure_image_and_reader(&mut self) -> TiffResult<(&mut Image, &mut ValueReader<R>)> {
fn ensure_image_and_reader(
&mut self,
) -> TiffResult<(&mut Image, &mut ValueReader<R>, &mut Vec<u8>)> {
if self.image.is_some() {
return Ok((self.image.as_mut().unwrap(), &mut self.value_reader));
return Ok((
self.image.as_mut().unwrap(),
&mut self.value_reader,
&mut self.scratch,
));
}
let image = Image::from_reader(&mut self.value_reader, &self.directory)?;
Ok((self.image.insert(image), &mut self.value_reader))
Ok((
self.image.insert(image),
&mut self.value_reader,
&mut self.scratch,
))
}
fn ensure_image(&mut self) -> TiffResult<&mut Image> {
@@ -848,6 +981,7 @@ impl<R: Read + Seek> Decoder<R> {
directory: self.directory.clone(),
current_ifd_pointer: self.current_ifd_pointer,
image: core::mem::take(&mut self.image),
scratch: core::mem::take(&mut self.scratch),
}
}
@@ -1323,10 +1457,10 @@ impl<R: Read + Seek> Decoder<R> {
chunk_index: u32,
layout: &image::ReadoutLayout,
) -> TiffResult<()> {
let (image, value_reader) = self.ensure_image_and_reader()?;
let (image, value_reader, scratch) = self.ensure_image_and_reader()?;
let offset = image.chunk_file_range(chunk_index)?.0;
value_reader.reader.goto_offset(offset)?;
image.expand_chunk(value_reader, buffer, layout, chunk_index)?;
image.expand_chunk(value_reader, buffer, layout, chunk_index, scratch)?;
Ok(())
}
@@ -1478,7 +1612,7 @@ impl<R: Read + Seek> Decoder<R> {
slice: TiffCodingUnit,
buffer: &mut [u8],
) -> TiffResult<()> {
let (image, value_reader) = self.ensure_image_and_reader()?;
let (image, value_reader, scratch) = self.ensure_image_and_reader()?;
let (width, height) = image.chunk_data_dimensions(slice.0)?;
let readout = image.readout_for_size(width, height)?;
@@ -1505,7 +1639,13 @@ impl<R: Read + Seek> Decoder<R> {
let chunk = slice.0 + idx as u32 * readout.chunks_per_plane;
let offset = image.chunk_offsets[chunk as usize];
value_reader.reader.goto_offset(offset)?;
image.expand_chunk(value_reader, &mut buffer[plane_offset..], readout, chunk)?;
image.expand_chunk(
value_reader,
&mut buffer[plane_offset..],
readout,
chunk,
scratch,
)?;
}
Ok(())
@@ -1676,7 +1816,7 @@ impl<R: Read + Seek> Decoder<R> {
/// Returns an error if the buffer fits less than one plane. In particular, for non-planar
/// images returns an error if the buffer does not fit the required size.
pub fn read_image_bytes(&mut self, buffer: &mut [u8]) -> TiffResult<()> {
let (image, value_reader) = self.ensure_image_and_reader()?;
let (image, value_reader, scratch) = self.ensure_image_and_reader()?;
let readout = image.readout_for_image()?;
let ref layout @ image::PlaneLayout {
@@ -1709,6 +1849,7 @@ impl<R: Read + Seek> Decoder<R> {
&mut buffer[plane_offset..][buffer_offset..],
readout,
chunk,
scratch,
)?;
}
}
+5 -2
View File
@@ -448,7 +448,7 @@ impl Group3Reader {
) -> Option<u16> {
let mut sum = 0u16;
while let Some(n) = decoder(reader) {
sum += n;
sum = sum.checked_add(n)?;
if n < 64 {
return Some(sum);
}
@@ -475,7 +475,10 @@ impl Read for Group3Reader {
};
match run {
Some(len) => {
a0 += len;
a0 = match a0.checked_add(len) {
Some(v) => v,
None => break, // overflow = corrupt data
};
transitions.push(a0);
is_white = !is_white;
}