Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# -*- coding: utf-8 -*- 

2 

3# spechomo, Spectral homogenization of multispectral satellite data 

4# 

5# Copyright (C) 2019-2021 

6# - Daniel Scheffler (GFZ Potsdam, daniel.scheffler@gfz-potsdam.de) 

7# - Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences Potsdam, 

8# Germany (https://www.gfz-potsdam.de/) 

9# 

10# This software was developed within the context of the GeoMultiSens project funded 

11# by the German Federal Ministry of Education and Research 

12# (project grant code: 01 IS 14 010 A-C). 

13# 

14# Licensed under the Apache License, Version 2.0 (the "License"); 

15# you may not use this file except in compliance with the License. 

16# You may obtain a copy of the License at 

17# 

18# http://www.apache.org/licenses/LICENSE-2.0 

19# 

20# Please note the following exception: `spechomo` depends on tqdm, which is 

21# distributed under the Mozilla Public Licence (MPL) v2.0 except for the files 

22# "tqdm/_tqdm.py", "setup.py", "README.rst", "MANIFEST.in" and ".gitignore". 

23# Details can be found here: https://github.com/tqdm/tqdm/blob/master/LICENCE. 

24# 

25# Unless required by applicable law or agreed to in writing, software 

26# distributed under the License is distributed on an "AS IS" BASIS, 

27# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

28# See the License for the specific language governing permissions and 

29# limitations under the License. 

30 

31from multiprocessing import Pool 

32from typing import Union, TYPE_CHECKING # noqa F401 # flake8 issue 

33 

34import numpy as np 

35from geoarray import GeoArray 

36from geoarray.baseclasses import get_array_tilebounds 

37 

38from .logging import SpecHomo_Logger 

39 

40if TYPE_CHECKING: 

41 from pyrsr import RSR # noqa F401 # flake8 issue 

42 

43 

44class SpectralResampler(object): 

45 """Class for spectral resampling of a single spectral signature (1D-array) or an image (3D-array).""" 

46 

47 def __init__(self, wvl_src, rsr_tgt, logger=None): 

48 # type: (np.ndarray, RSR, str) -> None 

49 """Get an instance of the SpectralResampler class. 

50 

51 :param wvl_src: center wavelength positions of the source spectrum 

52 :param rsr_tgt: relative spectral response of the target instrument as an instance of 

53 pyrsr.RelativeSpectralRespnse. 

54 """ 

55 # privates 

56 self._wvl_1nm = None 

57 self._rsr_1nm = {} 

58 

59 wvl_src = np.array(wvl_src, dtype=float).flatten() 

60 if rsr_tgt.wvl_unit != 'nanometers': 

61 rsr_tgt.convert_wvl_unit() 

62 

63 self.wvl_src_nm = wvl_src if max(wvl_src) > 100 else wvl_src * 1000 

64 self.rsr_tgt = rsr_tgt 

65 self.logger = logger or SpecHomo_Logger(__name__) # must be pickable 

66 

67 @property 

68 def wvl_1nm(self): 

69 # spectral resampling of input image to 1 nm resolution 

70 if self._wvl_1nm is None: 

71 self._wvl_1nm = np.arange(np.ceil(self.wvl_src_nm.min()), np.floor(self.wvl_src_nm.max()), 1) 

72 return self._wvl_1nm 

73 

74 @property 

75 def rsr_1nm(self): 

76 if not self._rsr_1nm: 

77 from scipy.interpolate import interp1d # import here to avoid static TLS ImportError 

78 

79 for band in self.rsr_tgt.bands: 

80 # resample RSR to 1 nm 

81 self._rsr_1nm[band] = \ 

82 interp1d(self.rsr_tgt.rsrs_wvl, self.rsr_tgt.rsrs[band], 

83 bounds_error=False, fill_value=0, kind='linear')(self.wvl_1nm) 

84 

85 # validate 

86 assert len(self._rsr_1nm[band]) == len(self.wvl_1nm) 

87 

88 return self._rsr_1nm 

89 

