yscv-eval 0.1.8

Evaluation metrics (mAP, MOTA, HOTA) and dataset adapters
Documentation
use std::fs;
use std::path::Path;

use serde::Deserialize;

use crate::EvalError;

#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct CameraDiagnosticsReport {
    pub tool: String,
    pub mode: String,
    pub status: String,
    pub requested: CameraDiagnosticsRequested,
    #[serde(default)]
    pub discovered_devices: Vec<CameraDiagnosticsDevice>,
    pub selected_device: Option<CameraDiagnosticsDevice>,
    pub capture: Option<CameraDiagnosticsCapture>,
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct CameraDiagnosticsRequested {
    pub device_index: u32,
    pub width: u32,
    pub height: u32,
    pub fps: u32,
    pub diagnose_frames: usize,
    pub device_name_query: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct CameraDiagnosticsDevice {
    pub index: u32,
    pub label: String,
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct CameraDiagnosticsCapture {
    pub requested_frames: usize,
    pub collected_frames: usize,
    pub wall_ms: f64,
    pub first_frame: Option<CameraDiagnosticsFirstFrame>,
    pub timing: Option<CameraDiagnosticsTiming>,
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct CameraDiagnosticsFirstFrame {
    pub index: u64,
    pub timestamp_us: u64,
    pub shape: [usize; 3],
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct CameraDiagnosticsTiming {
    pub target_fps: f64,
    pub wall_fps: f64,
    pub sensor_fps: f64,
    pub wall_drift_pct: f64,
    pub sensor_drift_pct: f64,
    pub mean_gap_us: f64,
    pub min_gap_us: u64,
    pub max_gap_us: u64,
    pub dropped_frames: u64,
    pub drift_warning: bool,
    pub dropped_frames_warning: bool,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CameraDiagnosticsThresholds {
    pub min_collected_frames: usize,
    pub max_abs_wall_drift_pct: f64,
    pub max_abs_sensor_drift_pct: f64,
    pub max_dropped_frames: u64,
}

impl Default for CameraDiagnosticsThresholds {
    fn default() -> Self {
        Self {
            min_collected_frames: 2,
            max_abs_wall_drift_pct: 25.0,
            max_abs_sensor_drift_pct: 25.0,
            max_dropped_frames: 0,
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct CameraDiagnosticsViolation {
    pub field: &'static str,
    pub message: String,
}

pub fn parse_camera_diagnostics_report_json(
    text: &str,
) -> Result<CameraDiagnosticsReport, EvalError> {
    serde_json::from_str(text).map_err(|err| EvalError::InvalidDiagnosticsReport {
        message: err.to_string(),
    })
}

pub fn load_camera_diagnostics_report_json_file(
    path: &Path,
) -> Result<CameraDiagnosticsReport, EvalError> {
    let body = fs::read_to_string(path).map_err(|err| EvalError::DiagnosticsReportIo {
        path: path.display().to_string(),
        message: err.to_string(),
    })?;
    parse_camera_diagnostics_report_json(&body)
}

pub fn validate_camera_diagnostics_report(
    report: &CameraDiagnosticsReport,
    thresholds: CameraDiagnosticsThresholds,
) -> Vec<CameraDiagnosticsViolation> {
    let mut violations = Vec::new();
    if report.mode != "diagnostics" {
        violations.push(CameraDiagnosticsViolation {
            field: "mode",
            message: format!("expected `diagnostics`, got `{}`", report.mode),
        });
    }
    if report.status != "ok" {
        violations.push(CameraDiagnosticsViolation {
            field: "status",
            message: format!("expected `ok`, got `{}`", report.status),
        });
    }

    let Some(capture) = report.capture.as_ref() else {
        violations.push(CameraDiagnosticsViolation {
            field: "capture",
            message: "capture section is missing".to_string(),
        });
        return violations;
    };

    if capture.collected_frames < thresholds.min_collected_frames {
        violations.push(CameraDiagnosticsViolation {
            field: "capture.collected_frames",
            message: format!(
                "expected >= {}, got {}",
                thresholds.min_collected_frames, capture.collected_frames
            ),
        });
    }
    if !capture.wall_ms.is_finite() || capture.wall_ms < 0.0 {
        violations.push(CameraDiagnosticsViolation {
            field: "capture.wall_ms",
            message: format!(
                "expected finite non-negative value, got {}",
                capture.wall_ms
            ),
        });
    }

    let Some(timing) = capture.timing.as_ref() else {
        violations.push(CameraDiagnosticsViolation {
            field: "capture.timing",
            message: "timing section is missing".to_string(),
        });
        return violations;
    };

    for (field, value) in [
        ("capture.timing.target_fps", timing.target_fps),
        ("capture.timing.wall_fps", timing.wall_fps),
        ("capture.timing.sensor_fps", timing.sensor_fps),
        ("capture.timing.mean_gap_us", timing.mean_gap_us),
    ] {
        if !value.is_finite() {
            violations.push(CameraDiagnosticsViolation {
                field,
                message: format!("expected finite value, got {value}"),
            });
        }
    }

    if timing.wall_drift_pct.abs() > thresholds.max_abs_wall_drift_pct {
        violations.push(CameraDiagnosticsViolation {
            field: "capture.timing.wall_drift_pct",
            message: format!(
                "abs drift {} exceeds threshold {}",
                timing.wall_drift_pct.abs(),
                thresholds.max_abs_wall_drift_pct
            ),
        });
    }
    if timing.sensor_drift_pct.abs() > thresholds.max_abs_sensor_drift_pct {
        violations.push(CameraDiagnosticsViolation {
            field: "capture.timing.sensor_drift_pct",
            message: format!(
                "abs drift {} exceeds threshold {}",
                timing.sensor_drift_pct.abs(),
                thresholds.max_abs_sensor_drift_pct
            ),
        });
    }
    if timing.dropped_frames > thresholds.max_dropped_frames {
        violations.push(CameraDiagnosticsViolation {
            field: "capture.timing.dropped_frames",
            message: format!(
                "dropped frames {} exceeds threshold {}",
                timing.dropped_frames, thresholds.max_dropped_frames
            ),
        });
    }
    violations
}