pyopenrivercam 0.8.6__py3-none-any.whl → 0.8.7__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/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
@@ -279,23 +278,22 @@ def _frames_plot(ref, ax=None, mode="local", **kwargs):
279
278
  x = "xp"
280
279
  y = "yp"
281
280
  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:
281
+ if x == "x":
282
+ # use a simple imshow, much faster
283
+ dx = abs(float(ref._obj[x][1] - ref._obj[x][0])) # x grid cell size
284
+ dy = abs(float(ref._obj[y][1] - ref._obj[y][0])) # y grid cell size
285
+ # Calculate extent with half grid cell expansion
286
+ extent = [
287
+ ref._obj[x].min().item() - 0.5 * dx,
288
+ ref._obj[x].max().item() + 0.5 * dx,
289
+ ref._obj[y].min().item() - 0.5 * dy,
290
+ ref._obj[y].max().item() + 0.5 * dy,
291
+ ]
292
+
293
+ if x != "x":
298
294
  primitive = ax.pcolormesh(ref._obj[x], ref._obj[y], ref._obj, **kwargs)
295
+ else:
296
+ primitive = ax.imshow(ref._obj, origin="upper", extent=extent, aspect="auto", **kwargs)
299
297
  # fix axis limits to min and max of extent of frames
300
298
  if mode == "geographical":
301
299
  ax.set_extent(
@@ -371,7 +369,8 @@ class _Transect_PlotMethods:
371
369
  _u = self._obj[v_eff] * np.sin(self._obj[v_dir])
372
370
  _v = self._obj[v_eff] * np.cos(self._obj[v_dir])
373
371
  s = np.abs(self._obj[v_eff].values)
374
- x_moved, y_moved = x + _u * dt, y + _v * dt
372
+ # x_moved, y_moved = x + _u * dt, y + _v * dt
373
+ x_moved, y_moved = x + _u, y + _v
375
374
  # transform to real-world
376
375
  cols_moved, rows_moved = x_moved / camera_config.resolution, y_moved / camera_config.resolution
377
376
  rows_moved = camera_config.shape[0] - rows_moved
@@ -496,9 +495,11 @@ class _Velocimetry_PlotMethods:
496
495
  """
497
496
  # select lon and lat variables as coordinates
498
497
  velocimetry = self._obj.velocimetry
499
- u = self._obj["v_x"]
500
- v = -self._obj["v_y"]
501
- s = (u**2 + v**2) ** 0.5
498
+ v_x = self._obj["v_x"].values
499
+ v_y = self._obj["v_y"].values
500
+ u = v_x / (2 * 1e5) # 1e5 is aobut the amount of meters per degree
501
+ v = -v_y / (2 * 1e5)
502
+ s = (v_x**2 + v_y**2) ** 0.5
502
503
  aff = velocimetry.camera_config.transform
503
504
  theta = np.arctan2(aff.d, aff.a)
504
505
  # rotate velocity vectors along angle theta to match the requested projection. this only changes values
@@ -519,9 +520,14 @@ class _Velocimetry_PlotMethods:
519
520
  scalar velocity
520
521
 
521
522
  """
522
- u = self._obj["v_x"].values
523
- v = -self._obj["v_y"].values
524
- s = (u**2 + v**2) ** 0.5
523
+ # u and v should be scaled to a nice proportionality for local plots. This is typically about 2x smaller
524
+ # than the m/s values
525
+ v_x = self._obj["v_x"].values
526
+ v_y = self._obj["v_y"].values
527
+
528
+ u = v_x / 2
529
+ v = -v_y / 2
530
+ s = (v_x**2 + v_y**2) ** 0.5
525
531
  return u, v, s
526
532
 
527
533
  def get_uv_camera(self, dt=0.1):
@@ -552,7 +558,8 @@ class _Velocimetry_PlotMethods:
552
558
  yi = np.flipud(yi)
553
559
 
554
560
  # 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
561
+ # x_moved, y_moved = xi + self._obj["v_x"] * dt, yi + self._obj["v_y"] * dt
562
+ x_moved, y_moved = xi + self._obj["v_x"] / 2, yi + self._obj["v_y"] / 2
556
563
  # transform to real-world
557
564
  cols_moved, rows_moved = x_moved / camera_config.resolution, y_moved / camera_config.resolution
558
565
  xs_moved, ys_moved = helpers.get_xs_ys(cols_moved, rows_moved, camera_config.transform)
@@ -578,10 +585,51 @@ class _Velocimetry_PlotMethods:
578
585
  return u, v, s
579
586
 
580
587
 
588
+ def set_default_kwargs(kwargs, method="quiver", mode="local"):
589
+ """Set color mapping default kwargs if no vmin and/or vmax is supplied."""
590
+ if mode == "local":
591
+ # width scale is in cm
592
+ width_scale = 0.02
593
+ elif mode == "geographical":
594
+ # widths are in degrees
595
+ width_scale = 0.00000025
596
+ elif mode == "camera":
597
+ # widths in pixels
598
+ width_scale = 3.0
599
+ else:
600
+ raise ValueError("mode must be one of 'local', 'geographical' or 'camera'")
601
+ if "cmap" not in kwargs:
602
+ kwargs["cmap"] = "rainbow" # the famous rainbow colormap!
603
+ if "vmin" not in kwargs and "vmax" not in kwargs and "norm" not in kwargs:
604
+ # set a normalization array
605
+ norm = [0, 0.05, 0.1, 0.2, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0]
606
+ kwargs["norm"] = colors.BoundaryNorm(norm, ncolors=256, extend="max")
607
+ if method == "quiver":
608
+ if "scale" not in kwargs:
609
+ kwargs["scale"] = 1 # larger quiver arrows
610
+ if "units" not in kwargs:
611
+ kwargs["units"] = "xy"
612
+ # for width, it will matter a lot what mode is used, width should be a dimensionless number
613
+ if "width" not in kwargs:
614
+ kwargs["width"] = width_scale
615
+ else:
616
+ kwargs["width"] *= width_scale # multiply
617
+
618
+ return kwargs
619
+
620
+
581
621
  @_base_plot
582
622
  def quiver(_, x, y, u, v, s=None, ax=None, **kwargs):
583
623
  """Create quiver plot from velocimetry results on new or existing axes.
584
624
 
625
+ Note that the `width` parameter is always in a unitless scale, with `1` providing a nice-looking default value
626
+ for all plot modes. The default value is usually very nice. If you do want to change:
627
+
628
+ The `scale` parameter defaults to 1, providing a nice looking arrow length. With a smaller (larger) value,
629
+ quivers will become longer (shorter).
630
+
631
+ The `width` parameter is in a unitless scale, with `1` providing a nice-looking default value.
632
+
585
633
  Wraps :py:func:`matplotlib:matplotlib.pyplot.quiver`.
586
634
  """
587
635
  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
 
@@ -327,8 +332,13 @@ def read_shape_as_gdf(fn=None, geojson=None, gdf=None):
327
332
  gdf = gpd.read_file(fn)
328
333
  crs = gdf.crs if hasattr(gdf, "crs") else None
329
334
  # also read raw json, and check if crs attribute exists
330
- with open(fn, "r") as f:
331
- raw_json = json.load(f)
335
+ if isinstance(fn, str):
336
+ with open(fn, "r") as f:
337
+ raw_json = json.load(f)
338
+ else:
339
+ # apparently a file object was provided
340
+ fn.seek(0)
341
+ raw_json = json.load(fn)
332
342
  if "crs" not in raw_json:
333
343
  # override the crs
334
344
  crs = None
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