feat: decode the HDF5 scale-offset filter (integer variant)

Implements decompression for the scale-offset filter (id 6), integer mode
(H5Z_SO_INT) — previously returned UnsupportedFilter(6). The on-disk format was
reverse-engineered against HDF5 2.0 / h5py and verified across signed/unsigned
element sizes, negative minima, multi-chunk datasets and fill-value handling:

  minbits (u32 LE) | 0x08 | minval (8 bytes LE) | 8 reserved bytes |
  MSB-first packed codes (nelmts * minbits bits)

Each code is `value - minval`; the all-ones code is reserved for the (defined)
fill value. The floating-point variants (D-scale/E-scale) use a different
algorithm and remain reported as unsupported.

Validated end-to-end (a 200-element chunked scale-offset dataset, plus negative
and unsigned datasets, now decode to the exact h5py values). Adds h5py-free unit
tests using real captured compressed chunks.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-06-03 12:18:42 +00:00
co-authored by Claude Opus 4.8
parent 19ca662975
commit 6ab42c2f07
+208 -1
View File
@@ -8,7 +8,8 @@ use alloc::{vec, vec::Vec};
use crate::error::FormatError; use crate::error::FormatError;
use crate::filter_pipeline::{ use crate::filter_pipeline::{
FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_SHUFFLE, FILTER_ZSTD, FilterPipeline, FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_SCALEOFFSET, FILTER_SHUFFLE, FILTER_ZSTD,
FilterPipeline,
}; };
/// Apply a filter pipeline to decompress a chunk. /// Apply a filter pipeline to decompress a chunk.
@@ -28,6 +29,7 @@ pub fn decompress_chunk(
FILTER_LZ4 => lz4_decompress(&data)?, FILTER_LZ4 => lz4_decompress(&data)?,
FILTER_ZSTD => zstd_decompress(&data)?, FILTER_ZSTD => zstd_decompress(&data)?,
FILTER_FLETCHER32 => fletcher32_verify(&data)?, FILTER_FLETCHER32 => fletcher32_verify(&data)?,
FILTER_SCALEOFFSET => scaleoffset_decompress(&data, &filter.client_data)?,
other => return Err(FormatError::UnsupportedFilter(other)), other => return Err(FormatError::UnsupportedFilter(other)),
}; };
} }
@@ -64,6 +66,154 @@ pub fn compress_chunk(
Ok(result) Ok(result)
} }
/// Decode the HDF5 scale-offset filter (id 6), integer variant (`H5Z_SO_INT`).
///
/// Layout of the compressed buffer (reverse-engineered against HDF5 2.0 and
/// verified across signed/unsigned sizes, fill values and chunk sizes):
/// `minbits` (u32 LE) · `minval_width` (1 byte, = 8) · `minval`
/// (`minval_width` bytes LE) · 8 reserved bytes · MSB-first packed codes
/// (`nelmts * minbits` bits). Each element's code is `value - minval`; the
/// all-ones code is reserved for the (defined) fill value.
///
/// `cd` is the filter client data (`H5Zscaleoffset.c` parameter block):
/// `[0]`=scale type (2 = integer), `[2]`=element count, `[4]`=element size,
/// `[5]`=signed flag, `[6]`=byte order (1 = big-endian), `[7]`=fill defined,
/// `[8..]`=fill value. The floating-point variants (D-scale/E-scale) use a
/// different algorithm and are reported as unsupported.
fn scaleoffset_decompress(data: &[u8], cd: &[u32]) -> Result<Vec<u8>, FormatError> {
const H5Z_SO_INT: u32 = 2;
if cd.len() < 8 {
return Err(FormatError::ChunkedReadError(
"scale-offset: missing filter client data".into(),
));
}
if cd[0] != H5Z_SO_INT {
// Floating-point scale-offset uses a different (D/E-scale) algorithm.
return Err(FormatError::UnsupportedFilter(FILTER_SCALEOFFSET));
}
let nelmts = cd[2] as usize;
let elem_size = cd[4] as usize;
if elem_size == 0 || elem_size > 8 {
return Err(FormatError::ChunkedReadError(
"scale-offset: unsupported element size".into(),
));
}
let signed = cd[5] == 1;
let big_endian = cd[6] == 1;
let fill_defined = cd[7] == 1;
let fill_value: i64 = if fill_defined {
let lo = *cd.get(8).unwrap_or(&0) as u64;
let hi = *cd.get(9).unwrap_or(&0) as u64;
sign_extend(lo | (hi << 32), elem_size, signed)
} else {
0
};
if data.len() < 5 {
return Err(FormatError::ChunkedReadError(
"scale-offset: truncated header".into(),
));
}
let minbits = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
let minval_width = data[4] as usize;
let minval_end = 5 + minval_width;
if data.len() < minval_end {
return Err(FormatError::ChunkedReadError(
"scale-offset: truncated minval".into(),
));
}
let minval = read_le_int(&data[5..minval_end], signed);
// minbits == 0: every element equals minval (no packed payload).
if minbits == 0 {
let vals = vec![minval; nelmts];
return Ok(write_elements(&vals, elem_size, big_endian));
}
if minbits > 64 {
return Err(FormatError::ChunkedReadError(
"scale-offset: implausible minbits".into(),
));
}
let packed_off = minval_end + 8;
let packed = data.get(packed_off..).ok_or_else(|| {
FormatError::ChunkedReadError("scale-offset: truncated packed data".into())
})?;
let need_bits = nelmts
.checked_mul(minbits)
.ok_or_else(|| FormatError::ChunkedReadError("scale-offset: size overflow".into()))?;
if packed.len() * 8 < need_bits {
return Err(FormatError::ChunkedReadError(
"scale-offset: packed data too short".into(),
));
}
let fill_code: u64 = if minbits >= 64 {
u64::MAX
} else {
(1u64 << minbits) - 1
};
let mut values = Vec::with_capacity(nelmts);
let mut bitpos = 0usize;
for _ in 0..nelmts {
let mut code: u64 = 0;
for _ in 0..minbits {
let bit = (packed[bitpos / 8] >> (7 - (bitpos % 8))) & 1;
code = (code << 1) | bit as u64;
bitpos += 1;
}
let v = if fill_defined && code == fill_code {
fill_value
} else {
minval.wrapping_add(code as i64)
};
values.push(v);
}
Ok(write_elements(&values, elem_size, big_endian))
}
/// Read a little-endian integer of `bytes.len()` bytes, sign-extending when
/// `signed`. Used for the scale-offset `minval` field.
fn read_le_int(bytes: &[u8], signed: bool) -> i64 {
let mut raw: u64 = 0;
for (i, &b) in bytes.iter().enumerate().take(8) {
raw |= (b as u64) << (i * 8);
}
sign_extend(raw, bytes.len().min(8), signed)
}
/// Interpret the low `size` bytes of `raw` as a (possibly signed) integer.
fn sign_extend(raw: u64, size: usize, signed: bool) -> i64 {
if size == 0 || size >= 8 {
return raw as i64;
}
let bits = size * 8;
let mask = (1u64 << bits) - 1;
let val = raw & mask;
if signed && (val & (1u64 << (bits - 1))) != 0 {
(val | !mask) as i64
} else {
val as i64
}
}
/// Serialize reconstructed integer values as `elem_size`-byte elements in the
/// requested byte order.
fn write_elements(values: &[i64], elem_size: usize, big_endian: bool) -> Vec<u8> {
let mut out = Vec::with_capacity(values.len() * elem_size);
for &v in values {
let le = (v as u64).to_le_bytes();
if big_endian {
for i in (0..elem_size).rev() {
out.push(le[i]);
}
} else {
out.extend_from_slice(&le[..elem_size]);
}
}
out
}
/// Decompress zlib-compressed data. /// Decompress zlib-compressed data.
#[cfg(feature = "deflate")] #[cfg(feature = "deflate")]
fn deflate_decompress(data: &[u8]) -> Result<Vec<u8>, FormatError> { fn deflate_decompress(data: &[u8]) -> Result<Vec<u8>, FormatError> {
@@ -707,4 +857,61 @@ mod tests {
let decompressed = decompress_chunk(&compressed, &pipeline, data.len(), 8).unwrap(); let decompressed = decompress_chunk(&compressed, &pipeline, data.len(), 8).unwrap();
assert_eq!(decompressed, data); assert_eq!(decompressed, data);
} }
// --- scale-offset (filter id 6) -------------------------------------------
// Inputs below are real compressed chunks + client data captured from
// h5py 3.16 / HDF5 2.0 (`scaleoffset=0`), so they guard the decoder against
// the reference implementation without needing h5py at test time.
fn i32_le(vals: &[i32]) -> Vec<u8> {
vals.iter().flat_map(|v| v.to_le_bytes()).collect()
}
#[test]
fn scaleoffset_int_basic() {
// i32 [0,1,2,3], chunk of 4, default fill 0 (element 0 is the fill).
let cd = [2u32, 0, 4, 0, 4, 1, 0, 1, 0];
let raw = [
0x02, 0x00, 0x00, 0x00, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc6, 0x00,
];
assert_eq!(scaleoffset_decompress(&raw, &cd).unwrap(), i32_le(&[0, 1, 2, 3]));
}
#[test]
fn scaleoffset_int_negative() {
// i32 [-5,-3,-1,0,2,4,7,9], chunk of 8, fill 0 (minval = -5).
let cd = [2u32, 0, 8, 0, 4, 1, 0, 1, 0];
let raw = [
0x04, 0x00, 0x00, 0x00, 0x08, 0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x4f, 0x79, 0xce, 0x00,
];
assert_eq!(
scaleoffset_decompress(&raw, &cd).unwrap(),
i32_le(&[-5, -3, -1, 0, 2, 4, 7, 9])
);
}
#[test]
fn scaleoffset_uint() {
// u32 [100..109], chunk of 10, unsigned (cd[5]==0), minval 100.
let cd = [2u32, 0, 10, 0, 4, 0, 0, 1, 0];
let raw = [
0x04, 0x00, 0x00, 0x00, 0x08, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0x00,
];
let expected: Vec<u8> = (100u32..110).flat_map(|v| v.to_le_bytes()).collect();
assert_eq!(scaleoffset_decompress(&raw, &cd).unwrap(), expected);
}
#[test]
fn scaleoffset_float_variant_unsupported() {
// scale_type 0 = float D-scale — a different algorithm, must be rejected.
let cd = [0u32, 3, 50, 1, 4, 0, 0, 1, 0];
let raw = [0u8; 24];
assert!(matches!(
scaleoffset_decompress(&raw, &cd),
Err(FormatError::UnsupportedFilter(FILTER_SCALEOFFSET))
));
}
} }