From aea16be92e4935a6440e0061b91bb2dee595c154 Mon Sep 17 00:00:00 2001 From: grabbit Date: Sun, 29 Jun 2025 11:53:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8A=8Asolve-field=E6=8A=BD=E8=B1=A1?= =?UTF-8?q?=E5=87=BA=E6=9D=A5=EF=BC=8C=E5=9B=A0=E4=B8=BAlinux=E5=92=8Cmac?= =?UTF-8?q?=E4=B8=8B=E5=8F=82=E6=95=B0=E8=A1=8C=E4=B8=BA=E4=B8=8D=E4=B8=80?= =?UTF-8?q?=E8=87=B4=EF=BC=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/astrometry/mod.rs | 12 + src/astrometry/output_parser.rs | 307 +++++++++++++++++++++++++ src/astrometry/solver.rs | 388 ++++++++++++++++++++++++++++++++ src/astrometry/types.rs | 212 +++++++++++++++++ src/lib.rs | 1 + src/overlay/star_chart.rs | 174 +++++++++++++- 6 files changed, 1086 insertions(+), 8 deletions(-) create mode 100644 src/astrometry/mod.rs create mode 100644 src/astrometry/output_parser.rs create mode 100644 src/astrometry/solver.rs create mode 100644 src/astrometry/types.rs diff --git a/src/astrometry/mod.rs b/src/astrometry/mod.rs new file mode 100644 index 0000000..3069677 --- /dev/null +++ b/src/astrometry/mod.rs @@ -0,0 +1,12 @@ +/// Astrometry.net solver interface and platform abstraction +/// +/// This module provides a platform-independent interface to astrometry.net +/// solve-field command and handles reading various output files (WCS, AXY, RDLS, etc.) + +pub mod solver; +pub mod output_parser; +pub mod types; + +pub use solver::{AstrometrySolver, SolverConfigBuilder}; +pub use output_parser::{OutputFileParser, WcsData, AxyData, RdlsData}; +pub use types::{SolverConfig, SolverResult, SolverError, SolverStatus, CelestialCoordinate, PixelCoordinate, Range}; \ No newline at end of file diff --git a/src/astrometry/output_parser.rs b/src/astrometry/output_parser.rs new file mode 100644 index 0000000..b5f049a --- /dev/null +++ b/src/astrometry/output_parser.rs @@ -0,0 +1,307 @@ +use anyhow::{Context, Result}; +use fitsio::FitsFile; +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use super::types::{CelestialCoordinate, PixelCoordinate, SolverError}; + +/// WCS (World Coordinate System) data from solved field +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WcsData { + /// Field center Right Ascension in degrees + pub crval1: f64, + /// Field center Declination in degrees + pub crval2: f64, + /// Reference pixel X coordinate + pub crpix1: f64, + /// Reference pixel Y coordinate + pub crpix2: f64, + /// CD matrix element 1,1 (degrees per pixel) + pub cd1_1: f64, + /// CD matrix element 1,2 (degrees per pixel) + pub cd1_2: f64, + /// CD matrix element 2,1 (degrees per pixel) + pub cd2_1: f64, + /// CD matrix element 2,2 (degrees per pixel) + pub cd2_2: f64, + /// Image width in pixels + pub image_width: Option, + /// Image height in pixels + pub image_height: Option, +} + +impl WcsData { + /// Get field center as CelestialCoordinate + pub fn field_center(&self) -> Result { + CelestialCoordinate::new(self.crval1, self.crval2) + } + + /// Calculate pixel scale in arcseconds per pixel + pub fn pixel_scale(&self) -> f64 { + let det = self.cd1_1 * self.cd2_2 - self.cd1_2 * self.cd2_1; + det.abs().sqrt() * 3600.0 // Convert degrees to arcseconds + } + + /// Calculate rotation angle in degrees + pub fn rotation_angle(&self) -> f64 { + self.cd1_2.atan2(self.cd1_1).to_degrees() + } + + /// Convert pixel coordinates to celestial coordinates + pub fn pixel_to_celestial(&self, pixel: &PixelCoordinate) -> Result { + // Standard WCS transformation + let dx = pixel.x - self.crpix1; + let dy = pixel.y - self.crpix2; + + let ra = self.crval1 + (self.cd1_1 * dx + self.cd1_2 * dy); + let dec = self.crval2 + (self.cd2_1 * dx + self.cd2_2 * dy); + + CelestialCoordinate::new(ra, dec) + } + + /// Convert celestial coordinates to pixel coordinates + pub fn celestial_to_pixel(&self, celestial: &CelestialCoordinate) -> PixelCoordinate { + // Inverse WCS transformation (simplified linear approximation) + let dra = celestial.ra - self.crval1; + let ddec = celestial.dec - self.crval2; + + // Solve the linear system: CD * [dx, dy]^T = [dra, ddec]^T + let det = self.cd1_1 * self.cd2_2 - self.cd1_2 * self.cd2_1; + + let dx = (self.cd2_2 * dra - self.cd1_2 * ddec) / det; + let dy = (-self.cd2_1 * dra + self.cd1_1 * ddec) / det; + + PixelCoordinate::new(self.crpix1 + dx, self.crpix2 + dy) + } +} + +/// AXY data from detected stars +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AxyData { + /// Detected star positions in pixel coordinates + pub stars: Vec, +} + +/// RDLS data from matched stars +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RdlsData { + /// Matched star positions in celestial coordinates + pub stars: Vec, +} + +/// Parser for astrometry.net output files +pub struct OutputFileParser; + +impl OutputFileParser { + /// Parse WCS file and extract coordinate system information using fitsio + pub fn parse_wcs_file>(path: P) -> Result { + let path = path.as_ref(); + info!("🔍 PARSING WCS SOLUTION from file: {:?}", path); + + let mut fits = FitsFile::open(path) + .context(format!("Failed to open WCS FITS file at {:?}", path)) + .map_err(|e| SolverError::ParseError { + file: path.display().to_string(), + error: e.to_string(), + })?; + + let hdu = fits.primary_hdu() + .map_err(|e| SolverError::ParseError { + file: path.display().to_string(), + error: format!("Failed to read primary HDU: {}", e), + })?; + + // Read core WCS parameters + let crval1 = hdu.read_key::(&mut fits, "CRVAL1").unwrap_or(0.0); + let crval2 = hdu.read_key::(&mut fits, "CRVAL2").unwrap_or(0.0); + let crpix1 = hdu.read_key::(&mut fits, "CRPIX1").unwrap_or(0.0); + let crpix2 = hdu.read_key::(&mut fits, "CRPIX2").unwrap_or(0.0); + + info!("🌍 Field center coordinates: RA={:.6}°, Dec={:.6}°", crval1, crval2); + + // Try to read CD matrix first, as it's more accurate + let cd1_1 = hdu.read_key::(&mut fits, "CD1_1").unwrap_or(0.0); + let cd1_2 = hdu.read_key::(&mut fits, "CD1_2").unwrap_or(0.0); + let cd2_1 = hdu.read_key::(&mut fits, "CD2_1").unwrap_or(0.0); + let cd2_2 = hdu.read_key::(&mut fits, "CD2_2").unwrap_or(0.0); + + // Log CD matrix information + if cd1_1 != 0.0 || cd1_2 != 0.0 || cd2_1 != 0.0 || cd2_2 != 0.0 { + let determinant = (cd1_1 * cd2_2 - cd1_2 * cd2_1).abs(); + let scale = determinant.sqrt() * 3600.0; // Convert degrees to arcseconds + info!("📐 CD matrix scale calculation: det={:.8e}, scale={:.3} arcsec/px", determinant, scale); + info!("📐 CD matrix: [{:.8}, {:.8}] [{:.8}, {:.8}]", cd1_1, cd1_2, cd2_1, cd2_2); + } else if let Ok(cdelt1) = hdu.read_key::(&mut fits, "CDELT1") { + let scale = cdelt1.abs() * 3600.0; + info!("📐 CDELT scale calculation: CDELT1={:.8}, scale={:.3} arcsec/px", cdelt1, scale); + } else { + warn!("⚠️ No CD matrix or CDELT found in WCS file"); + } + + // Read image dimensions - try IMAGEW/IMAGEH first (astrometry.net format), then fallback to NAXIS1/NAXIS2 + let image_width = hdu.read_key::(&mut fits, "IMAGEW") + .or_else(|_| hdu.read_key::(&mut fits, "NAXIS1")) + .ok() + .and_then(|w| if w > 0 { Some(w as u32) } else { None }); + + let image_height = hdu.read_key::(&mut fits, "IMAGEH") + .or_else(|_| hdu.read_key::(&mut fits, "NAXIS2")) + .ok() + .and_then(|h| if h > 0 { Some(h as u32) } else { None }); + + info!("📏 Image dimensions: width={:?}, height={:?}", image_width, image_height); + + let wcs_data = WcsData { + crval1, + crval2, + crpix1, + crpix2, + cd1_1, + cd1_2, + cd2_1, + cd2_2, + image_width, + image_height, + }; + + // Validation checks + if crval1 == 0.0 && crval2 == 0.0 { + warn!("⚠️ CRVAL1 and CRVAL2 are both zero - this may indicate parsing failure"); + return Err(SolverError::ParseError { + file: path.display().to_string(), + error: "Failed to parse RA/Dec from WCS file - both coordinates are zero".to_string(), + }); + } + + let scale = wcs_data.pixel_scale(); + if scale == 0.0 { + warn!("⚠️ Could not determine pixel scale from WCS file"); + } + + info!("🎯 PARSED WCS SOLUTION: RA={:.6}°, Dec={:.6}°, Scale={:.3} arcsec/px", + crval1, crval2, scale); + info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + + debug!("Parsed WCS data: center=({:.6}, {:.6}), scale={:.2} arcsec/px", + wcs_data.crval1, wcs_data.crval2, wcs_data.pixel_scale()); + + Ok(wcs_data) + } + + /// Parse AXY file containing detected star positions + pub fn parse_axy_file>(path: P) -> Result { + let path = path.as_ref(); + debug!("Parsing AXY file: {}", path.display()); + + let content = fs::read_to_string(path) + .map_err(|e| SolverError::ParseError { + file: path.display().to_string(), + error: e.to_string(), + })?; + + let mut stars = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Parse x y coordinates (possibly with additional columns) + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + if let (Ok(x), Ok(y)) = (parts[0].parse::(), parts[1].parse::()) { + stars.push(PixelCoordinate::new(x, y)); + } else { + warn!("Failed to parse AXY line: {}", line); + } + } + } + + debug!("Parsed {} stars from AXY file", stars.len()); + Ok(AxyData { stars }) + } + + /// Parse RDLS file containing matched star positions in celestial coordinates + pub fn parse_rdls_file>(path: P) -> Result { + let path = path.as_ref(); + debug!("Parsing RDLS file: {}", path.display()); + + let content = fs::read_to_string(path) + .map_err(|e| SolverError::ParseError { + file: path.display().to_string(), + error: e.to_string(), + })?; + + let mut stars = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Parse RA Dec coordinates (possibly with additional columns) + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + if let (Ok(ra), Ok(dec)) = (parts[0].parse::(), parts[1].parse::()) { + if let Ok(coord) = CelestialCoordinate::new(ra, dec) { + stars.push(coord); + } else { + warn!("Invalid celestial coordinates in RDLS: {} {}", ra, dec); + } + } else { + warn!("Failed to parse RDLS line: {}", line); + } + } + } + + debug!("Parsed {} matched stars from RDLS file", stars.len()); + Ok(RdlsData { stars }) + } + + /// Parse solve-field output to extract basic solution information + pub fn parse_solve_output(output: &str) -> HashMap { + let mut info = HashMap::new(); + + for line in output.lines() { + let line = line.trim(); + + // Look for specific patterns in solve-field output + if line.contains("Field center: (RA,Dec)") { + // Extract RA/Dec from format like "Field center: (RA,Dec) = (123.456, 78.901) deg." + if let Some(start) = line.find('(') { + if let Some(end) = line.rfind(')') { + let coords_part = &line[start+1..end]; + if let Some(comma) = coords_part.find(',') { + let ra_str = coords_part[..comma].trim(); + let dec_str = coords_part[comma+1..].trim(); + info.insert("field_center_ra".to_string(), ra_str.to_string()); + info.insert("field_center_dec".to_string(), dec_str.to_string()); + } + } + } + } else if line.contains("Field size:") { + // Extract field size information + info.insert("field_size_line".to_string(), line.to_string()); + } else if line.contains("Field rotation angle:") { + // Extract rotation angle + if let Some(angle_start) = line.find(':') { + let angle_part = line[angle_start+1..].trim(); + if let Some(deg_pos) = angle_part.find("deg") { + let angle_str = angle_part[..deg_pos].trim(); + info.insert("rotation_angle".to_string(), angle_str.to_string()); + } + } + } else if line.contains("pixel scale") { + // Extract pixel scale information + info.insert("pixel_scale_line".to_string(), line.to_string()); + } + } + + info + } +} \ No newline at end of file diff --git a/src/astrometry/solver.rs b/src/astrometry/solver.rs new file mode 100644 index 0000000..fccfc1f --- /dev/null +++ b/src/astrometry/solver.rs @@ -0,0 +1,388 @@ +use anyhow::Result; +use log::{debug, error, info, warn}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; +use tokio::process::Command as AsyncCommand; +use tokio::time::timeout; + +use super::output_parser::OutputFileParser; +use super::types::{SolverConfig, SolverError, SolverResult, SolverStatus, CelestialCoordinate}; + +/// Main interface for astrometry.net solving +pub struct AstrometrySolver { + config: SolverConfig, +} + +impl AstrometrySolver { + /// Create a new solver with the given configuration + pub fn new(config: SolverConfig) -> Result { + // Ensure working directory exists + if !config.working_dir.exists() { + fs::create_dir_all(&config.working_dir)?; + } + + // Validate solve-field path + if !Self::check_solve_field_availability(&config.solve_field_path) { + return Err(SolverError::CommandFailed( + format!("solve-field not found at: {}", config.solve_field_path.display()) + )); + } + + Ok(Self { config }) + } + + /// Check if solve-field is available and working + pub fn check_solve_field_availability>(solve_field_path: P) -> bool { + match Command::new(solve_field_path.as_ref()) + .arg("--help") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Ok(status) => status.success(), + Err(_) => false, + } + } + + /// Solve an image file and return the result + pub async fn solve_image>(&self, image_path: P) -> Result { + let image_path = image_path.as_ref(); + let start_time = Instant::now(); + + info!("Starting astrometry solve for: {}", image_path.display()); + + // Generate output file paths + let base_name = image_path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("image"); + + let output_base = self.config.working_dir.join(format!("solve_{}", base_name)); + + // Build solve-field command + let mut cmd = AsyncCommand::new(&self.config.solve_field_path); + + // Input image + cmd.arg(image_path); + + // Output directory and file naming + cmd.arg("--dir").arg(&self.config.working_dir); + cmd.arg("--out").arg(base_name); + + // Overwrite existing files + if self.config.overwrite { + cmd.arg("--overwrite"); + } + + // Scale hints + if let Some(ref scale_range) = self.config.scale_range { + cmd.arg("--scale-low").arg(scale_range.min.to_string()); + cmd.arg("--scale-high").arg(scale_range.max.to_string()); + cmd.arg("--scale-units").arg("arcsecperpix"); + } + + // Center hint + if let Some(ref center) = self.config.center_hint { + cmd.arg("--ra").arg(center.ra.to_string()); + cmd.arg("--dec").arg(center.dec.to_string()); + + if let Some(radius) = self.config.search_radius { + cmd.arg("--radius").arg(radius.to_string()); + } + } + + // Index files + if !self.config.index_files.is_empty() { + for index_file in &self.config.index_files { + cmd.arg("--index-file").arg(index_file); + } + } + + // Common options that match the original implementation + cmd.arg("--no-plots"); // Disable plotting for performance + cmd.arg("--depth").arg("10,20,30,40,50"); // Search depth + cmd.arg("--downsample").arg("2"); // Downsample factor + cmd.arg("--no-remove-lines"); // Don't remove lines + cmd.arg("--crpix-center"); // Use center as reference pixel + cmd.arg("--cpulimit").arg("60"); // CPU time limit + + // Additional arguments (after standard ones to avoid conflicts) + for arg in &self.config.extra_args { + cmd.arg(arg); + } + + // Capture output + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + info!("Executing solve-field command: {:?}", cmd); + + // Run command with timeout + let output = timeout( + Duration::from_secs(self.config.timeout_seconds as u64), + cmd.output() + ).await; + + let execution_time = start_time.elapsed().as_secs_f64(); + + let output = match output { + Ok(Ok(output)) => output, + Ok(Err(e)) => { + error!("Failed to execute solve-field: {}", e); + return Ok(SolverResult { + status: SolverStatus::Failed, + execution_time, + command_output: format!("Execution error: {}", e), + ..Default::default() + }); + }, + Err(_) => { + error!("solve-field timed out after {} seconds", self.config.timeout_seconds); + return Ok(SolverResult { + status: SolverStatus::Timeout, + execution_time, + command_output: "Command timed out".to_string(), + ..Default::default() + }); + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let command_output = format!("STDOUT:\n{}\n\nSTDERR:\n{}", stdout, stderr); + + debug!("solve-field completed with exit code: {:?}", output.status.code()); + debug!("solve-field output: {}", command_output); + + // Check if solve was successful + let solved_file_path = self.config.working_dir.join(format!("{}.solved", base_name)); + let success = output.status.success() && + (stdout.contains("solved") || solved_file_path.exists()); + + if !success { + warn!("solve-field did not find a solution"); + return Ok(SolverResult { + status: SolverStatus::Failed, + execution_time, + command_output, + ..Default::default() + }); + } + + info!("✅ solve-field found a solution in {:.2}s", execution_time); + + // Parse output files + let mut result = SolverResult { + status: SolverStatus::Success, + execution_time, + command_output, + ..Default::default() + }; + + // Find and parse WCS file + let wcs_path = self.config.working_dir.join(format!("{}.wcs", base_name)); + if wcs_path.exists() { + result.wcs_file = Some(wcs_path.clone()); + + match OutputFileParser::parse_wcs_file(&wcs_path) { + Ok(wcs_data) => { + result.field_center = wcs_data.field_center().ok(); + result.scale = Some(wcs_data.pixel_scale()); + result.rotation = Some(wcs_data.rotation_angle()); + + if let (Some(width), Some(height)) = (wcs_data.image_width, wcs_data.image_height) { + let pixel_scale_deg = wcs_data.pixel_scale() / 3600.0; // Convert to degrees + result.field_size = Some(( + width as f64 * pixel_scale_deg, + height as f64 * pixel_scale_deg + )); + } + }, + Err(e) => { + warn!("Failed to parse WCS file: {}", e); + } + } + } + + // Find AXY file (detected stars) + let axy_path = self.config.working_dir.join(format!("{}.axy", base_name)); + if axy_path.exists() { + result.axy_file = Some(axy_path); + } + + // Find RDLS file (matched stars) + let rdls_path = self.config.working_dir.join(format!("{}.rdls", base_name)); + if rdls_path.exists() { + result.rdls_file = Some(rdls_path); + } + + // Find solved file + let solved_path = self.config.working_dir.join(format!("{}.solved", base_name)); + if solved_path.exists() { + result.solved_file = Some(solved_path); + } + + // // Clean up temporary files if requested + // if !self.config.keep_temp_files { + // self.cleanup_temp_files(base_name).await; + // } + + Ok(result) + } + + /// Solve an image with a specific configuration override + pub async fn solve_with_config>( + &self, + image_path: P, + config_override: SolverConfig + ) -> Result { + let original_config = self.config.clone(); + let mut temp_solver = Self::new(config_override)?; + let result = temp_solver.solve_image(image_path).await; + result + } + + /// Get detected stars from the last solve (requires AXY file) + pub fn get_detected_stars(&self, axy_file: &Path) -> Result, SolverError> { + let axy_data = OutputFileParser::parse_axy_file(axy_file)?; + Ok(axy_data.stars) + } + + /// Get matched stars from the last solve (requires RDLS file) + pub fn get_matched_stars(&self, rdls_file: &Path) -> Result, SolverError> { + let rdls_data = OutputFileParser::parse_rdls_file(rdls_file)?; + Ok(rdls_data.stars) + } + + /// Convert pixel coordinates to celestial coordinates using WCS file + pub fn pixel_to_celestial( + &self, + wcs_file: &Path, + pixel: &super::types::PixelCoordinate + ) -> Result { + let wcs_data = OutputFileParser::parse_wcs_file(wcs_file)?; + wcs_data.pixel_to_celestial(pixel) + } + + /// Convert celestial coordinates to pixel coordinates using WCS file + pub fn celestial_to_pixel( + &self, + wcs_file: &Path, + celestial: &CelestialCoordinate + ) -> Result { + let wcs_data = OutputFileParser::parse_wcs_file(wcs_file)?; + Ok(wcs_data.celestial_to_pixel(celestial)) + } + + /// Clean up temporary files from a solve operation + async fn cleanup_temp_files(&self, base_name: &str) { + let extensions = ["axy", "xyls", "corr", "new", "rdls", "solved", "match", "wcs"]; + + for ext in &extensions { + let temp_file = self.config.working_dir.join(format!("{}.{}", base_name, ext)); + if temp_file.exists() { + if let Err(e) = fs::remove_file(&temp_file) { + debug!("Failed to remove temp file {}: {}", temp_file.display(), e); + } + } + } + } + + /// Update solver configuration + pub fn set_config(&mut self, config: SolverConfig) -> Result<(), SolverError> { + // Validate new configuration + if !config.working_dir.exists() { + fs::create_dir_all(&config.working_dir)?; + } + + if !Self::check_solve_field_availability(&config.solve_field_path) { + return Err(SolverError::CommandFailed( + format!("solve-field not found at: {}", config.solve_field_path.display()) + )); + } + + self.config = config; + Ok(()) + } + + /// Get current configuration + pub fn get_config(&self) -> &SolverConfig { + &self.config + } +} + +/// Builder pattern for easier SolverConfig creation +pub struct SolverConfigBuilder { + config: SolverConfig, +} + +impl SolverConfigBuilder { + pub fn new() -> Self { + Self { + config: SolverConfig::default(), + } + } + + pub fn solve_field_path>(mut self, path: P) -> Self { + self.config.solve_field_path = path.into(); + self + } + + pub fn working_dir>(mut self, dir: P) -> Self { + self.config.working_dir = dir.into(); + self + } + + pub fn index_files(mut self, files: I) -> Self + where + I: IntoIterator, + P: Into, + { + self.config.index_files = files.into_iter().map(Into::into).collect(); + self + } + + pub fn scale_range(mut self, min: f64, max: f64) -> Self { + self.config.scale_range = Some(super::types::Range::new(min, max)); + self + } + + pub fn center_hint(mut self, ra: f64, dec: f64) -> Result { + self.config.center_hint = Some(CelestialCoordinate::new(ra, dec)?); + Ok(self) + } + + pub fn search_radius(mut self, radius: f64) -> Self { + self.config.search_radius = Some(radius); + self + } + + pub fn timeout(mut self, seconds: u32) -> Self { + self.config.timeout_seconds = seconds; + self + } + + pub fn overwrite(mut self, overwrite: bool) -> Self { + self.config.overwrite = overwrite; + self + } + + pub fn keep_temp_files(mut self, keep: bool) -> Self { + self.config.keep_temp_files = keep; + self + } + + pub fn extra_args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.config.extra_args = args.into_iter().map(Into::into).collect(); + self + } + + pub fn build(self) -> SolverConfig { + self.config + } +} \ No newline at end of file diff --git a/src/astrometry/types.rs b/src/astrometry/types.rs new file mode 100644 index 0000000..4654128 --- /dev/null +++ b/src/astrometry/types.rs @@ -0,0 +1,212 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Errors that can occur during astrometry solving +#[derive(Debug, thiserror::Error)] +pub enum SolverError { + #[error("Solve-field command failed: {0}")] + CommandFailed(String), + + #[error("Timeout waiting for solve-field to complete")] + Timeout, + + #[error("No solution found for the image")] + NoSolution, + + #[error("Failed to parse output file {file}: {error}")] + ParseError { file: String, error: String }, + + #[error("Required output file not found: {0}")] + OutputFileNotFound(String), + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("Invalid coordinates: {0}")] + InvalidCoordinates(String), +} + +/// Status of the solver operation +#[derive(Debug, Clone, PartialEq)] +pub enum SolverStatus { + /// Solver is running + Running, + /// Solution found successfully + Success, + /// No solution found + Failed, + /// Operation timed out + Timeout, + /// Solver was cancelled + Cancelled, +} + +/// Celestial coordinates (RA/Dec) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CelestialCoordinate { + /// Right Ascension in degrees + pub ra: f64, + /// Declination in degrees + pub dec: f64, +} + +impl CelestialCoordinate { + pub fn new(ra: f64, dec: f64) -> Result { + if ra < 0.0 || ra >= 360.0 { + return Err(SolverError::InvalidCoordinates( + format!("RA must be in range [0, 360), got {}", ra) + )); + } + if dec < -90.0 || dec > 90.0 { + return Err(SolverError::InvalidCoordinates( + format!("Dec must be in range [-90, 90], got {}", dec) + )); + } + Ok(Self { ra, dec }) + } + + /// Convert RA from hours to degrees + pub fn from_hours_degrees(ra_hours: f64, dec_degrees: f64) -> Result { + Self::new(ra_hours * 15.0, dec_degrees) + } + + /// Get RA in hours + pub fn ra_hours(&self) -> f64 { + self.ra / 15.0 + } +} + +/// Pixel coordinates in image +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PixelCoordinate { + /// X coordinate (column) + pub x: f64, + /// Y coordinate (row) + pub y: f64, +} + +impl PixelCoordinate { + pub fn new(x: f64, y: f64) -> Self { + Self { x, y } + } +} + +/// Range for solver parameters +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Range { + pub min: T, + pub max: T, +} + +impl Range { + pub fn new(min: T, max: T) -> Self { + Self { min, max } + } +} + +/// Configuration for astrometry solver +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SolverConfig { + /// Path to solve-field executable + pub solve_field_path: PathBuf, + + /// Working directory for temporary files + pub working_dir: PathBuf, + + /// Index files to use (optional, if empty uses default search) + pub index_files: Vec, + + /// Scale range in arcseconds per pixel + pub scale_range: Option>, + + /// Field center hint (RA/Dec in degrees) + pub center_hint: Option, + + /// Search radius around center hint in degrees + pub search_radius: Option, + + /// Maximum time to wait for solution in seconds + pub timeout_seconds: u32, + + /// Whether to overwrite existing output files + pub overwrite: bool, + + /// Whether to keep temporary files for debugging + pub keep_temp_files: bool, + + /// Additional command line arguments + pub extra_args: Vec, +} + +impl Default for SolverConfig { + fn default() -> Self { + Self { + solve_field_path: PathBuf::from("solve-field"), + working_dir: PathBuf::from("/tmp/astrometry"), + index_files: Vec::new(), + scale_range: None, + center_hint: None, + search_radius: None, + timeout_seconds: 60, + overwrite: true, + keep_temp_files: false, + extra_args: Vec::new(), + } + } +} + +/// Result of astrometry solving +#[derive(Debug, Clone)] +pub struct SolverResult { + /// Status of the solve operation + pub status: SolverStatus, + + /// Field center coordinates (if solution found) + pub field_center: Option, + + /// Field size in degrees (width, height) + pub field_size: Option<(f64, f64)>, + + /// Rotation angle in degrees + pub rotation: Option, + + /// Scale in arcseconds per pixel + pub scale: Option, + + /// Path to WCS file (if generated) + pub wcs_file: Option, + + /// Path to AXY file (detected stars) + pub axy_file: Option, + + /// Path to RDLS file (matched stars) + pub rdls_file: Option, + + /// Path to solved file (if generated) + pub solved_file: Option, + + /// Execution time in seconds + pub execution_time: f64, + + /// Command output (stdout + stderr) + pub command_output: String, +} + +impl Default for SolverResult { + fn default() -> Self { + Self { + status: SolverStatus::Failed, + field_center: None, + field_size: None, + rotation: None, + scale: None, + wcs_file: None, + axy_file: None, + rdls_file: None, + solved_file: None, + execution_time: 0.0, + command_output: String::new(), + } + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 0bd0a76..6368b89 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub mod streaming; pub mod storage; pub mod communication; pub mod monitoring; +pub mod astrometry; pub mod overlay; pub mod hooks; pub mod events; \ No newline at end of file diff --git a/src/overlay/star_chart.rs b/src/overlay/star_chart.rs index c22308f..9ec1164 100644 --- a/src/overlay/star_chart.rs +++ b/src/overlay/star_chart.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Utc}; use log::{debug, error, info, warn}; use opencv::{core, imgproc, prelude::*, types, imgcodecs, calib3d}; @@ -19,6 +19,9 @@ use crate::camera::{Frame}; use crate::utils::memory_monitor::estimate_mat_size; use crate::utils::opencv_compat; +// Import the new astrometry abstraction layer +use super::super::astrometry::{AstrometrySolver, SolverConfig, SolverConfigBuilder, SolverResult, SolverStatus, CelestialCoordinate, Range}; + /// Configuration options for the star chart overlay #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StarChartOptions { @@ -279,6 +282,8 @@ pub struct StarChart { /// Current GPS status for position information gps_status: Arc>, state: Arc>, + /// Astrometry solver instance + solver: Arc, /// Solver thread handle _solver_thread: Option>, /// Channel for sending messages to the solver thread @@ -286,6 +291,39 @@ pub struct StarChart { } impl StarChart { + /// Convert StarChartOptions to SolverConfig + fn create_solver_config(options: &StarChartOptions) -> Result { + let mut config_builder = SolverConfigBuilder::new() + .solve_field_path(PathBuf::from(&options.solve_field_path)) + .working_dir(PathBuf::from(&options.working_dir)) + .scale_range(options.index_scale_range.0, options.index_scale_range.1) + .timeout(options.max_solve_time as u32) + .overwrite(true) + .keep_temp_files(false); + + // Add index files if specified + if let Some(ref index_files) = options.index_file { + let index_paths: Vec = index_files.iter() + .map(|s| PathBuf::from(s)) + .collect(); + config_builder = config_builder.index_files(index_paths); + } + + // Add extra arguments for better solve performance + let extra_args = vec![ + "--no-plot".to_string(), + "--depth".to_string(), "10,20,30,40,50".to_string(), + "--downsample".to_string(), "2".to_string(), + "--no-remove-lines".to_string(), + "--crpix-center".to_string(), + "--no-plots".to_string(), + "--cpulimit".to_string(), "60".to_string(), + ]; + config_builder = config_builder.extra_args(extra_args); + + Ok(config_builder.build()) + } + /// Create a new star chart overlay with the given options pub async fn new( options: StarChartOptions, @@ -298,6 +336,11 @@ impl StarChart { .context("Failed to create working directory for astrometry")?; } + // Create solver configuration and instance + let solver_config = Self::create_solver_config(&options)?; + let solver = Arc::new(AstrometrySolver::new(solver_config) + .context("Failed to create astrometry solver")?); + // Create the message channel for the solver thread let (solver_tx, solver_rx) = mpsc::channel::(10); @@ -307,6 +350,7 @@ impl StarChart { options: options.clone(), gps_status, state: state.clone(), + solver: solver.clone(), _solver_thread: None, solver_tx: Some(solver_tx), }; @@ -315,7 +359,7 @@ impl StarChart { star_chart.load_catalog_data().await?; // Start the solver thread - let solver_thread_handle = star_chart.start_solver_thread(solver_rx, options, state).await?; + let solver_thread_handle = star_chart.start_solver_thread(solver_rx, options, state, solver).await?; star_chart._solver_thread = Some(solver_thread_handle); Ok(star_chart) @@ -1198,6 +1242,7 @@ impl StarChart { mut rx: mpsc::Receiver, options: StarChartOptions, state: Arc>, + solver: Arc, ) -> Result> { // Clone needed values for the thread let gps_status = self.gps_status.clone(); @@ -1247,12 +1292,13 @@ impl StarChart { let hint = { state.lock().unwrap().latest_hint.clone() }; // Run astrometry.net on the frame - let result = Self::run_astrometry( + let result = Self::run_astrometry_with_solver( &frame, &working_dir, &options, &hint, - &gps_status + &gps_status, + &solver ).await; match result { @@ -1316,7 +1362,119 @@ impl StarChart { Ok(frame_path) } - /// Run astrometry.net to solve the star field + /// Run astrometry.net to solve the star field using the abstracted solver + async fn run_astrometry_with_solver( + _frame: &Frame, + working_dir: &Path, + options: &StarChartOptions, + previous_hint: &Option, + _gps_status: &Arc>, + solver: &Arc + ) -> Result<(SolutionHint, PathBuf, PathBuf)> { + let input_file = working_dir.join("current_frame.jpg"); + + // Create solver configuration with hint if available + let mut solver_config = Self::create_solver_config(options)?; + + // Apply previous hint if available + if let Some(hint) = previous_hint { + debug!("Using previous solution as hint: RA={}, Dec={}, Scale={}", + hint.ra, hint.dec, hint.scale); + + // Set center hint + if let Ok(center_hint) = CelestialCoordinate::new(hint.ra, hint.dec) { + solver_config.center_hint = Some(center_hint); + solver_config.search_radius = Some(hint.radius * 1.2); // 20% margin + + // Set scale hint with 10% margin + let scale_low = hint.scale * 0.9; + let scale_high = hint.scale * 1.1; + solver_config.scale_range = Some(Range::new(scale_low, scale_high)); + } + } else { + debug!("No previous hint available, using wider search parameters"); + // For first solve, use wider scale range + solver_config.scale_range = Some(Range::new(70.0, 90.0)); + } + + // Create a temporary solver with the updated configuration + let temp_solver = AstrometrySolver::new(solver_config) + .context("Failed to create temporary solver with updated configuration")?; + + // Run the solve + let result = temp_solver.solve_image(&input_file).await + .context("Failed to solve image")?; + + match result.status { + SolverStatus::Success => { + let field_center = result.field_center + .ok_or_else(|| anyhow!("Solution found but no field center available"))?; + let scale = result.scale + .ok_or_else(|| anyhow!("Solution found but no scale available"))?; + let rotation = result.rotation.unwrap_or(0.0); + + // Calculate radius from field size or use a default + let radius = if let Some((width, height)) = result.field_size { + (width.max(height)) / 2.0 + } else { + 1.0 // Default 1 degree radius + }; + + let hint = SolutionHint { + ra: field_center.ra, + dec: field_center.dec, + radius, + scale, + timestamp: Utc::now(), + }; + + let wcs_path = result.wcs_file + .ok_or_else(|| anyhow!("Solution found but no WCS file available"))?; + let axy_path = result.axy_file + .unwrap_or_else(|| input_file.with_extension("axy")); + + info!("✅ Astrometry solve succeeded: RA={:.6}°, Dec={:.6}°, Scale={:.2} arcsec/px", + hint.ra, hint.dec, hint.scale); + + Ok((hint, wcs_path, axy_path)) + }, + SolverStatus::Failed => { + Err(anyhow!("Astrometry solve failed: {}", result.command_output)) + }, + SolverStatus::Timeout => { + Err(anyhow!("Astrometry solve timed out after {} seconds", result.execution_time)) + }, + _ => { + Err(anyhow!("Astrometry solve ended with unexpected status: {:?}", result.status)) + } + } + } + + /// Parse WCS solution using the new abstracted parser + fn parse_wcs_solution_new(wcs_file: &Path) -> Result<(f64, f64, f64, f64)> { + use super::super::astrometry::OutputFileParser; + + let wcs_data = OutputFileParser::parse_wcs_file(wcs_file) + .context("Failed to parse WCS file using new parser")?; + + let center_ra = wcs_data.crval1; + let center_dec = wcs_data.crval2; + let scale = wcs_data.pixel_scale(); + + // Calculate radius from image dimensions if available + let radius = if let (Some(width), Some(height)) = (wcs_data.image_width, wcs_data.image_height) { + let diagonal_pixels = ((width as f64).powi(2) + (height as f64).powi(2)).sqrt(); + let diagonal_arcsec = diagonal_pixels * scale; + diagonal_arcsec / 2.0 / 3600.0 // Convert to degrees + } else { + 1.0 // Default 1 degree radius + }; + + Ok((center_ra, center_dec, radius, scale)) + } + + /// Legacy run_astrometry method - kept for compatibility but marked as deprecated + #[deprecated(note = "Use run_astrometry_with_solver instead")] async fn run_astrometry( _frame: &Frame, working_dir: &Path, @@ -1428,7 +1586,7 @@ impl StarChart { let axy_file = input_file.with_extension("axy"); if wcs_file.exists() { // info!("WCS file found, solve succeeded."); - let solution = Self::parse_wcs_solution(&wcs_file)?; + let solution = Self::parse_wcs_solution_new(&wcs_file)?; let hint = SolutionHint { ra: solution.0, dec: solution.1, @@ -1975,7 +2133,7 @@ impl StarChart { } // Parse WCS solution to get field of view - let (center_ra, center_dec, radius, scale) = Self::parse_wcs_solution(wcs_path)?; + let (center_ra, center_dec, radius, scale) = Self::parse_wcs_solution_new(wcs_path)?; info!("Field center: RA={}, Dec={}, Radius={} deg, Catalog has {} objects", center_ra, center_dec, radius, state.ngc_catalog.len()); // Calculate field dimensions @@ -2069,7 +2227,7 @@ impl StarChart { } // Parse WCS solution to get field of view - let (center_ra, center_dec, radius, _scale) = Self::parse_wcs_solution(wcs_path)?; + let (center_ra, center_dec, radius, _scale) = Self::parse_wcs_solution_new(wcs_path)?; // 🚨 POTENTIAL MEMORY LEAK: Cloning entire catalog every update! warn!("🚨 MEMORY LEAK SOURCE: About to clone entire constellation catalog ({} entries, ~{:.2}MB)",