//! SlideScope pathology service implementation //! //! This module provides the main service interface for SlideScope digital //! pathology processing. It handles slide import, NMF stain unmixing, //! tile pyramid serving, and job management. use std::collections::HashMap; use std::num::NonZeroUsize; use std::path::Path; use std::sync::Arc; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use image::GenericImageView; use lru::LruCache; use tokio::sync::{RwLock, broadcast, mpsc}; use uuid::Uuid; use rtx_slidescope::{ MultiLayerPyramid, NmfProcessingConfig, ProcessingConfig, SlidescopeProcessor, }; use slidescope_shared::{ DziMetadata, ImageFormat, JobProgress, JobStatus, NmfConfig, NmfResult, SlideFilter, SlideMetadata, SlideStatus, SlidescopeStatus, StainType, TileLayer, TileRequest, TileResponse, jobs::JobState, }; use crate::error::{ServerError, ServerResult}; /// SlideScope service configuration #[derive(Debug, Clone)] pub struct SlidescopeServiceConfig { /// Tile size for pyramids pub tile_size: u32, /// JPEG quality for tiles (0-100) pub jpeg_quality: u8, /// Maximum tiles in LRU cache pub max_cached_tiles: usize, /// Maximum memory for tile cache in MB pub max_cache_memory_mb: usize, /// Default NMF components pub default_nmf_components: usize, /// Maximum NMF iterations pub max_nmf_iterations: usize, /// NMF convergence tolerance pub nmf_tolerance: f32, /// Use GPU for NMF pub use_gpu: bool, } impl Default for SlidescopeServiceConfig { fn default() -> Self { Self { tile_size: 256, jpeg_quality: 85, max_cached_tiles: 10000, max_cache_memory_mb: 512, default_nmf_components: 2, max_nmf_iterations: 200, nmf_tolerance: 1e-4, use_gpu: true, } } } impl SlidescopeServiceConfig { /// Fast configuration for testing #[must_use] pub fn fast() -> Self { Self { tile_size: 128, jpeg_quality: 75, max_cached_tiles: 1000, max_cache_memory_mb: 64, default_nmf_components: 2, max_nmf_iterations: 50, nmf_tolerance: 1e-3, use_gpu: false, } } } /// Tile cache key #[derive(Debug, Clone, Hash, PartialEq, Eq)] struct TileCacheKey { slide_id: String, layer: TileLayer, level: u8, x: u32, y: u32, } /// Processing job for the job queue (reserved for async job manager) #[allow(dead_code)] #[derive(Debug)] struct ProcessingJob { job_id: String, slide_id: String, config: NmfConfig, } /// SlideScope handle containing active state struct SlidescopeHandle { /// Workspace path workspace: String, /// Slide metadata registry slides: HashMap, /// Loaded pyramids (slide_id -> layer -> pyramid) pyramids: HashMap, /// Tile cache tile_cache: LruCache>, /// Processing results results: HashMap, /// Job statuses jobs: HashMap, /// Start time start_time: Instant, } impl SlidescopeHandle { /// Create a new handle fn new(workspace: String, cache_size: usize) -> Self { let cache_size = NonZeroUsize::new(cache_size).unwrap_or(NonZeroUsize::new(10000).unwrap()); Self { workspace, slides: HashMap::new(), pyramids: HashMap::new(), tile_cache: LruCache::new(cache_size), results: HashMap::new(), jobs: HashMap::new(), start_time: Instant::now(), } } /// Get current status fn status(&self) -> SlidescopeStatus { let pending_count = self .jobs .values() .filter(|j| j.state == JobState::Queued) .count(); let running_job = self.jobs.values().find(|j| j.state == JobState::Running); SlidescopeStatus { initialized: true, gpu_available: rtx_slidescope::gpu_available(), gpu_name: rtx_slidescope::gpu_device_name(), slide_count: self.slides.len(), pending_jobs: pending_count, active_job: running_job.map(|j| j.id.clone()), workspace: Some(self.workspace.clone()), uptime_secs: self.start_time.elapsed().as_secs(), } } } /// SlideScope pathology service /// /// This service manages slide import, NMF stain unmixing, /// tile pyramid generation and serving. pub struct SlidescopeService { /// Service configuration config: SlidescopeServiceConfig, /// Active handle handle: Arc>>, /// Job sender job_tx: mpsc::UnboundedSender, /// Progress broadcast progress_tx: broadcast::Sender, } impl SlidescopeService { /// Create a new SlideScope service #[must_use] pub fn new(config: SlidescopeServiceConfig) -> Self { let (job_tx, _job_rx) = mpsc::unbounded_channel(); let (progress_tx, _) = broadcast::channel(100); Self { config, handle: Arc::new(RwLock::new(None)), job_tx, progress_tx, } } /// Create a service with default configuration #[must_use] pub fn with_defaults() -> Self { Self::new(SlidescopeServiceConfig::default()) } /// Subscribe to progress updates pub fn subscribe_progress(&self) -> broadcast::Receiver { self.progress_tx.subscribe() } /// Initialize the service with a workspace path /// /// Creates the workspace directory and initializes the tile cache. pub async fn initialize(&self, workspace: &str) -> ServerResult<()> { let mut handle_guard = self.handle.write().await; if handle_guard.is_some() { return Err(ServerError::AlreadyInitialized); } // Create workspace directory let workspace_path = Path::new(workspace); std::fs::create_dir_all(workspace_path) .map_err(|e| ServerError::internal(format!("Failed to create workspace: {}", e)))?; let handle = SlidescopeHandle::new(workspace.to_string(), self.config.max_cached_tiles); tracing::info!( "SlideScope service initialized with workspace: {}", workspace ); *handle_guard = Some(handle); Ok(()) } /// Reset the service pub async fn reset(&self) -> ServerResult<()> { let mut handle_guard = self.handle.write().await; *handle_guard = None; Ok(()) } /// Check if initialized pub async fn is_initialized(&self) -> bool { self.handle.read().await.is_some() } /// Import a slide from a file path pub async fn import_slide(&self, path: &str) -> ServerResult { let mut handle_guard = self.handle.write().await; let handle = handle_guard.as_mut().ok_or(ServerError::NotInitialized)?; // Load the image let image_path = Path::new(path); if !image_path.exists() { return Err(ServerError::not_found(format!("File not found: {}", path))); } let image = image::open(image_path) .map_err(|e| ServerError::internal(format!("Failed to load image: {}", e)))?; let (width, height) = image.dimensions(); let _format = detect_image_format(path); let name = image_path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string(); let id = Uuid::new_v4().to_string(); let created_at = current_timestamp(); // Generate pyramid tracing::info!( "Generating tile pyramid for {} ({}x{})", name, width, height ); let processor = SlidescopeProcessor::with_defaults() .map_err(|e| ServerError::internal(format!("Failed to create processor: {}", e)))?; let pyramid = processor .generate_pyramid(&image) .map_err(|e| ServerError::internal(format!("Failed to generate pyramid: {}", e)))?; // Create multi-layer pyramid with original only let mut multi_pyramid = MultiLayerPyramid::new(width, height); multi_pyramid.add_layer(TileLayer::Original, pyramid); // Create metadata let metadata = SlideMetadata { id: id.clone(), name: name.clone(), path: path.to_string(), width, height, status: SlideStatus::Imported, stain_type: None, thumbnail_path: None, pyramid_path: None, created_at, processed_at: None, }; // Store in memory handle.slides.insert(id.clone(), metadata.clone()); handle.pyramids.insert(id.clone(), multi_pyramid); tracing::info!( "Imported slide {} ({} tiles)", id, handle .pyramids .get(&id) .map(|p| p.layers().len()) .unwrap_or(0) ); Ok(metadata) } /// List slides with optional filter pub async fn list_slides( &self, filter: Option, ) -> ServerResult> { let handle_guard = self.handle.read().await; let handle = handle_guard.as_ref().ok_or(ServerError::NotInitialized)?; let slides: Vec = handle .slides .values() .filter(|s| { if let Some(ref f) = filter { if let Some(ref status) = f.status { if s.status != *status { return false; } } if let Some(ref name_filter) = f.name_contains { if !s.name.contains(name_filter) { return false; } } } true }) .cloned() .collect(); Ok(slides) } /// Get a slide by ID pub async fn get_slide(&self, slide_id: &str) -> ServerResult { let handle_guard = self.handle.read().await; let handle = handle_guard.as_ref().ok_or(ServerError::NotInitialized)?; handle .slides .get(slide_id) .cloned() .ok_or_else(|| ServerError::not_found(format!("Slide not found: {}", slide_id))) } /// Get a tile from a slide pub async fn get_tile(&self, request: TileRequest) -> ServerResult { let mut handle_guard = self.handle.write().await; let handle = handle_guard.as_mut().ok_or(ServerError::NotInitialized)?; let cache_key = TileCacheKey { slide_id: request.slide_id.clone(), layer: request.layer, level: request.z, x: request.x, y: request.y, }; // Check cache first if let Some(data) = handle.tile_cache.get(&cache_key) { return Ok(TileResponse { data: data.clone(), width: self.config.tile_size, height: self.config.tile_size, }); } // Get from pyramid let pyramid = handle.pyramids.get(&request.slide_id).ok_or_else(|| { ServerError::not_found(format!("Slide not loaded: {}", request.slide_id)) })?; let tile_data = pyramid .get_tile(request.layer, request.z, request.x, request.y) .ok_or_else(|| { ServerError::not_found(format!( "Tile not found: {}/{}/{}/{}", request.layer, request.z, request.x, request.y )) })?; // Cache the tile handle.tile_cache.put(cache_key, tile_data.clone()); Ok(TileResponse { data: tile_data.clone(), width: self.config.tile_size, height: self.config.tile_size, }) } /// Get DZI metadata for a slide layer pub async fn get_dzi(&self, slide_id: &str, layer: TileLayer) -> ServerResult { let handle_guard = self.handle.read().await; let handle = handle_guard.as_ref().ok_or(ServerError::NotInitialized)?; let pyramid = handle .pyramids .get(slide_id) .ok_or_else(|| ServerError::not_found(format!("Slide not loaded: {}", slide_id)))?; pyramid .get_dzi_metadata(layer) .ok_or_else(|| ServerError::not_found(format!("Layer not found: {:?}", layer))) } /// Queue a slide for NMF processing pub async fn queue_processing( &self, slide_id: &str, config: NmfConfig, ) -> ServerResult { let mut handle_guard = self.handle.write().await; let handle = handle_guard.as_mut().ok_or(ServerError::NotInitialized)?; // Verify slide exists if !handle.slides.contains_key(slide_id) { return Err(ServerError::not_found(format!( "Slide not found: {}", slide_id ))); } let job_id = Uuid::new_v4().to_string(); let status = JobStatus::new_queued(job_id.clone(), slide_id.to_string()); handle.jobs.insert(job_id.clone(), status.clone()); // Queue for processing let job = ProcessingJob { job_id: job_id.clone(), slide_id: slide_id.to_string(), config, }; let _ = self.job_tx.send(job); Ok(status) } /// Process a slide with NMF (synchronous, for simplicity) pub async fn process_nmf(&self, slide_id: &str, config: NmfConfig) -> ServerResult { let start_time = Instant::now(); // Get the slide image let slide_path = { let handle_guard = self.handle.read().await; let handle = handle_guard.as_ref().ok_or(ServerError::NotInitialized)?; let slide = handle .slides .get(slide_id) .ok_or_else(|| ServerError::not_found(format!("Slide not found: {}", slide_id)))?; slide.path.clone() }; // Update status to processing { let mut handle_guard = self.handle.write().await; let handle = handle_guard.as_mut().ok_or(ServerError::NotInitialized)?; if let Some(slide) = handle.slides.get_mut(slide_id) { slide.status = SlideStatus::Processing; } } // Load image and process let image = image::open(&slide_path) .map_err(|e| ServerError::internal(format!("Failed to load image: {}", e)))?; // Create processor config let _nmf_config = NmfProcessingConfig { n_components: config.n_components, max_iterations: config.max_iterations, tolerance: config.tolerance, use_gpu: config.use_gpu, random_seed: config.random_seed, }; // Process with GPU or CPU let processor_config = ProcessingConfig::default(); let processor = SlidescopeProcessor::new(processor_config) .map_err(|e| ServerError::internal(format!("Failed to create processor: {}", e)))?; let nmf_result = processor .process_nmf(&image) .map_err(|e| ServerError::processing(format!("NMF processing failed: {}", e)))?; let processing_time_ms = start_time.elapsed().as_millis() as u64; // Store the multi-layer pyramid { let mut handle_guard = self.handle.write().await; let handle = handle_guard.as_mut().ok_or(ServerError::NotInitialized)?; // Update slide status if let Some(slide) = handle.slides.get_mut(slide_id) { slide.status = SlideStatus::Processed; slide.stain_type = Some(StainType::HE); slide.processed_at = Some(current_timestamp()); } // Store pyramid handle .pyramids .insert(slide_id.to_string(), nmf_result.multi_pyramid); } // Convert stain matrix to RGB triplet vectors let stain_vectors: Vec<[f32; 3]> = nmf_result .stain_matrix .as_slice() .unwrap_or(&[]) .chunks_exact(3) .map(|chunk| [chunk[0], chunk[1], chunk[2]]) .collect(); // Create result let result = NmfResult { slide_id: slide_id.to_string(), stain_images: vec![], // Stored as in-memory pyramids composite_path: None, reconstruction_error: nmf_result.stain_matrix.iter().sum::().abs() / nmf_result.stain_matrix.len() as f32, // Simple metric iterations: config.n_components, // NMF components used processing_time_ms, gpu_memory_mb: None, stain_vectors: if stain_vectors.is_empty() { None } else { Some(stain_vectors) }, }; // Store result { let mut handle_guard = self.handle.write().await; let handle = handle_guard.as_mut().ok_or(ServerError::NotInitialized)?; handle.results.insert(slide_id.to_string(), result.clone()); } tracing::info!( "NMF processing complete for {} in {}ms", slide_id, processing_time_ms ); Ok(result) } /// Get job status pub async fn job_status(&self, job_id: &str) -> ServerResult { let handle_guard = self.handle.read().await; let handle = handle_guard.as_ref().ok_or(ServerError::NotInitialized)?; handle .jobs .get(job_id) .cloned() .ok_or_else(|| ServerError::not_found(format!("Job not found: {}", job_id))) } /// Get processing result for a slide pub async fn get_result(&self, slide_id: &str) -> ServerResult { let handle_guard = self.handle.read().await; let handle = handle_guard.as_ref().ok_or(ServerError::NotInitialized)?; handle .results .get(slide_id) .cloned() .ok_or_else(|| ServerError::not_found(format!("Result not found: {}", slide_id))) } /// Get current service status pub async fn status(&self) -> ServerResult { let handle_guard = self.handle.read().await; match handle_guard.as_ref() { Some(handle) => Ok(handle.status()), None => Ok(SlidescopeStatus { initialized: false, gpu_available: rtx_slidescope::gpu_available(), gpu_name: rtx_slidescope::gpu_device_name(), slide_count: 0, pending_jobs: 0, active_job: None, workspace: None, uptime_secs: 0, }), } } /// Delete a slide pub async fn delete_slide(&self, slide_id: &str) -> ServerResult<()> { let mut handle_guard = self.handle.write().await; let handle = handle_guard.as_mut().ok_or(ServerError::NotInitialized)?; // Remove from memory handle.slides.remove(slide_id); handle.pyramids.remove(slide_id); handle.results.remove(slide_id); // Clear related cache entries let keys_to_remove: Vec<_> = handle .tile_cache .iter() .filter(|(k, _)| k.slide_id == slide_id) .map(|(k, _)| k.clone()) .collect(); for key in keys_to_remove { handle.tile_cache.pop(&key); } Ok(()) } } // Helper functions fn current_timestamp() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() as i64 } fn detect_image_format(path: &str) -> ImageFormat { let lower = path.to_lowercase(); if lower.ends_with(".tiff") || lower.ends_with(".tif") { ImageFormat::Tiff } else if lower.ends_with(".png") { ImageFormat::Png } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { ImageFormat::Jpeg } else if lower.ends_with(".svs") { ImageFormat::Svs } else if lower.ends_with(".ndpi") { ImageFormat::Ndpi } else { ImageFormat::Tiff // Default } } // Helper functions reserved for future persistence layer #[allow(dead_code)] fn format_to_string(format: &ImageFormat) -> &'static str { match format { ImageFormat::Tiff => "tiff", ImageFormat::BigTiff => "bigtiff", ImageFormat::Svs => "svs", ImageFormat::Ndpi => "ndpi", ImageFormat::Png => "png", ImageFormat::Jpeg => "jpeg", } } #[allow(dead_code)] fn parse_slide_status(s: &str) -> SlideStatus { match s { "imported" => SlideStatus::Imported, "processing" => SlideStatus::Processing, "processed" => SlideStatus::Processed, "failed" => SlideStatus::Failed, _ => SlideStatus::Imported, } } #[allow(dead_code)] fn parse_stain_type(s: &str) -> StainType { match s { "H&E" | "HE" => StainType::HE, "IHC-DAB" | "IhcDab" => StainType::IhcDab, _ => StainType::Custom, } } #[allow(dead_code)] fn parse_image_format(s: &str) -> ImageFormat { match s { "tiff" => ImageFormat::Tiff, "bigtiff" => ImageFormat::BigTiff, "svs" => ImageFormat::Svs, "ndpi" => ImageFormat::Ndpi, "png" => ImageFormat::Png, "jpeg" => ImageFormat::Jpeg, _ => ImageFormat::Tiff, } } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; async fn create_test_service() -> (SlidescopeService, tempfile::TempDir) { let dir = tempdir().unwrap(); let service = SlidescopeService::new(SlidescopeServiceConfig::fast()); service .initialize(dir.path().to_str().unwrap()) .await .unwrap(); (service, dir) } #[tokio::test] async fn test_service_creation() { let service = SlidescopeService::with_defaults(); assert!(!service.is_initialized().await); } #[tokio::test] async fn test_initialize() { let (service, _dir) = create_test_service().await; assert!(service.is_initialized().await); } #[tokio::test] async fn test_double_initialize_fails() { let (service, dir) = create_test_service().await; let result = service.initialize(dir.path().to_str().unwrap()).await; assert!(result.is_err()); } #[tokio::test] async fn test_status() { let (service, _dir) = create_test_service().await; let status = service.status().await.unwrap(); assert!(status.initialized); assert_eq!(status.slide_count, 0); } #[tokio::test] async fn test_reset() { let (service, _dir) = create_test_service().await; assert!(service.is_initialized().await); service.reset().await.unwrap(); assert!(!service.is_initialized().await); } #[tokio::test] async fn test_list_slides_empty() { let (service, _dir) = create_test_service().await; let slides = service.list_slides(None).await.unwrap(); assert!(slides.is_empty()); } }