Coverage for arosics/CoReg.py: 86%

735 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-04-03 14:59 +0000

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

2 

3# AROSICS - Automated and Robust Open-Source Image Co-Registration Software 

4# 

5# Copyright (C) 2017-2024 

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# https://www.apache.org/licenses/LICENSE-2.0 

19# 

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

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

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

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

24# limitations under the License. 

25 

26import os 

27import time 

28import warnings 

29from copy import copy 

30from typing import Iterable, Union, Tuple, List, Optional # noqa F401 

31from multiprocessing import cpu_count 

32 

33# custom 

34from osgeo import gdal # noqa 

35import numpy as np 

36from scipy.fft import fft2, ifft2, fftshift 

37 

38from packaging.version import parse as parse_version 

39try: 

40 import pyfftw 

41 # pyfftw>=0.13.0 is currently not used due to https://github.com/pyFFTW/pyFFTW/issues/294 

42 if parse_version(pyfftw.__version__) >= parse_version('0.13.0'): 

43 pyfftw = None 

44except ImportError: 

45 pyfftw = None 

46from shapely.geometry import Point, Polygon 

47 

48# internal modules 

49from .DeShifter import DESHIFTER, _dict_rspAlg_rsp_Int 

50from . import geometry as GEO 

51from . import plotting as PLT 

52 

53from geoarray import GeoArray 

54from py_tools_ds.convenience.object_oriented import alias_property 

55from py_tools_ds.geo.coord_calc import get_corner_coordinates 

56from py_tools_ds.geo.vector.topology import get_overlap_polygon, get_smallest_boxImYX_that_contains_boxMapYX 

57from py_tools_ds.geo.projection import prj_equal 

58from py_tools_ds.geo.vector.geometry import boxObj, round_shapelyPoly_coords 

59from py_tools_ds.geo.coord_grid import move_shapelyPoly_to_image_grid, is_coord_grid_equal 

60from py_tools_ds.geo.coord_trafo import reproject_shapelyGeometry, mapXY2imXY, imXY2mapXY 

61from py_tools_ds.geo.raster.reproject import warp_ndarray 

62from py_tools_ds.geo.map_info import geotransform2mapinfo 

63from py_tools_ds.io.vector.writer import write_shp 

64 

65__author__ = 'Daniel Scheffler' 

66 

67 

68class GeoArray_CoReg(GeoArray): 

69 def __init__(self, 

70 CoReg_params: dict, 

71 imID: str 

72 ) -> None: 

73 assert imID in ['ref', 'shift'] 

74 

75 # run GeoArray init 

76 path_or_geoArr = CoReg_params['im_ref'] if imID == 'ref' else CoReg_params['im_tgt'] 

77 nodata = CoReg_params['nodata'][0 if imID == 'ref' else 1] 

78 progress = CoReg_params['progress'] 

79 q = CoReg_params['q'] if not CoReg_params['v'] else False 

80 

81 super(GeoArray_CoReg, self).__init__(path_or_geoArr, nodata=nodata, progress=progress, q=q) 

82 

83 self.imID = imID 

84 self.imName = 'reference image' if imID == 'ref' else 'image to be shifted' 

85 self.v = CoReg_params['v'] 

86 

87 assert isinstance(self, GeoArray), \ 

88 'Something went wrong with the creation of GeoArray instance for the %s. The created ' \ 

89 'instance does not seem to belong to the GeoArray class. If you are working in Jupyter Notebook, reset ' \ 

90 'the kernel and try again.' % self.imName 

91 

92 # set title to be used in plots 

93 self.title = os.path.basename(self.filePath) if self.filePath else self.imName 

94 

95 # validate params 

96 # assert self.prj, 'The %s has no projection.' % self.imName # TODO 

97 # assert not re.search('LOCAL_CS', self.prj), 'The %s is not georeferenced.' % self.imName # TODO 

98 assert self.gt, 'The %s has no map information.' % self.imName 

99 

100 # set band4match 

101 self.band4match = (CoReg_params['r_b4match'] if imID == 'ref' else CoReg_params['s_b4match']) - 1 

102 assert self.bands >= self.band4match + 1 >= 1, \ 

103 "The %s has %s %s. So its band number to match must be %s%s. Got %s." \ 

104 % (self.imName, self.bands, 'bands' if self.bands > 1 else 

105 'band', 'between 1 and ' if self.bands > 1 else '', self.bands, self.band4match) 

106 

107 # compute nodata mask and validate that it is not completely filled with nodata 

108 self.calc_mask_nodata(fromBand=self.band4match) # this avoids that all bands have to be read 

109 

110 if True not in self.mask_nodata[:]: 

111 raise RuntimeError(f'The {self.imName} passed to AROSICS only contains nodata values.') 

112 

113 # set footprint_poly 

114 given_footprint_poly = CoReg_params['footprint_poly_%s' % ('ref' if imID == 'ref' else 'tgt')] 

115 given_corner_coord = CoReg_params['data_corners_%s' % ('ref' if imID == 'ref' else 'tgt')] 

116 

117 if given_footprint_poly: 

118 self.footprint_poly = given_footprint_poly 

119 elif given_corner_coord is not None: 

120 self.footprint_poly = Polygon(given_corner_coord) 

121 elif not CoReg_params['calc_corners']: 

122 # use the image extent 

123 self.footprint_poly = Polygon(get_corner_coordinates(gt=self.gt, cols=self.cols, rows=self.rows)) 

124 else: 

125 # footprint_poly is calculated automatically by GeoArray 

126 if not CoReg_params['q']: 

127 print('Calculating footprint polygon and actual data corner coordinates for %s...' % self.imName) 

128 

129 with warnings.catch_warnings(record=True) as w: 

130 _ = self.footprint_poly # execute getter 

131 

132 if len(w) > 0 and 'disjunct polygon(s) outside' in str(w[-1].message): 

133 warnings.warn('The footprint of the %s contains multiple separate image parts. ' 

134 'AROSICS will only process the largest image part.' % self.imName) 

135 # FIXME use a convex hull as footprint poly 

136 

137 # validate footprint poly 

138 if not self.footprint_poly.is_valid: 

139 self.footprint_poly = self.footprint_poly.buffer(0) 

140 

141 if not self.q: 

142 print('Bounding box of calculated footprint for %s:\n\t%s' % (self.imName, self.footprint_poly.bounds)) 

143 

144 # add bad data mask 

145 given_mask = CoReg_params['mask_baddata_%s' % ('ref' if imID == 'ref' else 'tgt')] 

146 if given_mask is not None: 

147 self.mask_baddata = given_mask # runs GeoArray.mask_baddata.setter -> sets it to BadDataMask() 

148 

149 poly = alias_property('footprint_poly') # ensures that self.poly is updated if self.footprint_poly is updated 

150 

151 

152class COREG(object): 

153 """The COREG class detects and corrects global X/Y shifts between a target and reference image. 

154 

155 Geometric shifts are calculated at a specific (adjustable) image position. Correction performs a global shifting 

156 in X- or Y direction. 

157 

158 See help(COREG) for documentation! 

159 """ 

160 

161 def __init__(self, 

162 im_ref: Union[GeoArray, str], 

163 im_tgt: Union[GeoArray, str], 

164 path_out: str = None, 

165 fmt_out: str = 'ENVI', 

166 out_crea_options: list = None, 

167 r_b4match: int = 1, 

168 s_b4match: int = 1, 

169 wp: Tuple[float, float] = (None, None), 

170 ws: Tuple[int, int] = (256, 256), 

171 max_iter: int = 5, 

172 max_shift: int = 5, 

173 align_grids: bool = False, 

174 match_gsd: bool = False, 

175 out_gsd: Tuple[float] = None, 

176 target_xyGrid: List[List] = None, 

177 resamp_alg_deshift: str = 'cubic', 

178 resamp_alg_calc: str = 'cubic', 

179 footprint_poly_ref: str = None, 

180 footprint_poly_tgt: str = None, 

181 data_corners_ref: list = None, 

182 data_corners_tgt: list = None, 

183 nodata: Tuple = (None, None), 

184 calc_corners: bool = True, 

185 binary_ws: bool = True, 

186 mask_baddata_ref: Union[GeoArray, str] = None, 

187 mask_baddata_tgt: Union[GeoArray, str] = None, 

188 CPUs: int = None, 

189 force_quadratic_win: bool = True, 

190 progress: bool = True, 

191 v: bool = False, 

192 path_verbose_out: str = None, 

193 q: bool = False, 

194 ignore_errors: bool = False 

195 ) -> None: 

196 """Get an instance of the COREG class. 

197 

198 :param im_ref: 

199 source path or GeoArray instance of reference image 

200 (any GDAL compatible image format is supported) 

201 

202 :param im_tgt: 

203 source path or GeoArray instance of the target image, i.e., the image to be shifted 

204 (any GDAL compatible image format is supported) 

205 

206 :param path_out: 

207 target path of the coregistered image 

208 - if None (default), the method correct_shifts() does not write to disk 

209 - if 'auto': /dir/of/im1/<im1>__shifted_to__<im0>.bsq 

210 

211 :param fmt_out: 

212 raster file format for output file. ignored if path_out is None. can be any GDAL compatible raster file 

213 format (e.g. 'ENVI', 'GTIFF'; default: ENVI). Refer to https://gdal.org/drivers/raster/index.html to get a 

214 full list of supported formats. 

215 

216 :param out_crea_options: 

217 GDAL creation options for the output image, e.g. ["QUALITY=80", "REVERSIBLE=YES", "WRITE_METADATA=YES"] 

218 

219 :param r_b4match: 

220 band of reference image to be used for matching (starts with 1; default: 1) 

221 

222 :param s_b4match: 

223 band of shift image to be used for matching (starts with 1; default: 1) 

224 

225 :param wp: 

226 custom matching window position as (X, Y) map coordinate in the same projection as the reference image 

227 (default: central position of image overlap) 

228 

229 :param ws: 

230 custom matching window size [pixels] as (X, Y) tuple (default: (256,256)) 

231 

232 :param max_iter: 

233 maximum number of iterations for matching (default: 5) 

234 

235 :param max_shift: 

236 maximum shift distance in reference image pixel units (default: 5 px) 

237 

238 :param align_grids: 

239 True: align the input coordinate grid to the reference (does not affect the output pixel size as long as 

240 input and output pixel sizes are compatible (5:30 or 10:30 but not 4:30)), default = False 

241 

242 - NOTE: If this is set to False, the mis-registration in the target image is corrected by updating its 

243 geocoding information only, i.e., without performing any resampling which preserves the original 

244 image values (except that a resampling is needed due to other reasons, e.g., if 'match_gsd', 

245 'out_gsd' or 'target_xyGrid' are given). 

246 

247 :param match_gsd: 

248 True: match the input pixel size to the reference pixel size, 

249 default = False 

250 

251 :param out_gsd: 

252 output pixel size (X/Y) in units of the reference coordinate system (default = pixel size of the target 

253 image), given values are overridden by match_gsd=True 

254 

255 :param target_xyGrid: 

256 a list with a target x-grid and a target y-grid like [[15,45], [15,45]] 

257 This overrides 'out_gsd', 'align_grids' and 'match_gsd'. 

258 

259 :param resamp_alg_deshift: 

260 the resampling algorithm to be used for shift correction (if neccessary) 

261 - valid algorithms: nearest, bilinear, cubic, cubic_spline, lanczos, average, mode, max, min, med, q1, q3 

262 - default: cubic 

263 

264 :param resamp_alg_calc: 

265 the resampling algorithm to be used for all warping processes during calculatio of spatial shift 

266 - valid algorithms: nearest, bilinear, cubic, cubic_spline, lanczos, average, mode, max, min, med, q1, q3 

267 - default: cubic (highly recommended) 

268 

269 :param footprint_poly_ref: 

270 footprint polygon of the reference image (WKT string or shapely.geometry.Polygon), 

271 e.g. 'POLYGON ((299999 6000000, 299999 5890200, 409799 5890200, 409799 6000000, 299999 6000000))' 

272 

273 :param footprint_poly_tgt: 

274 footprint polygon of the image to be shifted (WKT string or shapely.geometry.Polygon) 

275 e.g. 'POLYGON ((299999 6000000, 299999 5890200, 409799 5890200, 409799 6000000, 299999 6000000))' 

276 

277 :param data_corners_ref: 

278 map coordinates of data corners within reference image. ignored if footprint_poly_ref is given. 

279 

280 :param data_corners_tgt: 

281 map coordinates of data corners within image to be shifted. ignored if footprint_poly_tgt is given. 

282 

283 :param nodata: 

284 no-data values for reference image and image to be shifted. The default is (None, None) which indicates 

285 that there is no specific no-data value for both of the input images. 

286 

287 :param calc_corners: 

288 calculate true positions of the dataset corners in order to get a useful matching window position within 

289 the actual image overlap (default: True; deactivated if '-cor0' and '-cor1' are given) 

290 

291 :param binary_ws: 

292 use binary X/Y dimensions for the matching window (default: True) 

293 

294 :param mask_baddata_ref: 

295 path to a 2D boolean mask file (or an instance of GeoArray) for the reference image where all bad data 

296 pixels (e.g. clouds) are marked with True and the remaining pixels with False. Must have the same 

297 geographic extent and projection like 'im_ref'. The mask is used to check if the chosen matching window 

298 position is valid in the sense of useful data. Otherwise, this window position is rejected. 

299 

300 :param mask_baddata_tgt: 

301 path to a 2D boolean mask file (or an instance of GeoArray) for the image to be shifted where all bad data 

302 pixels (e.g. clouds) are marked with True and the remaining pixels with False. Must have the same 

303 geographic extent and projection like 'im_ref'. The mask is used to check if the chosen matching window 

304 position is valid in the sense of useful data. Otherwise, this window position is rejected. 

305 

306 :param CPUs: 

307 number of CPUs to use during pixel grid equalization (default: None, which means 'all CPUs available') 

308 

309 :param force_quadratic_win: 

310 force a quadratic matching window (default: True) 

311 

312 :param progress: 

313 show progress bars (default: True) 

314 

315 :param v: 

316 verbose mode (default: False) 

317 

318 :param path_verbose_out: 

319 an optional output directory for intermediate results 

320 (if not given, no intermediate results are written to disk) 

321 

322 :param q: 

323 quiet mode (default: False) 

324 

325 :param ignore_errors: 

326 Useful for batch processing. (default: False) 

327 In case of error COREG.success == False and COREG.x_shift_px/COREG.y_shift_px is None 

328 """ 

329 self.params = dict([x for x in locals().items() if x[0] != "self"]) 

330 

331 # input validation 

332 if gdal.GetDriverByName(fmt_out) is None: 

333 raise ValueError(fmt_out, "'%s' is not a supported GDAL driver." % fmt_out) 

334 

335 if match_gsd and out_gsd: 

336 warnings.warn("'-out_gsd' is ignored because '-match_gsd' is set.\n") 

337 

338 if out_gsd and (not isinstance(out_gsd, list) or len(out_gsd) != 2): 

339 raise ValueError(out_gsd, 'out_gsd must be a list with two values.') 

340 

341 if data_corners_ref and not isinstance(data_corners_ref[0], list): 

342 # group if not [[x,y],[x,y]..] but [x,y,x,y,] 

343 data_corners_ref = [data_corners_ref[i:i + 2] 

344 for i in range(0, len(data_corners_ref), 2)] 

345 

346 if data_corners_tgt and not isinstance(data_corners_tgt[0], list): 

347 # group if not [[x,y],[x,y]..] 

348 data_corners_tgt = [data_corners_tgt[i:i + 2] 

349 for i in range(0, len(data_corners_tgt), 2)] 

350 

351 if nodata and (not isinstance(nodata, Iterable) or len(nodata) != 2): 

352 raise ValueError(nodata, "'nodata' must be an iterable with two values. " 

353 "Got %s with length %s." % (type(nodata), len(nodata))) 

354 

355 for rspAlg in [resamp_alg_deshift, resamp_alg_calc]: 

356 if rspAlg not in _dict_rspAlg_rsp_Int.keys(): 

357 raise ValueError("'%s' is not a supported resampling algorithm." % rspAlg) 

358 

359 if resamp_alg_calc in ['average', 5] and (v or not q): 

360 warnings.warn("The resampling algorithm 'average' causes sinus-shaped patterns in fft images that will " 

361 "affect the precision of the calculated spatial shifts! It is highly recommended to " 

362 "choose another resampling algorithm.") 

363 

364 self.path_out = path_out # updated by self.set_outpathes 

365 self.fmt_out = fmt_out 

366 self.out_creaOpt = out_crea_options 

367 self.win_pos_XY = wp # updated by self.get_opt_winpos_winsize() 

368 self.win_size_XY = ws # updated by self.get_opt_winpos_winsize() 

369 self.max_iter = max_iter 

370 self.max_shift = max_shift 

371 self.align_grids = align_grids 

372 self.match_gsd = match_gsd 

373 self.out_gsd = out_gsd 

374 self.target_xyGrid = target_xyGrid 

375 self.rspAlg_DS = resamp_alg_deshift \ 

376 if isinstance(resamp_alg_deshift, str) else _dict_rspAlg_rsp_Int[resamp_alg_deshift] 

377 self.rspAlg_calc = resamp_alg_calc \ 

378 if isinstance(resamp_alg_calc, str) else _dict_rspAlg_rsp_Int[resamp_alg_calc] 

379 self.calc_corners = calc_corners 

380 self.CPUs = CPUs if CPUs and CPUs <= cpu_count() else cpu_count() 

381 self.bin_ws = binary_ws 

382 self.force_quadratic_win = force_quadratic_win 

383 self.v = v 

384 self.path_verbose_out = path_verbose_out 

385 self.q = q if not v else False # overridden by v 

386 self.progress = progress if not q else False # overridden by q 

387 

388 self.ignErr = ignore_errors 

389 self.max_win_sz_changes = 3 # TODO: änderung der window size, falls nach max_iter kein valider match gefunden 

390 self.ref: Optional[GeoArray_CoReg] = None # set by self.get_image_params 

391 self.shift: Optional[GeoArray_CoReg] = None # set by self.get_image_params 

392 self.matchBox: Optional[boxObj] = None # set by self.get_clip_window_properties() 

393 self.otherBox: Optional[boxObj] = None # set by self.get_clip_window_properties() 

394 self.matchWin: Optional[GeoArray] = None # set by self._get_image_windows_to_match() 

395 self.otherWin: Optional[GeoArray] = None # set by self._get_image_windows_to_match() 

396 self.overlap_poly = None # set by self._get_overlap_properties() 

397 self.overlap_percentage = None # set by self._get_overlap_properties() 

398 self.overlap_area = None # set by self._get_overlap_properties() 

399 self.imfft_xgsd = None # set by self.get_clip_window_properties() 

400 self.imfft_ygsd = None # set by self.get_clip_window_properties() 

401 self.fftw_works = None # set by self._calc_shifted_cross_power_spectrum() 

402 self.fftw_win_size_YX = None # set by calc_shifted_cross_power_spectrum() 

403 

404 self.x_shift_px = None # always in shift image units (image coords) # set by calculate_spatial_shifts() 

405 self.y_shift_px = None # always in shift image units (image coords) # set by calculate_spatial_shifts() 

406 self.x_shift_map = None # set by self.get_updated_map_info() 

407 self.y_shift_map = None # set by self.get_updated_map_info() 

408 self.vec_length_map = None 

409 self.vec_angle_deg = None 

410 self.updated_map_info = None # set by self.get_updated_map_info() 

411 self.ssim_orig = None # set by self._validate_ssim_improvement() 

412 self.ssim_deshifted = None # set by self._validate_ssim_improvement() 

413 self._ssim_improved = None # private attribute to be filled by self.ssim_improved 

414 self.shift_reliability = None # set by self.calculate_spatial_shifts() 

415 

416 self.tracked_errors = [] # expanded each time an error occurs 

417 self.success = None # default 

418 self.deshift_results = None # set by self.correct_shifts() 

419 

420 self._check_and_handle_metaRotation() 

421 self._get_image_params() 

422 self._set_outpathes(im_ref, im_tgt) 

423 self.grid2use = 'ref' if self.shift.xgsd <= self.ref.xgsd else 'shift' 

424 if self.v: 

425 print('resolutions: ', self.ref.xgsd, self.shift.xgsd) 

426 

427 self._get_overlap_properties() 

428 

429 if self.v and self.path_verbose_out: 

430 write_shp(os.path.join(self.path_verbose_out, 'poly_imref.shp'), self.ref.poly, self.ref.prj) 

431 write_shp(os.path.join(self.path_verbose_out, 'poly_im2shift.shp'), self.shift.poly, self.shift.prj) 

432 write_shp(os.path.join(self.path_verbose_out, 'overlap_poly.shp'), self.overlap_poly, self.ref.prj) 

433 

434 # FIXME: transform_mapPt1_to_mapPt2(im2shift_center_map, ds_imref.GetProjection(), ds_im2shift.GetProjection()) 

435 # FIXME später basteln für den fall, dass projektionen nicht gleich sind 

436 

437 # get_clip_window_properties 

438 self._get_opt_winpos_winsize() 

439 if not self.q: 

440 print('Matching window position (X,Y): %s/%s' % (self.win_pos_XY[0], self.win_pos_XY[1])) 

441 self._get_clip_window_properties() # sets self.matchBox, self.otherBox and much more 

442 

443 if self.v and self.path_verbose_out and self.matchBox.mapPoly and self.success is not False: 

444 write_shp(os.path.join(self.path_verbose_out, 'poly_matchWin.shp'), 

445 self.matchBox.mapPoly, self.matchBox.prj) 

446 

447 self.success = False if self.success is False or not self.matchBox.boxMapYX else None 

448 

449 self._coreg_info = None # private attribute to be filled by self.coreg_info property 

450 

451 def _handle_error(self, error, warn=False, warnMsg=None): 

452 """Append the given error to self.tracked_errors. 

453 

454 This sets self.success to False and raises the error in case self.ignore_errors = True. 

455 

456 :param error: instance of an error 

457 :param warn: whether to give a warning in case error would be ignored otherwise 

458 :param warnMsg: a custom message for the warning 

459 :return: 

460 """ 

461 warn = warn or warnMsg is not None or self.v 

462 

463 self.tracked_errors.append(error) 

464 self.success = False 

465 

466 if self.ignErr and warn: 

467 warnMsg = repr(error) if not warnMsg else warnMsg 

468 print('\nWARNING: ' + warnMsg) 

469 

470 if not self.ignErr: 

471 raise error 

472 

473 def _set_outpathes(self, 

474 im_ref: Union[(GeoArray, str)], 

475 im_tgt: Union[(GeoArray, str)] 

476 ) -> None: 

477 def get_baseN(path): 

478 return os.path.splitext(os.path.basename(path))[0] 

479 

480 # get input paths 

481 def get_input_path(im): 

482 path = im.filePath if isinstance(im, GeoArray) else im 

483 

484 if isinstance(im, GeoArray) and im.filePath is None and self.path_out == 'auto': 

485 raise ValueError(self.path_out, "The output path must be explicitly set in case the input " 

486 "reference or target image is in-memory (without a reference to a " 

487 "physical file on disk). Received path_out='%s'." % self.path_out) 

488 

489 return path 

490 

491 path_im_ref = get_input_path(im_ref) 

492 path_im_tgt = get_input_path(im_tgt) 

493 

494 if self.path_out: # this also applies to self.path_out='auto' 

495 

496 if self.path_out == 'auto': 

497 dir_out, fName_out = os.path.dirname(path_im_tgt), '' 

498 else: 

499 dir_out, fName_out = os.path.split(self.path_out) 

500 

501 if dir_out and fName_out: 

502 # a valid output path is given => do nothing 

503 pass 

504 

505 else: 

506 # automatically create an output directory and filename if not given 

507 if not dir_out: 

508 if not path_im_ref: 

509 dir_out = os.path.abspath(os.path.curdir) 

510 else: 

511 dir_out = os.path.dirname(path_im_ref) 

512 

513 if not fName_out: 

514 ext = 'bsq' if self.fmt_out == 'ENVI' else \ 

515 gdal.GetDriverByName(self.fmt_out).GetMetadataItem(gdal.DMD_EXTENSION) 

516 fName_out = fName_out if fName_out not in ['.', ''] else \ 

517 '%s__shifted_to__%s' % (get_baseN(path_im_tgt), get_baseN(path_im_ref)) 

518 fName_out = fName_out + '.%s' % ext if ext else fName_out 

519 

520 self.path_out = os.path.abspath(os.path.join(dir_out, fName_out)) 

521 

522 assert ' ' not in self.path_out, \ 

523 "The path of the output image contains whitespaces. This is not supported by GDAL." 

524 else: 

525 # this only happens if COREG is not instanced from within Python and self.path_out is explicitly set to None 

526 # => DESHIFTER will return an array 

527 pass 

528 

529 if self.v: 

530 if self.path_verbose_out: 

531 dir_out, dirname_out = os.path.split(self.path_verbose_out) 

532 

533 if not dir_out: 

534 if self.path_out: 

535 self.path_verbose_out = os.path.dirname(self.path_out) 

536 else: 

537 self.path_verbose_out = \ 

538 os.path.abspath(os.path.join(os.path.curdir, 'CoReg_verboseOut__%s__shifted_to__%s' 

539 % (get_baseN(path_im_tgt), get_baseN(path_im_ref)))) 

540 elif dirname_out and not dir_out: 

541 self.path_verbose_out = os.path.abspath(os.path.join(os.path.curdir, dirname_out)) 

542 

543 assert ' ' not in self.path_verbose_out, \ 

544 "'path_verbose_out' contains whitespaces. This is not supported by GDAL." 

545 

546 else: 

547 self.path_verbose_out = None 

548 

549 if self.path_verbose_out and not os.path.isdir(self.path_verbose_out): 

550 os.makedirs(self.path_verbose_out) 

551 

552 def _get_image_params(self) -> None: 

553 self.ref = GeoArray_CoReg(self.params, 'ref') 

554 self.shift = GeoArray_CoReg(self.params, 'shift') 

555 

556 if not prj_equal(self.ref.prj, self.shift.prj): 

557 from pyproj import CRS 

558 

559 crs_ref = CRS.from_user_input(self.ref.prj) 

560 crs_shift = CRS.from_user_input(self.shift.prj) 

561 

562 name_ref, name_shift = \ 

563 (crs_ref.name, crs_shift.name) if not crs_ref.name == crs_shift.name else (crs_ref.srs, crs_shift.srs) 

564 

565 raise RuntimeError( 

566 'Input projections are not equal. Different projections are currently not supported. ' 

567 'Got %s vs. %s.' % (name_ref, name_shift)) 

568 

569 def _get_overlap_properties(self) -> None: 

570 with warnings.catch_warnings(): 

571 # already warned in GeoArray_CoReg.__init__() 

572 warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*disjunct.*") 

573 

574 overlap_tmp = get_overlap_polygon(self.ref.poly, self.shift.poly, self.v) 

575 

576 self.overlap_poly = overlap_tmp['overlap poly'] # has to be in reference projection 

577 self.overlap_percentage = overlap_tmp['overlap percentage'] 

578 self.overlap_area = overlap_tmp['overlap area'] 

579 

580 assert self.overlap_poly, 'The input images have no spatial overlap.' 

581 

582 # overlap are must at least cover 16*16 pixels 

583 px_area = self.ref.xgsd * self.ref.ygsd if self.grid2use == 'ref' else self.shift.xgsd * self.shift.ygsd 

584 px_covered = self.overlap_area / px_area 

585 assert px_covered > 16 * 16, \ 

586 'Overlap area covers only %s pixels. At least 16*16 pixels are needed.' % px_covered 

587 

588 def _check_and_handle_metaRotation(self): 

589 """Check if the provided input data have a metadata rotation and if yes, correct it. 

590 

591 In case there is a rotation, the GDAL GeoTransform is not 0 at positions 2 or 4. So far, AROSICS does not 

592 handle such rotations, so the resampling is needed to make things work. 

593 """ 

594 gA_ref = GeoArray(self.params['im_ref']) 

595 gA_tgt = GeoArray(self.params['im_tgt']) 

596 

597 msg = 'The %s image needs to be resampled because it has a row/column rotation in ' \ 

598 'its map info which is not handled by AROSICS.' 

599 

600 if GEO.has_metaRotation(gA_ref): 

601 warnings.warn(msg % 'reference') 

602 self.params['im_ref'] = GEO.remove_metaRotation(gA_ref) 

603 

604 if GEO.has_metaRotation(gA_tgt): 

605 warnings.warn(msg % 'target') 

606 self.params['im_tgt'] = GEO.remove_metaRotation(gA_tgt) 

607 

608 @property 

609 def are_pixGrids_equal(self): 

610 return prj_equal(self.ref.prj, self.shift.prj) and \ 

611 is_coord_grid_equal(self.ref.gt, *self.shift.xygrid_specs, tolerance=1e-8) 

612 

613 def equalize_pixGrids(self) -> None: 

614 """Equalize image grids and projections of reference and target image (align target to reference). 

615 

616 NOTE: This method is only called by COREG_LOCAL to speed up processing during detection of displacements. 

617 """ 

618 if not self.are_pixGrids_equal or GEO.has_metaRotation(self.ref) or GEO.has_metaRotation(self.shift): 

619 if not self.q: 

620 print("Equalizing pixel grids and projections of reference and target image...") 

621 

622 def equalize(gA_from: GeoArray, gA_to: GeoArray) -> GeoArray: 

623 if gA_from.bands > 1: 

624 gA_from = gA_from.get_subset(zslice=slice(gA_from.band4match, gA_from.band4match + 1)) 

625 gA_from.reproject_to_new_grid(prototype=gA_to, CPUs=self.CPUs) 

626 gA_from.band4match = 0 # after resampling there is only one band in the GeoArray 

627 

628 return gA_from 

629 

630 if self.grid2use == 'ref': 

631 # resample target to reference image 

632 self.shift = equalize(gA_from=self.shift, gA_to=self.ref) 

633 

634 else: 

635 # resample reference to target image 

636 self.ref = equalize(gA_from=self.ref, gA_to=self.shift) 

637 

638 # self.ref.gt = (self.ref.gt[0], 1, self.ref.gt[2], self.ref.gt[3], self.ref.gt[4], -1) 

639 # self.shift.gt = (self.shift.gt[0], 1, self.shift.gt[2], self.shift.gt[3], self.shift.gt[4], -1) 

640 

641 def show_image_footprints(self): 

642 """Show a web map containing the calculated footprints and overlap area of the input images. 

643 

644 NOTE: This method is intended to be called from Jupyter Notebook. 

645 """ 

646 # TODO different colors for polygons 

647 assert self.overlap_poly, 'Please calculate the overlap polygon first.' 

648 

649 import folium 

650 import geojson 

651 

652 refPoly = reproject_shapelyGeometry(self.ref.poly, self.ref.prj, 4326) 

653 shiftPoly = reproject_shapelyGeometry(self.shift.poly, self.shift.prj, 4326) 

654 overlapPoly = reproject_shapelyGeometry(self.overlap_poly, self.shift.prj, 4326) 

655 matchBoxPoly = reproject_shapelyGeometry(self.matchBox.mapPoly, self.shift.prj, 4326) 

656 

657 m = folium.Map(location=tuple(np.array(overlapPoly.centroid.coords.xy).flatten())[::-1]) 

658 for poly in [refPoly, shiftPoly, overlapPoly, matchBoxPoly]: 

659 gjs = geojson.Feature(geometry=poly, properties={}) 

660 folium.GeoJson(gjs).add_to(m) 

661 return m 

662 

663 def show_matchWin(self, 

664 figsize: tuple = (15, 15), 

665 interactive: bool = True, 

666 after_correction: bool = None, 

667 pmin=2, 

668 pmax=98): 

669 """Show the image content within the matching window. 

670 

671 :param figsize: figure size 

672 :param interactive: whether to return an interactive figure based on 'holoviews' library 

673 :param after_correction: True/False: show the image content AFTER shift correction or before 

674 None: show both states - before and after correction (default) 

675 :param pmin: percentage to be used for excluding the darkest pixels from stretching (default: 2) 

676 :param pmax: percentage to be used for excluding the brightest pixels from stretching 

677 (default: 98) 

678 :return: 

679 """ 

680 if not self.success and after_correction in [True, None]: 

681 warnings.warn('It is only possible to show the matching window before correction of spatial displacements ' 

682 'because no valid displacement has been calculated yet.') 

683 after_correction = False 

684 

685 if interactive: 

686 # use Holoviews 

687 try: 

688 import holoviews as hv 

689 except ImportError: 

690 hv = None 

691 if not hv: 

692 raise ImportError( 

693 "This method requires the library 'holoviews'. It can be installed for Conda with " 

694 "the shell command 'conda install -c conda-forge holoviews bokeh'.") 

695 

696 hv.notebook_extension('matplotlib') 

