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 storage;
|
||||||
pub mod communication;
|
pub mod communication;
|
||||||
pub mod monitoring;
|
pub mod monitoring;
|
||||||
|
pub mod astrometry;
|
||||||
pub mod overlay;
|
pub mod overlay;
|
||||||
pub mod hooks;
|
pub mod hooks;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use opencv::{core, imgproc, prelude::*, types, imgcodecs, calib3d};
|
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::memory_monitor::estimate_mat_size;
|
||||||
use crate::utils::opencv_compat;
|
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
|
/// Configuration options for the star chart overlay
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StarChartOptions {
|
pub struct StarChartOptions {
|
||||||
@ -279,6 +282,8 @@ pub struct StarChart {
|
|||||||
/// Current GPS status for position information
|
/// Current GPS status for position information
|
||||||
gps_status: Arc<Mutex<GpsStatus>>,
|
gps_status: Arc<Mutex<GpsStatus>>,
|
||||||
state: Arc<Mutex<StarChartState>>,
|
state: Arc<Mutex<StarChartState>>,
|
||||||
|
/// Astrometry solver instance
|
||||||
|
solver: Arc<AstrometrySolver>,
|
||||||
/// Solver thread handle
|
/// Solver thread handle
|
||||||
_solver_thread: Option<tokio::task::JoinHandle<()>>,
|
_solver_thread: Option<tokio::task::JoinHandle<()>>,
|
||||||
/// Channel for sending messages to the solver thread
|
/// Channel for sending messages to the solver thread
|
||||||
@ -286,6 +291,39 @@ pub struct StarChart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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
|
/// Create a new star chart overlay with the given options
|
||||||
pub async fn new(
|
pub async fn new(
|
||||||
options: StarChartOptions,
|
options: StarChartOptions,
|
||||||
@ -298,6 +336,11 @@ impl StarChart {
|
|||||||
.context("Failed to create working directory for astrometry")?;
|
.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
|
// Create the message channel for the solver thread
|
||||||
let (solver_tx, solver_rx) = mpsc::channel::<SolverMessage>(10);
|
let (solver_tx, solver_rx) = mpsc::channel::<SolverMessage>(10);
|
||||||
|
|
||||||
@ -307,6 +350,7 @@ impl StarChart {
|
|||||||
options: options.clone(),
|
options: options.clone(),
|
||||||
gps_status,
|
gps_status,
|
||||||
state: state.clone(),
|
state: state.clone(),
|
||||||
|
solver: solver.clone(),
|
||||||
_solver_thread: None,
|
_solver_thread: None,
|
||||||
solver_tx: Some(solver_tx),
|
solver_tx: Some(solver_tx),
|
||||||
};
|
};
|
||||||
@ -315,7 +359,7 @@ impl StarChart {
|
|||||||
star_chart.load_catalog_data().await?;
|
star_chart.load_catalog_data().await?;
|
||||||
|
|
||||||
// Start the solver thread
|
// 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);
|
star_chart._solver_thread = Some(solver_thread_handle);
|
||||||
|
|
||||||
Ok(star_chart)
|
Ok(star_chart)
|
||||||
@ -1198,6 +1242,7 @@ impl StarChart {
|
|||||||
mut rx: mpsc::Receiver<SolverMessage>,
|
mut rx: mpsc::Receiver<SolverMessage>,
|
||||||
options: StarChartOptions,
|
options: StarChartOptions,
|
||||||
state: Arc<Mutex<StarChartState>>,
|
state: Arc<Mutex<StarChartState>>,
|
||||||
|
solver: Arc<AstrometrySolver>,
|
||||||
) -> Result<tokio::task::JoinHandle<()>> {
|
) -> Result<tokio::task::JoinHandle<()>> {
|
||||||
// Clone needed values for the thread
|
// Clone needed values for the thread
|
||||||
let gps_status = self.gps_status.clone();
|
let gps_status = self.gps_status.clone();
|
||||||
@ -1247,12 +1292,13 @@ impl StarChart {
|
|||||||
|
|
||||||
let hint = { state.lock().unwrap().latest_hint.clone() };
|
let hint = { state.lock().unwrap().latest_hint.clone() };
|
||||||
// Run astrometry.net on the frame
|
// Run astrometry.net on the frame
|
||||||
let result = Self::run_astrometry(
|
let result = Self::run_astrometry_with_solver(
|
||||||
&frame,
|
&frame,
|
||||||
&working_dir,
|
&working_dir,
|
||||||
&options,
|
&options,
|
||||||
&hint,
|
&hint,
|
||||||
&gps_status
|
&gps_status,
|
||||||
|
&solver
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@ -1316,7 +1362,119 @@ impl StarChart {
|
|||||||
Ok(frame_path)
|
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(
|
async fn run_astrometry(
|
||||||
_frame: &Frame,
|
_frame: &Frame,
|
||||||
working_dir: &Path,
|
working_dir: &Path,
|
||||||
@ -1428,7 +1586,7 @@ impl StarChart {
|
|||||||
let axy_file = input_file.with_extension("axy");
|
let axy_file = input_file.with_extension("axy");
|
||||||
if wcs_file.exists() {
|
if wcs_file.exists() {
|
||||||
// info!("WCS file found, solve succeeded.");
|
// 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 {
|
let hint = SolutionHint {
|
||||||
ra: solution.0,
|
ra: solution.0,
|
||||||
dec: solution.1,
|
dec: solution.1,
|
||||||
@ -1975,7 +2133,7 @@ impl StarChart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse WCS solution to get field of view
|
// 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());
|
info!("Field center: RA={}, Dec={}, Radius={} deg, Catalog has {} objects", center_ra, center_dec, radius, state.ngc_catalog.len());
|
||||||
|
|
||||||
// Calculate field dimensions
|
// Calculate field dimensions
|
||||||
@ -2069,7 +2227,7 @@ impl StarChart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse WCS solution to get field of view
|
// 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!
|
// 🚨 POTENTIAL MEMORY LEAK: Cloning entire catalog every update!
|
||||||
warn!("🚨 MEMORY LEAK SOURCE: About to clone entire constellation catalog ({} entries, ~{:.2}MB)",
|
warn!("🚨 MEMORY LEAK SOURCE: About to clone entire constellation catalog ({} entries, ~{:.2}MB)",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user