├── demo.png ├── README.md └── peak_prominence2d.py /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xunius/python_peak_promience2d/HEAD/demo.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python_peak_promience2d 2 | 3 | Compute peak prominence on 2d array using contour method using `numpy` and 4 | `matplotlib` 5 | 6 | The prominence of a local maximum (peak) is defined as 7 | 8 | > the height of the peak's summit above the lowest contour line encircling it 9 | > but containing no higher summit. 10 | 11 | See [wikipedia](https://en.wikipedia.org/wiki/Topographic_prominence) 12 | for more details. 13 | 14 | This module takes a surface in R3 defined by 2D arrays of X, Y and Z, 15 | and use enclosing contours to find local maxima and their prominences. 16 | 17 | Optionally, peaks with small prominence or area can be filtered out. 18 | 19 | See a toy example below: 20 | 21 | ![](https://github.com/Xunius/python_peak_promience2d/blob/master/demo.png) 22 | 23 | -------------------------------------------------------------------------------- /peak_prominence2d.py: -------------------------------------------------------------------------------- 1 | '''Compute peak prominence on 2d array using contour method. 2 | 3 | Compute topographic prominence on a 2d surface. See 4 | https://en.wikipedia.org/wiki/Topographic_prominence 5 | for more details. 6 | 7 | This module takes a surface in R3 defined by 2D X, Y and Z arrays, 8 | and use enclosing contours to define local maxima. The prominence of a local 9 | maximum (peak) is defined as the height of the peak's summit above the 10 | lowest contour line encircling it but containing no higher summit. 11 | 12 | Optionally, peaks with small prominence or area can be filtered out. 13 | 14 | 15 | Author: guangzhi XU (xugzhi1987@gmail.com; guangzhi.xu@outlook.com) 16 | Update time: 2018-11-10 16:03:49. 17 | ''' 18 | 19 | 20 | 21 | 22 | 23 | #--------Import modules------------------------- 24 | import numpy as np 25 | from matplotlib.transforms import Bbox 26 | from matplotlib.path import Path 27 | import matplotlib.pyplot as plt 28 | from scipy.interpolate import interp1d 29 | 30 | 31 | 32 | 33 | def isClosed(xs,ys): 34 | if np.alltrue([np.allclose(xs[0],xs[-1]),\ 35 | np.allclose(ys[0],ys[-1]),xs.ptp(),ys.ptp()]): 36 | return True 37 | else: 38 | return False 39 | 40 | def isContClosed(contour): 41 | x=contour.vertices[:, 0] 42 | y=contour.vertices[:, 1] 43 | return isClosed(x,y) 44 | 45 | def polygonArea(x,y): 46 | if not isClosed(x,y): 47 | # here is a minor issue: isclosed() on lat/lon can be closed, 48 | # but after projection, unclosed. Happens to spurious small 49 | # contours usually a triangle. just return 0. 50 | return 0 51 | area=np.sum(y[:-1]*np.diff(x)-x[:-1]*np.diff(y)) 52 | return np.abs(0.5*area) 53 | 54 | def contourArea(contour): 55 | '''Compute area of contour 56 | : matplotlib Path obj, contour. 57 | 58 | Return : float, area enclosed by . 59 | NOTE that is not necessarily closed by isClosed() method, 60 | it won't be when a closed contour has holes in it (like a doughnut). In such 61 | cases, areas of holes are subtracted. 62 | ''' 63 | 64 | segs=contour.to_polygons() 65 | if len(segs)>1: 66 | areas=[] 67 | for pp in segs: 68 | xii=pp[:,0] 69 | yii=pp[:,1] 70 | areaii=polygonArea(xii,yii) 71 | areas.append(areaii) 72 | areas.sort() 73 | result=areas[-1]-np.sum(areas[:-1]) 74 | else: 75 | x=contour.vertices[:, 0] 76 | y=contour.vertices[:, 1] 77 | result=polygonArea(x,y) 78 | 79 | return result 80 | 81 | def polygonGeoArea(lons,lats,method='basemap',projection='cea',bmap=None, 82 | verbose=True): 83 | 84 | #------Use basemap to project coordinates------ 85 | if method=='basemap': 86 | if bmap is None: 87 | from mpl_toolkits.basemap import Basemap 88 | 89 | lat1=np.min(lats) 90 | lat2=np.max(lats) 91 | lat0=np.mean(lats) 92 | lon1=np.min(lons) 93 | lon2=np.max(lons) 94 | lon0=np.mean(lons) 95 | 96 | if projection=='cea': 97 | bmap=Basemap(projection=projection,\ 98 | llcrnrlat=lat1,llcrnrlon=lon1,\ 99 | urcrnrlat=lat2,urcrnrlon=lon2) 100 | elif projection=='aea': 101 | bmap=Basemap(projection=projection,\ 102 | lat_1=lat1,lat_2=lat2,lat_0=lat0,lon_0=lon0, 103 | llcrnrlat=lat1,llcrnrlon=lon1,\ 104 | urcrnrlat=lat2,urcrnrlon=lon2) 105 | 106 | xs,ys=bmap(lons,lats) 107 | 108 | #------Use pyproj to project coordinates------ 109 | elif method=='proj': 110 | from pyproj import Proj 111 | 112 | lat1=np.min(lats) 113 | lat2=np.max(lats) 114 | lat0=np.mean(lats) 115 | lon0=np.mean(lons) 116 | 117 | pa=Proj('+proj=aea +lat_1=%f +lat_2=%f +lat_0=%f +lon_0=%f'\ 118 | %(lat1,lat2,lat0,lon0)) 119 | xs,ys=pa(lons,lats) 120 | 121 | result=polygonArea(xs,ys) 122 | 123 | return result 124 | 125 | def contourGeoArea(contour,bmap=None): 126 | '''Compute area enclosed by latitude/longitude contour. 127 | Result in m^2 128 | ''' 129 | 130 | segs=contour.to_polygons() 131 | if len(segs)>1: 132 | areas=[] 133 | for pp in segs: 134 | xii=pp[:,0] 135 | yii=pp[:,1] 136 | areaii=polygonGeoArea(xii,yii,bmap=bmap) 137 | areas.append(areaii) 138 | areas.sort() 139 | result=areas[-1]-np.sum(areas[:-1]) 140 | else: 141 | x=contour.vertices[:, 0] 142 | y=contour.vertices[:, 1] 143 | result=polygonGeoArea(x,y,bmap=bmap) 144 | 145 | return result 146 | 147 | 148 | 149 | def getProminence(var,step,lats=None,lons=None,min_depth=None, 150 | include_edge=True, 151 | min_area=None,max_area=None,area_func=contourArea, 152 | centroid_num_to_center=5, 153 | allow_hole=True,max_hole_area=None, 154 | verbose=True): 155 | '''Find 2d prominences of peaks. 156 | 157 | : 2D ndarray, data to find local maxima. Missings (nans) are masked. 158 | : float, contour interval. Finder interval gives better accuarcy. 159 | , : 1d array, y and x coordinates of . If not given, 160 | use int indices. 161 | : float, filter out peaks with prominence smaller than this. 162 | : bool, whether to include unclosed contours that touch 163 | the edges of the data, useful to include incomplete 164 | contours. 165 | : float, minimal area of the contour of a peak's col. Peaks with 166 | its col contour area smaller than are discarded. 167 | If None, don't filter by contour area. If latitude and 168 | longitude axes available, compute geographical area in km^2. 169 | : float, maximal area of a contour. Contours larger than 170 | are discarded. If latitude and 171 | longitude axes available, compute geographical area in km^2. 172 | : function obj, a function that accepts x, y coordinates of a 173 | closed contour and computes the inclosing area. Default 174 | to contourArea(). 175 | : int, number of the smallest contours in a peak 176 | used to compute peak center. 177 | : bool, whether to discard tidy holes in contour that could arise 178 | from noise. 179 | : float, if is True, tidy holes with area 180 | smaller than this are discarded. 181 | 182 | Return : dict, keys: ids of found peaks. 183 | values: dict, storing info of a peak: 184 | 'id' : int, id of peak, 185 | 'height' : max of height level, 186 | 'col_level' : height level at col, 187 | 'prominence': prominence of peak, 188 | 'area' : float, area of col contour. If latitude and 189 | longitude axes available, geographical area in 190 | km^2. Otherwise, area in unit^2, unit is the same 191 | as x, y axes, 192 | 'contours' : list, contours of peak from heights level to col, 193 | each being a matplotlib Path obj 194 | 'parent' : int, id of a peak's parent. Heightest peak as a 195 | parent id of 0. 196 | 197 | Author: guangzhi XU (xugzhi1987@gmail.com; guangzhi.xu@outlook.com) 198 | Update time: 2018-11-11 18:42:04. 199 | ''' 200 | 201 | fig,ax=plt.subplots() 202 | 203 | def checkIn(cont1,cont2,lon1,lon2,lat1,lat2): 204 | fails=[] 205 | vs2=cont2.vertices 206 | for ii in range(len(vs2)): 207 | if not cont1.contains_point(vs2[ii]) and\ 208 | not np.isclose(vs2[ii][0],lon1) and\ 209 | not np.isclose(vs2[ii][0],lon2) and\ 210 | not np.isclose(vs2[ii][1],lat1) and\ 211 | not np.isclose(vs2[ii][1],lat2): 212 | fails.append(vs2[ii]) 213 | if len(fails)>0: 214 | break 215 | return fails 216 | 217 | var=np.ma.masked_where(np.isnan(var),var).astype('float') 218 | needslerpx=True 219 | needslerpy=True 220 | if lats is None: 221 | lats=np.arange(var.shape[0]) 222 | needslerpy=False 223 | if lons is None: 224 | lons=np.arange(var.shape[1]) 225 | needslerpx=False 226 | 227 | if area_func==contourGeoArea: 228 | from mpl_toolkits.basemap import Basemap 229 | lat1=np.min(lats) 230 | lat2=np.max(lats) 231 | lon1=np.min(lons) 232 | lon2=np.max(lons) 233 | 234 | bmap=Basemap(projection='cea',\ 235 | llcrnrlat=lat1,llcrnrlon=lon1,\ 236 | urcrnrlat=lat2,urcrnrlon=lon2) 237 | 238 | vmax=np.nanmax(var) 239 | vmin=np.nanmin(var) 240 | step=abs(step) 241 | levels=np.arange(vmin,vmax+step,step).astype('float') 242 | 243 | npeak=0 244 | peaks={} 245 | prominence={} 246 | parents={} 247 | 248 | #----------------Get bounding box---------------- 249 | #bbox=Bbox.from_bounds(lons[0],lats[0],np.ptp(lons),np.ptp(height)) 250 | bbox=Path([[lons[0],lats[0]], [lons[0],lats[-1]], 251 | [lons[-1],lats[-1]], [lons[-1],lats[0]], [lons[0], lats[0]]]) 252 | 253 | #If not allow unclosed contours, get all contours in one go 254 | if not include_edge: 255 | conts=ax.contour(lons,lats,var,levels) 256 | contours=conts.collections[::-1] 257 | got_levels=conts.cvalues 258 | if not np.all(got_levels==levels): 259 | levels=got_levels 260 | ax.cla() 261 | 262 | large_conts=[] 263 | 264 | #---------------Loop through levels--------------- 265 | for ii,levii in enumerate(levels[::-1]): 266 | if verbose: 267 | print('# : Finding contour %f' %levii) 268 | 269 | #-Get a 2-level contour if allow unclosed contours- 270 | if include_edge: 271 | csii=ax.contourf(lons,lats,var,[levii,vmax+step]) 272 | csii=csii.collections[0] 273 | ax.cla() 274 | else: 275 | csii=contours[ii] 276 | 277 | #--------------Loop through contours at level-------------- 278 | for jj, contjj in enumerate(csii.get_paths()): 279 | 280 | contjj.level=levii 281 | #contjj.is_edge=contjj.intersects_bbox(bbox,False) # False significant 282 | # this might be another matplotlib bug, intersects_bbox() used 283 | # to work 284 | contjj.is_edge=contjj.intersects_path(bbox,False) # False significant 285 | 286 | # NOTE: contjj.is_edge==True is NOT equivalent to 287 | # isContClosed(contjj)==False, unclosed contours inside boundaries 288 | # can happen when missings are present 289 | 290 | if not include_edge and contjj.is_edge: 291 | continue 292 | 293 | if not include_edge and not isContClosed(contjj): 294 | # Sometimes contours are not closed 295 | # even if not touching edge, this happens when missings 296 | # are present. In such cases, need to close it before 297 | # computing area. But even so, unclosed contours won't 298 | # contain any other, so might well just skip it. 299 | # the contourf() approach seems to be more robust in such 300 | # cases. 301 | continue 302 | 303 | #--------------------Check area-------------------- 304 | # if contour contains a big contour, skip area computation 305 | area_big=False 306 | for cii in large_conts: 307 | if contjj.contains_path(cii): 308 | area_big=True 309 | break 310 | 311 | if area_big: 312 | continue 313 | 314 | if area_func==contourGeoArea: 315 | contjj.area=area_func(contjj,bmap=bmap)/1e6 316 | else: 317 | contjj.area=area_func(contjj) 318 | 319 | if max_area is not None and contjj.area>max_area: 320 | large_conts.append(contjj) 321 | continue 322 | 323 | #----------------Remove small holes---------------- 324 | segs=contjj.to_polygons() 325 | if len(segs)>1: 326 | contjj.has_holes=True 327 | if not allow_hole: 328 | continue 329 | else: 330 | if max_hole_area is not None: 331 | areas=[] 332 | if area_func==contourGeoArea: 333 | areas=[polygonGeoArea(segkk[:,0],segkk[:,1],\ 334 | bmap=bmap)/1e6 for segkk in segs] 335 | else: 336 | areas=[polygonArea(segkk[:,0],segkk[:,1])\ 337 | for segkk in segs] 338 | areas.sort() 339 | if areas[-2]>=max_hole_area: 340 | continue 341 | 342 | else: 343 | contjj.has_holes=False 344 | 345 | if len(peaks)==0: 346 | npeak+=1 347 | peaks[npeak]=[contjj,] 348 | prominence[npeak]=levii 349 | parents[npeak]=0 350 | else: 351 | #-Check if new contour contains any previous ones- 352 | match_list=[] 353 | for kk,vv in peaks.items(): 354 | if contjj.contains_path(vv[-1]): 355 | match_list.append(kk) 356 | else: 357 | # this is likely a bug in matplotlib. The contains_path() 358 | # function is not entirely reliable when contours are 359 | # touching the edge and step is small. Sometimes 360 | # enclosing contours will fail the test. In such cases 361 | # check all the points in cont2 with cont1.contains_point() 362 | # if no more than 2 or 3 points failed, it is a pass. 363 | # see https://stackoverflow.com/questions/47967359/matplotlib-contains-path-gives-unstable-results for more details. 364 | # UPDATE: I've changed the method when 2 365 | # contours compared are touching the edge: it seems that 366 | # sometimes all points at the edge will fail so the 367 | # failed number can go above 10 or even more. The new 368 | # method compares the number of points that fail the contains_point() 369 | # check with points at the edge. If all failing points are 370 | # at the edge,report a contain relation 371 | fail=checkIn(contjj,vv[-1],lons[0],lons[-1],lats[0], 372 | lats[-1]) 373 | if len(fail)==0: 374 | match_list.append(kk) 375 | 376 | #---------Create new center if non-overlap--------- 377 | if len(match_list)==0: 378 | npeak+=1 379 | peaks[npeak]=[contjj,] 380 | prominence[npeak]=levii 381 | parents[npeak]=0 382 | 383 | elif len(match_list)==1: 384 | peaks[match_list[0]].append(contjj) 385 | 386 | else: 387 | #------------------Filter by area------------------ 388 | if min_area is not None and len(match_list)>1: 389 | match_list2=[] 390 | for mm in match_list: 391 | areamm=peaks[mm][-1].area 392 | if areamm1: 406 | match_heights=[peaks[mm][0].level for mm in match_list] 407 | max_idx=match_list[np.argmax(match_heights)] 408 | for mm in match_list: 409 | if prominence[mm]==peaks[mm][0].level and mm!=max_idx: 410 | prominence[mm]=peaks[mm][0].level-levii 411 | parents[mm]=max_idx 412 | peaks[max_idx].append(contjj) 413 | 414 | #---------------Filter by prominence--------------- 415 | if min_depth is not None and len(match_list)>1: 416 | match_list2=[] 417 | for mm in match_list: 418 | if prominence[mm]