697 hv.Store.add_style_opts(hv.Image, ['vmin', 'vmax']) 

698 

699 # hv.Store.option_setters.options().Image = hv.Options('style', cmap='gnuplot2') 

700 # hv.Store.add_style_opts(hv.Image, ['cmap']) 

701 # renderer = hv.Store.renderers['matplotlib'].instance(fig='svg', holomap='gif') 

702 # RasterPlot = renderer.plotting_class(hv.Image) 

703 # RasterPlot.cmap = 'gray' 

704 otherWin_corr = self._get_deshifted_otherWin() if after_correction in [True, None] else None 

705 xmin, xmax, ymin, ymax = self.matchBox.boundsMap 

706 

707 def get_hv_image(geoArr: GeoArray): 

708 from skimage.exposure import rescale_intensity # import here to avoid static TLS ImportError 

709 

710 arr_masked = np.ma.masked_equal(geoArr[:], geoArr.nodata) 

711 vmin = np.nanpercentile(arr_masked.compressed(), pmin) 

712 vmax = np.nanpercentile(arr_masked.compressed(), pmax) 

713 arr2plot = rescale_intensity(arr_masked, in_range=(vmin, vmax), out_range='int8') 

714 

715 return ( 

716 hv.Image( 

717 arr2plot, 

718 bounds=(xmin, ymin, xmax, ymax) 

719 ).options( 

720 cmap='gray', 

721 vmin=vmin, 

722 vmax=vmax, 

723 interpolation='none', 

724 fig_inches=figsize, 

725 show_grid=True 

726 ) 

727 ) 

728 

729 hvIm_matchWin = get_hv_image(self.matchWin) 

730 hvIm_otherWin_orig = get_hv_image(self.otherWin) 

731 hvIm_otherWin_corr = get_hv_image(otherWin_corr) if after_correction in [True, None] else None 

732 

733 if after_correction is None: 

734 # view both states 

735 print('Matching window before and after correction (left and right): ') 

736 

737 # get layouts (docs on options: https://holoviews.org/user_guide) 

738 layout_before = ( 

739 hvIm_matchWin.options(title='BEFORE co-registration') + 

740 hvIm_matchWin.options(title='AFTER co-registration') 

741 ).options( 

742 fig_inches=figsize 

743 ) 

744 layout_after = ( 

745 hvIm_otherWin_orig.options(title='BEFORE co-registration') + 

746 hvIm_otherWin_corr.options(title='AFTER co-registration') 

747 ).options( 

748 fig_inches=figsize 

749 ) 

750 

751 # plot! 

752 imgs = {1: layout_before, 2: layout_after} 

753 hmap = hv.HoloMap(imgs, kdims=['image']).collate().cols(2) 

754 

755 else: 

756 # view state before or after correction 

757 imgs = {1: hvIm_matchWin, 2: hvIm_otherWin_corr if after_correction else hvIm_otherWin_orig} 

758 hmap = hv.HoloMap(imgs, kdims=['image']) 

759 

760 # Construct a HoloMap by evaluating the function over all the keys 

761 # hmap = hv.HoloMap(imgs_corr, kdims=['image']) + hv.HoloMap(imgs_corr, kdims=['image']) 

762 

763 # Construct a HoloMap by defining the sampling on the Dimension 

764 # dmap = hv.DynamicMap(image_slice, kdims=[hv.Dimension('z_axis', values=keys)]) 

765 

766 return hmap 

767 

768 else: 

769 # TODO add titles 

770 # TODO handle after_correction=None here 

771 self.matchWin.show(figsize=figsize) 

772 if after_correction: 

773 self._get_deshifted_otherWin().show(figsize=figsize, pmin=pmin, pmax=pmax) 

774 else: 

775 self.otherWin.show(figsize=figsize, pmin=pmin, pmax=pmax) 

776 

777 def show_cross_power_spectrum(self, interactive: bool = False) -> None: 

778 """Show a 3D surface of the cross power spectrum. 

779 

780 NOTE: The cross power spectrum is the result from phase correlating the reference and target 

781 image within the matching window. 

782 

783 :param interactive: whether to return an interactice 3D surface plot based on 'plotly' library 

784 :return: 

785 """ 

786 if interactive: 

787 # create plotly 3D surface 

788 

789 # import plotly.plotly as py # online mode -> every plot is uploaded into online plotly account 

790 from plotly.offline import iplot, init_notebook_mode 

791 import plotly.graph_objs as go 

792 

793 init_notebook_mode(connected=True) 

794 

795 z_data = self._calc_shifted_cross_power_spectrum() 

796 data = [go.Surface(z=z_data)] 

797 layout = go.Layout( 

798 title='cross power spectrum', 

799 autosize=False, 

800 width=1000, 

801 height=1000, 

802 margin={'l': 65, 'r': 50, 'b': 65, 't': 90}) 

803 fig = go.Figure(data=data, layout=layout) 

804 

805 return iplot(fig, filename='SCPS') 

806 

807 else: 

808 # use matplotlib 

809 scps = self._calc_shifted_cross_power_spectrum() 

810 PLT.subplot_3dsurface(scps.astype(np.float32)) 

811 

812 def _get_opt_winpos_winsize(self) -> None: 

813 """Calculate optimal window position and size in reference image units. 

814 

815 NOTE: The returned values are computed according to DGM, cloud_mask and trueCornerLonLat. 

816 """ 

817 # dummy algorithm: get center position of overlap instead of searching ideal window position in whole overlap 

818 # TODO automatischer Algorithmus zur Bestimmung der optimalen Window Position 

819 

820 wp = tuple(self.win_pos_XY) 

821 assert type(self.win_pos_XY) in [tuple, list, np.ndarray], \ 

822 'The window position must be a tuple of two elements. Got %s with %s elements.' % (type(wp), len(wp)) 

823 wp = tuple(wp) 

824 

825 if None in wp: 

826 # use centroid point if possible 

827 overlap_center_pos_x, overlap_center_pos_y = self.overlap_poly.centroid.coords.xy 

828 wp = (wp[0] if wp[0] else overlap_center_pos_x[0]), (wp[1] if wp[1] else overlap_center_pos_y[0]) 

829 

830 # validate window position 

831 if not self.overlap_poly.buffer(1e-5).contains(Point(wp)): 

832 # in case the centroid point is not within overlap area 

833 if not self.q: 

834 warnings.warn("The centroid point of the two input images could not be used as matching window " 

835 "position since it is outside of the overlap area. Instead the so called " 

836 "'representative point' is used. Alternatively you can provide your own window " 

837 "position as input parameter.") 

838 

839 # -> use representative point: a point that is garanteed to be within overlap polygon 

840 overlap_center_pos_x, overlap_center_pos_y = self.overlap_poly.representative_point().coords.xy 

841 wp = overlap_center_pos_x[0], overlap_center_pos_y[0] 

842 

843 assert self.overlap_poly.buffer(1e-5).contains(Point(wp)) 

844 

845 else: 

846 # validate window position 

847 if not self.overlap_poly.buffer(1e-5).contains(Point(wp)): 

848 self._handle_error(ValueError('The provided window position %s/%s is outside of the overlap ' 

849 'area of the two input images. Check the coordinates.' % wp)) 

850 

851 # check if window position is within bad data area if a respective mask has been provided 

852 for im in [self.ref, self.shift]: 

853 if im.mask_baddata is not None: 

854 imX, imY = mapXY2imXY(wp, im.mask_baddata.gt) 

855 

856 if im.mask_baddata[int(imY), int(imX)] is True: 

857 self._handle_error( 

858 RuntimeError('According to the provided bad data mask for the %s the chosen window position ' 

859 '%s / %s is within a bad data area. Using this window position for coregistration ' 

860 'is not reasonable. Please provide a better window position!' 

861 % (im.imName, wp[0], wp[1]))) 

862 

863 self.win_pos_XY = wp 

864 self.win_size_XY = (int(self.win_size_XY[0]), int(self.win_size_XY[1])) if self.win_size_XY else (512, 512) 

865 

866 def _get_clip_window_properties(self) -> None: 

867 """Calculate all properties of the matching window and the other window. 

868 

869 These windows are used to read the corresponding image positions in the reference and the target image. 

870 

871 NOTE: Even if X- and Y-dimension of the target window is equal, the output window can be NON-quadratic! 

872 """ 

873 # FIXME image sizes like 10000*256 are still possible 

874 

875 wpX, wpY = self.win_pos_XY 

876 wsX, wsY = self.win_size_XY 

877 

878 # image units -> map units 

879 ref_wsX = wsX * self.ref.xgsd 

880 ref_wsY = wsY * self.ref.ygsd 

881 shift_wsX = wsX * self.shift.xgsd 

882 shift_wsY = wsY * self.shift.ygsd 

883 

884 ref_box_kwargs = \ 

885 dict(wp=(wpX, wpY), 

886 ws=(ref_wsX, ref_wsY), 

887 gt=self.ref.gt) 

888 shift_box_kwargs = \ 

889 dict(wp=(wpX, wpY), 

890 ws=(shift_wsX, shift_wsY), 

891 gt=self.shift.gt) 

892 matchBox =\ 

893 boxObj(**ref_box_kwargs) if self.grid2use == 'ref' else \ 

894 boxObj(**shift_box_kwargs) 

895 otherBox = \ 

896 boxObj(**shift_box_kwargs) if self.grid2use == 'ref' else \ 

897 boxObj(**ref_box_kwargs) 

898 overlapWin = \ 

899 boxObj(mapPoly=self.overlap_poly, 

900 gt=self.ref.gt) 

901 

902 # clip matching window to overlap area 

903 matchBox.mapPoly = matchBox.mapPoly.intersection(overlapWin.mapPoly) 

904 

905 # check if matchBox extent touches no data area of the image -> if yes: shrink it 

906 overlapPoly_within_matchWin = matchBox.mapPoly.intersection(self.overlap_poly) 

907 if overlapPoly_within_matchWin.area < matchBox.mapPoly.area: 

908 wsX_start, wsY_start = \ 

909 1 if wsX >= wsY else \ 

910 wsX / wsY, 1 if wsY >= wsX else \ 

911 wsY / wsX 

912 box = boxObj(**dict(wp=(wpX, wpY), 

913 ws=(wsX_start, wsY_start), 

914 gt=matchBox.gt)) 

915 while True: 

916 box.buffer_imXY(1, 1) 

917 if not box.mapPoly.within(overlapPoly_within_matchWin): 

918 box.buffer_imXY(-1, -1) 

919 matchBox = box 

920 break 

921 

922 # move matching window to imref grid or im2shift grid 

923 mW_rows, mW_cols = \ 

924 (self.ref.rows, self.ref.cols) if self.grid2use == 'ref' else \ 

925 (self.shift.rows, self.shift.cols) 

926 matchBox.mapPoly = move_shapelyPoly_to_image_grid(matchBox.mapPoly, 

927 matchBox.gt, mW_rows, 

928 mW_cols, 

929 'NW') 

930 

931 # check, if matchBox was moved outside of overlap_poly when moving it to the image grid 

932 if not matchBox.mapPoly.within(overlapWin.mapPoly): 

933 # further shrink matchPoly (1 px buffer is enough because the window was only moved to the grid) 

934 xLarger, yLarger = matchBox.is_larger_DimXY(overlapWin.boundsIm) 

935 matchBox.buffer_imXY(-1 if xLarger else 0, 

936 -1 if yLarger else 0) 

937 

938 # matching_win directly on grid2use (fix rounding error through coordinate transformation) 

939 matchBox.imPoly = round_shapelyPoly_coords(matchBox.imPoly, precision=0) 

940 

941 # check if matching window larger than the other one or equal 

942 if not (matchBox.mapPoly.within(otherBox.mapPoly) or 

943 matchBox.mapPoly == otherBox.mapPoly): 

944 # if yes, find the smallest 'other window' that encloses the matching window 

945 otherBox.boxImYX = \ 

946 get_smallest_boxImYX_that_contains_boxMapYX( 

947 matchBox.boxMapYX, 

948 otherBox.gt, 

949 tolerance_ndigits=5 # avoids float coordinate rounding issues 

950 ) 

951 

952 # in case after enlarging the 'other window', it gets too large for the overlap area 

953 # -> shrink match window and recompute smallest possible other window until everything is fine 

954 t_start = time.time() 

955 while not otherBox.mapPoly.within(overlapWin.mapPoly): 

956 xLarger, yLarger = otherBox.is_larger_DimXY(overlapWin.boundsIm) 

957 matchBox.buffer_imXY(-1 if xLarger else 0, 

958 -1 if yLarger else 0) 

959 previous_area = otherBox.mapPoly.area 

960 otherBox.boxImYX = \ 

961 get_smallest_boxImYX_that_contains_boxMapYX( 

962 matchBox.boxMapYX, 

963 otherBox.gt, 

964 tolerance_ndigits=5 # avoids float coordinate rounding issues 

965 ) 

966 

967 if previous_area == otherBox.mapPoly.area or \ 

968 time.time() - t_start > 1.5: 

969 # happens, e.g, in case of a triangular footprint 

970 # NOTE: first condition is not always fulfilled -> therefore added timeout of 1.5 sec 

971 self._handle_error( 

972 RuntimeError('Matching window in target image is larger than overlap area but further shrinking ' 

973 'the matching window is not possible. Check if the footprints of the input data have ' 

974 'been computed correctly.' + 

975 (' Matching window shrinking timed out.' if time.time() - t_start > 5 else ''))) 

976 break # break out of while loop in order to avoid that code gets stuck here 

977 

978 # output validation 

979 for winBox in [matchBox, otherBox]: 

980 if winBox.imDimsYX[0] < 16 or \ 

981 winBox.imDimsYX[1] < 16: 

982 self._handle_error( 

983 RuntimeError("One of the input images does not have sufficient gray value information " 

984 "(non-no-data values) for placing a matching window at the position %s. " 

985 "Matching failed." % str((wpX, wpY)))) 

986 

987 if self.success is not False: 

988 # check result -> ProgrammingError if not fulfilled 

989 def within_equal(inner, outer): 

990 return inner.within(outer.buffer(1e-5)) or \ 

991 inner.equals(outer) 

992 

993 assert within_equal(matchBox.mapPoly, 

994 otherBox.mapPoly) 

995 assert within_equal(otherBox.mapPoly, 

996 overlapWin.mapPoly) 

997 

998 if self.grid2use == 'ref': 

999 self.imfft_xgsd = self.ref.xgsd 

1000 self.imfft_ygsd = self.ref.ygsd 

1001 self.ref.win = matchBox 

1002 self.shift.win = otherBox 

1003 else: 

1004 self.imfft_xgsd = self.shift.xgsd 

1005 self.imfft_ygsd = self.shift.ygsd 

1006 self.ref.win = otherBox 

1007 self.shift.win = matchBox 

1008 

1009 self.matchBox = matchBox 

1010 self.otherBox = otherBox 

1011 

1012 self.ref.win.size_YX = tuple([int(i) for i in self.ref.win.imDimsYX]) 

1013 self.shift.win.size_YX = tuple([int(i) for i in self.shift.win.imDimsYX]) 

1014 match_win_size_XY = tuple(reversed([int(i) for i in matchBox.imDimsYX])) 

1015 

1016 if not self.q and \ 

1017 match_win_size_XY != self.win_size_XY: 

1018 print('Target window size %s not possible due to too small overlap area or window position too close ' 

1019 'to an image edge. New matching window size: %s.' % (self.win_size_XY, match_win_size_XY)) 

1020 

1021 # write_shp('matchMapPoly.shp', matchBox.mapPoly,matchBox.prj) 

1022 # write_shp('otherMapPoly.shp', otherBox.mapPoly,otherBox.prj) 

1023 

1024 def _get_image_windows_to_match(self) -> None: 

1025 """Read the matching window and the other window as subsets. 

1026 

1027 The other window is resampled to the resolution and the pixel grid of the matching window. 

1028 The result consists of two images with the same dimensions and exactly the same corner coordinates. 

1029 """ 

1030 match_fullGeoArr = self.ref if self.grid2use == 'ref' else self.shift 

1031 other_fullGeoArr = self.shift if self.grid2use == 'ref' else self.ref 

1032 

1033 # read matchWin via subset-read -> self.matchWin.data 

1034 rS, rE, cS, cE = GEO.get_GeoArrayPosition_from_boxImYX(self.matchBox.boxImYX) 

1035 assert np.array_equal(np.abs(np.array([rS, rE, cS, cE])), np.array([rS, rE, cS, cE])) and \ 

1036 rE <= match_fullGeoArr.rows and cE <= match_fullGeoArr.cols, \ 

1037 'Requested area is not completely within the input array for %s.' % match_fullGeoArr.imName 

1038 self.matchWin = GeoArray(match_fullGeoArr[rS:rE + 1, cS:cE + 1, match_fullGeoArr.band4match], 

1039 geotransform=GEO.get_subset_GeoTransform(match_fullGeoArr.gt, self.matchBox.boxImYX), 

1040 projection=copy(match_fullGeoArr.prj), 

1041 nodata=copy(match_fullGeoArr.nodata)) 

1042 self.matchWin.imID = match_fullGeoArr.imID 

1043 

1044 # read otherWin via subset-read 

1045 rS, rE, cS, cE = GEO.get_GeoArrayPosition_from_boxImYX(self.otherBox.boxImYX) 

1046 assert np.array_equal(np.abs(np.array([rS, rE, cS, cE])), np.array([rS, rE, cS, cE])) and \ 

1047 rE <= other_fullGeoArr.rows and cE <= other_fullGeoArr.cols, \ 

1048 'Requested area is not completely within the input array for %s.' % other_fullGeoArr.imName 

1049 self.otherWin = GeoArray(other_fullGeoArr[rS:rE + 1, cS:cE + 1, other_fullGeoArr.band4match], 

1050 geotransform=GEO.get_subset_GeoTransform(other_fullGeoArr.gt, self.otherBox.boxImYX), 

1051 projection=copy(other_fullGeoArr.prj), 

1052 nodata=copy(other_fullGeoArr.nodata)) 

1053 self.otherWin.imID = other_fullGeoArr.imID 

1054 

1055 # self.matchWin.deepcopy_array() 

1056 # self.otherWin.deepcopy_array() 

1057 

1058 if self.v: 

1059 print('Original matching windows:') 

1060 ref_data, shift_data = (self.matchWin[:], self.otherWin[:]) if self.grid2use == 'ref' else \ 

1061 (self.otherWin[:], self.matchWin[:]) 

1062 PLT.subplot_imshow([ref_data, shift_data], [self.ref.title, self.shift.title], grid=True) 

1063 

1064 # resample otherWin.arr to the resolution of matchWin AND make sure the pixel edges are identical 

1065 # (in order to make each image show the same window with the same coordinates) 

1066 # TODO replace cubic resampling by PSF resampling - average resampling leads to sinus like distortions in the 

1067 # TODO fft image that make a precise coregistration impossible. Thats why there is currently no way around 

1068 # TODO cubic resampling. 

1069 tgt_xmin, tgt_xmax, tgt_ymin, tgt_ymax = self.matchBox.boundsMap 

1070 

1071 # equalize pixel grids and projection of matchWin and otherWin (ONLY if grids are really different) 

1072 if not (self.matchWin.xygrid_specs == self.otherWin.xygrid_specs and 

1073 prj_equal(self.matchWin.prj, self.otherWin.prj)): 

1074 self.otherWin.arr, self.otherWin.gt = warp_ndarray(self.otherWin.arr, 

1075 self.otherWin.gt, 

1076 self.otherWin.prj, 

1077 self.matchWin.prj, 

1078 out_gsd=(self.imfft_xgsd, abs(self.imfft_ygsd)), 

1079 out_bounds=([tgt_xmin, tgt_ymin, tgt_xmax, tgt_ymax]), 

1080 rspAlg=_dict_rspAlg_rsp_Int[self.rspAlg_calc], 

1081 in_nodata=self.otherWin.nodata, 

1082 CPUs=self.CPUs, 

1083 progress=False)[:2] 

1084 

1085 if self.matchWin.shape != self.otherWin.shape: 

1086 self._handle_error( 

1087 RuntimeError('Caught a possible ProgrammingError at window position %s: Bad output of ' 

1088 'get_image_windows_to_match. Reference image shape is %s whereas shift ' 

1089 'image shape is %s.' % (str(self.matchBox.wp), self.matchWin.shape, self.otherWin.shape)), 

1090 warn=True) 

1091 

1092 # check of odd dimensions of output images 

1093 rows, cols = [i if i % 2 == 0 else i - 1 for i in self.matchWin.shape] 

1094 self.matchWin.arr, self.otherWin.arr = self.matchWin.arr[:rows, :cols], self.otherWin.arr[:rows, :cols] 

1095 if self.matchWin.box.imDimsYX != self.matchBox.imDimsYX: 

1096 self.matchBox = self.matchWin.box # update matchBox 

1097 self.otherBox = self.otherWin.box # update otherBox 

1098 

1099 assert self.matchWin.arr is not None and self.otherWin.arr is not None, 'Creation of matching windows failed.' 

1100 

1101 @staticmethod 

1102 def _shrink_winsize_to_binarySize(win_shape_YX: tuple, 

1103 target_size: tuple = None 

1104 ) -> Optional[Tuple[int, int]]: 

1105 """Shrink a given window size to the closest binary window size (a power of 2). 

1106 

1107 NOTE: X- and Y-dimension are handled separately. 

1108 

1109 :param win_shape_YX: source window shape as pixel units (rows,colums) 

1110 :param target_size: source window shape as pixel units (rows,colums) 

1111 """ 

1112 binarySizes = [2 ** i for i in range(3, 14)] # [8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192] 

1113 possibSizes_X = [i for i in binarySizes if i <= win_shape_YX[1]] 

1114 possibSizes_Y = [i for i in binarySizes if i <= win_shape_YX[0]] 

1115 if possibSizes_X and possibSizes_Y: 

1116 tgt_size_X, tgt_size_Y = target_size if target_size else (max(possibSizes_X), max(possibSizes_Y)) 