90 def resample_signature(self, spectrum, scale_factor=10000, nodataVal=None, alg_nodata='radical', v=False): 

91 # type: (np.ndarray, int, Union[int, float], str, bool) -> np.ndarray 

92 """Resample the given spectrum according to the spectral response functions of the target instument. 

93 

94 :param spectrum: spectral signature data 

95 :param scale_factor: the scale factor to apply to the given spectrum when it is plotted (default: 10000) 

96 :param nodataVal: no data value to be respected during resampling 

97 :param alg_nodata: algorithm how to deal with pixels where the spectral bands of the source image 

98 contain nodata within the spectral response of a target band 

99 'radical': set output band to nodata 

100 'conservative': use existing spectral information and ignore nodata 

101 (might alter the outpur spectral information, 

102 e.g., at spectral absorption bands) 

103 :param v: enable verbose mode (shows a plot of the resampled spectrum) (default: False) 

104 :return: resampled spectral signature 

105 """ 

106 if not spectrum.ndim == 1: 

107 raise ValueError("The array of the given spectral signature must be 1-dimensional. " 

108 "Received a %s-dimensional array." % spectrum.ndim) 

109 spectrum = np.array(spectrum, dtype=float).flatten() 

110 assert spectrum.size == self.wvl_src_nm.size 

111 

112 # convert spectrum to one multispectral image pixel and resample it 

113 spectrum_rsp = \ 

114 self.resample_image(spectrum.reshape(1, 1, spectrum.size), 

115 tiledims=(1, 1), 

116 nodataVal=nodataVal, 

117 alg_nodata=alg_nodata, 

118 CPUs=1)\ 

119 .ravel() 

120 

121 if v: 

122 from matplotlib import pyplot as plt 

123 

124 plt.figure() 

125 for band in self.rsr_tgt.bands: 

126 plt.plot(self.wvl_1nm, self.rsr_1nm[band] / max(self.rsr_1nm[band])) 

127 plt.plot(self.wvl_src_nm, spectrum / scale_factor, '.') 

128 plt.plot(self.rsr_tgt.wvl, spectrum_rsp / scale_factor, 'x', color='r') 

129 plt.show() 

130 

131 return spectrum_rsp 

132 

133 def resample_spectra(self, spectra, chunksize=200, nodataVal=None, alg_nodata='radical', CPUs=None): 

134 # type: (Union[GeoArray, np.ndarray], int, Union[int, float], str, Union[None, int]) -> np.ndarray 

135 """Resample the given spectral signatures according to the spectral response functions of the target instrument. 

136 

137 :param spectra: spectral signatures, provided as 2D array 

138 (rows: spectral samples, columns: spectral information / bands) 

139 :param chunksize: defines how many spectral signatures are resampled per CPU 

140 :param nodataVal: no data value to be respected during resampling 

141 :param alg_nodata: algorithm how to deal with pixels where the spectral bands of the source image 

142 contain nodata within the spectral response of a target band 

143 'radical': set output band to nodata 

144 'conservative': use existing spectral information and ignore nodata 

145 (might alter the outpur spectral information, 

146 e.g., at spectral absorption bands) 

147 :param CPUs: CPUs to use for processing 

148 """ 

149 # input validation 

150 if not spectra.ndim == 2: 

151 ValueError("The given spectra array must be 2-dimensional. Received a %s-dimensional array." 

152 % spectra.ndim) 

153 assert spectra.shape[1] == self.wvl_src_nm.size 

154 

155 # convert spectra to one multispectral image column 

156 im_col = spectra.reshape(spectra.shape[0], 1, spectra.shape[1]) 

157 

158 im_col_rsp = self.resample_image(im_col, 

159 tiledims=(1, chunksize), 

160 nodataVal=nodataVal, 

161 alg_nodata=alg_nodata, 

162 CPUs=CPUs) 

163 spectra_rsp = im_col_rsp.reshape(im_col_rsp.shape[0], im_col_rsp.shape[2]) 

164 

165 return spectra_rsp 

