diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a90fb904..f7f042874 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,9 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: +env: + RUST_BACKTRACE: 1 + jobs: gdal_35: name: "ci gdal-35" diff --git a/CHANGES.md b/CHANGES.md index 5eed282f9..1364cb663 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -27,6 +27,11 @@ - +- Added ability to set color table for bands with palette color interpretation. + Added ability to create a color ramp (interpolated) color table. + + - + ## 0.13 - Add prebuild bindings for GDAL 3.5 diff --git a/fixtures/labels.tif b/fixtures/labels.tif new file mode 100644 index 000000000..2b318c032 Binary files /dev/null and b/fixtures/labels.tif differ diff --git a/src/dataset.rs b/src/dataset.rs index ce1f9cc40..3c1234437 100644 --- a/src/dataset.rs +++ b/src/dataset.rs @@ -19,10 +19,10 @@ use crate::{ Driver, Metadata, }; -use gdal_sys::OGRGeometryH; use gdal_sys::{ self, CPLErr, GDALAccess, GDALDatasetH, GDALMajorObjectH, OGRErr, OGRLayerH, OGRwkbGeometryType, }; +use gdal_sys::{GDALFlushCache, OGRGeometryH}; use libc::{c_double, c_int, c_uint}; #[cfg(all(major_ge_3, minor_ge_1))] @@ -400,6 +400,13 @@ impl Dataset { Ok(Dataset { c_dataset }) } + /// Flush all write cached data to disk. + /// + /// See [`GDALFlushCache`]. + pub fn flush_cache(&mut self) { + unsafe { GDALFlushCache(self.c_dataset) } + } + /// Creates a new Dataset by wrapping a C pointer /// /// # Safety diff --git a/src/raster/rasterband.rs b/src/raster/rasterband.rs index 642a0274b..acf656975 100644 --- a/src/raster/rasterband.rs +++ b/src/raster/rasterband.rs @@ -5,11 +5,13 @@ use crate::raster::{GDALDataType, GdalType}; use crate::utils::{_last_cpl_err, _last_null_pointer_err, _string}; use gdal_sys::{ self, CPLErr, GDALColorEntry, GDALColorInterp, GDALColorTableH, GDALComputeRasterMinMax, + GDALCreateColorRamp, GDALCreateColorTable, GDALDestroyColorTable, GDALGetPaletteInterpretation, GDALGetRasterStatistics, GDALMajorObjectH, GDALPaletteInterp, GDALRIOResampleAlg, GDALRWFlag, - GDALRasterBandH, GDALRasterIOExtraArg, + GDALRasterBandH, GDALRasterIOExtraArg, GDALSetColorEntry, GDALSetRasterColorTable, }; use libc::c_int; use std::ffi::CString; +use std::fmt::{Debug, Formatter}; use std::marker::PhantomData; #[cfg(feature = "ndarray")] @@ -485,6 +487,13 @@ impl<'a> RasterBand<'a> { Some(ColorTable::from_c_color_table(c_color_table)) } + /// Set the color table for this band. + /// + /// See [`ColorTable`] for usage example. + pub fn set_color_table(&mut self, colors: &ColorTable) { + unsafe { GDALSetRasterColorTable(self.c_rasterband, colors.c_color_table) }; + } + /// Returns the scale of this band if set. pub fn scale(&self) -> Option { let mut pb_success = 1; @@ -811,15 +820,21 @@ impl ColorInterpretation { } } +/// Types of color interpretations for a [`ColorTable`]. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum PaletteInterpretation { + /// Grayscale Gray, + /// Red, Green, Blue and Alpha Rgba, + /// Cyan, Magenta, Yellow and Black Cmyk, + /// Hue, Lightness and Saturation Hls, } impl PaletteInterpretation { + /// Creates a Rust [`PaletteInterpretation`] from a C API [`GDALPaletteInterp`] value. fn from_c_int(palette_interpretation: GDALPaletteInterp::Type) -> Self { match palette_interpretation { GDALPaletteInterp::GPI_Gray => Self::Gray, @@ -829,13 +844,25 @@ impl PaletteInterpretation { _ => unreachable!("GDAL has implemented a new type of `GDALPaletteInterp`"), } } + + /// Returns the C API int value of this palette interpretation. + pub fn c_int(&self) -> GDALPaletteInterp::Type { + match self { + Self::Gray => GDALPaletteInterp::GPI_Gray, + Self::Rgba => GDALPaletteInterp::GPI_RGB, + Self::Cmyk => GDALPaletteInterp::GPI_CMYK, + Self::Hls => GDALPaletteInterp::GPI_HLS, + } + } } +/// Grayscale [`ColorTable`] entry. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct GrayEntry { pub g: i16, } +/// Red, green, blue, alpha [`ColorTable`] entry. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct RgbaEntry { pub r: i16, @@ -844,6 +871,7 @@ pub struct RgbaEntry { pub a: i16, } +/// Cyan, magenta, yellow, black [`ColorTable`] entry. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct CmykEntry { pub c: i16, @@ -852,6 +880,7 @@ pub struct CmykEntry { pub k: i16, } +/// Hue, lightness, saturation [`ColorTable`] entry. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct HlsEntry { pub h: i16, @@ -859,7 +888,8 @@ pub struct HlsEntry { pub s: i16, } -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +/// Options for defining [`ColorInterpretation::PaletteIndex`] entries in a [`ColorTable`]. +#[derive(Copy, Clone, PartialEq, Eq)] pub enum ColorEntry { Gray(GrayEntry), Rgba(RgbaEntry), @@ -867,23 +897,213 @@ pub enum ColorEntry { Hls(HlsEntry), } -/// Color table for raster bands that use the PaletteIndex color interpretation. +impl ColorEntry { + /// Instantiate a greyscale color entry + pub fn grey(g: i16) -> Self { + Self::Gray(GrayEntry { g }) + } + + /// Instantiate an red, green, blue, alpha color entry + pub fn rgba(r: i16, g: i16, b: i16, a: i16) -> Self { + Self::Rgba(RgbaEntry { r, g, b, a }) + } + + /// Instantiate a cyan, magenta, yellow, black color entry + pub fn cmyk(c: i16, m: i16, y: i16, k: i16) -> Self { + Self::Cmyk(CmykEntry { c, m, y, k }) + } + + /// Instantiate a hue, lightness, saturation color entry + pub fn hls(h: i16, l: i16, s: i16) -> Self { + Self::Hls(HlsEntry { h, l, s }) + } + + /// Get the ['PaletteInterpretation'] describing `self`. + pub fn palette_interpretation(&self) -> PaletteInterpretation { + match self { + ColorEntry::Gray(_) => PaletteInterpretation::Gray, + ColorEntry::Rgba(_) => PaletteInterpretation::Rgba, + ColorEntry::Cmyk(_) => PaletteInterpretation::Cmyk, + ColorEntry::Hls(_) => PaletteInterpretation::Hls, + } + } + + /// Create from a C [`GDALColorEntry`]. + fn from(e: GDALColorEntry, interp: PaletteInterpretation) -> ColorEntry { + match interp { + PaletteInterpretation::Gray => ColorEntry::Gray(GrayEntry { g: e.c1 }), + PaletteInterpretation::Rgba => ColorEntry::Rgba(RgbaEntry { + r: e.c1, + g: e.c2, + b: e.c3, + a: e.c4, + }), + PaletteInterpretation::Cmyk => ColorEntry::Cmyk(CmykEntry { + c: e.c1, + m: e.c2, + y: e.c3, + k: e.c4, + }), + PaletteInterpretation::Hls => ColorEntry::Hls(HlsEntry { + h: e.c1, + l: e.c2, + s: e.c3, + }), + } + } +} + +impl From<&ColorEntry> for GDALColorEntry { + fn from(e: &ColorEntry) -> Self { + match e { + ColorEntry::Gray(e) => GDALColorEntry { + c1: e.g, + c2: 0, + c3: 0, + c4: 0, + }, + ColorEntry::Rgba(e) => GDALColorEntry { + c1: e.r, + c2: e.g, + c3: e.b, + c4: e.a, + }, + ColorEntry::Cmyk(e) => GDALColorEntry { + c1: e.c, + c2: e.m, + c3: e.y, + c4: e.k, + }, + ColorEntry::Hls(e) => GDALColorEntry { + c1: e.h, + c2: e.l, + c3: e.s, + c4: 0, + }, + } + } +} + +// For more compact debug output, skip enum wrapper. +impl Debug for ColorEntry { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ColorEntry::Gray(e) => e.fmt(f), + ColorEntry::Rgba(e) => e.fmt(f), + ColorEntry::Cmyk(e) => e.fmt(f), + ColorEntry::Hls(e) => e.fmt(f), + } + } +} + +/// Color table for raster bands that use [`ColorInterpretation::PaletteIndex`] color interpretation. +/// +/// +/// # Example +/// +/// ```rust, no_run +/// use gdal::{Dataset, Driver}; +/// use gdal::raster::{ColorEntry, ColorTable, PaletteInterpretation}; +/// # fn main() -> gdal::errors::Result<()> { /// -/// This object carries the lifetime of the raster band that -/// contains it. This is necessary to prevent the raster band -/// from being dropped before the color table. +/// // Open source multinomial classification raster +/// let ds = Dataset::open("fixtures/labels.tif")?; +/// +/// // Create in-memory copy to mutate +/// let mem_driver = Driver::get_by_name("MEM")?; +/// let ds = ds.create_copy(&mem_driver, "", &[])?; +/// let mut band = ds.rasterband(1)?; +/// assert!(band.color_table().is_none()); +/// +/// // Create a new color table for 3 classes + no-data +/// let mut ct = ColorTable::new(PaletteInterpretation::Rgba); +/// ct.set_color_entry(0, &ColorEntry::rgba(255, 255, 0, 255)); +/// ct.set_color_entry(1, &ColorEntry::rgba(0, 255, 255, 255)); +/// ct.set_color_entry(2, &ColorEntry::rgba(255, 0, 255, 255)); +/// ct.set_color_entry(255, &ColorEntry::rgba(0, 0, 0, 0)); +/// band.set_color_table(&ct); +/// +/// // Render a PNG +/// let png_driver = Driver::get_by_name("PNG")?; +/// ds.create_copy(&png_driver, "/tmp/labels.png", &[])?; +/// +/// # Ok(()) +/// # } +/// ``` pub struct ColorTable<'a> { palette_interpretation: PaletteInterpretation, c_color_table: GDALColorTableH, + /// If `true`, Rust is responsible for deallocating color table pointed to by + /// `c_color_table`, which is the case when instantiated directly, as opposed to + /// when read via [`RasterBand::color_table`]. + rust_owned: bool, phantom_raster_band: PhantomData<&'a RasterBand<'a>>, } impl<'a> ColorTable<'a> { + /// Instantiate a new color table with the given palette interpretation. + pub fn new(interp: PaletteInterpretation) -> Self { + let c_color_table = unsafe { GDALCreateColorTable(interp.c_int()) }; + Self { + palette_interpretation: interp, + c_color_table, + rust_owned: true, + phantom_raster_band: PhantomData, + } + } + + /// Constructs a color ramp from one color entry to another. + /// + /// `start_index` and `end_index` must be `0..=255`. + /// + /// Returns `None` if `start_color` and `end_color` do not have the same [`PaletteInterpretation`]. + /// + /// # Example + /// + /// ```rust, no_run + /// use gdal::{Dataset, Driver}; + /// use gdal::raster::{ColorEntry, ColorTable, PaletteInterpretation}; + /// # fn main() -> gdal::errors::Result<()> { + /// // Create a 16 step blue to white color table. + /// let ct = ColorTable::color_ramp( + /// 0, &ColorEntry::rgba(0, 0, 255, 255), + /// 15, &ColorEntry::rgba(255, 255, 255, 255) + /// )?; + /// println!("{ct:?}"); + /// # Ok(()) + /// # } + pub fn color_ramp( + start_index: u8, + start_color: &ColorEntry, + end_index: u8, + end_color: &ColorEntry, + ) -> Result> { + if start_color.palette_interpretation() != end_color.palette_interpretation() { + Err(GdalError::BadArgument( + "start_color and end_color must have the same palette_interpretation".into(), + )) + } else { + let ct = ColorTable::new(start_color.palette_interpretation()); + unsafe { + GDALCreateColorRamp( + ct.c_color_table, + start_index as c_int, + &start_color.into(), + end_index as c_int, + &end_color.into(), + ); + } + Ok(ct) + } + } + + /// Wrap C color table fn from_c_color_table(c_color_table: GDALColorTableH) -> Self { - let interp_index = unsafe { gdal_sys::GDALGetPaletteInterpretation(c_color_table) }; + let interp_index = unsafe { GDALGetPaletteInterpretation(c_color_table) }; ColorTable { palette_interpretation: PaletteInterpretation::from_c_int(interp_index), c_color_table, + rust_owned: false, phantom_raster_band: PhantomData, } } @@ -907,29 +1127,12 @@ impl<'a> ColorTable<'a> { } *c_color_entry }; - match self.palette_interpretation { - PaletteInterpretation::Gray => Some(ColorEntry::Gray(GrayEntry { g: color_entry.c1 })), - PaletteInterpretation::Rgba => Some(ColorEntry::Rgba(RgbaEntry { - r: color_entry.c1, - g: color_entry.c2, - b: color_entry.c3, - a: color_entry.c4, - })), - PaletteInterpretation::Cmyk => Some(ColorEntry::Cmyk(CmykEntry { - c: color_entry.c1, - m: color_entry.c2, - y: color_entry.c3, - k: color_entry.c4, - })), - PaletteInterpretation::Hls => Some(ColorEntry::Hls(HlsEntry { - h: color_entry.c1, - l: color_entry.c2, - s: color_entry.c3, - })), - } + Some(ColorEntry::from(color_entry, self.palette_interpretation)) } /// Get a color entry as RGB. + /// + /// Returns `None` if `palette_interpretation` is not `Rgba`. pub fn entry_as_rgb(&self, index: usize) -> Option { let mut color_entry = GDALColorEntry { c1: 0, @@ -950,4 +1153,41 @@ impl<'a> ColorTable<'a> { a: color_entry.c4, }) } + + /// Set entry in the RasterBand color table. + /// + /// The `entry` variant type must match `palette_interpretation`. + /// + /// The table is grown as needed to hold the supplied index, filling in gaps with + /// the default [`GDALColorEntry`] value. + pub fn set_color_entry(&mut self, index: u16, entry: &ColorEntry) { + unsafe { GDALSetColorEntry(self.c_color_table, index as c_int, &entry.into()) } + } +} + +impl Drop for ColorTable<'_> { + fn drop(&mut self) { + if self.rust_owned { + unsafe { GDALDestroyColorTable(self.c_color_table) } + } + } +} + +impl Default for ColorTable<'_> { + fn default() -> Self { + Self::new(PaletteInterpretation::Rgba) + } +} + +impl Debug for ColorTable<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let entries = (0..self.entry_count()) + .filter_map(|i| self.entry(i)) + .collect::>(); + + f.debug_struct("ColorTable") + .field("palette_interpretation", &self.palette_interpretation) + .field("entries", &entries) + .finish() + } } diff --git a/src/raster/tests.rs b/src/raster/tests.rs index ba67a2a4c..789c4a0d7 100644 --- a/src/raster/tests.rs +++ b/src/raster/tests.rs @@ -2,7 +2,8 @@ use crate::dataset::Dataset; use crate::metadata::Metadata; use crate::raster::rasterband::ResampleAlg; use crate::raster::{ - ByteBuffer, ColorInterpretation, RasterCreationOption, StatisticsAll, StatisticsMinMax, + ByteBuffer, ColorEntry, ColorInterpretation, ColorTable, RasterCreationOption, StatisticsAll, + StatisticsMinMax, }; use crate::test_utils::TempFixture; use crate::vsi::unlink_mem_file; @@ -807,6 +808,63 @@ fn test_color_table() { } } +#[test] +fn test_create_color_table() { + let outfile = TempFixture::empty("color_labels.tif"); + // Open, modify, then close the base file. + { + let dataset = Dataset::open(fixture!("labels.tif")).unwrap(); + // Confirm we have a band without a color table. + assert_eq!(dataset.raster_count(), 1); + let band = dataset.rasterband(1).unwrap(); + assert_eq!(band.band_type(), GDALDataType::GDT_Byte); + assert!(band.color_table().is_none()); + + // Create a new file to put color table in + let dataset = dataset + .create_copy(&dataset.driver(), &outfile, &[]) + .unwrap(); + dataset + .rasterband(1) + .unwrap() + .set_no_data_value(None) + .unwrap(); + let mut ct = ColorTable::default(); + ct.set_color_entry(2, &ColorEntry::rgba(255, 0, 0, 255)); + ct.set_color_entry(5, &ColorEntry::rgba(0, 255, 0, 255)); + ct.set_color_entry(7, &ColorEntry::rgba(0, 0, 255, 255)); + + assert_eq!(ct.entry_count(), 8); + assert_eq!(ct.entry(0), Some(ColorEntry::rgba(0, 0, 0, 0))); + assert_eq!(ct.entry(2), Some(ColorEntry::rgba(255, 0, 0, 255))); + assert_eq!(ct.entry(8), None); + + dataset.rasterband(1).unwrap().set_color_table(&ct); + } + + // Reopen to confirm the changes. + let dataset = Dataset::open(&outfile).unwrap(); + let band = dataset.rasterband(1).unwrap(); + let ct = band.color_table().expect("saved color table"); + + // Note: the GeoTIFF driver alters the palette, creating black entries to fill up all indexes + // up to 255. Other drivers may do things differently. + assert_eq!(ct.entry(0), Some(ColorEntry::rgba(0, 0, 0, 255))); + assert_eq!(ct.entry(2), Some(ColorEntry::rgba(255, 0, 0, 255))); + assert_eq!(ct.entry(5), Some(ColorEntry::rgba(0, 255, 0, 255))); + assert_eq!(ct.entry(7), Some(ColorEntry::rgba(0, 0, 255, 255))); + assert_eq!(ct.entry(8), Some(ColorEntry::rgba(0, 0, 0, 255))); +} + +#[test] +fn test_color_ramp() { + let ct = ColorTable::color_ramp(0, &ColorEntry::grey(0), 99, &ColorEntry::grey(99)).unwrap(); + assert_eq!(ct.entry(0), Some(ColorEntry::grey(0))); + assert_eq!(ct.entry(57), Some(ColorEntry::grey(57))); + assert_eq!(ct.entry(99), Some(ColorEntry::grey(99))); + assert_eq!(ct.entry(100), None); +} + #[test] fn test_raster_stats() { let fixture = TempFixture::fixture("tinymarble.tif"); diff --git a/src/test_utils.rs b/src/test_utils.rs index a4b120d65..a728529fc 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -12,13 +12,20 @@ impl TempFixture { /// /// This can potentially be removed when is resolved. pub fn fixture(name: &str) -> Self { - let path = std::path::Path::new("fixtures").join(name); + let staging = Self::empty(name); + let source = Path::new("fixtures").join(name); + std::fs::copy(&source, &staging.temp_path).unwrap(); + staging + } + /// Creates a temporary directory and path to a non-existent file with given `name`. + /// Useful for writing results to during testing + /// + /// Returns the struct `TempFixture` that contains the temp dir (for clean-up on `drop`) + /// as well as the empty file path. + pub fn empty(name: &str) -> Self { let _temp_dir = tempfile::tempdir().unwrap(); - let temp_path = _temp_dir.path().join(path.file_name().unwrap()); - - std::fs::copy(&path, &temp_path).unwrap(); - + let temp_path = _temp_dir.path().join(name); Self { _temp_dir, temp_path,