use std::io::Read;
use std::path::Path;
use arcsec_core::types::{ImageBuffer, WcsSolution};
use crate::{asdf_io, fits_io, xisf_io};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFormat {
Fits,
Xisf,
Asdf,
}
impl ImageFormat {
pub const fn name(self) -> &'static str {
match self {
Self::Fits => "FITS",
Self::Xisf => "XISF",
Self::Asdf => "ASDF",
}
}
}
pub fn detect_format(path: &Path) -> Result<ImageFormat, String> {
let mut head = [0u8; 16];
let n = match std::fs::File::open(path).and_then(|mut f| f.read(&mut head)) {
Ok(n) => n,
Err(e) => {
if path.to_string_lossy().contains('[') {
return Ok(ImageFormat::Fits);
}
return Err(format!("{}: {e}", path.display()));
}
};
let head = &head[..n];
if head.starts_with(b"XISF0100") {
Ok(ImageFormat::Xisf)
} else if head.starts_with(b"#ASDF ") {
Ok(ImageFormat::Asdf)
} else if head.starts_with(b"\x1f\x8b") {
Err(format!(
"{}: gzip-compressed FITS is not supported; decompress it first (gunzip)",
path.display()
))
} else if head.starts_with(b"SIMPLE =")
|| head.starts_with(b"BZh") || head.starts_with(b"\x1f\x9d")
{
Ok(ImageFormat::Fits)
} else {
Err(format!(
"{}: not a FITS, XISF or ASDF image (no recognised magic bytes)",
path.display()
))
}
}
pub fn read_image(path: &Path) -> Result<ImageBuffer, String> {
match detect_format(path)? {
ImageFormat::Fits => fits_io::read_fits_image(path),
ImageFormat::Xisf => xisf_io::read_xisf_image(path),
ImageFormat::Asdf => asdf_io::read_asdf_image(path),
}
}
pub fn read_ra_dec(path: &Path) -> Option<(f64, f64)> {
match detect_format(path).ok()? {
ImageFormat::Fits => fits_io::read_fits_ra_dec(path),
ImageFormat::Xisf => xisf_io::read_xisf_ra_dec(path),
ImageFormat::Asdf => asdf_io::read_asdf_ra_dec(path),
}
}
pub fn read_pixel_scale(path: &Path) -> Option<f64> {
match detect_format(path).ok()? {
ImageFormat::Fits => fits_io::read_fits_pixel_scale(path),
ImageFormat::Xisf => xisf_io::read_xisf_pixel_scale(path),
ImageFormat::Asdf => asdf_io::read_asdf_pixel_scale(path),
}
}
pub fn read_dimensions(path: &Path) -> Option<(u32, u32)> {
match detect_format(path).ok()? {
ImageFormat::Fits => fits_io::read_fits_dimensions(path),
ImageFormat::Xisf => xisf_io::read_xisf_dimensions(path),
ImageFormat::Asdf => asdf_io::read_asdf_dimensions(path),
}
}
pub fn update_wcs(path: &Path, wcs: &WcsSolution) -> Result<(), String> {
match detect_format(path)? {
ImageFormat::Fits => fits_io::update_fits_wcs(path, wcs),
other => Err(format!(
"--update writes the solution into the image header, which is only \
supported for FITS (this file is {}). The .wcs and .ini files were \
still written.",
other.name()
)),
}
}
pub fn pixel_scale_from(
focallen_mm: Option<f64>,
xpixsz_um: Option<f64>,
xbinning: Option<f64>,
) -> Option<f64> {
let fl = focallen_mm.filter(|v| *v > 0.0)?;
let ps = xpixsz_um.filter(|v| *v > 0.0)?;
let bin = xbinning.filter(|v| *v > 0.0).unwrap_or(1.0);
Some(ps * bin / fl * 206.265)
}
pub fn ra_dec_from(
ra: Option<f64>,
dec: Option<f64>,
crval1: Option<f64>,
crval2: Option<f64>,
) -> Option<(f64, f64)> {
Some((ra.or(crval1)?, dec.or(crval2)?))
}
#[cfg(test)]
mod tests {
use super::*;
fn write(name: &str, bytes: &[u8]) -> std::path::PathBuf {
let p = std::env::temp_dir().join(name);
std::fs::write(&p, bytes).unwrap();
p
}
#[test]
fn detects_by_content_not_extension() {
let f = write("arcsec_fmt_a.dat", b"SIMPLE = T");
assert_eq!(detect_format(&f), Ok(ImageFormat::Fits));
let x = write("arcsec_fmt_b.fits", b"XISF0100\x00\x00\x00\x00");
assert_eq!(
detect_format(&x),
Ok(ImageFormat::Xisf),
"extension must not win"
);
let a = write("arcsec_fmt_c.fits", b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n");
assert_eq!(detect_format(&a), Ok(ImageFormat::Asdf));
for p in [f, x, a] {
std::fs::remove_file(p).ok();
}
}
#[test]
fn unknown_and_missing_files_are_rejected() {
let u = write("arcsec_fmt_d.bin", b"not any known magic");
assert!(
detect_format(&u).is_err(),
"junk must not be taken for FITS"
);
std::fs::remove_file(&u).ok();
assert!(
detect_format(Path::new("/nonexistent/arcsec/none.fits")).is_err(),
"a missing file must not be taken for FITS"
);
}
#[test]
fn compressed_fits_and_extended_syntax_still_reach_cfitsio() {
let g = write("arcsec_fmt_e.fits", b"\x1f\x8b\x08\x00rest-is-deflate");
assert!(
detect_format(&g).is_err_and(|e| e.contains("gzip")),
"gzip is refused"
);
std::fs::remove_file(&g).ok();
let b = write("arcsec_fmt_f.fits", b"BZh91AY&SY");
assert_eq!(detect_format(&b), Ok(ImageFormat::Fits), "bzip2");
std::fs::remove_file(&b).ok();
assert_eq!(
detect_format(Path::new("/nonexistent/arcsec/none.fits[1]")),
Ok(ImageFormat::Fits)
);
}
#[test]
fn pixel_scale_formula() {
let ps = pixel_scale_from(Some(250.0), Some(3.76), Some(1.0)).unwrap();
assert!((ps - 3.1022).abs() < 1e-3, "got {ps}");
let ps2 = pixel_scale_from(Some(250.0), Some(3.76), Some(2.0)).unwrap();
assert!((ps2 - 2.0 * ps).abs() < 1e-9);
assert!(pixel_scale_from(None, Some(3.76), None).is_none());
assert!(pixel_scale_from(Some(250.0), None, None).is_none());
assert!(pixel_scale_from(Some(0.0), Some(3.76), None).is_none());
assert!(pixel_scale_from(Some(250.0), Some(-1.0), None).is_none());
assert!(pixel_scale_from(Some(250.0), Some(3.76), None).is_some());
}
#[test]
fn ra_dec_prefers_the_telescope_over_crval() {
assert_eq!(
ra_dec_from(Some(10.0), Some(20.0), Some(99.0), Some(99.0)),
Some((10.0, 20.0))
);
assert_eq!(
ra_dec_from(None, None, Some(30.0), Some(40.0)),
Some((30.0, 40.0))
);
assert_eq!(ra_dec_from(Some(10.0), None, None, None), None);
}
}