166 

167 def resample_image(self, image_cube, tiledims=(20, 20), nodataVal=None, alg_nodata='radical', CPUs=None): 

168 # type: (Union[GeoArray, np.ndarray], tuple, Union[int, float], str, Union[None, int]) -> np.ndarray 

169 """Resample the given spectral image cube according to the spectral response functions of the target instrument. 

170 

171 :param image_cube: image (3D array) containing the spectral information in the third dimension 

172 :param tiledims: dimension of tiles to be used during computation (rows, columns) 

173 :param nodataVal: nodata value of the input image 

174 :param alg_nodata: algorithm how to deal with pixels where the spectral bands of the source image 

175 contain nodata within the spectral response of a target band 

176 'radical': set output band to nodata 

177 'conservative': use existing spectral information and ignore nodata 

178 (might alter the output spectral information, 

179 e.g., at spectral absorption bands) 

180 :param CPUs: CPUs to use for processing 

181 :return: resampled spectral image cube 

182 """ 

183 # input validation 

184 if not image_cube.ndim == 3: 

185 raise ValueError("The given image cube must be 3-dimensional. Received a %s-dimensional array." 

186 % image_cube.ndim) 

187 assert image_cube.shape[2] == self.wvl_src_nm.size 

188 

189 image_cube = GeoArray(image_cube) 

190 

191 (R, C), B = image_cube.shape[:2], len(self.rsr_tgt.bands) 

192 image_rsp = np.zeros((R, C, B), dtype=image_cube.dtype) 

193 

194 initargs = image_cube, self.rsr_tgt, self.rsr_1nm, self.wvl_src_nm, self.wvl_1nm 

195 if CPUs is None or CPUs > 1: 

196 with Pool(CPUs, initializer=_initializer_mp, initargs=initargs) as pool: 

197 tiles_rsp = pool.starmap(_resample_tile_mp, 

198 [(bounds, nodataVal, alg_nodata) 

199 for bounds in get_array_tilebounds(image_cube.shape, tiledims)]) 

200 pool.close() # needed to make coverage work in multiprocessing 

201 pool.join() 

202 

203 else: 

204 _initializer_mp(*initargs) 

205 tiles_rsp = [_resample_tile_mp(bounds, nodataVal, alg_nodata) 

206 for bounds in get_array_tilebounds(image_cube.shape, tiledims)] 

207 

208 for ((rS, rE), (cS, cE)), tile_rsp in tiles_rsp: 

209 image_rsp[rS: rE + 1, cS: cE + 1, :] = tile_rsp 

210 

211 return image_rsp 

212 

213 

214_globs_mp = dict() 

215 

216 

217def _initializer_mp(image_cube, rsr_tgt, rsr_1nm, wvl_src_nm, wvl_1nm): 

218 # type: (np.ndarray, RSR, RSR, np.ndarray, np.ndarray) -> None 

219 global _globs_mp 

220 _globs_mp.update(dict( 

221 image_cube=image_cube, 

222 rsr_tgt=rsr_tgt, 

223 rsr_1nm=rsr_1nm, 

224 wvl_src_nm=wvl_src_nm, 

225 wvl_1nm=wvl_1nm, 

226 )) 

227 

228 

229def _resample_tile_mp(tilebounds, nodataVal=None, alg_nodata='radical'): 

230 # TODO speed up by using numba as described here https://krstn.eu/fast-linear-1D-interpolation-with-numba/ 

231 

232 from scipy.interpolate import interp1d 

233 

234 (rS, rE), (cS, cE) = tilebounds 

235 

236 # get global share variables 

237 tiledata = _globs_mp['image_cube'][rS: rE + 1, cS: cE + 1, :] # type: Union[GeoArray, np.ndarray] 

238 rsr_tgt = _globs_mp['rsr_tgt'] 

239 rsr_1nm = _globs_mp['rsr_1nm'] 

240 wvl_src_nm = _globs_mp['wvl_src_nm'] 

241 wvl_1nm = _globs_mp['wvl_1nm'] 

