use std::io::Read;
use std::path::Path;
use arcsec_core::types::{ImageBuffer, WcsSolution};
use arcsec_core::wcs::TanWcs;
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"SIMPLE =")
|| head.starts_with(b"\x1f\x8b") || 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 read_header_wcs(path: &Path) -> Option<TanWcs> {
match detect_format(path).ok()? {
ImageFormat::Fits => fits_io::read_fits_header_wcs(path),
ImageFormat::Xisf => xisf_io::read_xisf_header_wcs(path),
ImageFormat::Asdf => None,
}
}
pub fn read_channels(path: &Path) -> usize {
match detect_format(path) {
Ok(ImageFormat::Fits) => fits_io::read_fits_channels(path),
Ok(ImageFormat::Xisf) => xisf_io::read_xisf_channels(path),
Ok(ImageFormat::Asdf) | Err(_) => 1,
}
}
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 tan_wcs_from(mut key: impl FnMut(&str) -> Option<f64>) -> Option<TanWcs> {
let cd1_1 = key("CD1_1").filter(|v| *v != 0.0)?;
let wcs = TanWcs {
ra0: key("CRVAL1")?.to_radians(),
dec0: key("CRVAL2")?.to_radians(),
crpix1: key("CRPIX1")?,
crpix2: key("CRPIX2")?,
cd: [
[cd1_1, key("CD1_2").unwrap_or(0.0)],
[key("CD2_1").unwrap_or(0.0), key("CD2_2").unwrap_or(0.0)],
],
sip: None,
};
let finite = [wcs.ra0, wcs.dec0, wcs.crpix1, wcs.crpix2]
.into_iter()
.chain(wcs.cd.into_iter().flatten())
.all(f64::is_finite);
finite.then_some(wcs)
}
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_eq!(detect_format(&g), Ok(ImageFormat::Fits), "gzip");
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 a_header_wcs_needs_a_cd_matrix() {
let full = |k: &str| match k {
"CRVAL1" => Some(150.0),
"CRVAL2" => Some(40.0),
"CRPIX1" => Some(100.5),
"CRPIX2" => Some(80.5),
"CD1_1" => Some(-2e-4),
"CD2_2" => Some(2e-4),
_ => None,
};
let w = tan_wcs_from(full).unwrap();
assert_eq!(w.cd, [[-2e-4, 0.0], [0.0, 2e-4]]);
let (ra, dec) = w.pixel_to_sky(100.5, 80.5);
assert!((ra.to_degrees() - 150.0).abs() < 1e-9 && (dec.to_degrees() - 40.0).abs() < 1e-9);
assert!(tan_wcs_from(|k| if k == "CD1_1" { None } else { full(k) }).is_none());
assert!(tan_wcs_from(|k| if k == "CD1_1" { Some(0.0) } else { full(k) }).is_none());
assert!(tan_wcs_from(|k| if k == "CRPIX2" { None } else { full(k) }).is_none());
}
#[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);
}
}