pyopenrivercam 0.8.6__py3-none-any.whl → 0.8.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyorc/api/frames.py CHANGED
@@ -273,54 +273,13 @@ class Frames(ORCBase):
273
273
  {"xs": xs, "ys": ys, "lon": lons, "lat": lats}, coords, const.GEOGRAPHICAL_ATTRS
274
274
  )
275
275
  if "rgb" in da_proj.dims and len(da_proj.dims) == 4:
276
- # ensure that "rgb" is the last dimension
276
+ # ensure that "rgb" is the last dimension and dtype is int
277
277
  da_proj = da_proj.transpose("time", "y", "x", "rgb")
278
+ da_proj = da_proj.astype("uint8")
278
279
  # in case resolution was changed, overrule the camera_config attribute
279
280
  da_proj.attrs.update(camera_config=cc.to_json())
280
281
  return da_proj
281
282
 
282
- def landmask(self, dilate_iter=10, samples=15):
283
- """Attempt to mask out land from water.
284
-
285
- This is done by assuming that the time standard deviation over mean of land is much
286
- higher than that of water. An automatic threshold using Otsu thresholding is used to separate and a dilation
287
- operation is used to make the land mask slightly larger than the exact defined pixels.
288
-
289
- Parameters
290
- ----------
291
- dilate_iter : int, optional
292
- number of dilation iterations to use, to dilate land mask (Default value = 10)
293
- samples : int, optional
294
- amount of samples to retrieve from frames for estimating standard deviation and mean. Set to a lower
295
- number to speed up calculation (Default value = 15)
296
-
297
- Returns
298
- -------
299
- da : xr.DataArray
300
- filtered frames
301
-
302
- """
303
- time_interval = round(len(self._obj) / samples)
304
- assert time_interval != 0, f"Amount of frames is too small to provide {samples} samples"
305
- # ensure attributes are kept
306
- xr.set_options(keep_attrs=True)
307
- # compute standard deviation over mean, assuming this value is low over water, and high over land
308
- std_norm = (self._obj[::time_interval].std(axis=0) / self._obj[::time_interval].mean(axis=0)).load()
309
- # retrieve a simple 3x3 equal weight kernel
310
- kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
311
- # dilate the std_norm by some dilation iterations
312
- dilate_std_norm = cv2.dilate(std_norm.values, kernel, iterations=dilate_iter)
313
- # rescale result to typical uint8 0-255 range
314
- img = cv2.normalize(
315
- dilate_std_norm, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_32F
316
- ).astype(np.uint8)
317
- # threshold with Otsu thresholding
318
- ret, thres = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
319
- # mask is where thres is
320
- mask = thres != 255
321
- # make mask 3-dimensional
322
- return self._obj * mask
323
-
324
283
  def normalize(self, samples=15):
325
284
  """Remove the temporal mean of sampled frames.
326
285
 
@@ -406,6 +365,23 @@ class Frames(ORCBase):
406
365
  """
407
366
  return np.maximum(np.minimum(self._obj, max), min)
408
367
 
368
+ def range(self):
369
+ """Return the range of pixel values through time.
370
+
371
+ Returned array does not have a time dimension. This filter is typically used to detect
372
+ widely changing pixels, e.g. to distinguish moving water from land.
373
+
374
+ Returns
375
+ -------
376
+ xr.DataArray
377
+ Single image (with coordinates) with minimum-maximum range in time [x, y]
378
+
379
+ """
380
+ range_da = (self._obj.max(dim="time", keep_attrs=True) - self._obj.min(dim="time", keep_attrs=True)).astype(
381
+ self._obj.dtype
382
+ ) # ensure dtype out is same as dtype in
383
+ return range_da
384
+
409
385
  def reduce_rolling(self, samples=25):
