use crate::error::{QcError, QcIssue, QcResult, Severity};
use oxigdal_core::buffer::RasterBuffer;
use oxigdal_core::types::{BoundingBox, GeoTransform};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AccuracyResult {
pub georef_accuracy: GeoreferencingAccuracy,
pub gcp_validation: Option<GcpValidation>,
pub resolution_check: ResolutionCheck,
pub dem_accuracy: Option<DemAccuracy>,
pub ortho_quality: Option<OrthoQuality>,
pub issues: Vec<QcIssue>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GeoreferencingAccuracy {
pub has_valid_geotransform: bool,
pub has_coordinate_system: bool,
pub pixel_size_x: f64,
pub pixel_size_y: f64,
pub reasonable_pixel_size: bool,
pub has_rotation: bool,
pub quality: GeoreferenceQuality,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum GeoreferenceQuality {
Excellent,
Good,
Fair,
Poor,
None,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GcpValidation {
pub gcp_count: usize,
pub rmse_x: f64,
pub rmse_y: f64,
pub rmse_total: f64,
pub max_error: f64,
pub meets_threshold: bool,
pub distribution_quality: DistributionQuality,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DistributionQuality {
WellDistributed,
Adequate,
Poor,
Clustered,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ResolutionCheck {
pub actual_resolution_x: f64,
pub actual_resolution_y: f64,
pub expected_resolution: Option<f64>,
pub is_isotropic: bool,
pub resolution_deviation: Option<f64>,
pub meets_requirements: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DemAccuracy {
pub elevation_range: f64,
pub min_elevation: f64,
pub max_elevation: f64,
pub reasonable_elevations: bool,
pub vertical_accuracy: Option<f64>,
pub has_artifacts: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct OrthoQuality {
pub quality_score: f64,
pub geometric_accuracy: f64,
pub has_distortion: bool,
pub assessment: OrthoAssessment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum OrthoAssessment {
Excellent,
Good,
Fair,
Poor,
}
#[derive(Debug, Clone)]
pub struct AccuracyConfig {
pub expected_resolution: Option<f64>,
pub max_resolution_deviation: f64,
pub gcp_rmse_threshold: f64,
pub min_gcp_count: usize,
pub expected_elevation_range: Option<(f64, f64)>,
pub check_dem_artifacts: bool,
pub assess_ortho_quality: bool,
}
impl Default for AccuracyConfig {
fn default() -> Self {
Self {
expected_resolution: None,
max_resolution_deviation: 10.0,
gcp_rmse_threshold: 1.0,
min_gcp_count: 4,
expected_elevation_range: None,
check_dem_artifacts: true,
assess_ortho_quality: false,
}
}
}
pub struct AccuracyChecker {
config: AccuracyConfig,
}
impl AccuracyChecker {
#[must_use]
pub fn new() -> Self {
Self {
config: AccuracyConfig::default(),
}
}
#[must_use]
pub fn with_config(config: AccuracyConfig) -> Self {
Self { config }
}
pub fn check_raster(
&self,
buffer: &RasterBuffer,
geotransform: &GeoTransform,
_bbox: Option<&BoundingBox>,
) -> QcResult<AccuracyResult> {
let mut issues = Vec::new();
let georef_accuracy = self.check_georeferencing(buffer, geotransform)?;
if matches!(
georef_accuracy.quality,
GeoreferenceQuality::Poor | GeoreferenceQuality::None
) {
issues.push(
QcIssue::new(
Severity::Critical,
"accuracy",
"Poor or missing georeferencing",
format!("Georeferencing quality: {:?}", georef_accuracy.quality),
)
.with_suggestion("Verify geotransform and coordinate system definition"),
);
}
let resolution_check = self.check_resolution(geotransform)?;
if !resolution_check.meets_requirements {
issues.push(
QcIssue::new(
Severity::Minor,
"accuracy",
"Resolution does not meet requirements",
format!(
"Resolution deviation: {:?}%",
resolution_check.resolution_deviation
),
)
.with_suggestion("Verify expected resolution and processing parameters"),
);
}
if !resolution_check.is_isotropic {
issues.push(
QcIssue::new(
Severity::Warning,
"accuracy",
"Non-isotropic pixels detected",
format!(
"Pixel size X: {:.6}, Y: {:.6}",
resolution_check.actual_resolution_x, resolution_check.actual_resolution_y
),
)
.with_suggestion("Consider resampling to square pixels if required"),
);
}
let dem_accuracy = self.check_dem_accuracy(buffer)?;
if let Some(ref dem) = dem_accuracy {
if !dem.reasonable_elevations {
issues.push(
QcIssue::new(
Severity::Major,
"accuracy",
"Unreasonable elevation values detected",
format!(
"Elevation range: {:.2} (min: {:.2}, max: {:.2})",
dem.elevation_range, dem.min_elevation, dem.max_elevation
),
)
.with_suggestion("Verify elevation data source and units"),
);
}
if dem.has_artifacts {
issues.push(
QcIssue::new(
Severity::Minor,
"accuracy",
"DEM artifacts detected",
"Suspicious pits or peaks found in elevation data",
)
.with_suggestion("Apply artifact removal filter or manual editing"),
);
}
}
Ok(AccuracyResult {
georef_accuracy,
gcp_validation: None, resolution_check,
dem_accuracy,
ortho_quality: None, issues,
})
}
fn check_georeferencing(
&self,
_buffer: &RasterBuffer,
geotransform: &GeoTransform,
) -> QcResult<GeoreferencingAccuracy> {
let pixel_size_x = geotransform.pixel_width.abs();
let pixel_size_y = geotransform.pixel_height.abs();
let reasonable_pixel_size = pixel_size_x > 1e-10
&& pixel_size_y > 1e-10
&& pixel_size_x < 1e10
&& pixel_size_y < 1e10;
let has_rotation =
geotransform.row_rotation.abs() > 1e-10 || geotransform.col_rotation.abs() > 1e-10;
let quality = if !reasonable_pixel_size {
GeoreferenceQuality::None
} else if has_rotation {
GeoreferenceQuality::Fair
} else if (pixel_size_x - pixel_size_y).abs() / pixel_size_x > 0.1 {
GeoreferenceQuality::Good
} else {
GeoreferenceQuality::Excellent
};
Ok(GeoreferencingAccuracy {
has_valid_geotransform: reasonable_pixel_size,
has_coordinate_system: true, pixel_size_x,
pixel_size_y,
reasonable_pixel_size,
has_rotation,
quality,
})
}
fn check_resolution(&self, geotransform: &GeoTransform) -> QcResult<ResolutionCheck> {
let actual_resolution_x = geotransform.pixel_width.abs();
let actual_resolution_y = geotransform.pixel_height.abs();
let is_isotropic =
(actual_resolution_x - actual_resolution_y).abs() / actual_resolution_x < 0.01;
let (resolution_deviation, meets_requirements) =
if let Some(expected) = self.config.expected_resolution {
let avg_resolution = (actual_resolution_x + actual_resolution_y) / 2.0;
let deviation = ((avg_resolution - expected).abs() / expected) * 100.0;
let meets = deviation <= self.config.max_resolution_deviation;
(Some(deviation), meets)
} else {
(None, true)
};
Ok(ResolutionCheck {
actual_resolution_x,
actual_resolution_y,
expected_resolution: self.config.expected_resolution,
is_isotropic,
resolution_deviation,
meets_requirements,
})
}
pub fn validate_gcps(&self, gcps: &[GroundControlPoint]) -> QcResult<GcpValidation> {
if gcps.len() < self.config.min_gcp_count {
return Err(QcError::ValidationRule(format!(
"Insufficient GCPs: found {}, required {}",
gcps.len(),
self.config.min_gcp_count
)));
}
let mut sum_x_sq: f64 = 0.0;
let mut sum_y_sq: f64 = 0.0;
let mut max_error: f64 = 0.0;
for gcp in gcps {
let error_x = gcp.residual_x.abs();
let error_y = gcp.residual_y.abs();
sum_x_sq += error_x * error_x;
sum_y_sq += error_y * error_y;
max_error = max_error.max(error_x.max(error_y));
}
let n = gcps.len() as f64;
let rmse_x = (sum_x_sq / n).sqrt();
let rmse_y = (sum_y_sq / n).sqrt();
let rmse_total = ((sum_x_sq + sum_y_sq) / n).sqrt();
let meets_threshold = rmse_total <= self.config.gcp_rmse_threshold;
let distribution_quality = self.assess_gcp_distribution(gcps);
Ok(GcpValidation {
gcp_count: gcps.len(),
rmse_x,
rmse_y,
rmse_total,
max_error,
meets_threshold,
distribution_quality,
})
}
fn assess_gcp_distribution(&self, gcps: &[GroundControlPoint]) -> DistributionQuality {
if gcps.len() < 4 {
return DistributionQuality::Poor;
}
let mut min_x = f64::MAX;
let mut max_x = f64::MIN;
let mut min_y = f64::MAX;
let mut max_y = f64::MIN;
let mut sum_x = 0.0;
let mut sum_y = 0.0;
for gcp in gcps {
min_x = min_x.min(gcp.pixel_x);
max_x = max_x.max(gcp.pixel_x);
min_y = min_y.min(gcp.pixel_y);
max_y = max_y.max(gcp.pixel_y);
sum_x += gcp.pixel_x;
sum_y += gcp.pixel_y;
}
let centroid_x = sum_x / gcps.len() as f64;
let centroid_y = sum_y / gcps.len() as f64;
let range_x = max_x - min_x;
let range_y = max_y - min_y;
let threshold = 0.2;
let clustered_x = range_x < threshold * (max_x + min_x) / 2.0;
let clustered_y = range_y < threshold * (max_y + min_y) / 2.0;
if clustered_x || clustered_y {
return DistributionQuality::Clustered;
}
let center_x = (min_x + max_x) / 2.0;
let center_y = (min_y + max_y) / 2.0;
let centroid_offset = ((centroid_x - center_x).powi(2) + (centroid_y - center_y).powi(2))
.sqrt()
/ ((range_x.powi(2) + range_y.powi(2)).sqrt());
if centroid_offset < 0.1 {
DistributionQuality::WellDistributed
} else if centroid_offset < 0.25 {
DistributionQuality::Adequate
} else {
DistributionQuality::Poor
}
}
fn check_dem_accuracy(&self, buffer: &RasterBuffer) -> QcResult<Option<DemAccuracy>> {
if !self.config.check_dem_artifacts {
return Ok(None);
}
let stats = buffer.compute_statistics()?;
if stats.valid_count == 0 {
return Ok(None);
}
let elevation_range = stats.max - stats.min;
let reasonable_elevations =
if let Some((min_expected, max_expected)) = self.config.expected_elevation_range {
stats.min >= min_expected && stats.max <= max_expected
} else {
stats.min >= -500.0 && stats.max <= 9000.0
};
let has_artifacts = self.detect_dem_artifacts(buffer)?;
Ok(Some(DemAccuracy {
elevation_range,
min_elevation: stats.min,
max_elevation: stats.max,
reasonable_elevations,
vertical_accuracy: None, has_artifacts,
}))
}
fn detect_dem_artifacts(&self, _buffer: &RasterBuffer) -> QcResult<bool> {
Ok(false)
}
}
impl Default for AccuracyChecker {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct GroundControlPoint {
pub pixel_x: f64,
pub pixel_y: f64,
pub geo_x: f64,
pub geo_y: f64,
pub residual_x: f64,
pub residual_y: f64,
}
#[cfg(test)]
mod tests {
use super::*;
use oxigdal_core::types::RasterDataType;
#[test]
fn test_accuracy_checker_basic() {
let buffer = RasterBuffer::zeros(1000, 1000, RasterDataType::Float32);
let bbox = BoundingBox::new(-180.0, -90.0, 180.0, 90.0)
.expect("Failed to create test bounding box");
let geotransform = GeoTransform::from_bounds(&bbox, 1000, 1000)
.expect("Failed to create test geotransform from bounds");
let checker = AccuracyChecker::new();
let result = checker.check_raster(&buffer, &geotransform, Some(&bbox));
assert!(result.is_ok());
}
#[test]
fn test_resolution_check() {
let bbox =
BoundingBox::new(0.0, 0.0, 100.0, 100.0).expect("Failed to create test bounding box");
let geotransform = GeoTransform::from_bounds(&bbox, 100, 100)
.expect("Failed to create test geotransform from bounds");
let checker = AccuracyChecker::new();
let result = checker.check_resolution(&geotransform);
assert!(result.is_ok());
let result = result.expect("Resolution check should succeed");
assert!(result.is_isotropic);
}
#[test]
fn test_gcp_validation() {
let gcps = vec![
GroundControlPoint {
pixel_x: 0.0,
pixel_y: 0.0,
geo_x: 0.0,
geo_y: 0.0,
residual_x: 0.1,
residual_y: 0.1,
},
GroundControlPoint {
pixel_x: 100.0,
pixel_y: 0.0,
geo_x: 1.0,
geo_y: 0.0,
residual_x: 0.2,
residual_y: 0.1,
},
GroundControlPoint {
pixel_x: 0.0,
pixel_y: 100.0,
geo_x: 0.0,
geo_y: 1.0,
residual_x: 0.1,
residual_y: 0.2,
},
GroundControlPoint {
pixel_x: 100.0,
pixel_y: 100.0,
geo_x: 1.0,
geo_y: 1.0,
residual_x: 0.15,
residual_y: 0.15,
},
];
let checker = AccuracyChecker::new();
let result = checker.validate_gcps(&gcps);
assert!(result.is_ok());
let result = result.expect("GCP validation should succeed");
assert_eq!(result.gcp_count, 4);
assert!(result.rmse_total < 1.0);
}
}