-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRaster2PercentageLakePoly.py
More file actions
340 lines (293 loc) · 17.5 KB
/
Copy pathRaster2PercentageLakePoly.py
File metadata and controls
340 lines (293 loc) · 17.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
'''
Script to calculate emergent macrophyte percentage from uavsar lake/wetland map. Polygonizes landcover map to lake polygons, with attributes for EM%, whether or not it is a border lake, and whether it was observed by AirSWOT CIR camera. Run instead of analysis/Raster2PercentagePoly.py. Make sure to use River mask in place of 'bridges' file.
Inputs:
_ UAVSAR classification as shapefiles, with class codes from python_env.py
_ ROI file of polygon areas to include in analysis. Used to extract ROI from larger flight swath
_ River mask: a shapefile of rivers to exclude
_ Optional: seed lakes from AirSWOT DCS/CIR dataset
Outputs:
_ An ESRI shapefile with rows for each lake and attributes of Area, Perimeter, EM percentage, FW%, GW% SW%, date/filename
Note:
- search for the #sub-roi tag to toggle between full roi and sub-rois (changes input and output paths)
TODO:
* simplify polygons -or smooth
* - and use parallel for rasterio polygonize/rasterize!!
* Manually update bridges .
* Speed up (smaller) bridge buffer?
* additional noted in comments
* Use dask ndimage label instead of imlabel https://dask-image.readthedocs.io/en/latest/dask_image.ndmeasure.html#dask_image.ndmeasure.label
* Sensitivity analysis for: min water body size, DCS seed vs no, river mask vs no, edge lakes vs no, 5-class (with maj filter) vs 13 class versions, bridge buffer size
'''
# In[1]:
import os
import glob
import pprint
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import geopandas as gpd
from skimage.measure import label, regionprops, regionprops_table
from skimage import measure
from skimage.morphology import binary_dilation, selem, remove_small_holes
import rasterio as rio
from rasterio import plot, features
from rasterio.features import shapes
import shapely
from shapely.geometry import shape
import dask.array as da
## functions
def polygonize(source, mask=None, connectivity=4, src=None): # transform=IDENTITY
'''
Inputs:
source = label matrix to be polygonized
mask = optional mask to consider
connectivity = 4 or 8
src = from: 'with rio.open(landcover_in_path) as src'
Based on rasterio.features.shapes, but simplifies the strange loop notation needed.
Only real input it needs is
Referenced https://gis.stackexchange.com/questions/187877/how-to-polygonize-raster-to-shapely-polygons
TODO: make failsafe by setting conditional clause for if src.transform DNE (previously transfrom was a keyword argument with default 'identify')
'''
## Change data type to least-expensive type that is valid for rasterio.features.shapes
if not((source.max() > 255) or (source.min() < 0)):
source = source.astype('uint8') # can't be boolean for shape index
elif not((source.max() > 65535) or (source.min() < 0)):
source = source.astype('uint16')
elif not((source.max() > 32767) or (source.min() < -32767)):
source = source.astype('int16')
else:
source = source.astype('float32')
print('Converting source to type float because it has extreme values (may consume large memory)')
## Polygonize
results = (
{'properties': {'raster_val': v}, 'geometry': s}
for i, (s, v)
in enumerate(
shapes(source, mask=mask, transform = src.transform, connectivity = connectivity))) # transform=src.affine, mask=mask # Here only consider water within ROI
# print('Example of geometry feature created:')
# pprint.pprint(next(results))
## The result is a generator of GeoJSON features
# That you can transform into shapely geometries
# Create geopandas Dataframe and enable easy to use functionalities of spatial join, plotting, save as geojson, ESRI shapefile etc.
geoms = list(results)
## convert to polygon
poly = gpd.GeoDataFrame.from_features(geoms,crs=src.crs.wkt)
poly['raster_val']=poly['raster_val'].astype(int) # for some reason, I had to create poly using double precesion. Converting to int64 for simplicity.
return poly
# Import env variables
from python_env import *
## dynamic I/O
############################################################
## Option for loading all files in dir #####################
# files_in=glob.glob(base_dir+'/*cls.tif')
############################################################
## Option for only loading specific files ##################
with open(unique_date_files, 'r') as f:
files_in=f.read().strip().split('\n')
############################################################
## Testing: Option forloading specific file directly #######
# files_in=[
# '/mnt/d/GoogleDrive/ABoVE top level folder/Kyzivat_ORNL_DAAC_2021/lake-wetland-maps/5-classes/PAD_170613_mosaic_rcls.tif',
# '/mnt/d/GoogleDrive/ABoVE top level folder/Kyzivat_ORNL_DAAC_2021/lake-wetland-maps/5-classes/PAD_170908_mosaic_rcls.tif',
# '/mnt/d/GoogleDrive/ABoVE top level folder/Kyzivat_ORNL_DAAC_2021/lake-wetland-maps/5-classes/PAD_180821_mosaic_rcls.tif'
# ] # TODO: comment out for real
# files_in=['/mnt/d/GoogleDrive/ABoVE top level folder/Kyzivat_ORNL_DAAC_2021/lake-wetland-maps/5-classes/bakerc_16008_19059_012_190904_L090_CX_01_Freeman-inc_rcls.tif']
## Option for only loading specific files ##################
############################################################
for i in range(len(files_in)):
## Option for quickly building only PAD mosaics ############
# if ('PAD' in files_in[i]) & ('mosaic' in files_in[i]):
# pass
# else:
# continue
############################################################
## load using rasterio
landcover_in_path=files_in[i] # '/mnt/f/PAD2019/classification_training/PixelClassifier/Test35/padelE_36000_19059_003_190904_L090_CX_01_LUT-Freeman_cls.tif'
print(f'\n\n----------------\nInput: {landcover_in_path}')
print(f'\t(File {i+1} of {len(files_in)})\n')
file_basename = os.path.splitext(os.path.basename(landcover_in_path))[0]
poly_out_pth=os.path.join(shape_dir, file_basename+'_lakes.shp') # shape_dir shape_dir_common_roi #Modify here if making lake polys for full or #sub-roi'/mnt/f/PAD2019/classification_training/PixelClassifier/Test35/shp/padelE_36000_19059_003_190904_L090_CX_01_LUT-Freeman_cls_poly.shp'
ouput_raster_pth = os.path.join(output_raster_dir, file_basename + '_brn.tif') # brn=burned #sub-roi # output_raster_dir output_raster_dir_common_roi
if os.path.exists(ouput_raster_pth): # poly_out_pth
print('Modified output tif already exists. Skipping...')
continue
if '#' in landcover_in_path: # allows me to "comment out" inputs in unique_dates.txt
print('Manual skip.')
continue
## load srs for area math, etc
with rio.open(landcover_in_path) as src:
lc = src.read(1)
src_crs=src.crs
src_res=src.res
src_shp=src.shape
src=src
print(src.profile)
profile = src.profile.copy() # save for output raster
# rio.plot.show(src)
# plt.draw()
# plt.show(block = False)
# src_res
# lc.shape
# plt.imshow(lc)
## burn in artificial bridges to separate open-basin lakes, then buffer to be safe
# (https://gis.stackexchange.com/questions/151339/rasterize-a-shapefile-with-geopandas-or-fiona-python)
print('Rasterize and buffer rivers/bridges...')
bridges=gpd.read_file(bridges_pth)
bridges['val']=bridge_val # add dummy variable
shapes_gen = ((geom,value) for geom, value in zip(bridges.geometry, bridges.val))
bridges_burned = features.rasterize(shapes=shapes_gen, fill=0, out_shape=src.shape, transform=src.transform, all_touched=True).astype('bool')
strelem = selem.disk(12) #25
bridges_burned = binary_dilation(bridges_burned, selem = strelem) # TODO: temp
# plt.imshow(bridges_burned)
print(f'Number of bridge pixels burned: {(bridges_burned>0).sum()}') # sanity check
## add burned bridges to original landcover raster
# np.sum(lc==bridge_val)
lc[bridges_burned>0]=bridge_val
del bridges, bridges_burned
print(f'Number of bridge pixels burned into landcover: {np.sum(lc==bridge_val)}')
## load ROI, and filter to correct feature in shapefile Corresponding to current region
roi=gpd.read_file(roi_pth) # roi_pth roi_sub_pth #sub-roi
roi = roi[roi.Region == region_lookup[file_basename]] # region_lookup region_lookup_sub_roi #sub-roi
if len(roi) == 0:
raise ValueError('EK: Region name note found in lookup table.')
## Rasterize, like bridges, to use as mask
print('Rasterize ROI...')
roi['val']=1 # add dummy variable
shapes_gen = ((geom,value) for geom, value in zip(roi.geometry, roi.val))
roi_burned = features.rasterize(shapes=shapes_gen, fill=0, out_shape=src.shape, transform=src.transform, all_touched=True).astype('bool')
lc[(roi_burned==0) & (lc != 0)]=non_roi_val # mark values that are within footprint, but not ROI as 'non_roi_val'
del shapes_gen, roi_burned
## create masks
# mask_wet_emerg = np.isin(lc, classes_reclass['wet_emergent'])
mask_wet = np.isin(lc, classes_reclass['wet'])
print(f'Number of masked pixels: {np.sum(mask_wet)}')
## Convert water bodies to label matrix
print('Label...')
lb=label(mask_wet, connectivity=2).astype('int32') # save memory
print(f'Label range, before masking: {lb.min()} : {lb.max()}')
# plt.imshow(lb)
## Regionprops
print('Regionprops #1...')
stats=measure.regionprops_table(lb, np.isin(lc, classes_reclass['wet_emergent']), cache=False, properties=['label','area','perimeter','mean_intensity']) # Users should remember to add "label" to keep track of region # cache is faster, but more mem identities.
## convert to DataFrame and drop entries with no open water
stats=pd.DataFrame(stats)
# wetland_regions=stats[stats['mean_intensity']==1.].label.to_numpy() # save labels for regions with no open water
stats_mask_neg=(stats['mean_intensity']==1.) | (stats['area']<3) # rows not to keep (no water or less than 3 px )
## dask computation
print('Converting to dask array and masking out wetlands and smallest ponds...')
lbd=da.from_array(lb)
labels_to_remove = stats[stats_mask_neg].label.to_numpy(dtype='int32')
labels_to_remove_image_mask = da.map_blocks(np.isin, lbd, labels_to_remove, dtype='int32')
labels_to_remove_image_mask=np.array(labels_to_remove_image_mask)
lb[labels_to_remove_image_mask]=0
del labels_to_remove
## save values of em types that aren't lake littoral zones in modified landcover (lc) raster
# from utils import reclassify
lc[np.isin(lc, classes_reclass['wet_graminoid']) & labels_to_remove_image_mask] = classes_reclass['wet_graminoid_no_lake']
lc[np.isin(lc, classes_reclass['wet_shrubs']) & labels_to_remove_image_mask] = classes_reclass['wet_shrubs_no_lake']
lc[np.isin(lc, classes_reclass['wet_forest']) & labels_to_remove_image_mask] = classes_reclass['wet_forest_no_lake']
lc[np.isin(lc, classes_reclass['water']) & labels_to_remove_image_mask] = classes_reclass['water_no_lake']
del labels_to_remove_image_mask
# lb[np.isin(lb, stats[stats_mask_neg].label.to_numpy())]=0 # remove regions from label matrix with mask criteria ## fails for large data size
## end rewrite
## Save landcover raster after modification
with rio.open(ouput_raster_pth, 'w', **profile) as dst:
dst.write(lc, 1)
print(f'Wrote output raster: {ouput_raster_pth}')
# continue # if no need to make shapefiles, quit loop here
stats=stats[~stats_mask_neg] # update stats dataframe with the same mask
stats.rename(columns={'mean_intensity':'em_fraction', 'area':'area_px_m2', 'perimeter':'perimeter_px_m'}, inplace=True)
# stats['em_fraction']=stats['mean_intensity']
# del stats['mean_intensity']
## area math
stats['area_px_m2']=stats.area_px_m2*np.prod(src_res)
stats['perimeter_px_m']=stats.perimeter_px_m*np.mean(src_res)
# del stats['area']
# del stats['perimeter']
## Regionprops for Fw, SW, GW
print('Regionprops #2...') # cache or no cache?
stats_fw=measure.regionprops_table(lb, np.isin(lc, classes_reclass['wet_forest']), cache=True, properties=['label','mean_intensity']) # Users should remember to add "label" to keep track of region # cache is faster, but more mem identities.
stats_fw=pd.DataFrame(stats_fw)
# stats_fw['fw_fraction']=stats_fw['mean_intensity']
stats_fw.rename(columns={'mean_intensity':'fw_fraction'}, inplace=True)
# del stats_fw['mean_intensity']
stats = stats.merge(stats_fw, on='label', how='left', validate='one_to_one')
del stats_fw
print('Regionprops #3...')
stats_sw=pd.DataFrame(measure.regionprops_table(lb, np.isin(lc, classes_reclass['wet_shrubs']), cache=True, properties=['label','mean_intensity'])) # Users should remember to add "label" to keep track of region # cache is faster, but more mem identities.
# stats_sw['fw_fraction']=stats_sw['mean_intensity']
# del stats_sw['mean_intensity']
stats_sw.rename(columns={'mean_intensity':'sw_fraction'}, inplace=True)
stats = stats.merge(stats_sw, on='label', how='left', validate='one_to_one')
del stats_sw
print('Regionprops #4...')
stats_gw=pd.DataFrame(measure.regionprops_table(lb, np.isin(lc, classes_reclass['wet_graminoid']), cache=True, properties=['label','mean_intensity'])) # Users should remember to add "label" to keep track of region # cache is faster, but more mem identities.
# stats_gw['fw_fraction']=stats_gw['mean_intensity']
# del stats_gw['mean_intensity']
stats_gw.rename(columns={'mean_intensity':'gw_fraction'}, inplace=True)
stats = stats.merge(stats_gw, on='label', how='left', validate='one_to_one')
del stats_gw
## Mark edge lakes before deleting input objects
print('Marking edge lakes...')
strelem = selem.disk(1)
nodata_mask_neg=np.isin(lc, [0, non_roi_val])
nodata_mask_neg_no_islands = ~(remove_small_holes(~nodata_mask_neg, 30000)) # only consider outer boundary
edge_ring = (binary_dilation(nodata_mask_neg_no_islands, selem = strelem)) & ~nodata_mask_neg_no_islands
edge_regions = np.unique(lb[edge_ring])
df_edge_regions = pd.DataFrame(edge_regions, columns=['label']); df_edge_regions['edge']=True
stats = stats.merge(df_edge_regions, on='label', how='left'); stats.loc[:, 'edge'].fillna(False, inplace=True)
del nodata_mask_neg, nodata_mask_neg_no_islands, lc
## convert lancover data coverage to shape
# print('Polygonize domain...')
# domain=polygonize(~nodata_mask_neg, mask=~nodata_mask_neg, src=src, connectivity=8) # this is a little risky if I ever update class values...but not planning on it!
# domain = domain.dissolve(by='raster_val') # domain is all the positive data mask
## Rasterize, cir lakes
print('Rasterize CIR lakes...')
cir_lakes=gpd.read_file(cir_pth)
cir_lakes['val']=1 # add dummy variable
shapes_gen = ((geom,value) for geom, value in zip(cir_lakes.geometry, cir_lakes.val))
cir_lakes_mask_positive = features.rasterize(shapes=shapes_gen, fill=0, out_shape=src.shape, transform=src.transform, all_touched=True).astype('bool')
del shapes_gen
## Mark lakes observed by DCS/CIR
print('Marking CIR-observed lakes...')
cir_lakes_labels = np.unique(lb[cir_lakes_mask_positive])
df_cir_lakes_labels = pd.DataFrame(cir_lakes_labels, columns=['label']); df_cir_lakes_labels['cir_observed']=True
stats = stats.merge(df_cir_lakes_labels, on='label', how='left'); stats.loc[:, 'cir_observed'].fillna(False, inplace=True)
## convert to polygon
# from rasterio.features import shapes
# with rio.drivers():
print('Polygonizing...')
poly=polygonize(lb, mask = mask_wet, src=src, connectivity=8)
del lb # save memory
# print(f'Number of polygon geometries created: {len(geoms)}')
poly.rename(columns={'raster_val':'label'}, inplace=True)
print(f'Number of polygons created: {len(poly)}')
poly.head()
# poly.plot()
# plt.draw()
# plt.show(block = False)
## test to find unique vals
print('Max polygon label: ', poly.label.max())
# poly.label.unique().shape
d1=np.setdiff1d(poly.label, stats.label) # > Return the unique values in `ar1` that are not in `ar2`.
d2=np.setdiff1d(stats.label,poly.label)
print(f'Number of polygons that aren\'t listed in regionprops: {d1.size}')
print(f'Number of regionprops regions that didn\'t become polygons: {d2.size}')
## Attribute join
poly=poly.merge(stats, on='label') #country_shapes = country_shapes.merge(country_names, on='iso_a3') # note poly has some repeating labels... validate='one_to_one'
# poly.head()
## Simplify polygons using shapely, if I wish:
# object.simplify(tolerance, preserve_topology=True)
## check if anything didn't merge
# testing poly2=gpd.read_file('/mnt/f/PAD2019/classification_training/PixelClassifier/Final-ORNL-DAAC/shp_no_rivers/bakerc_16008_18047_005_180821_L090_CX_02_Freeman-inc_rcls_lakes.shp')
print(f'Any polygons that didn\'t merge? {np.any(poly.em_fraction.isnull())}')
## save to .shp
# poly_simpl.to_file(poly_out_pth)
poly.to_file(poly_out_pth)
print(f'Wrote file: {poly_out_pth}')
# plt.show() # to keep plots?
# # Compute and plot statistics for EM
# plt.hist(poly[poly.area_px_m2>50][poly.em_fraction<1][poly.em_fraction>0].em_fraction)
# plt.title('EM fraction for WBs > 50 m2 with littoral zones')
# plt.ylabel('count')
# plt.xlabel('EM fraction')