// src/services/observation/cutout.rs // // 图像 Cutout 服务 —— SDSS / DSS / Pan-STARRS cutout API // // 支持: // - SDSS SkyServer cutout(光学 2.5m) // - STScI DSS(Digitized Sky Survey,多种底片) // - Pan-STARRS cutout(光学 1.8m) use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use tracing::info; /// Cutout 数据源 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum CutoutSource { /// SDSS optical Sdss, /// DSS (POSS-I, POSS-II, etc.) Dss, /// Pan-STARRS DR1 Panstarrs, } impl CutoutSource { pub fn as_str(&self) -> &'static str { match self { Self::Sdss => "sdss", Self::Dss => "dss", Self::Panstarrs => "panstarrs", } } } /// Cutout 结果 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CutoutResult { /// 图像数据(JPEG/PNG 字节) pub image_bytes: Vec, /// 图像格式 pub format: String, /// 中心坐标 pub ra: f64, pub dec: f64, /// cutout 大小(角秒) pub size_arcsec: f64, /// 数据源 pub source: String, /// 图像宽度(像素) pub width: u32, /// 图像高度(像素) pub height: u32, } /// SDSS cutout(SkyServer) pub async fn sdss_cutout(ra: f64, dec: f64, size_arcsec: f64) -> Result { let scale = size_arcsec / 14.0; // 14 arcsec/pixel for SDSS let width = (size_arcsec / scale) as u32; let url = format!( "https://skyserver.sdss.org/dr18/SkyServerWS/ImgCutout/getjpeg?ra={}&dec={}&scale={:.4}&width={}&height={}", ra, dec, scale, width, width ); info!( "[Cutout] SDSS: ra={:.4}, dec={:.4}, size={}\"", ra, dec, size_arcsec ); let resp = reqwest::get(&url) .await .map_err(|e| anyhow!("SDSS cutout 请求失败: {}", e))?; if !resp.status().is_success() { return Err(anyhow!("SDSS cutout 失败: {}", resp.status())); } let bytes = resp.bytes().await?.to_vec(); Ok(CutoutResult { image_bytes: bytes, format: "jpeg".to_string(), ra, dec, size_arcsec, source: "SDSS".to_string(), width, height: width, }) } /// DSS cutout(STScI) pub async fn dss_cutout(ra: f64, dec: f64, size_arcsec: f64) -> Result { let size_deg = size_arcsec / 3600.0; let url = format!( "https://archive.stsci.edu/cgi-bin/dss_search?faste&ra={}&dec={}&equinox=J2000&epoch=2000&width={}&height={}&nimages=1&format=jpeg", ra, dec, size_deg, size_deg ); info!( "[Cutout] DSS: ra={:.4}, dec={:.4}, size={}\"", ra, dec, size_arcsec ); let resp = reqwest::get(&url) .await .map_err(|e| anyhow!("DSS cutout 请求失败: {}", e))?; if !resp.status().is_success() { return Err(anyhow!("DSS cutout 失败: {}", resp.status())); } let bytes = resp.bytes().await?.to_vec(); let pixel_size = size_arcsec / 1.0; // ~1 arcsec/pixel for DSS let width = (size_arcsec / pixel_size) as u32; Ok(CutoutResult { image_bytes: bytes, format: "jpeg".to_string(), ra, dec, size_arcsec, source: "DSS".to_string(), width, height: width, }) } /// Pan-STARRS cutout(via VizieR / PS1 cutout service) pub async fn panstarrs_cutout(ra: f64, dec: f64, size_arcsec: f64) -> Result { let size_deg = size_arcsec / 3600.0; // PS1 cutout service let url = format!( "https://ps1images.stsci.edu/cgi-bin/ps1cutoutservice?ra={}&dec={}&size={}&format=fits&filters=g,r,i,z,y", ra, dec, size_deg ); info!( "[Cutout] Pan-STARRS: ra={:.4}, dec={:.4}, size={}\"", ra, dec, size_arcsec ); let resp = reqwest::get(&url) .await .map_err(|e| anyhow!("PS1 cutout 请求失败: {}", e))?; if !resp.status().is_success() { return Err(anyhow!("PS1 cutout 失败: {}", resp.status())); } let bytes = resp.bytes().await?.to_vec(); Ok(CutoutResult { image_bytes: bytes, format: "fits".to_string(), ra, dec, size_arcsec, source: "Pan-STARRS".to_string(), width: 0, height: 0, }) } /// 统一 cutout 接口 pub async fn get_cutout( source: CutoutSource, ra: f64, dec: f64, size_arcsec: f64, ) -> Result { match source { CutoutSource::Sdss => sdss_cutout(ra, dec, size_arcsec).await, CutoutSource::Dss => dss_cutout(ra, dec, size_arcsec).await, CutoutSource::Panstarrs => panstarrs_cutout(ra, dec, size_arcsec).await, } } /// Finding Chart 生成(基于 cutout + 标注) pub async fn generate_finding_chart( ra: f64, dec: f64, size_arcsec: f64, source: CutoutSource, _label: Option<&str>, ) -> Result { let result = get_cutout(source, ra, dec, size_arcsec).await?; // 保存到文件 let ext = match result.format.as_str() { "jpeg" => "jpg", "fits" => "fits", _ => "png", }; let file_name = format!("finding_chart_{:.4}_{:.4}.{}", ra, dec, ext); let file_path = format!("finding_charts/{}", file_name); let library_dir = std::env::var("LIBRARY_DIR").unwrap_or_else(|_| "library".to_string()); let full_path = std::path::Path::new(&library_dir).join(&file_path); if let Some(parent) = full_path.parent() { tokio::fs::create_dir_all(parent).await.ok(); } tokio::fs::write(&full_path, &result.image_bytes).await.ok(); Ok(result) } // ═══════════════════════════════════════════════════════════════ // CutoutFetcher —— ObservationFetcher trait 实现 // ═══════════════════════════════════════════════════════════════ use crate::api::AppState; use crate::services::observation::cache::{ cached_files_total_size, fetch_observation_cache, log_write_failure, persist_bytes, write_observation_cache, }; use crate::services::observation::fetcher::{Candidate, ObservationFetcher}; use crate::services::observation::types::{ Artifact, ObservationProduct, ProductSpec, ProductType, Source, }; use tracing::warn; /// 图像 Cutout fetcher(SDSS/DSS/Pan-STARRS) /// /// 这不是一个标准的 ObservationFetcher(cutout 不通过 TAP/VO 检索), /// 而是通过 HTTP API 直接获取图像 cutout。 /// 注册为 (Source::Panstarrs, ProductType::Image) 的 fetcher, /// 坐标模式直接构造 cutout URL。 #[derive(Debug)] pub struct CutoutFetcher; #[async_trait::async_trait] impl ObservationFetcher for CutoutFetcher { fn key(&self) -> (Source, ProductType) { (Source::Panstarrs, ProductType::Image) } fn subtypes(&self) -> &'static [&'static str] { &["sdss", "dss", "panstarrs"] } fn suggested_max_radius_deg(&self) -> f64 { 0.0 } fn hard_max_radius_deg(&self) -> f64 { 0.0 } fn supports_coordinates(&self) -> bool { true } fn supports_identifiers(&self) -> bool { false } async fn cone_search_raw( &self, _state: &AppState, ra: f64, dec: f64, _radius_deg: f64, _release: Option<&str>, _subtype: Option<&str>, _version: Option<&str>, ) -> Result> { // Cutout 不做 cone search,直接返回目标本身 Ok(vec![Candidate { source: Source::Panstarrs, source_id: format!("cutout_{:.4}_{:.4}", ra, dec), label: format!("Cutout at ({:.4}, {:.4})", ra, dec), ra: Some(ra), dec: Some(dec), distance: Some(0.0), raw: None, }]) } async fn resolve_identifier( &self, _identifier: &str, _release: Option<&str>, _subtype: Option<&str>, _version: Option<&str>, ) -> Result { Err(anyhow!("Cutout 不支持标识符模式")) } async fn fetch( &self, state: &AppState, candidate: &Candidate, _release: Option<&str>, subtype: Option<&str>, _version: Option<&str>, force: bool, ) -> Result { let ra = candidate.ra.unwrap_or(0.0); let dec = candidate.dec.unwrap_or(0.0); let sub = subtype.unwrap_or("sdss"); let product = ProductSpec::with_subtype(ProductType::Image, sub); let cache_key = format!("cutout_{:.4}_{:.4}_{}", ra, dec, sub); let source_label = candidate.label.clone(); // 1) 检查缓存 if !force { if let Some((artifacts, meta)) = fetch_observation_cache(&state.db, Source::Panstarrs, &product, &cache_key).await? { if cached_files_total_size(&state.config.storage.library_dir, &artifacts) .await .is_some() { info!("[Cutout] 缓存命中 (key={})", cache_key); return Ok(ObservationProduct { source: Source::Panstarrs, product, source_id: cache_key, source_label, artifacts, source_meta: meta, }); } warn!("[Cutout] 缓存文件缺失,重新下载 (key={})", cache_key); } } // 2) 真实获取 let source = match sub { "dss" => CutoutSource::Dss, "panstarrs" => CutoutSource::Panstarrs, _ => CutoutSource::Sdss, }; let result = get_cutout(source, ra, dec, 120.0).await?; // 2 arcmin default let ext = match result.format.as_str() { "jpeg" => "jpg", "fits" => "fits", _ => "png", }; let file_name = format!("cutout_{:.4}_{:.4}.{}", ra, dec, ext); let file_path = format!("Telescope/cutout/{}/{}", candidate.source_id, file_name); persist_bytes( &state.config.storage.library_dir, &file_path, &result.image_bytes, ) .await?; let artifacts = vec![Artifact { band: None, original_name: Some(file_name), file_path: file_path.clone(), file_url: crate::services::observation::cache::file_url_from_path(&file_path), file_format: ext.to_string(), size_bytes: result.image_bytes.len(), cached: false, }]; // 3) 写入缓存 if let Err(e) = write_observation_cache( &state.db, Source::Panstarrs, &product, &cache_key, Some(ra), Some(dec), &artifacts, None, ) .await { log_write_failure("panstarrs", "image", e); } Ok(ObservationProduct { source: Source::Panstarrs, product, source_id: cache_key, source_label, artifacts, source_meta: candidate.raw.clone(), }) } }