242 

243 if alg_nodata not in ['radical', 'conservative']: 

244 raise ValueError(alg_nodata) 

245 

246 tile_rsp = np.zeros((*tiledata.shape[:2], len(rsr_tgt.bands)), dtype=tiledata.dtype) 

247 

248 if nodataVal is not None: 

249 if np.isfinite(nodataVal): 

250 _mask_bool3d = tiledata == nodataVal 

251 mask_anynodata = np.any(_mask_bool3d, axis=2) 

252 mask_allnodata = np.all(_mask_bool3d, axis=2) 

253 else: 

254 raise NotImplementedError(nodataVal) 

255 

256 tiledata = tiledata.astype(float) 

257 tiledata[_mask_bool3d] = np.nan 

258 

259 # fill pixels with all bands nodata 

260 tile_rsp[mask_allnodata] = nodataVal 

261 

262 # upsample spectra containing data to 1nm and get nan-mask 

263 tilespectra_1nm = interp1d(wvl_src_nm, tiledata[~mask_allnodata], 

264 axis=1, bounds_error=False, fill_value=0, kind='linear')(wvl_1nm) 

265 isnan = np.isnan(tilespectra_1nm) 

266 nan_in_spec = np.any(isnan, axis=1) 

267 

268 # compute resampled values for pixels without nodata 

269 tilespectra_1nm_nonan = tilespectra_1nm[~nan_in_spec, :] 

270 for band_idx, band in enumerate(rsr_tgt.bands): 

271 # compute the resampled image cube (np.average computes the weighted mean value) 

272 if rsr_1nm[band].sum() != 0: 

273 res = np.average(tilespectra_1nm_nonan, weights=rsr_1nm[band], axis=1) 

274 # NOTE: rounding here is important to prevent -9999. converted to -9998 

275 if not np.issubdtype(tile_rsp.dtype, np.floating): 

276 res = np.around(res) 

277 else: 

278 res = nodataVal 

279 

280 tile_rsp[~mask_anynodata, band_idx] = res 

281 

282 # compute resampled values for pixels with nodata 

283 tilespectra_1nm_withnan = tilespectra_1nm[nan_in_spec, :] 

284 tilespectra_1nm_withnan_ma = np.ma.masked_invalid(tilespectra_1nm_withnan) 

285 mask_withnan = mask_anynodata & ~mask_allnodata 

286 isnan_sub = isnan[nan_in_spec, :] 

287 

288 for band_idx, band in enumerate(rsr_tgt.bands): 

289 res_ma = np.ma.average(tilespectra_1nm_withnan_ma, axis=1, weights=rsr_1nm[band]) 

290 

291 # in case all 1nm bands for the current band are NaN, the resulting average will also be NaN => fill 

292 res = np.ma.filled(res_ma, nodataVal) 

293 

294 if alg_nodata == 'radical': 

295 # set those output values to nodata where the input bands within the target RSR contain any nodata 

296 badspec = np.any(isnan_sub & (rsr_1nm[band] > 0), axis=1) 

297 res[badspec] = nodataVal 

298 

299 if not np.issubdtype(tile_rsp.dtype, np.floating): 

300 res = np.around(res) 

301 

302 tile_rsp[mask_withnan, band_idx] = res 

303 

304 else: 

305 # spectral resampling of input image to 1 nm resolution 

306 tile_1nm = interp1d(wvl_src_nm, tiledata, 

307 axis=2, bounds_error=False, fill_value=0, kind='linear')(wvl_1nm) 

308 

309 for band_idx, band in enumerate(rsr_tgt.bands): 

310 # compute the resampled image cube (np.average computes the weighted mean value) 

311 res = np.average(tile_1nm, weights=rsr_1nm[band], axis=2) 

312 # NOTE: rounding here is important to prevent -9999. converted to -9998 

313 tile_rsp[:, :, band_idx] = res if np.issubdtype(tile_rsp.dtype, np.floating) else np.around(res) 

314 

315 return tilebounds, tile_rsp