410
386
  """Remove a rolling mean from the frames.
411
387
 
@@ -601,21 +577,6 @@ class Frames(ORCBase):
601
577
 
602
578
  out.write(img)
603
579
  pbar.update(1)
604
- #
605
- # pbar = tqdm(self._obj, position=0, leave=True)
606
- # pbar.set_description("Writing frames")
607
- # for n, f in enumerate(pbar):
608
- # if len(f.shape) == 3:
609
- # img = cv2.cvtColor(np.uint8(f.values), cv2.COLOR_RGB2BGR)
610
- # else:
611
- # img = f.values
612
- # if n == 0:
613
- # # make a scale between 0 and 255, only with first frame
614
- # img_min = img.min(axis=0).min(axis=0)
615
- # img_max = img.max(axis=0).max(axis=0)
616
- # img = np.uint8(255 * ((img - img_min) / (img_max - img_min)))
617
- # img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
618
- # out.write(img)
619
580
  out.release()
620
581
 
621
582
  plot = _frames_plot
pyorc/api/plot.py CHANGED
@@ -6,8 +6,7 @@ import functools
6
6
  import matplotlib.pyplot as plt
7
7
  import matplotlib.ticker as mticker
8
8
  import numpy as np
9
- from matplotlib import patheffects
10
- from matplotlib.collections import QuadMesh
9
+ from matplotlib import colors, patheffects
11
10
 
12
11
  from pyorc import helpers
13
12
 
@@ -132,7 +131,7 @@ def _base_plot(plot_func):
132
131
 
133
132
  # check if dataset is a transect or not
134
133
  is_transect = True if "points" in ref._obj.dims else False
135
-
134
+ kwargs = set_default_kwargs(kwargs, method=plot_func.__name__, mode=mode)
136
135
  assert mode in ["local", "geographical", "camera"], 'Mode must be "local", "geographical" or "camera"'
137
136
  if mode == "local":
138
137
  x = ref._obj["x"].values
@@ -185,16 +184,26 @@ def _base_plot(plot_func):
185
184
  planar=False,
186
185
  bottom=False,
187
186
  )
188
- ref._obj.transect.cross_section.plot_water_level(
189
- h=ref._obj.transect.h_a,
190
- length=2.0,
191
- linewidth=3.0,
192
- ax=ax,
193
- camera=True,
194
- swap_y_coords=True,
195
- color="r",
196
- label="water level",
187
+ # check if water level is above the lowest level
188
+ check_low = (
189
+ ref._obj.transect.camera_config.h_to_z(ref._obj.transect.h_a)
190
+ > ref._obj.transect.cross_section.z.min()
197
191
  )
192
+ check_high = (
193
+ ref._obj.transect.camera_config.h_to_z(ref._obj.transect.h_a)
194
+ < ref._obj.transect.cross_section.z.max()
195
+ )
196
+ if check_low and check_high:
197
+ ref._obj.transect.cross_section.plot_water_level(
198
+ h=ref._obj.transect.h_a,
199
+ length=2.0,
200
+ linewidth=3.0,
201
+ ax=ax,
202
+ camera=True,
203
+ swap_y_coords=True,
204
+ color="r",
205
+ label="water level",
206
+ )
198
207
 
199
208
  # draw some depth lines for better visual interpretation.
200
209
  depth_lines = ref._obj.transect.get_depth_perspective(h=ref._obj.transect.h_a)
@@ -279,23 +288,22 @@ def _frames_plot(ref, ax=None, mode="local", **kwargs):
279
288
  x = "xp"
280
289
  y = "yp"
281
290
  assert all(v in ref._obj.coords for v in [x, y]), f'required coordinates "{x}" and/or "{y}" are not available'
282
- if len(ref._obj.shape) == 3 and ref._obj.shape[-1] == 3:
283
- # looking at an rgb image
284
- facecolors = ref._obj.values.reshape(ref._obj.shape[0] * ref._obj.shape[1], 3) / 255
285
- facecolors = np.hstack([facecolors, np.ones((len(facecolors), 1))])
286
- primitive = ax.pcolormesh(
287
- ref._obj[x],
288
- ref._obj[y],
289
- ref._obj.mean(dim="rgb"),
290
- shading="nearest",
291
- facecolors=facecolors,
292
- **kwargs,
293
- )
294
- # remove array values, override .set_array, needed in case GeoAxes is provided, because GeoAxes asserts if
295
- # array has dims
296
- QuadMesh.set_array(primitive, None)
297
- else:
291
+ if x == "x":
292
+ # use a simple imshow, much faster
293
+ dx = abs(float(ref._obj[x][1] - ref._obj[x][0])) # x grid cell size
294
+ dy = abs(float(ref._obj[y][1] - ref._obj[y][0])) # y grid cell size
295
+ # Calculate extent with half grid cell expansion
296
+ extent = [
297
+ ref._obj[x].min().item() - 0.5 * dx,
298
+ ref._obj[x].max().item() + 0.5 * dx,
299
+ ref._obj[y].min().item() - 0.5 * dy,
300
+ ref._obj[y].max().item() + 0.5 * dy,
301
+ ]
302
+
303
+ if x != "x":
298
304
  primitive = ax.pcolormesh(ref._obj[x], ref._obj[y], ref._obj, **kwargs)
305
+ else:
306
+ primitive = ax.imshow(ref._obj, origin="upper", extent=extent, aspect="auto", **kwargs)
299
307
  # fix axis limits to min and max of extent of frames
300
308
  if mode == "geographical":
301
309
  ax.set_extent(
@@ -371,7 +379,8 @@ class _Transect_PlotMethods:
371
379
  _u = self._obj[v_eff] * np.sin(self._obj[v_dir])
372
380
  _v = self._obj[v_eff] * np.cos(self._obj[v_dir])
373
381
  s = np.abs(self._obj[v_eff].values)
374
- x_moved, y_moved = x + _u * dt, y + _v * dt
382
+ # x_moved, y_moved = x + _u * dt, y + _v * dt
383
+ x_moved, y_moved = x + _u, y + _v
375
384
  # transform to real-world
376
385
  cols_moved, rows_moved = x_moved / camera_config.resolution, y_moved / camera_config.resolution
377
386
  rows_moved = camera_config.shape[0] - rows_moved
@@ -496,9 +505,11 @@ class _Velocimetry_PlotMethods:
496
505
  """
