pyopenrivercam 0.8.5__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_elements.py CHANGED
@@ -360,7 +360,7 @@ class AoiSelect(BaseSelect):
360
360
  class GcpSelect(BaseSelect):
361
361
  """Selector tool to provide source GCP coordinates to pyOpenRiverCam."""
362
362
 
363
- def __init__(self, img, dst, crs=None, lens_position=None, logger=logging):
363
+ def __init__(self, img, dst, crs=None, camera_matrix=None, dist_coeffs=None, lens_position=None, logger=logging):
364
364
  """Set up interactive GCP selection plot."""
365
365
  super(GcpSelect, self).__init__(img, dst, crs=crs, logger=logger)
366
366
  # make empty plot
@@ -389,6 +389,8 @@ class GcpSelect(BaseSelect):
389
389
  self.ax_geo.legend()
390
390
  self.ax.legend()
391
391
  self.lens_position = lens_position
392
+ self.camera_matrix_input = camera_matrix
393
+ self.dist_coeffs_input = dist_coeffs
392
394
  # add dst coords in the intended CRS
393
395
  if crs is not None and use_cartopy:
394
396
  self.dst_crs = helpers.xyz_transform(self.dst, 4326, crs)
@@ -407,7 +409,14 @@ class GcpSelect(BaseSelect):
407
409
  self.title.set_text("Fitting pose and camera parameters...")
408
410
  self.ax.figure.canvas.draw()
409
411
  src_fit, dst_fit, camera_matrix, dist_coeffs, rvec, tvec, err = cli_utils.get_gcps_optimized_fit(
410
- self.src, self.dst_crs, self.height, self.width, c=2.0, lens_position=self.lens_position
412
+ self.src,
413
+ self.dst_crs,
414
+ self.height,
415
+ self.width,
416
+ c=2.0,
417
+ camera_matrix=self.camera_matrix_input,
418
+ dist_coeffs=self.dist_coeffs_input,
419
+ lens_position=self.lens_position,
411
420
  )
412
421
  self.p_fit.set_data(*list(zip(*src_fit)))
413
422
  self.camera_matrix = camera_matrix
pyorc/cli/cli_utils.py CHANGED
@@ -4,6 +4,7 @@ import hashlib
4
4
  import json
5
5
  import logging
6
6
  import os
7
+ from typing import Optional
7
8
 
8
9
  import click
9
10
  import cv2
@@ -57,15 +58,39 @@ def get_corners_interactive(
57
58
 
58
59
 
59
60
  def get_gcps_interactive(
60
- fn, dst, crs=None, crs_gcps=None, frame_sample=0, lens_position=None, rotation=None, logger=logging
61
+ fn,
62
+ dst,
63
+ crs=None,
64
+ crs_gcps=None,
65
+ frame_sample=0,
66
+ focal_length=None,
67
+ k1=None,
68
+ k2=None,
69
+ lens_position=None,
70
+ rotation=None,
71
+ logger=logging,
61
72
  ):
62
73
  """Select GCP points in interactive display using first selected video frame."""
63
74
  vid = Video(fn, start_frame=frame_sample, end_frame=frame_sample + 1, rotation=rotation)
64
75
  # get first frame
65
76
  frame = vid.get_frame(0, method="rgb")
77
+ # construct camera matrix and distortion coefficients
78
+ # parse items
79
+ camera_matrix, dist_coeffs = parse_lens_params(
80
+ height=frame.shape[0], width=frame.shape[1], focal_length=focal_length, k1=k1, k2=k2
81
+ )
82
+
66
83
  if crs_gcps is not None:
67
84
  dst = helpers.xyz_transform(dst, crs_from=crs_gcps, crs_to=4326)
68
- selector = GcpSelect(frame, dst, crs=crs, lens_position=lens_position, logger=logger)
85
+ selector = GcpSelect(
86
+ frame,
87
+ dst,
88
+ crs=crs,
89
+ camera_matrix=camera_matrix,
90
+ dist_coeffs=dist_coeffs,
91
+ lens_position=lens_position,
92
+ logger=logger,
93
+ )
69
94
  plt.show(block=True)
70
95
  return selector.src, selector.camera_matrix, selector.dist_coeffs
71
96
 
@@ -90,21 +115,16 @@ def get_file_hash(fn):
90
115
  return hash256
91
116
 
92
117
 
93
- def get_gcps_optimized_fit(src, dst, height, width, c=2.0, lens_position=None):
118
+ def get_gcps_optimized_fit(src, dst, height, width, c=2.0, camera_matrix=None, dist_coeffs=None, lens_position=None):
94
119
  """Fit intrinsic and extrinsic parameters on provided set of src and dst points."""
95
120
  # optimize cam matrix and dist coeffs with provided control points
96
121
  if np.array(dst).shape == (4, 2):
97
122
  _dst = np.c_[np.array(dst), np.zeros(4)]
98
123
  else:
99
124
  _dst = np.array(dst)
125
+ print(camera_matrix)
100
126
  camera_matrix, dist_coeffs, err = cv.optimize_intrinsic(
101
- src,
102
- _dst,
103
- height,
104
- width,
105
- c=c,
106
- lens_position=lens_position,
107
- # dist_coeffs=cv.DIST_COEFFS
127
+ src, _dst, height, width, c=c, lens_position=lens_position, camera_matrix=camera_matrix, dist_coeffs=dist_coeffs
108
128
  )
109
129
  # once optimized, solve the perspective, and estimate the GCP locations with the perspective rot/trans
110
130
  coord_mean = np.array(_dst).mean(axis=0)
@@ -116,7 +136,12 @@ def get_gcps_optimized_fit(src, dst, height, width, c=2.0, lens_position=None):
116
136
  src_est, jacobian = cv2.projectPoints(_dst, rvec, tvec, camera_matrix, np.array(dist_coeffs))
117
137
  src_est = np.array([list(point[0]) for point in src_est])
118
138
  dst_est = cv.unproject_points(_src, _dst[:, -1], rvec, tvec, camera_matrix, dist_coeffs)
139
+ # add mean coordinates to estimated locations
119
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)
120
145
  return src_est, dst_est, camera_matrix, dist_coeffs, rvec, tvec, err
