use std::path::Path;
use oxigeo_core::io::FileDataSource;
use oxigeo_core::types::RasterDataType;
use oxigeo_geotiff::GeoTiffReader;
use oxigeo_geotiff::tiff::SampleFormat;
use crate::error::{QcIssue, QcResult, Severity};
use crate::raster::band_scan::{RasterScan, native, scan_band};
pub const DEFAULT_FLOAT_EPS_F32: f32 = 1e-6;
pub const DEFAULT_FLOAT_EPS_F64: f64 = 1e-12;
pub const DEFAULT_OUTLIER_THRESHOLD: f64 = 0.5;
#[derive(Debug, Clone)]
pub struct NoDataValidator {
pub float_eps_f32: f32,
pub float_eps_f64: f64,
pub outlier_threshold: f64,
}
impl Default for NoDataValidator {
fn default() -> Self {
Self::new()
}
}
impl NoDataValidator {
#[must_use]
pub const fn new() -> Self {
Self {
float_eps_f32: DEFAULT_FLOAT_EPS_F32,
float_eps_f64: DEFAULT_FLOAT_EPS_F64,
outlier_threshold: DEFAULT_OUTLIER_THRESHOLD,
}
}
#[must_use]
pub const fn with_float_eps_f32(mut self, eps: f32) -> Self {
self.float_eps_f32 = eps;
self
}
#[must_use]
pub const fn with_float_eps_f64(mut self, eps: f64) -> Self {
self.float_eps_f64 = eps;
self
}
#[must_use]
pub const fn with_outlier_threshold(mut self, t: f64) -> Self {
self.outlier_threshold = t;
self
}
pub fn check_file<P: AsRef<Path>>(&self, path: P) -> QcResult<NoDataValidationResult> {
let source = FileDataSource::open(path.as_ref()).map_err(|e| {
crate::error::QcError::RasterError(format!("Failed to open raster: {}", e))
})?;
let reader = GeoTiffReader::open(source).map_err(|e| {
crate::error::QcError::RasterError(format!("Failed to read GeoTIFF: {}", e))
})?;
let scan = RasterScan::probe(&reader)?;
let nodata_value = reader.nodata();
let declared_per_band: Vec<Option<f64>> = (0..scan.band_count)
.map(|_| nodata_value.as_f64())
.collect();
let masks = read_band_masks(&reader, &scan, &declared_per_band, self)?;
Ok(self.evaluate_masks(masks, declared_per_band))
}
pub(crate) fn evaluate_masks(
&self,
masks: Vec<NoDataBandMask>,
declared: Vec<Option<f64>>,
) -> NoDataValidationResult {
let mut issues = Vec::new();
let mut per_band: Vec<NoDataBandStats> = Vec::with_capacity(masks.len());
let mut declared_values_present: Vec<f64> = declared.iter().flatten().copied().collect();
declared_values_present
.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
declared_values_present.dedup_by(|a, b| (a.is_nan() && b.is_nan()) || a == b);
if declared_values_present.len() > 1 {
issues.push(
QcIssue::new(
Severity::Major,
"nodata",
"Bands have different NoData values",
format!(
"Bands declare conflicting NoData sentinels: {:?}",
declared_values_present
),
)
.with_rule_id("NODATA-VALUES-DIFFER")
.with_suggestion(
"Reconcile band NoData values; multi-band rasters should share one sentinel.",
),
);
}
for (band_idx, mask) in masks.iter().enumerate() {
let declared_for_band = declared.get(band_idx).copied().flatten();
let total = mask.total;
let actual = mask.nodata_count;
let coverage = if total > 0 {
actual as f64 / total as f64
} else {
0.0
};
per_band.push(NoDataBandStats {
band: (band_idx + 1) as u32,
declared_nodata: declared_for_band,
actual_nodata_count: actual,
coverage_pct: coverage * 100.0,
});
match (declared_for_band, actual) {
(Some(_), 0) => {
issues.push(
QcIssue::new(
Severity::Warning,
"nodata",
"Band has NoData metadata but no NoData pixels",
format!(
"Band {}: declared NoData but the raster contains zero matching pixels",
band_idx + 1
),
)
.with_rule_id("NODATA-METADATA-WITHOUT-PIXELS"),
);
}
(None, n) if n > 0 && mask.suspected_unmarked_nodata => {
issues.push(
QcIssue::new(
Severity::Major,
"nodata",
"Band has NoData pixels but no metadata",
format!(
"Band {}: pixel data contains likely-NoData sentinels (count = {}) \
but the file has no NoData metadata",
band_idx + 1,
n
),
)
.with_rule_id("NODATA-PIXELS-WITHOUT-METADATA")
.with_suggestion("Add a GDAL_NODATA tag to declare the sentinel value."),
);
}
_ => {}
}
}
let mut common_count: u64 = 0;
if let Some(first) = masks.first() {
common_count = (0..first.bitmap.len())
.filter(|i| {
masks
.iter()
.all(|m| m.bitmap.get(*i).copied().unwrap_or(false))
})
.count() as u64;
}
if masks.len() >= 2 {
for (band_idx, mask) in masks.iter().enumerate() {
let other_max = masks
.iter()
.enumerate()
.filter(|(j, _)| *j != band_idx)
.map(|(_, m)| m.nodata_count)
.max()
.unwrap_or(0);
if other_max == 0 {
continue;
}
let ratio = mask.nodata_count as f64 / other_max as f64;
if ratio < (1.0 - self.outlier_threshold) {
issues.push(
QcIssue::new(
Severity::Warning,
"nodata",
"Band under-covers common NoData footprint",
format!(
"Band {} has only {} NoData pixels vs other bands' max {} \
(ratio {:.2}, threshold {:.2})",
band_idx + 1,
mask.nodata_count,
other_max,
ratio,
1.0 - self.outlier_threshold
),
)
.with_rule_id("NODATA-COMMON-FOOTPRINT-OUTLIER")
.with_suggestion("Possible fill-value pollution; verify band consistency."),
);
}
}
}
NoDataValidationResult {
issues,
per_band,
common_footprint_count: common_count,
}
}
}
#[derive(Debug, Clone)]
pub struct NoDataBandStats {
pub band: u32,
pub declared_nodata: Option<f64>,
pub actual_nodata_count: u64,
pub coverage_pct: f64,
}
#[derive(Debug, Clone)]
pub struct NoDataValidationResult {
pub issues: Vec<QcIssue>,
pub per_band: Vec<NoDataBandStats>,
pub common_footprint_count: u64,
}
impl NoDataValidationResult {
#[must_use]
pub fn is_valid(&self) -> bool {
!self.issues.iter().any(|i| i.severity >= Severity::Major)
}
}
#[derive(Debug, Clone)]
pub(crate) struct NoDataBandMask {
pub bitmap: Vec<bool>,
pub total: u64,
pub nodata_count: u64,
pub suspected_unmarked_nodata: bool,
}
fn read_band_masks<S: oxigeo_core::io::DataSource>(
reader: &GeoTiffReader<S>,
scan: &RasterScan,
declared_per_band: &[Option<f64>],
validator: &NoDataValidator,
) -> QcResult<Vec<NoDataBandMask>> {
let total_pixels = scan.total_pixels();
let pixels = usize::try_from(total_pixels).map_err(|_| {
crate::error::QcError::RasterError(format!(
"raster of {total_pixels} pixels does not fit in memory on this target"
))
})?;
let mut masks = Vec::with_capacity(scan.band_count);
for band_idx in 0..scan.band_count {
let declared = declared_per_band.get(band_idx).copied().flatten();
let mut bitmap = vec![false; pixels];
let mut nodata_count = 0u64;
scan_band(reader, scan, band_idx, |first_row, samples| {
let base = usize::try_from(first_row * scan.width).map_err(|_| {
crate::error::QcError::RasterError("row offset overflows usize".to_string())
})?;
for (offset, sample) in samples.chunks_exact(scan.bytes_per_sample).enumerate() {
if matches_nodata(
sample,
scan.data_type,
scan.sample_format,
declared,
validator,
) {
if let Some(cell) = bitmap.get_mut(base + offset) {
*cell = true;
nodata_count += 1;
}
}
}
Ok(())
})?;
masks.push(NoDataBandMask {
bitmap,
total: total_pixels,
nodata_count,
suspected_unmarked_nodata: false,
});
}
Ok(masks)
}
fn matches_nodata(
sample_bytes: &[u8],
dtype: RasterDataType,
sample_format: SampleFormat,
declared: Option<f64>,
validator: &NoDataValidator,
) -> bool {
let Some(decl) = declared else {
return false;
};
use SampleFormat::{IeeeFloatingPoint, SignedInteger, UnsignedInteger};
match (sample_format, dtype) {
(UnsignedInteger, RasterDataType::UInt8) => {
sample_bytes.first().is_some_and(|&v| v as f64 == decl)
}
(UnsignedInteger, RasterDataType::UInt16) => {
native::read_u16(sample_bytes).is_some_and(|v| v as f64 == decl)
}
(UnsignedInteger, RasterDataType::UInt32) => {
native::read_u32(sample_bytes).is_some_and(|v| v as f64 == decl)
}
(UnsignedInteger, RasterDataType::UInt64) => {
native::read_u64(sample_bytes).is_some_and(|v| v as f64 == decl)
}
(SignedInteger, RasterDataType::Int8) => sample_bytes
.first()
.is_some_and(|&v| (v as i8) as f64 == decl),
(SignedInteger, RasterDataType::Int16) => {
native::read_i16(sample_bytes).is_some_and(|v| v as f64 == decl)
}
(SignedInteger, RasterDataType::Int32) => {
native::read_i32(sample_bytes).is_some_and(|v| v as f64 == decl)
}
(SignedInteger, RasterDataType::Int64) => {
native::read_i64(sample_bytes).is_some_and(|v| v as f64 == decl)
}
(IeeeFloatingPoint, RasterDataType::Float32) => {
native::read_f32(sample_bytes).is_some_and(|v| {
if (decl as f32).is_nan() {
return v.is_nan();
}
(v - decl as f32).abs() <= validator.float_eps_f32
})
}
(IeeeFloatingPoint, RasterDataType::Float64) => {
native::read_f64(sample_bytes).is_some_and(|v| {
if decl.is_nan() {
return v.is_nan();
}
(v - decl).abs() <= validator.float_eps_f64
})
}
_ => false,
}
}
#[cfg(test)]
pub(crate) fn mask_from_bools(bits: Vec<bool>) -> NoDataBandMask {
let total = bits.len() as u64;
let count = bits.iter().filter(|b| **b).count() as u64;
NoDataBandMask {
bitmap: bits,
total,
nodata_count: count,
suspected_unmarked_nodata: false,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
#![allow(clippy::unwrap_used)]
#![allow(clippy::float_cmp)]
use super::*;
fn validator() -> NoDataValidator {
NoDataValidator::new()
}
#[test]
fn test_nodata_consistent_across_bands_passes() {
let v = validator();
let mask_a = mask_from_bools(vec![true, false, false, true]);
let mask_b = mask_from_bools(vec![true, false, false, true]);
let result = v.evaluate_masks(vec![mask_a, mask_b], vec![Some(-9999.0), Some(-9999.0)]);
assert!(
!result.issues.iter().any(|i| i.severity >= Severity::Major),
"no Major issues expected; got {:#?}",
result.issues
);
assert_eq!(result.common_footprint_count, 2);
}
#[test]
fn test_nodata_metadata_without_pixels_warns() {
let v = validator();
let mask = mask_from_bools(vec![false, false, false, false]);
let result = v.evaluate_masks(vec![mask], vec![Some(-9999.0)]);
assert!(
result.issues.iter().any(|i| i.severity == Severity::Warning
&& i.rule_id.as_deref() == Some("NODATA-METADATA-WITHOUT-PIXELS")),
"expected Warning, got {:#?}",
result.issues
);
}
#[test]
fn test_nodata_pixels_without_metadata_majors() {
let v = validator();
let mut mask = mask_from_bools(vec![true, true, false, false]);
mask.suspected_unmarked_nodata = true;
let result = v.evaluate_masks(vec![mask], vec![None]);
assert!(
result.issues.iter().any(|i| i.severity == Severity::Major
&& i.rule_id.as_deref() == Some("NODATA-PIXELS-WITHOUT-METADATA")),
"expected Major, got {:#?}",
result.issues
);
}
#[test]
fn test_nodata_values_differ_majors() {
let v = validator();
let mask_a = mask_from_bools(vec![true, false]);
let mask_b = mask_from_bools(vec![false, true]);
let result = v.evaluate_masks(vec![mask_a, mask_b], vec![Some(-9999.0), Some(0.0)]);
assert!(
result.issues.iter().any(|i| i.severity == Severity::Major
&& i.rule_id.as_deref() == Some("NODATA-VALUES-DIFFER")),
"expected Major, got {:#?}",
result.issues
);
}
#[test]
fn test_nodata_common_footprint_outlier_warns() {
let v = validator();
let mask_a = mask_from_bools(vec![true, true, true, true, true, false, false, false]);
let mask_b = mask_from_bools(vec![true, true, true, true, true, false, false, false]);
let mask_c = mask_from_bools(vec![false, false, false, false, false, false, false, false]);
let result = v.evaluate_masks(
vec![mask_a, mask_b, mask_c],
vec![Some(-9999.0), Some(-9999.0), Some(-9999.0)],
);
assert!(
result
.issues
.iter()
.any(|i| i.rule_id.as_deref() == Some("NODATA-COMMON-FOOTPRINT-OUTLIER")),
"expected outlier Warning, got {:#?}",
result.issues
);
}
#[test]
fn test_nodata_float_eps_tolerance() {
let pixel: f32 = -9998.99;
let bytes = pixel.to_ne_bytes();
let lenient = NoDataValidator::new().with_float_eps_f32(0.1);
assert!(
matches_nodata(
&bytes,
RasterDataType::Float32,
SampleFormat::IeeeFloatingPoint,
Some(-9999.0),
&lenient,
),
"f32 pixel within lenient eps should match"
);
let strict = NoDataValidator::new(); assert!(
!matches_nodata(
&bytes,
RasterDataType::Float32,
SampleFormat::IeeeFloatingPoint,
Some(-9999.0),
&strict,
),
"outside strict eps should miss"
);
let nan_pixel: f32 = f32::NAN;
let nan_bytes = nan_pixel.to_ne_bytes();
let nan_match = matches_nodata(
&nan_bytes,
RasterDataType::Float32,
SampleFormat::IeeeFloatingPoint,
Some(f64::NAN),
&strict,
);
assert!(nan_match, "NaN pixel matches NaN sentinel");
}
#[test]
fn test_nodata_truncated_sample_does_not_match() {
let strict = NoDataValidator::new();
for dtype in [
RasterDataType::UInt16,
RasterDataType::Int32,
RasterDataType::Float64,
] {
let fmt = match dtype {
RasterDataType::UInt16 => SampleFormat::UnsignedInteger,
RasterDataType::Int32 => SampleFormat::SignedInteger,
_ => SampleFormat::IeeeFloatingPoint,
};
assert!(
!matches_nodata(&[0u8], dtype, fmt, Some(0.0), &strict),
"{dtype:?}: a one-byte slice cannot hold a sample"
);
}
}
}