Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions crates/data/src/raster/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,19 @@ pub struct Band {
pub name: String,
pub data: Vec<f32>,
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<String>,
}

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.
Expand Down Expand Up @@ -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(),
Expand All @@ -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() {
Expand All @@ -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);
Expand Down Expand Up @@ -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#"<PAMDataset><PAMRasterBand band="1"><CategoryNames>
<Category>no data</Category><Category>0-5 % flat</Category><Category></Category><Category>over 100 %</Category>
</CategoryNames></PAMRasterBand></PAMDataset>"#;
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 {
Expand Down
54 changes: 52 additions & 2 deletions crates/data/src/raster/pam.rs
Original file line number Diff line number Diff line change
@@ -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
//! <PAMDataset>
//! <PAMRasterBand band="1">
//! <Description>Blue</Description>
//! <NoDataValue>0</NoDataValue>
//! <CategoryNames>
//! <Category>no data</Category>
//! <Category>water</Category>
//! </CategoryNames>
//! <Metadata>
//! <MDI key="STATISTICS_MINIMUM">12</MDI>
//! <MDI key="STATISTICS_MAXIMUM">2400</MDI>
Expand All @@ -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)]
Expand All @@ -40,6 +47,9 @@ pub struct PamBand {
pub description: Option<String>,
pub nodata: Option<f64>,
pub stats: Option<Stats>,
/// 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<String>,
}

/// What a sidecar says about a raster's bands.
Expand Down Expand Up @@ -113,7 +123,19 @@ pub fn parse(text: &str) -> Option<Pam> {
}
_ => None,
};
pam.bands.push(PamBand { band, description, nodata, stats });
let mut categories: Vec<String> = 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)
}
Expand Down Expand Up @@ -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#"<PAMDataset>
<PAMRasterBand band="1">
<CategoryNames>
<Category>no data</Category>
<Category> 0-5 % flat </Category>
<Category></Category>
<Category>over 100 % (over 45°)</Category>
<Category/>
</CategoryNames>
</PAMRasterBand>
<PAMRasterBand band="2"><CategoryNames><Category></Category></CategoryNames></PAMRasterBand>
<PAMRasterBand band="3"><Description>none</Description></PAMRasterBand>
</PAMDataset>"#;
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!(
"<PAMDataset><PAMRasterBand band=\"1\"><CategoryNames><Category>{}</Category></CategoryNames></PAMRasterBand></PAMDataset>",
"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", "<GDALMetadata/>", "<PAMDataset><PAMRasterBand band=\"x\"/>", "<!DOCTYPE x><PAMDataset/>"] {
Expand Down
36 changes: 33 additions & 3 deletions crates/map/src/raster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,8 @@ impl RasterLayer {
pub fn legend(&self) -> Vec<RasterLegend> {
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));
Expand Down Expand Up @@ -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()
}
}
Expand All @@ -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<String> {
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]
Expand Down
4 changes: 3 additions & 1 deletion docs/user-guide/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading