hylite.hyimage
Store and manipulate hyperspectral image data.
1""" 2Store and manipulate hyperspectral image data. 3""" 4 5import os 6import numpy as np 7import hylite 8from hylite.hydata import HyData 9from hylite.hylibrary import HyLibrary 10from hylite._deps import require 11 12 13 14class HyImage( HyData ): 15 """ 16 A class for hyperspectral image data. These can be individual scenes or hyperspectral orthoimages. 17 """ 18 19 def __init__(self, data, **kwds): 20 """ 21 Args: 22 data (ndarray): a numpy array such that data[x][y][band] gives each pixel value. 23 **kwds: 24 wav = A numpy array containing band wavelengths for this image. 25 affine = an affine transform of the format returned by GDAL.GetGeoTransform(). 26 projection = string defining the project. Default is None. 27 sensor = sensor name. Default is "unknown". 28 header = path to associated header file. Default is None. 29 """ 30 31 #call constructor for HyData 32 super().__init__(data, **kwds) 33 34 # special case - if dataset only has oneband, slice it so it still has 35 # the format data[x,y,b]. 36 if not self.data is None: 37 if len(self.data.shape) == 1: 38 self.data = self.data[None, None, :] # single pixel image 39 if len(self.data.shape) == 2: 40 self.data = self.data[:, :, None] # single band iamge 41 42 #load any additional project information (specific to images) 43 self.set_projection(kwds.get("projection",None)) 44 self.affine = np.array( kwds.get("affine",[0,1,0,0,0,1]) ) 45 self.header['affine'] = kwds.get("affine",[0,1,0,0,0,1]) # also store this here 46 47 # wavelengths 48 if 'wav' in kwds: 49 self.set_wavelengths(kwds['wav']) 50 51 #special header formatting 52 self.header['file type'] = 'ENVI Standard' 53 54 def copy(self,data=True): 55 """ 56 Make a deep copy of this image instance. 57 58 Args: 59 data (bool): True if a copy of the data should be made, otherwise only copy header. 60 61 Returns: 62 a new `hylite.hyimage.HyImage` instance. 63 """ 64 if not data: 65 return HyImage(None, header=self.header.copy(), projection=self.projection, affine=self.affine) 66 else: 67 return HyImage( self.data.copy(), header=self.header.copy(), projection=self.projection, affine=self.affine) 68 69 def T(self): 70 """ 71 Return a transposed view of the data matrix (corresponding with the [y,x] indexing used by matplotlib, opencv etc. 72 """ 73 return np.transpose(self.data, (1,0,2)) 74 75 def xdim(self): 76 """ 77 Return number of pixels in x (first dimension of data array) 78 """ 79 return self.data.shape[0] 80 81 def ydim(self): 82 """ 83 Return number of pixels in y (second dimension of data array) 84 """ 85 return self.data.shape[1] 86 87 def aspx(self): 88 """ 89 Return the aspect ratio of this image (width/height). 90 """ 91 return self.ydim() / self.xdim() 92 93 ##################################### 94 ## GEOREFERENCING METHODS 95 ##################################### 96 97 def get_extent(self): 98 """ 99 Returns the width and height of this image in world coordinates. 100 101 Returns: 102 tuple with (width, height). 103 """ 104 return self.xdim * self.pixel_size[0], self.ydim * self.pixel_size[1] 105 106 def set_projection(self,proj): 107 """ 108 Set this project to an existing osgeo.osr.SpatialReference or GDAL georeference string. 109 110 Args: 111 proj (str, osgeo.osr.SpatialReference): the project to use as osgeo.osr.SpatialReference or GDAL georeference string. 112 """ 113 if proj is None: 114 self.projection = None 115 else: 116 try: 117 from osgeo.osr import SpatialReference 118 except: 119 assert False, "Error - GDAL must be installed to work with spatial projections in hylite." 120 if isinstance(proj, SpatialReference): 121 self.projection = proj 122 elif isinstance(proj, str): 123 self.projection = SpatialReference(proj) 124 else: 125 print("Invalid project %s" % proj) 126 raise 127 128 def set_projection_EPSG(self,EPSG): 129 """ 130 Sets this image project using an EPSG code. 131 132 Args: 133 EPSG (str): string EPSG code that can be passed to SpatialReference.SetFromUserInput(...). 134 """ 135 136 try: 137 from osgeo.osr import SpatialReference 138 except: 139 assert False, "Error - GDAL must be installed to work with spatial projections in hylite." 140 141 self.projection = SpatialReference() 142 self.projection.SetFromUserInput(EPSG) 143 144 def get_projection_EPSG(self): 145 """ 146 Gets a string describing this projections EPSG code (if it is an EPSG project). 147 148 Returns: 149 an EPSG code string of the format "EPSG:XXXX". 150 """ 151 if self.projection is None: 152 return None 153 else: 154 return "%s:%s" % (self.projection.GetAttrValue("AUTHORITY",0),self.projection.GetAttrValue("AUTHORITY",1)) 155 156 def pix_to_world(self, px, py, proj=None): 157 """ 158 Take pixel coordinates and return world coordinates 159 160 Args: 161 px (int): the pixel x-coord. 162 py (int): the pixel y-coord. 163 proj (str, osr.SpatialReference): the coordinate system to use. Default (None) uses the same system as this image. Otherwise 164 an osr.SpatialReference can be passed (`hylite.hyimage.HyImage`.project), or an EPSG string (e.g. get_projection_EPSG(...)). 165 Returns: 166 the world coordinates in the coordinate system defined by get_projection_EPSG(...). 167 """ 168 169 try: 170 from osgeo import osr 171 import osgeo.gdal as gdal 172 from osgeo import ogr 173 except: 174 assert False, "Error - GDAL must be installed to work with spatial projections in hylite." 175 176 # parse project 177 if proj is None: 178 proj = self.projection 179 elif isinstance(proj, str) or isinstance(proj, int): 180 epsg = proj 181 if isinstance(epsg, str): 182 try: 183 epsg = int(str.split(':')[1]) 184 except: 185 assert False, "Error - %s is an invalid EPSG code." % proj 186 proj = osr.SpatialReference() 187 proj.ImportFromEPSG(epsg) 188 189 # check we have all the required info 190 assert isinstance(proj, osr.SpatialReference), "Error - invalid spatial reference %s" % proj 191 assert (not self.affine is None) and ( 192 not self.projection is None), "Error - project information is undefined." 193 194 #project to world coordinates in this images project/world coords 195 x,y = gdal.ApplyGeoTransform(self.affine, px, py) 196 197 #project to target coords (if different) 198 if not proj.IsSameGeogCS(self.projection): 199 P = ogr.Geometry(ogr.wkbPoint) 200 if proj.EPSGTreatsAsNorthingEasting(): 201 P.AddPoint(x, y) 202 else: 203 P.AddPoint(y, x) 204 P.AssignSpatialReference(self.projection) # tell the point what coordinates it's in 205 P.TransformTo(proj) # reproject it to the out spatial reference 206 x, y = P.GetX(), P.GetY() 207 208 #do we need to transpose? 209 if proj.EPSGTreatsAsLatLong(): 210 x,y=y,x #we want lon,lat not lat,lon 211 return x, y 212 213 def world_to_pix(self, x, y, proj = None): 214 """ 215 Take world coordinates and return pixel coordinates 216 217 Args: 218 x (float): the world x-coord. 219 y (float): the world y-coord. 220 proj (str, osr.SpatialReference): the coordinate system of the input coordinates. Default (None) uses the same system as this image. Otherwise 221 an osr.SpatialReference can be passed (`hylite.hyimage.HyImage`.project), or an EPSG string (e.g. get_projection_EPSG(...)). 222 223 Returns: 224 the pixel coordinates based on the affine transform stored in self.affine. 225 """ 226 227 try: 228 from osgeo import osr 229 import osgeo.gdal as gdal 230 from osgeo import ogr 231 except: 232 assert False, "Error - GDAL must be installed to work with spatial projections in hylite." 233 234 # parse project 235 if proj is None: 236 proj = self.projection 237 elif isinstance(proj, str) or isinstance(proj, int): 238 epsg = proj 239 if isinstance(epsg, str): 240 try: 241 epsg = int(str.split(':')[1]) 242 except: 243 assert False, "Error - %s is an invalid EPSG code." % proj 244 proj = osr.SpatialReference() 245 proj.ImportFromEPSG(epsg) 246 247 248 # check we have all the required info 249 assert isinstance(proj, osr.SpatialReference), "Error - invalid spatial reference %s" % proj 250 assert (not self.affine is None) and (not self.projection is None), "Error - project information is undefined." 251 252 # project to this images CS (if different) 253 if not proj.IsSameGeogCS(self.projection): 254 P = ogr.Geometry(ogr.wkbPoint) 255 if proj.EPSGTreatsAsNorthingEasting(): 256 P.AddPoint(x, y) 257 else: 258 P.AddPoint(y, x) 259 P.AssignSpatialReference(proj) # tell the point what coordinates it's in 260 P.AddPoint(x, y) 261 P.TransformTo(self.projection) # reproject it to the out spatial reference 262 x, y = P.GetX(), P.GetY() 263 if self.projection.EPSGTreatsAsLatLong(): # do we need to transpose? 264 x, y = y, x # we want lon,lat not lat,lon 265 266 inv = gdal.InvGeoTransform(self.affine) 267 assert not inv is None, "Error - could not invert affine transform?" 268 269 #apply 270 return gdal.ApplyGeoTransform(inv, x, y) 271 272 def crop(self, xmin, xmax, ymin, ymax, bands=None): 273 """ 274 Return a cropped copy of this image. 275 276 Args: 277 xmin, xmax (int): pixel bounds in x (rows) 278 ymin, ymax (int): pixel bounds in y (columns) 279 bands (None, list, tuple): optional band indices or (min,max) range 280 281 Returns: 282 `hylite.hyimage.HyImage`: cropped image with updated affine transform 283 """ 284 285 # ---- validate bounds ---- 286 xmin = int(max(0, xmin)) 287 ymin = int(max(0, ymin)) 288 xmax = int(min(self.xdim(), xmax)) 289 ymax = int(min(self.ydim(), ymax)) 290 291 assert xmin < xmax and ymin < ymax, "Invalid crop extent." 292 293 # ---- crop data ---- 294 if bands is None: 295 data = self.data[xmin:xmax, ymin:ymax, :].copy() 296 wav = self.get_wavelengths() 297 else: # band selection 298 if isinstance(bands, tuple): 299 b0 = self.get_band_index(bands[0]) 300 b1 = self.get_band_index(bands[1]) 301 data = self.data[xmin:xmax, ymin:ymax, b0:b1].copy() 302 wav = self.get_wavelengths()[b0:b1] 303 else: 304 idx = [self.get_band_index(b) for b in bands] 305 data = self.data[xmin:xmax, ymin:ymax, idx].copy() 306 wav = self.get_wavelengths()[idx] 307 308 # ---- update affine transform ---- 309 if self.affine is not None: 310 a = list(self.affine) 311 new_affine = a.copy() 312 313 # shift origin to new top-left pixel 314 new_affine[0] = a[0] + xmin*a[1] + ymin*a[2] 315 new_affine[3] = a[3] + xmin*a[4] + ymin*a[5] 316 else: 317 new_affine = None 318 319 # ---- construct output image ---- 320 out = HyImage( 321 data, 322 header=self.header.copy(), 323 projection=self.projection, 324 affine=new_affine, 325 wav=wav 326 ) 327 328 return out 329 330 def resize(self, newdims: tuple, interpolation: int = 1): 331 """ 332 Resize this image with opencv and update affine transform accordingly. 333 334 Args: 335 newdims (tuple): the new image dimensions (xdim, ydim) 336 interpolation (int): opencv interpolation method. Default is cv2.INTER_LINEAR. 337 """ 338 import cv2 # avoid import issues if opencv is missing 339 340 old_x, old_y = self.xdim(), self.ydim() 341 new_x, new_y = int(newdims[0]), int(newdims[1]) 342 343 assert new_x > 0 and new_y > 0, "Invalid resize dimensions." 344 345 # resize data (opencv uses width, height = y, x) 346 self.data = cv2.resize( 347 self.data, 348 (new_y, new_x), 349 interpolation=interpolation 350 ) 351 352 # update affine transform 353 if self.affine is not None: 354 a = list(self.affine) 355 356 sx = old_x / new_x 357 sy = old_y / new_y 358 359 self.affine = [ 360 a[0], # x origin unchanged 361 a[1] * sx, # pixel width 362 a[2] * sy, # row rotation 363 a[3], # y origin unchanged 364 a[4] * sx, # column rotation 365 a[5] * sy # pixel height 366 ] 367 368 def tile(self, tile_size): 369 """ 370 Break image into tiles of given size and return a list of `hylite.hyimage.HyImage` tiles. 371 Each tile has an updated affine transform reflecting its position in the original image. 372 373 Args: 374 tile_size (tuple): (tile_x, tile_y) in pixels 375 Returns: 376 list of `hylite.hyimage.HyImage` 377 """ 378 tiles = [] 379 tx, ty = tile_size 380 nx, ny = self.xdim(), self.ydim() 381 for i in range(0, nx, tx): 382 for j in range(0, ny, ty): 383 data_tile = self.data[i:min(i+tx, nx), j:min(j+ty, ny), :].copy() # slice data 384 385 # compute new affine 386 # affine = [x0, dx, rotx, y0, roty, dy] 387 x0, dx, rotx, y0, roty, dy = self.affine 388 new_x0 = x0 + i*dx + j*rotx 389 new_y0 = y0 + i*roty + j*dy 390 new_affine = [new_x0, dx, rotx, new_y0, roty, dy] 391 392 # create tile 393 tile_img = HyImage( 394 data_tile, 395 affine=new_affine, 396 projection=self.projection, 397 wav=self.get_wavelengths(), 398 header=self.header.copy() 399 ) 400 tile_img.header['xleft'] = i 401 tile_img.header['ytop'] = j 402 tiles.append(tile_img) 403 404 return tiles 405 406 @staticmethod 407 def mosaic( 408 tiles, 409 blend="mean", 410 resampling="nearest", 411 out_affine=None, 412 out_shape=None, 413 ): 414 """ 415 Mosaic georeferenced `hylite.hyimage.HyImage` tiles using GDAL. Note that this assumes all tiles are in the same coordinate system. 416 417 Args: 418 tiles (list[`hylite.hyimage.HyImage`]) 419 blend (str): 'first', 'min', 'max', 'mean', 'median' 420 resampling (str): 'nearest', 'bilinear', 'cubic' 421 out_affine (list): optional 6-element affine to define output grid. If None, the affine of the first tile is used. 422 out_shape (tuple): optional (xdim, ydim) shape of the output grid. If None, the extent of all tiles will be used. 423 Returns: 424 HyImage 425 """ 426 import numpy as np 427 from hylite.project.align import resample_raster 428 from osgeo import gdal, osr 429 430 assert len(tiles) > 0 431 assert blend in ("first", "min", "max", "mean", "median") 432 433 # compute bounds in world coordinates 434 # N.B. THIS ASSUMES ALL DATA ARE IN THE SAME CRS 435 points = [] 436 for t in tiles: 437 points.append( t.pix_to_world(0,0) ) 438 points.append( t.pix_to_world(t.xdim()+1,t.ydim()+1) ) 439 min_x, min_y = np.min(points, axis=0) 440 max_x, max_y = np.max(points, axis=0) 441 if out_shape is None: 442 out_shape = (np.array(tiles[0].world_to_pix(max_x, max_y)) + np.array(tiles[0].world_to_pix(min_x, min_y))).round().astype(int) 443 if out_affine is None: 444 out_affine = list(tiles[0].affine) 445 out_affine[0] = min_x 446 out_affine[3] = max_y 447 448 if blend == "first": # fill output, first come, first served. 449 out = np.full( tuple(out_shape) + (tiles[0].band_count(),), np.nan, dtype=np.float32) 450 for t in tiles: 451 r = resample_raster( t.data, t.affine, out_affine, out_shape ) 452 mask = np.isnan(out) 453 out[mask] = r[mask] 454 elif blend == "min": # keep minimum value in case of overlap 455 out = np.nanmin( np.stack([ resample_raster( t.data, t.affine, out_affine, out_shape ) for t in tiles ], 456 axis=0), axis=0 ) 457 elif blend == "max": # keep maximum value in case of overlap 458 out = np.nanmax( np.stack([ resample_raster( t.data, t.affine, out_affine, out_shape ) for t in tiles ], 459 axis=0), axis=0 ) 460 elif blend == "mean": # use average in case of overlap 461 out = np.nanmean( np.stack([ resample_raster( t.data, t.affine, out_affine, out_shape ) for t in tiles ], 462 axis=0), axis=0 ) 463 elif blend == "median": # use mean in case of overlap 464 out = np.nanmedian( np.stack([ resample_raster( t.data, t.affine, out_affine, out_shape ) for t in tiles ], 465 axis=0), axis=0 ) 466 467 # Return HyImage 468 out = HyImage( 469 out, 470 affine=out_affine, 471 projection=tiles[0].projection, 472 wav=tiles[0].get_wavelengths(), 473 header=tiles[0].header.copy() 474 ) 475 if 'xleft' in out.header: del out.header['xleft'] # stored in tiles, but not meaningful here 476 if 'ytop' in out.header: del out.header['ytop'] # stored in tiles, but not meaningful here 477 478 return out 479 480 ##################################### 481 ## BASIC TRANSFORMS 482 ##################################### 483 484 def flip(self, axis='x'): 485 """ 486 Flip the image on the x or y axis. Note that this will remove any defined affine transform. 487 488 Args: 489 axis (str): 'x' or 'y' or both 'xy'. 490 """ 491 492 if 'x' in axis.lower(): 493 self.data = np.flip(self.data,axis=0) 494 if 'y' in axis.lower(): 495 self.data = np.flip(self.data,axis=1) 496 self.affine = None 497 if 'affine' in self.header: del self.header['affine'] 498 self.push_to_header() # update width and height info 499 500 def rot90(self): 501 """ 502 Rotate this image by 90 degrees by transposing the underlying data array. Combine with flip('x') or flip('y') 503 to achieve positive/negative rotations. 504 """ 505 self.data = np.transpose( self.data, (1,0,2) ) 506 self.affine = None 507 if 'affine' in self.header: del self.header['affine'] 508 self.push_to_header() # update width and height info 509 510 ##################################### 511 ##IMAGE FILTERING 512 ##################################### 513 def fill_holes(self): 514 """ 515 Replaces nan pixel with an average of their neighbours, thus removing 1-pixel large holes from an image. Note that 516 for performance reasons this assumes that holes line up across bands. Note that this is not vectorized so very slow... 517 """ 518 519 # perform greyscale dilation 520 ndimage = require("scipy").ndimage 521 dilate = self.data.copy() 522 mask = np.logical_not(np.isfinite(dilate)) 523 dilate[mask] = 0 524 for b in range(self.band_count()): 525 dilate[:, :, b] = ndimage.grey_dilation(dilate[:, :, b], size=(3, 3)) 526 527 # map back to holes in dataset 528 self.data[mask] = dilate[mask] 529 #self.data[self.data == 0] = np.nan # replace remaining 0's with nans 530 531 def blur(self, n=3): 532 """ 533 Applies a gaussian kernel of size n to the image using OpenCV. 534 535 Args: 536 n (int): the dimensions of the gaussian kernel to convolve. Default is 3. Increase for more blurry results. 537 """ 538 import cv2 # import this here to avoid errors if opencv is not installed properly 539 540 nanmask = np.isnan(self.data) 541 assert isinstance(n, int) and n >= 3, "Error - invalid kernel. N must be an integer > 3. " 542 kernel = np.ones((n, n), np.float32) / (n ** 2) 543 self.data = cv2.filter2D(self.data, -1, kernel) 544 self.data[nanmask] = np.nan # remove mask 545 546 def erode(self, size=3, iterations=1): 547 """ 548 Apply an erode filter to this image to expand background (nan) pixels. Refer to open-cv's erode 549 function for more details. 550 551 Args: 552 size (int): the size of the erode filter. Default is a 3x3 kernel. 553 iterations (int): the number of erode iterations. Default is 1. 554 """ 555 import cv2 # import this here to avoid errors if opencv is not installed properly 556 557 # erode 558 kernel = np.ones((size, size), np.uint8) 559 if self.is_float(): 560 mask = np.isfinite(self.data).any(axis=-1) 561 mask = cv2.erode(mask.astype(np.uint8), kernel, iterations=iterations) 562 self.data[mask == 0, :] = np.nan 563 else: 564 mask = (self.data != 0).any( axis=-1 ) 565 mask = cv2.erode(mask.astype(np.uint8), kernel, iterations=iterations) 566 self.data[mask == 0, :] = 0 567 568 def despeckle(self, size=5): 569 """ 570 Despeckle each band of this image (independently) using a median filter. 571 572 Args: 573 size (int): the size of the median filter kernel. Default is 5. Must be an odd number. 574 """ 575 576 assert (size % 2) == 1, "Error - size must be an odd integer" 577 import cv2 # import this here to avoid errors if opencv is not installed properly 578 if self.is_float(): 579 self.data = cv2.medianBlur( self.data.astype(np.float32), size ) 580 else: 581 self.data = cv2.medianBlur( self.data, size ) 582 583 ##################################### 584 ##FEATURES AND FEATURE MATCHING 585 ###################################### 586 def get_keypoints(self, band, eq=False, mask=True, method='sift', cfac=0.0,bfac=0.0, **kwds): 587 """ 588 Get feature descriptors from the specified band. 589 590 Args: 591 band (int,float,str,tuple): the band index (int) or wavelength (float) to extract features from. Alternatively, a tuple can be passed 592 containing a range of bands (min : max) to average before feature matching. 593 eq (bool): True if the image should be histogram equalized first. Default is False. 594 mask (bool): True if 0 value pixels should be masked. Default is True. 595 method (str): the feature detector to use. Options are 'SIFT' and 'ORB' (faster but less accurate). Default is 'SIFT'. 596 cfac (float): contrast adjustment to apply to hyperspectral bands before matching. Default is 0.0. 597 bfac (float): brightness adjustment to apply to hyperspectral bands before matching. Default is 0.0. 598 **kwds: keyword arguments are passed to the opencv feature detector. For SIFT these are: 599 600 - contrastThreshold: default is 0.01. 601 - edgeThreshold: default is 10. 602 - sigma: default is 1.0 603 604 For ORB these are: 605 606 - nfeatures = the number of features to detect. Default is 5000. 607 608 Returns: 609 Tuple containing 610 611 - k (ndarray): the keypoints detected 612 - d (ndarray): corresponding feature descriptors 613 """ 614 import cv2 # import this here to avoid errors if opencv is not installed properly 615 616 # get image 617 if isinstance(band, int) or isinstance(band, float): #single band 618 image = self.data[:, :, self.get_band_index(band)] 619 elif isinstance(band,tuple): #range of bands (averaged) 620 idx0 = self.get_band_index(band[0]) 621 idx1 = self.get_band_index(band[1]) 622 623 #deal with out of range errors 624 if idx0 is None: 625 idx0 = 0 626 if idx1 is None: 627 idx1 = self.band_count() 628 629 #average bands 630 image = np.nanmean(self.data[:,:,idx0:idx1],axis=2) 631 else: 632 assert False, "Error, unrecognised band %s" % band 633 634 #normalise image to range 0 - 1 635 image -= np.nanmin(image) 636 image = image / np.nanmax(image) 637 638 #apply brightness/contrast adjustment 639 image = (1.0+cfac)*image + bfac 640 image[image > 1.0] = 1.0 641 image[image < 0.0] = 0.0 642 643 #convert image to uint8 for opencv 644 image = np.uint8(255 * image) 645 if eq: 646 image = cv2.equalizeHist(image) 647 648 if mask: 649 mask = np.zeros(image.shape, dtype=np.uint8) 650 mask[image != 0] = 255 # include only non-zero pixels 651 else: 652 mask = None 653 654 if 'sift' in method.lower(): # SIFT 655 656 # setup default keywords 657 kwds["contrastThreshold"] = kwds.get("contrastThreshold", 0.01) 658 kwds["edgeThreshold"] = kwds.get("edgeThreshold", 10) 659 kwds["sigma"] = kwds.get("sigma", 1.0) 660 661 # make feature detector 662 #alg = cv2.xfeatures2d.SIFT_create(**kwds) 663 alg = cv2.SIFT_create() 664 elif 'orb' in method.lower(): # orb 665 kwds['nfeatures'] = kwds.get('nfeatures', 5000) 666 alg = cv2.ORB_create(scoreType=cv2.ORB_FAST_SCORE, **kwds) 667 else: 668 assert False, "Error - %s is not a recognised feature detector." % method 669 670 # detect keypoints 671 kp = alg.detect(image, mask) 672 673 # extract and return feature vectors 674 return alg.compute(image, kp) 675 676 @classmethod 677 def match_keypoints(cls, kp1, kp2, d1, d2, method='SIFT', dist=0.7, tree = 5, check = 100, min_count=5): 678 """ 679 Compares keypoint feature vectors from two images and returns matching pairs. 680 681 Args: 682 kp1 (ndarray): keypoints from the first image 683 kp2 (ndarray): keypoints from the second image 684 d1 (ndarray): descriptors for the keypoints from the first image 685 d2 (ndarray): descriptors for the keypoints from the second image 686 method (str): the method used to calculate the feature descriptors. Should be 'sift' or 'orb'. Default is 'sift'. 687 dist (float): minimum match distance (0 to 1), default is 0.7 688 tree (int): not sure what this does? Default is 5. See open-cv docs. 689 check (int): ditto. Default is 100. 690 min_count (int): the minimum number of matches to consider a valid matching operation. If fewer matches are found, 691 then the function returns None, None. Default is 5. 692 """ 693 import cv2 # import this here to avoid errors if opencv is not installed properly 694 if 'sift' in method.lower(): 695 algorithm = cv2.NORM_INF 696 elif 'orb' in method.lower(): 697 algorithm = cv2.NORM_HAMMING 698 else: 699 assert False, "Error - unknown matching algorithm %s" % method 700 701 #calculate flann matches 702 index_params = dict(algorithm=algorithm, trees=tree) 703 search_params = dict(checks=check) 704 flann = cv2.FlannBasedMatcher(index_params, search_params) 705 matches = flann.knnMatch(d1, d2, k=2) 706 707 # store all the good matches as per Lowe's ratio test. 708 good = [] 709 for m, n in matches: 710 if m.distance < dist * n.distance: 711 good.append(m) 712 713 if len(good) < min_count: 714 return None, None 715 else: 716 src_pts = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) 717 dst_pts = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) 718 return src_pts, dst_pts 719 720 ############################ 721 ## Visualisation methods 722 ############################ 723 def quick_plot(self, bands=0, ax=None, bfac=0.0, cfac=0.0, samples=False, tscale=False, invert=False, rot=False, flipX=False, flipY=False, 724 **kwds): 725 """ 726 Plot a band using matplotlib.imshow(...). 727 728 Args: 729 bands (str,int,float,tuple): the band name (string), index (integer) or wavelength (float) to plot. Default is 0. If a tuple is passed then 730 each band in the tuple (string or index) will be mapped to rgb. Bands with negative wavelengths or indices will be inverted before plotting. 731 ax: an axis object to plot to. If none, plt.imshow( ... ) is used. 732 bfac (float): a brightness adjustment to apply to RGB mappings (-1 to 1) 733 cfac (float): a contrast adjustment to apply to RGB mappings (-1 to 1) 734 samples (bool): True if sample points (defined in the header file) should be plotted. Default is False. Otherwise, a list of 735 [ (x,y), ... ] points can be passed. 736 tscale (bool): True if each band (for ternary images) should be scaled independently. Default is False. 737 When using scaling, vmin and vmax can be used to set the clipping percentiles (integers) or 738 (constant) values (float). 739 invert (bool) : True if each band should be inverted before plotting. Only works for multiband (ternary) images. 740 rot (bool): if True, the x and y axis will be flipped (90 degree rotation) before plotting. Default is False. 741 flipX (bool): if True, the x axis will be flipped before plotting (after applying rotations). 742 flipY (bool): if True, the y axis will be flippe before plotting (after applying rotations). 743 **kwds: keywords are passed to matplotlib.imshow( ... ), except for the following: 744 745 - mask = a 2D boolean mask containing true if pixels should be drawn and false otherwise. 746 - path = a file path to save the image too (at matching resolution; use fig.savefig(..) if you want to save the figure). 747 - ticks = True if x- and y- ticks should be plotted. Default is False. 748 - ps, pc = the size and color of sample points to plot. Can be constant or list. 749 - figsize = a figsize for the figure to create (if ax is None). 750 751 Returns: 752 Tuple containing 753 754 - fig: matplotlib figure object 755 - ax: matplotlib axes object. If a colorbar is created, (band is an integer or a float), then this will be stored in ax.cbar. 756 """ 757 758 plt = require("matplotlib.pyplot") 759 760 #create new axes? 761 if ax is None: 762 fig, ax = plt.subplots(figsize=kwds.pop('figsize', (18,18*self.ydim()/self.xdim()) )) 763 764 # deal with ticks 765 if not kwds.pop('ticks', False ): 766 ax.set_xticks([]) 767 ax.set_yticks([]) 768 769 #map individual band using colourmap 770 if isinstance(bands, str) or isinstance(bands, int) or isinstance(bands, float): 771 #get band 772 if isinstance(bands, str): 773 data = self.data[:, :, self.get_band_index(bands)] 774 else: 775 data = self.data[:, :, self.get_band_index(np.abs(bands))] 776 if not isinstance(bands, str) and bands < 0: 777 data = np.nanmax(data) - data # flip 778 779 # convert integer vmin and vmax values to percentiles 780 if 'vmin' in kwds: 781 if isinstance(kwds['vmin'], int): 782 kwds['vmin'] = np.nanpercentile( data, kwds['vmin'] ) 783 if 'vmax' in kwds: 784 if isinstance(kwds['vmax'], int): 785 kwds['vmax'] = np.nanpercentile( data, kwds['vmax'] ) 786 787 #mask nans (and apply custom mask) 788 mask = np.isnan(data) 789 if not np.isnan(self.header.get_data_ignore_value()): 790 mask = mask + data == self.header.get_data_ignore_value() 791 if 'mask' in kwds: 792 mask = mask + kwds.get('mask') 793 del kwds['mask'] 794 data = np.ma.array(data, mask = mask > 0 ) 795 796 # apply rotations and flipping 797 if rot: 798 data = data.T 799 if flipX: 800 data = data[::-1, :] 801 if flipY: 802 data = data[:, ::-1] 803 804 # save? 805 if 'path' in kwds: 806 path = kwds.pop('path') 807 imsave = require("matplotlib.pyplot").imsave 808 if not os.path.exists(os.path.dirname(path)): 809 os.makedirs(os.path.dirname(path)) # ensure output directory exists 810 imsave(path, data.T, **kwds) # save the image 811 812 ax.cbar = ax.imshow(data.T, interpolation=kwds.pop('interpolation', 'none'), **kwds) # change default interpolation to None 813 814 #map 3 bands to RGB 815 elif isinstance(bands, tuple) or isinstance(bands, list): 816 #get band indices and range 817 rgb = [] 818 for b in bands: 819 if isinstance(b, str): 820 rgb.append(self.get_band_index(b)) 821 else: 822 rgb.append(self.get_band_index(np.abs(b))) 823 824 #slice image (as copy) and map to 0 - 1 825 img = np.array(self.data[:, :, rgb]).copy() 826 if np.isnan(img).all(): 827 print("Warning - image contains no data.") 828 return ax.get_figure(), ax 829 830 # invert if needed 831 if invert: 832 bands = [-b for b in bands] 833 for i,b in enumerate(bands): 834 if not isinstance(b, str) and (b < 0): 835 img[..., i] = np.nanmax(img[..., i]) - img[..., i] 836 837 # do scaling 838 if tscale: # scale bands independently 839 for b in range(3): 840 mn = kwds.get("vmin", float(np.nanmin(img))) 841 mx = kwds.get("vmax", float(np.nanmax(img))) 842 if isinstance (mn, int): 843 assert mn >= 0 and mn <= 100, "Error - integer vmin values must be a percentile." 844 mn = float(np.nanpercentile(img[...,b], mn )) 845 if isinstance (mx, int): 846 assert mx >= 0 and mx <= 100, "Error - integer vmax values must be a percentile." 847 mx = float(np.nanpercentile(img[...,b], mx )) 848 img[...,b] = (img[..., b] - mn) / (mx - mn) 849 else: # scale bands together 850 mn = kwds.get("vmin", float(np.nanmin(img))) 851 mx = kwds.get("vmax", float(np.nanmax(img))) 852 if isinstance(mn, int): 853 assert mn >= 0 and mn <= 100, "Error - integer vmin values must be a percentile." 854 mn = float(np.nanpercentile(img, mn)) 855 if isinstance(mx, int): 856 assert mx >= 0 and mx <= 100, "Error - integer vmax values must be a percentile." 857 mx = float(np.nanpercentile(img, mx)) 858 img = (img - mn) / (mx - mn) 859 860 #apply brightness/contrast mapping 861 img = np.clip((1.0 + cfac) * img + bfac, 0, 1.0 ) 862 863 #apply masking so background is white 864 img[np.logical_not( np.isfinite( img ) )] = 1.0 865 if 'mask' in kwds: 866 img[kwds.pop("mask"),:] = 1.0 867 868 # apply rotations and flipping 869 if rot: 870 img = np.transpose( img, (1,0,2) ) 871 if flipX: 872 img = img[::-1, :, :] 873 if flipY: 874 img = img[:, ::-1, :] 875 876 # save? 877 if 'path' in kwds: 878 path = kwds.pop('path') 879 imsave = require("matplotlib.pyplot").imsave 880 if not os.path.exists(os.path.dirname(path)): 881 os.makedirs(os.path.dirname(path)) # ensure output directory exists 882 imsave(path, np.transpose( np.clip( img*255, 0, 255).astype(np.uint8), (1, 0, 2))) # save the image 883 884 # plot samples? 885 ps = kwds.pop('ps', 5) 886 pc = kwds.pop('pc', 'r') 887 if samples: 888 if isinstance(samples, list) or isinstance(samples, np.ndarray): 889 ax.scatter([s[0] for s in samples], [s[1] for s in samples], s=ps, c=pc) 890 else: 891 for n in self.header.get_class_names(): 892 points = np.array(self.header.get_sample_points(n)) 893 ax.scatter(points[:, 0], points[:, 1], s=ps, c=pc) 894 895 #plot 896 ax.imshow(np.transpose(img, (1,0,2)), interpolation=kwds.pop('interpolation', 'none'), **kwds) 897 ax.cbar = None # no colorbar 898 899 return ax.get_figure(), ax 900 901 ## masking 902 def mask(self, mask=None, flag=np.nan, invert=False, crop=False, bands=None): 903 """ 904 Apply a mask to an image, flagging masked pixels with the specified value. Note that this applies the mask to the 905 image in-situ. 906 907 Args: 908 flag (float): the value to use for masked pixels. Default is np.nan 909 mask (ndarray): a numpy array defining the mask polygon of the format [[x1,y1],[x2,y2],...]. If None is passed then 910 pickPolygon( ... ) is used to interactively define a polygon. If a file path is passed then the polygon 911 will be loaded using np.load( ... ). Alternatively if mask.shape == image.shape[0,1] then it is treated as a 912 binary image mask (must be boolean) and True values will be masked across all bands. Default is None. 913 invert (bool): if True, pixels within the polygon will be masked. If False, pixels outside the polygon are masked. Default is False. 914 crop (bool): True if rows/columns containing only zeros should be removed. Default is False. 915 bands (tuple): the bands of the image to plot if no mask is specified. If None, the middle band is used. 916 917 Returns: 918 Tuple containing 919 920 - mask (ndarray): a boolean array with True where pixels are masked and False elsewhere. 921 - poly (ndarray): the mask polygon array in the format described above. Useful if the polygon was interactively defined. 922 """ 923 924 if mask is None: # pick mask interactively 925 if bands is None: 926 bands = int(self.band_count() / 2) 927 928 regions = self.pickPolygons(region_names=["mask"], bands=bands) 929 930 # the user bailed without picking a mask? 931 if len(regions) == 0: 932 print("Warning - no mask picked/applied.") 933 return 934 935 # extract polygon mask 936 mask = regions[0] 937 938 # convert polygon mask to binary mask 939 if mask.shape[1] == 2: 940 941 # build meshgrid with pixel coords 942 xx, yy = np.meshgrid(np.arange(self.xdim()), np.arange(self.ydim())) 943 xx = xx.flatten() 944 yy = yy.flatten() 945 points = np.vstack([xx, yy]).T # coordinates of each pixel 946 947 # calculate per-pixel mask 948 MplPath = require("matplotlib.path").Path 949 mask = MplPath(mask).contains_points(points) 950 mask = mask.reshape((self.ydim(), self.xdim())).T 951 952 # flip as we want to mask (==True) outside points (unless invert is true) 953 if not invert: 954 mask = np.logical_not(mask) 955 956 # apply binary image mask 957 assert mask.shape[0] == self.data.shape[0] and mask.shape[1] == self.data.shape[1], \ 958 "Error - mask shape %s does not match image shape %s" % (mask.shape, self.data.shape) 959 for b in range(self.band_count()): 960 self.data[:, :, b][mask] = flag 961 962 # crop image 963 if crop: 964 self.crop_to_data() 965 966 return mask 967 968 def crop_to_data(self): 969 """ 970 Remove padding of nan or zero pixels from image. Note that this is performed in place. 971 """ 972 valid = np.isfinite(self.data).any(axis=-1) & (self.data != 0).any(axis=-1) 973 974 # integrate along axes 975 xdata = np.sum(valid, axis=1) > 0.0 976 ydata = np.sum(valid, axis=0) > 0.0 977 978 # calculate domain containing valid pixels 979 xmin = np.argmax(xdata) 980 xmax = xdata.shape[0] - np.argmax(xdata[::-1]) 981 ymin = np.argmax(ydata) 982 ymax = ydata.shape[0] - np.argmax(ydata[::-1]) 983 984 # crop 985 self.data = self.data[xmin:xmax, ymin:ymax, :] 986 987 # shift affine origin to new top-left pixel 988 if self.affine is not None: 989 a = self.affine # shorthand for affine 990 new_affine = list(self.affine) 991 new_affine[0] = a[0] + xmin*a[1] + ymin*a[2] 992 new_affine[3] = a[3] + xmin*a[4] + ymin*a[5] 993 self.affine = np.array(new_affine) 994 self.header['affine'] = self.affine 995 996 ################################################## 997 ## Interactive tools for picking regions/pixels 998 ################################################## 999 def pickPolygons(self, region_names, bands=0): 1000 """ 1001 Creates a matplotlib gui for selecting polygon regions in an image. 1002 1003 Args: 1004 names (list, str): a list containing the names of the regions to pick. If a string is passed only one name is used. 1005 bands (tuple): the bands of the image to plot. 1006 """ 1007 1008 if isinstance(region_names, str): 1009 region_names = [region_names] 1010 1011 assert isinstance(region_names, list), "Error - names must be a list or a string." 1012 1013 matplotlib = require("matplotlib") 1014 plt = require("matplotlib.pyplot") 1015 MultiRoi = require("roipoly").MultiRoi 1016 1017 # set matplotlib backend 1018 backend = matplotlib.get_backend() 1019 matplotlib.use('Qt5Agg') # need this backend for ROIPoly to work 1020 1021 # plot image and extract roi's 1022 fig, ax = self.quick_plot(bands) 1023 roi = MultiRoi(roi_names=region_names) 1024 plt.close(fig) # close figure 1025 1026 # extract regions 1027 regions = [] 1028 for name, r in roi.rois.items(): 1029 # store region 1030 x = r.x 1031 y = r.y 1032 regions.append(np.vstack([x, y]).T) 1033 1034 # restore matplotlib backend (if possible) 1035 try: 1036 matplotlib.use(backend) 1037 except: 1038 print("Warning: could not reset matplotlib backend. Plots will remain interactive...") 1039 pass 1040 1041 return regions 1042 1043 def pickPoints(self, n=-1, bands=hylite.RGB, integer=True, title="Pick Points", **kwds): 1044 """ 1045 Creates a matplotlib gui for picking pixels from an image. 1046 1047 Args: 1048 n (int): the number of pixels to pick, or -1 if the user can select as many as they wish. Default is -1. 1049 bands (tuple): the bands of the image to plot. Default is `hylite.hyimage.HyImage`.RGB 1050 integer (bool): True if points coordinates should be cast to integers (for use as indices). Default is True. 1051 title (str): The title of the point picking window. 1052 **kwds: Keywords are passed to `hylite.hyimage.HyImage`.quick_plot( ... ). 1053 1054 Returns: 1055 A list containing the picked point coordinates [ (x1,y1), (x2,y2), ... ]. 1056 """ 1057 1058 matplotlib = require("matplotlib") 1059 plt = require("matplotlib.pyplot") 1060 1061 # set matplotlib backend 1062 backend = matplotlib.get_backend() 1063 matplotlib.use('Qt5Agg') # need this backend for ROIPoly to work 1064 1065 # create figure 1066 fig, ax = self.quick_plot( bands, **kwds ) 1067 ax.set_title(title) 1068 1069 # get points 1070 points = fig.ginput( n ) 1071 1072 if integer: 1073 points = [ (int(p[0]), int(p[1])) for p in points ] 1074 1075 # restore matplotlib backend (if possible) 1076 try: 1077 matplotlib.use(backend) 1078 except: 1079 print("Warning: could not reset matplotlib backend. Plots will remain interactive...") 1080 pass 1081 1082 return points 1083 1084 def pickSamples(self, names=None, store=True, **kwds): 1085 """ 1086 Pick sample probe points and store these in the image header file. 1087 1088 Args: 1089 names (str, list): the name of the sample to pick, or a list of names to pick multiple. 1090 store (bool): True if sample should be stored in the image header file (for later access). Default is True. 1091 **kwds: Keywords are passed to `hylite.hyimage.HyImage`.quick_plot( ... ) 1092 1093 Returns: 1094 a list containing a list of points for each sample. 1095 """ 1096 1097 if isinstance(names, str): 1098 names = [names] 1099 1100 # pick points 1101 points = [] 1102 for s in names: 1103 pnts = self.pickPoints(title="%s" % s, **kwds) 1104 if store: 1105 self.header['sample %s' % s] = pnts # store in header 1106 points.append(pnts) 1107 # add class to header file 1108 if store: 1109 cls_names = self.header.get_class_names() 1110 if cls_names is None: 1111 cls_names = [] 1112 self.header['class names'] = cls_names + names 1113 1114 return points
15class HyImage( HyData ): 16 """ 17 A class for hyperspectral image data. These can be individual scenes or hyperspectral orthoimages. 18 """ 19 20 def __init__(self, data, **kwds): 21 """ 22 Args: 23 data (ndarray): a numpy array such that data[x][y][band] gives each pixel value. 24 **kwds: 25 wav = A numpy array containing band wavelengths for this image. 26 affine = an affine transform of the format returned by GDAL.GetGeoTransform(). 27 projection = string defining the project. Default is None. 28 sensor = sensor name. Default is "unknown". 29 header = path to associated header file. Default is None. 30 """ 31 32 #call constructor for HyData 33 super().__init__(data, **kwds) 34 35 # special case - if dataset only has oneband, slice it so it still has 36 # the format data[x,y,b]. 37 if not self.data is None: 38 if len(self.data.shape) == 1: 39 self.data = self.data[None, None, :] # single pixel image 40 if len(self.data.shape) == 2: 41 self.data = self.data[:, :, None] # single band iamge 42 43 #load any additional project information (specific to images) 44 self.set_projection(kwds.get("projection",None)) 45 self.affine = np.array( kwds.get("affine",[0,1,0,0,0,1]) ) 46 self.header['affine'] = kwds.get("affine",[0,1,0,0,0,1]) # also store this here 47 48 # wavelengths 49 if 'wav' in kwds: 50 self.set_wavelengths(kwds['wav']) 51 52 #special header formatting 53 self.header['file type'] = 'ENVI Standard' 54 55 def copy(self,data=True): 56 """ 57 Make a deep copy of this image instance. 58 59 Args: 60 data (bool): True if a copy of the data should be made, otherwise only copy header. 61 62 Returns: 63 a new `hylite.hyimage.HyImage` instance. 64 """ 65 if not data: 66 return HyImage(None, header=self.header.copy(), projection=self.projection, affine=self.affine) 67 else: 68 return HyImage( self.data.copy(), header=self.header.copy(), projection=self.projection, affine=self.affine) 69 70 def T(self): 71 """ 72 Return a transposed view of the data matrix (corresponding with the [y,x] indexing used by matplotlib, opencv etc. 73 """ 74 return np.transpose(self.data, (1,0,2)) 75 76 def xdim(self): 77 """ 78 Return number of pixels in x (first dimension of data array) 79 """ 80 return self.data.shape[0] 81 82 def ydim(self): 83 """ 84 Return number of pixels in y (second dimension of data array) 85 """ 86 return self.data.shape[1] 87 88 def aspx(self): 89 """ 90 Return the aspect ratio of this image (width/height). 91 """ 92 return self.ydim() / self.xdim() 93 94 ##################################### 95 ## GEOREFERENCING METHODS 96 ##################################### 97 98 def get_extent(self): 99 """ 100 Returns the width and height of this image in world coordinates. 101 102 Returns: 103 tuple with (width, height). 104 """ 105 return self.xdim * self.pixel_size[0], self.ydim * self.pixel_size[1] 106 107 def set_projection(self,proj): 108 """ 109 Set this project to an existing osgeo.osr.SpatialReference or GDAL georeference string. 110 111 Args: 112 proj (str, osgeo.osr.SpatialReference): the project to use as osgeo.osr.SpatialReference or GDAL georeference string. 113 """ 114 if proj is None: 115 self.projection = None 116 else: 117 try: 118 from osgeo.osr import SpatialReference 119 except: 120 assert False, "Error - GDAL must be installed to work with spatial projections in hylite." 121 if isinstance(proj, SpatialReference): 122 self.projection = proj 123 elif isinstance(proj, str): 124 self.projection = SpatialReference(proj) 125 else: 126 print("Invalid project %s" % proj) 127 raise 128 129 def set_projection_EPSG(self,EPSG): 130 """ 131 Sets this image project using an EPSG code. 132 133 Args: 134 EPSG (str): string EPSG code that can be passed to SpatialReference.SetFromUserInput(...). 135 """ 136 137 try: 138 from osgeo.osr import SpatialReference 139 except: 140 assert False, "Error - GDAL must be installed to work with spatial projections in hylite." 141 142 self.projection = SpatialReference() 143 self.projection.SetFromUserInput(EPSG) 144 145 def get_projection_EPSG(self): 146 """ 147 Gets a string describing this projections EPSG code (if it is an EPSG project). 148 149 Returns: 150 an EPSG code string of the format "EPSG:XXXX". 151 """ 152 if self.projection is None: 153 return None 154 else: 155 return "%s:%s" % (self.projection.GetAttrValue("AUTHORITY",0),self.projection.GetAttrValue("AUTHORITY",1)) 156 157 def pix_to_world(self, px, py, proj=None): 158 """ 159 Take pixel coordinates and return world coordinates 160 161 Args: 162 px (int): the pixel x-coord. 163 py (int): the pixel y-coord. 164 proj (str, osr.SpatialReference): the coordinate system to use. Default (None) uses the same system as this image. Otherwise 165 an osr.SpatialReference can be passed (`hylite.hyimage.HyImage`.project), or an EPSG string (e.g. get_projection_EPSG(...)). 166 Returns: 167 the world coordinates in the coordinate system defined by get_projection_EPSG(...). 168 """ 169 170 try: 171 from osgeo import osr 172 import osgeo.gdal as gdal 173 from osgeo import ogr 174 except: 175 assert False, "Error - GDAL must be installed to work with spatial projections in hylite." 176 177 # parse project 178 if proj is None: 179 proj = self.projection 180 elif isinstance(proj, str) or isinstance(proj, int): 181 epsg = proj 182 if isinstance(epsg, str): 183 try: 184 epsg = int(str.split(':')[1]) 185 except: 186 assert False, "Error - %s is an invalid EPSG code." % proj 187 proj = osr.SpatialReference() 188 proj.ImportFromEPSG(epsg) 189 190 # check we have all the required info 191 assert isinstance(proj, osr.SpatialReference), "Error - invalid spatial reference %s" % proj 192 assert (not self.affine is None) and ( 193 not self.projection is None), "Error - project information is undefined." 194 195 #project to world coordinates in this images project/world coords 196 x,y = gdal.ApplyGeoTransform(self.affine, px, py) 197 198 #project to target coords (if different) 199 if not proj.IsSameGeogCS(self.projection): 200 P = ogr.Geometry(ogr.wkbPoint) 201 if proj.EPSGTreatsAsNorthingEasting(): 202 P.AddPoint(x, y) 203 else: 204 P.AddPoint(y, x) 205 P.AssignSpatialReference(self.projection) # tell the point what coordinates it's in 206 P.TransformTo(proj) # reproject it to the out spatial reference 207 x, y = P.GetX(), P.GetY() 208 209 #do we need to transpose? 210 if proj.EPSGTreatsAsLatLong(): 211 x,y=y,x #we want lon,lat not lat,lon 212 return x, y 213 214 def world_to_pix(self, x, y, proj = None): 215 """ 216 Take world coordinates and return pixel coordinates 217 218 Args: 219 x (float): the world x-coord. 220 y (float): the world y-coord. 221 proj (str, osr.SpatialReference): the coordinate system of the input coordinates. Default (None) uses the same system as this image. Otherwise 222 an osr.SpatialReference can be passed (`hylite.hyimage.HyImage`.project), or an EPSG string (e.g. get_projection_EPSG(...)). 223 224 Returns: 225 the pixel coordinates based on the affine transform stored in self.affine. 226 """ 227 228 try: 229 from osgeo import osr 230 import osgeo.gdal as gdal 231 from osgeo import ogr 232 except: 233 assert False, "Error - GDAL must be installed to work with spatial projections in hylite." 234 235 # parse project 236 if proj is None: 237 proj = self.projection 238 elif isinstance(proj, str) or isinstance(proj, int): 239 epsg = proj 240 if isinstance(epsg, str): 241 try: 242 epsg = int(str.split(':')[1]) 243 except: 244 assert False, "Error - %s is an invalid EPSG code." % proj 245 proj = osr.SpatialReference() 246 proj.ImportFromEPSG(epsg) 247 248 249 # check we have all the required info 250 assert isinstance(proj, osr.SpatialReference), "Error - invalid spatial reference %s" % proj 251 assert (not self.affine is None) and (not self.projection is None), "Error - project information is undefined." 252 253 # project to this images CS (if different) 254 if not proj.IsSameGeogCS(self.projection): 255 P = ogr.Geometry(ogr.wkbPoint) 256 if proj.EPSGTreatsAsNorthingEasting(): 257 P.AddPoint(x, y) 258 else: 259 P.AddPoint(y, x) 260 P.AssignSpatialReference(proj) # tell the point what coordinates it's in 261 P.AddPoint(x, y) 262 P.TransformTo(self.projection) # reproject it to the out spatial reference 263 x, y = P.GetX(), P.GetY() 264 if self.projection.EPSGTreatsAsLatLong(): # do we need to transpose? 265 x, y = y, x # we want lon,lat not lat,lon 266 267 inv = gdal.InvGeoTransform(self.affine) 268 assert not inv is None, "Error - could not invert affine transform?" 269 270 #apply 271 return gdal.ApplyGeoTransform(inv, x, y) 272 273 def crop(self, xmin, xmax, ymin, ymax, bands=None): 274 """ 275 Return a cropped copy of this image. 276 277 Args: 278 xmin, xmax (int): pixel bounds in x (rows) 279 ymin, ymax (int): pixel bounds in y (columns) 280 bands (None, list, tuple): optional band indices or (min,max) range 281 282 Returns: 283 `hylite.hyimage.HyImage`: cropped image with updated affine transform 284 """ 285 286 # ---- validate bounds ---- 287 xmin = int(max(0, xmin)) 288 ymin = int(max(0, ymin)) 289 xmax = int(min(self.xdim(), xmax)) 290 ymax = int(min(self.ydim(), ymax)) 291 292 assert xmin < xmax and ymin < ymax, "Invalid crop extent." 293 294 # ---- crop data ---- 295 if bands is None: 296 data = self.data[xmin:xmax, ymin:ymax, :].copy() 297 wav = self.get_wavelengths() 298 else: # band selection 299 if isinstance(bands, tuple): 300 b0 = self.get_band_index(bands[0]) 301 b1 = self.get_band_index(bands[1]) 302 data = self.data[xmin:xmax, ymin:ymax, b0:b1].copy() 303 wav = self.get_wavelengths()[b0:b1] 304 else: 305 idx = [self.get_band_index(b) for b in bands] 306 data = self.data[xmin:xmax, ymin:ymax, idx].copy() 307 wav = self.get_wavelengths()[idx] 308 309 # ---- update affine transform ---- 310 if self.affine is not None: 311 a = list(self.affine) 312 new_affine = a.copy() 313 314 # shift origin to new top-left pixel 315 new_affine[0] = a[0] + xmin*a[1] + ymin*a[2] 316 new_affine[3] = a[3] + xmin*a[4] + ymin*a[5] 317 else: 318 new_affine = None 319 320 # ---- construct output image ---- 321 out = HyImage( 322 data, 323 header=self.header.copy(), 324 projection=self.projection, 325 affine=new_affine, 326 wav=wav 327 ) 328 329 return out 330 331 def resize(self, newdims: tuple, interpolation: int = 1): 332 """ 333 Resize this image with opencv and update affine transform accordingly. 334 335 Args: 336 newdims (tuple): the new image dimensions (xdim, ydim) 337 interpolation (int): opencv interpolation method. Default is cv2.INTER_LINEAR. 338 """ 339 import cv2 # avoid import issues if opencv is missing 340 341 old_x, old_y = self.xdim(), self.ydim() 342 new_x, new_y = int(newdims[0]), int(newdims[1]) 343 344 assert new_x > 0 and new_y > 0, "Invalid resize dimensions." 345 346 # resize data (opencv uses width, height = y, x) 347 self.data = cv2.resize( 348 self.data, 349 (new_y, new_x), 350 interpolation=interpolation 351 ) 352 353 # update affine transform 354 if self.affine is not None: 355 a = list(self.affine) 356 357 sx = old_x / new_x 358 sy = old_y / new_y 359 360 self.affine = [ 361 a[0], # x origin unchanged 362 a[1] * sx, # pixel width 363 a[2] * sy, # row rotation 364 a[3], # y origin unchanged 365 a[4] * sx, # column rotation 366 a[5] * sy # pixel height 367 ] 368 369 def tile(self, tile_size): 370 """ 371 Break image into tiles of given size and return a list of `hylite.hyimage.HyImage` tiles. 372 Each tile has an updated affine transform reflecting its position in the original image. 373 374 Args: 375 tile_size (tuple): (tile_x, tile_y) in pixels 376 Returns: 377 list of `hylite.hyimage.HyImage` 378 """ 379 tiles = [] 380 tx, ty = tile_size 381 nx, ny = self.xdim(), self.ydim() 382 for i in range(0, nx, tx): 383 for j in range(0, ny, ty): 384 data_tile = self.data[i:min(i+tx, nx), j:min(j+ty, ny), :].copy() # slice data 385 386 # compute new affine 387 # affine = [x0, dx, rotx, y0, roty, dy] 388 x0, dx, rotx, y0, roty, dy = self.affine 389 new_x0 = x0 + i*dx + j*rotx 390 new_y0 = y0 + i*roty + j*dy 391 new_affine = [new_x0, dx, rotx, new_y0, roty, dy] 392 393 # create tile 394 tile_img = HyImage( 395 data_tile, 396 affine=new_affine, 397 projection=self.projection, 398 wav=self.get_wavelengths(), 399 header=self.header.copy() 400 ) 401 tile_img.header['xleft'] = i 402 tile_img.header['ytop'] = j 403 tiles.append(tile_img) 404 405 return tiles 406 407 @staticmethod 408 def mosaic( 409 tiles, 410 blend="mean", 411 resampling="nearest", 412 out_affine=None, 413 out_shape=None, 414 ): 415 """ 416 Mosaic georeferenced `hylite.hyimage.HyImage` tiles using GDAL. Note that this assumes all tiles are in the same coordinate system. 417 418 Args: 419 tiles (list[`hylite.hyimage.HyImage`]) 420 blend (str): 'first', 'min', 'max', 'mean', 'median' 421 resampling (str): 'nearest', 'bilinear', 'cubic' 422 out_affine (list): optional 6-element affine to define output grid. If None, the affine of the first tile is used. 423 out_shape (tuple): optional (xdim, ydim) shape of the output grid. If None, the extent of all tiles will be used. 424 Returns: 425 HyImage 426 """ 427 import numpy as np 428 from hylite.project.align import resample_raster 429 from osgeo import gdal, osr 430 431 assert len(tiles) > 0 432 assert blend in ("first", "min", "max", "mean", "median") 433 434 # compute bounds in world coordinates 435 # N.B. THIS ASSUMES ALL DATA ARE IN THE SAME CRS 436 points = [] 437 for t in tiles: 438 points.append( t.pix_to_world(0,0) ) 439 points.append( t.pix_to_world(t.xdim()+1,t.ydim()+1) ) 440 min_x, min_y = np.min(points, axis=0) 441 max_x, max_y = np.max(points, axis=0) 442 if out_shape is None: 443 out_shape = (np.array(tiles[0].world_to_pix(max_x, max_y)) + np.array(tiles[0].world_to_pix(min_x, min_y))).round().astype(int) 444 if out_affine is None: 445 out_affine = list(tiles[0].affine) 446 out_affine[0] = min_x 447 out_affine[3] = max_y 448 449 if blend == "first": # fill output, first come, first served. 450 out = np.full( tuple(out_shape) + (tiles[0].band_count(),), np.nan, dtype=np.float32) 451 for t in tiles: 452 r = resample_raster( t.data, t.affine, out_affine, out_shape ) 453 mask = np.isnan(out) 454 out[mask] = r[mask] 455 elif blend == "min": # keep minimum value in case of overlap 456 out = np.nanmin( np.stack([ resample_raster( t.data, t.affine, out_affine, out_shape ) for t in tiles ], 457 axis=0), axis=0 ) 458 elif blend == "max": # keep maximum value in case of overlap 459 out = np.nanmax( np.stack([ resample_raster( t.data, t.affine, out_affine, out_shape ) for t in tiles ], 460 axis=0), axis=0 ) 461 elif blend == "mean": # use average in case of overlap 462 out = np.nanmean( np.stack([ resample_raster( t.data, t.affine, out_affine, out_shape ) for t in tiles ], 463 axis=0), axis=0 ) 464 elif blend == "median": # use mean in case of overlap 465 out = np.nanmedian( np.stack([ resample_raster( t.data, t.affine, out_affine, out_shape ) for t in tiles ], 466 axis=0), axis=0 ) 467 468 # Return HyImage 469 out = HyImage( 470 out, 471 affine=out_affine, 472 projection=tiles[0].projection, 473 wav=tiles[0].get_wavelengths(), 474 header=tiles[0].header.copy() 475 ) 476 if 'xleft' in out.header: del out.header['xleft'] # stored in tiles, but not meaningful here 477 if 'ytop' in out.header: del out.header['ytop'] # stored in tiles, but not meaningful here 478 479 return out 480 481 ##################################### 482 ## BASIC TRANSFORMS 483 ##################################### 484 485 def flip(self, axis='x'): 486 """ 487 Flip the image on the x or y axis. Note that this will remove any defined affine transform. 488 489 Args: 490 axis (str): 'x' or 'y' or both 'xy'. 491 """ 492 493 if 'x' in axis.lower(): 494 self.data = np.flip(self.data,axis=0) 495 if 'y' in axis.lower(): 496 self.data = np.flip(self.data,axis=1) 497 self.affine = None 498 if 'affine' in self.header: del self.header['affine'] 499 self.push_to_header() # update width and height info 500 501 def rot90(self): 502 """ 503 Rotate this image by 90 degrees by transposing the underlying data array. Combine with flip('x') or flip('y') 504 to achieve positive/negative rotations. 505 """ 506 self.data = np.transpose( self.data, (1,0,2) ) 507 self.affine = None 508 if 'affine' in self.header: del self.header['affine'] 509 self.push_to_header() # update width and height info 510 511 ##################################### 512 ##IMAGE FILTERING 513 ##################################### 514 def fill_holes(self): 515 """ 516 Replaces nan pixel with an average of their neighbours, thus removing 1-pixel large holes from an image. Note that 517 for performance reasons this assumes that holes line up across bands. Note that this is not vectorized so very slow... 518 """ 519 520 # perform greyscale dilation 521 ndimage = require("scipy").ndimage 522 dilate = self.data.copy() 523 mask = np.logical_not(np.isfinite(dilate)) 524 dilate[mask] = 0 525 for b in range(self.band_count()): 526 dilate[:, :, b] = ndimage.grey_dilation(dilate[:, :, b], size=(3, 3)) 527 528 # map back to holes in dataset 529 self.data[mask] = dilate[mask] 530 #self.data[self.data == 0] = np.nan # replace remaining 0's with nans 531 532 def blur(self, n=3): 533 """ 534 Applies a gaussian kernel of size n to the image using OpenCV. 535 536 Args: 537 n (int): the dimensions of the gaussian kernel to convolve. Default is 3. Increase for more blurry results. 538 """ 539 import cv2 # import this here to avoid errors if opencv is not installed properly 540 541 nanmask = np.isnan(self.data) 542 assert isinstance(n, int) and n >= 3, "Error - invalid kernel. N must be an integer > 3. " 543 kernel = np.ones((n, n), np.float32) / (n ** 2) 544 self.data = cv2.filter2D(self.data, -1, kernel) 545 self.data[nanmask] = np.nan # remove mask 546 547 def erode(self, size=3, iterations=1): 548 """ 549 Apply an erode filter to this image to expand background (nan) pixels. Refer to open-cv's erode 550 function for more details. 551 552 Args: 553 size (int): the size of the erode filter. Default is a 3x3 kernel. 554 iterations (int): the number of erode iterations. Default is 1. 555 """ 556 import cv2 # import this here to avoid errors if opencv is not installed properly 557 558 # erode 559 kernel = np.ones((size, size), np.uint8) 560 if self.is_float(): 561 mask = np.isfinite(self.data).any(axis=-1) 562 mask = cv2.erode(mask.astype(np.uint8), kernel, iterations=iterations) 563 self.data[mask == 0, :] = np.nan 564 else: 565 mask = (self.data != 0).any( axis=-1 ) 566 mask = cv2.erode(mask.astype(np.uint8), kernel, iterations=iterations) 567 self.data[mask == 0, :] = 0 568 569 def despeckle(self, size=5): 570 """ 571 Despeckle each band of this image (independently) using a median filter. 572 573 Args: 574 size (int): the size of the median filter kernel. Default is 5. Must be an odd number. 575 """ 576 577 assert (size % 2) == 1, "Error - size must be an odd integer" 578 import cv2 # import this here to avoid errors if opencv is not installed properly 579 if self.is_float(): 580 self.data = cv2.medianBlur( self.data.astype(np.float32), size ) 581 else: 582 self.data = cv2.medianBlur( self.data, size ) 583 584 ##################################### 585 ##FEATURES AND FEATURE MATCHING 586 ###################################### 587 def get_keypoints(self, band, eq=False, mask=True, method='sift', cfac=0.0,bfac=0.0, **kwds): 588 """ 589 Get feature descriptors from the specified band. 590 591 Args: 592 band (int,float,str,tuple): the band index (int) or wavelength (float) to extract features from. Alternatively, a tuple can be passed 593 containing a range of bands (min : max) to average before feature matching. 594 eq (bool): True if the image should be histogram equalized first. Default is False. 595 mask (bool): True if 0 value pixels should be masked. Default is True. 596 method (str): the feature detector to use. Options are 'SIFT' and 'ORB' (faster but less accurate). Default is 'SIFT'. 597 cfac (float): contrast adjustment to apply to hyperspectral bands before matching. Default is 0.0. 598 bfac (float): brightness adjustment to apply to hyperspectral bands before matching. Default is 0.0. 599 **kwds: keyword arguments are passed to the opencv feature detector. For SIFT these are: 600 601 - contrastThreshold: default is 0.01. 602 - edgeThreshold: default is 10. 603 - sigma: default is 1.0 604 605 For ORB these are: 606 607 - nfeatures = the number of features to detect. Default is 5000. 608 609 Returns: 610 Tuple containing 611 612 - k (ndarray): the keypoints detected 613 - d (ndarray): corresponding feature descriptors 614 """ 615 import cv2 # import this here to avoid errors if opencv is not installed properly 616 617 # get image 618 if isinstance(band, int) or isinstance(band, float): #single band 619 image = self.data[:, :, self.get_band_index(band)] 620 elif isinstance(band,tuple): #range of bands (averaged) 621 idx0 = self.get_band_index(band[0]) 622 idx1 = self.get_band_index(band[1]) 623 624 #deal with out of range errors 625 if idx0 is None: 626 idx0 = 0 627 if idx1 is None: 628 idx1 = self.band_count() 629 630 #average bands 631 image = np.nanmean(self.data[:,:,idx0:idx1],axis=2) 632 else: 633 assert False, "Error, unrecognised band %s" % band 634 635 #normalise image to range 0 - 1 636 image -= np.nanmin(image) 637 image = image / np.nanmax(image) 638 639 #apply brightness/contrast adjustment 640 image = (1.0+cfac)*image + bfac 641 image[image > 1.0] = 1.0 642 image[image < 0.0] = 0.0 643 644 #convert image to uint8 for opencv 645 image = np.uint8(255 * image) 646 if eq: 647 image = cv2.equalizeHist(image) 648 649 if mask: 650 mask = np.zeros(image.shape, dtype=np.uint8) 651 mask[image != 0] = 255 # include only non-zero pixels 652 else: 653 mask = None 654 655 if 'sift' in method.lower(): # SIFT 656 657 # setup default keywords 658 kwds["contrastThreshold"] = kwds.get("contrastThreshold", 0.01) 659 kwds["edgeThreshold"] = kwds.get("edgeThreshold", 10) 660 kwds["sigma"] = kwds.get("sigma", 1.0) 661 662 # make feature detector 663 #alg = cv2.xfeatures2d.SIFT_create(**kwds) 664 alg = cv2.SIFT_create() 665 elif 'orb' in method.lower(): # orb 666 kwds['nfeatures'] = kwds.get('nfeatures', 5000) 667 alg = cv2.ORB_create(scoreType=cv2.ORB_FAST_SCORE, **kwds) 668 else: 669 assert False, "Error - %s is not a recognised feature detector." % method 670 671 # detect keypoints 672 kp = alg.detect(image, mask) 673 674 # extract and return feature vectors 675 return alg.compute(image, kp) 676 677 @classmethod 678 def match_keypoints(cls, kp1, kp2, d1, d2, method='SIFT', dist=0.7, tree = 5, check = 100, min_count=5): 679 """ 680 Compares keypoint feature vectors from two images and returns matching pairs. 681 682 Args: 683 kp1 (ndarray): keypoints from the first image 684 kp2 (ndarray): keypoints from the second image 685 d1 (ndarray): descriptors for the keypoints from the first image 686 d2 (ndarray): descriptors for the keypoints from the second image 687 method (str): the method used to calculate the feature descriptors. Should be 'sift' or 'orb'. Default is 'sift'. 688 dist (float): minimum match distance (0 to 1), default is 0.7 689 tree (int): not sure what this does? Default is 5. See open-cv docs. 690 check (int): ditto. Default is 100. 691 min_count (int): the minimum number of matches to consider a valid matching operation. If fewer matches are found, 692 then the function returns None, None. Default is 5. 693 """ 694 import cv2 # import this here to avoid errors if opencv is not installed properly 695 if 'sift' in method.lower(): 696 algorithm = cv2.NORM_INF 697 elif 'orb' in method.lower(): 698 algorithm = cv2.NORM_HAMMING 699 else: 700 assert False, "Error - unknown matching algorithm %s" % method 701 702 #calculate flann matches 703 index_params = dict(algorithm=algorithm, trees=tree) 704 search_params = dict(checks=check) 705 flann = cv2.FlannBasedMatcher(index_params, search_params) 706 matches = flann.knnMatch(d1, d2, k=2) 707 708 # store all the good matches as per Lowe's ratio test. 709 good = [] 710 for m, n in matches: 711 if m.distance < dist * n.distance: 712 good.append(m) 713 714 if len(good) < min_count: 715 return None, None 716 else: 717 src_pts = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) 718 dst_pts = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) 719 return src_pts, dst_pts 720 721 ############################ 722 ## Visualisation methods 723 ############################ 724 def quick_plot(self, bands=0, ax=None, bfac=0.0, cfac=0.0, samples=False, tscale=False, invert=False, rot=False, flipX=False, flipY=False, 725 **kwds): 726 """ 727 Plot a band using matplotlib.imshow(...). 728 729 Args: 730 bands (str,int,float,tuple): the band name (string), index (integer) or wavelength (float) to plot. Default is 0. If a tuple is passed then 731 each band in the tuple (string or index) will be mapped to rgb. Bands with negative wavelengths or indices will be inverted before plotting. 732 ax: an axis object to plot to. If none, plt.imshow( ... ) is used. 733 bfac (float): a brightness adjustment to apply to RGB mappings (-1 to 1) 734 cfac (float): a contrast adjustment to apply to RGB mappings (-1 to 1) 735 samples (bool): True if sample points (defined in the header file) should be plotted. Default is False. Otherwise, a list of 736 [ (x,y), ... ] points can be passed. 737 tscale (bool): True if each band (for ternary images) should be scaled independently. Default is False. 738 When using scaling, vmin and vmax can be used to set the clipping percentiles (integers) or 739 (constant) values (float). 740 invert (bool) : True if each band should be inverted before plotting. Only works for multiband (ternary) images. 741 rot (bool): if True, the x and y axis will be flipped (90 degree rotation) before plotting. Default is False. 742 flipX (bool): if True, the x axis will be flipped before plotting (after applying rotations). 743 flipY (bool): if True, the y axis will be flippe before plotting (after applying rotations). 744 **kwds: keywords are passed to matplotlib.imshow( ... ), except for the following: 745 746 - mask = a 2D boolean mask containing true if pixels should be drawn and false otherwise. 747 - path = a file path to save the image too (at matching resolution; use fig.savefig(..) if you want to save the figure). 748 - ticks = True if x- and y- ticks should be plotted. Default is False. 749 - ps, pc = the size and color of sample points to plot. Can be constant or list. 750 - figsize = a figsize for the figure to create (if ax is None). 751 752 Returns: 753 Tuple containing 754 755 - fig: matplotlib figure object 756 - ax: matplotlib axes object. If a colorbar is created, (band is an integer or a float), then this will be stored in ax.cbar. 757 """ 758 759 plt = require("matplotlib.pyplot") 760 761 #create new axes? 762 if ax is None: 763 fig, ax = plt.subplots(figsize=kwds.pop('figsize', (18,18*self.ydim()/self.xdim()) )) 764 765 # deal with ticks 766 if not kwds.pop('ticks', False ): 767 ax.set_xticks([]) 768 ax.set_yticks([]) 769 770 #map individual band using colourmap 771 if isinstance(bands, str) or isinstance(bands, int) or isinstance(bands, float): 772 #get band 773 if isinstance(bands, str): 774 data = self.data[:, :, self.get_band_index(bands)] 775 else: 776 data = self.data[:, :, self.get_band_index(np.abs(bands))] 777 if not isinstance(bands, str) and bands < 0: 778 data = np.nanmax(data) - data # flip 779 780 # convert integer vmin and vmax values to percentiles 781 if 'vmin' in kwds: 782 if isinstance(kwds['vmin'], int): 783 kwds['vmin'] = np.nanpercentile( data, kwds['vmin'] ) 784 if 'vmax' in kwds: 785 if isinstance(kwds['vmax'], int): 786 kwds['vmax'] = np.nanpercentile( data, kwds['vmax'] ) 787 788 #mask nans (and apply custom mask) 789 mask = np.isnan(data) 790 if not np.isnan(self.header.get_data_ignore_value()): 791 mask = mask + data == self.header.get_data_ignore_value() 792 if 'mask' in kwds: 793 mask = mask + kwds.get('mask') 794 del kwds['mask'] 795 data = np.ma.array(data, mask = mask > 0 ) 796 797 # apply rotations and flipping 798 if rot: 799 data = data.T 800 if flipX: 801 data = data[::-1, :] 802 if flipY: 803 data = data[:, ::-1] 804 805 # save? 806 if 'path' in kwds: 807 path = kwds.pop('path') 808 imsave = require("matplotlib.pyplot").imsave 809 if not os.path.exists(os.path.dirname(path)): 810 os.makedirs(os.path.dirname(path)) # ensure output directory exists 811 imsave(path, data.T, **kwds) # save the image 812 813 ax.cbar = ax.imshow(data.T, interpolation=kwds.pop('interpolation', 'none'), **kwds) # change default interpolation to None 814 815 #map 3 bands to RGB 816 elif isinstance(bands, tuple) or isinstance(bands, list): 817 #get band indices and range 818 rgb = [] 819 for b in bands: 820 if isinstance(b, str): 821 rgb.append(self.get_band_index(b)) 822 else: 823 rgb.append(self.get_band_index(np.abs(b))) 824 825 #slice image (as copy) and map to 0 - 1 826 img = np.array(self.data[:, :, rgb]).copy() 827 if np.isnan(img).all(): 828 print("Warning - image contains no data.") 829 return ax.get_figure(), ax 830 831 # invert if needed 832 if invert: 833 bands = [-b for b in bands] 834 for i,b in enumerate(bands): 835 if not isinstance(b, str) and (b < 0): 836 img[..., i] = np.nanmax(img[..., i]) - img[..., i] 837 838 # do scaling 839 if tscale: # scale bands independently 840 for b in range(3): 841 mn = kwds.get("vmin", float(np.nanmin(img))) 842 mx = kwds.get("vmax", float(np.nanmax(img))) 843 if isinstance (mn, int): 844 assert mn >= 0 and mn <= 100, "Error - integer vmin values must be a percentile." 845 mn = float(np.nanpercentile(img[...,b], mn )) 846 if isinstance (mx, int): 847 assert mx >= 0 and mx <= 100, "Error - integer vmax values must be a percentile." 848 mx = float(np.nanpercentile(img[...,b], mx )) 849 img[...,b] = (img[..., b] - mn) / (mx - mn) 850 else: # scale bands together 851 mn = kwds.get("vmin", float(np.nanmin(img))) 852 mx = kwds.get("vmax", float(np.nanmax(img))) 853 if isinstance(mn, int): 854 assert mn >= 0 and mn <= 100, "Error - integer vmin values must be a percentile." 855 mn = float(np.nanpercentile(img, mn)) 856 if isinstance(mx, int): 857 assert mx >= 0 and mx <= 100, "Error - integer vmax values must be a percentile." 858 mx = float(np.nanpercentile(img, mx)) 859 img = (img - mn) / (mx - mn) 860 861 #apply brightness/contrast mapping 862 img = np.clip((1.0 + cfac) * img + bfac, 0, 1.0 ) 863 864 #apply masking so background is white 865 img[np.logical_not( np.isfinite( img ) )] = 1.0 866 if 'mask' in kwds: 867 img[kwds.pop("mask"),:] = 1.0 868 869 # apply rotations and flipping 870 if rot: 871 img = np.transpose( img, (1,0,2) ) 872 if flipX: 873 img = img[::-1, :, :] 874 if flipY: 875 img = img[:, ::-1, :] 876 877 # save? 878 if 'path' in kwds: 879 path = kwds.pop('path') 880 imsave = require("matplotlib.pyplot").imsave 881 if not os.path.exists(os.path.dirname(path)): 882 os.makedirs(os.path.dirname(path)) # ensure output directory exists 883 imsave(path, np.transpose( np.clip( img*255, 0, 255).astype(np.uint8), (1, 0, 2))) # save the image 884 885 # plot samples? 886 ps = kwds.pop('ps', 5) 887 pc = kwds.pop('pc', 'r') 888 if samples: 889 if isinstance(samples, list) or isinstance(samples, np.ndarray): 890 ax.scatter([s[0] for s in samples], [s[1] for s in samples], s=ps, c=pc) 891 else: 892 for n in self.header.get_class_names(): 893 points = np.array(self.header.get_sample_points(n)) 894 ax.scatter(points[:, 0], points[:, 1], s=ps, c=pc) 895 896 #plot 897 ax.imshow(np.transpose(img, (1,0,2)), interpolation=kwds.pop('interpolation', 'none'), **kwds) 898 ax.cbar = None # no colorbar 899 900 return ax.get_figure(), ax 901 902 ## masking 903 def mask(self, mask=None, flag=np.nan, invert=False, crop=False, bands=None): 904 """ 905 Apply a mask to an image, flagging masked pixels with the specified value. Note that this applies the mask to the 906 image in-situ. 907 908 Args: 909 flag (float): the value to use for masked pixels. Default is np.nan 910 mask (ndarray): a numpy array defining the mask polygon of the format [[x1,y1],[x2,y2],...]. If None is passed then 911 pickPolygon( ... ) is used to interactively define a polygon. If a file path is passed then the polygon 912 will be loaded using np.load( ... ). Alternatively if mask.shape == image.shape[0,1] then it is treated as a 913 binary image mask (must be boolean) and True values will be masked across all bands. Default is None. 914 invert (bool): if True, pixels within the polygon will be masked. If False, pixels outside the polygon are masked. Default is False. 915 crop (bool): True if rows/columns containing only zeros should be removed. Default is False. 916 bands (tuple): the bands of the image to plot if no mask is specified. If None, the middle band is used. 917 918 Returns: 919 Tuple containing 920 921 - mask (ndarray): a boolean array with True where pixels are masked and False elsewhere. 922 - poly (ndarray): the mask polygon array in the format described above. Useful if the polygon was interactively defined. 923 """ 924 925 if mask is None: # pick mask interactively 926 if bands is None: 927 bands = int(self.band_count() / 2) 928 929 regions = self.pickPolygons(region_names=["mask"], bands=bands) 930 931 # the user bailed without picking a mask? 932 if len(regions) == 0: 933 print("Warning - no mask picked/applied.") 934 return 935 936 # extract polygon mask 937 mask = regions[0] 938 939 # convert polygon mask to binary mask 940 if mask.shape[1] == 2: 941 942 # build meshgrid with pixel coords 943 xx, yy = np.meshgrid(np.arange(self.xdim()), np.arange(self.ydim())) 944 xx = xx.flatten() 945 yy = yy.flatten() 946 points = np.vstack([xx, yy]).T # coordinates of each pixel 947 948 # calculate per-pixel mask 949 MplPath = require("matplotlib.path").Path 950 mask = MplPath(mask).contains_points(points) 951 mask = mask.reshape((self.ydim(), self.xdim())).T 952 953 # flip as we want to mask (==True) outside points (unless invert is true) 954 if not invert: 955 mask = np.logical_not(mask) 956 957 # apply binary image mask 958 assert mask.shape[0] == self.data.shape[0] and mask.shape[1] == self.data.shape[1], \ 959 "Error - mask shape %s does not match image shape %s" % (mask.shape, self.data.shape) 960 for b in range(self.band_count()): 961 self.data[:, :, b][mask] = flag 962 963 # crop image 964 if crop: 965 self.crop_to_data() 966 967 return mask 968 969 def crop_to_data(self): 970 """ 971 Remove padding of nan or zero pixels from image. Note that this is performed in place. 972 """ 973 valid = np.isfinite(self.data).any(axis=-1) & (self.data != 0).any(axis=-1) 974 975 # integrate along axes 976 xdata = np.sum(valid, axis=1) > 0.0 977 ydata = np.sum(valid, axis=0) > 0.0 978 979 # calculate domain containing valid pixels 980 xmin = np.argmax(xdata) 981 xmax = xdata.shape[0] - np.argmax(xdata[::-1]) 982 ymin = np.argmax(ydata) 983 ymax = ydata.shape[0] - np.argmax(ydata[::-1]) 984 985 # crop 986 self.data = self.data[xmin:xmax, ymin:ymax, :] 987 988 # shift affine origin to new top-left pixel 989 if self.affine is not None: 990 a = self.affine # shorthand for affine 991 new_affine = list(self.affine) 992 new_affine[0] = a[0] + xmin*a[1] + ymin*a[2] 993 new_affine[3] = a[3] + xmin*a[4] + ymin*a[5] 994 self.affine = np.array(new_affine) 995 self.header['affine'] = self.affine 996 997 ################################################## 998 ## Interactive tools for picking regions/pixels 999 ################################################## 1000 def pickPolygons(self, region_names, bands=0): 1001 """ 1002 Creates a matplotlib gui for selecting polygon regions in an image. 1003 1004 Args: 1005 names (list, str): a list containing the names of the regions to pick. If a string is passed only one name is used. 1006 bands (tuple): the bands of the image to plot. 1007 """ 1008 1009 if isinstance(region_names, str): 1010 region_names = [region_names] 1011 1012 assert isinstance(region_names, list), "Error - names must be a list or a string." 1013 1014 matplotlib = require("matplotlib") 1015 plt = require("matplotlib.pyplot") 1016 MultiRoi = require("roipoly").MultiRoi 1017 1018 # set matplotlib backend 1019 backend = matplotlib.get_backend() 1020 matplotlib.use('Qt5Agg') # need this backend for ROIPoly to work 1021 1022 # plot image and extract roi's 1023 fig, ax = self.quick_plot(bands) 1024 roi = MultiRoi(roi_names=region_names) 1025 plt.close(fig) # close figure 1026 1027 # extract regions 1028 regions = [] 1029 for name, r in roi.rois.items(): 1030 # store region 1031 x = r.x 1032 y = r.y 1033 regions.append(np.vstack([x, y]).T) 1034 1035 # restore matplotlib backend (if possible) 1036 try: 1037 matplotlib.use(backend) 1038 except: 1039 print("Warning: could not reset matplotlib backend. Plots will remain interactive...") 1040 pass 1041 1042 return regions 1043 1044 def pickPoints(self, n=-1, bands=hylite.RGB, integer=True, title="Pick Points", **kwds): 1045 """ 1046 Creates a matplotlib gui for picking pixels from an image. 1047 1048 Args: 1049 n (int): the number of pixels to pick, or -1 if the user can select as many as they wish. Default is -1. 1050 bands (tuple): the bands of the image to plot. Default is `hylite.hyimage.HyImage`.RGB 1051 integer (bool): True if points coordinates should be cast to integers (for use as indices). Default is True. 1052 title (str): The title of the point picking window. 1053 **kwds: Keywords are passed to `hylite.hyimage.HyImage`.quick_plot( ... ). 1054 1055 Returns: 1056 A list containing the picked point coordinates [ (x1,y1), (x2,y2), ... ]. 1057 """ 1058 1059 matplotlib = require("matplotlib") 1060 plt = require("matplotlib.pyplot") 1061 1062 # set matplotlib backend 1063 backend = matplotlib.get_backend() 1064 matplotlib.use('Qt5Agg') # need this backend for ROIPoly to work 1065 1066 # create figure 1067 fig, ax = self.quick_plot( bands, **kwds ) 1068 ax.set_title(title) 1069 1070 # get points 1071 points = fig.ginput( n ) 1072 1073 if integer: 1074 points = [ (int(p[0]), int(p[1])) for p in points ] 1075 1076 # restore matplotlib backend (if possible) 1077 try: 1078 matplotlib.use(backend) 1079 except: 1080 print("Warning: could not reset matplotlib backend. Plots will remain interactive...") 1081 pass 1082 1083 return points 1084 1085 def pickSamples(self, names=None, store=True, **kwds): 1086 """ 1087 Pick sample probe points and store these in the image header file. 1088 1089 Args: 1090 names (str, list): the name of the sample to pick, or a list of names to pick multiple. 1091 store (bool): True if sample should be stored in the image header file (for later access). Default is True. 1092 **kwds: Keywords are passed to `hylite.hyimage.HyImage`.quick_plot( ... ) 1093 1094 Returns: 1095 a list containing a list of points for each sample. 1096 """ 1097 1098 if isinstance(names, str): 1099 names = [names] 1100 1101 # pick points 1102 points = [] 1103 for s in names: 1104 pnts = self.pickPoints(title="%s" % s, **kwds) 1105 if store: 1106 self.header['sample %s' % s] = pnts # store in header 1107 points.append(pnts) 1108 # add class to header file 1109 if store: 1110 cls_names = self.header.get_class_names() 1111 if cls_names is None: 1112 cls_names = [] 1113 self.header['class names'] = cls_names + names 1114 1115 return points
A class for hyperspectral image data. These can be individual scenes or hyperspectral orthoimages.
20 def __init__(self, data, **kwds): 21 """ 22 Args: 23 data (ndarray): a numpy array such that data[x][y][band] gives each pixel value. 24 **kwds: 25 wav = A numpy array containing band wavelengths for this image. 26 affine = an affine transform of the format returned by GDAL.GetGeoTransform(). 27 projection = string defining the project. Default is None. 28 sensor = sensor name. Default is "unknown". 29 header = path to associated header file. Default is None. 30 """ 31 32 #call constructor for HyData 33 super().__init__(data, **kwds) 34 35 # special case - if dataset only has oneband, slice it so it still has 36 # the format data[x,y,b]. 37 if not self.data is None: 38 if len(self.data.shape) == 1: 39 self.data = self.data[None, None, :] # single pixel image 40 if len(self.data.shape) == 2: 41 self.data = self.data[:, :, None] # single band iamge 42 43 #load any additional project information (specific to images) 44 self.set_projection(kwds.get("projection",None)) 45 self.affine = np.array( kwds.get("affine",[0,1,0,0,0,1]) ) 46 self.header['affine'] = kwds.get("affine",[0,1,0,0,0,1]) # also store this here 47 48 # wavelengths 49 if 'wav' in kwds: 50 self.set_wavelengths(kwds['wav']) 51 52 #special header formatting 53 self.header['file type'] = 'ENVI Standard'
Arguments:
- data (ndarray): a numpy array such that data[x][y][band] gives each pixel value.
- **kwds: wav = A numpy array containing band wavelengths for this image. affine = an affine transform of the format returned by GDAL.GetGeoTransform(). projection = string defining the project. Default is None. sensor = sensor name. Default is "unknown". header = path to associated header file. Default is None.
55 def copy(self,data=True): 56 """ 57 Make a deep copy of this image instance. 58 59 Args: 60 data (bool): True if a copy of the data should be made, otherwise only copy header. 61 62 Returns: 63 a new `hylite.hyimage.HyImage` instance. 64 """ 65 if not data: 66 return HyImage(None, header=self.header.copy(), projection=self.projection, affine=self.affine) 67 else: 68 return HyImage( self.data.copy(), header=self.header.copy(), projection=self.projection, affine=self.affine)
Make a deep copy of this image instance.
Arguments:
- data (bool): True if a copy of the data should be made, otherwise only copy header.
Returns:
a new
hylite.hyimage.HyImageinstance.
70 def T(self): 71 """ 72 Return a transposed view of the data matrix (corresponding with the [y,x] indexing used by matplotlib, opencv etc. 73 """ 74 return np.transpose(self.data, (1,0,2))
Return a transposed view of the data matrix (corresponding with the [y,x] indexing used by matplotlib, opencv etc.
76 def xdim(self): 77 """ 78 Return number of pixels in x (first dimension of data array) 79 """ 80 return self.data.shape[0]
Return number of pixels in x (first dimension of data array)
82 def ydim(self): 83 """ 84 Return number of pixels in y (second dimension of data array) 85 """ 86 return self.data.shape[1]
Return number of pixels in y (second dimension of data array)
88 def aspx(self): 89 """ 90 Return the aspect ratio of this image (width/height). 91 """ 92 return self.ydim() / self.xdim()
Return the aspect ratio of this image (width/height).
98 def get_extent(self): 99 """ 100 Returns the width and height of this image in world coordinates. 101 102 Returns: 103 tuple with (width, height). 104 """ 105 return self.xdim * self.pixel_size[0], self.ydim * self.pixel_size[1]
Returns the width and height of this image in world coordinates.
Returns:
tuple with (width, height).
107 def set_projection(self,proj): 108 """ 109 Set this project to an existing osgeo.osr.SpatialReference or GDAL georeference string. 110 111 Args: 112 proj (str, osgeo.osr.SpatialReference): the project to use as osgeo.osr.SpatialReference or GDAL georeference string. 113 """ 114 if proj is None: 115 self.projection = None 116 else: 117 try: 118 from osgeo.osr import SpatialReference 119 except: 120 assert False, "Error - GDAL must be installed to work with spatial projections in hylite." 121 if isinstance(proj, SpatialReference): 122 self.projection = proj 123 elif isinstance(proj, str): 124 self.projection = SpatialReference(proj) 125 else: 126 print("Invalid project %s" % proj) 127 raise
Set this project to an existing osgeo.osr.SpatialReference or GDAL georeference string.
Arguments:
- proj (str, osgeo.osr.SpatialReference): the project to use as osgeo.osr.SpatialReference or GDAL georeference string.
129 def set_projection_EPSG(self,EPSG): 130 """ 131 Sets this image project using an EPSG code. 132 133 Args: 134 EPSG (str): string EPSG code that can be passed to SpatialReference.SetFromUserInput(...). 135 """ 136 137 try: 138 from osgeo.osr import SpatialReference 139 except: 140 assert False, "Error - GDAL must be installed to work with spatial projections in hylite." 141 142 self.projection = SpatialReference() 143 self.projection.SetFromUserInput(EPSG)
Sets this image project using an EPSG code.
Arguments:
- EPSG (str): string EPSG code that can be passed to SpatialReference.SetFromUserInput(...).
145 def get_projection_EPSG(self): 146 """ 147 Gets a string describing this projections EPSG code (if it is an EPSG project). 148 149 Returns: 150 an EPSG code string of the format "EPSG:XXXX". 151 """ 152 if self.projection is None: 153 return None 154 else: 155 return "%s:%s" % (self.projection.GetAttrValue("AUTHORITY",0),self.projection.GetAttrValue("AUTHORITY",1))
Gets a string describing this projections EPSG code (if it is an EPSG project).
Returns:
an EPSG code string of the format "EPSG:XXXX".
157 def pix_to_world(self, px, py, proj=None): 158 """ 159 Take pixel coordinates and return world coordinates 160 161 Args: 162 px (int): the pixel x-coord. 163 py (int): the pixel y-coord. 164 proj (str, osr.SpatialReference): the coordinate system to use. Default (None) uses the same system as this image. Otherwise 165 an osr.SpatialReference can be passed (`hylite.hyimage.HyImage`.project), or an EPSG string (e.g. get_projection_EPSG(...)). 166 Returns: 167 the world coordinates in the coordinate system defined by get_projection_EPSG(...). 168 """ 169 170 try: 171 from osgeo import osr 172 import osgeo.gdal as gdal 173 from osgeo import ogr 174 except: 175 assert False, "Error - GDAL must be installed to work with spatial projections in hylite." 176 177 # parse project 178 if proj is None: 179 proj = self.projection 180 elif isinstance(proj, str) or isinstance(proj, int): 181 epsg = proj 182 if isinstance(epsg, str): 183 try: 184 epsg = int(str.split(':')[1]) 185 except: 186 assert False, "Error - %s is an invalid EPSG code." % proj 187 proj = osr.SpatialReference() 188 proj.ImportFromEPSG(epsg) 189 190 # check we have all the required info 191 assert isinstance(proj, osr.SpatialReference), "Error - invalid spatial reference %s" % proj 192 assert (not self.affine is None) and ( 193 not self.projection is None), "Error - project information is undefined." 194 195 #project to world coordinates in this images project/world coords 196 x,y = gdal.ApplyGeoTransform(self.affine, px, py) 197 198 #project to target coords (if different) 199 if not proj.IsSameGeogCS(self.projection): 200 P = ogr.Geometry(ogr.wkbPoint) 201 if proj.EPSGTreatsAsNorthingEasting(): 202 P.AddPoint(x, y) 203 else: 204 P.AddPoint(y, x) 205 P.AssignSpatialReference(self.projection) # tell the point what coordinates it's in 206 P.TransformTo(proj) # reproject it to the out spatial reference 207 x, y = P.GetX(), P.GetY() 208 209 #do we need to transpose? 210 if proj.EPSGTreatsAsLatLong(): 211 x,y=y,x #we want lon,lat not lat,lon 212 return x, y
Take pixel coordinates and return world coordinates
Arguments:
- px (int): the pixel x-coord.
- py (int): the pixel y-coord.
- proj (str, osr.SpatialReference): the coordinate system to use. Default (None) uses the same system as this image. Otherwise
an osr.SpatialReference can be passed (
hylite.hyimage.HyImagehylite.project), or an EPSG string (e.g. get_projection_EPSG(...)).
Returns:
the world coordinates in the coordinate system defined by get_projection_EPSG(...).
214 def world_to_pix(self, x, y, proj = None): 215 """ 216 Take world coordinates and return pixel coordinates 217 218 Args: 219 x (float): the world x-coord. 220 y (float): the world y-coord. 221 proj (str, osr.SpatialReference): the coordinate system of the input coordinates. Default (None) uses the same system as this image. Otherwise 222 an osr.SpatialReference can be passed (`hylite.hyimage.HyImage`.project), or an EPSG string (e.g. get_projection_EPSG(...)). 223 224 Returns: 225 the pixel coordinates based on the affine transform stored in self.affine. 226 """ 227 228 try: 229 from osgeo import osr 230 import osgeo.gdal as gdal 231 from osgeo import ogr 232 except: 233 assert False, "Error - GDAL must be installed to work with spatial projections in hylite." 234 235 # parse project 236 if proj is None: 237 proj = self.projection 238 elif isinstance(proj, str) or isinstance(proj, int): 239 epsg = proj 240 if isinstance(epsg, str): 241 try: 242 epsg = int(str.split(':')[1]) 243 except: 244 assert False, "Error - %s is an invalid EPSG code." % proj 245 proj = osr.SpatialReference() 246 proj.ImportFromEPSG(epsg) 247 248 249 # check we have all the required info 250 assert isinstance(proj, osr.SpatialReference), "Error - invalid spatial reference %s" % proj 251 assert (not self.affine is None) and (not self.projection is None), "Error - project information is undefined." 252 253 # project to this images CS (if different) 254 if not proj.IsSameGeogCS(self.projection): 255 P = ogr.Geometry(ogr.wkbPoint) 256 if proj.EPSGTreatsAsNorthingEasting(): 257 P.AddPoint(x, y) 258 else: 259 P.AddPoint(y, x) 260 P.AssignSpatialReference(proj) # tell the point what coordinates it's in 261 P.AddPoint(x, y) 262 P.TransformTo(self.projection) # reproject it to the out spatial reference 263 x, y = P.GetX(), P.GetY() 264 if self.projection.EPSGTreatsAsLatLong(): # do we need to transpose? 265 x, y = y, x # we want lon,lat not lat,lon 266 267 inv = gdal.InvGeoTransform(self.affine) 268 assert not inv is None, "Error - could not invert affine transform?" 269 270 #apply 271 return gdal.ApplyGeoTransform(inv, x, y)
Take world coordinates and return pixel coordinates
Arguments:
- x (float): the world x-coord.
- y (float): the world y-coord.
- proj (str, osr.SpatialReference): the coordinate system of the input coordinates. Default (None) uses the same system as this image. Otherwise
an osr.SpatialReference can be passed (
hylite.hyimage.HyImagehylite.project), or an EPSG string (e.g. get_projection_EPSG(...)).
Returns:
the pixel coordinates based on the affine transform stored in self.affine.
273 def crop(self, xmin, xmax, ymin, ymax, bands=None): 274 """ 275 Return a cropped copy of this image. 276 277 Args: 278 xmin, xmax (int): pixel bounds in x (rows) 279 ymin, ymax (int): pixel bounds in y (columns) 280 bands (None, list, tuple): optional band indices or (min,max) range 281 282 Returns: 283 `hylite.hyimage.HyImage`: cropped image with updated affine transform 284 """ 285 286 # ---- validate bounds ---- 287 xmin = int(max(0, xmin)) 288 ymin = int(max(0, ymin)) 289 xmax = int(min(self.xdim(), xmax)) 290 ymax = int(min(self.ydim(), ymax)) 291 292 assert xmin < xmax and ymin < ymax, "Invalid crop extent." 293 294 # ---- crop data ---- 295 if bands is None: 296 data = self.data[xmin:xmax, ymin:ymax, :].copy() 297 wav = self.get_wavelengths() 298 else: # band selection 299 if isinstance(bands, tuple): 300 b0 = self.get_band_index(bands[0]) 301 b1 = self.get_band_index(bands[1]) 302 data = self.data[xmin:xmax, ymin:ymax, b0:b1].copy() 303 wav = self.get_wavelengths()[b0:b1] 304 else: 305 idx = [self.get_band_index(b) for b in bands] 306 data = self.data[xmin:xmax, ymin:ymax, idx].copy() 307 wav = self.get_wavelengths()[idx] 308 309 # ---- update affine transform ---- 310 if self.affine is not None: 311 a = list(self.affine) 312 new_affine = a.copy() 313 314 # shift origin to new top-left pixel 315 new_affine[0] = a[0] + xmin*a[1] + ymin*a[2] 316 new_affine[3] = a[3] + xmin*a[4] + ymin*a[5] 317 else: 318 new_affine = None 319 320 # ---- construct output image ---- 321 out = HyImage( 322 data, 323 header=self.header.copy(), 324 projection=self.projection, 325 affine=new_affine, 326 wav=wav 327 ) 328 329 return out
Return a cropped copy of this image.
Arguments:
- xmin, xmax (int): pixel bounds in x (rows)
- ymin, ymax (int): pixel bounds in y (columns)
- bands (None, list, tuple): optional band indices or (min,max) range
Returns:
hylite.hyimage.HyImage: cropped image with updated affine transform
331 def resize(self, newdims: tuple, interpolation: int = 1): 332 """ 333 Resize this image with opencv and update affine transform accordingly. 334 335 Args: 336 newdims (tuple): the new image dimensions (xdim, ydim) 337 interpolation (int): opencv interpolation method. Default is cv2.INTER_LINEAR. 338 """ 339 import cv2 # avoid import issues if opencv is missing 340 341 old_x, old_y = self.xdim(), self.ydim() 342 new_x, new_y = int(newdims[0]), int(newdims[1]) 343 344 assert new_x > 0 and new_y > 0, "Invalid resize dimensions." 345 346 # resize data (opencv uses width, height = y, x) 347 self.data = cv2.resize( 348 self.data, 349 (new_y, new_x), 350 interpolation=interpolation 351 ) 352 353 # update affine transform 354 if self.affine is not None: 355 a = list(self.affine) 356 357 sx = old_x / new_x 358 sy = old_y / new_y 359 360 self.affine = [ 361 a[0], # x origin unchanged 362 a[1] * sx, # pixel width 363 a[2] * sy, # row rotation 364 a[3], # y origin unchanged 365 a[4] * sx, # column rotation 366 a[5] * sy # pixel height 367 ]
Resize this image with opencv and update affine transform accordingly.
Arguments:
- newdims (tuple): the new image dimensions (xdim, ydim)
- interpolation (int): opencv interpolation method. Default is cv2.INTER_LINEAR.
369 def tile(self, tile_size): 370 """ 371 Break image into tiles of given size and return a list of `hylite.hyimage.HyImage` tiles. 372 Each tile has an updated affine transform reflecting its position in the original image. 373 374 Args: 375 tile_size (tuple): (tile_x, tile_y) in pixels 376 Returns: 377 list of `hylite.hyimage.HyImage` 378 """ 379 tiles = [] 380 tx, ty = tile_size 381 nx, ny = self.xdim(), self.ydim() 382 for i in range(0, nx, tx): 383 for j in range(0, ny, ty): 384 data_tile = self.data[i:min(i+tx, nx), j:min(j+ty, ny), :].copy() # slice data 385 386 # compute new affine 387 # affine = [x0, dx, rotx, y0, roty, dy] 388 x0, dx, rotx, y0, roty, dy = self.affine 389 new_x0 = x0 + i*dx + j*rotx 390 new_y0 = y0 + i*roty + j*dy 391 new_affine = [new_x0, dx, rotx, new_y0, roty, dy] 392 393 # create tile 394 tile_img = HyImage( 395 data_tile, 396 affine=new_affine, 397 projection=self.projection, 398 wav=self.get_wavelengths(), 399 header=self.header.copy() 400 ) 401 tile_img.header['xleft'] = i 402 tile_img.header['ytop'] = j 403 tiles.append(tile_img) 404 405 return tiles
Break image into tiles of given size and return a list of hylite.hyimage.HyImage tiles.
Each tile has an updated affine transform reflecting its position in the original image.
Arguments:
- tile_size (tuple): (tile_x, tile_y) in pixels
Returns:
list of
hylite.hyimage.HyImage
407 @staticmethod 408 def mosaic( 409 tiles, 410 blend="mean", 411 resampling="nearest", 412 out_affine=None, 413 out_shape=None, 414 ): 415 """ 416 Mosaic georeferenced `hylite.hyimage.HyImage` tiles using GDAL. Note that this assumes all tiles are in the same coordinate system. 417 418 Args: 419 tiles (list[`hylite.hyimage.HyImage`]) 420 blend (str): 'first', 'min', 'max', 'mean', 'median' 421 resampling (str): 'nearest', 'bilinear', 'cubic' 422 out_affine (list): optional 6-element affine to define output grid. If None, the affine of the first tile is used. 423 out_shape (tuple): optional (xdim, ydim) shape of the output grid. If None, the extent of all tiles will be used. 424 Returns: 425 HyImage 426 """ 427 import numpy as np 428 from hylite.project.align import resample_raster 429 from osgeo import gdal, osr 430 431 assert len(tiles) > 0 432 assert blend in ("first", "min", "max", "mean", "median") 433 434 # compute bounds in world coordinates 435 # N.B. THIS ASSUMES ALL DATA ARE IN THE SAME CRS 436 points = [] 437 for t in tiles: 438 points.append( t.pix_to_world(0,0) ) 439 points.append( t.pix_to_world(t.xdim()+1,t.ydim()+1) ) 440 min_x, min_y = np.min(points, axis=0) 441 max_x, max_y = np.max(points, axis=0) 442 if out_shape is None: 443 out_shape = (np.array(tiles[0].world_to_pix(max_x, max_y)) + np.array(tiles[0].world_to_pix(min_x, min_y))).round().astype(int) 444 if out_affine is None: 445 out_affine = list(tiles[0].affine) 446 out_affine[0] = min_x 447 out_affine[3] = max_y 448 449 if blend == "first": # fill output, first come, first served. 450 out = np.full( tuple(out_shape) + (tiles[0].band_count(),), np.nan, dtype=np.float32) 451 for t in tiles: 452 r = resample_raster( t.data, t.affine, out_affine, out_shape ) 453 mask = np.isnan(out) 454 out[mask] = r[mask] 455 elif blend == "min": # keep minimum value in case of overlap 456 out = np.nanmin( np.stack([ resample_raster( t.data, t.affine, out_affine, out_shape ) for t in tiles ], 457 axis=0), axis=0 ) 458 elif blend == "max": # keep maximum value in case of overlap 459 out = np.nanmax( np.stack([ resample_raster( t.data, t.affine, out_affine, out_shape ) for t in tiles ], 460 axis=0), axis=0 ) 461 elif blend == "mean": # use average in case of overlap 462 out = np.nanmean( np.stack([ resample_raster( t.data, t.affine, out_affine, out_shape ) for t in tiles ], 463 axis=0), axis=0 ) 464 elif blend == "median": # use mean in case of overlap 465 out = np.nanmedian( np.stack([ resample_raster( t.data, t.affine, out_affine, out_shape ) for t in tiles ], 466 axis=0), axis=0 ) 467 468 # Return HyImage 469 out = HyImage( 470 out, 471 affine=out_affine, 472 projection=tiles[0].projection, 473 wav=tiles[0].get_wavelengths(), 474 header=tiles[0].header.copy() 475 ) 476 if 'xleft' in out.header: del out.header['xleft'] # stored in tiles, but not meaningful here 477 if 'ytop' in out.header: del out.header['ytop'] # stored in tiles, but not meaningful here 478 479 return out
Mosaic georeferenced hylite.hyimage.HyImage tiles using GDAL. Note that this assumes all tiles are in the same coordinate system.
Arguments:
- tiles (list[
hylite.hyimage.HyImage]) - blend (str): 'first', 'min', 'max', 'mean', 'median'
- resampling (str): 'nearest', 'bilinear', 'cubic'
- out_affine (list): optional 6-element affine to define output grid. If None, the affine of the first tile is used.
- out_shape (tuple): optional (xdim, ydim) shape of the output grid. If None, the extent of all tiles will be used.
Returns:
HyImage
485 def flip(self, axis='x'): 486 """ 487 Flip the image on the x or y axis. Note that this will remove any defined affine transform. 488 489 Args: 490 axis (str): 'x' or 'y' or both 'xy'. 491 """ 492 493 if 'x' in axis.lower(): 494 self.data = np.flip(self.data,axis=0) 495 if 'y' in axis.lower(): 496 self.data = np.flip(self.data,axis=1) 497 self.affine = None 498 if 'affine' in self.header: del self.header['affine'] 499 self.push_to_header() # update width and height info
Flip the image on the x or y axis. Note that this will remove any defined affine transform.
Arguments:
- axis (str): 'x' or 'y' or both 'xy'.
501 def rot90(self): 502 """ 503 Rotate this image by 90 degrees by transposing the underlying data array. Combine with flip('x') or flip('y') 504 to achieve positive/negative rotations. 505 """ 506 self.data = np.transpose( self.data, (1,0,2) ) 507 self.affine = None 508 if 'affine' in self.header: del self.header['affine'] 509 self.push_to_header() # update width and height info
Rotate this image by 90 degrees by transposing the underlying data array. Combine with flip('x') or flip('y') to achieve positive/negative rotations.
514 def fill_holes(self): 515 """ 516 Replaces nan pixel with an average of their neighbours, thus removing 1-pixel large holes from an image. Note that 517 for performance reasons this assumes that holes line up across bands. Note that this is not vectorized so very slow... 518 """ 519 520 # perform greyscale dilation 521 ndimage = require("scipy").ndimage 522 dilate = self.data.copy() 523 mask = np.logical_not(np.isfinite(dilate)) 524 dilate[mask] = 0 525 for b in range(self.band_count()): 526 dilate[:, :, b] = ndimage.grey_dilation(dilate[:, :, b], size=(3, 3)) 527 528 # map back to holes in dataset 529 self.data[mask] = dilate[mask] 530 #self.data[self.data == 0] = np.nan # replace remaining 0's with nans
Replaces nan pixel with an average of their neighbours, thus removing 1-pixel large holes from an image. Note that for performance reasons this assumes that holes line up across bands. Note that this is not vectorized so very slow...
532 def blur(self, n=3): 533 """ 534 Applies a gaussian kernel of size n to the image using OpenCV. 535 536 Args: 537 n (int): the dimensions of the gaussian kernel to convolve. Default is 3. Increase for more blurry results. 538 """ 539 import cv2 # import this here to avoid errors if opencv is not installed properly 540 541 nanmask = np.isnan(self.data) 542 assert isinstance(n, int) and n >= 3, "Error - invalid kernel. N must be an integer > 3. " 543 kernel = np.ones((n, n), np.float32) / (n ** 2) 544 self.data = cv2.filter2D(self.data, -1, kernel) 545 self.data[nanmask] = np.nan # remove mask
Applies a gaussian kernel of size n to the image using OpenCV.
Arguments:
- n (int): the dimensions of the gaussian kernel to convolve. Default is 3. Increase for more blurry results.
547 def erode(self, size=3, iterations=1): 548 """ 549 Apply an erode filter to this image to expand background (nan) pixels. Refer to open-cv's erode 550 function for more details. 551 552 Args: 553 size (int): the size of the erode filter. Default is a 3x3 kernel. 554 iterations (int): the number of erode iterations. Default is 1. 555 """ 556 import cv2 # import this here to avoid errors if opencv is not installed properly 557 558 # erode 559 kernel = np.ones((size, size), np.uint8) 560 if self.is_float(): 561 mask = np.isfinite(self.data).any(axis=-1) 562 mask = cv2.erode(mask.astype(np.uint8), kernel, iterations=iterations) 563 self.data[mask == 0, :] = np.nan 564 else: 565 mask = (self.data != 0).any( axis=-1 ) 566 mask = cv2.erode(mask.astype(np.uint8), kernel, iterations=iterations) 567 self.data[mask == 0, :] = 0
Apply an erode filter to this image to expand background (nan) pixels. Refer to open-cv's erode function for more details.
Arguments:
- size (int): the size of the erode filter. Default is a 3x3 kernel.
- iterations (int): the number of erode iterations. Default is 1.
569 def despeckle(self, size=5): 570 """ 571 Despeckle each band of this image (independently) using a median filter. 572 573 Args: 574 size (int): the size of the median filter kernel. Default is 5. Must be an odd number. 575 """ 576 577 assert (size % 2) == 1, "Error - size must be an odd integer" 578 import cv2 # import this here to avoid errors if opencv is not installed properly 579 if self.is_float(): 580 self.data = cv2.medianBlur( self.data.astype(np.float32), size ) 581 else: 582 self.data = cv2.medianBlur( self.data, size )
Despeckle each band of this image (independently) using a median filter.
Arguments:
- size (int): the size of the median filter kernel. Default is 5. Must be an odd number.
587 def get_keypoints(self, band, eq=False, mask=True, method='sift', cfac=0.0,bfac=0.0, **kwds): 588 """ 589 Get feature descriptors from the specified band. 590 591 Args: 592 band (int,float,str,tuple): the band index (int) or wavelength (float) to extract features from. Alternatively, a tuple can be passed 593 containing a range of bands (min : max) to average before feature matching. 594 eq (bool): True if the image should be histogram equalized first. Default is False. 595 mask (bool): True if 0 value pixels should be masked. Default is True. 596 method (str): the feature detector to use. Options are 'SIFT' and 'ORB' (faster but less accurate). Default is 'SIFT'. 597 cfac (float): contrast adjustment to apply to hyperspectral bands before matching. Default is 0.0. 598 bfac (float): brightness adjustment to apply to hyperspectral bands before matching. Default is 0.0. 599 **kwds: keyword arguments are passed to the opencv feature detector. For SIFT these are: 600 601 - contrastThreshold: default is 0.01. 602 - edgeThreshold: default is 10. 603 - sigma: default is 1.0 604 605 For ORB these are: 606 607 - nfeatures = the number of features to detect. Default is 5000. 608 609 Returns: 610 Tuple containing 611 612 - k (ndarray): the keypoints detected 613 - d (ndarray): corresponding feature descriptors 614 """ 615 import cv2 # import this here to avoid errors if opencv is not installed properly 616 617 # get image 618 if isinstance(band, int) or isinstance(band, float): #single band 619 image = self.data[:, :, self.get_band_index(band)] 620 elif isinstance(band,tuple): #range of bands (averaged) 621 idx0 = self.get_band_index(band[0]) 622 idx1 = self.get_band_index(band[1]) 623 624 #deal with out of range errors 625 if idx0 is None: 626 idx0 = 0 627 if idx1 is None: 628 idx1 = self.band_count() 629 630 #average bands 631 image = np.nanmean(self.data[:,:,idx0:idx1],axis=2) 632 else: 633 assert False, "Error, unrecognised band %s" % band 634 635 #normalise image to range 0 - 1 636 image -= np.nanmin(image) 637 image = image / np.nanmax(image) 638 639 #apply brightness/contrast adjustment 640 image = (1.0+cfac)*image + bfac 641 image[image > 1.0] = 1.0 642 image[image < 0.0] = 0.0 643 644 #convert image to uint8 for opencv 645 image = np.uint8(255 * image) 646 if eq: 647 image = cv2.equalizeHist(image) 648 649 if mask: 650 mask = np.zeros(image.shape, dtype=np.uint8) 651 mask[image != 0] = 255 # include only non-zero pixels 652 else: 653 mask = None 654 655 if 'sift' in method.lower(): # SIFT 656 657 # setup default keywords 658 kwds["contrastThreshold"] = kwds.get("contrastThreshold", 0.01) 659 kwds["edgeThreshold"] = kwds.get("edgeThreshold", 10) 660 kwds["sigma"] = kwds.get("sigma", 1.0) 661 662 # make feature detector 663 #alg = cv2.xfeatures2d.SIFT_create(**kwds) 664 alg = cv2.SIFT_create() 665 elif 'orb' in method.lower(): # orb 666 kwds['nfeatures'] = kwds.get('nfeatures', 5000) 667 alg = cv2.ORB_create(scoreType=cv2.ORB_FAST_SCORE, **kwds) 668 else: 669 assert False, "Error - %s is not a recognised feature detector." % method 670 671 # detect keypoints 672 kp = alg.detect(image, mask) 673 674 # extract and return feature vectors 675 return alg.compute(image, kp)
Get feature descriptors from the specified band.
Arguments:
- band (int,float,str,tuple): the band index (int) or wavelength (float) to extract features from. Alternatively, a tuple can be passed containing a range of bands (min : max) to average before feature matching.
- eq (bool): True if the image should be histogram equalized first. Default is False.
- mask (bool): True if 0 value pixels should be masked. Default is True.
- method (str): the feature detector to use. Options are 'SIFT' and 'ORB' (faster but less accurate). Default is 'SIFT'.
- cfac (float): contrast adjustment to apply to hyperspectral bands before matching. Default is 0.0.
- bfac (float): brightness adjustment to apply to hyperspectral bands before matching. Default is 0.0.
**kwds: keyword arguments are passed to the opencv feature detector. For SIFT these are:
- contrastThreshold: default is 0.01.
- edgeThreshold: default is 10.
- sigma: default is 1.0
For ORB these are:
- nfeatures = the number of features to detect. Default is 5000.
Returns: Tuple containing
- k (ndarray): the keypoints detected
- d (ndarray): corresponding feature descriptors
677 @classmethod 678 def match_keypoints(cls, kp1, kp2, d1, d2, method='SIFT', dist=0.7, tree = 5, check = 100, min_count=5): 679 """ 680 Compares keypoint feature vectors from two images and returns matching pairs. 681 682 Args: 683 kp1 (ndarray): keypoints from the first image 684 kp2 (ndarray): keypoints from the second image 685 d1 (ndarray): descriptors for the keypoints from the first image 686 d2 (ndarray): descriptors for the keypoints from the second image 687 method (str): the method used to calculate the feature descriptors. Should be 'sift' or 'orb'. Default is 'sift'. 688 dist (float): minimum match distance (0 to 1), default is 0.7 689 tree (int): not sure what this does? Default is 5. See open-cv docs. 690 check (int): ditto. Default is 100. 691 min_count (int): the minimum number of matches to consider a valid matching operation. If fewer matches are found, 692 then the function returns None, None. Default is 5. 693 """ 694 import cv2 # import this here to avoid errors if opencv is not installed properly 695 if 'sift' in method.lower(): 696 algorithm = cv2.NORM_INF 697 elif 'orb' in method.lower(): 698 algorithm = cv2.NORM_HAMMING 699 else: 700 assert False, "Error - unknown matching algorithm %s" % method 701 702 #calculate flann matches 703 index_params = dict(algorithm=algorithm, trees=tree) 704 search_params = dict(checks=check) 705 flann = cv2.FlannBasedMatcher(index_params, search_params) 706 matches = flann.knnMatch(d1, d2, k=2) 707 708 # store all the good matches as per Lowe's ratio test. 709 good = [] 710 for m, n in matches: 711 if m.distance < dist * n.distance: 712 good.append(m) 713 714 if len(good) < min_count: 715 return None, None 716 else: 717 src_pts = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) 718 dst_pts = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) 719 return src_pts, dst_pts
Compares keypoint feature vectors from two images and returns matching pairs.
Arguments:
- kp1 (ndarray): keypoints from the first image
- kp2 (ndarray): keypoints from the second image
- d1 (ndarray): descriptors for the keypoints from the first image
- d2 (ndarray): descriptors for the keypoints from the second image
- method (str): the method used to calculate the feature descriptors. Should be 'sift' or 'orb'. Default is 'sift'.
- dist (float): minimum match distance (0 to 1), default is 0.7
- tree (int): not sure what this does? Default is 5. See open-cv docs.
- check (int): ditto. Default is 100.
- min_count (int): the minimum number of matches to consider a valid matching operation. If fewer matches are found, then the function returns None, None. Default is 5.
724 def quick_plot(self, bands=0, ax=None, bfac=0.0, cfac=0.0, samples=False, tscale=False, invert=False, rot=False, flipX=False, flipY=False, 725 **kwds): 726 """ 727 Plot a band using matplotlib.imshow(...). 728 729 Args: 730 bands (str,int,float,tuple): the band name (string), index (integer) or wavelength (float) to plot. Default is 0. If a tuple is passed then 731 each band in the tuple (string or index) will be mapped to rgb. Bands with negative wavelengths or indices will be inverted before plotting. 732 ax: an axis object to plot to. If none, plt.imshow( ... ) is used. 733 bfac (float): a brightness adjustment to apply to RGB mappings (-1 to 1) 734 cfac (float): a contrast adjustment to apply to RGB mappings (-1 to 1) 735 samples (bool): True if sample points (defined in the header file) should be plotted. Default is False. Otherwise, a list of 736 [ (x,y), ... ] points can be passed. 737 tscale (bool): True if each band (for ternary images) should be scaled independently. Default is False. 738 When using scaling, vmin and vmax can be used to set the clipping percentiles (integers) or 739 (constant) values (float). 740 invert (bool) : True if each band should be inverted before plotting. Only works for multiband (ternary) images. 741 rot (bool): if True, the x and y axis will be flipped (90 degree rotation) before plotting. Default is False. 742 flipX (bool): if True, the x axis will be flipped before plotting (after applying rotations). 743 flipY (bool): if True, the y axis will be flippe before plotting (after applying rotations). 744 **kwds: keywords are passed to matplotlib.imshow( ... ), except for the following: 745 746 - mask = a 2D boolean mask containing true if pixels should be drawn and false otherwise. 747 - path = a file path to save the image too (at matching resolution; use fig.savefig(..) if you want to save the figure). 748 - ticks = True if x- and y- ticks should be plotted. Default is False. 749 - ps, pc = the size and color of sample points to plot. Can be constant or list. 750 - figsize = a figsize for the figure to create (if ax is None). 751 752 Returns: 753 Tuple containing 754 755 - fig: matplotlib figure object 756 - ax: matplotlib axes object. If a colorbar is created, (band is an integer or a float), then this will be stored in ax.cbar. 757 """ 758 759 plt = require("matplotlib.pyplot") 760 761 #create new axes? 762 if ax is None: 763 fig, ax = plt.subplots(figsize=kwds.pop('figsize', (18,18*self.ydim()/self.xdim()) )) 764 765 # deal with ticks 766 if not kwds.pop('ticks', False ): 767 ax.set_xticks([]) 768 ax.set_yticks([]) 769 770 #map individual band using colourmap 771 if isinstance(bands, str) or isinstance(bands, int) or isinstance(bands, float): 772 #get band 773 if isinstance(bands, str): 774 data = self.data[:, :, self.get_band_index(bands)] 775 else: 776 data = self.data[:, :, self.get_band_index(np.abs(bands))] 777 if not isinstance(bands, str) and bands < 0: 778 data = np.nanmax(data) - data # flip 779 780 # convert integer vmin and vmax values to percentiles 781 if 'vmin' in kwds: 782 if isinstance(kwds['vmin'], int): 783 kwds['vmin'] = np.nanpercentile( data, kwds['vmin'] ) 784 if 'vmax' in kwds: 785 if isinstance(kwds['vmax'], int): 786 kwds['vmax'] = np.nanpercentile( data, kwds['vmax'] ) 787 788 #mask nans (and apply custom mask) 789 mask = np.isnan(data) 790 if not np.isnan(self.header.get_data_ignore_value()): 791 mask = mask + data == self.header.get_data_ignore_value() 792 if 'mask' in kwds: 793 mask = mask + kwds.get('mask') 794 del kwds['mask'] 795 data = np.ma.array(data, mask = mask > 0 ) 796 797 # apply rotations and flipping 798 if rot: 799 data = data.T 800 if flipX: 801 data = data[::-1, :] 802 if flipY: 803 data = data[:, ::-1] 804 805 # save? 806 if 'path' in kwds: 807 path = kwds.pop('path') 808 imsave = require("matplotlib.pyplot").imsave 809 if not os.path.exists(os.path.dirname(path)): 810 os.makedirs(os.path.dirname(path)) # ensure output directory exists 811 imsave(path, data.T, **kwds) # save the image 812 813 ax.cbar = ax.imshow(data.T, interpolation=kwds.pop('interpolation', 'none'), **kwds) # change default interpolation to None 814 815 #map 3 bands to RGB 816 elif isinstance(bands, tuple) or isinstance(bands, list): 817 #get band indices and range 818 rgb = [] 819 for b in bands: 820 if isinstance(b, str): 821 rgb.append(self.get_band_index(b)) 822 else: 823 rgb.append(self.get_band_index(np.abs(b))) 824 825 #slice image (as copy) and map to 0 - 1 826 img = np.array(self.data[:, :, rgb]).copy() 827 if np.isnan(img).all(): 828 print("Warning - image contains no data.") 829 return ax.get_figure(), ax 830 831 # invert if needed 832 if invert: 833 bands = [-b for b in bands] 834 for i,b in enumerate(bands): 835 if not isinstance(b, str) and (b < 0): 836 img[..., i] = np.nanmax(img[..., i]) - img[..., i] 837 838 # do scaling 839 if tscale: # scale bands independently 840 for b in range(3): 841 mn = kwds.get("vmin", float(np.nanmin(img))) 842 mx = kwds.get("vmax", float(np.nanmax(img))) 843 if isinstance (mn, int): 844 assert mn >= 0 and mn <= 100, "Error - integer vmin values must be a percentile." 845 mn = float(np.nanpercentile(img[...,b], mn )) 846 if isinstance (mx, int): 847 assert mx >= 0 and mx <= 100, "Error - integer vmax values must be a percentile." 848 mx = float(np.nanpercentile(img[...,b], mx )) 849 img[...,b] = (img[..., b] - mn) / (mx - mn) 850 else: # scale bands together 851 mn = kwds.get("vmin", float(np.nanmin(img))) 852 mx = kwds.get("vmax", float(np.nanmax(img))) 853 if isinstance(mn, int): 854 assert mn >= 0 and mn <= 100, "Error - integer vmin values must be a percentile." 855 mn = float(np.nanpercentile(img, mn)) 856 if isinstance(mx, int): 857 assert mx >= 0 and mx <= 100, "Error - integer vmax values must be a percentile." 858 mx = float(np.nanpercentile(img, mx)) 859 img = (img - mn) / (mx - mn) 860 861 #apply brightness/contrast mapping 862 img = np.clip((1.0 + cfac) * img + bfac, 0, 1.0 ) 863 864 #apply masking so background is white 865 img[np.logical_not( np.isfinite( img ) )] = 1.0 866 if 'mask' in kwds: 867 img[kwds.pop("mask"),:] = 1.0 868 869 # apply rotations and flipping 870 if rot: 871 img = np.transpose( img, (1,0,2) ) 872 if flipX: 873 img = img[::-1, :, :] 874 if flipY: 875 img = img[:, ::-1, :] 876 877 # save? 878 if 'path' in kwds: 879 path = kwds.pop('path') 880 imsave = require("matplotlib.pyplot").imsave 881 if not os.path.exists(os.path.dirname(path)): 882 os.makedirs(os.path.dirname(path)) # ensure output directory exists 883 imsave(path, np.transpose( np.clip( img*255, 0, 255).astype(np.uint8), (1, 0, 2))) # save the image 884 885 # plot samples? 886 ps = kwds.pop('ps', 5) 887 pc = kwds.pop('pc', 'r') 888 if samples: 889 if isinstance(samples, list) or isinstance(samples, np.ndarray): 890 ax.scatter([s[0] for s in samples], [s[1] for s in samples], s=ps, c=pc) 891 else: 892 for n in self.header.get_class_names(): 893 points = np.array(self.header.get_sample_points(n)) 894 ax.scatter(points[:, 0], points[:, 1], s=ps, c=pc) 895 896 #plot 897 ax.imshow(np.transpose(img, (1,0,2)), interpolation=kwds.pop('interpolation', 'none'), **kwds) 898 ax.cbar = None # no colorbar 899 900 return ax.get_figure(), ax
Plot a band using matplotlib.imshow(...).
Arguments:
- bands (str,int,float,tuple): the band name (string), index (integer) or wavelength (float) to plot. Default is 0. If a tuple is passed then each band in the tuple (string or index) will be mapped to rgb. Bands with negative wavelengths or indices will be inverted before plotting.
- ax: an axis object to plot to. If none, plt.imshow( ... ) is used.
- bfac (float): a brightness adjustment to apply to RGB mappings (-1 to 1)
- cfac (float): a contrast adjustment to apply to RGB mappings (-1 to 1)
- samples (bool): True if sample points (defined in the header file) should be plotted. Default is False. Otherwise, a list of [ (x,y), ... ] points can be passed.
- tscale (bool): True if each band (for ternary images) should be scaled independently. Default is False. When using scaling, vmin and vmax can be used to set the clipping percentiles (integers) or (constant) values (float).
- invert (bool) : True if each band should be inverted before plotting. Only works for multiband (ternary) images.
- rot (bool): if True, the x and y axis will be flipped (90 degree rotation) before plotting. Default is False.
- flipX (bool): if True, the x axis will be flipped before plotting (after applying rotations).
- flipY (bool): if True, the y axis will be flippe before plotting (after applying rotations).
**kwds: keywords are passed to matplotlib.imshow( ... ), except for the following:
- mask = a 2D boolean mask containing true if pixels should be drawn and false otherwise.
- path = a file path to save the image too (at matching resolution; use fig.savefig(..) if you want to save the figure).
- ticks = True if x- and y- ticks should be plotted. Default is False.
- ps, pc = the size and color of sample points to plot. Can be constant or list.
- figsize = a figsize for the figure to create (if ax is None).
Returns:
Tuple containing
- fig: matplotlib figure object
- ax: matplotlib axes object. If a colorbar is created, (band is an integer or a float), then this will be stored in ax.cbar.
903 def mask(self, mask=None, flag=np.nan, invert=False, crop=False, bands=None): 904 """ 905 Apply a mask to an image, flagging masked pixels with the specified value. Note that this applies the mask to the 906 image in-situ. 907 908 Args: 909 flag (float): the value to use for masked pixels. Default is np.nan 910 mask (ndarray): a numpy array defining the mask polygon of the format [[x1,y1],[x2,y2],...]. If None is passed then 911 pickPolygon( ... ) is used to interactively define a polygon. If a file path is passed then the polygon 912 will be loaded using np.load( ... ). Alternatively if mask.shape == image.shape[0,1] then it is treated as a 913 binary image mask (must be boolean) and True values will be masked across all bands. Default is None. 914 invert (bool): if True, pixels within the polygon will be masked. If False, pixels outside the polygon are masked. Default is False. 915 crop (bool): True if rows/columns containing only zeros should be removed. Default is False. 916 bands (tuple): the bands of the image to plot if no mask is specified. If None, the middle band is used. 917 918 Returns: 919 Tuple containing 920 921 - mask (ndarray): a boolean array with True where pixels are masked and False elsewhere. 922 - poly (ndarray): the mask polygon array in the format described above. Useful if the polygon was interactively defined. 923 """ 924 925 if mask is None: # pick mask interactively 926 if bands is None: 927 bands = int(self.band_count() / 2) 928 929 regions = self.pickPolygons(region_names=["mask"], bands=bands) 930 931 # the user bailed without picking a mask? 932 if len(regions) == 0: 933 print("Warning - no mask picked/applied.") 934 return 935 936 # extract polygon mask 937 mask = regions[0] 938 939 # convert polygon mask to binary mask 940 if mask.shape[1] == 2: 941 942 # build meshgrid with pixel coords 943 xx, yy = np.meshgrid(np.arange(self.xdim()), np.arange(self.ydim())) 944 xx = xx.flatten() 945 yy = yy.flatten() 946 points = np.vstack([xx, yy]).T # coordinates of each pixel 947 948 # calculate per-pixel mask 949 MplPath = require("matplotlib.path").Path 950 mask = MplPath(mask).contains_points(points) 951 mask = mask.reshape((self.ydim(), self.xdim())).T 952 953 # flip as we want to mask (==True) outside points (unless invert is true) 954 if not invert: 955 mask = np.logical_not(mask) 956 957 # apply binary image mask 958 assert mask.shape[0] == self.data.shape[0] and mask.shape[1] == self.data.shape[1], \ 959 "Error - mask shape %s does not match image shape %s" % (mask.shape, self.data.shape) 960 for b in range(self.band_count()): 961 self.data[:, :, b][mask] = flag 962 963 # crop image 964 if crop: 965 self.crop_to_data() 966 967 return mask
Apply a mask to an image, flagging masked pixels with the specified value. Note that this applies the mask to the image in-situ.
Arguments:
- flag (float): the value to use for masked pixels. Default is np.nan
- mask (ndarray): a numpy array defining the mask polygon of the format [[x1,y1],[x2,y2],...]. If None is passed then pickPolygon( ... ) is used to interactively define a polygon. If a file path is passed then the polygon will be loaded using np.load( ... ). Alternatively if mask.shape == image.shape[0,1] then it is treated as a binary image mask (must be boolean) and True values will be masked across all bands. Default is None.
- invert (bool): if True, pixels within the polygon will be masked. If False, pixels outside the polygon are masked. Default is False.
- crop (bool): True if rows/columns containing only zeros should be removed. Default is False.
- bands (tuple): the bands of the image to plot if no mask is specified. If None, the middle band is used.
Returns:
Tuple containing
- mask (ndarray): a boolean array with True where pixels are masked and False elsewhere.
- poly (ndarray): the mask polygon array in the format described above. Useful if the polygon was interactively defined.
969 def crop_to_data(self): 970 """ 971 Remove padding of nan or zero pixels from image. Note that this is performed in place. 972 """ 973 valid = np.isfinite(self.data).any(axis=-1) & (self.data != 0).any(axis=-1) 974 975 # integrate along axes 976 xdata = np.sum(valid, axis=1) > 0.0 977 ydata = np.sum(valid, axis=0) > 0.0 978 979 # calculate domain containing valid pixels 980 xmin = np.argmax(xdata) 981 xmax = xdata.shape[0] - np.argmax(xdata[::-1]) 982 ymin = np.argmax(ydata) 983 ymax = ydata.shape[0] - np.argmax(ydata[::-1]) 984 985 # crop 986 self.data = self.data[xmin:xmax, ymin:ymax, :] 987 988 # shift affine origin to new top-left pixel 989 if self.affine is not None: 990 a = self.affine # shorthand for affine 991 new_affine = list(self.affine) 992 new_affine[0] = a[0] + xmin*a[1] + ymin*a[2] 993 new_affine[3] = a[3] + xmin*a[4] + ymin*a[5] 994 self.affine = np.array(new_affine) 995 self.header['affine'] = self.affine
Remove padding of nan or zero pixels from image. Note that this is performed in place.
1000 def pickPolygons(self, region_names, bands=0): 1001 """ 1002 Creates a matplotlib gui for selecting polygon regions in an image. 1003 1004 Args: 1005 names (list, str): a list containing the names of the regions to pick. If a string is passed only one name is used. 1006 bands (tuple): the bands of the image to plot. 1007 """ 1008 1009 if isinstance(region_names, str): 1010 region_names = [region_names] 1011 1012 assert isinstance(region_names, list), "Error - names must be a list or a string." 1013 1014 matplotlib = require("matplotlib") 1015 plt = require("matplotlib.pyplot") 1016 MultiRoi = require("roipoly").MultiRoi 1017 1018 # set matplotlib backend 1019 backend = matplotlib.get_backend() 1020 matplotlib.use('Qt5Agg') # need this backend for ROIPoly to work 1021 1022 # plot image and extract roi's 1023 fig, ax = self.quick_plot(bands) 1024 roi = MultiRoi(roi_names=region_names) 1025 plt.close(fig) # close figure 1026 1027 # extract regions 1028 regions = [] 1029 for name, r in roi.rois.items(): 1030 # store region 1031 x = r.x 1032 y = r.y 1033 regions.append(np.vstack([x, y]).T) 1034 1035 # restore matplotlib backend (if possible) 1036 try: 1037 matplotlib.use(backend) 1038 except: 1039 print("Warning: could not reset matplotlib backend. Plots will remain interactive...") 1040 pass 1041 1042 return regions
Creates a matplotlib gui for selecting polygon regions in an image.
Arguments:
- names (list, str): a list containing the names of the regions to pick. If a string is passed only one name is used.
- bands (tuple): the bands of the image to plot.
1044 def pickPoints(self, n=-1, bands=hylite.RGB, integer=True, title="Pick Points", **kwds): 1045 """ 1046 Creates a matplotlib gui for picking pixels from an image. 1047 1048 Args: 1049 n (int): the number of pixels to pick, or -1 if the user can select as many as they wish. Default is -1. 1050 bands (tuple): the bands of the image to plot. Default is `hylite.hyimage.HyImage`.RGB 1051 integer (bool): True if points coordinates should be cast to integers (for use as indices). Default is True. 1052 title (str): The title of the point picking window. 1053 **kwds: Keywords are passed to `hylite.hyimage.HyImage`.quick_plot( ... ). 1054 1055 Returns: 1056 A list containing the picked point coordinates [ (x1,y1), (x2,y2), ... ]. 1057 """ 1058 1059 matplotlib = require("matplotlib") 1060 plt = require("matplotlib.pyplot") 1061 1062 # set matplotlib backend 1063 backend = matplotlib.get_backend() 1064 matplotlib.use('Qt5Agg') # need this backend for ROIPoly to work 1065 1066 # create figure 1067 fig, ax = self.quick_plot( bands, **kwds ) 1068 ax.set_title(title) 1069 1070 # get points 1071 points = fig.ginput( n ) 1072 1073 if integer: 1074 points = [ (int(p[0]), int(p[1])) for p in points ] 1075 1076 # restore matplotlib backend (if possible) 1077 try: 1078 matplotlib.use(backend) 1079 except: 1080 print("Warning: could not reset matplotlib backend. Plots will remain interactive...") 1081 pass 1082 1083 return points
Creates a matplotlib gui for picking pixels from an image.
Arguments:
- n (int): the number of pixels to pick, or -1 if the user can select as many as they wish. Default is -1.
- bands (tuple): the bands of the image to plot. Default is
hylite.hyimage.HyImage.RGB - integer (bool): True if points coordinates should be cast to integers (for use as indices). Default is True.
- title (str): The title of the point picking window.
- **kwds: Keywords are passed to
hylite.hyimage.HyImage.quick_plot( ... ).
Returns:
A list containing the picked point coordinates [ (x1,y1), (x2,y2), ... ].
1085 def pickSamples(self, names=None, store=True, **kwds): 1086 """ 1087 Pick sample probe points and store these in the image header file. 1088 1089 Args: 1090 names (str, list): the name of the sample to pick, or a list of names to pick multiple. 1091 store (bool): True if sample should be stored in the image header file (for later access). Default is True. 1092 **kwds: Keywords are passed to `hylite.hyimage.HyImage`.quick_plot( ... ) 1093 1094 Returns: 1095 a list containing a list of points for each sample. 1096 """ 1097 1098 if isinstance(names, str): 1099 names = [names] 1100 1101 # pick points 1102 points = [] 1103 for s in names: 1104 pnts = self.pickPoints(title="%s" % s, **kwds) 1105 if store: 1106 self.header['sample %s' % s] = pnts # store in header 1107 points.append(pnts) 1108 # add class to header file 1109 if store: 1110 cls_names = self.header.get_class_names() 1111 if cls_names is None: 1112 cls_names = [] 1113 self.header['class names'] = cls_names + names 1114 1115 return points
Pick sample probe points and store these in the image header file.
Arguments:
- names (str, list): the name of the sample to pick, or a list of names to pick multiple.
- store (bool): True if sample should be stored in the image header file (for later access). Default is True.
- **kwds: Keywords are passed to
hylite.hyimage.HyImage.quick_plot( ... )
Returns:
a list containing a list of points for each sample.
Inherited Members
- hylite.hydata.HyData
- to_grey
- data
- set_header
- push_to_header
- has_wavelengths
- get_wavelengths
- has_band_names
- get_band_names
- has_fwhm
- get_fwhm
- set_wavelengths
- set_band_names
- set_fwhm
- is_image
- is_point
- is_classification
- band_count
- samples
- lines
- is_int
- is_float
- export_bands
- delete_nan_bands
- set_as_nan
- mask_bands
- get_band
- get_band_grey
- get_raveled
- X
- eval
- set_raveled
- get_band_index
- resample
- contiguous_chunks
- smooth_median
- smooth_savgol
- fill_gaps
- plot_spectra
- compress
- decompress
- getQuantized
- fromQuanta
- normalise
- percent_clip
- correct_spectral_shift