Browse Source

Fixed #28232 -- Made raster metadata readable and writable on GDALRaster/Band.

Daniel Wiesmann 7 years ago
parent
commit
e0b456bee7

+ 10 - 1
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

+ 18 - 2
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'),

+ 2 - 2
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.
     """

+ 78 - 0
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,
+                )

+ 12 - 2
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()

+ 41 - 0
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

+ 5 - 0
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`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 97 - 42
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({