feat: decode the HDF5 N-Bit filter (atomic variant)

Implements decompression for the N-Bit filter (id 5), atomic integer/float
variant — previously returned UnsupportedFilter(5). N-Bit packs each element's
significant `precision` bits MSB-first with no header; decode reads those bits
per element and places them at the datatype's bit offset in a zero-filled
`size`-byte element, reproducing HDF5's canonical reduced-precision layout
(verified byte-for-byte against the equivalent un-filtered dataset).

Reverse-engineered and validated against HDF5 2.0 / h5py: unsigned
reduced-precision datasets now read end-to-end with exact values. Signed
reduced-precision values are restored to their canonical (zero-filled) bytes;
sign-extending them to the application width is the datatype reader's job — a
pre-existing concern shared with un-filtered reduced-precision data. Recursive
compound/array N-Bit layouts remain unsupported. Adds h5py-free unit tests from
real captured chunks.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-06-03 12:36:13 +00:00
co-authored by Claude Opus 4.8
parent 9062b3fb53
commit 4a5ab1c584
+116 -2
View File
@@ -8,8 +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_SCALEOFFSET, FILTER_SHUFFLE, FILTER_ZSTD, FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_NBIT, FILTER_SCALEOFFSET, FILTER_SHUFFLE,
FilterPipeline, FILTER_ZSTD, FilterPipeline,
}; };
/// Apply a filter pipeline to decompress a chunk. /// Apply a filter pipeline to decompress a chunk.
@@ -30,6 +30,7 @@ pub fn decompress_chunk(
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)?, FILTER_SCALEOFFSET => scaleoffset_decompress(&data, &filter.client_data)?,
FILTER_NBIT => nbit_decompress(&data, &filter.client_data)?,
other => return Err(FormatError::UnsupportedFilter(other)), other => return Err(FormatError::UnsupportedFilter(other)),
}; };
} }
@@ -297,6 +298,75 @@ fn write_elements(values: &[i64], elem_size: usize, big_endian: bool) -> Vec<u8>
out out
} }
/// Decode the HDF5 N-Bit filter (id 5), atomic (integer/float) variant.
///
/// N-Bit strips the unused leading/trailing bits of a datatype whose precision
/// is smaller than its storage size and packs the significant `precision` bits
/// of each element MSB-first, contiguously, with no header. Decompression
/// reverses this: it reads `precision` bits per element and places them at bit
/// `offset` of a zero-filled `size`-byte element — which is exactly HDF5's
/// canonical on-disk layout for a reduced-precision value (the high bits are
/// zero; sign extension of signed reduced-precision integers is the datatype
/// reader's responsibility, as it is for un-filtered reduced-precision data).
///
/// `cd` is the `H5Znbit.c` parameter block: `[2]`=element count,
/// `[3]`=type class (1 = atomic), and for atomics `[4]`=storage size,
/// `[5]`=byte order (1 = big-endian), `[6]`=precision (bits), `[7]`=bit offset.
/// The recursive compound/array layouts are reported as unsupported.
fn nbit_decompress(data: &[u8], cd: &[u32]) -> Result<Vec<u8>, FormatError> {
const H5Z_NBIT_ATOMIC: u32 = 1;
if cd.len() < 8 {
return Err(FormatError::ChunkedReadError(
"nbit: missing filter client data".into(),
));
}
let nelmts = cd[2] as usize;
if cd[3] != H5Z_NBIT_ATOMIC {
// Compound/array N-Bit layouts encode a recursive parameter tree.
return Err(FormatError::UnsupportedFilter(FILTER_NBIT));
}
let size = cd[4] as usize;
let big_endian = cd[5] == 1;
let precision = cd[6] as usize;
let offset = cd[7] as usize;
if size == 0 || size > 8 || precision == 0 || offset + precision > size * 8 {
return Err(FormatError::ChunkedReadError(
"nbit: invalid atomic parameters".into(),
));
}
let need_bits = nelmts
.checked_mul(precision)
.ok_or_else(|| FormatError::ChunkedReadError("nbit: size overflow".into()))?;
if data.len() * 8 < need_bits {
return Err(FormatError::ChunkedReadError(
"nbit: packed data too short".into(),
));
}
let mut out = vec![0u8; nelmts * size];
let mut bitpos = 0usize;
for elem in out.chunks_exact_mut(size) {
// Read `precision` bits MSB-first into the significant value.
let mut value: u64 = 0;
for _ in 0..precision {
let bit = (data[bitpos / 8] >> (7 - (bitpos % 8))) & 1;
value = (value << 1) | bit as u64;
bitpos += 1;
}
// Place the value at its bit offset; the rest of the element stays zero.
let le = (value << offset).to_le_bytes();
if big_endian {
for (j, slot) in elem.iter_mut().enumerate() {
*slot = le[size - 1 - j];
}
} else {
elem.copy_from_slice(&le[..size]);
}
}
Ok(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> {
@@ -1032,4 +1102,48 @@ mod tests {
Err(FormatError::UnsupportedFilter(FILTER_SCALEOFFSET)) Err(FormatError::UnsupportedFilter(FILTER_SCALEOFFSET))
)); ));
} }
// --- N-Bit (filter id 5) --------------------------------------------------
// Real compressed chunks + client data from h5py 3.16 / HDF5 2.0. The
// expected outputs are the canonical zero-filled element bytes (verified
// equal to the contiguous, un-filtered on-disk layout of the same type).
#[test]
fn nbit_unsigned_12bit() {
// u32 storage, 12-bit precision: [0, 1, 4095, 2048].
let cd = [8u32, 0, 4, 1, 4, 0, 12, 0];
let raw = [0x00, 0x00, 0x01, 0xff, 0xf8, 0x00, 0x00];
let expected: Vec<u8> = [0u32, 1, 4095, 2048]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect();
assert_eq!(nbit_decompress(&raw, &cd).unwrap(), expected);
}
#[test]
fn nbit_signed_16bit_zero_filled() {
// i32 storage, 16-bit precision: [-1, -50, 100, -1000]. N-Bit restores
// the canonical zero-filled layout (high 16 bits zero); the datatype
// reader is responsible for sign-extending reduced-precision integers.
let cd = [8u32, 0, 4, 1, 4, 0, 16, 0];
let raw = [0xff, 0xff, 0xff, 0xce, 0x00, 0x64, 0xfc, 0x18, 0x00];
// Canonical bytes captured from an equivalent un-filtered dataset.
let expected: Vec<u8> = vec![
0xff, 0xff, 0x00, 0x00, // 0x0000ffff
0xce, 0xff, 0x00, 0x00, // 0x0000ffce
0x64, 0x00, 0x00, 0x00, // 0x00000064
0x18, 0xfc, 0x00, 0x00, // 0x0000fc18
];
assert_eq!(nbit_decompress(&raw, &cd).unwrap(), expected);
}
#[test]
fn nbit_compound_unsupported() {
// class != 1 (atomic) is the recursive compound/array layout.
let cd = [8u32, 0, 4, 2, 4, 0, 16, 0];
assert!(matches!(
nbit_decompress(&[0u8; 8], &cd),
Err(FormatError::UnsupportedFilter(FILTER_NBIT))
));
}
} }