Skip to main content

glint_mask_tools/
cli.rs

1/// Command-line interface for the glint mask generation tool.
2///
3/// This module provides a comprehensive CLI that dynamically generates
4/// subcommands for each supported sensor type, matching the functionality
5/// of the original Python implementation.
6use clap::{Arg, ArgAction, ArgMatches, Command, Parser};
7use lazy_static::lazy_static;
8use std::path::PathBuf;
9use tracing::{error, info};
10
11use crate::{
12    algorithms::ThresholdAlgorithm,
13    core::{
14        masker::MaskerBuilder,
15        postprocessor::{CompositePostProcessor, MetashapeConverter, PixelBufferProcessor},
16        sensor::SensorRegistry,
17    },
18    error::{GlintError, Result},
19    loaders::{BigTiffLoader, ConfigurableMultiFileLoader, SingleFileLoader},
20};
21
22lazy_static! {
23    static ref SENSOR_REGISTRY: SensorRegistry = SensorRegistry::from_user_config();
24}
25
26/// Glint mask generation tool for UAV and aerial imagery
27#[derive(Parser)]
28#[command(name = "glint-mask")]
29#[command(about = "Generate glint masks for specular reflection in UAV and aerial imagery")]
30#[command(version)]
31pub struct Cli {
32    /// Enable verbose logging
33    #[arg(short, long, global = true)]
34    pub verbose: bool,
35}
36
37/// Common arguments for sensor processing commands
38#[derive(Debug)]
39pub struct SensorArgs {
40    /// Input directory containing images or single image file
41    pub input: PathBuf,
42
43    /// Output directory for generated masks
44    pub output: PathBuf,
45
46    /// Threshold values for each band (0.0-1.0)
47    pub thresholds: Option<Vec<f64>>,
48
49    /// Pixel buffer radius for mask dilation
50    pub pixel_buffer: usize,
51}
52
53impl From<&ArgMatches> for SensorArgs {
54    fn from(matches: &ArgMatches) -> Self {
55        let input = matches
56            .get_one::<String>("input")
57            .map(PathBuf::from)
58            .unwrap();
59        let output = matches
60            .get_one::<String>("output")
61            .map(PathBuf::from)
62            .unwrap();
63        let thresholds = matches
64            .get_many::<f64>("thresholds")
65            .map(|v| v.copied().collect());
66        let pixel_buffer = matches.get_one::<usize>("pixel_buffer").copied().unwrap();
67
68        Self {
69            input,
70            output,
71            thresholds,
72            pixel_buffer,
73        }
74    }
75}
76
77/// Initialize the CLI and run the appropriate command
78pub fn run() -> Result<()> {
79    let mut cmd = Command::new("glint-mask")
80        .about("Generate glint masks for specular reflection in UAV and aerial imagery")
81        .version(env!("CARGO_PKG_VERSION"))
82        .arg(
83            Arg::new("verbose")
84                .short('v')
85                .long("verbose")
86                .action(ArgAction::SetTrue)
87                .help("Enable verbose logging")
88                .global(true),
89        )
90        .subcommand_required(true);
91
92    for sensor in SENSOR_REGISTRY.sensors() {
93        let mut subcommand = Command::new(sensor.id.as_str()).about(sensor.name.as_str());
94        subcommand = subcommand
95            .arg(
96                Arg::new("input")
97                    .help("Path to input directory or single image file")
98                    .required(true),
99            )
100            .arg(
101                Arg::new("output")
102                    .help("Path to output directory for mask files")
103                    .required(true),
104            )
105            .arg(
106                Arg::new("thresholds")
107                    .short('t')
108                    .long("thresholds")
109                    .value_delimiter(',')
110                    .value_parser(clap::value_parser!(f64))
111                    .help("Comma-separated threshold values for each band (e.g., 0.8,0.9,0.7)"),
112            )
113            .arg(
114                Arg::new("pixel_buffer")
115                    .short('b')
116                    .long("pixel_buffer")
117                    .default_value("0")
118                    .value_parser(clap::value_parser!(usize))
119                    .help("Pixel buffer radius to expand masks"),
120            );
121        cmd = cmd.subcommand(subcommand);
122    }
123
124    cmd = cmd.subcommand(Command::new("list-sensors").about("List available sensors"));
125    cmd = cmd.subcommand(
126        Command::new("sensor-info")
127            .about("Show sensor information")
128            .arg(
129                Arg::new("sensor_id")
130                    .help("Sensor ID to show information for")
131                    .required(true),
132            ),
133    );
134
135    let matches = cmd.get_matches();
136
137    // Initialize logging
138    let verbose = matches.get_flag("verbose");
139    let subscriber = tracing_subscriber::fmt()
140        .with_max_level(if verbose {
141            tracing::Level::DEBUG
142        } else {
143            tracing::Level::INFO
144        })
145        .finish();
146
147    tracing::subscriber::set_global_default(subscriber)
148        .map_err(|e| GlintError::processing(format!("Failed to initialize logging: {}", e)))?;
149
150    match matches.subcommand() {
151        Some(("list-sensors", _)) => list_sensors(),
152        Some(("sensor-info", sub_matches)) => {
153            let sensor_id = sub_matches.get_one::<String>("sensor_id").unwrap();
154            show_sensor_info(sensor_id)
155        }
156        Some((sensor_id, sub_matches)) => {
157            let args = SensorArgs::from(sub_matches);
158            process_sensor(sensor_id, args)
159        }
160        _ => unreachable!("Subcommand is required"),
161    }
162}
163
164/// Process images using the specified sensor configuration
165fn process_sensor(sensor_id: &str, args: SensorArgs) -> Result<()> {
166    info!("Processing {} images", sensor_id.to_uppercase());
167    info!("Input: {}", args.input.display());
168    info!("Output: {}", args.output.display());
169
170    // Validate input and output paths
171    if !args.input.exists() {
172        return Err(GlintError::Io(std::io::Error::new(
173            std::io::ErrorKind::NotFound,
174            format!("Input path does not exist: {}", args.input.display()),
175        )));
176    }
177
178    crate::utils::ensure_directory_exists(&args.output)?;
179
180    // Get sensor configuration
181    let sensor = SENSOR_REGISTRY
182        .get_sensor(sensor_id)
183        .ok_or_else(|| GlintError::sensor(format!("Unknown sensor: {}", sensor_id)))?
184        .clone();
185
186    // Determine thresholds
187    let thresholds = if let Some(user_thresholds) = args.thresholds {
188        if user_thresholds.len() != sensor.band_count() {
189            return Err(GlintError::BandCountMismatch {
190                expected: sensor.band_count(),
191                actual: user_thresholds.len(),
192            });
193        }
194        user_thresholds
195    } else {
196        sensor.default_thresholds()
197    };
198
199    info!("Using thresholds: {:?}", thresholds);
200    info!("Pixel buffer: {} pixels", args.pixel_buffer);
201
202    // Create algorithm
203    let algorithm = ThresholdAlgorithm::new(thresholds)?;
204
205    // Create post-processor pipeline
206    let mut postprocessor = CompositePostProcessor::new();
207
208    if args.pixel_buffer > 0 {
209        postprocessor =
210            postprocessor.add_processor(Box::new(PixelBufferProcessor::new(args.pixel_buffer)));
211    }
212
213    // Always add Metashape converter
214    postprocessor = postprocessor.add_processor(Box::new(MetashapeConverter::new()));
215
216    // Create loader based on sensor type
217    let loader = create_loader(&sensor)?;
218
219    // Build masker
220    let masker = MaskerBuilder::new()
221        .with_sensor(sensor.clone())
222        .with_algorithm(Box::new(algorithm))
223        .with_postprocessor(Box::new(postprocessor))
224        .with_loader(loader)
225        .build()?;
226
227    // Process images
228    let stats = masker.process_directory(&args.input, &args.output, None)?;
229
230    // Report results
231    info!("Processing complete!");
232    info!("Total captures: {}", stats.total_captures);
233    info!("Successful: {}", stats.successful_captures);
234    info!("Failed: {}", stats.failed_captures);
235    info!("Success rate: {:.1}%", stats.success_rate());
236
237    if !stats.errors.is_empty() {
238        error!("Errors encountered:");
239        for error in &stats.errors {
240            error!("  {}", error);
241        }
242    }
243
244    if !stats.all_successful() {
245        std::process::exit(1);
246    }
247
248    Ok(())
249}
250
251/// Create the appropriate image loader for a sensor
252fn create_loader(
253    sensor: &crate::core::sensor::Sensor,
254) -> Result<Box<dyn crate::core::ImageLoader>> {
255    match sensor.loader_type.as_str() {
256        "single_file" => {
257            if sensor.band_count() == 1 {
258                Ok(Box::new(SingleFileLoader::grayscale()?))
259            } else if sensor.band_count() == 3 {
260                Ok(Box::new(SingleFileLoader::rgb()?))
261            } else if sensor.band_count() == 4 {
262                // For 4-band sensors, check the bit depth to determine the right loader
263                if sensor.bit_depth == 8 {
264                    // 8-bit 4-band (e.g., CIR) - create a custom 8-bit loader
265                    let extensions = vec![
266                        "tif".to_string(),
267                        "tiff".to_string(),
268                        "jpg".to_string(),
269                        "jpeg".to_string(),
270                        "png".to_string(),
271                    ];
272                    Ok(Box::new(SingleFileLoader::new(extensions, 4, 8)?))
273                } else {
274                    // 16-bit 4-band
275                    Ok(Box::new(SingleFileLoader::tiff_16bit(4)?))
276                }
277            } else {
278                Err(GlintError::validation(format!(
279                    "Unsupported band count for single file loader: {}",
280                    sensor.band_count()
281                )))
282            }
283        }
284        "multifile" => {
285            // Create configurable multifile loader from sensor configuration
286            Ok(Box::new(ConfigurableMultiFileLoader::from_config(
287                &sensor.loader_config,
288            )?))
289        }
290        "big_tiff" => {
291            // Extract chunk size from loader config if available
292            let chunk_size = sensor
293                .loader_config
294                .get("chunk_size")
295                .and_then(|s| s.parse::<usize>().ok());
296
297            // Extract extensions from loader config
298            let extensions = sensor
299                .loader_config
300                .get("extensions")
301                .map(|s| s.split(',').map(|ext| ext.trim().to_string()).collect())
302                .unwrap_or_else(|| vec!["tif".to_string(), "tiff".to_string()]);
303
304            Ok(Box::new(BigTiffLoader::new(
305                extensions,
306                sensor.band_count(),
307                sensor.bit_depth,
308                chunk_size,
309            )?))
310        }
311        _ => Err(GlintError::validation(format!(
312            "Unknown loader type: {}",
313            sensor.loader_type
314        ))),
315    }
316}
317
318/// List all available sensors
319fn list_sensors() -> Result<()> {
320    println!("Available sensors:");
321    println!();
322
323    for sensor in SENSOR_REGISTRY.sensors() {
324        println!("  {} - {}", sensor.id, sensor.name);
325        println!(
326            "    Bands: {} ({}-bit)",
327            sensor.band_count(),
328            sensor.bit_depth
329        );
330        println!("    Band names: {}", sensor.band_names().join(", "));
331        if let Some(desc) = &sensor.description {
332            println!("    Description: {}", desc);
333        }
334        println!();
335    }
336
337    Ok(())
338}
339
340/// Show detailed information about a specific sensor
341fn show_sensor_info(sensor_id: &str) -> Result<()> {
342    let sensor = SENSOR_REGISTRY
343        .get_sensor(sensor_id)
344        .ok_or_else(|| GlintError::sensor(format!("Unknown sensor: {}", sensor_id)))?;
345
346    println!("Sensor: {} ({})", sensor.name, sensor.id);
347    println!("Bit depth: {}", sensor.bit_depth);
348    println!("Loader type: {}", sensor.loader_type);
349
350    if let Some(desc) = &sensor.description {
351        println!("Description: {}", desc);
352    }
353
354    println!();
355    println!("Bands ({}):", sensor.band_count());
356    for (i, band) in sensor.bands.iter().enumerate() {
357        print!(
358            "  {}: {} (threshold: {:.3}",
359            i + 1,
360            band.name,
361            band.default_threshold
362        );
363        if let Some(wavelength) = band.wavelength {
364            print!(", wavelength: {} nm", wavelength);
365        }
366        println!(")");
367        if let Some(desc) = &band.description {
368            println!("      {}", desc);
369        }
370    }
371
372    println!();
373    println!("Default thresholds: {:?}", sensor.default_thresholds());
374
375    if !sensor.loader_config.is_empty() {
376        println!();
377        println!("Loader configuration:");
378        for (key, value) in &sensor.loader_config {
379            println!("  {}: {}", key, value);
380        }
381    }
382
383    Ok(())
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn test_cli_parse() {
392        let mut cmd = Command::new("glint-mask");
393        for sensor in SENSOR_REGISTRY.sensors() {
394            cmd = cmd.subcommand(Command::new(sensor.id.as_str()));
395        }
396        let matches = cmd.try_get_matches_from(vec!["glint-mask", "rgb"]);
397        assert!(matches.is_ok());
398        let matches = matches.unwrap();
399        assert_eq!(matches.subcommand_name(), Some("rgb"));
400    }
401
402    #[test]
403    fn test_sensor_args_validation() {
404        // This would be more comprehensive in a real test suite
405        let mut cmd = Command::new("test");
406        let mut subcommand = Command::new("rgb");
407        subcommand = subcommand
408            .arg(Arg::new("input").required(true))
409            .arg(Arg::new("output").required(true))
410            .arg(
411                Arg::new("thresholds")
412                    .short('t')
413                    .long("thresholds")
414                    .value_delimiter(',')
415                    .value_parser(clap::value_parser!(f64)),
416            )
417            .arg(
418                Arg::new("pixel_buffer")
419                    .short('b')
420                    .long("pixel_buffer")
421                    .default_value("0")
422                    .value_parser(clap::value_parser!(usize)),
423            );
424        cmd = cmd.subcommand(subcommand);
425        let matches = cmd.get_matches_from(vec![
426            "test",
427            "rgb",
428            "test_input",
429            "test_output",
430            "-t",
431            "0.8,0.9,0.7",
432            "-b",
433            "5",
434        ]);
435
436        let sub_matches = matches.subcommand_matches("rgb").unwrap();
437        let args = SensorArgs::from(sub_matches);
438
439        assert_eq!(args.pixel_buffer, 5);
440        assert_eq!(args.thresholds.as_ref().unwrap().len(), 3);
441    }
442
443    #[test]
444    fn test_create_loader() {
445        let rgb_sensor = SENSOR_REGISTRY.get_sensor("rgb").unwrap();
446        let loader = create_loader(rgb_sensor);
447        assert!(loader.is_ok());
448
449        let p4ms_sensor = SENSOR_REGISTRY.get_sensor("p4ms").unwrap();
450        let loader = create_loader(p4ms_sensor);
451        assert!(loader.is_ok());
452
453        let msre_sensor = SENSOR_REGISTRY.get_sensor("msre").unwrap();
454        let loader = create_loader(msre_sensor);
455        assert!(loader.is_ok());
456    }
457}