497
506
  # select lon and lat variables as coordinates
498
507
  velocimetry = self._obj.velocimetry
499
- u = self._obj["v_x"]
500
- v = -self._obj["v_y"]
501
- s = (u**2 + v**2) ** 0.5
508
+ v_x = self._obj["v_x"].values
509
+ v_y = self._obj["v_y"].values
510
+ u = v_x / (2 * 1e5) # 1e5 is aobut the amount of meters per degree
511
+ v = -v_y / (2 * 1e5)
512
+ s = (v_x**2 + v_y**2) ** 0.5
502
513
  aff = velocimetry.camera_config.transform
503
514
  theta = np.arctan2(aff.d, aff.a)
504
515
  # rotate velocity vectors along angle theta to match the requested projection. this only changes values
@@ -519,9 +530,14 @@ class _Velocimetry_PlotMethods:
519
530
  scalar velocity
520
531
 
521
532
  """
522
- u = self._obj["v_x"].values
523
- v = -self._obj["v_y"].values
524
- s = (u**2 + v**2) ** 0.5
533
+ # u and v should be scaled to a nice proportionality for local plots. This is typically about 2x smaller
534
+ # than the m/s values
535
+ v_x = self._obj["v_x"].values
536
+ v_y = self._obj["v_y"].values
537
+
538
+ u = v_x / 2
539
+ v = -v_y / 2
540
+ s = (v_x**2 + v_y**2) ** 0.5
525
541
  return u, v, s
526
542
 
527
543
  def get_uv_camera(self, dt=0.1):
@@ -552,7 +568,8 @@ class _Velocimetry_PlotMethods:
552
568
  yi = np.flipud(yi)
553
569
 
554
570
  # follow the velocity vector over a short distance (dt*velocity)
555
- x_moved, y_moved = xi + self._obj["v_x"] * dt, yi + self._obj["v_y"] * dt
571
+ # x_moved, y_moved = xi + self._obj["v_x"] * dt, yi + self._obj["v_y"] * dt
572
+ x_moved, y_moved = xi + self._obj["v_x"] / 2, yi + self._obj["v_y"] / 2
556
573
  # transform to real-world
557
574
  cols_moved, rows_moved = x_moved / camera_config.resolution, y_moved / camera_config.resolution
558
575
  xs_moved, ys_moved = helpers.get_xs_ys(cols_moved, rows_moved, camera_config.transform)
@@ -578,10 +595,51 @@ class _Velocimetry_PlotMethods:
578
595
  return u, v, s
579
596
 
580
597
 
598
+ def set_default_kwargs(kwargs, method="quiver", mode="local"):
599
+ """Set color mapping default kwargs if no vmin and/or vmax is supplied."""
600
+ if mode == "local":
601
+ # width scale is in cm
602
+ width_scale = 0.02
603
+ elif mode == "geographical":
604
+ # widths are in degrees
605
+ width_scale = 0.00000025
606
+ elif mode == "camera":
607
+ # widths in pixels
608
+ width_scale = 3.0
609
+ else:
610
+ raise ValueError("mode must be one of 'local', 'geographical' or 'camera'")
611
+ if "cmap" not in kwargs:
612
+ kwargs["cmap"] = "rainbow" # the famous rainbow colormap!
613
+ if "vmin" not in kwargs and "vmax" not in kwargs and "norm" not in kwargs:
614
+ # set a normalization array
615
+ norm = [0, 0.05, 0.1, 0.2, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0]
616
+ kwargs["norm"] = colors.BoundaryNorm(norm, ncolors=256, extend="max")
617
+ if method == "quiver":
618
+ if "scale" not in kwargs:
619
+ kwargs["scale"] = 1 # larger quiver arrows
620
+ if "units" not in kwargs:
621
+ kwargs["units"] = "xy"
622
+ # for width, it will matter a lot what mode is used, width should be a dimensionless number
623
+ if "width" not in kwargs:
624
+ kwargs["width"] = width_scale
625
+ else:
626
+ kwargs["width"] *= width_scale # multiply
627
+
628
+ return kwargs
629
+
630
+
581
631
  @_base_plot
582
632
  def quiver(_, x, y, u, v, s=None, ax=None, **kwargs):
583
633
  """Create quiver plot from velocimetry results on new or existing axes.
