1use std::env;
2use std::path::PathBuf;
3
4use thiserror::Error;
5use yscv_detect::{CLASS_ID_FACE, CLASS_ID_PERSON};
6
7use crate::util::face_min_area;
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct CliConfig {
11 pub list_cameras: bool,
12 pub diagnose_camera: bool,
13 pub diagnose_frames: usize,
14 pub diagnose_report_path: Option<PathBuf>,
15 pub camera: bool,
16 pub benchmark: bool,
17 pub detect_target: DetectTarget,
18 pub detect_score_threshold: Option<f32>,
19 pub detect_min_area: Option<usize>,
20 pub detect_iou_threshold: Option<f32>,
21 pub detect_max_detections: Option<usize>,
22 pub device_index: u32,
23 pub device_name_query: Option<String>,
24 pub width: u32,
25 pub height: u32,
26 pub fps: u32,
27 pub track_iou_threshold: f32,
28 pub track_max_missed_frames: u32,
29 pub track_max_tracks: usize,
30 pub recognition_threshold: f32,
31 pub max_frames: Option<usize>,
32 pub identities_path: Option<PathBuf>,
33 pub eval_detection_dataset_path: Option<PathBuf>,
34 pub eval_detection_coco_gt_path: Option<PathBuf>,
35 pub eval_detection_coco_pred_path: Option<PathBuf>,
36 pub eval_detection_openimages_gt_path: Option<PathBuf>,
37 pub eval_detection_openimages_pred_path: Option<PathBuf>,
38 pub eval_detection_yolo_manifest_path: Option<PathBuf>,
39 pub eval_detection_yolo_gt_dir_path: Option<PathBuf>,
40 pub eval_detection_yolo_pred_dir_path: Option<PathBuf>,
41 pub eval_detection_voc_manifest_path: Option<PathBuf>,
42 pub eval_detection_voc_gt_dir_path: Option<PathBuf>,
43 pub eval_detection_voc_pred_dir_path: Option<PathBuf>,
44 pub eval_detection_kitti_manifest_path: Option<PathBuf>,
45 pub eval_detection_kitti_gt_dir_path: Option<PathBuf>,
46 pub eval_detection_kitti_pred_dir_path: Option<PathBuf>,
47 pub eval_detection_widerface_gt_path: Option<PathBuf>,
48 pub eval_detection_widerface_pred_path: Option<PathBuf>,
49 pub eval_tracking_dataset_path: Option<PathBuf>,
50 pub eval_tracking_mot_gt_path: Option<PathBuf>,
51 pub eval_tracking_mot_pred_path: Option<PathBuf>,
52 pub eval_iou_threshold: f32,
53 pub eval_score_threshold: f32,
54 pub validate_diagnostics_report_path: Option<PathBuf>,
55 pub validate_diagnostics_min_frames: usize,
56 pub validate_diagnostics_max_drift_pct: f64,
57 pub validate_diagnostics_max_dropped_frames: u64,
58 pub benchmark_report_path: Option<PathBuf>,
59 pub benchmark_baseline_path: Option<PathBuf>,
60 pub event_log_path: Option<PathBuf>,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum DetectTarget {
65 People,
66 Faces,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub struct RuntimeDetectConfig {
71 pub score_threshold: f32,
72 pub min_area: usize,
73 pub iou_threshold: f32,
74 pub max_detections: usize,
75}
76
77impl DetectTarget {
78 fn parse(raw: &str) -> Result<Self, CliError> {
79 match raw {
80 "people" => Ok(Self::People),
81 "face" | "faces" => Ok(Self::Faces),
82 _ => Err(CliError::Message(format!(
83 "invalid --detect-target `{raw}`; expected one of: people, face"
84 ))),
85 }
86 }
87
88 pub fn class_id(self) -> usize {
89 match self {
90 Self::People => CLASS_ID_PERSON,
91 Self::Faces => CLASS_ID_FACE,
92 }
93 }
94
95 pub fn count_label(self) -> &'static str {
96 match self {
97 Self::People => "people",
98 Self::Faces => "faces",
99 }
100 }
101
102 pub fn as_str(self) -> &'static str {
103 match self {
104 Self::People => "people",
105 Self::Faces => "face",
106 }
107 }
108
109 fn default_config(self, frame_width: usize, frame_height: usize) -> RuntimeDetectConfig {
110 match self {
111 Self::People => RuntimeDetectConfig {
112 score_threshold: 0.5,
113 min_area: 2,
114 iou_threshold: 0.4,
115 max_detections: 16,
116 },
117 Self::Faces => RuntimeDetectConfig {
118 score_threshold: 0.35,
119 min_area: face_min_area(frame_width, frame_height),
120 iou_threshold: 0.4,
121 max_detections: 16,
122 },
123 }
124 }
125}
126
127pub fn resolve_detect_config(
128 cli: &CliConfig,
129 frame_width: usize,
130 frame_height: usize,
131) -> RuntimeDetectConfig {
132 let defaults = cli.detect_target.default_config(frame_width, frame_height);
133 RuntimeDetectConfig {
134 score_threshold: cli
135 .detect_score_threshold
136 .unwrap_or(defaults.score_threshold),
137 min_area: cli.detect_min_area.unwrap_or(defaults.min_area),
138 iou_threshold: cli.detect_iou_threshold.unwrap_or(defaults.iou_threshold),
139 max_detections: cli.detect_max_detections.unwrap_or(defaults.max_detections),
140 }
141}
142
143impl Default for CliConfig {
144 fn default() -> Self {
145 Self {
146 list_cameras: false,
147 diagnose_camera: false,
148 diagnose_frames: 30,
149 diagnose_report_path: None,
150 camera: false,
151 benchmark: false,
152 detect_target: DetectTarget::People,
153 detect_score_threshold: None,
154 detect_min_area: None,
155 detect_iou_threshold: None,
156 detect_max_detections: None,
157 device_index: 0,
158 device_name_query: None,
159 width: 640,
160 height: 480,
161 fps: 30,
162 track_iou_threshold: 0.2,
163 track_max_missed_frames: 2,
164 track_max_tracks: 64,
165 recognition_threshold: 0.92,
166 max_frames: None,
167 identities_path: None,
168 eval_detection_dataset_path: None,
169 eval_detection_coco_gt_path: None,
170 eval_detection_coco_pred_path: None,
171 eval_detection_openimages_gt_path: None,
172 eval_detection_openimages_pred_path: None,
173 eval_detection_yolo_manifest_path: None,
174 eval_detection_yolo_gt_dir_path: None,
175 eval_detection_yolo_pred_dir_path: None,
176 eval_detection_voc_manifest_path: None,
177 eval_detection_voc_gt_dir_path: None,
178 eval_detection_voc_pred_dir_path: None,
179 eval_detection_kitti_manifest_path: None,
180 eval_detection_kitti_gt_dir_path: None,
181 eval_detection_kitti_pred_dir_path: None,
182 eval_detection_widerface_gt_path: None,
183 eval_detection_widerface_pred_path: None,
184 eval_tracking_dataset_path: None,
185 eval_tracking_mot_gt_path: None,
186 eval_tracking_mot_pred_path: None,
187 eval_iou_threshold: 0.5,
188 eval_score_threshold: 0.0,
189 validate_diagnostics_report_path: None,
190 validate_diagnostics_min_frames: 2,
191 validate_diagnostics_max_drift_pct: 25.0,
192 validate_diagnostics_max_dropped_frames: 0,
193 benchmark_report_path: None,
194 benchmark_baseline_path: None,
195 event_log_path: None,
196 }
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Error)]
201pub enum CliError {
202 #[error("help requested")]
203 HelpRequested,
204 #[error("{0}")]
205 Message(String),
206}
207
208impl CliConfig {
209 pub fn from_env() -> Result<Self, CliError> {
210 Self::parse_from(env::args().skip(1))
211 }
212
213 pub fn parse_from<I>(args: I) -> Result<Self, CliError>
214 where
215 I: IntoIterator<Item = String>,
216 {
217 let args = args.into_iter().collect::<Vec<_>>();
218 let mut config = Self::default();
219 let mut index = 0usize;
220 let mut has_device_index = false;
221 let mut has_device_name = false;
222
223 while index < args.len() {
224 match args[index].as_str() {
225 "--diagnose-camera" => {
226 config.diagnose_camera = true;
227 }
228 "--diagnose-frames" => {
229 let raw = next_value(&args, &mut index, "--diagnose-frames")?;
230 config.diagnose_frames = parse_usize("--diagnose-frames", raw)?;
231 }
232 "--diagnose-report" => {
233 let raw = next_value(&args, &mut index, "--diagnose-report")?;
234 config.diagnose_report_path = Some(parse_path(raw));
235 }
236 "--camera" => {
237 config.camera = true;
238 }
239 "--list-cameras" => {
240 config.list_cameras = true;
241 }
242 "--benchmark" => {
243 config.benchmark = true;
244 }
245 "--detect-target" => {
246 let raw = next_value(&args, &mut index, "--detect-target")?;
247 config.detect_target = DetectTarget::parse(&raw)?;
248 }
249 "--detect-score" => {
250 let raw = next_value(&args, &mut index, "--detect-score")?;
251 config.detect_score_threshold = Some(parse_f32("--detect-score", raw)?);
252 }
253 "--detect-min-area" => {
254 let raw = next_value(&args, &mut index, "--detect-min-area")?;
255 config.detect_min_area = Some(parse_usize("--detect-min-area", raw)?);
256 }
257 "--detect-iou" => {
258 let raw = next_value(&args, &mut index, "--detect-iou")?;
259 config.detect_iou_threshold = Some(parse_f32("--detect-iou", raw)?);
260 }
261 "--detect-max" => {
262 let raw = next_value(&args, &mut index, "--detect-max")?;
263 config.detect_max_detections = Some(parse_usize("--detect-max", raw)?);
264 }
265 "--device" => {
266 if has_device_name {
267 return Err(CliError::Message(
268 "`--device` cannot be used together with `--device-name`".to_string(),
269 ));
270 }
271 let raw = next_value(&args, &mut index, "--device")?;
272 config.device_index = parse_u32("--device", raw)?;
273 has_device_index = true;
274 }
275 "--device-name" => {
276 if has_device_index {
277 return Err(CliError::Message(
278 "`--device-name` cannot be used together with `--device`".to_string(),
279 ));
280 }
281 let raw = next_value(&args, &mut index, "--device-name")?;
282 config.device_name_query = Some(parse_non_empty("--device-name", raw)?);
283 has_device_name = true;
284 }
285 "--width" => {
286 let raw = next_value(&args, &mut index, "--width")?;
287 config.width = parse_u32("--width", raw)?;
288 }
289 "--height" => {
290 let raw = next_value(&args, &mut index, "--height")?;
291 config.height = parse_u32("--height", raw)?;
292 }
293 "--fps" => {
294 let raw = next_value(&args, &mut index, "--fps")?;
295 config.fps = parse_u32("--fps", raw)?;
296 }
297 "--track-iou" => {
298 let raw = next_value(&args, &mut index, "--track-iou")?;
299 config.track_iou_threshold = parse_f32("--track-iou", raw)?;
300 }
301 "--track-max-missed" => {
302 let raw = next_value(&args, &mut index, "--track-max-missed")?;
303 config.track_max_missed_frames = parse_u32("--track-max-missed", raw)?;
304 }
305 "--track-max" => {
306 let raw = next_value(&args, &mut index, "--track-max")?;
307 config.track_max_tracks = parse_usize("--track-max", raw)?;
308 }
309 "--recognition-threshold" => {
310 let raw = next_value(&args, &mut index, "--recognition-threshold")?;
311 config.recognition_threshold = parse_f32("--recognition-threshold", raw)?;
312 }
313 "--max-frames" => {
314 let raw = next_value(&args, &mut index, "--max-frames")?;
315 config.max_frames = Some(parse_usize("--max-frames", raw)?);
316 }
317 "--identities" => {
318 let raw = next_value(&args, &mut index, "--identities")?;
319 config.identities_path = Some(parse_path(raw));
320 }
321 "--eval-detection-jsonl" => {
322 let raw = next_value(&args, &mut index, "--eval-detection-jsonl")?;
323 config.eval_detection_dataset_path = Some(parse_path(raw));
324 }
325 "--eval-detection-coco-gt" => {
326 let raw = next_value(&args, &mut index, "--eval-detection-coco-gt")?;
327 config.eval_detection_coco_gt_path = Some(parse_path(raw));
328 }
329 "--eval-detection-coco-pred" => {
330 let raw = next_value(&args, &mut index, "--eval-detection-coco-pred")?;
331 config.eval_detection_coco_pred_path = Some(parse_path(raw));
332 }
333 "--eval-detection-openimages-gt" => {
334 let raw = next_value(&args, &mut index, "--eval-detection-openimages-gt")?;
335 config.eval_detection_openimages_gt_path = Some(parse_path(raw));
336 }
337 "--eval-detection-openimages-pred" => {
338 let raw = next_value(&args, &mut index, "--eval-detection-openimages-pred")?;
339 config.eval_detection_openimages_pred_path = Some(parse_path(raw));
340 }
341 "--eval-detection-yolo-manifest" => {
342 let raw = next_value(&args, &mut index, "--eval-detection-yolo-manifest")?;
343 config.eval_detection_yolo_manifest_path = Some(parse_path(raw));
344 }
345 "--eval-detection-yolo-gt-dir" => {
346 let raw = next_value(&args, &mut index, "--eval-detection-yolo-gt-dir")?;
347 config.eval_detection_yolo_gt_dir_path = Some(parse_path(raw));
348 }
349 "--eval-detection-yolo-pred-dir" => {
350 let raw = next_value(&args, &mut index, "--eval-detection-yolo-pred-dir")?;
351 config.eval_detection_yolo_pred_dir_path = Some(parse_path(raw));
352 }
353 "--eval-detection-voc-manifest" => {
354 let raw = next_value(&args, &mut index, "--eval-detection-voc-manifest")?;
355 config.eval_detection_voc_manifest_path = Some(parse_path(raw));
356 }
357 "--eval-detection-voc-gt-dir" => {
358 let raw = next_value(&args, &mut index, "--eval-detection-voc-gt-dir")?;
359 config.eval_detection_voc_gt_dir_path = Some(parse_path(raw));
360 }
361 "--eval-detection-voc-pred-dir" => {
362 let raw = next_value(&args, &mut index, "--eval-detection-voc-pred-dir")?;
363 config.eval_detection_voc_pred_dir_path = Some(parse_path(raw));
364 }
365 "--eval-detection-kitti-manifest" => {
366 let raw = next_value(&args, &mut index, "--eval-detection-kitti-manifest")?;
367 config.eval_detection_kitti_manifest_path = Some(parse_path(raw));
368 }
369 "--eval-detection-kitti-gt-dir" => {
370 let raw = next_value(&args, &mut index, "--eval-detection-kitti-gt-dir")?;
371 config.eval_detection_kitti_gt_dir_path = Some(parse_path(raw));
372 }
373 "--eval-detection-kitti-pred-dir" => {
374 let raw = next_value(&args, &mut index, "--eval-detection-kitti-pred-dir")?;
375 config.eval_detection_kitti_pred_dir_path = Some(parse_path(raw));
376 }
377 "--eval-detection-widerface-gt" => {
378 let raw = next_value(&args, &mut index, "--eval-detection-widerface-gt")?;
379 config.eval_detection_widerface_gt_path = Some(parse_path(raw));
380 }
381 "--eval-detection-widerface-pred" => {
382 let raw = next_value(&args, &mut index, "--eval-detection-widerface-pred")?;
383 config.eval_detection_widerface_pred_path = Some(parse_path(raw));
384 }
385 "--eval-tracking-jsonl" => {
386 let raw = next_value(&args, &mut index, "--eval-tracking-jsonl")?;
387 config.eval_tracking_dataset_path = Some(parse_path(raw));
388 }
389 "--eval-tracking-mot-gt" => {
390 let raw = next_value(&args, &mut index, "--eval-tracking-mot-gt")?;
391 config.eval_tracking_mot_gt_path = Some(parse_path(raw));
392 }
393 "--eval-tracking-mot-pred" => {
394 let raw = next_value(&args, &mut index, "--eval-tracking-mot-pred")?;
395 config.eval_tracking_mot_pred_path = Some(parse_path(raw));
396 }
397 "--eval-iou" => {
398 let raw = next_value(&args, &mut index, "--eval-iou")?;
399 config.eval_iou_threshold = parse_f32("--eval-iou", raw)?;
400 }
401 "--eval-score" => {
402 let raw = next_value(&args, &mut index, "--eval-score")?;
403 config.eval_score_threshold = parse_f32("--eval-score", raw)?;
404 }
405 "--validate-diagnostics-report" => {
406 let raw = next_value(&args, &mut index, "--validate-diagnostics-report")?;
407 config.validate_diagnostics_report_path = Some(parse_path(raw));
408 }
409 "--validate-diagnostics-min-frames" => {
410 let raw = next_value(&args, &mut index, "--validate-diagnostics-min-frames")?;
411 config.validate_diagnostics_min_frames =
412 parse_usize("--validate-diagnostics-min-frames", raw)?;
413 }
414 "--validate-diagnostics-max-drift-pct" => {
415 let raw =
416 next_value(&args, &mut index, "--validate-diagnostics-max-drift-pct")?;
417 config.validate_diagnostics_max_drift_pct =
418 parse_f64("--validate-diagnostics-max-drift-pct", raw)?;
419 }
420 "--validate-diagnostics-max-dropped" => {
421 let raw = next_value(&args, &mut index, "--validate-diagnostics-max-dropped")?;
422 config.validate_diagnostics_max_dropped_frames =
423 parse_u64("--validate-diagnostics-max-dropped", raw)?;
424 }
425 "--benchmark-report" => {
426 let raw = next_value(&args, &mut index, "--benchmark-report")?;
427 config.benchmark_report_path = Some(parse_path(raw));
428 }
429 "--benchmark-baseline" => {
430 let raw = next_value(&args, &mut index, "--benchmark-baseline")?;
431 config.benchmark_baseline_path = Some(parse_path(raw));
432 }
433 "--event-log" => {
434 let raw = next_value(&args, &mut index, "--event-log")?;
435 config.event_log_path = Some(parse_path(raw));
436 }
437 "--help" | "-h" => return Err(CliError::HelpRequested),
438 unknown => {
439 return Err(CliError::Message(format!(
440 "unknown argument: {unknown}; run with --help for usage"
441 )));
442 }
443 }
444 index += 1;
445 }
446
447 validate_positive("--width", config.width)?;
448 validate_positive("--height", config.height)?;
449 validate_positive("--fps", config.fps)?;
450 validate_positive_usize("--diagnose-frames", config.diagnose_frames)?;
451 if let Some(score_threshold) = config.detect_score_threshold {
452 validate_in_unit_interval("--detect-score", score_threshold)?;
453 }
454 if let Some(min_area) = config.detect_min_area {
455 validate_positive_usize("--detect-min-area", min_area)?;
456 }
457 if let Some(iou_threshold) = config.detect_iou_threshold {
458 validate_in_unit_interval("--detect-iou", iou_threshold)?;
459 }
460 if let Some(max_detections) = config.detect_max_detections {
461 validate_positive_usize("--detect-max", max_detections)?;
462 }
463 validate_in_unit_interval("--track-iou", config.track_iou_threshold)?;
464 validate_positive_usize("--track-max", config.track_max_tracks)?;
465 validate_in_closed_range(
466 "--recognition-threshold",
467 config.recognition_threshold,
468 -1.0,
469 1.0,
470 )?;
471 if let Some(max_frames) = config.max_frames
472 && max_frames == 0
473 {
474 return Err(CliError::Message(
475 "`--max-frames` must be greater than 0".to_string(),
476 ));
477 }
478 if config.benchmark_baseline_path.is_some() {
479 config.benchmark = true;
480 }
481 if config.device_name_query.is_some() && !config.camera && !config.list_cameras {
482 return Err(CliError::Message(
483 "`--device-name` requires `--camera` or `--list-cameras`".to_string(),
484 ));
485 }
486 validate_in_unit_interval("--eval-iou", config.eval_iou_threshold)?;
487 validate_in_unit_interval("--eval-score", config.eval_score_threshold)?;
488 if config.eval_detection_coco_gt_path.is_some()
489 != config.eval_detection_coco_pred_path.is_some()
490 {
491 return Err(CliError::Message(
492 "`--eval-detection-coco-gt` and `--eval-detection-coco-pred` must be provided together".to_string(),
493 ));
494 }
495 if config.eval_detection_openimages_gt_path.is_some()
496 != config.eval_detection_openimages_pred_path.is_some()
497 {
498 return Err(CliError::Message(
499 "`--eval-detection-openimages-gt` and `--eval-detection-openimages-pred` must be provided together".to_string(),
500 ));
501 }
502 let yolo_eval_flags = [
503 config.eval_detection_yolo_manifest_path.is_some(),
504 config.eval_detection_yolo_gt_dir_path.is_some(),
505 config.eval_detection_yolo_pred_dir_path.is_some(),
506 ];
507 if yolo_eval_flags.iter().any(|present| *present)
508 && !yolo_eval_flags.iter().all(|present| *present)
509 {
510 return Err(CliError::Message(
511 "`--eval-detection-yolo-manifest`, `--eval-detection-yolo-gt-dir`, and `--eval-detection-yolo-pred-dir` must be provided together".to_string(),
512 ));
513 }
514 let voc_eval_flags = [
515 config.eval_detection_voc_manifest_path.is_some(),
516 config.eval_detection_voc_gt_dir_path.is_some(),
517 config.eval_detection_voc_pred_dir_path.is_some(),
518 ];
519 if voc_eval_flags.iter().any(|present| *present)
520 && !voc_eval_flags.iter().all(|present| *present)
521 {
522 return Err(CliError::Message(
523 "`--eval-detection-voc-manifest`, `--eval-detection-voc-gt-dir`, and `--eval-detection-voc-pred-dir` must be provided together".to_string(),
524 ));
525 }
526 let kitti_eval_flags = [
527 config.eval_detection_kitti_manifest_path.is_some(),
528 config.eval_detection_kitti_gt_dir_path.is_some(),
529 config.eval_detection_kitti_pred_dir_path.is_some(),
530 ];
531 if kitti_eval_flags.iter().any(|present| *present)
532 && !kitti_eval_flags.iter().all(|present| *present)
533 {
534 return Err(CliError::Message(
535 "`--eval-detection-kitti-manifest`, `--eval-detection-kitti-gt-dir`, and `--eval-detection-kitti-pred-dir` must be provided together".to_string(),
536 ));
537 }
538 if config.eval_detection_widerface_gt_path.is_some()
539 != config.eval_detection_widerface_pred_path.is_some()
540 {
541 return Err(CliError::Message(
542 "`--eval-detection-widerface-gt` and `--eval-detection-widerface-pred` must be provided together".to_string(),
543 ));
544 }
545 if config.eval_tracking_mot_gt_path.is_some()
546 != config.eval_tracking_mot_pred_path.is_some()
547 {
548 return Err(CliError::Message(
549 "`--eval-tracking-mot-gt` and `--eval-tracking-mot-pred` must be provided together"
550 .to_string(),
551 ));
552 }
553 validate_positive_usize(
554 "--validate-diagnostics-min-frames",
555 config.validate_diagnostics_min_frames,
556 )?;
557 validate_non_negative_finite(
558 "--validate-diagnostics-max-drift-pct",
559 config.validate_diagnostics_max_drift_pct,
560 )?;
561 let eval_mode = config.eval_detection_dataset_path.is_some()
562 || config.eval_detection_coco_gt_path.is_some()
563 || config.eval_detection_openimages_gt_path.is_some()
564 || config.eval_detection_yolo_manifest_path.is_some()
565 || config.eval_detection_voc_manifest_path.is_some()
566 || config.eval_detection_kitti_manifest_path.is_some()
567 || config.eval_detection_widerface_gt_path.is_some()
568 || config.eval_tracking_dataset_path.is_some()
569 || config.eval_tracking_mot_gt_path.is_some();
570 let diagnostics_validation_mode = config.validate_diagnostics_report_path.is_some();
571 if eval_mode && (config.camera || config.list_cameras) {
572 return Err(CliError::Message(
573 "evaluation dataset mode cannot be combined with camera/listing mode".to_string(),
574 ));
575 }
576 if eval_mode && config.diagnose_camera {
577 return Err(CliError::Message(
578 "evaluation dataset mode cannot be combined with `--diagnose-camera`".to_string(),
579 ));
580 }
581 if eval_mode && config.benchmark {
582 return Err(CliError::Message(
583 "evaluation dataset mode cannot be combined with benchmark mode".to_string(),
584 ));
585 }
586 if config.event_log_path.is_some() && config.list_cameras {
587 return Err(CliError::Message(
588 "`--event-log` cannot be used together with `--list-cameras`".to_string(),
589 ));
590 }
591 if eval_mode && config.event_log_path.is_some() {
592 return Err(CliError::Message(
593 "evaluation dataset mode cannot be combined with `--event-log`".to_string(),
594 ));
595 }
596 if config.diagnose_camera && config.list_cameras {
597 return Err(CliError::Message(
598 "`--diagnose-camera` cannot be used together with `--list-cameras`".to_string(),
599 ));
600 }
601 if config.diagnose_camera && config.benchmark {
602 return Err(CliError::Message(
603 "`--diagnose-camera` cannot be used together with `--benchmark`".to_string(),
604 ));
605 }
606 if config.diagnose_report_path.is_some() && !config.diagnose_camera {
607 return Err(CliError::Message(
608 "`--diagnose-report` requires `--diagnose-camera`".to_string(),
609 ));
610 }
611 if diagnostics_validation_mode
612 && (config.camera
613 || config.list_cameras
614 || config.diagnose_camera
615 || eval_mode
616 || config.benchmark
617 || config.event_log_path.is_some())
618 {
619 return Err(CliError::Message(
620 "diagnostics report validation mode cannot be combined with camera/diagnostics/eval/benchmark/event-log modes".to_string(),
621 ));
622 }
623 if !diagnostics_validation_mode
624 && (config.validate_diagnostics_min_frames
625 != CliConfig::default().validate_diagnostics_min_frames
626 || (config.validate_diagnostics_max_drift_pct
627 - CliConfig::default().validate_diagnostics_max_drift_pct)
628 .abs()
629 > f64::EPSILON
630 || config.validate_diagnostics_max_dropped_frames
631 != CliConfig::default().validate_diagnostics_max_dropped_frames)
632 {
633 return Err(CliError::Message(
634 "diagnostics validation thresholds require `--validate-diagnostics-report`"
635 .to_string(),
636 ));
637 }
638
639 Ok(config)
640 }
641}
642
643pub fn print_usage() {
644 println!("yscv-cli usage:");
645 println!(" cargo run -p yscv-cli --bin yscv-cli -- [options]");
646 println!();
647 println!("options:");
648 println!(" --list-cameras list available camera devices and exit");
649 println!(" --diagnose-camera run camera environment/capture diagnostics and exit");
650 println!(
651 " --diagnose-frames <n> number of frames to sample in diagnostics mode (default: 30)"
652 );
653 println!(" --diagnose-report <path> write diagnostics summary JSON report");
654 println!(
655 " --camera use native camera source instead of deterministic demo frames"
656 );
657 println!(" --detect-target <mode> detection mode: people (default) or face");
658 println!(" --detect-score <value> detection score threshold in [0, 1]");
659 println!(" --detect-min-area <n> minimum connected-component area in pixels");
660 println!(" --detect-iou <value> NMS IoU threshold in [0, 1]");
661 println!(" --detect-max <n> maximum detections per frame");
662 println!(" --device <index> camera device index (default: 0)");
663 println!(
664 " --device-name <query> camera device query by label substring (also filters --list-cameras)"
665 );
666 println!(" --width <pixels> camera frame width (default: 640)");
667 println!(" --height <pixels> camera frame height (default: 480)");
668 println!(" --fps <value> camera target FPS (default: 30)");
669 println!(" --track-iou <value> tracker IoU match threshold in [0, 1] (default: 0.2)");
670 println!(" --track-max-missed <n> tracker missed-frame budget before expiry (default: 2)");
671 println!(" --track-max <n> tracker max simultaneous tracks (default: 64)");
672 println!(" --recognition-threshold <value> recognizer threshold in [-1, 1] (default: 0.92)");
673 println!(" --max-frames <count> stop after N emitted frames");
674 println!(" --identities <path> load recognizer identities from JSON snapshot");
675 println!(" --eval-detection-jsonl <path> evaluate detection metrics from JSONL dataset");
676 println!(" --eval-detection-coco-gt <path> COCO detection ground-truth JSON");
677 println!(" --eval-detection-coco-pred <path> COCO detection predictions JSON");
678 println!(" --eval-detection-openimages-gt <path> OpenImages detection ground-truth CSV");
679 println!(" --eval-detection-openimages-pred <path> OpenImages detection predictions CSV");
680 println!(
681 " --eval-detection-yolo-manifest <path> YOLO detection manifest (`<image_id> <width> <height>` per line)"
682 );
683 println!(" --eval-detection-yolo-gt-dir <path> YOLO ground-truth label directory");
684 println!(" --eval-detection-yolo-pred-dir <path> YOLO prediction label directory");
685 println!(
686 " --eval-detection-voc-manifest <path> VOC detection image-id manifest (one `<image_id>` per line)"
687 );
688 println!(" --eval-detection-voc-gt-dir <path> VOC ground-truth XML directory");
689 println!(" --eval-detection-voc-pred-dir <path> VOC prediction XML directory");
690 println!(
691 " --eval-detection-kitti-manifest <path> KITTI detection image-id manifest (one `<image_id>` per line)"
692 );
693 println!(" --eval-detection-kitti-gt-dir <path> KITTI ground-truth label directory");
694 println!(" --eval-detection-kitti-pred-dir <path> KITTI prediction label directory");
695 println!(" --eval-detection-widerface-gt <path> WIDER FACE detection ground-truth TXT");
696 println!(" --eval-detection-widerface-pred <path> WIDER FACE detection predictions TXT");
697 println!(" --eval-tracking-jsonl <path> evaluate tracking metrics from JSONL dataset");
698 println!(" --eval-tracking-mot-gt <path> MOTChallenge tracking ground-truth TXT");
699 println!(" --eval-tracking-mot-pred <path> MOTChallenge tracking predictions TXT");
700 println!(" --eval-iou <value> evaluation IoU threshold in [0, 1] (default: 0.5)");
701 println!(" --eval-score <value> evaluation score threshold in [0, 1] (default: 0.0)");
702 println!(" --validate-diagnostics-report <path> validate camera diagnostics JSON report");
703 println!(
704 " --validate-diagnostics-min-frames <n> min collected frames for diagnostics report validation (default: 2)"
705 );
706 println!(
707 " --validate-diagnostics-max-drift-pct <value> max absolute wall/sensor fps drift percent (default: 25.0)"
708 );
709 println!(
710 " --validate-diagnostics-max-dropped <n> max dropped frames allowed in diagnostics report (default: 0)"
711 );
712 println!(" --event-log <path> write per-frame JSONL events for downstream tooling");
713 println!(" --benchmark collect and print per-stage timing report");
714 println!(" --benchmark-report <path> write benchmark report to a file");
715 println!(" --benchmark-baseline <path> check benchmark report against thresholds");
716 println!(" -h, --help print this help");
717 println!();
718 println!("To enable camera capture, run with feature flag:");
719 println!(" cargo run -p yscv-cli --bin yscv-cli --features native-camera -- --camera");
720}
721
722fn next_value(args: &[String], index: &mut usize, flag: &str) -> Result<String, CliError> {
723 *index += 1;
724 if let Some(value) = args.get(*index) {
725 Ok(value.clone())
726 } else {
727 Err(CliError::Message(format!(
728 "missing value for {flag}; run with --help for usage"
729 )))
730 }
731}
732
733fn parse_u32(flag: &str, raw: String) -> Result<u32, CliError> {
734 raw.parse::<u32>().map_err(|_| {
735 CliError::Message(format!(
736 "failed to parse {flag} value `{raw}` as unsigned integer"
737 ))
738 })
739}
740
741fn parse_usize(flag: &str, raw: String) -> Result<usize, CliError> {
742 raw.parse::<usize>().map_err(|_| {
743 CliError::Message(format!(
744 "failed to parse {flag} value `{raw}` as unsigned integer"
745 ))
746 })
747}
748
749fn parse_f32(flag: &str, raw: String) -> Result<f32, CliError> {
750 raw.parse::<f32>()
751 .map_err(|_| CliError::Message(format!("failed to parse {flag} value `{raw}` as number")))
752}
753
754fn parse_f64(flag: &str, raw: String) -> Result<f64, CliError> {
755 raw.parse::<f64>()
756 .map_err(|_| CliError::Message(format!("failed to parse {flag} value `{raw}` as number")))
757}
758
759fn parse_u64(flag: &str, raw: String) -> Result<u64, CliError> {
760 raw.parse::<u64>().map_err(|_| {
761 CliError::Message(format!(
762 "failed to parse {flag} value `{raw}` as unsigned integer"
763 ))
764 })
765}
766
767fn parse_non_empty(flag: &str, raw: String) -> Result<String, CliError> {
768 let normalized = raw.trim();
769 if normalized.is_empty() {
770 return Err(CliError::Message(format!("{flag} must not be empty")));
771 }
772 Ok(normalized.to_string())
773}
774
775fn parse_path(raw: String) -> PathBuf {
776 PathBuf::from(raw)
777}
778
779fn validate_positive(flag: &str, value: u32) -> Result<(), CliError> {
780 if value == 0 {
781 return Err(CliError::Message(format!("{flag} must be greater than 0")));
782 }
783 Ok(())
784}
785
786fn validate_positive_usize(flag: &str, value: usize) -> Result<(), CliError> {
787 if value == 0 {
788 return Err(CliError::Message(format!("{flag} must be greater than 0")));
789 }
790 Ok(())
791}
792
793fn validate_in_unit_interval(flag: &str, value: f32) -> Result<(), CliError> {
794 if !value.is_finite() || !(0.0..=1.0).contains(&value) {
795 return Err(CliError::Message(format!(
796 "{flag} must be finite and in [0, 1]"
797 )));
798 }
799 Ok(())
800}
801
802fn validate_in_closed_range(flag: &str, value: f32, min: f32, max: f32) -> Result<(), CliError> {
803 if !value.is_finite() || !(min..=max).contains(&value) {
804 return Err(CliError::Message(format!(
805 "{flag} must be finite and in [{min}, {max}]"
806 )));
807 }
808 Ok(())
809}
810
811fn validate_non_negative_finite(flag: &str, value: f64) -> Result<(), CliError> {
812 if !value.is_finite() || value < 0.0 {
813 return Err(CliError::Message(format!("{flag} must be finite and >= 0")));
814 }
815 Ok(())
816}
817
818#[cfg(test)]
819mod tests {
820 use super::{CliConfig, CliError, DetectTarget};
821
822 #[test]
823 fn parse_defaults_without_args() {
824 let config = CliConfig::parse_from(Vec::<String>::new()).unwrap();
825 assert_eq!(config, CliConfig::default());
826 }
827
828 #[test]
829 fn parse_list_cameras_flag() {
830 let config = CliConfig::parse_from(vec!["--list-cameras".to_string()]).unwrap();
831 assert!(config.list_cameras);
832 assert!(!config.camera);
833 }
834
835 #[test]
836 fn parse_diagnose_camera_flag() {
837 let config = CliConfig::parse_from(vec!["--diagnose-camera".to_string()]).unwrap();
838 assert!(config.diagnose_camera);
839 assert!(!config.camera);
840 }
841
842 #[test]
843 fn parse_diagnose_frames_override() {
844 let config =
845 CliConfig::parse_from(vec!["--diagnose-frames".to_string(), "12".to_string()]).unwrap();
846 assert_eq!(config.diagnose_frames, 12);
847 }
848
849 #[test]
850 fn parse_diagnose_report_path() {
851 let config = CliConfig::parse_from(vec![
852 "--diagnose-camera".to_string(),
853 "--diagnose-report".to_string(),
854 "diag.json".to_string(),
855 ])
856 .unwrap();
857 assert_eq!(
858 config.diagnose_report_path,
859 Some(std::path::PathBuf::from("diag.json"))
860 );
861 }
862
863 #[test]
864 fn parse_rejects_zero_diagnose_frames() {
865 let err = CliConfig::parse_from(vec!["--diagnose-frames".to_string(), "0".to_string()])
866 .unwrap_err();
867 assert_eq!(
868 err,
869 CliError::Message("--diagnose-frames must be greater than 0".to_string())
870 );
871 }
872
873 #[test]
874 fn parse_device_name_query() {
875 let config = CliConfig::parse_from(vec![
876 "--camera".to_string(),
877 "--device-name".to_string(),
878 "Logitech".to_string(),
879 ])
880 .unwrap();
881 assert_eq!(config.device_name_query.as_deref(), Some("Logitech"));
882 }
883
884 #[test]
885 fn parse_rejects_conflicting_device_flags() {
886 let err = CliConfig::parse_from(vec![
887 "--device".to_string(),
888 "1".to_string(),
889 "--device-name".to_string(),
890 "cam".to_string(),
891 ])
892 .unwrap_err();
893 assert_eq!(
894 err,
895 CliError::Message(
896 "`--device-name` cannot be used together with `--device`".to_string()
897 )
898 );
899 }
900
901 #[test]
902 fn parse_rejects_device_name_without_camera_mode() {
903 let err = CliConfig::parse_from(vec!["--device-name".to_string(), "cam".to_string()])
904 .unwrap_err();
905 assert_eq!(
906 err,
907 CliError::Message(
908 "`--device-name` requires `--camera` or `--list-cameras`".to_string()
909 )
910 );
911 }
912
913 #[test]
914 fn parse_allows_device_name_with_list_cameras() {
915 let config = CliConfig::parse_from(vec![
916 "--list-cameras".to_string(),
917 "--device-name".to_string(),
918 "cam".to_string(),
919 ])
920 .unwrap();
921 assert!(config.list_cameras);
922 assert_eq!(config.device_name_query.as_deref(), Some("cam"));
923 }
924
925 #[test]
926 fn parse_event_log_path() {
927 let config =
928 CliConfig::parse_from(vec!["--event-log".to_string(), "events.jsonl".to_string()])
929 .unwrap();
930 assert_eq!(
931 config.event_log_path,
932 Some(std::path::PathBuf::from("events.jsonl"))
933 );
934 }
935
936 #[test]
937 fn parse_rejects_event_log_with_list_cameras() {
938 let err = CliConfig::parse_from(vec![
939 "--list-cameras".to_string(),
940 "--event-log".to_string(),
941 "events.jsonl".to_string(),
942 ])
943 .unwrap_err();
944 assert_eq!(
945 err,
946 CliError::Message(
947 "`--event-log` cannot be used together with `--list-cameras`".to_string()
948 )
949 );
950 }
951
952 #[test]
953 fn parse_eval_dataset_flags() {
954 let config = CliConfig::parse_from(vec![
955 "--eval-detection-jsonl".to_string(),
956 "det.jsonl".to_string(),
957 "--eval-detection-coco-gt".to_string(),
958 "det-gt.json".to_string(),
959 "--eval-detection-coco-pred".to_string(),
960 "det-pred.json".to_string(),
961 "--eval-detection-openimages-gt".to_string(),
962 "det-openimages-gt.csv".to_string(),
963 "--eval-detection-openimages-pred".to_string(),
964 "det-openimages-pred.csv".to_string(),
965 "--eval-detection-yolo-manifest".to_string(),
966 "det-yolo-manifest.txt".to_string(),
967 "--eval-detection-yolo-gt-dir".to_string(),
968 "det-yolo-gt".to_string(),
969 "--eval-detection-yolo-pred-dir".to_string(),
970 "det-yolo-pred".to_string(),
971 "--eval-detection-voc-manifest".to_string(),
972 "det-voc-manifest.txt".to_string(),
973 "--eval-detection-voc-gt-dir".to_string(),
974 "det-voc-gt".to_string(),
975 "--eval-detection-voc-pred-dir".to_string(),
976 "det-voc-pred".to_string(),
977 "--eval-detection-kitti-manifest".to_string(),
978 "det-kitti-manifest.txt".to_string(),
979 "--eval-detection-kitti-gt-dir".to_string(),
980 "det-kitti-gt".to_string(),
981 "--eval-detection-kitti-pred-dir".to_string(),
982 "det-kitti-pred".to_string(),
983 "--eval-detection-widerface-gt".to_string(),
984 "det-widerface-gt.txt".to_string(),
985 "--eval-detection-widerface-pred".to_string(),
986 "det-widerface-pred.txt".to_string(),
987 "--eval-tracking-jsonl".to_string(),
988 "trk.jsonl".to_string(),
989 "--eval-tracking-mot-gt".to_string(),
990 "trk-gt.txt".to_string(),
991 "--eval-tracking-mot-pred".to_string(),
992 "trk-pred.txt".to_string(),
993 "--eval-iou".to_string(),
994 "0.55".to_string(),
995 "--eval-score".to_string(),
996 "0.25".to_string(),
997 ])
998 .unwrap();
999 assert_eq!(
1000 config.eval_detection_dataset_path,
1001 Some(std::path::PathBuf::from("det.jsonl"))
1002 );
1003 assert_eq!(
1004 config.eval_detection_coco_gt_path,
1005 Some(std::path::PathBuf::from("det-gt.json"))
1006 );
1007 assert_eq!(
1008 config.eval_detection_coco_pred_path,
1009 Some(std::path::PathBuf::from("det-pred.json"))
1010 );
1011 assert_eq!(
1012 config.eval_detection_openimages_gt_path,
1013 Some(std::path::PathBuf::from("det-openimages-gt.csv"))
1014 );
1015 assert_eq!(
1016 config.eval_detection_openimages_pred_path,
1017 Some(std::path::PathBuf::from("det-openimages-pred.csv"))
1018 );
1019 assert_eq!(
1020 config.eval_detection_yolo_manifest_path,
1021 Some(std::path::PathBuf::from("det-yolo-manifest.txt"))
1022 );
1023 assert_eq!(
1024 config.eval_detection_yolo_gt_dir_path,
1025 Some(std::path::PathBuf::from("det-yolo-gt"))
1026 );
1027 assert_eq!(
1028 config.eval_detection_yolo_pred_dir_path,
1029 Some(std::path::PathBuf::from("det-yolo-pred"))
1030 );
1031 assert_eq!(
1032 config.eval_detection_voc_manifest_path,
1033 Some(std::path::PathBuf::from("det-voc-manifest.txt"))
1034 );
1035 assert_eq!(
1036 config.eval_detection_voc_gt_dir_path,
1037 Some(std::path::PathBuf::from("det-voc-gt"))
1038 );
1039 assert_eq!(
1040 config.eval_detection_voc_pred_dir_path,
1041 Some(std::path::PathBuf::from("det-voc-pred"))
1042 );
1043 assert_eq!(
1044 config.eval_detection_kitti_manifest_path,
1045 Some(std::path::PathBuf::from("det-kitti-manifest.txt"))
1046 );
1047 assert_eq!(
1048 config.eval_detection_kitti_gt_dir_path,
1049 Some(std::path::PathBuf::from("det-kitti-gt"))
1050 );
1051 assert_eq!(
1052 config.eval_detection_kitti_pred_dir_path,
1053 Some(std::path::PathBuf::from("det-kitti-pred"))
1054 );
1055 assert_eq!(
1056 config.eval_detection_widerface_gt_path,
1057 Some(std::path::PathBuf::from("det-widerface-gt.txt"))
1058 );
1059 assert_eq!(
1060 config.eval_detection_widerface_pred_path,
1061 Some(std::path::PathBuf::from("det-widerface-pred.txt"))
1062 );
1063 assert_eq!(
1064 config.eval_tracking_dataset_path,
1065 Some(std::path::PathBuf::from("trk.jsonl"))
1066 );
1067 assert_eq!(
1068 config.eval_tracking_mot_gt_path,
1069 Some(std::path::PathBuf::from("trk-gt.txt"))
1070 );
1071 assert_eq!(
1072 config.eval_tracking_mot_pred_path,
1073 Some(std::path::PathBuf::from("trk-pred.txt"))
1074 );
1075 assert_eq!(config.eval_iou_threshold, 0.55);
1076 assert_eq!(config.eval_score_threshold, 0.25);
1077 }
1078
1079 #[test]
1080 fn parse_rejects_partial_coco_eval_flags() {
1081 let err = CliConfig::parse_from(vec![
1082 "--eval-detection-coco-gt".to_string(),
1083 "det-gt.json".to_string(),
1084 ])
1085 .unwrap_err();
1086 assert_eq!(
1087 err,
1088 CliError::Message(
1089 "`--eval-detection-coco-gt` and `--eval-detection-coco-pred` must be provided together".to_string()
1090 )
1091 );
1092 }
1093
1094 #[test]
1095 fn parse_rejects_partial_openimages_eval_flags() {
1096 let err = CliConfig::parse_from(vec![
1097 "--eval-detection-openimages-gt".to_string(),
1098 "det-openimages-gt.csv".to_string(),
1099 ])
1100 .unwrap_err();
1101 assert_eq!(
1102 err,
1103 CliError::Message(
1104 "`--eval-detection-openimages-gt` and `--eval-detection-openimages-pred` must be provided together".to_string()
1105 )
1106 );
1107 }
1108
1109 #[test]
1110 fn parse_rejects_partial_yolo_eval_flags() {
1111 let err = CliConfig::parse_from(vec![
1112 "--eval-detection-yolo-manifest".to_string(),
1113 "det-yolo-manifest.txt".to_string(),
1114 ])
1115 .unwrap_err();
1116 assert_eq!(
1117 err,
1118 CliError::Message(
1119 "`--eval-detection-yolo-manifest`, `--eval-detection-yolo-gt-dir`, and `--eval-detection-yolo-pred-dir` must be provided together".to_string()
1120 )
1121 );
1122 }
1123
1124 #[test]
1125 fn parse_rejects_partial_voc_eval_flags() {
1126 let err = CliConfig::parse_from(vec![
1127 "--eval-detection-voc-manifest".to_string(),
1128 "det-voc-manifest.txt".to_string(),
1129 ])
1130 .unwrap_err();
1131 assert_eq!(
1132 err,
1133 CliError::Message(
1134 "`--eval-detection-voc-manifest`, `--eval-detection-voc-gt-dir`, and `--eval-detection-voc-pred-dir` must be provided together".to_string()
1135 )
1136 );
1137 }
1138
1139 #[test]
1140 fn parse_rejects_partial_kitti_eval_flags() {
1141 let err = CliConfig::parse_from(vec![
1142 "--eval-detection-kitti-manifest".to_string(),
1143 "det-kitti-manifest.txt".to_string(),
1144 ])
1145 .unwrap_err();
1146 assert_eq!(
1147 err,
1148 CliError::Message(
1149 "`--eval-detection-kitti-manifest`, `--eval-detection-kitti-gt-dir`, and `--eval-detection-kitti-pred-dir` must be provided together".to_string()
1150 )
1151 );
1152 }
1153
1154 #[test]
1155 fn parse_rejects_partial_widerface_eval_flags() {
1156 let err = CliConfig::parse_from(vec![
1157 "--eval-detection-widerface-gt".to_string(),
1158 "det-widerface-gt.txt".to_string(),
1159 ])
1160 .unwrap_err();
1161 assert_eq!(
1162 err,
1163 CliError::Message(
1164 "`--eval-detection-widerface-gt` and `--eval-detection-widerface-pred` must be provided together".to_string()
1165 )
1166 );
1167 }
1168
1169 #[test]
1170 fn parse_rejects_partial_tracking_mot_eval_flags() {
1171 let err = CliConfig::parse_from(vec![
1172 "--eval-tracking-mot-gt".to_string(),
1173 "trk-gt.txt".to_string(),
1174 ])
1175 .unwrap_err();
1176 assert_eq!(
1177 err,
1178 CliError::Message(
1179 "`--eval-tracking-mot-gt` and `--eval-tracking-mot-pred` must be provided together"
1180 .to_string()
1181 )
1182 );
1183 }
1184
1185 #[test]
1186 fn parse_validate_diagnostics_flags() {
1187 let config = CliConfig::parse_from(vec![
1188 "--validate-diagnostics-report".to_string(),
1189 "diag.json".to_string(),
1190 "--validate-diagnostics-min-frames".to_string(),
1191 "10".to_string(),
1192 "--validate-diagnostics-max-drift-pct".to_string(),
1193 "12.5".to_string(),
1194 "--validate-diagnostics-max-dropped".to_string(),
1195 "2".to_string(),
1196 ])
1197 .unwrap();
1198 assert_eq!(
1199 config.validate_diagnostics_report_path,
1200 Some(std::path::PathBuf::from("diag.json"))
1201 );
1202 assert_eq!(config.validate_diagnostics_min_frames, 10);
1203 assert_eq!(config.validate_diagnostics_max_drift_pct, 12.5);
1204 assert_eq!(config.validate_diagnostics_max_dropped_frames, 2);
1205 }
1206
1207 #[test]
1208 fn parse_rejects_eval_mode_with_camera() {
1209 let err = CliConfig::parse_from(vec![
1210 "--camera".to_string(),
1211 "--eval-detection-jsonl".to_string(),
1212 "det.jsonl".to_string(),
1213 ])
1214 .unwrap_err();
1215 assert_eq!(
1216 err,
1217 CliError::Message(
1218 "evaluation dataset mode cannot be combined with camera/listing mode".to_string()
1219 )
1220 );
1221 }
1222
1223 #[test]
1224 fn parse_rejects_diagnostics_validation_with_camera() {
1225 let err = CliConfig::parse_from(vec![
1226 "--validate-diagnostics-report".to_string(),
1227 "diag.json".to_string(),
1228 "--camera".to_string(),
1229 ])
1230 .unwrap_err();
1231 assert_eq!(
1232 err,
1233 CliError::Message(
1234 "diagnostics report validation mode cannot be combined with camera/diagnostics/eval/benchmark/event-log modes".to_string()
1235 )
1236 );
1237 }
1238
1239 #[test]
1240 fn parse_rejects_eval_mode_with_diagnostics() {
1241 let err = CliConfig::parse_from(vec![
1242 "--diagnose-camera".to_string(),
1243 "--eval-detection-jsonl".to_string(),
1244 "det.jsonl".to_string(),
1245 ])
1246 .unwrap_err();
1247 assert_eq!(
1248 err,
1249 CliError::Message(
1250 "evaluation dataset mode cannot be combined with `--diagnose-camera`".to_string()
1251 )
1252 );
1253 }
1254
1255 #[test]
1256 fn parse_rejects_eval_mode_with_event_log() {
1257 let err = CliConfig::parse_from(vec![
1258 "--eval-tracking-jsonl".to_string(),
1259 "trk.jsonl".to_string(),
1260 "--event-log".to_string(),
1261 "events.jsonl".to_string(),
1262 ])
1263 .unwrap_err();
1264 assert_eq!(
1265 err,
1266 CliError::Message(
1267 "evaluation dataset mode cannot be combined with `--event-log`".to_string()
1268 )
1269 );
1270 }
1271
1272 #[test]
1273 fn parse_rejects_eval_mode_with_benchmark() {
1274 let err = CliConfig::parse_from(vec![
1275 "--eval-detection-jsonl".to_string(),
1276 "det.jsonl".to_string(),
1277 "--benchmark".to_string(),
1278 ])
1279 .unwrap_err();
1280 assert_eq!(
1281 err,
1282 CliError::Message(
1283 "evaluation dataset mode cannot be combined with benchmark mode".to_string()
1284 )
1285 );
1286 }
1287
1288 #[test]
1289 fn parse_rejects_diagnostics_with_list_cameras() {
1290 let err = CliConfig::parse_from(vec![
1291 "--diagnose-camera".to_string(),
1292 "--list-cameras".to_string(),
1293 ])
1294 .unwrap_err();
1295 assert_eq!(
1296 err,
1297 CliError::Message(
1298 "`--diagnose-camera` cannot be used together with `--list-cameras`".to_string()
1299 )
1300 );
1301 }
1302
1303 #[test]
1304 fn parse_rejects_diagnostics_with_benchmark() {
1305 let err = CliConfig::parse_from(vec![
1306 "--diagnose-camera".to_string(),
1307 "--benchmark".to_string(),
1308 ])
1309 .unwrap_err();
1310 assert_eq!(
1311 err,
1312 CliError::Message(
1313 "`--diagnose-camera` cannot be used together with `--benchmark`".to_string()
1314 )
1315 );
1316 }
1317
1318 #[test]
1319 fn parse_rejects_diagnostics_validation_thresholds_without_report() {
1320 let err = CliConfig::parse_from(vec![
1321 "--validate-diagnostics-max-drift-pct".to_string(),
1322 "10".to_string(),
1323 ])
1324 .unwrap_err();
1325 assert_eq!(
1326 err,
1327 CliError::Message(
1328 "diagnostics validation thresholds require `--validate-diagnostics-report`"
1329 .to_string()
1330 )
1331 );
1332 }
1333
1334 #[test]
1335 fn parse_rejects_diagnose_report_without_diagnostics_mode() {
1336 let err = CliConfig::parse_from(vec![
1337 "--diagnose-report".to_string(),
1338 "diag.json".to_string(),
1339 ])
1340 .unwrap_err();
1341 assert_eq!(
1342 err,
1343 CliError::Message("`--diagnose-report` requires `--diagnose-camera`".to_string())
1344 );
1345 }
1346
1347 #[test]
1348 fn parse_rejects_invalid_eval_iou() {
1349 let err =
1350 CliConfig::parse_from(vec!["--eval-iou".to_string(), "2.0".to_string()]).unwrap_err();
1351 assert_eq!(
1352 err,
1353 CliError::Message("--eval-iou must be finite and in [0, 1]".to_string())
1354 );
1355 }
1356
1357 #[test]
1358 fn parse_camera_mode_and_options() {
1359 let args = vec![
1360 "--camera".to_string(),
1361 "--device".to_string(),
1362 "2".to_string(),
1363 "--width".to_string(),
1364 "1280".to_string(),
1365 "--height".to_string(),
1366 "720".to_string(),
1367 "--fps".to_string(),
1368 "60".to_string(),
1369 "--track-iou".to_string(),
1370 "0.35".to_string(),
1371 "--track-max-missed".to_string(),
1372 "4".to_string(),
1373 "--track-max".to_string(),
1374 "128".to_string(),
1375 "--recognition-threshold".to_string(),
1376 "0.88".to_string(),
1377 "--max-frames".to_string(),
1378 "120".to_string(),
1379 "--identities".to_string(),
1380 "ids.json".to_string(),
1381 ];
1382 let config = CliConfig::parse_from(args).unwrap();
1383 assert!(config.camera);
1384 assert_eq!(config.device_index, 2);
1385 assert_eq!(config.width, 1280);
1386 assert_eq!(config.height, 720);
1387 assert_eq!(config.fps, 60);
1388 assert_eq!(config.track_iou_threshold, 0.35);
1389 assert_eq!(config.track_max_missed_frames, 4);
1390 assert_eq!(config.track_max_tracks, 128);
1391 assert_eq!(config.recognition_threshold, 0.88);
1392 assert_eq!(config.max_frames, Some(120));
1393 assert_eq!(
1394 config.identities_path,
1395 Some(std::path::PathBuf::from("ids.json"))
1396 );
1397 }
1398
1399 #[test]
1400 fn parse_rejects_unknown_argument() {
1401 let err = CliConfig::parse_from(vec!["--what".to_string()]).unwrap_err();
1402 assert_eq!(
1403 err,
1404 CliError::Message("unknown argument: --what; run with --help for usage".to_string())
1405 );
1406 }
1407
1408 #[test]
1409 fn parse_rejects_non_positive_resolution() {
1410 let err = CliConfig::parse_from(vec!["--width".to_string(), "0".to_string()]).unwrap_err();
1411 assert_eq!(
1412 err,
1413 CliError::Message("--width must be greater than 0".to_string())
1414 );
1415 }
1416
1417 #[test]
1418 fn parse_benchmark_flags() {
1419 let args = vec![
1420 "--benchmark".to_string(),
1421 "--detect-target".to_string(),
1422 "face".to_string(),
1423 "--benchmark-report".to_string(),
1424 "bench.txt".to_string(),
1425 "--benchmark-baseline".to_string(),
1426 "baseline.txt".to_string(),
1427 ];
1428 let config = CliConfig::parse_from(args).unwrap();
1429 assert!(config.benchmark);
1430 assert_eq!(config.detect_target, DetectTarget::Faces);
1431 assert_eq!(
1432 config.benchmark_report_path,
1433 Some(std::path::PathBuf::from("bench.txt"))
1434 );
1435 assert_eq!(
1436 config.benchmark_baseline_path,
1437 Some(std::path::PathBuf::from("baseline.txt"))
1438 );
1439 }
1440
1441 #[test]
1442 fn parse_baseline_enables_benchmark_mode() {
1443 let config = CliConfig::parse_from(vec![
1444 "--benchmark-baseline".to_string(),
1445 "baseline.txt".to_string(),
1446 ])
1447 .unwrap();
1448 assert!(config.benchmark);
1449 assert_eq!(
1450 config.benchmark_baseline_path,
1451 Some(std::path::PathBuf::from("baseline.txt"))
1452 );
1453 }
1454
1455 #[test]
1456 fn parse_rejects_invalid_track_iou() {
1457 let err =
1458 CliConfig::parse_from(vec!["--track-iou".to_string(), "1.5".to_string()]).unwrap_err();
1459 assert_eq!(
1460 err,
1461 CliError::Message("--track-iou must be finite and in [0, 1]".to_string())
1462 );
1463 }
1464
1465 #[test]
1466 fn parse_rejects_invalid_recognition_threshold() {
1467 let err = CliConfig::parse_from(vec![
1468 "--recognition-threshold".to_string(),
1469 "2.0".to_string(),
1470 ])
1471 .unwrap_err();
1472 assert_eq!(
1473 err,
1474 CliError::Message("--recognition-threshold must be finite and in [-1, 1]".to_string())
1475 );
1476 }
1477
1478 #[test]
1479 fn parse_rejects_invalid_detect_target() {
1480 let err = CliConfig::parse_from(vec!["--detect-target".to_string(), "animals".to_string()])
1481 .unwrap_err();
1482 assert_eq!(
1483 err,
1484 CliError::Message(
1485 "invalid --detect-target `animals`; expected one of: people, face".to_string()
1486 )
1487 );
1488 }
1489
1490 #[test]
1491 fn parse_detect_overrides() {
1492 let config = CliConfig::parse_from(vec![
1493 "--detect-score".to_string(),
1494 "0.42".to_string(),
1495 "--detect-min-area".to_string(),
1496 "12".to_string(),
1497 "--detect-iou".to_string(),
1498 "0.33".to_string(),
1499 "--detect-max".to_string(),
1500 "20".to_string(),
1501 ])
1502 .unwrap();
1503 assert_eq!(config.detect_score_threshold, Some(0.42));
1504 assert_eq!(config.detect_min_area, Some(12));
1505 assert_eq!(config.detect_iou_threshold, Some(0.33));
1506 assert_eq!(config.detect_max_detections, Some(20));
1507 }
1508
1509 #[test]
1510 fn parse_rejects_invalid_detect_score() {
1511 let err = CliConfig::parse_from(vec!["--detect-score".to_string(), "1.2".to_string()])
1512 .unwrap_err();
1513 assert_eq!(
1514 err,
1515 CliError::Message("--detect-score must be finite and in [0, 1]".to_string())
1516 );
1517 }
1518
1519 #[test]
1520 fn parse_rejects_zero_detect_limits() {
1521 let err = CliConfig::parse_from(vec!["--detect-min-area".to_string(), "0".to_string()])
1522 .unwrap_err();
1523 assert_eq!(
1524 err,
1525 CliError::Message("--detect-min-area must be greater than 0".to_string())
1526 );
1527
1528 let err =
1529 CliConfig::parse_from(vec!["--detect-max".to_string(), "0".to_string()]).unwrap_err();
1530 assert_eq!(
1531 err,
1532 CliError::Message("--detect-max must be greater than 0".to_string())
1533 );
1534 }
1535}