use crate::header::card_keys;
use crate::header::{Bitpix, Header};
use crate::image::compression::dither::{Dither, Quantization};
use crate::image::compression::rice::BytesPerValue;
use crate::image::compression::{dither, hcompress, plio, rice};
use std::error::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Compression {
#[default]
Rice,
Gzip,
ShuffledGzip,
Hcompress {
scale: i64,
},
Plio,
None,
}
impl Compression {
pub fn card_value(self) -> &'static str {
match self {
Compression::Rice => "RICE_1",
Compression::Gzip => "GZIP_1",
Compression::ShuffledGzip => "GZIP_2",
Compression::Hcompress { .. } => "HCOMPRESS_1",
Compression::Plio => "PLIO_1",
Compression::None => "NOCOMPRESS",
}
}
fn needs_integers(self) -> bool {
matches!(
self,
Compression::Rice | Compression::Hcompress { .. } | Compression::Plio
)
}
fn element_bytes(self) -> usize {
match self {
Compression::Plio => 2,
_ => 1,
}
}
fn column_format(self) -> &'static str {
match self {
Compression::Plio => "1PI",
_ => "1PB",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum Quantize {
#[default]
Lossless,
Step(f64),
NoiseLevel(f64),
}
#[derive(Debug, Clone, PartialEq)]
pub struct CompressionOptions {
compression: Compression,
tile: Option<Vec<u32>>,
quantize: Quantize,
quantization: Quantization,
seed: i64,
block: usize,
}
impl Default for CompressionOptions {
fn default() -> Self {
Self::new(Compression::default())
}
}
impl CompressionOptions {
pub fn new(compression: Compression) -> Self {
Self {
compression,
tile: None,
quantize: Quantize::Lossless,
quantization: Quantization::SubtractiveDither1,
seed: 1,
block: 32,
}
}
#[must_use]
pub fn with_tile_size(mut self, tile: &[u32]) -> Self {
self.tile = Some(tile.to_vec());
self
}
#[must_use]
pub fn with_quantization(mut self, quantize: Quantize) -> Self {
self.quantize = quantize;
self
}
#[must_use]
pub fn with_dithering(mut self, quantization: Quantization) -> Self {
self.quantization = quantization;
self
}
#[must_use]
pub fn with_dither_seed(mut self, seed: i64) -> Self {
self.seed = seed.rem_euclid(dither::SEQUENCE_LENGTH as i64).max(1);
self
}
#[must_use]
pub fn with_block_size(mut self, block: usize) -> Self {
self.block = block.max(1);
self
}
pub fn compression(&self) -> Compression {
self.compression
}
}
const NULL_VALUE: i64 = -2147483647;
const RESERVED_VALUES: f64 = 10.0;
const COMPRESSED_DATA: &str = "COMPRESSED_DATA";
const SCALE: &str = "ZSCALE";
const ZERO: &str = "ZZERO";
pub(crate) fn compress(
header: &Header,
data: &[u8],
options: &CompressionOptions,
) -> Result<(Header, Vec<u8>), Box<dyn Error + Send + Sync>> {
let bitpix = header
.bitpix()
.ok_or("An image needs a BITPIX card before it can be compressed")?;
let shape = shape_of(header);
if shape.is_empty() {
return Err("An image with no axes has nothing to compress".into());
}
let tile = tile_shape(options, &shape);
let quantizing = matches!(bitpix, Bitpix::F32 | Bitpix::F64)
&& !matches!(options.quantize, Quantize::Lossless);
if bitpix.is_floating() && options.compression.needs_integers() && !quantizing {
return Err(format!(
"{} compresses integers, and this image holds floating point values. Either quantise \
it, which loses the low bits of every pixel, or compress it with GZIP_1, which does \
not.",
options.compression.card_value()
)
.into());
}
let pixels = read_pixels(data, bitpix, &shape)?;
let stored = if quantizing { Bitpix::I32 } else { bitpix };
let tiles: Vec<usize> = shape
.iter()
.zip(&tile)
.map(|(length, tile)| length.div_ceil(*tile))
.collect();
let tile_count: usize = tiles.iter().product();
let mut rows = Vec::new();
let mut heap = Vec::new();
let mut any_blank = false;
let elements = options.compression.element_bytes();
for index in 0..tile_count {
let (values, extent) = gather(&pixels, &shape, &tile, &tiles, index);
let (integers, scale, zero) = if quantizing {
let (integers, scale, zero) = quantize_tile(&values, options, index);
any_blank |= values.iter().any(|value| !value.is_finite());
(integers, Some(scale), Some(zero))
} else {
(
values.iter().map(|value| *value as i64).collect(),
None,
None,
)
};
let compressed = encode(&integers, &values, stored, &extent, options)?;
rows.extend_from_slice(&((compressed.len() / elements) as u32).to_be_bytes());
rows.extend_from_slice(&(heap.len() as u32).to_be_bytes());
heap.extend_from_slice(&compressed);
if let (Some(scale), Some(zero)) = (scale, zero) {
rows.extend_from_slice(&scale.to_be_bytes());
rows.extend_from_slice(&zero.to_be_bytes());
}
}
let row_bytes = if quantizing { 8 + 16 } else { 8 };
let mut table = rows;
table.extend_from_slice(&heap);
let compressed_header = compressed_header(
header,
bitpix,
&shape,
&tile,
options,
quantizing,
any_blank,
tile_count,
row_bytes,
heap.len(),
)?;
Ok((compressed_header, table))
}
fn read_pixels(
data: &[u8],
bitpix: Bitpix,
shape: &[usize],
) -> Result<Vec<f64>, Box<dyn Error + Send + Sync>> {
let count: usize = shape.iter().product();
let width = bitpix.byte_size();
if data.len() < count * width {
return Err(format!(
"This image says it holds {} pixels of {} bytes, and its data section is {} bytes",
count,
width,
data.len()
)
.into());
}
Ok(data[..count * width]
.chunks_exact(width)
.filter_map(|raw| bitpix.read_be(raw))
.collect())
}
fn shape_of(header: &Header) -> Vec<usize> {
let axes = header.naxis().unwrap_or(0).max(0) as usize;
(0..axes)
.map(|axis| header.naxis_n(axis).unwrap_or(0).max(0) as usize)
.collect()
}
fn tile_shape(options: &CompressionOptions, shape: &[usize]) -> Vec<usize> {
(0..shape.len())
.map(|axis| {
let asked = match &options.tile {
Some(tile) => tile.get(axis).map(|size| *size as usize),
None => Some(if axis == 0 { shape[0] } else { 1 }),
};
asked.unwrap_or(1).clamp(1, shape[axis].max(1))
})
.collect()
}
fn gather(
pixels: &[f64],
shape: &[usize],
tile: &[usize],
tiles: &[usize],
index: usize,
) -> (Vec<f64>, Vec<usize>) {
let mut origin = vec![0_usize; shape.len()];
let mut extent = vec![0_usize; shape.len()];
let mut rest = index;
for axis in 0..shape.len() {
origin[axis] = (rest % tiles[axis]) * tile[axis];
rest /= tiles[axis];
extent[axis] = tile[axis].min(shape[axis] - origin[axis]);
}
let run = extent[0];
let runs: usize = extent.iter().skip(1).product();
let mut values = Vec::with_capacity(run * runs);
let mut within = vec![0_usize; shape.len()];
for index in 0..runs {
let mut rest = index;
for axis in 1..shape.len() {
within[axis] = rest % extent[axis];
rest /= extent[axis];
}
let mut at = origin[0];
let mut stride = shape[0];
for axis in 1..shape.len() {
at += (origin[axis] + within[axis]) * stride;
stride *= shape[axis];
}
values.extend_from_slice(&pixels[at..at + run]);
}
(values, extent)
}
fn quantize_tile(
values: &[f64],
options: &CompressionOptions,
tile: usize,
) -> (Vec<i64>, f64, f64) {
let finite: Vec<f64> = values.iter().copied().filter(|v| v.is_finite()).collect();
let step = match options.quantize {
Quantize::Step(step) => step.abs(),
Quantize::NoiseLevel(level) => noise(&finite) / level.max(f64::MIN_POSITIVE),
Quantize::Lossless => 0.0,
};
let step = if step > 0.0 && step.is_finite() {
step
} else {
1.0
};
let minimum = finite.iter().copied().fold(f64::INFINITY, f64::min);
let maximum = finite.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let zero = if !minimum.is_finite() {
0.0
} else if finite.len() < values.len()
|| options.quantization == Quantization::SubtractiveDither2
{
minimum - step * (NULL_VALUE as f64 + RESERVED_VALUES)
} else {
let factor = (minimum / step + 0.5).floor();
factor * step
};
let blank = (finite.len() < values.len()).then_some(NULL_VALUE);
let integers = dither::quantize(
values,
step,
zero,
options.quantization,
blank,
Dither::for_tile(options.seed, tile),
);
let _ = maximum;
(integers, step, zero)
}
fn noise(values: &[f64]) -> f64 {
const MEDIAN_TO_SIGMA: f64 = 0.6052697;
if values.len() < 3 {
return 0.0;
}
let mut differences: Vec<f64> = values
.windows(3)
.map(|window| (2.0 * window[1] - window[0] - window[2]).abs())
.collect();
differences.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let middle = differences.len() / 2;
let median = if differences.len().is_multiple_of(2) {
(differences[middle - 1] + differences[middle]) / 2.0
} else {
differences[middle]
};
MEDIAN_TO_SIGMA * median
}
fn encode(
integers: &[i64],
values: &[f64],
stored: Bitpix,
extent: &[usize],
options: &CompressionOptions,
) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
match options.compression {
Compression::Rice => {
let width = BytesPerValue::from_count(stored.byte_size() as i64)?;
Ok(rice::compress(integers, width, options.block))
}
Compression::Hcompress { scale } => {
if extent.iter().skip(2).any(|length| *length > 1) {
return Err(format!(
"HCOMPRESS compresses a plane at a time, and this tile is {:?}",
extent
)
.into());
}
let columns = extent.first().copied().unwrap_or(0);
let rows = extent.get(1).copied().unwrap_or(1);
hcompress::compress(integers, rows, columns, scale)
}
Compression::Plio => {
let words = plio::compress(integers)?;
Ok(words.iter().flat_map(|word| word.to_be_bytes()).collect())
}
Compression::None => Ok(to_be_bytes(integers, values, stored)),
Compression::Gzip => gzip(&to_be_bytes(integers, values, stored)),
Compression::ShuffledGzip => {
let bytes = to_be_bytes(integers, values, stored);
gzip(&shuffle(&bytes, stored.byte_size()))
}
}
}
fn to_be_bytes(integers: &[i64], values: &[f64], stored: Bitpix) -> Vec<u8> {
let mut bytes = Vec::with_capacity(integers.len() * stored.byte_size());
match stored {
Bitpix::U8 => bytes.extend(integers.iter().map(|value| *value as u8)),
Bitpix::I16 => {
for value in integers {
bytes.extend_from_slice(&(*value as i16).to_be_bytes());
}
}
Bitpix::I32 => {
for value in integers {
bytes.extend_from_slice(&(*value as i32).to_be_bytes());
}
}
Bitpix::F32 => {
for value in values {
bytes.extend_from_slice(&(*value as f32).to_be_bytes());
}
}
Bitpix::F64 => {
for value in values {
bytes.extend_from_slice(&value.to_be_bytes());
}
}
}
bytes
}
fn shuffle(bytes: &[u8], width: usize) -> Vec<u8> {
if width <= 1 {
return bytes.to_vec();
}
let count = bytes.len() / width;
let mut out = vec![0_u8; count * width];
for byte in 0..width {
for value in 0..count {
out[byte * count + value] = bytes[value * width + byte];
}
}
out
}
#[cfg(feature = "gzip")]
fn gzip(bytes: &[u8]) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
use std::io::Write;
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(bytes)?;
Ok(encoder.finish()?)
}
#[cfg(not(feature = "gzip"))]
fn gzip(_bytes: &[u8]) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
Err("Compressing with gzip needs the `gzip` feature".into())
}
#[allow(clippy::too_many_arguments)]
fn compressed_header(
header: &Header,
bitpix: Bitpix,
shape: &[usize],
tile: &[usize],
options: &CompressionOptions,
quantizing: bool,
any_blank: bool,
rows: usize,
row_bytes: usize,
heap: usize,
) -> Result<Header, Box<dyn Error + Send + Sync>> {
let mut out = header.clone();
out.remove_card(card_keys::NAXIS);
out.remove_prefixed(card_keys::PREFIX_NAXIS_N);
out.set_card(card_keys::BITPIX, 8_i64)?;
out.set_naxis_n(0, row_bytes as i64)?;
out.set_naxis_n(1, rows as i64)?;
out.set_card(card_keys::NAXIS, 2_i64)?;
out.set_card(card_keys::PCOUNT, heap as i64)?;
out.set_card(card_keys::GCOUNT, 1_i64)?;
let tiles = options.compression.column_format();
let columns: Vec<(&str, &str)> = if quantizing {
vec![(COMPRESSED_DATA, tiles), (SCALE, "1D"), (ZERO, "1D")]
} else {
vec![(COMPRESSED_DATA, tiles)]
};
out.set_card(card_keys::TFIELDS, columns.len() as i64)?;
for (index, (name, format)) in columns.iter().enumerate() {
out.set_card(
&format!("{}{}", card_keys::PREFIX_TTYPE_N, index + 1),
*name,
)?;
out.set_card(
&format!("{}{}", card_keys::PREFIX_TFORM_N, index + 1),
*format,
)?;
}
out.set_card(card_keys::ZIMAGE, true)?;
out.set_card(card_keys::ZBITPIX, i64::from(bitpix))?;
out.set_card(card_keys::ZNAXIS, shape.len() as i64)?;
for (axis, length) in shape.iter().enumerate() {
out.set_card(&format!("ZNAXIS{}", axis + 1), *length as i64)?;
out.set_card(&format!("ZTILE{}", axis + 1), tile[axis] as i64)?;
}
out.set_card(card_keys::ZCMPTYPE, options.compression.card_value())?;
let mut parameters: Vec<(&str, i64)> = Vec::new();
match options.compression {
Compression::Rice => {
parameters.push(("BLOCKSIZE", options.block as i64));
parameters.push((
"BYTEPIX",
if quantizing {
4
} else {
bitpix.byte_size() as i64
},
));
}
Compression::Hcompress { scale } => {
parameters.push(("SCALE", scale));
parameters.push(("SMOOTH", 0));
}
_ => {}
}
for (index, (name, value)) in parameters.iter().enumerate() {
out.set_card(&format!("ZNAME{}", index + 1), *name)?;
out.set_card(&format!("ZVAL{}", index + 1), *value)?;
}
if quantizing {
out.set_card(card_keys::ZQUANTIZ, options.quantization.card_value())?;
out.set_card(card_keys::ZDITHER0, options.seed)?;
if any_blank {
out.set_card(card_keys::ZBLANK, NULL_VALUE)?;
}
}
Ok(out)
}
impl Bitpix {
pub(crate) fn is_floating(self) -> bool {
matches!(self, Bitpix::F32 | Bitpix::F64)
}
}
#[cfg(test)]
mod tests {
use super::{Compression, CompressionOptions, Quantize, compress, noise, shuffle};
use crate::header::{Bitpix, Header};
fn header(bitpix: Bitpix, width: usize, height: usize) -> Header {
let mut header = Header::default();
header.set_card("BITPIX", i64::from(bitpix)).unwrap();
header.set_card("NAXIS", 2_i64).unwrap();
header.set_naxis_n(0, width as i64).unwrap();
header.set_naxis_n(1, height as i64).unwrap();
header
}
fn i16_data(values: &[i16]) -> Vec<u8> {
values.iter().flat_map(|v| v.to_be_bytes()).collect()
}
#[test]
fn a_compressed_header_describes_both_the_table_and_the_image() {
let values: Vec<i16> = (0..64).collect();
let (compressed, _) = compress(
&header(Bitpix::I16, 8, 8),
&i16_data(&values),
&CompressionOptions::new(Compression::Rice),
)
.expect("an image that can be compressed");
assert_eq!(compressed.bitpix(), Some(Bitpix::U8));
assert_eq!(compressed.naxis(), Some(2));
assert_eq!(compressed.table_fields(), Some(1));
assert!(compressed.is_compressed_image());
assert_eq!(compressed.compressed_bitpix(), Some(Bitpix::I16));
assert_eq!(compressed.compressed_naxis(), Some(2));
assert_eq!(compressed.compressed_naxis_n(0), Some(8));
assert_eq!(compressed.compressed_naxis_n(1), Some(8));
assert_eq!(compressed.compression_type(), Some("RICE_1"));
assert_eq!(compressed.compression_parameter("BYTEPIX"), Some(2));
}
#[test]
fn the_default_tile_is_one_row_of_the_image() {
let values: Vec<i16> = (0..64).collect();
let (compressed, _) = compress(
&header(Bitpix::I16, 8, 8),
&i16_data(&values),
&CompressionOptions::new(Compression::Rice),
)
.expect("an image that can be compressed");
assert_eq!(compressed.compressed_tile_size(0), 8);
assert_eq!(compressed.compressed_tile_size(1), 1);
assert_eq!(compressed.naxis_n(1), Some(8));
}
#[test]
fn a_tile_size_larger_than_the_image_is_cut_down_to_it() {
let values: Vec<i16> = (0..64).collect();
let (compressed, _) = compress(
&header(Bitpix::I16, 8, 8),
&i16_data(&values),
&CompressionOptions::new(Compression::Rice).with_tile_size(&[1000, 1000]),
)
.expect("an image that can be compressed");
assert_eq!(compressed.compressed_tile_size(0), 8);
assert_eq!(compressed.compressed_tile_size(1), 8);
assert_eq!(compressed.naxis_n(1), Some(1));
}
#[test]
fn a_floating_point_image_cannot_be_rice_coded_without_being_quantised() {
let data: Vec<u8> = (0..64).flat_map(|i| (i as f32).to_be_bytes()).collect();
let error = compress(
&header(Bitpix::F32, 8, 8),
&data,
&CompressionOptions::new(Compression::Rice),
)
.expect_err("Rice coding works on integers");
assert!(error.to_string().contains("quantise"), "got: {error}");
}
#[test]
fn a_quantised_image_says_how_it_was_quantised() {
let data: Vec<u8> = (0..64)
.flat_map(|i| (i as f32 * 0.5).to_be_bytes())
.collect();
let (compressed, _) = compress(
&header(Bitpix::F32, 8, 8),
&data,
&CompressionOptions::new(Compression::Rice)
.with_quantization(Quantize::Step(0.01))
.with_dither_seed(42),
)
.expect("a quantised image compresses");
assert_eq!(
compressed.quantization_method(),
Some("SUBTRACTIVE_DITHER_1")
);
assert_eq!(compressed.dither_seed(), Some(42));
assert_eq!(compressed.table_fields(), Some(3));
assert_eq!(compressed.compression_parameter("BYTEPIX"), Some(4));
assert_eq!(compressed.compressed_bitpix(), Some(Bitpix::F32));
assert_eq!(
compressed.card("TTYPE2").map(|v| v.value_to_string()),
Some("ZSCALE".to_string())
);
}
#[test]
fn the_noise_estimate_follows_the_noise() {
let quiet: Vec<f64> = (0..200)
.map(|i| i as f64 + if i % 2 == 0 { 0.1 } else { -0.1 })
.collect();
let loud: Vec<f64> = (0..200)
.map(|i| i as f64 + if i % 2 == 0 { 5.0 } else { -5.0 })
.collect();
assert!(noise(&quiet) > 0.0);
assert!(
noise(&loud) > 10.0 * noise(&quiet),
"{} vs {}",
noise(&loud),
noise(&quiet)
);
let ramp: Vec<f64> = (0..200).map(|i| i as f64 * 3.0).collect();
assert_eq!(noise(&ramp), 0.0);
}
#[test]
fn shuffling_gathers_each_byte_of_every_value_together() {
assert_eq!(
shuffle(&[0x12, 0x34, 0x56, 0x78], 2),
vec![0x12, 0x56, 0x34, 0x78]
);
assert_eq!(shuffle(&[1, 2, 3], 1), vec![1, 2, 3]);
}
}