584
634
 
635
+ Note that the `width` parameter is always in a unitless scale, with `1` providing a nice-looking default value
636
+ for all plot modes. The default value is usually very nice. If you do want to change:
637
+
638
+ The `scale` parameter defaults to 1, providing a nice looking arrow length. With a smaller (larger) value,
639
+ quivers will become longer (shorter).
640
+
641
+ The `width` parameter is in a unitless scale, with `1` providing a nice-looking default value.
642
+
585
643
  Wraps :py:func:`matplotlib:matplotlib.pyplot.quiver`.
586
644
  """
587
645
  if "color" in kwargs:
pyorc/cli/cli_utils.py CHANGED
@@ -136,7 +136,12 @@ def get_gcps_optimized_fit(src, dst, height, width, c=2.0, camera_matrix=None, d
136
136
  src_est, jacobian = cv2.projectPoints(_dst, rvec, tvec, camera_matrix, np.array(dist_coeffs))
137
137
  src_est = np.array([list(point[0]) for point in src_est])
138
138
  dst_est = cv.unproject_points(_src, _dst[:, -1], rvec, tvec, camera_matrix, dist_coeffs)
139
+ # add mean coordinates to estimated locations
139
140
  dst_est = np.array(dst_est)[:, 0 : len(coord_mean)] + coord_mean
141
+ # also reverse rvec and tvec to real-world (rw), add mean coordinate, and reverse again.
142
+ rvec_cam, tvec_cam = cv.pose_world_to_camera(rvec, tvec)
143
+ tvec_cam += coord_mean
144
+ rvec, tvec = cv.pose_world_to_camera(rvec_cam, tvec_cam)
140
145
  return src_est, dst_est, camera_matrix, dist_coeffs, rvec, tvec, err
141
146
 
142
147
 
@@ -324,15 +329,8 @@ def read_shape_as_gdf(fn=None, geojson=None, gdf=None):
324
329
  crs = None
325
330
  gdf = gpd.GeoDataFrame().from_features(geojson, crs=crs)
326
331
  else:
327
- gdf = gpd.read_file(fn)
328
- crs = gdf.crs if hasattr(gdf, "crs") else None
329
- # also read raw json, and check if crs attribute exists
330
- with open(fn, "r") as f:
331
- raw_json = json.load(f)
332
- if "crs" not in raw_json:
333
- # override the crs
334
- crs = None
335
- gdf = gdf.set_crs(None, allow_override=True)
332
+ gdf = helpers.read_shape_safe_crs(fn)
333
+ crs = gdf.crs
336
334
  # check if all geometries are points
337
335
  assert all([isinstance(geom, Point) for geom in gdf.geometry]), (
338
336
  "shapefile may only contain geometries of type " '"Point"'
pyorc/cli/main.py CHANGED
@@ -312,9 +312,17 @@ def camera_config(
312
312
  @click.option(
313
313
  "--cross",
314
314
  type=click.Path(exists=True, resolve_path=True, dir_okay=False, file_okay=True),
315
+ help="Cross section file (*.geojson). This will be used for discharge estimation if the `transect` "
316
+ " section is provided in your recipe.",
317
+ callback=cli_utils.parse_cross_section_gdf,
318
+ required=False,
319
+ )
320
+ @click.option(
321
+ "--cross_wl",
322
+ type=click.Path(exists=True, resolve_path=True, dir_okay=False, file_okay=True),
315
323
  help="Cross section file (*.geojson). If you provide this, you may add water level retrieval settings to the"
316
- " recipe section video: water_level: For more information see"
317
- " [PyORC docs](https://localdevices.github.io/pyorc/user-guide/video/index.html)",
324
+ " recipe in the section `water_level`. For more information see"
325
+ " [PyORC docs](https://localdevices.github.io/pyorc/user-guide/cross_section/index.html)",
318
326
  callback=cli_utils.parse_cross_section_gdf,
319
327
  required=False,
320
328
  )
@@ -333,7 +341,7 @@ def camera_config(
333
341
  )
334
342
  @verbose_opt
335
343
  @click.pass_context
336
- def velocimetry(ctx, output, videofile, recipe, cameraconfig, prefix, h_a, cross, update, lowmem, verbose):
344
+ def velocimetry(ctx, output, videofile, recipe, cameraconfig, prefix, h_a, cross, cross_wl, update, lowmem, verbose):
337
345
  """CLI subcommand for velocimetry."""
338
346
  log_level = max(10, 20 - 10 * verbose)
339
347
  logger = log.setuplog("velocimetry", os.path.abspath("pyorc.log"), append=False, log_level=log_level)
@@ -345,6 +353,7 @@ def velocimetry(ctx, output, videofile, recipe, cameraconfig, prefix, h_a, cross
345
353
  cameraconfig=cameraconfig,
346
354
  h_a=h_a,
347
355
  cross=cross,
356
+ cross_wl=cross_wl,
348
357
  prefix=prefix,
349
358
  output=output,
350
359
  update=update,
pyorc/cv.py CHANGED
@@ -9,7 +9,7 @@ import numpy as np
9
9
  import rasterio
10
10
  from scipy import optimize
11
11
  from shapely.affinity import rotate
12
- from shapely.geometry import LineString, Polygon
12
+ from shapely.geometry import LineString, Point, Polygon
13
13
  from tqdm import tqdm
14
14
 
15
15
  from . import helpers
@@ -88,6 +88,56 @@ def _combine_m(m1, m2):
88
88
  return m_combi
89
89
 
90
90
 
91
+ def _get_aoi_corners(dst_corners, resolution=None):
92
+ polygon = Polygon(dst_corners)
93
+ coords = np.array(polygon.exterior.coords)
94
+ # estimate the angle of the bounding box
95
+ # retrieve average line across AOI
96
+ point1 = (coords[0] + coords[3]) / 2
97
+ point2 = (coords[1] + coords[2]) / 2
98
+ diff = point2 - point1
99
+ angle = np.arctan2(diff[1], diff[0])
100
+ # rotate the polygon over this angle to get a proper bounding box
101
+ polygon_rotate = rotate(polygon, -angle, origin=tuple(dst_corners[0]), use_radians=True)
102
+
103
+ xmin, ymin, xmax, ymax = polygon_rotate.bounds
104
+ if resolution is not None:
105
+ xmin = helpers.round_to_multiple(xmin, resolution)
106
+ xmax = helpers.round_to_multiple(xmax, resolution)
107
+ ymin = helpers.round_to_multiple(ymin, resolution)
108
+ ymax = helpers.round_to_multiple(ymax, resolution)
109
+
110
+ bbox_coords = [(xmin, ymax), (xmax, ymax), (xmax, ymin), (xmin, ymin), (xmin, ymax)]
111
+ bbox = Polygon(bbox_coords)
112
+ # now rotate back
113
+ bbox = rotate(bbox, angle, origin=tuple(dst_corners[0]), use_radians=True)
114
+ return bbox
115
+
116
+
117
+ def _get_aoi_width_length(dst_corners):
118
+ points = [Point(x, y) for x, y, _ in dst_corners]
119
+ linecross = LineString([points[0], points[1]])
120
+ # linecross = LineString(dst_corners[0:2])
121
+ length = np.abs(_get_perpendicular_distance(points[-1], linecross))
122
+ point1 = np.array(dst_corners[0][0:2])
123
+ point2 = np.array(dst_corners[1][0:2])
124
+ diff = np.array(point2 - point1)
125
+ angle = np.arctan2(diff[1], diff[0])
126
+
127
+ # compute xy distance from line to other line making up the bounding box
128
+ xy_diff = np.array([np.sin(-angle) * length, np.cos(angle) * length])
129
+ points_pol = np.array([point1 - xy_diff, point1 + xy_diff, point2 + xy_diff, point2 - xy_diff])
130
+ # always make sure the order of the points of upstream-left, downstream-left, downstream-right, upstream-right
131
+ # if length <= 0:
132
+ # # negative length means the selected length is selected upstream of left-right cross section
133
+ # points_pol = np.array([point1 + xy_diff, point1, point2, point2 + xy_diff])
134
+ # else:
135
+ # # postive means it is selected downstream of left-right cross section
136
+ # points_pol = np.array([point1, point1 + xy_diff, point2 + xy_diff, point2])
137
+
138
+ return Polygon(points_pol)
139
+
140
+
91
141
  def _smooth(img, stride):
92
142
  """Blur image through gaussian smoothing.