1117 closest_to_target_X = int(min(possibSizes_X, key=lambda x: abs(x - tgt_size_X))) 

1118 closest_to_target_Y = int(min(possibSizes_Y, key=lambda y: abs(y - tgt_size_Y))) 

1119 return closest_to_target_Y, closest_to_target_X 

1120 else: 

1121 return None 

1122 

1123 def _calc_shifted_cross_power_spectrum(self, 

1124 im0=None, 

1125 im1=None, 

1126 precision=np.complex64 

1127 ) -> np.ndarray: 

1128 """Calculate the shifted cross power spectrum for quantifying X/Y-shifts. 

1129 

1130 :param im0: reference image 

1131 :param im1: subject image to shift 

1132 :param precision: to be quantified as a datatype 

1133 :return: 2D-numpy-array of the shifted cross power spectrum 

1134 """ 

1135 im0 = im0 if im0 is not None else self.matchWin[:] if self.matchWin.imID == 'ref' else self.otherWin[:] 

1136 im1 = im1 if im1 is not None else self.otherWin[:] if self.otherWin.imID == 'shift' else self.matchWin[:] 

1137 

1138 assert im0.shape == im1.shape, 'The reference and the target image must have the same dimensions.' 

1139 if im0.shape[0] % 2 != 0: 

1140 warnings.warn('Odd row count in one of the match images!') 

1141 if im1.shape[1] % 2 != 0: 

1142 warnings.warn('Odd column count in one of the match images!') 

1143 

1144 wsYX = self._shrink_winsize_to_binarySize(im0.shape) if self.bin_ws else im0.shape 

1145 wsYX = ((min(wsYX),) * 2 if self.force_quadratic_win else wsYX) if wsYX else None 

1146 

1147 if wsYX not in [None, (0, 0)]: 

1148 time0 = time.time() 

1149 if self.v: 

1150 print('final window size: %s/%s (X/Y)' % (wsYX[1], wsYX[0])) 

1151 # FIXME size of self.matchWin is not updated 

1152 # FIXME CoRegPoints_grid.WIN_SZ is taken from self.matchBox.imDimsYX but this is not updated 

1153 

1154 center_YX = np.array(im0.shape) / 2 

1155 xmin, xmax = int(center_YX[1] - wsYX[1] / 2), int(center_YX[1] + wsYX[1] / 2) 

1156 ymin, ymax = int(center_YX[0] - wsYX[0] / 2), int(center_YX[0] + wsYX[0] / 2) 

1157 

1158 in_arr0 = im0[ymin:ymax, xmin:xmax].astype(precision) 

1159 in_arr1 = im1[ymin:ymax, xmin:xmax].astype(precision) 

1160 

1161 if self.v: 

1162 PLT.subplot_imshow([np.real(in_arr0).astype(np.float32), np.real(in_arr1).astype(np.float32)], 

1163 ['FFTin ' + self.ref.title, 'FFTin ' + self.shift.title], grid=True) 

1164 

1165 fft_arr0, fft_arr1 = None, None 

1166 if pyfftw and self.fftw_works is not False: # if module is installed and working 

1167 try: 

1168 fft_arr0 = pyfftw.FFTW(in_arr0, np.empty_like(in_arr0), axes=(0, 1))() 

1169 fft_arr1 = pyfftw.FFTW(in_arr1, np.empty_like(in_arr1), axes=(0, 1))() 

1170 

1171 # catch empty output arrays (for some reason this happens sometimes) -> use numpy fft 

1172 # => this is caused by the call of pyfftw.FFTW. Exactly at that moment the input array 

1173 # in_arr0 is overwritten with zeros (maybe this is a bug in pyFFTW?) 

1174 if np.std(fft_arr0) == 0 or np.std(fft_arr1) == 0: 

1175 raise RuntimeError('FFTW result is unexpectedly empty.') 

1176 

1177 self.fftw_works = True 

1178 

1179 except RuntimeError: 

1180 self.fftw_works = False 

1181 

1182 # recreate input arrays and use numpy fft as fallback 

1183 in_arr0 = im0[ymin:ymax, xmin:xmax].astype(precision) 

1184 in_arr1 = im1[ymin:ymax, xmin:xmax].astype(precision) 

1185 

1186 if self.fftw_works is False or fft_arr0 is None or fft_arr1 is None: 

1187 fft_arr0 = fft2(in_arr0) 

1188 fft_arr1 = fft2(in_arr1) 

1189 

1190 # GeoArray(fft_arr0.astype(np.float32)).show(figsize=(15,15)) 

1191 # GeoArray(fft_arr1.astype(np.float32)).show(figsize=(15,15)) 

1192 

1193 if self.v: 

1194 print('forward FFTW: %.2fs' % (time.time() - time0)) 

1195 

1196 eps = np.abs(fft_arr1).max() * 1e-15 

1197 # cps == cross-power spectrum of im0 and im2 

1198 

1199 with np.errstate(invalid='ignore'): # ignore RuntimeWarning: invalid value encountered in true_divide 

1200 temp = np.array(fft_arr0 * fft_arr1.conjugate()) / (np.abs(fft_arr0) * np.abs(fft_arr1) + eps) 

1201 

1202 time0 = time.time() 

1203 if 'pyfft' in globals(): 

1204 ifft_arr = pyfftw.FFTW(temp, np.empty_like(temp), axes=(0, 1), direction='FFTW_BACKWARD')() 

1205 else: 

1206 ifft_arr = ifft2(temp) 

1207 if self.v: 

1208 print('backward FFTW: %.2fs' % (time.time() - time0)) 

1209 

1210 cps = np.abs(ifft_arr) 

1211 # scps = shifted cps => shift the zero-frequency component to the center of the spectrum 

1212 scps = fftshift(cps) 

1213 if self.v: 

1214 PLT.subplot_imshow([np.real(in_arr0).astype(np.uint16), np.real(in_arr1).astype(np.uint16), 

1215 np.real(fft_arr0).astype(np.uint8), np.real(fft_arr1).astype(np.uint8), scps], 

1216 titles=['matching window im0', 'matching window im1', 

1217 "fft result im0", "fft result im1", "cross power spectrum"], grid=True) 

1218 PLT.subplot_3dsurface(np.real(scps).astype(np.float32)) 

1219 else: 

1220 scps = None 

1221 self._handle_error( 

1222 RuntimeError('The matching window became too small for calculating a reliable match. Matching failed.')) 

1223 

1224 self.fftw_win_size_YX = wsYX 

1225 return scps 

1226 

1227 @staticmethod 

1228 def _get_peakpos(scps: np.ndarray) -> np.ndarray: 

1229 """Return the row/column position of the peak within the given cross power spectrum. 

1230 

1231 :param scps: shifted cross power spectrum 

1232 :return: [row, column] 

1233 """ 

1234 max_flat_idx = np.argmax(scps) 

1235 return np.array(np.unravel_index(max_flat_idx, scps.shape)) 

1236 

1237 @staticmethod 

1238 def _get_shifts_from_peakpos(peakpos: np.array, 

1239 arr_shape: tuple 

1240 ) -> (float, float): 

1241 y_shift = peakpos[0] - arr_shape[0] // 2 

1242 x_shift = peakpos[1] - arr_shape[1] // 2 

1243 return x_shift, y_shift 

1244 

1245 @staticmethod 

1246 def _clip_image(im, center_YX, winSzYX): # TODO this is also implemented in GeoArray 

1247 

1248 def get_bounds(YX: tuple, wsY: float, wsX: float): 

1249 return ( 

1250 int(YX[1] - (wsX / 2)), 

1251 int(YX[1] + (wsX / 2)), 

1252 int(YX[0] - (wsY / 2)), 

1253 int(YX[0] + (wsY / 2)) 

1254 ) 

1255 

1256 wsY, wsX = winSzYX 

1257 xmin, xmax, ymin, ymax = get_bounds(center_YX, wsY, wsX) 

1258 

1259 return im[ymin:ymax, xmin:xmax] 

1260 

1261 def _get_grossly_deshifted_images(self, im0, im1, x_intshift, y_intshift): 

1262 # TODO this is also implemented in GeoArray # this should update ref.win.data and shift.win.data 

1263 # FIXME avoid that matching window gets smaller although shifting it with the previous win_size would not move 

1264 # it into nodata-area 

1265 # get_grossly_deshifted_im0 

1266 old_center_YX = np.array(im0.shape) / 2 

1267 new_center_YX = [old_center_YX[0] + y_intshift, 

1268 old_center_YX[1] + x_intshift] 

1269 

1270 x_left = new_center_YX[1] 

1271 x_right = im0.shape[1] - new_center_YX[1] 

1272 y_above = new_center_YX[0] 

1273 y_below = im0.shape[0] - new_center_YX[0] 

1274 maxposs_winsz_x = 2 * min(x_left, x_right) 

1275 maxposs_winsz_y = 2 * min(y_above, y_below) 

1276 

1277 if self.force_quadratic_win: 

1278 maxposs_winsz_x = maxposs_winsz_y = min([maxposs_winsz_x, maxposs_winsz_y]) 

1279 

1280 gdsh_im0 = self._clip_image(im0, new_center_YX, [maxposs_winsz_y, maxposs_winsz_x]) 

1281 

1282 # get_corresponding_im1_clip 

1283 crsp_im1 = self._clip_image(im1, np.array(im1.shape) / 2, gdsh_im0.shape) 

1284 

1285 if self.v: 

1286 PLT.subplot_imshow([self._clip_image(im0, old_center_YX, gdsh_im0.shape), crsp_im1], 

1287 titles=['reference original', 'target'], 

1288 grid=True) 

1289 PLT.subplot_imshow([gdsh_im0, crsp_im1], 

1290 titles=['reference virtually shifted', 'target'], 

1291 grid=True) 

1292 

1293 return gdsh_im0, crsp_im1 

1294 

1295 def _find_side_maximum(self, scps): 

1296 # peakR, peakC = self._get_peakpos(scps) 

1297 peakR, peakC = scps.shape[0] // 2, scps.shape[1] // 2 

1298 profileX = scps[peakR, :].flatten() # row profile with values from left to right 

1299 profileY = scps[:, peakC].flatten() # column profile with values from top to bottom 

1300 

1301 # get scps values of side maxima 

1302 sm_left, sm_right = profileX[peakC - 1], profileX[peakC + 1] 

1303 sm_above, sm_below = profileY[peakR - 1], profileY[peakR + 1] 

1304 

1305 sidemax_lr = {'value': max([sm_left, sm_right]), 

1306 'side': 'left' if sm_left > sm_right else 'right', 

1307 'direction_factor': -1 if sm_left > sm_right else 1} 

1308 sidemax_ab = {'value': max([sm_above, sm_below]), 

1309 'side': 'above' if sm_above > sm_below else 'below', 

1310 'direction_factor': -1 if sm_above > sm_below else 1} 

1311 

1312 if self.v: 

1313 print('Horizontal side maximum found %s. value: %s' % (sidemax_lr['side'], sidemax_lr['value'])) 

