1use crate::error::AgResult;
5use crate::io::CogReader;
6use ndarray::{Array3, Axis};
7
8#[derive(Debug, Clone, Copy, PartialEq)]
10pub enum Provenance {
11 Exact,
13 Embedded,
15 Estimated,
17}
18
19#[derive(Debug, Clone)]
21pub struct Histogram {
22 pub min: f64,
24 pub max: f64,
26 pub counts: Vec<u64>,
29}
30
31impl Histogram {
32 pub fn quantile(&self, p: f64) -> f64 {
39 let total: u128 = self.counts.iter().map(|&count| u128::from(count)).sum();
40 if total == 0 || self.max <= self.min || self.counts.is_empty() {
41 return self.min;
42 }
43 let target = (p / 100.0) * total as f64;
44 let bin_w = (self.max - self.min) / self.counts.len() as f64;
45 if p == 100.0 {
46 return self
49 .counts
50 .iter()
51 .rposition(|&count| count != 0)
52 .map_or(self.min, |i| self.min + (i + 1) as f64 * bin_w);
53 }
54 let mut cum = 0u128;
55 for (i, &c) in self.counts.iter().enumerate() {
56 let next = cum + u128::from(c);
57 if (next as f64) >= target {
58 let into = if c == 0 {
60 0.0
61 } else {
62 (target - cum as f64) / c as f64
63 };
64 return self.min + (i as f64 + into) * bin_w;
65 }
66 cum = next;
67 }
68 self.max
69 }
70}
71
72#[derive(Debug, Clone)]
74pub struct BandStat {
75 pub min: f64,
77 pub max: f64,
79 pub mean: f64,
81 pub stddev: f64,
83 pub hist: Histogram,
85}
86
87#[derive(Debug, Clone)]
89pub struct DatasetStats {
90 pub bands: Vec<BandStat>,
94 pub provenance: Provenance,
96}
97
98pub fn band_stats_from_f64(
103 arr: &Array3<f64>,
104 nodata: Option<f64>,
105 hist_bins: usize,
106) -> Vec<BandStat> {
107 let bins = hist_bins.max(1);
108 let valid = |v: f64| {
110 if !v.is_finite() {
111 return false;
112 }
113 match nodata {
114 Some(nd) => nd.is_nan() || (v - nd).abs() >= 1e-9,
115 None => true,
116 }
117 };
118 let mut out = Vec::with_capacity(arr.shape()[0]);
119 for b in 0..arr.shape()[0] {
120 let band = arr.index_axis(Axis(0), b);
121 let mut min = f64::INFINITY;
122 let mut max = f64::NEG_INFINITY;
123 let mut mean = 0.0_f64;
124 let mut m2 = 0.0_f64;
125 let mut count = 0_u64;
126 for &v in band.iter() {
127 if !valid(v) {
128 continue;
129 }
130 min = min.min(v);
131 max = max.max(v);
132 count += 1;
133 let delta = v - mean;
134 mean += delta / count as f64;
135 m2 += delta * (v - mean);
136 }
137 if count == 0 {
138 out.push(BandStat {
139 min: 0.0,
140 max: 1.0,
141 mean: 0.0,
142 stddev: 0.0,
143 hist: Histogram {
144 min: 0.0,
145 max: 1.0,
146 counts: vec![0; bins],
147 },
148 });
149 continue;
150 }
151 let var = m2 / count as f64;
152 let stddev = var.max(0.0).sqrt();
153 let mut counts = vec![0u64; bins];
154 let span = (max - min).max(1e-12);
155 for &v in band.iter() {
156 if !valid(v) {
157 continue;
158 }
159 let mut idx = (((v - min) / span) * bins as f64) as usize;
160 if idx >= bins {
161 idx = bins - 1;
162 }
163 counts[idx] += 1;
164 }
165 out.push(BandStat {
166 min,
167 max,
168 mean,
169 stddev,
170 hist: Histogram { min, max, counts },
171 });
172 }
173 out
174}
175
176pub fn parse_embedded_stats(
184 gdal_metadata: Option<&str>,
185 smin: Option<&[u16]>,
186 smax: Option<&[u16]>,
187 bands: usize,
188) -> Option<DatasetStats> {
189 fn items(xml: &str) -> Vec<(String, usize, String)> {
190 use quick_xml::events::Event;
191
192 let mut reader = quick_xml::Reader::from_str(xml);
193 reader.config_mut().trim_text(true);
194 let mut out = Vec::new();
195 let mut current: Option<(String, usize)> = None;
196
197 loop {
198 match reader.read_event() {
199 Ok(Event::Start(e)) if e.name().as_ref() == b"Item" => {
200 let mut name = String::new();
201 let mut sample = 0usize;
202 for attr in e.attributes().flatten() {
203 let Ok(value) = attr.decode_and_unescape_value(reader.decoder()) else {
204 continue;
205 };
206 match attr.key.as_ref() {
207 b"name" => name = value.into_owned(),
208 b"sample" => sample = value.parse().unwrap_or(0),
209 _ => {}
210 }
211 }
212 current = (!name.is_empty()).then_some((name, sample));
213 }
214 Ok(Event::Text(e)) => {
215 if let Some((name, sample)) = current.take() {
216 if let Ok(value) = e.decode() {
217 out.push((name, sample, value.trim().to_string()));
218 }
219 }
220 }
221 Ok(Event::End(e)) if e.name().as_ref() == b"Item" => {
222 current = None;
223 }
224 Ok(Event::Eof) => break,
225 Err(_) => break,
226 _ => {}
227 }
228 }
229 out
230 }
231
232 let parsed = gdal_metadata.map(items).unwrap_or_default();
233 let get = |name: &str, b: usize| -> Option<f64> {
234 parsed
235 .iter()
236 .find(|(n, s, _)| n == name && *s == b)
237 .and_then(|(_, _, v)| v.parse::<f64>().ok())
238 };
239
240 let mut out = Vec::with_capacity(bands);
241 for b in 0..bands {
242 let min = get("STATISTICS_MINIMUM", b)
243 .or_else(|| smin.and_then(|s| s.get(b)).map(|&x| x as f64))?;
244 let max = get("STATISTICS_MAXIMUM", b)
245 .or_else(|| smax.and_then(|s| s.get(b)).map(|&x| x as f64))?;
246 let mean = get("STATISTICS_MEAN", b).unwrap_or((min + max) / 2.0);
247 let stddev = get("STATISTICS_STDDEV", b).unwrap_or((max - min) / 4.0);
248 let hist = {
249 let hmin = get("STATISTICS_HISTOMIN", b).unwrap_or(min);
250 let hmax = get("STATISTICS_HISTOMAX", b).unwrap_or(max);
251 let counts: Vec<u64> = parsed
252 .iter()
253 .find(|(n, s, _)| n == "STATISTICS_HISTOBINS" && *s == b)
254 .map(|(_, _, v)| {
255 v.split(|c: char| c == ',' || c.is_whitespace())
256 .filter(|t| !t.is_empty())
257 .filter_map(|t| t.parse::<u64>().ok())
258 .collect()
259 })
260 .filter(|c: &Vec<u64>| !c.is_empty())
261 .unwrap_or_else(|| vec![1]);
262 Histogram {
263 min: hmin,
264 max: hmax,
265 counts,
266 }
267 };
268 out.push(BandStat {
269 min,
270 max,
271 mean,
272 stddev,
273 hist,
274 });
275 }
276 Some(DatasetStats {
277 bands: out,
278 provenance: Provenance::Embedded,
279 })
280}
281
282const HIST_BINS: usize = 256;
283
284pub(crate) fn band_stats_from_image(
286 image: &crate::pixel::ImageData,
287 hist_bins: usize,
288) -> Vec<BandStat> {
289 let mut arr = image.pixels.as_f64();
290 if let Some(mask) = &image.mask {
291 for mut band in arr.to_mut().axis_iter_mut(Axis(0)) {
292 ndarray::Zip::from(&mut band)
293 .and(mask)
294 .for_each(|value, &valid| {
295 if !valid {
296 *value = f64::NAN;
297 }
298 });
299 }
300 }
301 band_stats_from_f64(&arr, image.nodata, hist_bins)
302}
303
304pub async fn load_dataset_stats(reader: &CogReader) -> AgResult<Option<DatasetStats>> {
316 {
319 let ifd = reader.data_ifd(0)?;
320 let bands = ifd.samples_per_pixel() as usize;
321 if let Some(exact) = parse_embedded_stats(
322 ifd.gdal_metadata(),
323 ifd.min_sample_value(),
324 ifd.max_sample_value(),
325 bands,
326 ) {
327 return Ok(Some(exact));
328 }
329 }
330
331 let top = reader.level_count().saturating_sub(1);
333 if top == 0 {
334 return Ok(None);
335 }
336 let (w, h) = reader.level_dimensions(top)?;
337 const MAX_OVERVIEW_PIXELS: u64 = 16 * 1024 * 1024;
341 if w as u64 * h as u64 > MAX_OVERVIEW_PIXELS {
342 return Ok(None);
343 }
344 let bands = u64::from(reader.data_ifd(top)?.samples_per_pixel());
345 let estimate = u64::from(w)
346 .checked_mul(u64::from(h))
347 .and_then(|n| n.checked_mul(bands.saturating_mul(16).saturating_add(2)))
348 .ok_or_else(|| {
349 crate::error::AgError::ResourceLimit("statistics work estimate overflow".into())
350 })?;
351 let work = reader.work_budget().try_reserve(estimate)?;
352 let img = reader.read_window(top, 0, 0, w as u64, h as u64).await?;
353 let permit = reader.cpu_limiter().acquire().await?;
354 let request = crate::request::RequestContext::current();
355 let stats = tokio::task::spawn_blocking(move || {
356 let _permit = permit;
357 let _work = work;
358 crate::request::RequestContext::run_blocking(request, || {
359 Ok(band_stats_from_image(&img, HIST_BINS))
360 })
361 })
362 .await
363 .map_err(|e| crate::error::AgError::Warp(format!("statistics task: {e}")))??;
364 Ok(Some(DatasetStats {
365 bands: stats,
366 provenance: Provenance::Estimated,
367 }))
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn quantile_linear_interpolates() {
376 let h = Histogram {
378 min: 0.0,
379 max: 4.0,
380 counts: vec![10, 10, 10, 10],
381 };
382 assert!((h.quantile(50.0) - 2.0).abs() < 1e-9);
383 assert!((h.quantile(0.0) - 0.0).abs() < 1e-9);
384 assert!((h.quantile(100.0) - 4.0).abs() < 1e-9);
385 }
386
387 #[test]
388 fn quantile_accumulates_public_counts_beyond_u64() {
389 let h = Histogram {
390 min: 0.0,
391 max: 4.0,
392 counts: vec![u64::MAX; 4],
393 };
394 for (percentile, expected) in [
395 (0.0, 0.0),
396 (25.0, 1.0),
397 (50.0, 2.0),
398 (75.0, 3.0),
399 (100.0, 4.0),
400 ] {
401 assert!((h.quantile(percentile) - expected).abs() < 1e-9);
402 }
403 }
404
405 #[test]
406 fn quantile_handles_embedded_counts_beyond_u64() {
407 let xml = r#"<GDALMetadata>
408<Item name="STATISTICS_MINIMUM" sample="0">0</Item>
409<Item name="STATISTICS_MAXIMUM" sample="0">1</Item>
410<Item name="STATISTICS_HISTOMIN" sample="0">0</Item>
411<Item name="STATISTICS_HISTOMAX" sample="0">1</Item>
412<Item name="STATISTICS_HISTONUMBINS" sample="0">2</Item>
413<Item name="STATISTICS_HISTOBINS" sample="0">18446744073709551615,1</Item>
414</GDALMetadata>"#;
415 let stats = parse_embedded_stats(Some(xml), None, None, 1).unwrap();
416 assert_eq!(stats.bands[0].hist.counts, vec![u64::MAX, 1]);
417 assert!((stats.bands[0].hist.quantile(50.0) - 0.25).abs() < 1e-9);
418 assert_eq!(stats.bands[0].hist.quantile(100.0), 1.0);
419 }
420
421 #[test]
422 fn quantile_upper_endpoint_keeps_small_counts_and_excludes_empty_tail() {
423 for counts in [
424 vec![u64::MAX, 1],
425 vec![1, u64::MAX],
426 vec![u64::MAX, 1, 0],
427 vec![1, u64::MAX, 0, 0],
428 vec![1, 1, 0],
429 ] {
430 let histogram = Histogram {
431 min: 0.0,
432 max: counts.len() as f64,
433 counts,
434 };
435 assert_eq!(histogram.quantile(100.0), 2.0);
436 }
437 }
438
439 #[test]
440 fn quantile_skewed() {
441 let h = Histogram {
443 min: 0.0,
444 max: 100.0,
445 counts: vec![98, 1, 1, 0],
446 };
447 assert!(h.quantile(2.0) < 25.0);
448 assert!(h.quantile(98.0) >= 25.0);
449 }
450
451 #[test]
452 fn quantile_degenerate_returns_min() {
453 let h = Histogram {
454 min: 5.0,
455 max: 5.0,
456 counts: vec![0, 0],
457 };
458 assert_eq!(h.quantile(50.0), 5.0);
459 }
460
461 #[test]
462 fn kernel_basic_stats_and_nodata_exclusion() {
463 use ndarray::Array3;
464 let arr = Array3::from_shape_vec((1, 1, 4), vec![0.0, 2.0, 4.0, 6.0]).unwrap();
466 let s = band_stats_from_f64(&arr, Some(0.0), 8);
467 assert_eq!(s.len(), 1);
468 assert_eq!(s[0].min, 2.0);
469 assert_eq!(s[0].max, 6.0);
470 assert!((s[0].mean - 4.0).abs() < 1e-9);
471 assert!((s[0].stddev - (8.0_f64 / 3.0).sqrt()).abs() < 1e-9);
473 }
474
475 #[test]
476 fn kernel_all_nodata_is_safe() {
477 use ndarray::Array3;
478 let arr = Array3::from_shape_vec((1, 1, 2), vec![0.0, 0.0]).unwrap();
479 let s = band_stats_from_f64(&arr, Some(0.0), 4);
480 assert_eq!(s[0].min, 0.0);
481 assert_eq!(s[0].max, 1.0);
482 }
483
484 #[test]
485 fn nan_nodata_retains_finite_samples() {
486 let arr = Array3::from_shape_vec((1, 1, 4), vec![10.0, 20.0, 30.0, f64::NAN]).unwrap();
487 let s = band_stats_from_f64(&arr, Some(f64::NAN), 4);
488 assert_eq!((s[0].min, s[0].max, s[0].mean), (10.0, 30.0, 20.0));
489 assert_eq!(s[0].hist.counts.iter().sum::<u64>(), 3);
490 }
491
492 #[test]
493 fn image_stats_exclude_masked_outliers() {
494 let image = crate::pixel::ImageData {
495 pixels: crate::pixel::PixelData::F64(
496 Array3::from_shape_vec((1, 1, 3), vec![10.0, 20.0, 9999.0]).unwrap(),
497 ),
498 mask: Some(ndarray::array![[true, true, false]]),
499 bbox: crate::bounds::Bbox::new(0.0, 0.0, 3.0, 1.0),
500 crs: crate::crs::Crs::epsg(4326),
501 transform: crate::bounds::GeoTransform::from_gdal(&[0.0, 1.0, 0.0, 1.0, 0.0, -1.0]),
502 nodata: None,
503 };
504 let stats = band_stats_from_image(&image, 4);
505 assert_eq!(
506 (stats[0].min, stats[0].max, stats[0].mean),
507 (10.0, 20.0, 15.0)
508 );
509 assert_eq!(stats[0].hist.counts.iter().sum::<u64>(), 2);
510 }
511
512 #[test]
513 fn masked_stats_do_not_mutate_borrowed_strided_f64_pixels() {
514 let pixels = ndarray::array![[[1.0, 9999.0], [3.0, 5.0]]].permuted_axes([0, 2, 1]);
515 let image = crate::pixel::ImageData {
516 pixels: crate::pixel::PixelData::F64(pixels),
517 mask: Some(ndarray::array![[true, true], [false, true]]),
518 bbox: crate::bounds::Bbox::new(0.0, 0.0, 2.0, 2.0),
519 crs: crate::crs::Crs::epsg(4326),
520 transform: crate::bounds::GeoTransform::from_gdal(&[0.0, 1.0, 0.0, 2.0, 0.0, -1.0]),
521 nodata: None,
522 };
523 let stats = band_stats_from_image(&image, 4);
524 assert_eq!((stats[0].min, stats[0].max, stats[0].mean), (1.0, 5.0, 3.0));
525 assert_eq!(stats[0].hist.counts.iter().sum::<u64>(), 3);
526 assert_eq!(image.pixels.as_f64()[[0, 1, 0]], 9999.0);
527 }
528
529 #[test]
530 fn variance_retains_small_spread_at_large_offset() {
531 let arr = Array3::from_shape_vec((1, 1, 3), vec![1e12, 1e12 + 2.0, 1e12 + 4.0]).unwrap();
532 let s = band_stats_from_f64(&arr, None, 4);
533 assert!((s[0].stddev - (8.0_f64 / 3.0).sqrt()).abs() < 1e-9);
534 }
535
536 #[test]
537 fn parses_gdal_metadata_per_band() {
538 let xml = r#"<GDALMetadata>
539<Item name="STATISTICS_MINIMUM" sample="0">79</Item>
540<Item name="STATISTICS_MAXIMUM" sample="0">1896</Item>
541<Item name="STATISTICS_MEAN" sample="0">565</Item>
542<Item name="STATISTICS_STDDEV" sample="0">45</Item>
543<Item name="STATISTICS_MINIMUM" sample="1">74</Item>
544<Item name="STATISTICS_MAXIMUM" sample="1">2213</Item>
545</GDALMetadata>"#;
546 let s = parse_embedded_stats(Some(xml), None, None, 2).unwrap();
547 assert_eq!(s.provenance, Provenance::Embedded);
548 assert_eq!(s.bands[0].min, 79.0);
549 assert_eq!(s.bands[0].max, 1896.0);
550 assert_eq!(s.bands[0].mean, 565.0);
551 assert_eq!(s.bands[1].min, 74.0);
552 assert!((s.bands[1].mean - (74.0 + 2213.0) / 2.0).abs() < 1e-9);
554 }
555
556 #[test]
557 fn falls_back_to_sminmax_then_none() {
558 let s = parse_embedded_stats(None, Some(&[10, 20]), Some(&[100, 200]), 2).unwrap();
560 assert_eq!(s.bands[0].min, 10.0);
561 assert_eq!(s.bands[1].max, 200.0);
562 assert!(parse_embedded_stats(None, None, None, 1).is_none());
564 assert!(parse_embedded_stats(Some("<not xml"), None, None, 1).is_none());
566 }
567
568 #[test]
569 fn inferred_embedded_fields_are_not_claimed_exact() {
570 let stats = parse_embedded_stats(None, Some(&[10]), Some(&[100]), 1).unwrap();
571 assert_ne!(stats.provenance, Provenance::Exact);
572 }
573
574 #[tokio::test]
577 #[ignore = "requires local data/SVN3-..._cog.tif"]
578 async fn estimated_stats_from_overview() {
579 use object_store::local::LocalFileSystem;
580 use std::sync::Arc;
581
582 let fs = Arc::new(LocalFileSystem::new_with_prefix(".").unwrap());
583 let reader = crate::io::CogReader::open_object_store(
584 fs,
585 "data/SVN3-01_20250814_L2D0000203064_01_6112500050210001-FUS_3857_cog.tif",
586 )
587 .await
588 .unwrap();
589 let s = load_dataset_stats(&reader).await.unwrap().unwrap();
590 assert_eq!(s.provenance, Provenance::Estimated);
591 assert_eq!(s.bands.len(), 3);
592 assert!(s.bands[0].min >= 0.0 && s.bands[0].max < 65535.0);
593 assert!(s.bands[0].max > s.bands[0].min);
594 }
595}