93
143
 
@@ -152,6 +202,52 @@ def _get_dist_coefs(k1):
152
202
  return dist
153
203
 
154
204
 
205
+ def _get_perpendicular_distance(point, line):
206
+ """Calculate perpendicular distance from point to line.
207
+
208
+ Line is extended if perpendicular distance is larger than the distance to the endpoints.
209
+
210
+ Parameters
211
+ ----------
212
+ point : shapely.geometry.Point
213
+ x, y coordinates of point
214
+ line : shapely.geometry.LineString
215
+ line to calculate distance to
216
+
217
+ Returns
218
+ -------
219
+ float
220
+ perpendicular distance from point to line
221
+
222
+ """
223
+ # Get coordinates of line endpoints
224
+ p1 = np.array(line.coords[0])
225
+ p2 = np.array(line.coords[1])
226
+ # Convert point to numpy array
227
+ p3 = np.array(point.coords[0])
228
+
229
+ # Calculate line vector
230
+ line_vector = p2 - p1
231
+ # Calculate vector from point to line start
232
+ point_vector = p3 - p1
233
+
234
+ # Calculate unit vector of line
235
+ unit_line = line_vector / np.linalg.norm(line_vector)
236
+
237
+ # Calculate projection length
238
+ projection_length = np.dot(point_vector, unit_line)
239
+
240
+ # Calculate perpendicular vector
241
+ perpendicular_vector = point_vector - projection_length * unit_line
242
+ perpendicular_distance = np.linalg.norm(perpendicular_vector)
243
+
244
+ # Use cross product to calculate side
245
+ cross_product = np.cross(line_vector, point_vector)
246
+
247
+ # Determine the sign of the perpendicular distance
248
+ return perpendicular_distance if cross_product > 0 else -perpendicular_distance
249
+
250
+
155
251
  def get_cam_mtx(height, width, c=2.0, focal_length=None):
