feat: 把solve-field抽象出来,因为linux和mac下参数行为不一致;
This commit is contained in:
parent
52a5463664
commit
aea16be92e
12
src/astrometry/mod.rs
Normal file
12
src/astrometry/mod.rs
Normal file
@ -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};
|
||||
307
src/astrometry/output_parser.rs
Normal file
307
src/astrometry/output_parser.rs
Normal file
@ -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<u32>,
|
||||
/// Image height in pixels
|
||||
pub image_height: Option<u32>,
|
||||
}
|
||||
|
||||
impl WcsData {
|
||||
/// Get field center as CelestialCoordinate
|
||||
pub fn field_center(&self) -> Result<CelestialCoordinate, SolverError> {
|
||||
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<CelestialCoordinate, SolverError> {
|
||||
// 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<PixelCoordinate>,
|
||||
}
|
||||
|
||||
/// RDLS data from matched stars
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RdlsData {
|
||||
/// Matched star positions in celestial coordinates
|
||||
pub stars: Vec<CelestialCoordinate>,
|
||||
}
|
||||
|
||||
/// 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<P: AsRef<Path>>(path: P) -> Result<WcsData, SolverError> {
|
||||
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::<f64>(&mut fits, "CRVAL1").unwrap_or(0.0);
|
||||
let crval2 = hdu.read_key::<f64>(&mut fits, "CRVAL2").unwrap_or(0.0);
|
||||
let crpix1 = hdu.read_key::<f64>(&mut fits, "CRPIX1").unwrap_or(0.0);
|
||||
let crpix2 = hdu.read_key::<f64>(&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::<f64>(&mut fits, "CD1_1").unwrap_or(0.0);
|
||||
let cd1_2 = hdu.read_key::<f64>(&mut fits, "CD1_2").unwrap_or(0.0);
|
||||
let cd2_1 = hdu.read_key::<f64>(&mut fits, "CD2_1").unwrap_or(0.0);
|
||||
let cd2_2 = hdu.read_key::<f64>(&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::<f64>(&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::<i64>(&mut fits, "IMAGEW")
|
||||
.or_else(|_| hdu.read_key::<i64>(&mut fits, "NAXIS1"))
|
||||
.ok()
|
||||
.and_then(|w| if w > 0 { Some(w as u32) } else { None });
|
||||
|
||||
let image_height = hdu.read_key::<i64>(&mut fits, "IMAGEH")
|
||||
.or_else(|_| hdu.read_key::<i64>(&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<P: AsRef<Path>>(path: P) -> Result<AxyData, SolverError> {
|
||||
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::<f64>(), parts[1].parse::<f64>()) {
|
||||
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<P: AsRef<Path>>(path: P) -> Result<RdlsData, SolverError> {
|
||||
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::<f64>(), parts[1].parse::<f64>()) {
|
||||
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<String, String> {
|
||||
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
|
||||
}
|
||||
}
|
||||
388
src/astrometry/solver.rs
Normal file
388
src/astrometry/solver.rs
Normal file
@ -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<Self, SolverError> {
|
||||
// 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<P: AsRef<Path>>(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<P: AsRef<Path>>(&self, image_path: P) -> Result<SolverResult, SolverError> {
|
||||
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<P: AsRef<Path>>(
|
||||
&self,
|
||||
image_path: P,
|
||||
config_override: SolverConfig
|
||||
) -> Result<SolverResult, SolverError> {
|
||||
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<Vec<super::types::PixelCoordinate>, 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<Vec<CelestialCoordinate>, 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<CelestialCoordinate, SolverError> {
|
||||
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<super::types::PixelCoordinate, SolverError> {
|
||||
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<P: Into<PathBuf>>(mut self, path: P) -> Self {
|
||||
self.config.solve_field_path = path.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn working_dir<P: Into<PathBuf>>(mut self, dir: P) -> Self {
|
||||
self.config.working_dir = dir.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn index_files<I, P>(mut self, files: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = P>,
|
||||
P: Into<PathBuf>,
|
||||
{
|
||||
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, SolverError> {
|
||||
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<I, S>(mut self, args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
self.config.extra_args = args.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> SolverConfig {
|
||||
self.config
|
||||
}
|
||||
}
|
||||
212
src/astrometry/types.rs
Normal file
212
src/astrometry/types.rs
Normal file
@ -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<Self, SolverError> {
|
||||
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, SolverError> {
|
||||
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<T> {
|
||||
pub min: T,
|
||||
pub max: T,
|
||||
}
|
||||
|
||||
impl<T> Range<T> {
|
||||
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<PathBuf>,
|
||||
|
||||
/// Scale range in arcseconds per pixel
|
||||
pub scale_range: Option<Range<f64>>,
|
||||
|
||||
/// Field center hint (RA/Dec in degrees)
|
||||
pub center_hint: Option<CelestialCoordinate>,
|
||||
|
||||
/// Search radius around center hint in degrees
|
||||
pub search_radius: Option<f64>,
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<CelestialCoordinate>,
|
||||
|
||||
/// Field size in degrees (width, height)
|
||||
pub field_size: Option<(f64, f64)>,
|
||||
|
||||
/// Rotation angle in degrees
|
||||
pub rotation: Option<f64>,
|
||||
|
||||
/// Scale in arcseconds per pixel
|
||||
pub scale: Option<f64>,
|
||||
|
||||
/// Path to WCS file (if generated)
|
||||
pub wcs_file: Option<PathBuf>,
|
||||
|
||||
/// Path to AXY file (detected stars)
|
||||
pub axy_file: Option<PathBuf>,
|
||||
|
||||
/// Path to RDLS file (matched stars)
|
||||
pub rdls_file: Option<PathBuf>,
|
||||
|
||||
/// Path to solved file (if generated)
|
||||
pub solved_file: Option<PathBuf>,
|
||||
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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<Mutex<GpsStatus>>,
|
||||
state: Arc<Mutex<StarChartState>>,
|
||||
/// Astrometry solver instance
|
||||
solver: Arc<AstrometrySolver>,
|
||||
/// Solver thread handle
|
||||
_solver_thread: Option<tokio::task::JoinHandle<()>>,
|
||||
/// 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<SolverConfig> {
|
||||
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<PathBuf> = 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::<SolverMessage>(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<SolverMessage>,
|
||||
options: StarChartOptions,
|
||||
state: Arc<Mutex<StarChartState>>,
|
||||
solver: Arc<AstrometrySolver>,
|
||||
) -> Result<tokio::task::JoinHandle<()>> {
|
||||
// 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<SolutionHint>,
|
||||
_gps_status: &Arc<Mutex<GpsStatus>>,
|
||||
solver: &Arc<AstrometrySolver>
|
||||
) -> 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)",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user