1314 print('Vertical side maximum found %s. value: %s' % (sidemax_ab['side'], sidemax_ab['value'])) 

1315 PLT.subplot_2dline([[range(profileX.size), profileX], [range(profileY.size), profileY]], 

1316 titles=['X-Profile', 'Y-Profile'], shapetuple=(1, 2), grid=True) 

1317 

1318 return sidemax_lr, sidemax_ab 

1319 

1320 def _calc_integer_shifts(self, scps: np.ndarray) -> (int, int): 

1321 peakpos = self._get_peakpos(scps) 

1322 x_intshift, y_intshift = self._get_shifts_from_peakpos(peakpos, scps.shape) 

1323 

1324 return x_intshift, y_intshift 

1325 

1326 def _calc_shift_reliability(self, scps: np.ndarray): 

1327 """Calculate a confidence percentage to be used as an assessment for reliability of the calculated shifts. 

1328 

1329 :param scps: shifted cross power spectrum 

1330 :return: 

1331 """ 

1332 # calculate mean power at peak 

1333 peakR, peakC = self._get_peakpos(scps) 

1334 power_at_peak = np.mean(scps[peakR - 1:peakR + 2, peakC - 1:peakC + 2]) 

1335 

1336 # calculate mean power without peak + 3* standard deviation 

1337 scps_masked = scps 

1338 scps_masked[peakR - 1:peakR + 2, peakC - 1:peakC + 2] = -9999 

1339 scps_masked = np.ma.masked_equal(scps_masked, -9999) 

1340 power_without_peak = np.mean(scps_masked) + 2 * np.std(scps_masked) 

1341 

1342 # calculate confidence 

1343 confid = 100 - ((power_without_peak / power_at_peak) * 100) 

1344 confid = 100 if confid > 100 else 0 if confid < 0 else confid 

1345 

1346 if not self.q: 

1347 print('Estimated reliability of the calculated shifts: %.1f' % confid, '%') 

1348 

1349 return confid 

1350 

1351 def _validate_integer_shifts(self, im0, im1, x_intshift, y_intshift): 

1352 

1353 if (x_intshift, y_intshift) != (0, 0): 

1354 # temporalily deshift images on the basis of calculated integer shifts 

1355 gdsh_im0, crsp_im1 = self._get_grossly_deshifted_images(im0, im1, x_intshift, y_intshift) 

1356 

1357 # check if integer shifts are now gone (0/0) 

1358 scps = self._calc_shifted_cross_power_spectrum(gdsh_im0, crsp_im1) 

1359 

1360 if scps is not None: 

1361 if scps.shape[0] < 3 or scps.shape[1] < 3: 

1362 self._handle_error(RuntimeError('Shifted cross power spectrum became too small for computing the ' 

1363 'point of registration. Matching failed.')) 

1364 return 'invalid', None, None, scps 

1365 

1366 peakpos = self._get_peakpos(scps) 

1367 x_shift, y_shift = self._get_shifts_from_peakpos(peakpos, scps.shape) 

1368 if (x_shift, y_shift) == (0, 0): 

1369 return 'valid', 0, 0, scps 

1370 else: 

1371 return 'invalid', x_shift, y_shift, scps 

1372 else: 

1373 return 'invalid', None, None, scps 

1374 else: 

1375 return 'valid', 0, 0, None 

1376 

1377 def _calc_subpixel_shifts(self, scps: np.ndarray): 

1378 sidemax_lr, sidemax_ab = self._find_side_maximum(scps) 

1379 x_subshift = (sidemax_lr['direction_factor'] * sidemax_lr['value']) / (np.max(scps) + sidemax_lr['value']) 

1380 y_subshift = (sidemax_ab['direction_factor'] * sidemax_ab['value']) / (np.max(scps) + sidemax_ab['value']) 

1381 

1382 return x_subshift, y_subshift 

1383 

1384 @staticmethod 

1385 def _get_total_shifts(x_intshift: int, 

1386 y_intshift: int, 

1387 x_subshift: float, 

1388 y_subshift: float): 

1389 return x_intshift + x_subshift, y_intshift + y_subshift 

1390 

1391 def _get_deshifted_otherWin(self) -> GeoArray: 

1392 """Return a de-shifted version of self.otherWin as a GeoArray instance. 

1393 

1394 The output dimensions and geographic bounds are equal to those of self.matchWin and geometric shifts are 

1395 corrected according to the previously computed X/Y shifts within the matching window. This allows direct 

1396 application of algorithms e.g. measuring image similarity. 

1397 

1398 The image subset that is resampled in this function is always the same that has been resampled during 

1399 computation of geometric shifts (usually the image with the higher geometric resolution). 

1400 

1401 :returns: GeoArray instance of de-shifted self.otherWin 

1402 """ 

1403 # shift vectors have been calculated to fit target image onto reference image 

1404 # -> so the shift vectors have to be inverted if shifts are applied to reference image 

1405 coreg_info = self._get_inverted_coreg_info() if self.otherWin.imID == 'ref' else self.coreg_info 

1406 

1407 matchFull = self.ref if self.matchWin.imID == 'ref' else self.shift 

1408 otherFull = self.ref if self.otherWin.imID == 'ref' else self.shift 

1409 

1410 ds_results = DESHIFTER(otherFull, coreg_info, 

1411 band2process=otherFull.band4match + 1, 

1412 clipextent=self.matchBox.mapPoly.bounds, 

1413 target_xyGrid=matchFull.xygrid_specs, 

1414 q=True 

1415 ).correct_shifts() 

1416 return ds_results['GeoArray_shifted'] 

1417 

1418 def _validate_ssim_improvement(self, 

1419 v: bool = False 

1420 ) -> (float, float): 

1421 """Compute mean structural similarity index between reference and target image before and after co-registration. 

1422 

1423 :param v: verbose mode: shows images of the matchWin, otherWin and shifted version of otherWin 

1424 :return: SSIM before an after shift correction 

1425 """ 

1426 from skimage.metrics import structural_similarity as ssim # import here to avoid static TLS import error 

1427 

1428 assert self.success is not None, \ 

1429 'Calculate geometric shifts first before trying to measure image similarity improvement!' 

1430 assert self.success in [True, None], \ 

1431 'Since calculation of geometric shifts failed, no image similarity improvement can be measured.' 

1432 

1433 def normalize(array: np.ndarray) -> np.ndarray: 

1434 minval = np.min(array) 

1435 maxval = np.max(array) 

1436 

1437 # avoid zerodivision 

1438 if maxval == minval: 

1439 maxval += 1e-5 

1440 

1441 return ((array - minval) / (maxval - minval)).astype(np.float64) 

1442 

1443 # compute SSIM BEFORE shift correction # 

1444 ######################################## 

1445 

1446 # using gaussian weights could lead to value errors in case of small images when the automatically calculated 

1447 # window size exceeds the image size 

1448 self.ssim_orig = ssim(normalize(np.ma.masked_equal(self.matchWin[:], 

1449 self.matchWin.nodata)), 

1450 normalize(np.ma.masked_equal(self.otherWin[:], 

1451 self.otherWin.nodata)), 

1452 data_range=1) 

1453 

1454 # compute SSIM AFTER shift correction # 

1455 ####################################### 

1456 

1457 # resample otherWin while correcting detected shifts and match geographic bounds of matchWin 

1458 otherWin_deshift_geoArr = self._get_deshifted_otherWin() 

1459 

1460 # get the corresponding matchWin data 

1461 matchWinData = self.matchWin[:] 

1462 

1463 # check if shapes of two images are unequal (due to bug (?), in some cases otherWin_deshift_geoArr does not have 

1464 # the exact same dimensions as self.matchWin -> maybe bounds are handled differently by gdal.Warp) 

1465 if not self.matchWin.shape == otherWin_deshift_geoArr.shape: # FIXME this seems to be already fixed 

1466 warnings.warn('SSIM input array shapes are not equal. This should not happen. ' 

1467 'If it happens with your data, please report it here so that the issue can be fixed: ' 

1468 'https://git.gfz-potsdam.de/danschef/arosics/-/issues/85') 

1469 matchFull = \ 

1470 self.ref if self.matchWin.imID == 'ref' else\ 

1471 self.shift 

1472 matchWinData, _, _ = matchFull.get_mapPos(self.matchBox.mapPoly.bounds, 

1473 self.matchWin.prj, 

1474 rspAlg='cubic', 

1475 band2get=matchFull.band4match) 

1476 self.matchWin.clip_to_poly(self.matchBox.mapPoly) 

1477 

1478 # at the image edges it is possible that the size of the matchBox must be reduced 

1479 # in order to make array shapes match 

1480 if not matchWinData.shape == otherWin_deshift_geoArr.shape: # FIXME this seems to be already fixed 

1481 self.matchBox.buffer_imXY(float(-np.ceil(abs(self.x_shift_px))), 

1482 float(-np.ceil(abs(self.y_shift_px)))) 

1483 otherWin_deshift_geoArr = self._get_deshifted_otherWin() 

1484 matchWinData, _, _ = matchFull.get_mapPos(self.matchBox.mapPoly.bounds, 

1485 self.matchWin.prj, 

1486 rspAlg='cubic', 

1487 band2get=matchFull.band4match) 

1488 

1489 # output validation 

1490 if not matchWinData.shape == otherWin_deshift_geoArr.shape: 

1491 warnings.warn('SSIM input array shapes could not be equalized. SSIM calculation failed. ' 

1492 'SSIM of the de-shifted target image is set to 0.') 

1493 self.ssim_deshifted = 0 

1494 

1495 return self.ssim_orig, self.ssim_deshifted 

1496 

1497 self.ssim_deshifted = ssim(normalize(np.ma.masked_equal(otherWin_deshift_geoArr[:], 

1498 otherWin_deshift_geoArr.nodata)), 

1499 normalize(np.ma.masked_equal(matchWinData, 

1500 self.matchWin.nodata)), 

1501 data_range=1) 

1502 

1503 if v: 

1504 GeoArray(matchWinData).show() 

1505 self.otherWin.show() 

1506 otherWin_deshift_geoArr.show() 

1507 

1508 if not self.q: 

1509 print('Image similarity within the matching window (SSIM before/after correction): %.4f => %.4f' 

1510 % (self.ssim_orig, self.ssim_deshifted)) 

1511 

1512 self.ssim_improved = self.ssim_orig <= self.ssim_deshifted 

1513 

1514 # write win data to disk 

1515 # outDir = '/home/gfz-fe/scheffler/temp/ssim_debugging/' 

1516 # GeoArray(matchWinData, matchWinGt, matchWinPrj).save(outDir+'matchWinData.bsq') 

1517 

1518 # otherWinGt = (self.otherWin.boundsMap[0], self.matchWin.xgsd, 0, 

1519 # self.otherWin.boundsMap[3], 0, -self.matchWin.imParams.ygsd) 

1520 # GeoArray(self.otherWin.data, therWinGt, self.otherWin.prj).save(outDir+'otherWin.data.bsq') 

1521 

1522 # otherWin_deshift_geoArr.save(outDir+''shifted.bsq') 

1523 

1524 return self.ssim_orig, self.ssim_deshifted 

1525 

1526 @property 

