pyopenrivercam 0.8.7__tar.gz → 0.8.8__tar.gz

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.
Files changed (40) hide show
  1. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/CHANGELOG.md +10 -0
  2. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/PKG-INFO +1 -1
  3. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/__init__.py +1 -1
  4. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/api/cross_section.py +81 -26
  5. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/api/plot.py +19 -9
  6. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/cli/cli_utils.py +2 -14
  7. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/helpers.py +23 -0
  8. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/plot_helpers.py +21 -6
  9. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/service/velocimetry.py +2 -2
  10. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/.gitignore +0 -0
  11. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/.pre-commit-config.yaml +0 -0
  12. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/Dockerfile +0 -0
  13. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/LICENSE +0 -0
  14. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/README.md +0 -0
  15. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/TRADEMARK.md +0 -0
  16. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/environment.yml +0 -0
  17. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/api/__init__.py +0 -0
  18. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/api/cameraconfig.py +0 -0
  19. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/api/frames.py +0 -0
  20. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/api/mask.py +0 -0
  21. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/api/orcbase.py +0 -0
  22. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/api/transect.py +0 -0
  23. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/api/velocimetry.py +0 -0
  24. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/api/video.py +0 -0
  25. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/cli/__init__.py +0 -0
  26. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/cli/cli_elements.py +0 -0
  27. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/cli/log.py +0 -0
  28. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/cli/main.py +0 -0
  29. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/const.py +0 -0
  30. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/cv.py +0 -0
  31. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/project.py +0 -0
  32. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/pyorc.sh +0 -0
  33. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/sample_data.py +0 -0
  34. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/service/__init__.py +0 -0
  35. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/service/camera_config.py +0 -0
  36. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/velocimetry/__init__.py +0 -0
  37. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/velocimetry/ffpiv.py +0 -0
  38. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyorc/velocimetry/openpiv.py +0 -0
  39. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/pyproject.toml +0 -0
  40. {pyopenrivercam-0.8.7 → pyopenrivercam-0.8.8}/sonar-project.properties +0 -0
@@ -1,3 +1,13 @@
1
+ ## [0.8.8] = 2025-07-14
2
+ ### Added
3
+ ### Changed
4
+ ### Deprecated
5
+ ### Removed
6
+ ### Fixed
7
+ - issues with plotting cross sections in edge cases with water levels equal to lowest point or above highest point
8
+ - fixed problem with situations where either the cross section data from a shapefile or the camera configuration
9
+ did not contain CRS information. This is now correctly parsed when creating a `CrossSection` instance.
10
+
1
11
  ## [0.8.7] = 2025-06-30
2
12
  ### Added
3
13
  - CLI option `--cross_wl`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyopenrivercam
3
- Version: 0.8.7
3
+ Version: 0.8.8
4
4
  Summary: pyorc: free and open-source image-based surface velocity and discharge.
5
5
  Author-email: Hessel Winsemius <winsemius@rainbowsensing.com>
6
6
  Requires-Python: >=3.9
@@ -1,6 +1,6 @@
1
1
  """pyorc: free and open-source image-based surface velocity and discharge."""
2
2
 
3
- __version__ = "0.8.7"
3
+ __version__ = "0.8.8"
4
4
 
5
5
  from .api import CameraConfig, CrossSection, Frames, Transect, Velocimetry, Video, get_camera_config, load_camera_config # noqa
6
6
  from .project import * # noqa
@@ -146,9 +146,11 @@ class CrossSection:
146
146
  """
147
147
  # if cross_section is a GeoDataFrame, check if it has a CRS, if yes, convert coordinates to crs of CameraConfig
148
148
  if isinstance(cross_section, gpd.GeoDataFrame):
149
- if cross_section.crs is not None and camera_config.crs is not None:
149
+ crs_cs = getattr(cross_section, "crs", None)
150
+ crs_cam = getattr(camera_config, "crs", None)
151
+ if crs_cs is not None and crs_cam is not None:
150
152
  cross_section.to_crs(camera_config.crs, inplace=True)
151
- elif cross_section.crs is not None or camera_config.crs is not None:
153
+ elif crs_cs is not None or crs_cam is not None:
152
154
  raise ValueError("if a CRS is used, then both camera_config and cross_section must have a CRS.")
153
155
  g = cross_section.geometry
154
156
  x, y, z = g.x.values, g.y.values, g.z.values
@@ -270,7 +272,7 @@ class CrossSection:
270
272
  # check if there are any points within the image objective and return result
271
273
  return bool(np.any(within_image))
272
274
 
273
- def get_cs_waterlevel(self, h: float, sz=False) -> geometry.LineString:
275
+ def get_cs_waterlevel(self, h: float, sz=False, extend_by=None) -> geometry.LineString:
274
276
  """Retrieve LineString of water surface at cross-section at a given water level.
275
277
 
276
278
  Parameters
