Skip to main content

async_geotiff/io/
stats.rs

1//! Dataset band statistics: embedded `STATISTICS_*` tags (unverified precision) or a
2//! one-shot top-overview computation (estimated).
3
4use crate::error::AgResult;
5use crate::io::CogReader;
6use ndarray::{Array3, Axis};
7
8/// How a statistics set was obtained; embedded or sampled values may be approximate.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub enum Provenance {
11    /// Computed from every valid sample in the supplied domain.
12    Exact,
13    /// Read from metadata; may contain approximate or inferred fields.
14    Embedded,
15    /// Computed from a reduced-resolution overview or a subset of samples.
16    Estimated,
17}
18
19/// Equal-width histogram over `[min, max]`. `counts.len()` is the bin count.
20#[derive(Debug, Clone)]
21pub struct Histogram {
22    /// Lower edge of the first bin, in raw sample units.
23    pub min: f64,
24    /// Upper edge of the final bin, in raw sample units.
25    pub max: f64,
26    /// Counts in ascending equal-width bins. Empty or all-zero counts give
27    /// [`Self::min`] when querying a quantile; their sum may exceed `u64::MAX`.
28    pub counts: Vec<u64>,
29}
30
31impl Histogram {
32    /// Value at percentile `p` (0..=100) via linear interpolation on the
33    /// cumulative count. Robust to the overview-smoothing approximation
34    /// of a sampled overview. Empty / degenerate → `min`.
35    /// Counts may sum beyond `u64::MAX`; accumulation uses `u128` and only
36    /// the interpolation converts to f64. The 100th percentile is the upper
37    /// edge of the last nonempty bin, excluding any trailing zero-count bins.
38    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            // Distinct integer totals can round to the same f64 near u64::MAX.
47            // Locate the endpoint without losing a small final nonempty bin.
48            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                // Fraction into this bin.
59                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/// Summary of one band's valid raw samples, or values inferred from its metadata.
73#[derive(Debug, Clone)]
74pub struct BandStat {
75    /// Minimum sample or metadata minimum; an empty computed band uses `0.0`.
76    pub min: f64,
77    /// Maximum sample or metadata maximum; an empty computed band uses `1.0`.
78    pub max: f64,
79    /// Arithmetic mean, or a metadata fallback estimate.
80    pub mean: f64,
81    /// Population standard deviation, or a metadata fallback estimate.
82    pub stddev: f64,
83    /// Sample distribution used for approximate percentile interpolation.
84    pub hist: Histogram,
85}
86
87/// Per-band statistics together with their shared provenance.
88#[derive(Debug, Clone)]
89pub struct DatasetStats {
90    /// Band order of the producing operation: embedded metadata follows native
91    /// TIFF sample order; sampled statistics follow the decoded image bands,
92    /// which may omit Alpha promoted to a validity mask.
93    pub bands: Vec<BandStat>,
94    /// Whether the values were computed exactly, read from metadata or estimated.
95    pub provenance: Provenance,
96}
97
98/// Per-band stats from a planar `(bands, H, W)` f64 array, excluding
99/// `nodata` and non-finite samples. `hist_bins` is the histogram resolution,
100/// clamped to at least one bin. An empty band has min/max `0.0`/`1.0`, zero
101/// mean and deviation, and zero bin counts.
102pub 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    // A sample counts toward stats only if finite and not nodata.
109    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
176/// Parse embedded stats from a COG's GDAL_METADATA XML, falling back to
177/// TIFF `SMin/SMaxSampleValue`. Returns `None` when neither yields a
178/// usable min/max for every band. Histogram bounds come from
179/// `STATISTICS_HISTOMIN/HISTOMAX`; `STATISTICS_HISTOBINS` supplies comma- or
180/// whitespace-separated counts. Absent or unusable counts produce a single-bin
181/// placeholder. `STATISTICS_HISTONUMBINS` is not used; bin count follows the
182/// successfully parsed values.
183pub 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
284/// Statistics over the same valid samples carried by an image.
285pub(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
304/// Read embedded `STATISTICS_*` tags or estimate statistics from the smallest
305/// overview. Embedded values retain native TIFF band order; estimates follow
306/// [`CogReader::read_window`] output band order and its mask semantics.
307///
308/// Returns `Ok(None)` when no complete embedded statistics are available and
309/// the COG has no overview, or the smallest overview exceeds 16,777,216 pixels.
310/// Sampled statistics use the reader's CPU limiter and working-memory budget.
311///
312/// # Errors
313/// Propagates metadata, I/O, decoding, window-limit and resource-limit errors.
314/// When called inside a request, its cancellation and deadline also apply.
315pub async fn load_dataset_stats(reader: &CogReader) -> AgResult<Option<DatasetStats>> {
316    // 1) Embedded metadata path: tags on the full-res IFD. Scope the IFD
317    //    borrow so it ends before the later `read_window` call.
318    {
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    // 2) Estimated path: read the highest overview in full.
332    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    // Estimated path must stay a small read (spec: ≈ top overview only).
338    // If the only/highest overview is unexpectedly large, skip rather than
339    // spike memory; caller falls back to stretch::fallback_rescale.
340    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        // 4 bins over [0,4], 10 counts each. p=50 → midpoint = 2.0.
377        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        // Mass concentrated in first bin: p=2 stays near min.
442        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        // 1 band, 1x4: values 0(nodata), 2, 4, 6 → over {2,4,6}.
465        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        // population stddev of {2,4,6} = sqrt(8/3) ≈ 1.6329931619
472        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        // band 1 has no MEAN tag → midpoint fallback
553        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        // No XML, but SMin/SMax tags present.
559        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        // Nothing at all → None.
563        assert!(parse_embedded_stats(None, None, None, 1).is_none());
564        // Malformed XML → None (no usable min/max).
565        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    // Exercises the estimated (overview) path against a local fixture.
575    // Ignored by default: requires a multi-overview UInt16 COG on disk.
576    #[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}