use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand, ValueEnum};
use seiza::blind::BlindIndex;
use seiza::catalog::{StarCatalog, TileCatalog};
use seiza::minor_bodies::MinorBodyCatalog;
use seiza::objects::{ObjectCatalog, ObjectKind, ObjectQuery, ObjectSort, SkyRegion};
use seiza::solve::{SolveHint, solve};
use seiza::star_ids::{StarIdentifierCatalog, StarLookupMatch};
use seiza::{DetectBackend, DetectConfig, DetectedStar};
use std::path::PathBuf;
mod astap;
mod build_data;
mod worker;
fn is_fits_path(path: &std::path::Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("fits") || e.eq_ignore_ascii_case("fit"))
}
pub(crate) enum LoadedImage {
Dynamic(image::DynamicImage),
FitsF32 {
luma: Vec<f32>,
width: u32,
height: u32,
},
}
impl LoadedImage {
pub(crate) fn dimensions(&self) -> (u32, u32) {
match self {
Self::Dynamic(image) => (image.width(), image.height()),
Self::FitsF32 { width, height, .. } => (*width, *height),
}
}
pub(crate) fn detect_stars(&self, config: &DetectConfig) -> Vec<DetectedStar> {
match self {
Self::Dynamic(image) => seiza::detect_stars(image, config),
Self::FitsF32 {
luma,
width,
height,
} => seiza::detect_stars_luma_f32(luma, *width, *height, config),
}
}
fn is_converted_8bit_color(&self) -> bool {
matches!(
self,
Self::Dynamic(
image::DynamicImage::ImageLumaA8(_)
| image::DynamicImage::ImageRgb8(_)
| image::DynamicImage::ImageRgba8(_)
)
)
}
fn to_rgb8(&self) -> image::RgbImage {
match self {
Self::Dynamic(image) => image.to_rgb8(),
Self::FitsF32 {
luma,
width,
height,
} => {
let mut rgb = Vec::with_capacity(luma.len() * 3);
for &value in luma {
let value = (value.clamp(0.0, 1.0) * 255.0).round() as u8;
rgb.extend_from_slice(&[value; 3]);
}
image::RgbImage::from_raw(*width, *height, rgb)
.expect("FITS luma dimensions were validated when loaded")
}
}
}
fn to_luma8(&self) -> image::GrayImage {
match self {
Self::Dynamic(image) => image.to_luma8(),
Self::FitsF32 {
luma,
width,
height,
} => {
let luma = luma
.iter()
.map(|value| (value.clamp(0.0, 1.0) * 255.0).round() as u8)
.collect();
image::GrayImage::from_raw(*width, *height, luma)
.expect("FITS luma dimensions were validated when loaded")
}
}
}
}
pub(crate) fn load_image(path: &std::path::Path, backend: DetectBackend) -> Result<LoadedImage> {
if is_fits_path(path) {
let fits = seiza_fits::FitsImage::open(path)
.map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
let width = u32::try_from(fits.width)
.map_err(|_| anyhow::anyhow!("FITS width exceeds supported image dimensions"))?;
let height = u32::try_from(fits.height)
.map_err(|_| anyhow::anyhow!("FITS height exceeds supported image dimensions"))?;
if backend == DetectBackend::F32 {
return Ok(LoadedImage::FitsF32 {
luma: fits.to_luma_f32(),
width,
height,
});
}
let stretched = fits.stretch_to_u8(&seiza_fits::StretchParams::default());
let buffer = image::GrayImage::from_raw(width, height, stretched)
.ok_or_else(|| anyhow::anyhow!("FITS dimensions mismatch"))?;
return Ok(LoadedImage::Dynamic(image::DynamicImage::ImageLuma8(
buffer,
)));
}
image::open(path)
.map(LoadedImage::Dynamic)
.with_context(|| format!("failed to open {}", path.display()))
}
fn auto_can_retry_f32(path: &std::path::Path, image: &LoadedImage, backend: DetectBackend) -> bool {
backend == DetectBackend::Auto && (is_fits_path(path) || image.is_converted_8bit_color())
}
fn redetect_f32(
path: &std::path::Path,
image: &LoadedImage,
config: &DetectConfig,
) -> Result<Vec<DetectedStar>> {
let config = DetectConfig {
backend: DetectBackend::F32,
..config.clone()
};
if is_fits_path(path) {
return Ok(load_image(path, DetectBackend::F32)?.detect_stars(&config));
}
Ok(image.detect_stars(&config))
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub(crate) enum DetectionFallback {
None,
#[default]
F32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DetectionPass {
Primary,
F32Fallback,
}
pub(crate) struct SolveInvocation<'a> {
path: &'a std::path::Path,
image: &'a LoadedImage,
config: DetectConfig,
fallback: DetectionFallback,
primary_stars: Vec<DetectedStar>,
f32_stars: Option<Vec<DetectedStar>>,
active_f32: bool,
}
impl<'a> SolveInvocation<'a> {
pub(crate) fn new(
path: &'a std::path::Path,
image: &'a LoadedImage,
config: DetectConfig,
fallback: DetectionFallback,
) -> Self {
let primary_stars = image.detect_stars(&config);
Self {
path,
image,
config,
fallback,
primary_stars,
f32_stars: None,
active_f32: false,
}
}
pub(crate) fn stars(&self) -> &[DetectedStar] {
if self.active_f32 {
self.f32_stars
.as_deref()
.expect("active f32 solve has f32 detections")
} else {
&self.primary_stars
}
}
pub(crate) fn solve<T>(
&mut self,
mut solver: impl FnMut(&[DetectedStar]) -> std::result::Result<T, seiza::Error>,
) -> Result<T> {
self.solve_with_pass(|stars, _| solver(stars))
}
pub(crate) fn solve_with_pass<T>(
&mut self,
mut solver: impl FnMut(&[DetectedStar], DetectionPass) -> std::result::Result<T, seiza::Error>,
) -> Result<T> {
match solver(&self.primary_stars, DetectionPass::Primary) {
Ok(solution) => Ok(solution),
Err(seiza::Error::Solve(_))
if self.fallback == DetectionFallback::F32
&& auto_can_retry_f32(self.path, self.image, self.config.backend) =>
{
eprintln!("u8 detection did not solve; retrying with f32 detection");
if self.f32_stars.is_none() {
self.f32_stars = Some(redetect_f32(self.path, self.image, &self.config)?);
}
let result = solver(
self.f32_stars.as_deref().expect("f32 stars were populated"),
DetectionPass::F32Fallback,
);
if result.is_ok() {
self.active_f32 = true;
}
result.map_err(anyhow::Error::from)
}
Err(error) => Err(anyhow::Error::from(error)),
}
}
}
fn blind_params_for_detection_pass(
params: &seiza::blind::BlindParams,
can_retry_f32: bool,
fallback_hypotheses: usize,
pass: DetectionPass,
) -> seiza::blind::BlindParams {
let mut attempt = params.clone();
if can_retry_f32 && pass == DetectionPass::Primary && fallback_hypotheses > 0 {
attempt.max_hypotheses = attempt.max_hypotheses.min(fallback_hypotheses);
}
attempt
}
fn fits_hint(path: &std::path::Path) -> Option<(f64, f64)> {
let fits = seiza_fits::FitsImage::open(path).ok()?;
let ra = fits
.header_f64("RA")
.or_else(|| fits.header_f64("OBJCTRA"))?;
let dec = fits
.header_f64("DEC")
.or_else(|| fits.header_f64("OBJCTDEC"))?;
Some((ra, dec))
}
#[derive(Parser)]
#[command(name = "seiza", about = "Star detection and plate solving", version)]
struct Cli {
#[arg(long, value_enum, default_value = "auto", global = true)]
detection_backend: DetectionBackendArg,
#[arg(long, value_enum, default_value = "f32", global = true)]
detection_fallback: DetectionFallback,
#[arg(long, default_value_t = 64, global = true)]
detection_fallback_hypotheses: usize,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum DetectionBackendArg {
Auto,
U8,
F32,
}
impl From<DetectionBackendArg> for DetectBackend {
fn from(value: DetectionBackendArg) -> Self {
match value {
DetectionBackendArg::Auto => Self::Auto,
DetectionBackendArg::U8 => Self::U8,
DetectionBackendArg::F32 => Self::F32,
}
}
}
#[derive(Subcommand)]
enum Command {
Detect {
image: PathBuf,
#[arg(long, default_value_t = 4.0)]
sigma: f32,
#[arg(long, default_value_t = 100)]
max_stars: usize,
#[arg(long)]
annotate: Option<PathBuf>,
},
Solve {
image: PathBuf,
#[arg(long)]
data: PathBuf,
#[arg(long, allow_negative_numbers = true)]
ra: Option<f64>,
#[arg(long, allow_negative_numbers = true)]
dec: Option<f64>,
#[arg(long, default_value_t = 2.0)]
radius: f64,
#[arg(long)]
scale: f64,
#[arg(long, default_value_t = 0.2)]
scale_tolerance: f64,
#[arg(long, default_value_t = 4.0)]
sigma: f32,
#[arg(long, default_value_t = 0)]
ignore_border: u32,
#[arg(long)]
annotate: Option<PathBuf>,
#[arg(long)]
objects: Option<PathBuf>,
#[arg(long)]
minor_bodies: Option<PathBuf>,
#[arg(long)]
time: Option<String>,
},
DownloadData {
#[command(subcommand)]
source: DownloadSource,
},
BuildData {
#[command(subcommand)]
source: BuildDataSource,
},
BuildBlindIndex {
#[arg(long)]
data: PathBuf,
#[arg(long)]
output: PathBuf,
#[arg(long, default_value_t = 16.0)]
index_mag_limit: f32,
#[arg(long, default_value_t = 6.0)]
max_pattern_deg: f64,
},
SolveBlind {
image: PathBuf,
#[arg(long)]
data: PathBuf,
#[arg(long)]
index: Option<PathBuf>,
#[arg(long, default_value_t = 0.1)]
min_scale: f64,
#[arg(long, default_value_t = 20.0)]
max_scale: f64,
#[arg(long, default_value_t = 12.7)]
index_mag_limit: f32,
#[arg(long, default_value_t = 400)]
max_hypotheses: usize,
#[arg(long, default_value_t = 20_000)]
max_coarse_hypotheses: usize,
#[arg(long, default_value_t = 4.0)]
sigma: f32,
#[arg(long, default_value_t = 0)]
ignore_border: u32,
},
Worker {
#[arg(long, required_unless_present = "server", conflicts_with = "server")]
data: Option<PathBuf>,
#[arg(long, requires = "data", conflicts_with = "server")]
index: Option<PathBuf>,
#[arg(long)]
server: Option<String>,
#[arg(long, requires = "server")]
server_token: Option<String>,
#[arg(long, value_enum, requires = "server")]
server_upload: Option<worker::ServerUploadFormat>,
#[arg(
long,
value_name = "SECONDS",
requires = "server",
value_parser = clap::value_parser!(u64).range(1..)
)]
server_timeout: Option<u64>,
},
FitsInfo {
image: PathBuf,
#[arg(long)]
stretch: Option<PathBuf>,
},
Cone {
#[arg(long)]
data: PathBuf,
#[arg(long, allow_negative_numbers = true)]
ra: f64,
#[arg(long, allow_negative_numbers = true)]
dec: f64,
#[arg(long, default_value_t = 1.0)]
radius: f64,
#[arg(long, default_value_t = 25)]
limit: usize,
},
Catalog {
#[command(subcommand)]
query: CatalogCommand,
},
}
#[derive(Subcommand)]
enum CatalogCommand {
Object {
#[command(flatten)]
args: CatalogObjectArgs,
},
Objects {
#[command(flatten)]
args: CatalogObjectsArgs,
},
Star {
#[command(flatten)]
args: CatalogStarArgs,
},
Validate {
#[arg(long)]
data: PathBuf,
},
}
#[derive(Args)]
struct CatalogObjectArgs {
#[arg(long)]
data: PathBuf,
query: String,
#[arg(long)]
prefix: bool,
#[arg(long, default_value_t = 25)]
limit: usize,
#[arg(long, value_enum, default_value_t = CatalogOutputFormat::Table)]
format: CatalogOutputFormat,
}
#[derive(Args)]
struct CatalogStarArgs {
#[arg(long)]
data: PathBuf,
query: String,
#[arg(long)]
prefix: bool,
#[arg(long, default_value_t = 25)]
limit: usize,
#[arg(long, value_enum, default_value_t = CatalogOutputFormat::Table)]
format: CatalogOutputFormat,
}
#[derive(Args)]
struct CatalogObjectsArgs {
#[arg(long)]
data: PathBuf,
#[arg(long, allow_negative_numbers = true)]
ra: Option<f64>,
#[arg(long, allow_negative_numbers = true)]
dec: Option<f64>,
#[arg(long)]
radius: Option<f64>,
#[arg(
long,
value_name = "RA,DEC",
allow_hyphen_values = true,
value_parser = parse_sky_coordinate
)]
corner: Vec<SkyCoordinate>,
#[arg(long, value_delimiter = ',', value_parser = parse_object_kind)]
kind: Vec<ObjectKind>,
#[arg(long, allow_negative_numbers = true)]
max_mag: Option<f32>,
#[arg(long)]
min_size: Option<f32>,
#[arg(long)]
common_name_only: bool,
#[arg(long)]
center_only: bool,
#[arg(long, default_value_t = 25)]
limit: usize,
#[arg(long, value_enum, default_value_t = CatalogSortArg::Prominence)]
sort: CatalogSortArg,
#[arg(long, value_enum, default_value_t = CatalogOutputFormat::Table)]
format: CatalogOutputFormat,
}
#[derive(Debug, Clone, Copy)]
struct SkyCoordinate {
ra: f64,
dec: f64,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CatalogSortArg {
Prominence,
Size,
Magnitude,
Distance,
Name,
}
impl From<CatalogSortArg> for ObjectSort {
fn from(value: CatalogSortArg) -> Self {
match value {
CatalogSortArg::Prominence => Self::Prominence,
CatalogSortArg::Size => Self::Size,
CatalogSortArg::Magnitude => Self::Magnitude,
CatalogSortArg::Distance => Self::Distance,
CatalogSortArg::Name => Self::Name,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CatalogOutputFormat {
Table,
Json,
Csv,
}
#[derive(Subcommand)]
enum DownloadSource {
Tycho2 {
#[arg(long)]
output: PathBuf,
},
StarIdentifiers {
#[arg(long)]
output: PathBuf,
},
Openngc {
#[arg(long)]
output: PathBuf,
},
Objects {
#[arg(long)]
output: PathBuf,
},
Gaia {
#[arg(long)]
output: PathBuf,
#[arg(long, default_value_t = 15.0, allow_negative_numbers = true)]
max_mag: f32,
#[arg(
long,
default_value_t = 768,
value_parser = clap::value_parser!(u64).range(1..)
)]
chunks: u64,
},
Transients {
#[arg(long)]
output: PathBuf,
},
Mpc {
#[arg(long)]
output: PathBuf,
},
Prebuilt {
#[arg(long)]
output: PathBuf,
#[arg(long)]
file: Vec<String>,
},
}
#[derive(Subcommand)]
enum BuildDataSource {
Astap {
#[arg(long)]
input: PathBuf,
#[arg(long)]
output: PathBuf,
#[arg(long, default_value_t = 2025.0)]
epoch: f64,
#[arg(long, default_value_t = 21.0)]
max_mag: f32,
#[arg(long, default_value_t = 180)]
bands: u32,
},
Tycho2 {
#[arg(long)]
input: PathBuf,
#[arg(long)]
output: PathBuf,
#[arg(long)]
identifier_index: Option<PathBuf>,
#[arg(long, requires = "identifier_index")]
identifier_sources: Option<PathBuf>,
#[arg(long, default_value_t = 2025.5)]
epoch: f64,
#[arg(long, default_value_t = 13.0)]
max_mag: f32,
},
Objects {
#[arg(long)]
input: PathBuf,
#[arg(long)]
output: PathBuf,
#[arg(long)]
source_manifest: Option<PathBuf>,
},
Gaia {
#[arg(long)]
input: PathBuf,
#[arg(long)]
output: PathBuf,
#[arg(long, default_value_t = 2025.5)]
epoch: f64,
#[arg(long, default_value_t = 15.0)]
max_mag: f32,
#[arg(long, default_value_t = 180)]
bands: u32,
},
Transients {
#[arg(long)]
input: PathBuf,
#[arg(long)]
output: PathBuf,
},
MinorBodies {
#[arg(long)]
input: PathBuf,
#[arg(long)]
output: PathBuf,
#[arg(long, default_value_t = 16.0)]
max_h: f32,
},
Manifest {
#[arg(long)]
dir: PathBuf,
#[arg(long)]
version: String,
#[arg(long)]
output: PathBuf,
},
}
fn main() -> Result<()> {
let raw: Vec<String> = std::env::args().skip(1).collect();
if astap::looks_like_astap(&raw) {
return astap::run(&raw);
}
let cli = Cli::parse();
let detection_backend = cli.detection_backend.into();
let detection_fallback = cli.detection_fallback;
let detection_fallback_hypotheses = cli.detection_fallback_hypotheses;
match cli.command {
Command::Detect {
image,
sigma,
max_stars,
annotate,
} => detect(
&image,
sigma,
max_stars,
detection_backend,
annotate.as_deref(),
),
Command::Solve {
image,
data,
ra,
dec,
radius,
scale,
scale_tolerance,
sigma,
ignore_border,
annotate,
objects,
minor_bodies,
time,
} => {
let hint = match (ra, dec) {
(Some(ra), Some(dec)) => (ra, dec),
_ => fits_hint(&image).ok_or_else(|| {
anyhow::anyhow!("--ra/--dec required (no RA/DEC headers found in the image)")
})?,
};
let acquisition_jd = resolve_acquisition_jd(&image, time.as_deref())?;
solve_command(
&image,
&data,
hint,
radius,
scale,
scale_tolerance,
sigma,
ignore_border,
detection_backend,
detection_fallback,
annotate.as_deref(),
objects.as_deref(),
minor_bodies.as_deref(),
acquisition_jd,
)
}
Command::DownloadData { source } => run_download_command(source),
Command::BuildData { source } => match source {
BuildDataSource::Astap {
input,
output,
epoch,
max_mag,
bands,
} => build_data::build_astap(&input, &output, epoch, max_mag, bands),
BuildDataSource::Tycho2 {
input,
output,
identifier_index,
identifier_sources,
epoch,
max_mag,
} => build_data::build_tycho2(
&input,
&output,
identifier_index.as_deref(),
identifier_sources.as_deref(),
epoch,
max_mag,
),
BuildDataSource::Objects {
input,
output,
source_manifest,
} => build_data::build_objects(&input, &output, source_manifest.as_deref()),
BuildDataSource::Gaia {
input,
output,
epoch,
max_mag,
bands,
} => build_data::build_gaia(&input, &output, epoch, max_mag, bands),
BuildDataSource::Transients { input, output } => {
build_data::build_transients(&input, &output)
}
BuildDataSource::MinorBodies {
input,
output,
max_h,
} => build_data::build_minor_bodies(&input, &output, max_h),
BuildDataSource::Manifest {
dir,
version,
output,
} => build_data::build_manifest(&dir, &version, &output),
},
Command::BuildBlindIndex {
data,
output,
index_mag_limit,
max_pattern_deg,
} => build_blind_index_command(&data, &output, index_mag_limit, max_pattern_deg),
Command::SolveBlind {
image,
data,
index,
min_scale,
max_scale,
index_mag_limit,
max_hypotheses,
max_coarse_hypotheses,
sigma,
ignore_border,
} => solve_blind_command(
&image,
&data,
SolveBlindOptions {
index_path: index.as_deref(),
min_scale,
max_scale,
index_mag_limit,
max_hypotheses,
max_coarse_hypotheses,
sigma,
ignore_border,
detection_backend,
detection_fallback,
detection_fallback_hypotheses,
},
),
Command::Worker {
data,
index,
server,
server_token,
server_upload,
server_timeout,
} => {
let server_token = server_token.or_else(|| std::env::var("SEIZA_SERVER_TOKEN").ok());
worker::run(worker::WorkerOptions {
data_path: data.as_deref(),
index_path: index.as_deref(),
server: server.as_deref(),
server_token: server_token.as_deref(),
server_upload: server_upload.unwrap_or(worker::ServerUploadFormat::Png),
server_timeout: std::time::Duration::from_secs(server_timeout.unwrap_or(300)),
detection_backend,
detection_fallback,
detection_fallback_hypotheses,
})
}
Command::FitsInfo { image, stretch } => fits_info(&image, stretch.as_deref()),
Command::Cone {
data,
ra,
dec,
radius,
limit,
} => cone(&data, ra, dec, radius, limit),
Command::Catalog { query } => match query {
CatalogCommand::Object { args } => catalog_object(args),
CatalogCommand::Objects { args } => catalog_objects(args),
CatalogCommand::Star { args } => catalog_star(args),
CatalogCommand::Validate { data } => catalog_validate(&data),
},
}
}
fn run_download_command(source: DownloadSource) -> Result<()> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("failed to start the download runtime")?
.block_on(download_command(source))
}
async fn download_command(source: DownloadSource) -> Result<()> {
match source {
DownloadSource::Prebuilt { output, file } => {
let selection = seiza_download::CatalogSet::from_names(file)?;
let manager = seiza_download::CatalogManager::builder()
.policy(seiza_download::CachePolicy::ForceRefresh)
.build()?;
let bundle = manager
.ensure_with(&selection, report_download_event)
.await?;
let paths = bundle.materialize(&output).await?;
println!(
"{} dataset(s) from {} ready in {}",
paths.len(),
bundle.version,
output.display()
);
}
source => download_source(source).await?,
}
Ok(())
}
async fn download_source(source: DownloadSource) -> Result<()> {
let downloader = seiza_sources::SourceDownloader::with_reporter(report_source_event)?;
match source {
DownloadSource::Tycho2 { output } => downloader.download_tycho2(output).await,
DownloadSource::StarIdentifiers { output } => {
downloader.download_star_identifiers(output).await
}
DownloadSource::Openngc { output } => downloader.download_openngc(output).await,
DownloadSource::Objects { output } => downloader.download_objects(output).await,
DownloadSource::Gaia {
output,
max_mag,
chunks,
} => downloader.download_gaia(output, max_mag, chunks).await,
DownloadSource::Transients { output } => downloader.download_transients(output).await,
DownloadSource::Mpc { output } => downloader.download_mpc(output).await,
DownloadSource::Prebuilt { .. } => unreachable!("handled by download_command"),
}?;
Ok(())
}
fn report_download_event(event: seiza_download::DownloadEvent) {
use seiza_download::DownloadEvent;
match event {
DownloadEvent::FetchingManifest { url } => println!(" fetching {url}"),
DownloadEvent::UsingCachedManifest { version, stale } => {
let qualifier = if stale { "stale " } else { "" };
println!(" using {qualifier}cached manifest {version}");
}
DownloadEvent::CacheHit { name, .. } => println!(" {name} already cached"),
DownloadEvent::DownloadStarted { name, bytes } => {
println!(" fetching {name} ({bytes} bytes)")
}
DownloadEvent::DownloadComplete { name, .. } => println!(" cached {name}"),
DownloadEvent::DownloadProgress { .. } => {}
}
}
fn report_source_event(event: seiza_sources::SourceEvent) {
use seiza_sources::SourceEvent;
match event {
SourceEvent::AlreadyPresent { path } => println!(" {} already present", path.display()),
SourceEvent::Fetching { url, .. } => println!(" fetching {url}"),
SourceEvent::Progress { .. } => {}
SourceEvent::Retry {
label,
attempt,
delay,
error,
} => eprintln!(
" {label} attempt {attempt} failed: {error}; retrying in {}s",
delay.as_secs()
),
SourceEvent::GaiaChunkComplete {
chunk,
rows,
completed,
total,
} => println!(" chunk {chunk:04}: {rows} rows ({completed}/{total})"),
SourceEvent::Ready { source, directory } => {
println!("{source} ready in {}", directory.display())
}
}
}
fn catalog_object(args: CatalogObjectArgs) -> Result<()> {
let catalog = ObjectCatalog::open(&args.data)
.with_context(|| format!("failed to open {}", args.data.display()))?;
let matches = if args.prefix {
catalog.search_names(&args.query, args.limit)?
} else {
catalog.lookup_name(&args.query)?
};
match args.format {
CatalogOutputFormat::Table => {
println!(
"{} object name match{}:",
matches.len(),
if matches.len() == 1 { "" } else { "es" }
);
println!(
"{:<24} {:<18} {:<28} {:>10} {:>10} id",
"matched", "kind", "object", "ra", "dec"
);
for item in &matches {
println!(
"{:<24} {:<18} {:<28} {:>10.5} {:>10.5} {}",
item.matched_name,
item.object.kind.as_str(),
item.object.name,
item.object.ra,
item.object.dec,
item.object.metadata.id,
);
}
}
CatalogOutputFormat::Json => {
let values = matches
.iter()
.map(|item| {
let object = &item.object;
serde_json::json!({
"matched_name": item.matched_name,
"kind": object.kind.as_str(),
"name": object.name,
"common_name": object.common_name,
"id": object.metadata.id,
"source": object.metadata.source,
"aliases": object.metadata.aliases,
"parent_ids": object.metadata.parent_ids,
"alternate_ids": object.metadata.alternate_ids,
"alternate_sources": object.metadata.alternate_sources,
"ra_deg": object.ra,
"dec_deg": object.dec,
"mag": object.mag,
"major_arcmin": object.major_arcmin,
"minor_arcmin": object.minor_arcmin,
"position_angle_deg": object.position_angle_deg,
})
})
.collect::<Vec<_>>();
println!("{}", serde_json::to_string_pretty(&values)?);
}
CatalogOutputFormat::Csv => {
println!(
"matched_name,kind,name,common_name,ra_deg,dec_deg,mag,major_arcmin,id,source"
);
for item in &matches {
let object = &item.object;
println!(
"{},{},{},{},{:.8},{:.8},{},{},{},{}",
csv_field(&item.matched_name),
object.kind.as_str(),
csv_field(&object.name),
csv_field(&object.common_name),
object.ra,
object.dec,
csv_optional(object.mag),
csv_optional(object.major_arcmin),
csv_field(&object.metadata.id),
csv_field(&object.metadata.source),
);
}
}
}
Ok(())
}
fn fits_info(path: &std::path::Path, stretch: Option<&std::path::Path>) -> Result<()> {
let started = std::time::Instant::now();
let fits = seiza_fits::FitsImage::open(path)
.map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
let load_time = started.elapsed();
println!(
"{}: {}x{} ({:?}-type pixels), loaded in {:.0}ms",
path.display(),
fits.width,
fits.height,
match fits.pixels {
seiza_fits::Pixels::U8(_) => "u8",
seiza_fits::Pixels::U16(_) => "u16",
seiza_fits::Pixels::I32(_) => "i32",
seiza_fits::Pixels::F32(_) => "f32",
seiza_fits::Pixels::F64(_) => "f64",
},
load_time.as_secs_f64() * 1000.0
);
for key in [
"OBJECT", "FILTER", "EXPOSURE", "EXPTIME", "GAIN", "CCD-TEMP", "RA", "DEC", "INSTRUME",
"TELESCOP", "DATE-OBS", "BAYERPAT",
] {
if let Some(value) = fits.header(key) {
println!(" {key:<10} {value:?}");
}
}
let started = std::time::Instant::now();
let stats = fits.statistics();
println!(
" stats: median {} mad {:.0} mean {:.0} range {}..{} ({:.0}ms)",
stats.median,
stats.mad,
stats.mean,
stats.min,
stats.max,
started.elapsed().as_secs_f64() * 1000.0
);
if let Some(out) = stretch {
let started = std::time::Instant::now();
let data = fits.stretch_to_u8(&seiza_fits::StretchParams::default());
let elapsed = started.elapsed();
image::GrayImage::from_raw(fits.width as u32, fits.height as u32, data)
.ok_or_else(|| anyhow::anyhow!("dimension mismatch"))?
.save(out)?;
println!(
" stretched preview written to {} ({:.0}ms stretch)",
out.display(),
elapsed.as_secs_f64() * 1000.0
);
}
Ok(())
}
fn detect(
path: &std::path::Path,
sigma: f32,
max_stars: usize,
detection_backend: DetectBackend,
annotate: Option<&std::path::Path>,
) -> Result<()> {
let img = load_image(path, detection_backend)?;
let config = DetectConfig {
backend: detection_backend,
sigma,
max_stars,
..Default::default()
};
let stars = img.detect_stars(&config);
println!("{} stars detected in {}", stars.len(), path.display());
println!(
"{:>10} {:>10} {:>12} {:>8} {:>6}",
"x", "y", "flux", "peak", "area"
);
for star in &stars {
println!(
"{:>10.2} {:>10.2} {:>12.1} {:>8.3} {:>6}",
star.x, star.y, star.flux, star.peak, star.area
);
}
if let Some(out) = annotate {
let mut canvas = img.to_rgb8();
for star in &stars {
let radius = (star.area as f32).sqrt().max(6.0) as i32 + 4;
imageproc::drawing::draw_hollow_circle_mut(
&mut canvas,
(star.x.round() as i32, star.y.round() as i32),
radius,
image::Rgb([64, 255, 64]),
);
}
canvas
.save(out)
.with_context(|| format!("failed to write {}", out.display()))?;
println!("annotated image written to {}", out.display());
}
Ok(())
}
fn cone(data: &std::path::Path, ra: f64, dec: f64, radius: f64, limit: usize) -> Result<()> {
let catalog =
TileCatalog::open(data).with_context(|| format!("failed to open {}", data.display()))?;
println!(
"{} stars in catalog, epoch {}",
catalog.star_count(),
catalog.epoch()
);
let stars = catalog.cone_search(ra, dec, radius, limit);
println!("{} stars within {radius}° of ({ra}, {dec}):", stars.len());
println!("{:>12} {:>12} {:>7}", "ra", "dec", "mag");
for star in stars {
println!("{:>12.6} {:>12.6} {:>7.3}", star.ra, star.dec, star.mag);
}
Ok(())
}
fn parse_sky_coordinate(value: &str) -> std::result::Result<SkyCoordinate, String> {
let (ra, dec) = value
.split_once(',')
.ok_or_else(|| "expected RA,DEC in decimal degrees".to_string())?;
let ra = ra
.trim()
.parse::<f64>()
.map_err(|_| format!("invalid RA in {value:?}"))?;
let dec = dec
.trim()
.parse::<f64>()
.map_err(|_| format!("invalid Dec in {value:?}"))?;
Ok(SkyCoordinate { ra, dec })
}
fn parse_object_kind(value: &str) -> std::result::Result<ObjectKind, String> {
match value.trim().to_ascii_lowercase().as_str() {
"galaxy" => Ok(ObjectKind::Galaxy),
"open-cluster" => Ok(ObjectKind::OpenCluster),
"globular-cluster" => Ok(ObjectKind::GlobularCluster),
"nebula" => Ok(ObjectKind::Nebula),
"planetary-nebula" => Ok(ObjectKind::PlanetaryNebula),
"hii" | "hii-region" => Ok(ObjectKind::HiiRegion),
"snr" | "supernova-remnant" => Ok(ObjectKind::SupernovaRemnant),
"dark-nebula" => Ok(ObjectKind::DarkNebula),
"cluster-nebula" => Ok(ObjectKind::ClusterWithNebula),
"star" => Ok(ObjectKind::Star),
"double-star" => Ok(ObjectKind::DoubleStar),
"association" => Ok(ObjectKind::Association),
"other" => Ok(ObjectKind::Other),
"transient" => Ok(ObjectKind::Transient),
_ => Err(format!(
"unknown kind {value:?}; expected galaxy, open-cluster, globular-cluster, \
nebula, planetary-nebula, hii-region, supernova-remnant, dark-nebula, \
cluster-nebula, star, double-star, association, other, or transient"
)),
}
}
fn catalog_objects(args: CatalogObjectsArgs) -> Result<()> {
if args.max_mag.is_some_and(|value| !value.is_finite()) {
anyhow::bail!("--max-mag must be finite");
}
if args
.min_size
.is_some_and(|value| !value.is_finite() || value < 0.0)
{
anyhow::bail!("--min-size must be finite and non-negative");
}
let cone_requested = args.ra.is_some() || args.dec.is_some() || args.radius.is_some();
let region = if !args.corner.is_empty() {
if cone_requested {
anyhow::bail!("--corner conflicts with --ra, --dec, and --radius");
}
SkyRegion::Polygon {
vertices: args
.corner
.iter()
.map(|corner| (corner.ra, corner.dec))
.collect(),
}
} else {
let (Some(ra), Some(dec), Some(radius_deg)) = (args.ra, args.dec, args.radius) else {
anyhow::bail!(
"specify either --ra/--dec/--radius or at least three ordered --corner RA,DEC values"
);
};
SkyRegion::Cone {
center: (ra, dec),
radius_deg,
}
};
let catalog = ObjectCatalog::open(&args.data)
.with_context(|| format!("failed to open {}", args.data.display()))?;
let query = ObjectQuery {
kinds: args.kind,
max_mag: args.max_mag,
min_major_arcmin: args.min_size,
common_name_only: args.common_name_only,
include_extent_overlaps: !args.center_only,
limit: (args.limit > 0).then_some(args.limit),
sort: args.sort.into(),
};
let hits = catalog
.query_region(®ion, &query)
.map_err(|error| anyhow::anyhow!(error))?;
match args.format {
CatalogOutputFormat::Table => {
println!(
"{} of {} catalog objects matched:",
hits.len(),
catalog.len()
);
println!(
"{:>6} {:<7} {:<20} {:<30} {:>10} {:>10} {:>7} {:>8}",
"score", "match", "kind", "object", "ra", "dec", "mag", "size'"
);
for hit in &hits {
let object = &hit.object;
let name = if object.common_name.is_empty() {
object.name.clone()
} else {
format!("{} ({})", object.name, object.common_name)
};
let mag = object
.mag
.map(|value| format!("{value:.2}"))
.unwrap_or_else(|| "-".to_string());
let size = object
.major_arcmin
.map(|value| format!("{value:.1}"))
.unwrap_or_else(|| "-".to_string());
println!(
"{:>5.1}% {:<7} {:<20} {:<30} {:>10.5} {:>10.5} {:>7} {:>8}",
hit.predicted_prominence * 100.0,
if hit.extent_only { "extent" } else { "center" },
object.kind.as_str(),
name,
object.ra,
object.dec,
mag,
size
);
}
}
CatalogOutputFormat::Json => {
let region_json = match ®ion {
SkyRegion::Cone { center, radius_deg } => serde_json::json!({
"type": "cone",
"center": { "ra_deg": center.0, "dec_deg": center.1 },
"radius_deg": radius_deg,
}),
SkyRegion::Polygon { vertices } => serde_json::json!({
"type": "polygon",
"vertices": vertices.iter().map(|&(ra, dec)| {
serde_json::json!({ "ra_deg": ra, "dec_deg": dec })
}).collect::<Vec<_>>(),
}),
};
let objects = hits
.iter()
.map(|hit| {
let object = &hit.object;
serde_json::json!({
"kind": object.kind.as_str(),
"name": object.name,
"common_name": object.common_name,
"id": object.metadata.id,
"source": object.metadata.source,
"aliases": object.metadata.aliases,
"parent_ids": object.metadata.parent_ids,
"alternate_ids": object.metadata.alternate_ids,
"alternate_sources": object.metadata.alternate_sources,
"ra_deg": object.ra,
"dec_deg": object.dec,
"mag": object.mag,
"major_arcmin": object.major_arcmin,
"minor_arcmin": object.minor_arcmin,
"position_angle_deg": object.position_angle_deg,
"center_inside": hit.center_inside,
"extent_only": hit.extent_only,
"distance_from_center_deg": hit.distance_from_center_deg,
"predicted_prominence": hit.predicted_prominence,
})
})
.collect::<Vec<_>>();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"catalog": args.data.display().to_string(),
"catalog_objects": catalog.len(),
"region": region_json,
"returned": objects.len(),
"objects": objects,
}))?
);
}
CatalogOutputFormat::Csv => {
println!(
"kind,name,common_name,ra_deg,dec_deg,mag,major_arcmin,minor_arcmin,position_angle_deg,match,distance_from_center_deg,predicted_prominence,id,source,aliases,parent_ids,alternate_ids,alternate_sources"
);
for hit in &hits {
let object = &hit.object;
println!(
"{},{},{},{:.8},{:.8},{},{},{},{},{},{:.8},{:.8},{},{},{},{},{},{}",
object.kind.as_str(),
csv_field(&object.name),
csv_field(&object.common_name),
object.ra,
object.dec,
csv_optional(object.mag),
csv_optional(object.major_arcmin),
csv_optional(object.minor_arcmin),
csv_optional(object.position_angle_deg),
if hit.extent_only { "extent" } else { "center" },
hit.distance_from_center_deg,
hit.predicted_prominence,
csv_field(&object.metadata.id),
csv_field(&object.metadata.source),
csv_field(&object.metadata.aliases.join("|")),
csv_field(&object.metadata.parent_ids.join("|")),
csv_field(&object.metadata.alternate_ids.join("|")),
csv_field(&object.metadata.alternate_sources.join("|")),
);
}
}
}
Ok(())
}
fn catalog_star(args: CatalogStarArgs) -> Result<()> {
let catalog = StarIdentifierCatalog::open(&args.data)
.with_context(|| format!("failed to open {}", args.data.display()))?;
let matches = if args.prefix {
catalog
.search_names(&args.query, args.limit)?
.into_iter()
.map(StarLookupMatch::Name)
.collect::<Vec<_>>()
} else {
catalog.lookup_query(&args.query)?
};
let rows = matches.iter().map(catalog_star_row).collect::<Vec<_>>();
match args.format {
CatalogOutputFormat::Table => {
println!(
"{} {} match{} for {:?} in {} (epoch J{}):",
rows.len(),
if args.prefix { "prefix" } else { "exact" },
if rows.len() == 1 { "" } else { "es" },
args.query,
catalog.attribution(),
catalog.epoch()
);
if !rows.is_empty() {
println!(
"{:<22} {:<18} {:<17} {:<10} {:>11} {:>11} {:>7} stable ID",
"designation", "catalog", "kind", "detail", "ra", "dec", "mag"
);
for row in &rows {
println!(
"{:<22} {:<18} {:<17} {:<10} {:>11.6} {:>11.6} {:>7} {}",
row.designation,
row.catalog,
row.kind,
row.detail,
row.ra,
row.dec,
row.mag
.map(|value| format!("{value:.3}"))
.unwrap_or_else(|| "-".to_string()),
row.stable_id,
);
}
}
}
CatalogOutputFormat::Json => {
let matches = rows
.iter()
.map(|row| {
serde_json::json!({
"designation": row.designation,
"stable_id": row.stable_id,
"catalog": row.catalog,
"kind": row.kind,
"detail": row.detail,
"ra_deg": row.ra,
"dec_deg": row.dec,
"mag": row.mag,
})
})
.collect::<Vec<_>>();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"query": args.query,
"mode": if args.prefix { "prefix" } else { "exact" },
"source": catalog.attribution(),
"epoch": catalog.epoch(),
"numeric_entries": catalog.numeric_len(),
"name_entries": catalog.name_len(),
"returned": matches.len(),
"matches": matches,
}))?
);
}
CatalogOutputFormat::Csv => {
println!("designation,stable_id,catalog,kind,detail,ra_deg,dec_deg,mag,epoch,source");
for row in &rows {
println!(
"{},{},{},{},{},{:.8},{:.8},{},{},{}",
csv_field(&row.designation),
csv_field(&row.stable_id),
csv_field(&row.catalog),
csv_field(&row.kind),
csv_field(&row.detail),
row.ra,
row.dec,
row.mag
.map(|value| format!("{value:.3}"))
.unwrap_or_default(),
catalog.epoch(),
csv_field(catalog.attribution()),
);
}
}
}
Ok(())
}
fn catalog_validate(path: &std::path::Path) -> Result<()> {
use std::io::Read;
let mut file =
std::fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
let mut magic = [0u8; 8];
file.read_exact(&mut magic)
.with_context(|| format!("failed to read catalog header from {}", path.display()))?;
let summary = match &magic {
b"SEIZASI1" => {
let catalog = StarIdentifierCatalog::open(path)?;
catalog.validate()?;
format!(
"stellar identifier sidecar: {} numeric identifiers, {} names",
catalog.numeric_len(),
catalog.name_len()
)
}
b"SEIZAST1" | b"SEIZAST2" => {
let catalog = TileCatalog::open(path)?;
catalog.validate()?;
format!("star tile catalog: {} stars", catalog.star_count())
}
b"SEIZABI1" => {
let index = BlindIndex::open(path)?;
index.validate()?;
format!("blind-pattern index: {} patterns", index.pattern_count())
}
b"SEIZAOB1" | b"SEIZAOB3" => {
let catalog = ObjectCatalog::open(path)?;
catalog.validate()?;
format!("object catalog: {} objects", catalog.len())
}
b"SEIZAMB1" => {
let catalog = MinorBodyCatalog::open(path)?;
catalog.validate()?;
format!("minor-body catalog: {} bodies", catalog.len())
}
_ => anyhow::bail!("{} is not a recognized seiza catalog", path.display()),
};
println!("{}: valid {summary}", path.display());
Ok(())
}
struct CatalogStarRow {
designation: String,
stable_id: String,
catalog: String,
kind: String,
detail: String,
ra: f64,
dec: f64,
mag: Option<f32>,
}
fn catalog_star_row(value: &StarLookupMatch<'_>) -> CatalogStarRow {
match value {
StarLookupMatch::Identifier(star) => CatalogStarRow {
designation: star.identifier.to_string(),
stable_id: star.identifier.stable_id(),
catalog: star.identifier.namespace().as_str().to_string(),
kind: "catalog-identifier".to_string(),
detail: String::new(),
ra: star.ra,
dec: star.dec,
mag: Some(star.mag),
},
StarLookupMatch::Name(star) => CatalogStarRow {
designation: star.designation.to_string(),
stable_id: star.stable_id.to_string(),
catalog: star.catalog.as_str().to_string(),
kind: star.kind.as_str().to_string(),
detail: star.detail.to_string(),
ra: star.ra,
dec: star.dec,
mag: star.mag,
},
}
}
fn csv_field(value: &str) -> String {
if value.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value.to_string()
}
}
fn csv_optional(value: Option<f32>) -> String {
value.map(|value| value.to_string()).unwrap_or_default()
}
#[allow(clippy::too_many_arguments)]
fn resolve_acquisition_jd(image: &std::path::Path, time: Option<&str>) -> Result<Option<f64>> {
let text = match time {
Some(text) => Some(text.to_string()),
None => {
let is_fits = image
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("fits") || e.eq_ignore_ascii_case("fit"));
if is_fits {
seiza_fits::read_header(image).ok().and_then(|headers| {
headers
.iter()
.find(|(k, _)| k == "DATE-OBS")
.and_then(|(_, v)| v.as_str().map(str::to_string))
})
} else {
None
}
}
};
let Some(text) = text else { return Ok(None) };
parse_iso_jd(&text)
.map(Some)
.ok_or_else(|| anyhow::anyhow!("cannot parse acquisition time {text:?} as ISO 8601"))
}
fn parse_iso_jd(text: &str) -> Option<f64> {
let text = text.trim().trim_end_matches('Z');
let (date, clock) = match text.split_once('T') {
Some((d, t)) => (d, t),
None => (text, "0:0:0"),
};
let mut date_parts = date.split('-');
let year: i32 = date_parts.next()?.parse().ok()?;
let month: u32 = date_parts.next()?.parse().ok()?;
let day: u32 = date_parts.next()?.parse().ok()?;
let mut clock_parts = clock.split(':');
let hour: f64 = clock_parts.next()?.parse().ok()?;
let minute: f64 = clock_parts.next().unwrap_or("0").parse().ok()?;
let second: f64 = clock_parts.next().unwrap_or("0").parse().ok()?;
let day_fraction = day as f64 + (hour + minute / 60.0 + second / 3600.0) / 24.0;
Some(seiza::minor_bodies::julian_date(year, month, day_fraction))
}
#[allow(clippy::too_many_arguments)]
fn solve_command(
path: &std::path::Path,
data: &std::path::Path,
center: (f64, f64),
radius: f64,
scale: f64,
scale_tolerance: f64,
sigma: f32,
ignore_border: u32,
detection_backend: DetectBackend,
detection_fallback: DetectionFallback,
annotate: Option<&std::path::Path>,
objects: Option<&std::path::Path>,
minor_bodies: Option<&std::path::Path>,
acquisition_jd: Option<f64>,
) -> Result<()> {
let img = load_image(path, detection_backend)?;
let dims = img.dimensions();
let config = DetectConfig {
backend: detection_backend,
sigma,
ignore_border,
max_stars: 200,
..Default::default()
};
let mut invocation = SolveInvocation::new(path, &img, config, detection_fallback);
println!(
"{} stars detected in {}x{} image",
invocation.stars().len(),
dims.0,
dims.1
);
let catalog =
TileCatalog::open(data).with_context(|| format!("failed to open {}", data.display()))?;
let hint = SolveHint {
center,
radius_deg: radius,
scale_arcsec_px: scale,
scale_tolerance,
};
let started = std::time::Instant::now();
let solution = invocation.solve(|stars| solve(stars, &catalog, &hint, dims))?;
let elapsed = started.elapsed();
let wcs = &solution.wcs;
let (ra, dec) = wcs.pixel_to_world(dims.0 as f64 / 2.0, dims.1 as f64 / 2.0);
let det = wcs.cd[0][0] * wcs.cd[1][1] - wcs.cd[0][1] * wcs.cd[1][0];
let north = wcs.world_to_pixel(wcs.crval.0, wcs.crval.1 + 0.01);
let rotation = north
.map(|(x, y)| (x - wcs.crpix.0).atan2(-(y - wcs.crpix.1)).to_degrees())
.unwrap_or(f64::NAN);
println!("Solved in {:.2}s:", elapsed.as_secs_f64());
println!(
" center : {} {} ({ra:.5}°, {dec:.5}°)",
hms(ra),
dms(dec)
);
println!(" pixel scale: {:.4}\"/px", wcs.scale_arcsec_per_px());
println!(" rotation : {rotation:.2}° (north angle in image)");
println!(
" parity : {}",
if det > 0.0 { "normal" } else { "mirrored" }
);
println!(
" quality : {} stars matched, RMS {:.3}\"",
solution.matched_stars, solution.rms_arcsec
);
let footprint = wcs.footprint(dims.0, dims.1);
println!(
" footprint : {:.4},{:.4} / {:.4},{:.4} / {:.4},{:.4} / {:.4},{:.4}",
footprint[0].0,
footprint[0].1,
footprint[1].0,
footprint[1].1,
footprint[2].0,
footprint[2].1,
footprint[3].0,
footprint[3].1
);
let placed = match objects {
Some(path) => {
let object_catalog = seiza::objects::ObjectCatalog::open(path)
.with_context(|| format!("failed to open {}", path.display()))?;
let placed = object_catalog
.objects_in_footprint(wcs, dims)
.map_err(|error| anyhow::anyhow!(error))?;
println!("{} catalog objects in the field:", placed.len());
for p in &placed {
let size = match p.object.major_arcmin {
Some(major) => format!("{major:.1}'"),
None => "-".to_string(),
};
let common = if p.object.common_name.is_empty() {
String::new()
} else {
format!(" ({})", p.object.common_name)
};
println!(
" {:<12} {:>18} {:>8} at ({:.0}, {:.0}){common}",
p.object.kind.as_str(),
p.object.name,
size,
p.x,
p.y
);
}
placed
}
None => Vec::new(),
};
if let Some(path) = minor_bodies {
match acquisition_jd {
Some(jd) => {
let catalog = seiza::minor_bodies::MinorBodyCatalog::open(path)
.with_context(|| format!("failed to open {}", path.display()))?;
let moving = catalog.objects_in_footprint(wcs, dims, jd, 20.0);
println!("{} minor bodies in the field at JD {jd:.4}:", moving.len());
for m in &moving {
let kind = match m.body.kind {
seiza::minor_bodies::MinorBodyKind::Comet => "comet",
seiza::minor_bodies::MinorBodyKind::Asteroid => "asteroid",
};
println!(
" {:<9} {:<32} V~{:>4.1} at ({:.0}, {:.0}) {:.3} AU",
kind, m.body.name, m.mag, m.x, m.y, m.delta_au
);
}
}
None => println!(
"minor bodies skipped: no acquisition time (pass --time or use a FITS with DATE-OBS)"
),
}
}
if let Some(out) = annotate {
let mut canvas = img.to_rgb8();
for star in invocation.stars() {
imageproc::drawing::draw_hollow_circle_mut(
&mut canvas,
(star.x.round() as i32, star.y.round() as i32),
10,
image::Rgb([64, 255, 64]),
);
}
let fov = (dims.0 as f64).hypot(dims.1 as f64) / 2.0 * wcs.scale_arcsec_per_px() / 3600.0;
for cat_star in catalog.cone_search(ra, dec, fov, 300) {
if let Some((x, y)) = wcs.world_to_pixel(cat_star.ra, cat_star.dec)
&& x >= 0.0
&& y >= 0.0
&& x < dims.0 as f64
&& y < dims.1 as f64
{
imageproc::drawing::draw_hollow_circle_mut(
&mut canvas,
(x.round() as i32, y.round() as i32),
6,
image::Rgb([255, 64, 64]),
);
}
}
for p in &placed {
draw_rotated_ellipse(
&mut canvas,
(p.x, p.y),
p.semi_major_px.max(12.0),
p.semi_minor_px.max(12.0),
p.angle_deg,
image::Rgb([64, 220, 255]),
);
}
canvas
.save(out)
.with_context(|| format!("failed to write {}", out.display()))?;
println!("annotated image written to {}", out.display());
}
Ok(())
}
fn draw_rotated_ellipse(
canvas: &mut image::RgbImage,
center: (f64, f64),
semi_major: f64,
semi_minor: f64,
angle_deg: f64,
color: image::Rgb<u8>,
) {
let (sin_r, cos_r) = angle_deg.to_radians().sin_cos();
let segments = 72;
let point = |i: usize| -> (f32, f32) {
let t = i as f64 / segments as f64 * std::f64::consts::TAU;
let (lx, ly) = (semi_major * t.cos(), semi_minor * t.sin());
(
(center.0 + lx * cos_r - ly * sin_r) as f32,
(center.1 + lx * sin_r + ly * cos_r) as f32,
)
};
for i in 0..segments {
imageproc::drawing::draw_line_segment_mut(canvas, point(i), point(i + 1), color);
}
}
fn hms(ra: f64) -> String {
let hours = ra.rem_euclid(360.0) / 15.0;
let h = hours.floor();
let m = ((hours - h) * 60.0).floor();
let sec = (hours - h - m / 60.0) * 3600.0;
format!("{:02}h {:02}m {:05.2}s", h as u32, m as u32, sec)
}
fn dms(dec: f64) -> String {
let sign = if dec < 0.0 { '-' } else { '+' };
let a = dec.abs();
let d = a.floor();
let m = ((a - d) * 60.0).floor();
let sec = (a - d - m / 60.0) * 3600.0;
format!("{sign}{:02}° {:02}′ {:04.1}″", d as u32, m as u32, sec)
}
struct SolveBlindOptions<'a> {
index_path: Option<&'a std::path::Path>,
min_scale: f64,
max_scale: f64,
index_mag_limit: f32,
max_hypotheses: usize,
max_coarse_hypotheses: usize,
sigma: f32,
ignore_border: u32,
detection_backend: DetectBackend,
detection_fallback: DetectionFallback,
detection_fallback_hypotheses: usize,
}
fn solve_blind_command(
path: &std::path::Path,
data: &std::path::Path,
options: SolveBlindOptions<'_>,
) -> Result<()> {
use seiza::blind::{BlindIndex, BlindParams, solve_blind};
let img = load_image(path, options.detection_backend)?;
let dims = img.dimensions();
let config = DetectConfig {
backend: options.detection_backend,
sigma: options.sigma,
ignore_border: options.ignore_border,
max_stars: 600,
..Default::default()
};
let can_retry_f32 = options.detection_fallback == DetectionFallback::F32
&& auto_can_retry_f32(path, &img, options.detection_backend);
let mut invocation = SolveInvocation::new(path, &img, config, options.detection_fallback);
println!(
"{} stars detected in {}x{} image",
invocation.stars().len(),
dims.0,
dims.1
);
let catalog =
TileCatalog::open(data).with_context(|| format!("failed to open {}", data.display()))?;
let mut params = BlindParams {
min_scale_arcsec_px: options.min_scale,
max_scale_arcsec_px: options.max_scale,
index_mag_limit: options.index_mag_limit,
max_hypotheses: options.max_hypotheses,
max_coarse_hypotheses: options.max_coarse_hypotheses,
..Default::default()
};
let started = std::time::Instant::now();
let index = if let Some(path) = options.index_path {
let index = BlindIndex::open(path)
.map_err(anyhow::Error::from)
.with_context(|| format!("failed to open {}", path.display()))?;
params.index_mag_limit = index.index_mag_limit();
params.max_pattern_deg = index.max_pattern_deg();
warn_on_index_catalog_mismatch(&index, &catalog);
println!(
"pattern index: {} patterns mapped from {} in {:.2}s (G<={:.1})",
index.pattern_count(),
path.display(),
started.elapsed().as_secs_f64(),
index.index_mag_limit()
);
index
} else {
let index = BlindIndex::build(&catalog, ¶ms);
println!(
"pattern index: {} patterns built in {:.2}s",
index.pattern_count(),
started.elapsed().as_secs_f64()
);
index
};
let started = std::time::Instant::now();
let solution = invocation.solve_with_pass(|stars, pass| {
let attempt_params = blind_params_for_detection_pass(
¶ms,
can_retry_f32,
options.detection_fallback_hypotheses,
pass,
);
solve_blind(stars, &catalog, &index, &attempt_params, dims)
})?;
let wcs = &solution.wcs;
let (ra, dec) = wcs.pixel_to_world(dims.0 as f64 / 2.0, dims.1 as f64 / 2.0);
println!("Blind-solved in {:.2}s:", started.elapsed().as_secs_f64());
println!(
" center : {} {} ({ra:.5}°, {dec:.5}°)",
hms(ra),
dms(dec)
);
println!(" pixel scale: {:.4}\"/px", wcs.scale_arcsec_per_px());
println!(
" quality : {} stars matched, RMS {:.3}\"",
solution.matched_stars, solution.rms_arcsec
);
Ok(())
}
fn warn_on_index_catalog_mismatch(
index: &seiza::blind::BlindIndex,
catalog: &seiza::catalog::TileCatalog,
) {
let built_from = index.source_star_count();
let runtime = catalog.star_count();
if built_from > 0 && runtime > 0 && built_from.max(runtime) > 2 * built_from.min(runtime) {
eprintln!(
"warning: blind index was built from a {built_from}-star catalog but solving \
against {runtime} stars; deep-tier hypotheses may never verify"
);
}
}
fn build_blind_index_command(
data: &std::path::Path,
output: &std::path::Path,
index_mag_limit: f32,
max_pattern_deg: f64,
) -> Result<()> {
use seiza::blind::{BlindIndex, BlindParams};
let catalog =
TileCatalog::open(data).with_context(|| format!("failed to open {}", data.display()))?;
let params = BlindParams {
index_mag_limit,
max_pattern_deg,
..Default::default()
};
let started = std::time::Instant::now();
let index = BlindIndex::build(&catalog, ¶ms);
println!(
"built {} G<={index_mag_limit:.1} patterns in {:.2}s",
index.pattern_count(),
started.elapsed().as_secs_f64()
);
let started = std::time::Instant::now();
index
.write_to(output)
.with_context(|| format!("failed to write {}", output.display()))?;
println!(
"wrote {} in {:.2}s",
output.display(),
started.elapsed().as_secs_f64()
);
Ok(())
}
#[cfg(test)]
mod cli_tests {
use super::*;
#[test]
fn detection_backend_is_global_and_selectable() {
let automatic = Cli::try_parse_from(["seiza", "detect", "image.jpg"]).unwrap();
let before =
Cli::try_parse_from(["seiza", "--detection-backend", "f32", "detect", "image.jpg"])
.unwrap();
let after =
Cli::try_parse_from(["seiza", "detect", "image.jpg", "--detection-backend", "u8"])
.unwrap();
assert!(matches!(
automatic.detection_backend,
DetectionBackendArg::Auto
));
assert!(matches!(before.detection_backend, DetectionBackendArg::F32));
assert!(matches!(after.detection_backend, DetectionBackendArg::U8));
assert_eq!(automatic.detection_fallback, DetectionFallback::F32);
assert_eq!(automatic.detection_fallback_hypotheses, 64);
let no_fallback = Cli::try_parse_from([
"seiza",
"solve-blind",
"image.fits",
"--data",
"stars.bin",
"--detection-fallback",
"none",
"--detection-fallback-hypotheses",
"0",
])
.unwrap();
assert_eq!(no_fallback.detection_fallback, DetectionFallback::None);
assert_eq!(no_fallback.detection_fallback_hypotheses, 0);
assert!(
Cli::try_parse_from([
"seiza",
"detect",
"image.jpg",
"--detection-backend",
"other",
])
.is_err()
);
}
#[test]
fn auto_retries_fits_and_converted_color_inputs() {
let rgb = LoadedImage::Dynamic(image::DynamicImage::ImageRgb8(image::RgbImage::new(1, 1)));
let luma =
LoadedImage::Dynamic(image::DynamicImage::ImageLuma8(image::GrayImage::new(1, 1)));
let jpeg = std::path::Path::new("image.jpg");
let fits = std::path::Path::new("image.fits");
assert!(auto_can_retry_f32(jpeg, &rgb, DetectBackend::Auto));
assert!(!auto_can_retry_f32(jpeg, &rgb, DetectBackend::U8));
assert!(!auto_can_retry_f32(jpeg, &luma, DetectBackend::Auto));
assert!(auto_can_retry_f32(fits, &luma, DetectBackend::Auto));
assert!(!auto_can_retry_f32(fits, &luma, DetectBackend::U8));
}
#[test]
fn fits_loader_keeps_u8_mtf_and_linear_f32_paths_distinct() {
const CARD: usize = 80;
let card = |keyword: &str, value: &str| {
let mut card = [b' '; CARD];
card[..keyword.len()].copy_from_slice(keyword.as_bytes());
card[8] = b'=';
card[9] = b' ';
card[10..10 + value.len()].copy_from_slice(value.as_bytes());
card
};
let mut bytes = Vec::new();
for (keyword, value) in [
("SIMPLE", "T"),
("BITPIX", "16"),
("NAXIS", "2"),
("NAXIS1", "2"),
("NAXIS2", "2"),
("BZERO", "32768"),
("BSCALE", "1"),
] {
bytes.extend_from_slice(&card(keyword, value));
}
let mut end = [b' '; CARD];
end[..3].copy_from_slice(b"END");
bytes.extend_from_slice(&end);
bytes.resize(bytes.len().next_multiple_of(2880), b' ');
for value in [1000u16, 1001, 32768, 65535] {
bytes.extend_from_slice(&(value ^ 0x8000).to_be_bytes());
}
let path = std::env::temp_dir().join(format!(
"seiza-cli-detection-backends-{}-{}.fits",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, bytes).unwrap();
let compact = load_image(&path, DetectBackend::Auto).unwrap();
let precise = load_image(&path, DetectBackend::F32).unwrap();
assert!(matches!(
compact,
LoadedImage::Dynamic(image::DynamicImage::ImageLuma8(_))
));
let LoadedImage::FitsF32 { luma, .. } = precise else {
panic!("forced f32 FITS must retain a linear f32 buffer");
};
assert_eq!(luma[0], 1000.0 / u16::MAX as f32);
assert_eq!(luma[1], 1001.0 / u16::MAX as f32);
assert_ne!(luma[0], luma[1]);
let mut invocation = SolveInvocation::new(
&path,
&compact,
DetectConfig::default(),
DetectionFallback::F32,
);
let mut passes = Vec::new();
invocation
.solve_with_pass(|_, pass| {
passes.push(pass);
if pass == DetectionPass::Primary {
Err(seiza::Error::Solve("exercise f32 reload".into()))
} else {
Ok(())
}
})
.unwrap();
assert_eq!(passes, [DetectionPass::Primary, DetectionPass::F32Fallback]);
std::fs::remove_file(path).unwrap();
}
#[test]
fn solve_invocation_honors_disabled_fallback() {
let image =
LoadedImage::Dynamic(image::DynamicImage::ImageRgb8(image::RgbImage::new(2, 2)));
let mut invocation = SolveInvocation::new(
std::path::Path::new("image.jpg"),
&image,
DetectConfig::default(),
DetectionFallback::None,
);
let mut passes = Vec::new();
let result: Result<()> = invocation.solve_with_pass(|_, pass| {
passes.push(pass);
Err(seiza::Error::Solve("expected miss".into()))
});
assert!(result.is_err());
assert_eq!(passes, [DetectionPass::Primary]);
}
#[test]
fn blind_fallback_budget_only_caps_the_primary_pass() {
let params = seiza::blind::BlindParams {
max_hypotheses: 400,
..Default::default()
};
let primary = blind_params_for_detection_pass(¶ms, true, 64, DetectionPass::Primary);
let fallback =
blind_params_for_detection_pass(¶ms, true, 64, DetectionPass::F32Fallback);
let unavailable =
blind_params_for_detection_pass(¶ms, false, 64, DetectionPass::Primary);
let unlimited = blind_params_for_detection_pass(¶ms, true, 0, DetectionPass::Primary);
assert_eq!(primary.max_hypotheses, 64);
assert_eq!(fallback.max_hypotheses, 400);
assert_eq!(unavailable.max_hypotheses, 400);
assert_eq!(unlimited.max_hypotheses, 400);
}
#[test]
fn gaia_accepts_a_separated_negative_magnitude() {
let cli = Cli::try_parse_from([
"seiza",
"download-data",
"gaia",
"--output",
"gaia",
"--max-mag",
"-1.5",
"--chunks",
"1",
])
.unwrap();
let Command::DownloadData {
source: DownloadSource::Gaia {
max_mag, chunks, ..
},
} = cli.command
else {
panic!("expected Gaia download command");
};
assert_eq!(max_mag, -1.5);
assert_eq!(chunks, 1);
}
#[test]
fn gaia_rejects_zero_chunks_at_parse_time() {
assert!(
Cli::try_parse_from([
"seiza",
"download-data",
"gaia",
"--output",
"gaia",
"--chunks",
"0",
])
.is_err()
);
}
}