1use 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#[derive(Debug, Clone, PartialEq)]
19pub enum BandScheme {
20 Numbered { count: usize },
22 Named { names: Vec<String> },
24}
25
26#[derive(Debug, Clone)]
28pub struct MultiFileLoaderConfig {
29 pub base_file_pattern: String,
31 pub filename_template: String,
33 pub band_scheme: BandScheme,
35 pub reference_band: String,
37 pub extensions: Vec<String>,
39 pub bit_depth: u8,
41}
42
43impl MultiFileLoaderConfig {
44 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 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); 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 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 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#[derive(Debug, Clone)]
155pub struct ConfigurableMultiFileLoader {
156 base_pattern: Regex,
158 config: MultiFileLoaderConfig,
160}
161
162impl ConfigurableMultiFileLoader {
163 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 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 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 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 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 let mut parts = filename_parts.clone();
216 parts.insert("band".to_string(), band_id.clone());
217
218 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 fn generate_filename_from_template(&self, parts: &HashMap<String, String>) -> Result<String> {
236 let mut filename = self.config.filename_template.clone();
237
238 for (key, value) in parts {
240 let placeholder = format!("{{{}}}", key);
241 filename = filename.replace(&placeholder, value);
242 }
243
244 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 fn generate_capture_id(&self, parts: &HashMap<String, String>) -> String {
257 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 let mut id_parts = Vec::new();
272
273 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 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 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 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 let gray_img = img.to_luma16();
313 let (w, h) = gray_img.dimensions();
314
315 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 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 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 let all_files = list_image_files(input_dir, &self.config.extensions)?;
360
361 let mut captures = Vec::new();
362
363 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 match self.find_band_files(&file_path) {
370 Ok(band_files) => {
371 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 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 eprintln!(
400 "Warning: Could not load band files for {:?}: {}",
401 file_path, e
402 );
403 }
404 }
405 }
406 }
407
408 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 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); 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 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 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 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 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}