From 2aa2c104f0b6319ebf7fe8db73e5ded5732764ef Mon Sep 17 00:00:00 2001 From: GISspecialistAlejandraDuque <268712798+GISspecialistAlejandraDuque@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:53:56 +0100 Subject: [PATCH] feat(raster): label legend classes with the category names of the .aux.xml A classified raster's legend listed its values (1, 2, 3...) even when GDAL's .aux.xml sidecar names each class (, as written by SetCategoryNames). The sidecar reader now keeps those names on the band, and the Colormap and Unique Values legend rows use them, falling back to the value for a class without a name. Part of #889. Co-Authored-By: Claude Opus 5.5 --- crates/data/src/raster/mod.rs | 46 +++++++++++++++++++++++++++-- crates/data/src/raster/pam.rs | 54 +++++++++++++++++++++++++++++++++-- crates/map/src/raster.rs | 36 +++++++++++++++++++++-- docs/user-guide/data.md | 4 ++- 4 files changed, 131 insertions(+), 9 deletions(-) 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