close
Skip to content

Decoding a tiled + PlanarConfiguration=2 TIFF panics in Image::expand_chunk (assertion at src/decoder/image.rs:1153) #403

Description

@kleinicke

Affected version

Summary

Any TIFF that is tiled (TileWidth/TileLength present) and uses PlanarConfiguration = 2 (separate planes) panics while decoding, as soon as the image has more than one tile row or column in a plane (i.e. an "edge" tile is not the very first tile of the whole file). Striped planar images decode fine — only the tiled+planar combination is affected.

Panic:

thread 'main' panicked at src/decoder/image.rs:1153:9:
assertion failed: buf.len() >= layout.row_stride * (data_dims.1 as usize - 1) + data_row_bytes

Root cause

TileAttributes::get_padding (src/decoder/image.rs:45-61) computes a tile's row/column purely from its raw tile index:

pub fn get_padding(&self, tile: usize) -> (usize, usize) {
    let row = tile / self.tiles_across();
    let column = tile % self.tiles_across();
    ...
}

tiles_across()/tiles_down() (lines 33-38) describe the grid of a single plane. But the caller, chunk_data_dimensions (src/decoder/image.rs:1003-1011, specifically the call at line 1005), is invoked with a global chunk index that already has the per-plane offset baked in:

// src/decoder/mod.rs, Decoder::read_image_bytes (~line 1857-1866)
for chunk in 0..readout.chunks_per_plane {
    ...
    for (idx, &plane_offset) in plane_offsets[..used_plane_offsets].iter().enumerate() {
        let chunk = chunk + idx as u32 * readout.chunks_per_plane; // <-- global index across planes
        ...
        image.expand_chunk(value_reader, ..., readout, chunk, scratch)?;
    }
}

For plane idx >= 1, chunk is offset by idx * chunks_per_plane, so get_padding divides/mods a value outside the single-plane grid it was designed for. E.g. for a 128x72 image with 32x32 tiles (4x3 tiles/plane = 12 tiles/plane) and chunk 32 (plane 2, tile row 2 = last row within that plane):

row    = 32 / tiles_across() = 32 / 4 = 8   // should be 32 % 12 / 4 = 2
column = 32 % tiles_across() = 32 % 4 = 0   // should be (32 % 12) % 4 = 0 (ok by luck)

row == 8 never equals tiles_down() - 1 == 2, so padding_down is silently computed as 0 — the bottom-edge tile is not recognized as such, and chunk_data_dimensions returns the full, unclamped tile_length (e.g. 32) instead of the true remaining row count (e.g. 8).

expand_chunk (src/decoder/image.rs:1153) then asserts the output buffer is large enough for that (wrongly inflated) data_dims.1, and since the real remaining space in the output buffer was correctly sized for the true (smaller) edge-tile height, the assertion fails.

get_padding needs to operate on the chunk index modulo chunks_per_plane (or equivalently the caller should pass a per-plane-relative index), not the raw global index that includes the plane offset.

Because PlanarConfiguration::Chunky never offsets the chunk index by plane (chunks_per_plane == total tile count, loop always uses idx == 0), chunky tiled images are unaffected — this is specific to the planar + tiled combination. Striped planar is also unaffected because chunk_data_dimensions's ChunkType::Strip arm (lines 987-1002) derives padding via chunk_index % strips_per_band, which already reduces modulo the per-plane strip count.

Minimal synthetic reproducer (no attachments needed)

Generate a tiny fully-synthetic tiled+planar TIFF with Python/tifffile (any tile size smaller than the image in both dimensions triggers it, since it just needs one edge tile in plane > 0):

import numpy as np
import tifffile

# 24x24 RGB, 16x16 tiles -> 2x2 tile grid per plane (edge tiles on
# right column and bottom row), PlanarConfiguration=2 (separate planes).
data = np.zeros((3, 24, 24), dtype=np.uint8)
for c in range(3):
    data[c] = (np.arange(24 * 24).reshape(24, 24) + c * 7) % 256

tifffile.imwrite(
    "tiled_planar_repro.tif",
    data,
    tile=(16, 16),
    planarconfig="separate",
    photometric="rgb",
    compression=None,
)

Decode with upstream's own example:

// examples/decode.rs (unmodified)
let mut reader = tiff::decoder::Decoder::open(std::io::BufReader::new(
    std::fs::File::open("tiled_planar_repro.tif")?,
))?;
reader.next_directory()?;
let mut data = tiff::decoder::DecodingSampleBuffer::I8(vec![]);
reader.read_image_to_buffer(&mut data)?; // panics
$ cargo run --example decode -- tiled_planar_repro.tif
thread 'main' panicked at src/decoder/image.rs:1153:9:
assertion failed: buf.len() >= layout.row_stride * (data_dims.1 as usize - 1) + data_row_bytes
stack backtrace:
   ...
   3: tiff::decoder::image::Image::expand_chunk
             at ./src/decoder/image.rs:1153:9
   4: tiff::decoder::Decoder<R>::read_image_bytes
             at ./src/decoder/mod.rs:1868:23
   5: tiff::decoder::Decoder<R>::read_image_to_buffer
             at ./src/decoder/mod.rs:1824:14

Also independently reproduced with two real-world 128x72 RGB / 32x32-tile files (one LZW-compressed, one uncompressed) — same panic, same line, confirming compression is irrelevant.

Notes for the fix

  • TileAttributes::get_padding should take (or the caller should compute) a chunk index relative to the current plane, e.g. chunk_index % chunks_per_plane, before doing / tiles_across() and % tiles_across().
  • Given the existing // TODO: Should these return errors instead? comment directly above the assertions at src/decoder/image.rs:1151-1153, it may also be worth turning these into proper TiffErrors rather than panicking, as a defense-in-depth measure for any other buffer-sizing miscalculation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions