Skip to main content

glint_mask_tools/loaders/
multi_file.rs

1/// Configurable multi-file image loader.
2///
3/// This loader handles sensors where each capture consists of multiple separate files,
4/// one for each band. The loader can be configured to work with different file naming
5/// conventions and band schemes through configuration parameters.
6use ndarray::Array3;
7use regex::Regex;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use crate::core::{
12    image_loader::{list_image_files, ImageCapture},
13    ImageLoader,
14};
15use crate::error::{GlintError, Result};
16
17/// Band naming scheme for multifile sensors
18#[derive(Debug, Clone, PartialEq)]
19pub enum BandScheme {
20    /// Sequential numbering (1, 2, 3, 4, 5)
21    Numbered { count: usize },
22    /// Named bands (G, R, RE, NIR)
23    Named { names: Vec<String> },
24}
25
26/// Configuration for a multifile image loader
27#[derive(Debug, Clone)]
28pub struct MultiFileLoaderConfig {
29    /// Regex pattern to match base files (reference files) with named capture groups
30    pub base_file_pattern: String,
31    /// Template for generating filenames using captured groups and band identifiers
32    pub filename_template: String,
33    /// Band naming scheme
34    pub band_scheme: BandScheme,
35    /// Reference band identifier (the band used to find base files)
36    pub reference_band: String,
37    /// Supported file extensions
38    pub extensions: Vec<String>,
39    /// Bit depth of the sensor
40    pub bit_depth: u8,
41}
42
43impl MultiFileLoaderConfig {
44    /// Create a new multifile loader configuration
45    pub fn new(
46        base_file_pattern: String,
47        filename_template: String,
48        band_scheme: BandScheme,
49        reference_band: String,
50        extensions: Vec<String>,
51        bit_depth: u8,
52    ) -> Self {
53        Self {
54            base_file_pattern,
55            filename_template,
56            band_scheme,
57            reference_band,
58            extensions,
59            bit_depth,
60        }
61    }
62
63    /// Create configuration from a HashMap (typically from TOML loader_config)
64    pub fn from_config(config: &HashMap<String, String>) -> Result<Self> {
65        let base_file_pattern = config
66            .get("base_file_pattern")
67            .ok_or_else(|| GlintError::config("Missing base_file_pattern in loader_config"))?
68            .clone();
69
70        let filename_template = config
71            .get("filename_template")
72            .ok_or_else(|| GlintError::config("Missing filename_template in loader_config"))?
73            .clone();
74
75        let reference_band = config
76            .get("reference_band")
77            .ok_or_else(|| GlintError::config("Missing reference_band in loader_config"))?
78            .clone();
79
80        let extensions_str = config
81            .get("extensions")
82            .ok_or_else(|| GlintError::config("Missing extensions in loader_config"))?;
83        let extensions: Vec<String> = extensions_str
84            .split(',')
85            .map(|s| s.trim().to_string())
86            .collect();
87
88        let bit_depth = config
89            .get("bit_depth")
90            .and_then(|s| s.parse::<u8>().ok())
91            .unwrap_or(16); // Default to 16-bit
92
93        let band_scheme = if let Some(scheme_str) = config.get("band_scheme") {
94            match scheme_str.as_str() {
95                "numbered" => {
96                    let count = config
97                        .get("band_count")
98                        .and_then(|s| s.parse::<usize>().ok())
99                        .ok_or_else(|| {
100                            GlintError::config("Missing band_count for numbered band scheme")
101                        })?;
102                    BandScheme::Numbered { count }
103                }
104                "named" => {
105                    let names_str = config.get("band_names").ok_or_else(|| {
106                        GlintError::config("Missing band_names for named band scheme")
107                    })?;
108                    let names: Vec<String> =
109                        names_str.split(',').map(|s| s.trim().to_string()).collect();
110                    BandScheme::Named { names }
111                }
112                _ => {
113                    return Err(GlintError::config(
114                        "Invalid band_scheme. Must be 'numbered' or 'named'",
115                    ))
116                }
117            }
118        } else {
119            return Err(GlintError::config("Missing band_scheme in loader_config"));
120        };
121
122        Ok(Self::new(
123            base_file_pattern,
124            filename_template,
125            band_scheme,
126            reference_band,
127            extensions,
128            bit_depth,
129        ))
130    }
131
132    /// Get the expected number of bands
133    pub fn band_count(&self) -> usize {
134        match &self.band_scheme {
135            BandScheme::Numbered { count } => *count,
136            BandScheme::Named { names } => names.len(),
137        }
138    }
139
140    /// Get the list of band identifiers
141    pub fn band_identifiers(&self) -> Vec<String> {
142        match &self.band_scheme {
143            BandScheme::Numbered { count } => (1..=*count).map(|i| i.to_string()).collect(),
144            BandScheme::Named { names } => names.clone(),
145        }
146    }
147}
148
149/// Configurable multi-file image loader
150///
151/// This loader can be configured to work with different multifile sensor formats
152/// by providing appropriate configuration parameters for file patterns, band schemes,
153/// and naming conventions.
154#[derive(Debug, Clone)]
155pub struct ConfigurableMultiFileLoader {
156    /// Regex pattern to match base files
157    base_pattern: Regex,
158    /// Loader configuration
159    config: MultiFileLoaderConfig,
160}
161
162impl ConfigurableMultiFileLoader {
163    /// Create a new configurable multifile loader
164    pub fn new(config: MultiFileLoaderConfig) -> Result<Self> {
165        let base_pattern = Regex::new(&config.base_file_pattern)
166            .map_err(|e| GlintError::validation(format!("Invalid base file pattern: {}", e)))?;
167
168        Ok(Self {
169            base_pattern,
170            config,
171        })
172    }
173
174    /// Create a loader from a configuration HashMap
175    pub fn from_config(config: &HashMap<String, String>) -> Result<Self> {
176        let loader_config = MultiFileLoaderConfig::from_config(config)?;
177        Self::new(loader_config)
178    }
179
180    /// Extract capture groups from a filename using the configured pattern
181    fn extract_filename_parts(&self, path: &Path) -> Option<HashMap<String, String>> {
182        let filename = path.file_name()?.to_str()?;
183
184        if let Some(captures) = self.base_pattern.captures(filename) {
185            let mut parts = HashMap::new();
186
187            // Extract all named capture groups
188            for name in self.base_pattern.capture_names().flatten() {
189                if let Some(matched) = captures.name(name) {
190                    parts.insert(name.to_string(), matched.as_str().to_string());
191                }
192            }
193
194            Some(parts)
195        } else {
196            None
197        }
198    }
199
200    /// Find all band files for a given base file using template-based generation
201    fn find_band_files(&self, base_file: &Path) -> Result<Vec<PathBuf>> {
202        let filename_parts = self.extract_filename_parts(base_file).ok_or_else(|| {
203            GlintError::processing("Could not extract filename parts from base file")
204        })?;
205
206        let parent_dir = base_file
207            .parent()
208            .ok_or_else(|| GlintError::processing("File has no parent directory"))?;
209
210        let mut band_files = Vec::new();
211        let band_identifiers = self.config.band_identifiers();
212
213        for band_id in &band_identifiers {
214            // Create a copy of the filename parts and replace the band with the current band_id
215            let mut parts = filename_parts.clone();
216            parts.insert("band".to_string(), band_id.clone());
217
218            // Generate filename using the template
219            let filename = self.generate_filename_from_template(&parts)?;
220            let band_path = parent_dir.join(filename);
221
222            if !band_path.exists() {
223                return Err(GlintError::MissingFiles {
224                    files: vec![band_path],
225                });
226            }
227
228            band_files.push(band_path);
229        }
230
231        Ok(band_files)
232    }
233
234    /// Generate a filename from the template using captured parts
235    fn generate_filename_from_template(&self, parts: &HashMap<String, String>) -> Result<String> {
236        let mut filename = self.config.filename_template.clone();
237
238        // Replace placeholders in the template with actual values
239        for (key, value) in parts {
240            let placeholder = format!("{{{}}}", key);
241            filename = filename.replace(&placeholder, value);
242        }
243
244        // Check if any placeholders remain unreplaced
245        if filename.contains('{') && filename.contains('}') {
246            return Err(GlintError::processing(format!(
247                "Template contains unreplaced placeholders: {}",
248                filename
249            )));
250        }
251
252        Ok(filename)
253    }
254
255    /// Generate a capture ID from the extracted filename parts
256    fn generate_capture_id(&self, parts: &HashMap<String, String>) -> String {
257        // Priority order for creating capture ID:
258        // 1. If there's a "base" part, use it
259        // 2. If there's a "prefix" part, use it
260        // 3. Combine parts intelligently based on the pattern
261
262        if let Some(base) = parts.get("base") {
263            return base.clone();
264        }
265
266        if let Some(prefix) = parts.get("prefix") {
267            return prefix.clone();
268        }
269
270        // Try to construct a meaningful ID from available parts
271        let mut id_parts = Vec::new();
272
273        // Common parts that make good identifiers
274        for key in ["prefix", "sequence", "number", "id", "capture"] {
275            if let Some(value) = parts.get(key) {
276                id_parts.push(value.clone());
277            }
278        }
279
280        if !id_parts.is_empty() {
281            id_parts.join("_")
282        } else {
283            // Fallback: use the first non-band, non-extension part
284            parts
285                .iter()
286                .filter(|(k, _)| !matches!(k.as_str(), "band" | "extension" | "ext"))
287                .map(|(_, v)| v.clone())
288                .next()
289                .unwrap_or_else(|| "unknown".to_string())
290        }
291    }
292
293    /// Load multiple band files and stack them into a single array
294    fn load_band_files(&self, band_files: &[PathBuf]) -> Result<Array3<f64>> {
295        let expected_count = self.config.band_count();
296        if band_files.len() != expected_count {
297            return Err(GlintError::BandCountMismatch {
298                expected: expected_count,
299                actual: band_files.len(),
300            });
301        }
302
303        // Load all band images
304        let mut band_arrays = Vec::new();
305        let mut height = 0;
306        let mut width = 0;
307
308        for (i, band_path) in band_files.iter().enumerate() {
309            let img = image::open(band_path)?;
310
311            // Convert to grayscale since each file contains one band
312            let gray_img = img.to_luma16();
313            let (w, h) = gray_img.dimensions();
314
315            // Check dimensions consistency
316            if i == 0 {
317                height = h as usize;
318                width = w as usize;
319            } else if h as usize != height || w as usize != width {
320                return Err(GlintError::DimensionMismatch {
321                    expected: (width as u32, height as u32),
322                    actual: (w, h),
323                });
324            }
325
326            // Convert to f64 array
327            let data: Vec<f64> = gray_img.into_raw().into_iter().map(|x| x as f64).collect();
328            let band_array = ndarray::Array2::from_shape_vec((height, width), data)
329                .map_err(|_| GlintError::processing("Failed to reshape band data"))?;
330
331            band_arrays.push(band_array);
332        }
333
334        // Stack bands into 3D array
335        let mut stacked_data = Vec::with_capacity(height * width * expected_count);
336
337        for y in 0..height {
338            for x in 0..width {
339                for band in &band_arrays {
340                    stacked_data.push(band[[y, x]]);
341                }
342            }
343        }
344
345        let result = Array3::from_shape_vec((height, width, expected_count), stacked_data)
346            .map_err(|_| GlintError::processing("Failed to create stacked array"))?;
347
348        Ok(result)
349    }
350}
351
352impl ImageLoader for ConfigurableMultiFileLoader {
353    fn as_any(&self) -> &dyn std::any::Any {
354        self
355    }
356
357    fn discover_captures(&self, input_dir: &Path, output_dir: &Path) -> Result<Vec<ImageCapture>> {
358        // List all potential image files
359        let all_files = list_image_files(input_dir, &self.config.extensions)?;
360
361        let mut captures = Vec::new();
362
363        // Find base files (files matching the base pattern)
364        for file_path in all_files {
365            let filename = file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
366
367            if self.base_pattern.is_match(filename) {
368                // This is a base file, find all its band files
369                match self.find_band_files(&file_path) {
370                    Ok(band_files) => {
371                        // Extract filename parts to create a meaningful capture ID
372                        let filename_parts =
373                            self.extract_filename_parts(&file_path).unwrap_or_else(|| {
374                                let mut parts = HashMap::new();
375                                parts.insert("unknown".to_string(), "capture".to_string());
376                                parts
377                            });
378
379                        // Create a clean ID for the capture based on captured parts
380                        let id = self.generate_capture_id(&filename_parts);
381
382                        let mask_paths = self.generate_mask_paths(
383                            &ImageCapture {
384                                id: id.clone(),
385                                paths: band_files.clone(),
386                                mask_paths: Vec::new(),
387                            },
388                            output_dir,
389                        );
390
391                        captures.push(ImageCapture {
392                            id,
393                            paths: band_files,
394                            mask_paths,
395                        });
396                    }
397                    Err(e) => {
398                        // Log warning but continue processing other files
399                        eprintln!(
400                            "Warning: Could not load band files for {:?}: {}",
401                            file_path, e
402                        );
403                    }
404                }
405            }
406        }
407
408        // Sort captures by ID for consistent ordering
409        captures.sort_by(|a, b| a.id.cmp(&b.id));
410
411        Ok(captures)
412    }
413
414    fn load_image(&self, capture: &ImageCapture) -> Result<Array3<f64>> {
415        if capture.paths.len() != self.expected_file_count() {
416            return Err(GlintError::validation(format!(
417                "Configurable multifile loader expects exactly {} files, got {}",
418                self.expected_file_count(),
419                capture.paths.len()
420            )));
421        }
422
423        // Verify all files exist
424        for path in &capture.paths {
425            if !path.exists() {
426                return Err(GlintError::MissingFiles {
427                    files: vec![path.clone()],
428                });
429            }
430        }
431
432        self.load_band_files(&capture.paths)
433    }
434
435    fn band_count(&self) -> usize {
436        self.config.band_count()
437    }
438
439    fn bit_depth(&self) -> u8 {
440        self.config.bit_depth
441    }
442
443    fn supported_extensions(&self) -> Vec<String> {
444        self.config.extensions.clone()
445    }
446
447    fn expected_file_count(&self) -> usize {
448        self.config.band_count()
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use std::fs;
456    use tempfile::tempdir;
457
458    #[test]
459    fn test_multifile_loader_config_from_hashmap() {
460        let mut config = HashMap::new();
461        config.insert(
462            "base_file_pattern".to_string(),
463            "^(?P<base>IMG_\\d{4})_(?P<band>1)(?P<extension>\\.tif)$".to_string(),
464        );
465        config.insert(
466            "filename_template".to_string(),
467            "{base}_{band}{extension}".to_string(),
468        );
469        config.insert("band_scheme".to_string(), "numbered".to_string());
470        config.insert("band_count".to_string(), "5".to_string());
471        config.insert("reference_band".to_string(), "1".to_string());
472        config.insert("extensions".to_string(), "tif,tiff".to_string());
473        config.insert("bit_depth".to_string(), "16".to_string());
474
475        let loader_config = MultiFileLoaderConfig::from_config(&config).unwrap();
476        assert_eq!(loader_config.band_count(), 5);
477        assert_eq!(loader_config.bit_depth, 16);
478        assert_eq!(loader_config.extensions, vec!["tif", "tiff"]);
479
480        match loader_config.band_scheme {
481            BandScheme::Numbered { count } => assert_eq!(count, 5),
482            _ => panic!("Expected numbered band scheme"),
483        }
484    }
485
486    #[test]
487    fn test_multifile_loader_config_named_bands() {
488        let mut config = HashMap::new();
489        config.insert(
490            "base_file_pattern".to_string(),
491            "^(?P<base>DJI_\\d+_\\d+_MS)_(?P<band>G)(?P<extension>\\.TIF)$".to_string(),
492        );
493        config.insert(
494            "filename_template".to_string(),
495            "{base}_{band}{extension}".to_string(),
496        );
497        config.insert("band_scheme".to_string(), "named".to_string());
498        config.insert("band_names".to_string(), "G,R,RE,NIR".to_string());
499        config.insert("reference_band".to_string(), "G".to_string());
500        config.insert("extensions".to_string(), "TIF,tif".to_string());
501
502        let loader_config = MultiFileLoaderConfig::from_config(&config).unwrap();
503        assert_eq!(loader_config.band_count(), 4);
504        assert_eq!(loader_config.bit_depth, 16); // Default
505
506        match loader_config.band_scheme {
507            BandScheme::Named { names } => {
508                assert_eq!(names, vec!["G", "R", "RE", "NIR"]);
509            }
510            _ => panic!("Expected named band scheme"),
511        }
512    }
513
514    #[test]
515    fn test_configurable_multifile_loader_creation() {
516        let config = MultiFileLoaderConfig::new(
517            "^(?P<base>IMG_\\d{4})_(?P<band>1)(?P<extension>\\.tif)$".to_string(),
518            "{base}_{band}{extension}".to_string(),
519            BandScheme::Numbered { count: 5 },
520            "1".to_string(),
521            vec!["tif".to_string(), "tiff".to_string()],
522            16,
523        );
524
525        let loader = ConfigurableMultiFileLoader::new(config).unwrap();
526        assert_eq!(loader.band_count(), 5);
527        assert_eq!(loader.bit_depth(), 16);
528        assert_eq!(loader.expected_file_count(), 5);
529        assert!(loader.supported_extensions().contains(&"tif".to_string()));
530    }
531
532    #[test]
533    fn test_micasense_pattern_capture_groups() {
534        let config = MultiFileLoaderConfig::new(
535            "^(?P<base>IMG_\\d{4})_(?P<band>1)(?P<extension>\\.tif)$".to_string(),
536            "{base}_{band}{extension}".to_string(),
537            BandScheme::Numbered { count: 5 },
538            "1".to_string(),
539            vec!["tif".to_string()],
540            16,
541        );
542        let loader = ConfigurableMultiFileLoader::new(config).unwrap();
543
544        let path = Path::new("IMG_0001_1.tif");
545        let parts = loader.extract_filename_parts(path).unwrap();
546        assert_eq!(parts.get("base"), Some(&"IMG_0001".to_string()));
547        assert_eq!(parts.get("band"), Some(&"1".to_string()));
548        assert_eq!(parts.get("extension"), Some(&".tif".to_string()));
549    }
550
551    #[test]
552    fn test_dji_p4ms_pattern_capture_groups() {
553        let config = MultiFileLoaderConfig::new(
554            "^(?P<base>DJI_\\d{3})(?P<band>1)(?P<extension>\\.TIF)$".to_string(),
555            "{base}{band}{extension}".to_string(),
556            BandScheme::Numbered { count: 5 },
557            "1".to_string(),
558            vec!["TIF".to_string()],
559            16,
560        );
561        let loader = ConfigurableMultiFileLoader::new(config).unwrap();
562
563        let path = Path::new("DJI_0001.TIF");
564        let parts = loader.extract_filename_parts(path).unwrap();
565        assert_eq!(parts.get("base"), Some(&"DJI_000".to_string()));
566        assert_eq!(parts.get("band"), Some(&"1".to_string()));
567        assert_eq!(parts.get("extension"), Some(&".TIF".to_string()));
568    }
569
570    #[test]
571    fn test_dji_m3m_pattern_capture_groups() {
572        let config = MultiFileLoaderConfig::new(
573            "^(?P<base>DJI_\\d+_\\d+_MS)_(?P<band>G)(?P<extension>\\.TIF)$".to_string(),
574            "{base}_{band}{extension}".to_string(),
575            BandScheme::Named {
576                names: vec![
577                    "G".to_string(),
578                    "R".to_string(),
579                    "RE".to_string(),
580                    "NIR".to_string(),
581                ],
582            },
583            "G".to_string(),
584            vec!["TIF".to_string()],
585            16,
586        );
587        let loader = ConfigurableMultiFileLoader::new(config).unwrap();
588
589        let path = Path::new("DJI_20221208115250_0001_MS_G.TIF");
590        let parts = loader.extract_filename_parts(path).unwrap();
591        assert_eq!(
592            parts.get("base"),
593            Some(&"DJI_20221208115250_0001_MS".to_string())
594        );
595        assert_eq!(parts.get("band"), Some(&"G".to_string()));
596        assert_eq!(parts.get("extension"), Some(&".TIF".to_string()));
597    }
598
599    #[test]
600    fn test_discover_captures_micasense_pattern() {
601        let temp_dir = tempdir().unwrap();
602        let input_dir = temp_dir.path().join("input");
603        let output_dir = temp_dir.path().join("output");
604        fs::create_dir_all(&input_dir).unwrap();
605        fs::create_dir_all(&output_dir).unwrap();
606
607        // Create test files for MicaSense pattern
608        let base_files = ["IMG_0001", "IMG_0002"];
609        for base in &base_files {
610            for band in 1..=5 {
611                let filename = format!("{}_{}.tif", base, band);
612                let filepath = input_dir.join(filename);
613                fs::write(&filepath, b"fake image data").unwrap();
614            }
615        }
616
617        let config = MultiFileLoaderConfig::new(
618            "^(?P<base>IMG_\\d{4})_(?P<band>1)(?P<extension>\\.tif)$".to_string(),
619            "{base}_{band}{extension}".to_string(),
620            BandScheme::Numbered { count: 5 },
621            "1".to_string(),
622            vec!["tif".to_string()],
623            16,
624        );
625        let loader = ConfigurableMultiFileLoader::new(config).unwrap();
626        let captures = loader.discover_captures(&input_dir, &output_dir).unwrap();
627
628        assert_eq!(captures.len(), 2);
629        assert_eq!(captures[0].id, "IMG_0001");
630        assert_eq!(captures[1].id, "IMG_0002");
631
632        // Each capture should have 5 files and 5 mask paths
633        for capture in &captures {
634            assert_eq!(capture.paths.len(), 5);
635            assert_eq!(capture.mask_paths.len(), 5);
636            for mask_path in &capture.mask_paths {
637                assert!(mask_path.to_string_lossy().contains("_mask.png"));
638            }
639        }
640    }
641
642    #[test]
643    fn test_template_filename_generation() {
644        let config = MultiFileLoaderConfig::new(
645            "^(?P<base>DJI_\\d{3})(?P<band>1)(?P<extension>\\.TIF)$".to_string(),
646            "{base}{band}{extension}".to_string(),
647            BandScheme::Numbered { count: 5 },
648            "1".to_string(),
649            vec!["TIF".to_string()],
650            16,
651        );
652        let loader = ConfigurableMultiFileLoader::new(config).unwrap();
653
654        // Test that template generation works for different bands
655        let mut parts = std::collections::HashMap::new();
656        parts.insert("base".to_string(), "DJI_000".to_string());
657        parts.insert("band".to_string(), "2".to_string());
658        parts.insert("extension".to_string(), ".TIF".to_string());
659
660        let filename = loader.generate_filename_from_template(&parts).unwrap();
661        assert_eq!(filename, "DJI_0002.TIF");
662
663        // Test MicaSense template
664        let config = MultiFileLoaderConfig::new(
665            "^(?P<base>IMG_\\d{4})_(?P<band>1)(?P<extension>\\.tif)$".to_string(),
666            "{base}_{band}{extension}".to_string(),
667            BandScheme::Numbered { count: 5 },
668            "1".to_string(),
669            vec!["tif".to_string()],
670            16,
671        );
672        let loader = ConfigurableMultiFileLoader::new(config).unwrap();
673
674        let mut parts = std::collections::HashMap::new();
675        parts.insert("base".to_string(), "IMG_0001".to_string());
676        parts.insert("band".to_string(), "3".to_string());
677        parts.insert("extension".to_string(), ".tif".to_string());
678
679        let filename = loader.generate_filename_from_template(&parts).unwrap();
680        assert_eq!(filename, "IMG_0001_3.tif");
681    }
682}