121
146
 
122
147
 
@@ -152,6 +177,29 @@ def parse_corners(ctx, param, value):
152
177
  return [[int(x), int(y)] for x, y in corners]
153
178
 
154
179
 
180
+ def parse_lens_params(
181
+ height: int,
182
+ width: int,
183
+ focal_length: Optional[float] = None,
184
+ k1: Optional[float] = None,
185
+ k2: Optional[float] = None,
186
+ ):
187
+ """Parse lens parameters to camera matrix and distortion coefficients vector."""
188
+ if focal_length is not None:
189
+ camera_matrix = cv.get_cam_mtx(height, width, c=2.0, focal_length=focal_length)
190
+ else:
191
+ camera_matrix = None
192
+ if k1 is not None or k2 is not None:
193
+ dist_coeffs = cv.DIST_COEFFS.copy()
194
+ if k1 is not None:
195
+ dist_coeffs[0][0] = k1
196
+ if k2 is not None:
197
+ dist_coeffs[1][0] = k2
198
+ else:
199
+ dist_coeffs = None
200
+ return camera_matrix, dist_coeffs
201
+
202
+
155
203
  def validate_file(ctx, param, value):
156
204
  """Validate existence of file."""
157
205
  if value is not None:
@@ -283,6 +331,18 @@ def read_shape_as_gdf(fn=None, geojson=None, gdf=None):
283
331
  else:
284
332
  gdf = gpd.read_file(fn)
285
333
  crs = gdf.crs if hasattr(gdf, "crs") else None
334
+ # also read raw json, and check if crs attribute exists
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)
342
+ if "crs" not in raw_json:
343
+ # override the crs
344
+ crs = None
345
+ gdf = gdf.set_crs(None, allow_override=True)
286
346
  # check if all geometries are points
287
347
  assert all([isinstance(geom, Point) for geom in gdf.geometry]), (
288
348
  "shapefile may only contain geometries of type " '"Point"'
pyorc/cli/main.py CHANGED
@@ -99,6 +99,17 @@ def cli(ctx, info, license):
99
99
  help="Coordinate reference system in which destination GCP points (--dst) are measured",
100
100
  )
101
101
  @click.option("--resolution", type=float, help="Target resolution [m] for ortho-projection.")
102
+ @click.option("--focal_length", type=float, help="Focal length [pix] of lens.")
103
+ @click.option(
104
+ "--k1",
105
+ type=float,
106
+ help="First lens radial distortion coefficient k1 [-]. See also https://docs.opencv.org/2.4/modules/calib3d/doc/camera_calibration_and_3d_reconstruction.html",
107
+ )
108
+ @click.option(
109
+ "--k2",
110
+ type=float,
111
+ help="Second lens radial distortion coefficient k2 [-]. See also https://docs.opencv.org/2.4/modules/calib3d/doc/camera_calibration_and_3d_reconstruction.html",
112
+ )
102
113
  @click.option(
103
114
  "--window_size", type=int, help="Target window size [px] for interrogation window for Particle Image Velocimetry"
104
115
  )
@@ -148,6 +159,9 @@ def camera_config(
148
159
  z_0: Optional[float],
149
160
  h_ref: Optional[float],
150
161
  crs_gcps: Optional[Union[str, int]],
162
+ focal_length: Optional[float],
163
+ k1: Optional[float],
164
+ k2: Optional[float],
151
165
  resolution: Optional[float],
152
166
  window_size: Optional[int],
153
167
  lens_position: Optional[List[float]],
@@ -207,6 +221,9 @@ def camera_config(
207
221
  crs=crs,
208
222
  crs_gcps=crs_gcps,
209
223
  frame_sample=frame_sample,
224
+ focal_length=focal_length,
225
+ k1=k1,
226
+ k2=k2,
210
227
  lens_position=lens_position,
211
228
  rotation=rotation,
212
229
  logger=logger,
@@ -295,9 +312,17 @@ def camera_config(
295
312
  @click.option(
296
313
  "--cross",
297
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),
298
323
  help="Cross section file (*.geojson). If you provide this, you may add water level retrieval settings to the"
299
- " recipe section video: water_level: For more information see"
300
- " 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)",
301
326
  callback=cli_utils.parse_cross_section_gdf,
302
327
  required=False,
303
328
  )
@@ -316,7 +341,7 @@ def camera_config(
316
341
  )
317
342
  @verbose_opt
318
343
  @click.pass_context
319
- 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):
320
345
  """CLI subcommand for velocimetry."""
321
346
  log_level = max(10, 20 - 10 * verbose)
322
347
  logger = log.setuplog("velocimetry", os.path.abspath("pyorc.log"), append=False, log_level=log_level)
@@ -328,6 +353,7 @@ def velocimetry(ctx, output, videofile, recipe, cameraconfig, prefix, h_a, cross
328
353
  cameraconfig=cameraconfig,
329
354
  h_a=h_a,
330
355
  cross=cross,
356
+ cross_wl=cross_wl,
331
357
  prefix=prefix,
332
358
  output=output,
333
359
  update=update,