156
252
  """Compute camera matrix based on the given parameters for height, width, scaling factor, and focal length.
157
253
 
@@ -907,15 +1003,19 @@ def get_ortho(img, M, shape, flags=cv2.INTER_AREA):
907
1003
  return cv2.warpPerspective(img, M, shape, flags=flags)
908
1004
 
909
1005
 
910
- def get_aoi(dst_corners, resolution=None):
911
- """Get rectangular AOI from 4 user defined points within frames.
1006
+ def get_aoi(dst_corners, resolution=None, method="corners"):
1007
+ """Get rectangular AOI from 3 or 4 user defined points within frames.
912
1008
 
913
1009
  Parameters
914
1010
  ----------
915
1011
  dst_corners : np.ndarray
916
- corners of aoi, in order: upstream-left, downstream-left, downstream-right, upstream-right
1012
+ corners of aoi, with `method="width_length"` in order: left-bank, right-bank, up/downstream point,
1013
+ with `method="corners"` in order: upstream-left, downstream-left, downstream-right, upstream-right.
917
1014
  resolution : float
918
1015
  resolution of intended reprojection, used to round the bbox to a whole number of intended pixels
1016
+ method : str
1017
+ can be "corners" or "width_length". With "corners", the AOI is defined by the four corners of the rectangle.
1018
+ With "width" length, the AOI is defined by the width (2 points) and length (1 point) of the rectangle.
919
1019
 
920
1020
  Returns
921
1021
  -------
@@ -923,27 +1023,14 @@ def get_aoi(dst_corners, resolution=None):
923
1023
  bounding box of aoi (with rotated affine)
924
1024
 
925
1025
  """