@@ -279,18 +281,44 @@ class CrossSection:
279
281
  water level [m]
280
282
  sz : bool, optional
281
283
  If set, return water level line in y-z projection, by default False.
284
+ extend_by : float, optional
285
+ If set, the line will be extended left and right using the defined float in meters
282
286
 
283
287
  Returns
284
288
  -------
285
289
  geometry.LineString
286
- horizontal line at water level (2d if sz=True, 3d if yz=False)
290
+ horizontal line at water level (2d if `sz`=True, 3d if `yz`=False)
287
291
 
288
292
  """
289
293
  # get water level in camera config vertical datum
290
294
  z = self.camera_config.h_to_z(h)
291
295
  if sz:
292
- return geometry.LineString(zip(self.s, [z] * len(self.s)))
293
- return geometry.LineString(zip(self.x, self.y, [z] * len(self.x)))
296
+ if extend_by is None:
297
+ s_coords = self.s
298
+ else:
299
+ s_coords = np.concatenate([[-np.abs(extend_by)], self.s, [self.s[-1] + np.abs(extend_by)]])
300
+ return geometry.LineString(zip(s_coords, [z] * len(s_coords)))
301
+ if extend_by is not None:
302
+ alpha = np.arctan((self.x[1] - self.x[0]) / (self.y[1] - self.y[0]))
303
+ x_coords = np.concatenate(
304
+ [
305
+ [self.x[0] - np.cos(alpha) * np.abs(extend_by)],
306
+ self.x,
307
+ [self.x[-1] + np.cos(alpha) * np.abs(extend_by)],
308
+ ]
309
+ )
310
+ y_coords = np.concatenate(
311
+ [
312
+ [self.y[0] - np.sin(alpha) * np.abs(extend_by)],
313
+ self.y,
314
+ [self.y[-1] + np.sin(alpha) * np.abs(extend_by)],
315
+ ]
316
+ )
317
+ else:
318
+ x_coords = self.x
319
+ y_coords = self.y
320
+
321
+ return geometry.LineString(zip(x_coords, y_coords, [z] * len(x_coords)))
294
322
 
295
323
  def get_csl_point(
296
324
  self, h: Optional[float] = None, l: Optional[float] = None, camera: bool = False, swap_y_coords: bool = False
@@ -602,16 +630,32 @@ class CrossSection:
602
630
  Wetted surface as a polygon, in Y-Z projection.
603
631
 
604
632
  """
605
- wl = self.get_cs_waterlevel(h=h, sz=True)
633
+ wl = self.get_cs_waterlevel(
634
+ h=h, sz=True, extend_by=0.1
635
+ ) # extend a small bit to guarantee crossing with the bottom coordinates
636
+ zl = wl.xy[1][0]
606
637
  # create polygon by making a union
607
- pol = list(polygonize(wl.union(self.cs_linestring_sz)))
638
+ bottom_points = self.cs_points_sz
639
+ # add a point left and right slightly above the level if the level is below the water level
640
+ if bottom_points[0].y < zl:
641
+ bottom_points.insert(0, geometry.Point(bottom_points[0].x, zl + 0.1))
642
+ if bottom_points[-1].y < zl:
643
+ bottom_points.append(geometry.Point(bottom_points[-1].x, zl + 0.1))
644
+ bottom_line = geometry.LineString(bottom_points)
645
+ pol = list(polygonize(wl.union(bottom_line)))
608
646
  if len(pol) == 0:
609
- raise ValueError("Water level is not crossed by cross section and therefore undefined.")
647
+ # create infinitely small polygon at lowest z coordinate
648
+ lowest_z = min(self.z)
649
+ lowest_s = self.s[list(self.z).index(lowest_z)]
650
+ # make an infinitely small polygon around the lowest point in the cross section
651
+ pol = [geometry.Polygon([(lowest_s, lowest_z)] * 3)]
610
652
  elif len(pol) > 1:
611
- raise ValueError("Water level is crossed by multiple polygons.")
612
- else:
613
- pol = pol[0]
614
- return pol
653
+ # detect which polygons have their average z coordinate below the defined water level
654
+ pol = [p for p in pol if p.centroid.xy[1][0] < zl]
655
+ # raise ValueError("Water level is crossed by multiple polygons.")
656
+ # else:
657
+ # pol = pol[0]
658
+ return geometry.MultiPolygon(pol)
615
659
 
616
660
  def get_wetted_surface(self, h: float, camera: bool = False, swap_y_coords=False) -> geometry.Polygon:
617
661
  """Retrieve a wetted surface for a given water level, as a geometry.Polygon.
@@ -633,13 +677,16 @@ class CrossSection:
633
677
 
634
678
 
635
679
  """
