From e0b456bee7bcf37e2a55471255686ee59c6cf03c Mon Sep 17 00:00:00 2001 From: Daniel Wiesmann Date: Tue, 23 May 2017 20:07:16 +0100 Subject: [PATCH] Fixed #28232 -- Made raster metadata readable and writable on GDALRaster/Band. --- .../contrib/gis/gdal/prototypes/generation.py | 11 +- django/contrib/gis/gdal/prototypes/raster.py | 20 ++- django/contrib/gis/gdal/raster/band.py | 4 +- django/contrib/gis/gdal/raster/base.py | 78 ++++++++++ django/contrib/gis/gdal/raster/source.py | 14 +- docs/ref/contrib/gis/gdal.txt | 41 ++++++ docs/releases/2.0.txt | 5 + tests/gis_tests/gdal_tests/test_raster.py | 139 ++++++++++++------ 8 files changed, 263 insertions(+), 49 deletions(-) create mode 100644 django/contrib/gis/gdal/raster/base.py diff --git a/django/contrib/gis/gdal/prototypes/generation.py b/django/contrib/gis/gdal/prototypes/generation.py index b0135c9f480..012a5baab0a 100644 --- a/django/contrib/gis/gdal/prototypes/generation.py +++ b/django/contrib/gis/gdal/prototypes/generation.py @@ -2,7 +2,7 @@ This module contains functions that generate ctypes prototypes for the GDAL routines. """ -from ctypes import c_char_p, c_double, c_int, c_int64, c_void_p +from ctypes import POINTER, c_char_p, c_double, c_int, c_int64, c_void_p from functools import partial from django.contrib.gis.gdal.prototypes.errcheck import ( @@ -147,3 +147,12 @@ def voidptr_output(func, argtypes, errcheck=True): if errcheck: func.errcheck = check_pointer return func + + +def chararray_output(func, argtypes, errcheck=True): + """For functions that return a c_char_p array.""" + func.argtypes = argtypes + func.restype = POINTER(c_char_p) + if errcheck: + func.errcheck = check_pointer + return func diff --git a/django/contrib/gis/gdal/prototypes/raster.py b/django/contrib/gis/gdal/prototypes/raster.py index af710dfb6dc..7e95cbc9a43 100644 --- a/django/contrib/gis/gdal/prototypes/raster.py +++ b/django/contrib/gis/gdal/prototypes/raster.py @@ -7,13 +7,14 @@ from functools import partial from django.contrib.gis.gdal.libgdal import GDAL_VERSION, std_call from django.contrib.gis.gdal.prototypes.generation import ( - const_string_output, double_output, int_output, void_output, - voidptr_output, + chararray_output, const_string_output, double_output, int_output, + void_output, voidptr_output, ) # For more detail about c function names and definitions see # http://gdal.org/gdal_8h.html # http://gdal.org/gdalwarper_8h.html +# http://www.gdal.org/gdal__utils_8h.html # Prepare partial functions that use cpl error codes void_output = partial(void_output, cpl=True) @@ -48,6 +49,21 @@ set_ds_projection_ref = void_output(std_call('GDALSetProjection'), [c_void_p, c_ get_ds_geotransform = void_output(std_call('GDALGetGeoTransform'), [c_void_p, POINTER(c_double * 6)], errcheck=False) set_ds_geotransform = void_output(std_call('GDALSetGeoTransform'), [c_void_p, POINTER(c_double * 6)]) +get_ds_metadata = chararray_output(std_call('GDALGetMetadata'), [c_void_p, c_char_p], errcheck=False) +set_ds_metadata = void_output(std_call('GDALSetMetadata'), [c_void_p, POINTER(c_char_p), c_char_p]) +if GDAL_VERSION >= (1, 11): + get_ds_metadata_domain_list = chararray_output(std_call('GDALGetMetadataDomainList'), [c_void_p], errcheck=False) +else: + get_ds_metadata_domain_list = None +get_ds_metadata_item = const_string_output(std_call('GDALGetMetadataItem'), [c_void_p, c_char_p, c_char_p]) +set_ds_metadata_item = const_string_output(std_call('GDALSetMetadataItem'), [c_void_p, c_char_p, c_char_p, c_char_p]) +free_dsl = void_output(std_call('CSLDestroy'), [POINTER(c_char_p)], errcheck=False) + +if GDAL_VERSION >= (2, 1): + get_ds_info = const_string_output(std_call('GDALInfo'), [c_void_p, c_void_p]) +else: + get_ds_info = None + # Raster Band Routines band_io = void_output( std_call('GDALRasterIO'), diff --git a/django/contrib/gis/gdal/raster/band.py b/django/contrib/gis/gdal/raster/band.py index e78b370c09e..19d8ef41cf5 100644 --- a/django/contrib/gis/gdal/raster/band.py +++ b/django/contrib/gis/gdal/raster/band.py @@ -1,15 +1,15 @@ from ctypes import byref, c_double, c_int, c_void_p -from django.contrib.gis.gdal.base import GDALBase from django.contrib.gis.gdal.error import GDALException from django.contrib.gis.gdal.prototypes import raster as capi +from django.contrib.gis.gdal.raster.base import GDALRasterBase from django.contrib.gis.shortcuts import numpy from django.utils.encoding import force_text from .const import GDAL_INTEGER_TYPES, GDAL_PIXEL_TYPES, GDAL_TO_CTYPES -class GDALBand(GDALBase): +class GDALBand(GDALRasterBase): """ Wrap a GDAL raster band, needs to be obtained from a GDALRaster object. """ diff --git a/django/contrib/gis/gdal/raster/base.py b/django/contrib/gis/gdal/raster/base.py new file mode 100644 index 00000000000..c98f636ec2c --- /dev/null +++ b/django/contrib/gis/gdal/raster/base.py @@ -0,0 +1,78 @@ +from django.contrib.gis.gdal.base import GDALBase +from django.contrib.gis.gdal.prototypes import raster as capi + + +class GDALRasterBase(GDALBase): + """ + Attributes that exist on both GDALRaster and GDALBand. + """ + @property + def metadata(self): + """ + Return the metadata for this raster or band. The return value is a + nested dictionary, where the first-level key is the metadata domain and + the second-level is the metadata item names and values for that domain. + """ + if not capi.get_ds_metadata_domain_list: + raise ValueError('GDAL ≥ 1.11 is required for using the metadata property.') + + # The initial metadata domain list contains the default domain. + # The default is returned if domain name is None. + domain_list = ['DEFAULT'] + + # Get additional metadata domains from the raster. + meta_list = capi.get_ds_metadata_domain_list(self._ptr) + if meta_list: + # The number of domains is unknown, so retrieve data until there + # are no more values in the ctypes array. + counter = 0 + domain = meta_list[counter] + while domain: + domain_list.append(domain.decode()) + counter += 1 + domain = meta_list[counter] + + # Free domain list array. + capi.free_dsl(meta_list) + + # Retrieve metadata values for each domain. + result = {} + for domain in domain_list: + # Get metadata for this domain. + data = capi.get_ds_metadata( + self._ptr, + (None if domain == 'DEFAULT' else domain.encode()), + ) + if not data: + continue + # The number of metadata items is unknown, so retrieve data until + # there are no more values in the ctypes array. + domain_meta = {} + counter = 0 + item = data[counter] + while item: + key, val = item.decode().split('=') + domain_meta[key] = val + counter += 1 + item = data[counter] + # The default domain values are returned if domain is None. + result[domain if domain else 'DEFAULT'] = domain_meta + return result + + @metadata.setter + def metadata(self, value): + """ + Set the metadata. Update only the domains that are contained in the + value dictionary. + """ + # Loop through domains. + for domain, metadata in value.items(): + # Set the domain to None for the default, otherwise encode. + domain = None if domain == 'DEFAULT' else domain.encode() + # Set each metadata entry separately. + for meta_name, meta_value in metadata.items(): + capi.set_ds_metadata_item( + self._ptr, meta_name.encode(), + meta_value.encode() if meta_value else None, + domain, + ) diff --git a/django/contrib/gis/gdal/raster/source.py b/django/contrib/gis/gdal/raster/source.py index 03fa1f8f229..609c9d6b466 100644 --- a/django/contrib/gis/gdal/raster/source.py +++ b/django/contrib/gis/gdal/raster/source.py @@ -2,11 +2,11 @@ import json import os from ctypes import addressof, byref, c_double, c_void_p -from django.contrib.gis.gdal.base import GDALBase from django.contrib.gis.gdal.driver import Driver from django.contrib.gis.gdal.error import GDALException from django.contrib.gis.gdal.prototypes import raster as capi from django.contrib.gis.gdal.raster.band import BandList +from django.contrib.gis.gdal.raster.base import GDALRasterBase from django.contrib.gis.gdal.raster.const import GDAL_RESAMPLE_ALGORITHMS from django.contrib.gis.gdal.srs import SpatialReference, SRSException from django.contrib.gis.geometry.regex import json_regex @@ -49,7 +49,7 @@ class TransformPoint(list): self._raster.geotransform = gtf -class GDALRaster(GDALBase): +class GDALRaster(GDALRasterBase): """ Wrap a raster GDAL Data Source object. """ @@ -403,3 +403,13 @@ class GDALRaster(GDALBase): # Warp the raster into new srid return self.warp(data, resampling=resampling, max_error=max_error) + + @property + def info(self): + """ + Return information about this raster in a string format equivalent + to the output of the gdalinfo command line utility. + """ + if not capi.get_ds_info: + raise ValueError('GDAL ≥ 2.1 is required for using the info property.') + return capi.get_ds_info(self.ptr, None).decode() diff --git a/docs/ref/contrib/gis/gdal.txt b/docs/ref/contrib/gis/gdal.txt index 3edcf48bd64..d6501cbf487 100644 --- a/docs/ref/contrib/gis/gdal.txt +++ b/docs/ref/contrib/gis/gdal.txt @@ -1391,6 +1391,40 @@ blue. >>> target.origin [-82.98492744885776, 27.601924753080144] + .. attribute:: info + + .. versionadded:: 2.0 + + Returns a string with a summary of the raster. This is equivalent to + the `gdalinfo`__ command line utility. + + __ http://www.gdal.org/gdalinfo.html + + .. attribute:: metadata + + .. versionadded:: 2.0 + + The metadata of this raster, represented as a nested dictionary. The + first-level key is the metadata domain. The second-level contains the + metadata item names and values from each domain. + + To set or update a metadata item, pass the corresponding metadata item + to the method using the nested structure described above. Only keys + that are in the specified dictionary are updated; the rest of the + metadata remains unchanged. + + To remove a metadata item, use ``None`` as the metadata value. + + >>> rst = GDALRaster({'width': 10, 'height': 20, 'srid': 4326}) + >>> rst.metadata + {} + >>> rst.metadata = {'DEFAULT': {'OWNER': 'Django', 'VERSION': '1.0'}} + >>> rst.metadata + {'DEFAULT': {'OWNER': 'Django', 'VERSION': '1.0'}} + >>> rst.metadata = {'DEFAULT': {'OWNER': None, 'VERSION': '2.0'}} + >>> rst.metadata + {'DEFAULT': {'VERSION': '2.0'}} + ``GDALBand`` ------------ @@ -1539,6 +1573,13 @@ blue. [2, 2, 2, 2], [3, 3, 3, 3]], dtype=uint8) + .. attribute:: metadata + + .. versionadded:: 2.0 + + The metadata of this band. The functionality is identical to + :attr:`GDALRaster.metadata`. + .. _gdal-raster-ds-input: Creating rasters from data diff --git a/docs/releases/2.0.txt b/docs/releases/2.0.txt index fef59ba880e..3b7a5a74f15 100644 --- a/docs/releases/2.0.txt +++ b/docs/releases/2.0.txt @@ -78,6 +78,11 @@ Minor features * Added the :attr:`.OSMWidget.default_zoom` attribute to customize the map's default zoom level. +* Made metadata readable and editable on rasters through the + :attr:`~django.contrib.gis.gdal.GDALRaster.metadata`, + :attr:`~django.contrib.gis.gdal.GDALRaster.info`, and + :attr:`~django.contrib.gis.gdal.GDALBand.metadata` attributes. + :mod:`django.contrib.messages` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/gis_tests/gdal_tests/test_raster.py b/tests/gis_tests/gdal_tests/test_raster.py index 5495178a131..f82b37a6a96 100644 --- a/tests/gis_tests/gdal_tests/test_raster.py +++ b/tests/gis_tests/gdal_tests/test_raster.py @@ -1,45 +1,3 @@ -""" -gdalinfo tests/gis_tests/data/rasters/raster.tif: - -Driver: GTiff/GeoTIFF -Files: tests/gis_tests/data/rasters/raster.tif -Size is 163, 174 -Coordinate System is: -PROJCS["NAD83 / Florida GDL Albers", - GEOGCS["NAD83", - DATUM["North_American_Datum_1983", - SPHEROID["GRS 1980",6378137,298.2572221010002, - AUTHORITY["EPSG","7019"]], - TOWGS84[0,0,0,0,0,0,0], - AUTHORITY["EPSG","6269"]], - PRIMEM["Greenwich",0], - UNIT["degree",0.0174532925199433], - AUTHORITY["EPSG","4269"]], - PROJECTION["Albers_Conic_Equal_Area"], - PARAMETER["standard_parallel_1",24], - PARAMETER["standard_parallel_2",31.5], - PARAMETER["latitude_of_center",24], - PARAMETER["longitude_of_center",-84], - PARAMETER["false_easting",400000], - PARAMETER["false_northing",0], - UNIT["metre",1, - AUTHORITY["EPSG","9001"]], - AUTHORITY["EPSG","3086"]] -Origin = (511700.468070655711927,435103.377123198588379) -Pixel Size = (100.000000000000000,-100.000000000000000) -Metadata: - AREA_OR_POINT=Area -Image Structure Metadata: - INTERLEAVE=BAND -Corner Coordinates: -Upper Left ( 511700.468, 435103.377) ( 82d51'46.16"W, 27d55' 1.53"N) -Lower Left ( 511700.468, 417703.377) ( 82d51'52.04"W, 27d45'37.50"N) -Upper Right ( 528000.468, 435103.377) ( 82d41'48.81"W, 27d54'56.30"N) -Lower Right ( 528000.468, 417703.377) ( 82d41'55.54"W, 27d45'32.28"N) -Center ( 519850.468, 426403.377) ( 82d46'50.64"W, 27d50'16.99"N) -Band 1 Block=163x50 Type=Byte, ColorInterp=Gray - NoData Value=15 -""" import os import struct import tempfile @@ -255,6 +213,103 @@ class GDALRasterTests(SimpleTestCase): # Band data is equal to zero becaues no nodata value has been specified. self.assertEqual(result, [0] * 4) + def test_raster_metadata_property(self): + # Check for required gdal version. + if GDAL_VERSION < (1, 11): + msg = 'GDAL ≥ 1.11 is required for using the metadata property.' + with self.assertRaisesMessage(ValueError, msg): + self.rs.metadata + return + + self.assertEqual( + self.rs.metadata, + {'DEFAULT': {'AREA_OR_POINT': 'Area'}, 'IMAGE_STRUCTURE': {'INTERLEAVE': 'BAND'}}, + ) + + # Create file-based raster from scratch + source = GDALRaster({ + 'datatype': 1, + 'width': 2, + 'height': 2, + 'srid': 4326, + 'bands': [{'data': range(4), 'nodata_value': 99}], + }) + # Set metadata on raster and on a band. + metadata = { + 'DEFAULT': {'OWNER': 'Django', 'VERSION': '1.0', 'AREA_OR_POINT': 'Point', }, + } + source.metadata = metadata + source.bands[0].metadata = metadata + self.assertEqual(source.metadata['DEFAULT'], metadata['DEFAULT']) + self.assertEqual(source.bands[0].metadata['DEFAULT'], metadata['DEFAULT']) + # Update metadata on raster. + metadata = { + 'DEFAULT': {'VERSION': '2.0', }, + } + source.metadata = metadata + self.assertEqual(source.metadata['DEFAULT']['VERSION'], '2.0') + # Remove metadata on raster. + metadata = { + 'DEFAULT': {'OWNER': None, }, + } + source.metadata = metadata + self.assertNotIn('OWNER', source.metadata['DEFAULT']) + + def test_raster_info_accessor(self): + if GDAL_VERSION < (2, 1): + msg = 'GDAL ≥ 2.1 is required for using the info property.' + with self.assertRaisesMessage(ValueError, msg): + self.rs.info + return + gdalinfo = """ + Driver: GTiff/GeoTIFF + Files: {0} + Size is 163, 174 + Coordinate System is: + PROJCS["NAD83 / Florida GDL Albers", + GEOGCS["NAD83", + DATUM["North_American_Datum_1983", + SPHEROID["GRS 1980",6378137,298.257222101, + AUTHORITY["EPSG","7019"]], + TOWGS84[0,0,0,0,0,0,0], + AUTHORITY["EPSG","6269"]], + PRIMEM["Greenwich",0, + AUTHORITY["EPSG","8901"]], + UNIT["degree",0.0174532925199433, + AUTHORITY["EPSG","9122"]], + AUTHORITY["EPSG","4269"]], + PROJECTION["Albers_Conic_Equal_Area"], + PARAMETER["standard_parallel_1",24], + PARAMETER["standard_parallel_2",31.5], + PARAMETER["latitude_of_center",24], + PARAMETER["longitude_of_center",-84], + PARAMETER["false_easting",400000], + PARAMETER["false_northing",0], + UNIT["metre",1, + AUTHORITY["EPSG","9001"]], + AXIS["X",EAST], + AXIS["Y",NORTH], + AUTHORITY["EPSG","3086"]] + Origin = (511700.468070655711927,435103.377123198588379) + Pixel Size = (100.000000000000000,-100.000000000000000) + Metadata: + AREA_OR_POINT=Area + Image Structure Metadata: + INTERLEAVE=BAND + Corner Coordinates: + Upper Left ( 511700.468, 435103.377) ( 82d51'46.16"W, 27d55' 1.53"N) + Lower Left ( 511700.468, 417703.377) ( 82d51'52.04"W, 27d45'37.50"N) + Upper Right ( 528000.468, 435103.377) ( 82d41'48.81"W, 27d54'56.30"N) + Lower Right ( 528000.468, 417703.377) ( 82d41'55.54"W, 27d45'32.28"N) + Center ( 519850.468, 426403.377) ( 82d46'50.64"W, 27d50'16.99"N) + Band 1 Block=163x50 Type=Byte, ColorInterp=Gray + NoData Value=15 + """.format(self.rs_path) + # Data + info_dyn = [line.strip() for line in self.rs.info.split('\n') if line.strip() != ''] + info_ref = [line.strip() for line in gdalinfo.split('\n') if line.strip() != ''] + self.assertEqual(info_dyn, info_ref) + def test_raster_warp(self): # Create in memory raster source = GDALRaster({