926
- polygon = Polygon(dst_corners)
927
- coords = np.array(polygon.exterior.coords)
928
- # estimate the angle of the bounding box
929
- # retrieve average line across AOI
930
- point1 = (coords[0] + coords[3]) / 2
931
- point2 = (coords[1] + coords[2]) / 2
932
- diff = point2 - point1
933
- angle = np.arctan2(diff[1], diff[0])
934
- # rotate the polygon over this angle to get a proper bounding box
935
- polygon_rotate = rotate(polygon, -angle, origin=tuple(dst_corners[0]), use_radians=True)
936
- xmin, ymin, xmax, ymax = polygon_rotate.bounds
937
- if resolution is not None:
938
- xmin = helpers.round_to_multiple(xmin, resolution)
939
- xmax = helpers.round_to_multiple(xmax, resolution)
940
- ymin = helpers.round_to_multiple(ymin, resolution)
941
- ymax = helpers.round_to_multiple(ymax, resolution)
1026
+ if method == "corners":
1027
+ bbox = _get_aoi_corners(dst_corners, resolution)
1028
+ elif method == "width_length":
1029
+ bbox = _get_aoi_width_length(dst_corners)
1030
+
1031
+ else:
1032
+ raise ValueError("method must be 'corners' or 'width_length'")
942
1033
 
943
- bbox_coords = [(xmin, ymax), (xmax, ymax), (xmax, ymin), (xmin, ymin), (xmin, ymax)]
944
- bbox = Polygon(bbox_coords)
945
- # now rotate back
946
- bbox = rotate(bbox, angle, origin=tuple(dst_corners[0]), use_radians=True)
947
1034
  return bbox
