Coverage for enpt/model/images/image_baseclasses.py: 89%
190 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-03-07 11:39 +0000
« prev ^ index » next coverage.py v7.4.1, created at 2024-03-07 11:39 +0000
1# -*- coding: utf-8 -*-
3# EnPT, EnMAP Processing Tool - A Python package for pre-processing of EnMAP Level-1B data
4#
5# Copyright (C) 2018-2024 Karl Segl (GFZ Potsdam, segl@gfz-potsdam.de), Daniel Scheffler
6# (GFZ Potsdam, danschef@gfz-potsdam.de), Niklas Bohn (GFZ Potsdam, nbohn@gfz-potsdam.de),
7# Stéphane Guillaso (GFZ Potsdam, stephane.guillaso@gfz-potsdam.de)
8#
9# This software was developed within the context of the EnMAP project supported
10# by the DLR Space Administration with funds of the German Federal Ministry of
11# Economic Affairs and Energy (on the basis of a decision by the German Bundestag:
12# 50 EE 1529) and contributions from DLR, GFZ and OHB System AG.
13#
14# This program is free software: you can redistribute it and/or modify it under
15# the terms of the GNU General Public License as published by the Free Software
16# Foundation, either version 3 of the License, or (at your option) any later
17# version. Please note the following exception: `EnPT` depends on tqdm, which
18# is distributed under the Mozilla Public Licence (MPL) v2.0 except for the files
19# "tqdm/_tqdm.py", "setup.py", "README.rst", "MANIFEST.in" and ".gitignore".
20# Details can be found here: https://github.com/tqdm/tqdm/blob/master/LICENCE.
21#
22# This program is distributed in the hope that it will be useful, but WITHOUT
23# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
24# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
25# details.
26#
27# You should have received a copy of the GNU Lesser General Public License along
28# with this program. If not, see <https://www.gnu.org/licenses/>.
30"""EnPT EnMAP object base classes."""
32from types import SimpleNamespace
33from typing import List, Optional # noqa: F401
34import numpy as np
36# noinspection PyPackageRequirements
37from skimage import exposure # contained in package requirements as scikit-image
39from geoarray import GeoArray
41from ...model.metadata import EnMAP_Metadata_L2A_MapGeo # noqa: F401 # only used for type hint
43__author__ = ['Daniel Scheffler', 'Stéphane Guillaso', 'André Hollstein']
46class _EnMAP_Image(object):
47 """EnPT base class for all kinds of EnMAP images.
49 NOTE:
50 - Basic functionality that all EnMAP image objects have in common is to be implemented here.
51 - All EnMAP image classes should (directly or indirectly) inherit from _EnMAP_image.
53 Attributes:
54 - to be listed here. Check help(_EnMAP_image) in the meanwhile!
56 """
58 def __init__(self):
59 """Load and hold data needed for processing EnMAP Level-1B data to Level-2A.
61 Intended usage:
62 - only to be used as a base class to be inherited from!
63 - Example:
64 class EnMAP_Image_Subclass(_EnMAP_Image):
65 def __init__(self):
66 super(EnMAP_Image_Subclass, self).__init__()
68 """
69 # add EnMAP specific attributes
70 self.paths = SimpleNamespace()
72 # private attributes
73 self._data = None
74 self._mask_landwater = None
75 self._mask_clouds = None
76 self._mask_cloudshadow = None
77 self._mask_haze = None
78 self._mask_snow = None
79 self._mask_cirrus = None
80 self._dem = None
81 self._deadpixelmap = None
82 self._polymer_logchl = None
83 self._polymer_logfb = None
84 self._polymer_rgli = None
85 self._polymer_rnir = None
86 self._polymer_bitmask = None
87 self._subset = None # FIXME how is _subset to be set?
89 # defaults
90 self.entity_ID = ''
91 self.basename = ''
93 @property
94 def data(self) -> GeoArray:
95 """Return the actual EnMAP image data.
97 Bundled with all the corresponding metadata.
99 Attributes and functions (most important; for a full list check help(self.data)!):
101 - ALL attributes of numpy.ndarray!
102 - is_inmem(bool):
103 True if the image data are completely loaded into memory; False if GeoArray only holds a link to a file
104 on disk.
105 - arr: np.ndarray holding the pixel values (if is_mem is True)
106 - rows(int)
107 - cols(int)
108 - bands(int)
109 - shape(tuple)
110 - gt(list): GDAL geotransform: contains the geocoding
111 - prj(str): WKT projection string
112 - show(): plot the image
113 - show_map(): plot a map of the image (based on cartopy library)
114 - reproject_to_new_grid()
116 Usage (there will soon be detailed instructions on usage at https://git.gfz-potsdam.de/danschef/geoarray):
118 - Use self.data like a normal numpy.ndarray!
119 - NOTE: Operators like *, /, + , - will soon be implemented. In the meanwhile use:
120 result = self.data[:] *10
122 - How to set self.data?
123 - Link an image file to self.data -> all raster data is read into memory ON DEMAND:
124 self.data = '/path/to/image.tif' # sets self.data to GeoArray('/path/to/image.tif')
126 - Link a numpy.ndarray instance with self.data (remaining attributes like geocoding, projection, etc.
127 are copied from the previous self.data attribute).
128 self.data = numpy.array([[1,2,3],[4,5,6]])
130 - Set self.data to an existing instance of GeoArray
131 (allows to set specific attributes of GeoArray by yourself)
132 self.data = GeoArray('/path/to/image.tif', geotransform=[...], projection='WKTString')
134 - Delete self.data:
135 del self.data or 'self.data = None'
137 :return: instance of geoarray.GeoArray
138 """
139 if self._subset is None:
140 return self._data
142 return GeoArray(self._data[self._subset[0]:self._subset[1], self._subset[2]:self._subset[3], :],
143 geotransform=self._data.gt, projection=self._data.prj)
145 @data.setter
146 def data(self, *geoArr_initArgs):
147 if geoArr_initArgs[0] is not None:
148 # TODO this must be able to handle subset inputs in tiled processing
149 if isinstance(geoArr_initArgs[0], np.ndarray):
150 self._data = GeoArray(geoArr_initArgs[0], geotransform=self._data.gt, projection=self._data.prj)
151 else:
152 self._data = GeoArray(*geoArr_initArgs)
153 else:
154 del self.data
156 @data.deleter
157 def data(self):
158 self._data = None
160 @property
161 def mask_landwater(self) -> GeoArray:
162 """Return the land/water mask.
164 pixel values:
165 - 0: background within scene dimensions, e.g. due to missing values/errors
166 - 1: no water
167 - 2: water
168 - 3: background outside the scene dimensions (artifact from resampling between map and sensor geometry)
170 :return: geoarray.GeoArray
171 """
172 return self._mask_landwater
174 @mask_landwater.setter
175 def mask_landwater(self, *geoArr_initArgs):
176 self._mask_landwater = self._get_geoarray_with_datalike_geometry(geoArr_initArgs, 'mask_landwater', nodataVal=3)
178 @mask_landwater.deleter
179 def mask_landwater(self):
180 self._mask_landwater = None
182 @property
183 def mask_clouds(self) -> GeoArray:
184 """Return the cloud mask.
186 :return: geoarray.GeoArray
187 """
188 return self._mask_clouds
190 @mask_clouds.setter
191 def mask_clouds(self, *geoArr_initArgs):
192 self._mask_clouds = self._get_geoarray_with_datalike_geometry(geoArr_initArgs, 'mask_clouds', nodataVal=0)
194 @mask_clouds.deleter
195 def mask_clouds(self):
196 self._mask_clouds = None
198 @property
199 def mask_cloudshadow(self) -> GeoArray:
200 """Return the cloud shadow mask (0=no cloud shadow, 1=cloud shadow).
202 :return: geoarray.GeoArray
203 """
204 return self._mask_cloudshadow
206 @mask_cloudshadow.setter
207 def mask_cloudshadow(self, *geoArr_initArgs):
208 self._mask_cloudshadow = \
209 self._get_geoarray_with_datalike_geometry(geoArr_initArgs, 'mask_cloudshadow', nodataVal=0)
211 @mask_cloudshadow.deleter
212 def mask_cloudshadow(self):
213 self._mask_cloudshadow = None
215 @property
216 def mask_haze(self) -> GeoArray:
217 """Return the haze mask (0=no haze, 1=haze)..
219 :return: geoarray.GeoArray
220 """
221 return self._mask_haze
223 @mask_haze.setter
224 def mask_haze(self, *geoArr_initArgs):
225 self._mask_haze = self._get_geoarray_with_datalike_geometry(geoArr_initArgs, 'mask_haze', nodataVal=0)
227 @mask_haze.deleter
228 def mask_haze(self):
229 self._mask_haze = None
231 @property
232 def mask_snow(self) -> GeoArray:
233 """Return the snow mask (0=no snow, 1=snow).
235 :return: geoarray.GeoArray
236 """
237 return self._mask_snow
239 @mask_snow.setter
240 def mask_snow(self, *geoArr_initArgs):
241 self._mask_snow = self._get_geoarray_with_datalike_geometry(geoArr_initArgs, 'mask_snow', nodataVal=0)
243 @mask_snow.deleter
244 def mask_snow(self):
245 self._mask_snow = None
247 @property
248 def mask_cirrus(self) -> GeoArray:
249 """Return the cirrus mask (0=none, 1=thin, 2=medium, 3=thick).
251 :return: geoarray.GeoArray
252 """
253 return self._mask_cirrus
255 @mask_cirrus.setter
256 def mask_cirrus(self, *geoArr_initArgs):
257 self._mask_cirrus = self._get_geoarray_with_datalike_geometry(geoArr_initArgs, 'mask_cirrus', nodataVal=0)
259 @mask_cirrus.deleter
260 def mask_cirrus(self):
261 self._mask_cirrus = None
263 @property
264 def dem(self) -> GeoArray:
265 """Return a DEM in the exact dimension and pixel grid of self.data.
267 :return: geoarray.GeoArray
268 """
269 if self._dem is None:
270 raise NotImplementedError('DEM is missing. An automatic DEM getter is currently not implemented.')
271 return self._dem
273 @dem.setter
274 def dem(self, *geoArr_initArgs):
275 self._dem = self._get_geoarray_with_datalike_geometry(geoArr_initArgs, 'dem', nodataVal=0) # FIXME 0?
277 @dem.deleter
278 def dem(self):
279 self._dem = None
281 @property
282 def deadpixelmap(self) -> GeoArray:
283 """Return the dead pixel map.
285 :return: geoarray.GeoArray
286 """
287 if self._deadpixelmap is not None:
288 self._deadpixelmap.arr = self._deadpixelmap[:].astype(bool) # ensure boolean map
290 return self._deadpixelmap
292 @deadpixelmap.setter
293 def deadpixelmap(self, *geoArr_initArgs):
294 if geoArr_initArgs[0] is not None:
295 dpm = GeoArray(*geoArr_initArgs)
297 if dpm.ndim == 3 and dpm.shape != self.data.shape:
298 raise ValueError("The 'deadpixelmap' GeoArray can only be instanced with a 3D array with the same size "
299 "like %s.data, i.e.: %s Received %s."
300 % (self.__class__.__name__, str(self.data.shape), str(dpm.shape)))
301 elif dpm.ndim == 2 and dpm.shape != (self.data.bands, self.data.cols):
302 raise ValueError("The 'deadpixelmap' GeoArray can only be instanced with an array with the size "
303 "'bands x columns' of the GeoArray %s.data. Received %s. Expected %s"
304 % (self.__class__.__name__, str(dpm.shape), str((self.data.bands, self.data.cols))))
306 self._deadpixelmap = dpm
307 else:
308 del self.deadpixelmap
310 @deadpixelmap.deleter
311 def deadpixelmap(self):
312 self._deadpixelmap = None
314 @property
315 def polymer_logchl(self) -> GeoArray:
316 """Chlorophyll concentration, in mg/m3, in 10-based logarithm (generated by Polymer AC for water areas).
318 Data range: -3 to 2.3
320 :return: geoarray.GeoArray
321 """
322 return self._polymer_logchl
324 @polymer_logchl.setter
325 def polymer_logchl(self, *geoArr_initArgs):
326 self._polymer_logchl = self._get_geoarray_with_datalike_geometry(geoArr_initArgs, 'polymer_logchl',
327 nodataVal=-9999)
329 @polymer_logchl.deleter
330 def polymer_logchl(self):
331 self._polymer_logchl = None
333 @property
334 def polymer_logfb(self) -> GeoArray:
335 """Particle scattering factor fb in Park & Ruddick (2005) (in 10-based logarithm; generated by Polymer AC).
337 Data range: positive and negative values, maximum range not defined
339 :return: geoarray.GeoArray
340 """
341 return self._polymer_logfb
343 @polymer_logfb.setter
344 def polymer_logfb(self, *geoArr_initArgs):
345 self._polymer_logfb = self._get_geoarray_with_datalike_geometry(geoArr_initArgs, 'polymer_logfb',
346 nodataVal=-9999)
348 @polymer_logfb.deleter
349 def polymer_logfb(self):
350 self._polymer_logfb = None
352 @property
353 def polymer_rgli(self) -> GeoArray:
354 """Reflectance of the sun glint predicted from ECMWF wind speed (generated by Polymer AC for water areas).
356 Typical data range: 0-0.2
358 :return: geoarray.GeoArray
359 """
360 return self._polymer_rgli
362 @polymer_rgli.setter
363 def polymer_rgli(self, *geoArr_initArgs):
364 self._polymer_rgli = self._get_geoarray_with_datalike_geometry(geoArr_initArgs, 'polymer_rgli',
365 nodataVal=-9999)
367 @polymer_rgli.deleter
368 def polymer_rgli(self):
369 self._polymer_rgli = None
371 @property
372 def polymer_rnir(self) -> GeoArray:
373 """TOA reflectance at 865 nm corrected for Rayleigh scattering (generated by Polymer AC for water areas).
375 Typical data range: 0-0.2
377 :return: geoarray.GeoArray
378 """
379 return self._polymer_rnir
381 @polymer_rnir.setter
382 def polymer_rnir(self, *geoArr_initArgs):
383 self._polymer_rnir = self._get_geoarray_with_datalike_geometry(geoArr_initArgs, 'polymer_rnir',
384 nodataVal=-9999)
386 @polymer_rnir.deleter
387 def polymer_rnir(self):
388 self._polymer_rnir = None
390 @property
391 def polymer_bitmask(self) -> GeoArray:
392 """Quality flags as generated by Polymer AC (bit-encoded mask).
394 Value 0 represents water, all fine, no flags. Other pixel values are explained below:
396 +--------------------+-------------+--------------------------------------------+
397 | Flag name | Flag value | Description |
398 +====================+=============+============================================+
399 | LAND | 1 | Land mask |
400 +--------------------+-------------+--------------------------------------------+
401 | CLOUD_BASE | 2 | Polymer's basic cloud mask |
402 +--------------------+-------------+--------------------------------------------+
403 | L1_INVALID | 4 | Invalid level1 pixel |
404 +--------------------+-------------+--------------------------------------------+
405 | NEGATIVE_BB | 8 | (deprecated flag) |
406 +--------------------+-------------+--------------------------------------------+
407 | OUT_OF_BOUNDS | 16 | Retrieved marine parameters are outside |
408 | | | valid bounds |
409 +--------------------+-------------+--------------------------------------------+
410 | EXCEPTION | 32 | A processing error was encountered |
411 +--------------------+-------------+--------------------------------------------+
412 | THICK_AEROSOL | 64 | Thick aerosol flag |
413 +--------------------+-------------+--------------------------------------------+
414 | HIGH_AIR_MASS | 128 | Air mass exceeds 5 |
415 +--------------------+-------------+--------------------------------------------+
416 | EXTERNAL_MASK | 512 | Pixel was masked using external mask |
417 +--------------------+-------------+--------------------------------------------+
418 | CASE2 | 1024 | Pixel was processed in "case2" mode |
419 +--------------------+-------------+--------------------------------------------+
420 | INCONSISTENCY | 2048 | Inconsistent result was detected |
421 | | | (atmospheric reflectance out of bounds) |
422 +--------------------+-------------+--------------------------------------------+
423 | ANOMALY_RWMOD_BLUE | 4096 | Excessive difference was found at 412nm |
424 | | | between Rw and Rwmod |
425 +--------------------+-------------+--------------------------------------------+
427 :return: geoarray.GeoArray
428 """
429 return self._polymer_bitmask
431 @polymer_bitmask.setter
432 def polymer_bitmask(self, *geoArr_initArgs):
433 self._polymer_bitmask = self._get_geoarray_with_datalike_geometry(geoArr_initArgs, 'polymer_bitmask',
434 nodataVal=-9999)
436 @polymer_bitmask.deleter
437 def polymer_bitmask(self):
438 self._polymer_bitmask = None
440 def _get_geoarray_with_datalike_geometry(self,
441 geoArr_initArgs: tuple,
442 attrName: str,
443 nodataVal: int = None,
444 specialclass=None) -> Optional[GeoArray]:
445 if geoArr_initArgs[0] is None:
446 return None
447 else:
448 GeoArrayOrSubclass = GeoArray if not specialclass else specialclass
449 gA = GeoArrayOrSubclass(*geoArr_initArgs)
451 if gA.shape[:2] != self.data.shape[:2]:
452 raise ValueError("The '%s' GeoArray can only be instanced with an array with "
453 "the same X/Y dimensions like %s.data %s. Got %s." %
454 (attrName, self.__class__.__name__, str(self.data.shape[:2]), str(gA.shape[:2])))
456 # noinspection PyProtectedMember
457 if gA._nodata is None and nodataVal is not None:
458 gA.nodata = nodataVal
459 gA.gt = self.data.gt
460 gA.prj = self.data.prj
462 return gA
464 def generate_quicklook(self, bands2use: List[int]) -> GeoArray:
465 """
466 Generate image quicklook and save into a file.
468 :param bands2use: band indices of self.data to be used as (red, green, blue) for quicklook image,
469 e.g., [3, 2, 1]
470 :return: GeoArray
471 """
472 red, green, blue = [self.data[:, :, bandidx] for bandidx in bands2use]
474 def rescale(bandarray):
475 pixvals = np.ma.masked_equal(bandarray, self.data.nodata).compressed() \
476 if self.data.nodata is not None else bandarray
477 p2, p98 = np.nanpercentile(pixvals, 2), np.nanpercentile(pixvals, 98)
479 return exposure.rescale_intensity(bandarray, in_range=(p2, p98), out_range=(0, 255))
481 pix = np.dstack((rescale(red), rescale(green), rescale(blue))).astype(np.uint8)
483 return GeoArray(pix)