use fitskit::*;
use std::path::Path;
const SAMP_DIR: &str = "samp";
fn samp(name: &str) -> std::path::PathBuf {
Path::new(SAMP_DIR).join(name)
}
macro_rules! require_samples {
() => {
if !Path::new(SAMP_DIR).is_dir() {
eprintln!("skipping: samp/ directory not present");
return;
}
};
}
#[test]
fn read_euv_image() {
require_samples!();
let fits = FitsFile::from_file(samp("EUVEngc4151imgx.fits")).unwrap();
let primary = fits.primary();
assert_eq!(primary.header.get_bool("SIMPLE"), Some(true));
assert_eq!(primary.header.get_int("BITPIX"), Some(8));
assert_eq!(primary.header.get_int("NAXIS"), Some(0));
assert!(matches!(primary.data, HduData::Empty));
assert!(
fits.len() > 1,
"expected extensions, got {} HDUs",
fits.len()
);
let mut image_count = 0;
let mut bintable_count = 0;
for hdu in fits.extensions() {
match &hdu.data {
HduData::Image(_) => image_count += 1,
HduData::BinTable(_) => bintable_count += 1,
_ => {}
}
}
assert!(image_count > 0, "expected IMAGE extensions");
assert!(bintable_count > 0, "expected BINTABLE extensions");
for hdu in fits.extensions() {
if let HduData::Image(img) = &hdu.data {
assert_eq!(img.bitpix(), Bitpix::I16);
assert_eq!(img.axes.len(), 2);
assert!(img.width().unwrap() > 0);
assert!(img.height().unwrap() > 0);
break;
}
}
}
#[test]
fn read_fgs_with_ascii_table() {
require_samples!();
let fits = FitsFile::from_file(samp("FGSf64y0106m_a1f.fits")).unwrap();
let primary = fits.primary();
assert_eq!(primary.header.get_bool("SIMPLE"), Some(true));
assert_eq!(primary.header.get_int("BITPIX"), Some(32));
assert_eq!(primary.header.get_int("NAXIS"), Some(2));
assert_eq!(primary.header.get_int("NAXIS1"), Some(89688));
assert_eq!(primary.header.get_int("NAXIS2"), Some(7));
if let HduData::Image(img) = &primary.data {
assert_eq!(img.bitpix(), Bitpix::I32);
assert_eq!(img.axes, vec![89688, 7]);
assert_eq!(img.num_pixels(), 89688 * 7);
} else {
panic!("expected image data in primary HDU");
}
assert_eq!(fits.len(), 2, "expected primary + 1 extension");
if let HduData::AsciiTable(table) = &fits.extensions()[0].data {
assert_eq!(table.nrows, 7);
assert_eq!(table.columns.len(), 6);
} else {
panic!("expected ASCII TABLE extension");
}
}
#[test]
fn read_foc_float_image() {
require_samples!();
let fits = FitsFile::from_file(samp("FOCx38i0101t_c0f.fits")).unwrap();
let primary = fits.primary();
assert_eq!(primary.header.get_int("BITPIX"), Some(-32));
assert_eq!(primary.header.get_int("NAXIS"), Some(2));
assert_eq!(primary.header.get_int("NAXIS1"), Some(1024));
assert_eq!(primary.header.get_int("NAXIS2"), Some(1024));
if let HduData::Image(img) = &primary.data {
assert_eq!(img.bitpix(), Bitpix::F32);
assert_eq!(img.num_pixels(), 1024 * 1024);
if let PixelData::F32(data) = &img.pixels {
assert!(!data.is_empty());
let non_zero = data.iter().filter(|&&v| v != 0.0).count();
assert!(non_zero > 0, "expected some non-zero pixels");
}
} else {
panic!("expected float image data");
}
assert_eq!(fits.len(), 2);
if let HduData::AsciiTable(table) = &fits.extensions()[0].data {
assert_eq!(table.nrows, 1);
assert_eq!(table.columns.len(), 18);
} else {
panic!("expected ASCII TABLE extension");
}
}
#[test]
fn read_iue_header_only() {
require_samples!();
let fits = FitsFile::from_file(samp("IUElwp25637mxlo.fits")).unwrap();
let primary = fits.primary();
assert_eq!(primary.header.get_bool("SIMPLE"), Some(true));
assert_eq!(primary.header.get_int("BITPIX"), Some(8));
assert_eq!(primary.header.get_int("NAXIS"), Some(0));
assert!(matches!(primary.data, HduData::Empty));
assert!(
primary.header.find("TELESCOP").is_some() || primary.header.find("INSTRUME").is_some(),
"expected instrument metadata"
);
}
#[test]
fn read_wfpc2_3d_cube() {
require_samples!();
let fits = FitsFile::from_file(samp("WFPC2u5780205r_c0fx.fits")).unwrap();
let primary = fits.primary();
assert_eq!(primary.header.get_int("BITPIX"), Some(-32));
assert_eq!(primary.header.get_int("NAXIS"), Some(3));
assert_eq!(primary.header.get_int("NAXIS1"), Some(200));
assert_eq!(primary.header.get_int("NAXIS2"), Some(200));
assert_eq!(primary.header.get_int("NAXIS3"), Some(4));
if let HduData::Image(img) = &primary.data {
assert_eq!(img.bitpix(), Bitpix::F32);
assert_eq!(img.axes, vec![200, 200, 4]);
assert_eq!(img.num_pixels(), 200 * 200 * 4);
} else {
panic!("expected 3D image data");
}
assert_eq!(fits.len(), 2);
if let HduData::AsciiTable(table) = &fits.extensions()[0].data {
assert_eq!(table.nrows, 4);
assert_eq!(table.columns.len(), 49);
} else {
panic!("expected ASCII TABLE extension");
}
}
#[test]
fn all_sample_files_readable() {
require_samples!();
let fits_files = [
"EUVEngc4151imgx.fits",
"FGSf64y0106m_a1f.fits",
"FOCx38i0101t_c0f.fits",
"IUElwp25637mxlo.fits",
"WFPC2u5780205r_c0fx.fits",
];
for name in &fits_files {
let path = samp(name);
let result = FitsFile::from_file(&path);
assert!(result.is_ok(), "failed to read {name}: {:?}", result.err());
let fits = result.unwrap();
assert!(!fits.is_empty(), "{name}: expected at least one HDU");
assert_eq!(
fits.primary().header.get_bool("SIMPLE"),
Some(true),
"{name}: missing SIMPLE=T"
);
}
}
fn assert_rice_roundtrip(src_name: &str, fz_name: &str) {
let fz_path = samp(fz_name);
if !fz_path.exists() {
eprintln!("skipping: fixture {fz_name} not present");
return;
}
let src = FitsFile::from_file(samp(src_name)).expect("read source");
let fz = FitsFile::from_file(&fz_path).expect("read .fz");
let src_images: Vec<&ImageData> = src
.hdus
.iter()
.filter_map(|h| match &h.data {
HduData::Image(im) if !im.pixels.to_bytes().is_empty() => Some(im),
_ => None,
})
.collect();
let mut matched = 0usize;
let mut src_iter = src_images.iter();
for hdu in &fz.hdus {
if let Some(cimg) = hdu.as_compressed_image() {
let orig = src_iter
.next()
.expect("more compressed images than source images");
let dec = cimg.decompress().expect("decompress");
assert_eq!(
dec.axes, orig.axes,
"{fz_name}: axes mismatch on compressed HDU #{matched}"
);
assert_eq!(
dec.pixels.to_bytes(),
orig.pixels.to_bytes(),
"{fz_name}: pixel bytes differ on compressed HDU #{matched}"
);
matched += 1;
}
}
assert!(matched > 0, "{fz_name}: found no compressed-image HDUs");
}
#[test]
fn rice_roundtrip_euv_row_tiled() {
assert_rice_roundtrip("EUVEngc4151imgx.fits", "EUVEngc4151imgx.rice.fits.fz");
}
#[test]
fn rice_roundtrip_euv_square_tiled() {
assert_rice_roundtrip("EUVEngc4151imgx.fits", "EUVEngc4151imgx.rice_t100.fits.fz");
}
#[test]
fn rice_roundtrip_fgs_i32() {
assert_rice_roundtrip("FGSf64y0106m_a1f.fits", "FGSf64y0106m_a1f.rice.fits.fz");
}
#[test]
fn plio_roundtrip_euv() {
assert_rice_roundtrip("EUVEngc4151imgx.fits", "EUVEngc4151imgx.plio.fits.fz");
}
#[test]
fn hcompress_int_roundtrip_euv() {
assert_rice_roundtrip(
"EUVEngc4151imgx.fits",
"EUVEngc4151imgx.hcomp_int.fits.fz",
);
}
fn funpack_available() -> bool {
std::process::Command::new("funpack")
.arg("-V")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn funpack_reference(fz_name: &str) -> Option<FitsFile> {
let fz_path = samp(fz_name);
let mut out = std::env::temp_dir();
out.push(format!(
"fitskit_funpack_ref_{}_{}.fits",
std::process::id(),
fz_name.replace(['/', '.'], "_")
));
let _ = std::fs::remove_file(&out);
let status = std::process::Command::new("funpack")
.arg("-O")
.arg(&out)
.arg("-C") .arg(&fz_path)
.status()
.ok()?;
if !status.success() {
return None;
}
let f = FitsFile::from_file(&out).ok();
let _ = std::fs::remove_file(&out);
f
}
fn assert_float_matches_funpack(fz_name: &str) {
let fz_path = samp(fz_name);
if !fz_path.exists() {
eprintln!("skipping: fixture {fz_name} not present");
return;
}
if !funpack_available() {
eprintln!("skipping: funpack binary not available");
return;
}
let reference = match funpack_reference(fz_name) {
Some(r) => r,
None => {
eprintln!("skipping: funpack failed on {fz_name}");
return;
}
};
let ref_images: Vec<&ImageData> = reference
.hdus
.iter()
.filter_map(|h| match &h.data {
HduData::Image(im) if !im.pixels.to_bytes().is_empty() => Some(im),
_ => None,
})
.collect();
let fz = FitsFile::from_file(&fz_path).expect("read .fz");
let mut ref_iter = ref_images.iter();
let mut matched = 0usize;
for hdu in &fz.hdus {
if let Some(cimg) = hdu.as_compressed_image() {
let reference_img = ref_iter
.next()
.expect("more compressed images than funpack reference images");
let dec = cimg.decompress().expect("fitskit float decompress");
assert_eq!(
dec.axes, reference_img.axes,
"{fz_name}: axes mismatch on compressed HDU #{matched}"
);
compare_floats_vs_funpack(fz_name, matched, &dec.pixels, &reference_img.pixels);
matched += 1;
}
}
assert!(matched > 0, "{fz_name}: found no compressed-image HDUs");
}
fn compare_floats_vs_funpack(fz_name: &str, hdu: usize, got: &PixelData, want: &PixelData) {
fn cmp(fz_name: &str, hdu: usize, a: f64, b: f64) {
if a.is_nan() && b.is_nan() {
return;
}
assert!(
a.to_bits() == b.to_bits(),
"{fz_name} HDU#{hdu}: fitskit={a} ({:#x}) != funpack={b} ({:#x})",
a.to_bits(),
b.to_bits()
);
}
match (got, want) {
(PixelData::F32(g), PixelData::F32(w)) => {
assert_eq!(g.len(), w.len(), "{fz_name} HDU#{hdu}: length mismatch");
for (x, y) in g.iter().zip(w.iter()) {
cmp(fz_name, hdu, *x as f64, *y as f64);
}
}
(PixelData::F64(g), PixelData::F64(w)) => {
assert_eq!(g.len(), w.len(), "{fz_name} HDU#{hdu}: length mismatch");
for (x, y) in g.iter().zip(w.iter()) {
cmp(fz_name, hdu, *x, *y);
}
}
_ => panic!("{fz_name} HDU#{hdu}: pixel type mismatch vs funpack reference"),
}
}
#[test]
fn float_rice_nodither_matches_funpack() {
assert_float_matches_funpack("FOCx38i0101t_c0f.rice_nodith.fits.fz");
}
#[cfg(feature = "gzip")]
#[test]
fn float_gzip_lossless_matches_funpack() {
assert_float_matches_funpack("FOCx38i0101t_c0f.gzip_lossless.fits.fz");
}
#[test]
fn float_rice_dither1_matches_funpack() {
assert_float_matches_funpack("FOCx38i0101t_c0f.rice_dith.fits.fz");
}
#[test]
fn float_rice_dither2_matches_funpack() {
assert_float_matches_funpack("FOCx38i0101t_c0f.rice_dith2.fits.fz");
}
#[test]
fn float_cube_rice_dither1_matches_funpack() {
assert_float_matches_funpack("WFPC2u5780205r_c0fx.rice_dith.fits.fz");
}
#[test]
fn float_cube_rice_nodither_matches_funpack() {
assert_float_matches_funpack("WFPC2u5780205r_c0fx.rice_nodith.fits.fz");
}
#[test]
fn float_hcompress_dither1_matches_funpack() {
assert_float_matches_funpack("FOCx38i0101t_c0f.hcomp.fits.fz");
}
#[cfg(feature = "gzip")]
#[test]
fn gzip1_roundtrip_euv() {
assert_rice_roundtrip("EUVEngc4151imgx.fits", "EUVEngc4151imgx.gzip1.fits.fz");
}
use fitskit::tile_compress::CompressOptions;
use fitskit::{CompressionType, Quantize};
fn sample_images(src_name: &str) -> Vec<ImageData> {
let src = FitsFile::from_file(samp(src_name)).expect("read source");
src.hdus
.iter()
.filter_map(|h| match &h.data {
HduData::Image(im) if !im.pixels.to_bytes().is_empty() => Some(im.clone()),
_ => None,
})
.collect()
}
fn compress_sample(images: &[ImageData], opts: &CompressOptions) -> FitsFile {
let mut fits = FitsFile::with_empty_primary();
fits.primary_mut()
.header
.set("EXTEND", HeaderValue::Logical(true), None);
for img in images {
fits.push_extension(img.compress(opts).expect("compress"));
}
fits
}
fn assert_internal_roundtrip(images: &[ImageData], opts: &CompressOptions) {
let fits = compress_sample(images, opts);
let comp: Vec<&Hdu> = fits
.hdus
.iter()
.filter(|h| h.as_compressed_image().is_some())
.collect();
assert_eq!(comp.len(), images.len());
for (orig, hdu) in images.iter().zip(comp) {
let dec = hdu.as_compressed_image().unwrap().decompress().unwrap();
assert_eq!(dec.axes, orig.axes, "internal: axes");
assert_eq!(
dec.pixels.to_bytes(),
orig.pixels.to_bytes(),
"internal: pixel bytes"
);
}
let bytes = fits.to_bytes().expect("to_bytes");
let reread = FitsFile::from_bytes(&bytes).expect("reread");
let n = reread
.hdus
.iter()
.filter(|h| h.as_compressed_image().is_some())
.count();
assert_eq!(n, images.len(), "reread: compressed HDU count");
}
fn assert_funpack_reads_fitskit(src_name: &str, opts: &CompressOptions, tag: &str) {
if !Path::new(SAMP_DIR).is_dir() {
eprintln!("skipping: samp/ not present");
return;
}
if !funpack_available() {
eprintln!("skipping: funpack binary not available");
return;
}
let images = sample_images(src_name);
assert!(!images.is_empty(), "{src_name}: no source images");
let fits = compress_sample(&images, opts);
let mut fz = std::env::temp_dir();
fz.push(format!("fitskit_enc_{}_{}.fits.fz", std::process::id(), tag));
let mut out = std::env::temp_dir();
out.push(format!("fitskit_enc_{}_{}.fits", std::process::id(), tag));
let _ = std::fs::remove_file(&fz);
let _ = std::fs::remove_file(&out);
fits.to_file(&fz).expect("write fitskit .fz");
let status = std::process::Command::new("funpack")
.arg("-O")
.arg(&out)
.arg("-C")
.arg(&fz)
.status()
.expect("run funpack");
assert!(status.success(), "{tag}: funpack failed on fitskit output");
let recon = FitsFile::from_file(&out).expect("read funpack output");
let recon_images: Vec<&ImageData> = recon
.hdus
.iter()
.filter_map(|h| match &h.data {
HduData::Image(im) if !im.pixels.to_bytes().is_empty() => Some(im),
_ => None,
})
.collect();
assert_eq!(
recon_images.len(),
images.len(),
"{tag}: funpack image count"
);
for (orig, got) in images.iter().zip(recon_images) {
assert_eq!(got.axes, orig.axes, "{tag}: funpack axes");
assert_eq!(
got.pixels.to_bytes(),
orig.pixels.to_bytes(),
"{tag}: funpack pixel bytes differ from original (lossless expected)"
);
}
let _ = std::fs::remove_file(&fz);
let _ = std::fs::remove_file(&out);
}
fn rice_opts(tile: Option<Vec<usize>>) -> CompressOptions {
CompressOptions {
algorithm: CompressionType::Rice1,
tile,
..Default::default()
}
}
#[test]
fn encode_rice_i16_internal() {
require_samples!();
assert_internal_roundtrip(&sample_images("EUVEngc4151imgx.fits"), &rice_opts(None));
assert_internal_roundtrip(
&sample_images("EUVEngc4151imgx.fits"),
&rice_opts(Some(vec![100, 100])),
);
}
#[test]
fn encode_rice_i16_interop_funpack() {
assert_funpack_reads_fitskit("EUVEngc4151imgx.fits", &rice_opts(None), "rice_i16");
}
#[test]
fn encode_rice_i16_square_tiles_interop_funpack() {
assert_funpack_reads_fitskit(
"EUVEngc4151imgx.fits",
&rice_opts(Some(vec![100, 100])),
"rice_i16_t100",
);
}
#[test]
fn encode_rice_i32_internal() {
require_samples!();
assert_internal_roundtrip(&sample_images("FGSf64y0106m_a1f.fits"), &rice_opts(None));
}
#[test]
fn encode_rice_i32_interop_funpack() {
assert_funpack_reads_fitskit("FGSf64y0106m_a1f.fits", &rice_opts(None), "rice_i32");
}
#[cfg(feature = "gzip")]
#[test]
fn encode_gzip_i16_internal() {
require_samples!();
for alg in [CompressionType::Gzip1, CompressionType::Gzip2] {
let opts = CompressOptions {
algorithm: alg,
..Default::default()
};
assert_internal_roundtrip(&sample_images("EUVEngc4151imgx.fits"), &opts);
}
}
#[cfg(feature = "gzip")]
#[test]
fn encode_gzip1_i16_interop_funpack() {
let opts = CompressOptions {
algorithm: CompressionType::Gzip1,
..Default::default()
};
assert_funpack_reads_fitskit("EUVEngc4151imgx.fits", &opts, "gzip1_i16");
}
#[cfg(feature = "gzip")]
#[test]
fn encode_gzip2_i16_interop_funpack() {
let opts = CompressOptions {
algorithm: CompressionType::Gzip2,
..Default::default()
};
assert_funpack_reads_fitskit("EUVEngc4151imgx.fits", &opts, "gzip2_i16");
}
#[cfg(feature = "gzip")]
#[test]
fn encode_gzip_lossless_float_internal() {
require_samples!();
let opts = CompressOptions {
algorithm: CompressionType::Gzip1,
quantize: None, ..Default::default()
};
assert_internal_roundtrip(&sample_images("FOCx38i0101t_c0f.fits"), &opts);
}
#[cfg(feature = "gzip")]
#[test]
fn encode_gzip_lossless_float_interop_funpack() {
let opts = CompressOptions {
algorithm: CompressionType::Gzip1,
quantize: None,
..Default::default()
};
assert_funpack_reads_fitskit("FOCx38i0101t_c0f.fits", &opts, "gzip_lossless_f32");
}
#[test]
fn encode_rice_float_quantize_within_tolerance() {
require_samples!();
let images = sample_images("FOCx38i0101t_c0f.fits");
let opts = CompressOptions {
algorithm: CompressionType::Rice1,
tile: None,
quantize: Some(4.0),
dither: Quantize::SubtractiveDither1,
dither_seed: Some(5),
..Default::default()
};
let fits = compress_sample(&images, &opts);
let comp: Vec<&Hdu> = fits
.hdus
.iter()
.filter(|h| h.as_compressed_image().is_some())
.collect();
assert_eq!(comp.len(), images.len());
for (orig, hdu) in images.iter().zip(comp) {
let dec = hdu.as_compressed_image().unwrap().decompress().unwrap();
assert_eq!(dec.axes, orig.axes);
let (o, r) = match (&orig.pixels, &dec.pixels) {
(PixelData::F32(a), PixelData::F32(b)) => (a.clone(), b.clone()),
_ => panic!("expected F32 float image"),
};
assert_eq!(o.len(), r.len());
let finite_max = o
.iter()
.filter(|v| v.is_finite())
.fold(0.0f32, |m, &v| m.max(v.abs()));
let tol = (finite_max / 1000.0).max(1.0);
let mut max_err = 0.0f32;
for (&a, &b) in o.iter().zip(r.iter()) {
if a.is_finite() && b.is_finite() {
max_err = max_err.max((a - b).abs());
}
}
assert!(
max_err <= tol,
"quantize round-trip max error {max_err} exceeds tolerance {tol}"
);
}
}