diff --git a/crates/data/src/raster/mod.rs b/crates/data/src/raster/mod.rs index 5335a5158..cf8c660e8 100644 --- a/crates/data/src/raster/mod.rs +++ b/crates/data/src/raster/mod.rs @@ -211,6 +211,19 @@ pub struct Band { pub name: String, pub data: Vec, pub stats: BandStats, + /// The names of the band's classes, by value, from GDAL's `.aux.xml` sidecar (see + /// [`Band::class_name`]). Empty when the band names no class. + pub classes: Vec, +} + +impl Band { + /// The name of the class of value `v`: `None` when `v` isn't a whole number or has no name. + pub fn class_name(&self, v: f64) -> Option<&str> { + if v < 0.0 || v.fract() != 0.0 { + return None; + } + self.classes.get(v as usize).map(String::as_str).filter(|s| !s.is_empty()) + } } /// How a raster's bands were meant to be shown, from the file. @@ -292,7 +305,7 @@ impl Raster { let bands = bands .into_iter() .enumerate() - .map(|(i, data)| Band { name: format!("Band {}", i + 1), stats: BandStats::compute(&data, valid), data }) + .map(|(i, data)| Band { name: format!("Band {}", i + 1), stats: BandStats::compute(&data, valid), data, classes: Vec::new() }) .collect(); let mut r = Raster { name: name.into(), @@ -318,8 +331,9 @@ impl Raster { } /// Applies what GDAL's `.aux.xml` sidecar says about the bands: their descriptions name them, - /// its no-data value applies when the file has none, and its statistics stand in for those of - /// a reduced overview (the whole band's, where the overview's only approximate them). + /// their category names name their classes, its no-data value applies when the file has none, + /// and its statistics stand in for those of a reduced overview (the whole band's, where the + /// overview's only approximate them). pub fn apply_pam(&mut self, pam: &pam::Pam) { let mut recompute = false; if self.nodata.is_none() { @@ -334,6 +348,9 @@ impl Raster { if let Some(d) = p.and_then(|p| p.description.as_ref()) { band.name = d.clone(); } + if let Some(p) = p.filter(|p| !p.categories.is_empty()) { + band.classes = p.categories.clone(); + } let stored = p.and_then(|p| p.stats).filter(|_| overview); if recompute || stored.is_some() { band.stats = BandStats::compute_with(&band.data, valid, stored); @@ -887,6 +904,29 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn gdal_sidecars_name_classes() { + let dir = std::env::temp_dir().join(format!("rustgis-pam-classes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let gt = GeoTransform { origin_x: 10.0, origin_y: 50.0, pixel_width: 0.5, pixel_height: -0.5 }; + let r = Raster::new("slope", 2, 2, vec![vec![1.0, 2.0, 3.0, 1.0]], gt, Crs::LonLat, None, ColorHint::Gray, "UInt8").unwrap(); + let tif = dir.join("slope.tif"); + std::fs::write(&tif, geotiff::write(&r, 0).unwrap()).unwrap(); + let xml = r#" +no data0-5 % flatover 100 % +"#; + std::fs::write(dir.join("slope.tif.aux.xml"), xml).unwrap(); + let back = read_path(&tif).unwrap(); + let b = &back.bands[0]; + assert_eq!(b.classes, ["no data", "0-5 % flat", "", "over 100 %"]); + assert_eq!((b.class_name(1.0), b.class_name(3.0)), (Some("0-5 % flat"), Some("over 100 %"))); + for v in [2.0, 4.0, 1.5, -1.0, f64::NAN] { + assert_eq!(b.class_name(v), None, "{v}"); + } + assert!(small().bands[0].classes.is_empty(), "no sidecar, no names"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn sidecar_statistics_stand_in_for_an_overview() { let pam = pam::Pam { diff --git a/crates/data/src/raster/pam.rs b/crates/data/src/raster/pam.rs index 6e6d32fdc..306383195 100644 --- a/crates/data/src/raster/pam.rs +++ b/crates/data/src/raster/pam.rs @@ -1,11 +1,16 @@ //! GDAL's auxiliary metadata (PAM): the `.aux.xml` file GDAL writes next to a raster to hold what -//! the file itself doesn't, such as band descriptions, no-data values and computed statistics. +//! the file itself doesn't, such as band descriptions, no-data values, class names and computed +//! statistics. //! //! ```xml //! //! //! Blue //! 0 +//! +//! no data +//! water +//! //! //! 12 //! 2400 @@ -22,6 +27,8 @@ use roxmltree::ParsingOptions; const MAX_BYTES: u64 = 16 << 20; /// Most bands described. const MAX_BANDS: usize = 65_536; +/// Most class names read for a band: one for each value of a 16-bit band. +const MAX_CATEGORIES: usize = 65_536; /// Statistics GDAL stored for a band (`STATISTICS_*`). #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -40,6 +47,9 @@ pub struct PamBand { pub description: Option, pub nodata: Option, pub stats: Option, + /// The names of the band's classes, by value (GDAL's category names): `categories[v]` names + /// value `v`, and is empty when that value has none. Empty when the band names no class. + pub categories: Vec, } /// What a sidecar says about a raster's bands. @@ -113,7 +123,19 @@ pub fn parse(text: &str) -> Option { } _ => None, }; - pam.bands.push(PamBand { band, description, nodata, stats }); + let mut categories: Vec = b + .children() + .find(|n| n.has_tag_name("CategoryNames")) + .into_iter() + .flat_map(|c| c.children().filter(|n| n.has_tag_name("Category"))) + .take(MAX_CATEGORIES) + .map(|n| n.text().map(str::trim).unwrap_or_default().chars().take(256).collect()) + .collect(); + // Values past the last named one have no name either. + while categories.last().is_some_and(String::is_empty) { + categories.pop(); + } + pam.bands.push(PamBand { band, description, nodata, stats, categories }); } Some(pam) } @@ -191,6 +213,34 @@ mod tests { assert_eq!(differ.nodata(), None, "bands disagree"); } + #[test] + fn reads_class_names() { + // As GDAL writes them (SetCategoryNames): one per value from 0, empty for a value with no + // name. + let xml = r#" + + + no data + 0-5 % flat + + over 100 % (over 45°) + + + + + none +"#; + let pam = parse(xml).unwrap(); + assert_eq!(pam.band(1).unwrap().categories, ["no data", "0-5 % flat", "", "over 100 % (over 45°)"]); + assert!(pam.band(2).unwrap().categories.is_empty(), "no value is named"); + assert!(pam.band(3).unwrap().categories.is_empty()); + let long = format!( + "{}", + "x".repeat(1000) + ); + assert_eq!(parse(&long).unwrap().band(1).unwrap().categories[0].len(), 256); + } + #[test] fn junk_is_not_a_sidecar() { for text in ["", "not xml", "", "", ""] { diff --git a/crates/map/src/raster.rs b/crates/map/src/raster.rs index ba528a239..ae357e7b1 100644 --- a/crates/map/src/raster.rs +++ b/crates/map/src/raster.rs @@ -1026,6 +1026,8 @@ impl RasterLayer { pub fn legend(&self) -> Vec { let Some(r) = &self.data else { return Vec::new() }; let band_name = |b: usize| r.bands.get(b).map(|b| b.name.clone()).unwrap_or_default(); + // A class's name in the file (GDAL's category names), or else its value. + let class_label = |b: usize, v: f64| r.bands.get(b).and_then(|b| b.class_name(v)).map_or_else(|| fmt_num(v), str::to_owned); // The first renderer band's statistics, without the masked pixels. let masked = self.masked_stats(); let stats = |band: usize| masked.as_ref().and_then(|m| m.first()).or_else(|| r.bands.get(band).map(|b| &b.stats)); @@ -1060,17 +1062,17 @@ impl RasterLayer { }) .collect() } - RasterRenderer::UniqueValues { values, .. } => values + RasterRenderer::UniqueValues { band, values, .. } => values .iter() .filter(|(v, _)| !mask.hides(*v as f32)) - .map(|(v, c)| RasterLegend::Class { color: *c, label: fmt_num(*v) }) + .map(|(v, c)| RasterLegend::Class { color: *c, label: class_label(*band, *v) }) .collect(), RasterRenderer::Colormap => { let ColorHint::Palette(p) = &r.color else { return Vec::new() }; let cats = r.bands.first().and_then(|b| b.stats.categories.clone()).unwrap_or_default(); cats.iter() .filter(|v| !mask.hides(**v as f32)) - .filter_map(|v| p.get(*v as usize).map(|c| RasterLegend::Class { color: Rgba(*c), label: fmt_num(*v) })) + .filter_map(|v| p.get(*v as usize).map(|c| RasterLegend::Class { color: Rgba(*c), label: class_label(0, *v) })) .collect() } } @@ -1083,6 +1085,34 @@ mod tests { use super::*; + /// The legend names a class as the file does (#889), and gives the value of one it doesn't. + #[test] + fn legend_labels_classes_with_their_names() { + let gt = GeoTransform { origin_x: 10.0, origin_y: 50.0, pixel_width: 0.5, pixel_height: -0.5 }; + let mut palette = vec![[0, 0, 0, 0]; 256]; + palette[1..4].copy_from_slice(&[[239, 235, 221, 255], [255, 231, 160, 255], [253, 190, 110, 255]]); + let mut r = + Raster::new("slope", 3, 1, vec![vec![1.0, 2.0, 3.0]], gt, Crs::LonLat, None, ColorHint::Palette(palette), "UInt8").unwrap(); + r.bands[0].classes = vec!["no data".into(), "0-5 %".into(), String::new(), "15-30 %".into()]; + let labels = |rl: &RasterLayer| -> Vec { + rl.legend() + .into_iter() + .map(|e| match e { + RasterLegend::Class { label, .. } => label, + other => panic!("{other:?}"), + }) + .collect() + }; + let mut rl = RasterLayer::from_path(r.clone(), "slope.tif"); + rl.style.renderer = RasterRenderer::Colormap; + assert_eq!(labels(&rl), ["0-5 %", "2", "15-30 %"]); + rl.style.renderer = unique_values(&r, 0, Ramp::Harbour); + assert_eq!(labels(&rl), ["0-5 %", "2", "15-30 %"]); + r.bands[0].classes.clear(); + rl.data = Some(Arc::new(r)); + assert_eq!(labels(&rl), ["1", "2", "3"], "no names: the values, as before"); + } + /// A following layer's dates are its steps from `offset` on, one each: repair keeps them as /// they are when they are in order, and follows none rather than the wrong step otherwise. #[test] diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 0d456df8c..e8c001e08 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -706,7 +706,9 @@ A TIFF or PNG without coordinates opens as a scan to place with **Georeference** file next to it (as a Shapefile does); otherwise, and always for uploads, it must be in longitude/latitude degrees. On the desktop, a GDAL `.aux.xml` file next to a raster (`dem.tif.aux.xml` or `dem.aux.xml`) names its bands from their descriptions, gives the no-data value when the file -has none, and supplies the band statistics when only an overview of the raster is read. The band +has none, and supplies the band statistics when only an overview of the raster is read. Its +category names (GDAL's `SetCategoryNames`, one per value) label the classes in the legend of a +Colormap or Unique Values layer; a value without a name shows the value itself. The band descriptions GDAL and rasterio write inside a GeoTIFF (its `GDAL_METADATA` tag) name its bands too, a sidecar's taking precedence. Exporting a raster opened from an overview writes that overview; the results of adding and of