948
1035
 
949
1036
 
pyorc/helpers.py CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  import copy
4
4
  import importlib.util
5
+ import json
5
6
 
6
7
  import cv2
8
+ import geopandas as gpd
7
9
  import matplotlib.pyplot as plt
8
10
  import numpy as np
9
11
  import xarray as xr
@@ -582,6 +584,27 @@ def optimize_log_profile(
582
584
  return {"z0": z0, "k_max": k_max, "s0": s0, "s1": s1}
583
585
 
584
586
 
587
+ def read_shape_safe_crs(fn):
588
+ """Read a shapefile with geopandas, but ensure that CRS is set to None when not available.
589
+
590
+ This function is required in cases where geometries must be read that do not have a specified CRS. Geopandas
591
+ defaults to WGS84 EPSG 4326 if the CRS is not specified.
592
+ """
593
+ gdf = gpd.read_file(fn)
594
+ # also read raw json, and check if crs attribute exists
595
+ if isinstance(fn, str):
596
+ with open(fn, "r") as f:
597
+ raw_json = json.load(f)
598
+ else:
599
+ # apparently a file object was provided
600
+ fn.seek(0)
601
+ raw_json = json.load(fn)
602
+ if "crs" not in raw_json:
603
+ # override the crs
604
+ gdf = gdf.set_crs(None, allow_override=True)
605
+ return gdf
606
+
607
+
585
608
  def rotate_u_v(u, v, theta, deg=False):
586
609
  """Rotate u and v components of vector counterclockwise by an amount of rotation.
587
610
 
pyorc/plot_helpers.py CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  import matplotlib.pyplot as plt
4
4
  from mpl_toolkits.mplot3d.art3d import Poly3DCollection
5
+ from shapely import geometry
5
6
 
6
7
 
7
- def plot_3d_polygon(polygon, ax=None, **kwargs):
8
- """Plot a shapely.geometry.Polygon on matplotlib 3d ax."""
8
+ def _plot_3d_pol(polygon, ax=None, **kwargs):
9
+ """Plot single polygon on matplotlib 3d ax."""
9
10
  x, y, z = zip(*polygon.exterior.coords)
10
11
  verts = [list(zip(x, y, z))]
11
12
 
@@ -17,13 +18,27 @@ def plot_3d_polygon(polygon, ax=None, **kwargs):
17
18
  return p
18
19
 
19
20
 
21
+ def plot_3d_polygon(polygon, ax=None, **kwargs):
22
+ """Plot a shapely.geometry.Polygon or MultiPolygon on matplotlib 3d ax."""
23
+ if isinstance(polygon, geometry.MultiPolygon):
24
+ for pol in polygon.geoms:
25
+ p = _plot_3d_pol(pol, ax=ax, **kwargs)
26
+ else:
27
+ p = _plot_3d_pol(polygon, ax=ax, **kwargs)
28
+ return p
29
+
30
+
20
31
  def plot_polygon(polygon, ax=None, **kwargs):
21
- """Plot a shapely.geometry.Polygon on matplotlib ax."""
22
- # x, y = zip(*polygon.exterior.coords)
32
+ """Plot a shapely.geometry.Polygon or MultiPolygon on matplotlib ax."""
23
33
  if ax is None:
24
34
  ax = plt.axes()
25
- patch = plt.Polygon(polygon.exterior.coords, **kwargs)
26
- p = ax.add_patch(patch)
35
+ if isinstance(polygon, geometry.MultiPolygon):
36
+ for pol in polygon.geoms:
37
+ patch = plt.Polygon(pol.exterior.coords, **kwargs)
38
+ p = ax.add_patch(patch)
39
+ else:
40
+ patch = plt.Polygon(polygon.exterior.coords, **kwargs)
41
+ p = ax.add_patch(patch)
27
42
  return p
28
43
 
29
44