1527 def ssim_improved(self) -> bool: 

1528 """Return True if image similarity within the matching window has been improved by co-registration.""" 

1529 if self.success is True: 

1530 if self._ssim_improved is None: 

1531 ssim_orig, ssim_deshifted = self._validate_ssim_improvement() 

1532 self._ssim_improved = ssim_orig <= ssim_deshifted 

1533 

1534 return self._ssim_improved 

1535 

1536 @ssim_improved.setter 

1537 def ssim_improved(self, has_improved: bool): 

1538 self._ssim_improved = has_improved 

1539 

1540 def calculate_spatial_shifts(self) -> str: 

1541 """Compute the global X/Y shift between reference and the target image within the matching window. 

1542 

1543 :return: 'success' or 'fail' 

1544 """ 

1545 if self.success is False: 

1546 return 'fail' 

1547 

1548 # set self.matchWin and self.otherWin (GeoArray instances) 

1549 self._get_image_windows_to_match() # 45-90ms 

1550 

1551 im0 = self.matchWin[:] if self.matchWin.imID == 'ref' else self.otherWin[:] 

1552 im1 = self.otherWin[:] if self.otherWin.imID == 'shift' else self.matchWin[:] 

1553 

1554 xgsd_factor = self.imfft_xgsd / self.shift.xgsd 

1555 ygsd_factor = self.imfft_ygsd / self.shift.ygsd 

1556 

1557 if self.v: 

1558 print('Matching windows with equalized spatial resolution:') 

1559 PLT.subplot_imshow([im0, im1], [self.ref.title, self.shift.title], grid=True) 

1560 print('xgsd_factor', xgsd_factor) 

1561 print('ygsd_factor', ygsd_factor) 

1562 print('imfft_xgsd_mapvalues', self.imfft_xgsd) 

1563 print('imfft_ygsd_mapvalues', self.imfft_ygsd) 

1564 

1565 # calculate cross power spectrum without any de-shifting applied 

1566 scps = self._calc_shifted_cross_power_spectrum() # 8-18ms 

1567 

1568 if scps is None: 

1569 self.success = False 

1570 

1571 return 'fail' 

1572 

1573 # calculate spatial shifts 

1574 

1575 # calculate integer shifts 

1576 count_iter = 1 

1577 x_intshift, y_intshift = self._calc_integer_shifts(scps) 

1578 

1579 if (x_intshift, y_intshift) == (0, 0): 

1580 # in case integer shifts are zero 

1581 self.success = True 

1582 else: 

1583 valid_invalid, x_val_shift, y_val_shift, scps = \ 

1584 self._validate_integer_shifts(im0, im1, x_intshift, y_intshift) 

1585 

1586 while valid_invalid != 'valid': 

1587 count_iter += 1 

1588 

1589 if count_iter > self.max_iter: 

1590 self._handle_error(RuntimeError('No match found in the given window.')) 

1591 break 

1592 

1593 if valid_invalid == 'invalid' and (x_val_shift, y_val_shift) == (None, None): 

1594 # this happens if matching window became too small 

1595 self.success = False 

1596 break 

1597 

1598 if not self.q: 

1599 print('No clear match found yet. Jumping to iteration %s...' % count_iter) 

1600 print('input shifts: ', x_val_shift, y_val_shift) 

1601 

1602 valid_invalid, x_val_shift, y_val_shift, scps = \ 

1603 self._validate_integer_shifts(im0, im1, x_val_shift, y_val_shift) 

1604 

1605 # overwrite previous integer shifts if a valid match has been found 

1606 if valid_invalid == 'valid': 

1607 self.success = True 

1608 x_intshift, y_intshift = x_val_shift, y_val_shift 

1609 

1610 # calculate sub-pixel shifts 

1611 if self.success or self.success is None: 

1612 # get total pixel shifts 

1613 x_subshift, y_subshift = self._calc_subpixel_shifts(scps) 

1614 x_totalshift, y_totalshift = self._get_total_shifts(x_intshift, y_intshift, x_subshift, y_subshift) 

1615 

1616 if max([abs(x_totalshift), abs(y_totalshift)]) > self.max_shift: 

1617 self._handle_error( 

1618 RuntimeError("The calculated shift (X: %s px / Y: %s px) is recognized as too large to " 

1619 "be valid. If you know that it is valid, just set the '-max_shift' " 

1620 "parameter to an appropriate value. Otherwise try to use a different window " 

1621 "size for matching via the '-ws' parameter or define the spectral bands " 

1622 "to be used for matching manually ('-br' and '-bs')." 

1623 % (x_totalshift, y_totalshift))) 

1624 else: 

1625 self.success = True 

1626 self.x_shift_px, self.y_shift_px = x_totalshift * xgsd_factor, y_totalshift * ygsd_factor 

1627 

1628 # get map shifts 

1629 new_originX, new_originY = imXY2mapXY((self.x_shift_px, self.y_shift_px), self.shift.gt) 

1630 self.x_shift_map, self.y_shift_map = new_originX - self.shift.gt[0], new_originY - self.shift.gt[3] 

1631 

1632 # get length of shift vector in map units 

1633 self.vec_length_map = float(np.sqrt(self.x_shift_map ** 2 + self.y_shift_map ** 2)) 

1634 

1635 # get angle of shift vector 

1636 self.vec_angle_deg = GEO.angle_to_north((self.x_shift_px, self.y_shift_px)).tolist()[0] 

1637 

1638 # print results 

1639 if not self.q: 

1640 print('Detected integer shifts (X/Y): %s/%s' % (x_intshift, y_intshift)) 

1641 print('Detected subpixel shifts (X/Y): %s/%s' % (x_subshift, y_subshift)) 

1642 print('Calculated total shifts in fft pixel units (X/Y): %s/%s' 

1643 % (x_totalshift, y_totalshift)) 

1644 print('Calculated total shifts in reference pixel units (X/Y): %s/%s' 

1645 % (x_totalshift, y_totalshift)) 

1646 print('Calculated total shifts in target pixel units (X/Y): %s/%s' 

1647 % (self.x_shift_px, self.y_shift_px)) 

1648 print('Calculated map shifts (X,Y):\t\t\t\t %s/%s' % (self.x_shift_map, self.y_shift_map)) 

1649 print('Calculated absolute shift vector length in map units: %s' % self.vec_length_map) 

1650 print('Calculated angle of shift vector in degrees from North: %s' % self.vec_angle_deg) 

1651 

1652 if self.x_shift_px or self.y_shift_px: 

1653 self._get_updated_map_info() 

1654 

1655 # set self.ssim_before and ssim_after 

1656 self._validate_ssim_improvement() # FIXME uses the not updated matchWin size 

1657 self.shift_reliability = self._calc_shift_reliability(scps) 

1658 

1659 return 'success' 

1660 

1661 def _get_updated_map_info(self) -> None: 

1662 original_map_info = geotransform2mapinfo(self.shift.gt, self.shift.prj) 

1663 self.updated_map_info = copy(original_map_info) 

1664 self.updated_map_info[3] = str(float(original_map_info[3]) + self.x_shift_map) 

1665 self.updated_map_info[4] = str(float(original_map_info[4]) + self.y_shift_map) 

1666 

1667 if not self.q: 

1668 print('Original map info:', original_map_info) 

1669 print('Updated map info: ', self.updated_map_info) 

1670 

1671 @property 

1672 def coreg_info(self) -> dict: 

1673 """Return a dictionary containing everything to correct the detected global X/Y shift of the target image.""" 

1674 if self._coreg_info: 

1675 return self._coreg_info 

1676 

1677 else: 

1678 if self.success is None: 

1679 self.calculate_spatial_shifts() 

1680 

1681 self._coreg_info = { 

1682 'corrected_shifts_px': { 

1683 'x': self.x_shift_px, 

1684 'y': self.y_shift_px 

1685 }, 

1686 'corrected_shifts_map': { 

1687 'x': self.x_shift_map, 

1688 'y': self.y_shift_map 

1689 }, 

1690 'original map info': 

1691 geotransform2mapinfo(self.shift.gt, self.shift.prj), 

1692 'updated map info': 

1693 self.updated_map_info, 

1694 'reference projection': 

1695 self.ref.prj, 

1696 'reference geotransform': 

1697 self.ref.gt, 

1698 'reference grid': [ 

1699 [self.ref.gt[0], self.ref.gt[0] + self.ref.gt[1]], 

1700 [self.ref.gt[3], self.ref.gt[3] + self.ref.gt[5]] 

1701 ], 

1702 'reference extent': { 

1703 'cols': self.ref.xgsd, 

1704 'rows': self.ref.ygsd 

1705 }, # FIXME not needed anymore 

1706 'success': 

1707 self.success} 

1708 

1709 return self._coreg_info 

1710 

1711 def _get_inverted_coreg_info(self) -> dict: 

1712 """Return an inverted dictionary of coreg_info. 

1713 

1714 This dictionary can be passed to DESHIFTER in order to fit the REFERENCE image onto the TARGET image. 

1715 """ 

1716 inv_coreg_info = copy(self.coreg_info) 

1717 inv_coreg_info['corrected_shifts_px']['x'] *= -1 

1718 inv_coreg_info['corrected_shifts_px']['y'] *= -1 

1719 inv_coreg_info['corrected_shifts_map']['x'] *= -1 

1720 inv_coreg_info['corrected_shifts_map']['y'] *= -1 

1721 inv_coreg_info['original map info'] = geotransform2mapinfo(self.ref.gt, self.ref.prj) 

1722 inv_coreg_info['reference geotransform'] = self.shift.gt 

1723 inv_coreg_info['reference grid'] = self.shift.xygrid_specs 

1724 inv_coreg_info['reference projection'] = self.shift.prj 

1725 

1726 if inv_coreg_info['updated map info']: 

1727 updated_map_info = copy(inv_coreg_info['original map info']) 

1728 updated_map_info[3] = str(float(inv_coreg_info['original map info'][3]) - self.x_shift_map) 

1729 updated_map_info[4] = str(float(inv_coreg_info['original map info'][4]) - self.y_shift_map) 

1730 inv_coreg_info['updated map info'] = updated_map_info 

1731 

1732 return inv_coreg_info 

1733 

1734 def correct_shifts(self) -> dict: 

1735 """Correct the already calculated X/Y shift of the target image. 

1736 

1737 :return: COREG.deshift_results (dictionary) 

1738 """ 

1739 DS = DESHIFTER(self.shift, self.coreg_info, 

1740 path_out=self.path_out, 

1741 fmt_out=self.fmt_out, 

1742 out_crea_options=self.out_creaOpt, 

1743 out_gsd=self.out_gsd, 

1744 resamp_alg=self.rspAlg_DS, 

1745 align_grids=self.align_grids, 

1746 match_gsd=self.match_gsd, 

1747 target_xyGrid=self.target_xyGrid, 

1748 nodata=self.shift.nodata, 

1749 CPUs=self.CPUs, 

1750 progress=self.progress, 

1751 v=self.v, 

1752 q=self.q) 

1753 

1754 self.deshift_results = DS.correct_shifts() 

1755 

1756 return self.deshift_results