636
- pol = self.get_wetted_surface_sz(h=h)
637
- coords = [[self.interp_x_from_s(p[0]), self.interp_y_from_s(p[0]), p[1]] for p in pol.exterior.coords]
638
- if camera:
639
- coords_proj = self.camera_config.project_points(coords, swap_y_coords=swap_y_coords)
640
- return geometry.Polygon(coords_proj)
641
- else:
642
- return geometry.Polygon(coords)
680
+ pols = self.get_wetted_surface_sz(h=h)
681
+ pols_proj = []
682
+ for pol in pols.geoms:
683
+ coords = [[self.interp_x_from_s(p[0]), self.interp_y_from_s(p[0]), p[1]] for p in pol.exterior.coords]
684
+ if camera:
685
+ coords_proj = self.camera_config.project_points(coords, swap_y_coords=swap_y_coords)
686
+ pols_proj.append(geometry.Polygon(coords_proj))
687
+ else:
688
+ pols_proj.append(geometry.Polygon(coords))
689
+ return geometry.MultiPolygon(pols_proj)
643
690
 
644
691
  def get_line_of_interest(self, bank: BANK_OPTIONS = "far") -> List[float]:
645
692
  """Retrieve the points of interest within the cross-section for water level detection.
@@ -881,12 +928,20 @@ class CrossSection:
881
928
  plt.axes
882
929
 
883
930
  """
884
- surf = self.get_planar_surface(h=h, length=length, offset=offset, swap_y_coords=swap_y_coords, camera=camera)
885
- if camera:
886
- p = plot_helpers.plot_polygon(surf, ax=ax, label="surface", **kwargs)
887
- else:
888
- p = plot_helpers.plot_3d_polygon(surf, ax=ax, label="surface", **kwargs)
889
- return p.axes
931
+ try:
932
+ surf = self.get_planar_surface(
933
+ h=h, length=length, offset=offset, swap_y_coords=swap_y_coords, camera=camera
934
+ )
935
+ if camera:
936
+ p = plot_helpers.plot_polygon(surf, ax=ax, label="surface", **kwargs)
937
+ else:
938
+ p = plot_helpers.plot_3d_polygon(surf, ax=ax, label="surface", **kwargs)
939
+ return p.axes
940
+ except Exception:
941
+ warnings.warn(
942
+ "Cannot plot planar surface as there are too many crossings",
943
+ stacklevel=2,
944
+ )
890
945
 
891
946
  def plot_bottom_surface(
892
947
  self, length: float = 2.0, offset: float = 0.0, camera: bool = False, ax=None, swap_y_coords=False, **kwargs
@@ -184,16 +184,26 @@ def _base_plot(plot_func):
184
184
  planar=False,
185
185
  bottom=False,
186
186
  )
187
- ref._obj.transect.cross_section.plot_water_level(
188
- h=ref._obj.transect.h_a,
189
- length=2.0,
190
- linewidth=3.0,
191
- ax=ax,
192
- camera=True,
193
- swap_y_coords=True,
194
- color="r",
195
- 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()
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()
196
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
+ )
197
207
 
198
208
  # draw some depth lines for better visual interpretation.
199
209
  depth_lines = ref._obj.transect.get_depth_perspective(h=ref._obj.transect.h_a)
@@ -329,20 +329,8 @@ def read_shape_as_gdf(fn=None, geojson=None, gdf=None):
329
329
  crs = None
330
330
  gdf = gpd.GeoDataFrame().from_features(geojson, crs=crs)
331
331
  else:
332
- gdf = gpd.read_file(fn)
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)
332
+ gdf = helpers.read_shape_safe_crs(fn)
333
+ crs = gdf.crs
346
334
  # check if all geometries are points
347
335
  assert all([isinstance(geom, Point) for geom in gdf.geometry]), (
348
336
  "shapefile may only contain geometries of type " '"Point"'
@@ -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
 
@@ -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
 
@@ -9,7 +9,6 @@ import subprocess
9
9
  from typing import Dict, Optional
10
10
 
11
11
  import click
12
- import geopandas as gpd
13
12
  import numpy as np
14
13
  import xarray as xr
15
14
  import yaml
@@ -19,6 +18,7 @@ from matplotlib.colors import Normalize
19
18
  import pyorc
20
19
  from pyorc import CameraConfig, CrossSection, Video, const
21
20
  from pyorc.cli import cli_utils
21
+ from pyorc.helpers import read_shape_safe_crs
22
22
 
23
23
  __all__ = ["velocity_flow", "velocity_flow_subprocess"]
24
24
 
@@ -287,7 +287,7 @@ class VelocityFlowProcessor(object):
287
287
  "Cross section for water level detection provided, and no water level set, "
288
288
  " water level will be estimated optically."
289
289
  )
290
- gdf = gpd.read_file(cross)
290
+ gdf = read_shape_safe_crs(cross_wl)
291
291
  cross_section_wl = pyorc.CrossSection(camera_config=camera_config, cross_section=gdf)
292
292
  if "water_level" not in recipe:
293
293
  # make sure water_level is represented
File without changes
File without changes