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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyopenrivercam
3
- Version: 0.8.6
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
@@ -78,7 +78,8 @@ width=100 align="right">
78
78
 
79
79
  [![PyPI](https://badge.fury.io/py/pyopenrivercam.svg)](https://pypi.org/project/pyopenrivercam)
80
80
  [![Conda-Forge](https://anaconda.org/conda-forge/pyopenrivercam/badges/version.svg)](https://anaconda.org/conda-forge/pyopenrivercam)
81
- [![codecov](https://codecov.io/gh/localdevices/pyorc/branch/main/graph/badge.svg?token=0740LBNK6J)](https://codecov.io/gh/localdevices/pyorc)
81
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=localdevices_pyorc&metric=coverage)](https://sonarcloud.io/summary/new_code?id=localdevices_pyorc)
82
+ [![python](https://img.shields.io/pypi/pyversions/pyopenrivercam?color=%2376519B)](https://pypi.org/project/pyopenrivercam/)
82
83
  [![docs_latest](https://img.shields.io/badge/docs-latest-brightgreen.svg)](https://localdevices.github.io/pyorc/latest)
83
84
  [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/localdevices/pyorc.git/main?labpath=examples)
84
85
  [![License](https://img.shields.io/github/license/localdevices/pyorc?style=flat)](https://github.com/localdevices/pyorc/blob/main/LICENSE)
@@ -107,7 +108,6 @@ We are seeking funding for the following frequently requested functionalities:
107
108
  * Exports to simple text formats and GIS-compatible layers
108
109
  * Exports to augmented reality videos
109
110
  * Implementation of additional processing algorithms (STIV and LSPTV)
110
- * Implementation of several optical methods for reading water levels
111
111
  * Improved nighttime / poor weather conditions processing through learning approaches
112
112
 
113
113
  If you wish to fund this or other work on features, please contact us at info@rainbowsensing.com.
@@ -156,7 +156,7 @@ dependencies as follows:
156
156
  pip install pyopenrivercam[extra]
157
157
  ```
158
158
  The `[extra]` section ensures that also geographical plotting is supported, which we recommend especially for the
159
- set up of a camera configuration.
159
+ set up of a camera configuration with RTK-GPS measured control points.
160
160
 
161
161
  ### Upgrading from pypi with pip
162
162
 
@@ -1,34 +1,34 @@
1
- pyorc/__init__.py,sha256=wbzL3QPC8KrCa1QKwZs-oKJ3yeUhfDa8wLQMsRoKwyk,523
1
+ pyorc/__init__.py,sha256=xynVJQMf94pSaM-GTlHZAGLw-D2gujyIx5MfrPiz4KQ,523
2
2
  pyorc/const.py,sha256=Ia0KRkm-E1lJk4NxQVPDIfN38EBB7BKvxmwIHJrGPUY,2597
3
- pyorc/cv.py,sha256=qhz0y03k3CwMxFChodGW8kIcJNvWYJ4vFvQLj16JAr0,45841
4
- pyorc/helpers.py,sha256=2HN9_NQ5wp1xVtHFcFm0Ri7mwAKYT8jlmWLQ45Xs9GY,29871
5
- pyorc/plot_helpers.py,sha256=i6pcZHfpGCMkPNHWSkoE0N9-nuKfqXR7V5wgdT184IY,1274
3
+ pyorc/cv.py,sha256=CTv0TbbcKeSQmKsX8mdVDXpSkhKZmr8SgT20YXMvZ0s,49156
4
+ pyorc/helpers.py,sha256=90TDtka0ydAydv3g5Dfc8MgtuSt0_9D9-HOtffpcBds,30636
5
+ pyorc/plot_helpers.py,sha256=gLKslspsF_Z4jib5jkBv2wRjKnHTbuRFgkp_PCmv-uU,1803
6
6
  pyorc/project.py,sha256=CGKfICkQEpFRmh_ZeDEfbQ-wefJt7teWJd6B5IPF038,7747
7
7
  pyorc/pyorc.sh,sha256=-xOSUNnMAwVbdNkjKNKMZMaBljWsGLhadG-j0DNlJP4,5
8
8
  pyorc/sample_data.py,sha256=53NVnVmEksDw8ilbfhFFCiFJiGAIpxdgREbA_xt8P3o,2508
9
9
  pyorc/api/__init__.py,sha256=k2OQQH4NrtXTuVm23d0g_SX6H5DhnKC9_kDyzJ4dWdk,428
10
- pyorc/api/cameraconfig.py,sha256=IyhaX5IZHOwY_CMjVQiYTKUWLyzpGXFfY_tT3aV1y8c,60979
11
- pyorc/api/cross_section.py,sha256=p8DMFIAuxMzF6_9CYOtN4Fn25L1XAwslKJj5ovxouuw,46935
12
- pyorc/api/frames.py,sha256=u0ZUgs-DKdWTUTPMrpMrHtV5VMeCfL3h5SKTfn88wsk,25754
10
+ pyorc/api/cameraconfig.py,sha256=NP9F7LhPO3aO6FRWkrGl6XpX8O3K59zfTtaYR3Kujqw,65419
11
+ pyorc/api/cross_section.py,sha256=un7_VFHMOpBM8FE7lQnZIsaxidnABzFWlyaDHIUfzoA,52039
12
+ pyorc/api/frames.py,sha256=QJfcftmh47nClw5yGsMULdJXEsAVzucseiLb4LbpVJU,23671
13
13
  pyorc/api/mask.py,sha256=HVag3RkMu4ZYQg_pIZFhiJYkBGYLVBxeefdmWvFTR-4,14371
14
14
  pyorc/api/orcbase.py,sha256=C23QTKOyxHUafyJsq_t7xn_BzAEvf4DDfzlYAopons8,4189
15
- pyorc/api/plot.py,sha256=979O3ZukbOeTKCPz8U_1BeYVmT09Chsw1OGnw2KF5zA,27797
15
+ pyorc/api/plot.py,sha256=Aa-t_9aL7ILDj_JIbXpGjwNn9ZkmonsDApwb3w-xwWY,30564
16
16
  pyorc/api/transect.py,sha256=KU0ZW_0NqYD4jeDxvuWJi7X06KqrcgO9afP7QmWuixA,14162
17
17
  pyorc/api/velocimetry.py,sha256=bfU_XPbUbrdBI2XGprzh_3YADbGHfy4OuS1oBlbLEEI,12047
18
18
  pyorc/api/video.py,sha256=lGD6bcV6Uu2u3zuGF_m3KxX2Cyp9k-YHUiXA42TOE3E,22458
19
19
  pyorc/cli/__init__.py,sha256=A7hOQV26vIccPnDc8L2KqoJOSpMpf2PiMOXS18pAsWg,32
20
20
  pyorc/cli/cli_elements.py,sha256=zX9wv9-1KWC_E3cInGMm3g9jh4uXmT2NqooAMhhXR9s,22165
21
- pyorc/cli/cli_utils.py,sha256=uQ0I8wRMSJBOTvxxxMXSizDR7Qmn1KeaQwUHHhaQxU0,15090
21
+ pyorc/cli/cli_utils.py,sha256=S7qOO4bintxXDSUl26u3Ujqu4JHb_TNhw5d6psyDrFo,15085
22
22
  pyorc/cli/log.py,sha256=Vg8GznmrEPqijfW6wv4OCl8R00Ld_fVt-ULTitaDijY,2824
23
- pyorc/cli/main.py,sha256=YtS_st119N-SFpRJDPv2O4ttBGhKRfH_DQSl3OZkNlA,12358
23
+ pyorc/cli/main.py,sha256=qhAZkUuAViCpHh9c19tpcpbs_xoZJkYHhOsEXJBFXfM,12742
24
24
  pyorc/service/__init__.py,sha256=vPrzFlZ4e_GjnibwW6-k8KDz3b7WpgmGcwSDk0mr13Y,55
25
25
  pyorc/service/camera_config.py,sha256=OsRLpe5jd-lu6HT4Vx5wEg554CMS-IKz-q62ir4VbPo,2375
26
- pyorc/service/velocimetry.py,sha256=bPEuy6TOpC22OY1E8gNnKSL0diF5ja2FtwNsj3-NdFg,29268
26
+ pyorc/service/velocimetry.py,sha256=UFjxmq5Uhk8wnBLScAyTaVWTPTCnH9hJdKOYBFrGZ_Y,33288
27
27
  pyorc/velocimetry/__init__.py,sha256=lYM7oJZWxgJ2SpE22xhy7pBYcgkKFHMBHYmDvvMbtZk,148
28
28
  pyorc/velocimetry/ffpiv.py,sha256=MW_6fQ0vxRTA-HYwncgeWHGWiUQFSmM4unYxT7EfnEI,7372
29
29
  pyorc/velocimetry/openpiv.py,sha256=6BxsbXLzT4iEq7v08G4sOhVlYFodUpY6sIm3jdCxNMs,13149
30
- pyopenrivercam-0.8.6.dist-info/entry_points.txt,sha256=Cv_WI2Y6QLnPiNCXGli0gS4WAOAeMoprha1rAR3vdRE,44
31
- pyopenrivercam-0.8.6.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
32
- pyopenrivercam-0.8.6.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
33
- pyopenrivercam-0.8.6.dist-info/METADATA,sha256=xwryCN_ptr7C_o59bbD8NiXqv3ob549hIlgUYWatCRc,11513
34
- pyopenrivercam-0.8.6.dist-info/RECORD,,
30
+ pyopenrivercam-0.8.8.dist-info/entry_points.txt,sha256=Cv_WI2Y6QLnPiNCXGli0gS4WAOAeMoprha1rAR3vdRE,44
31
+ pyopenrivercam-0.8.8.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
32
+ pyopenrivercam-0.8.8.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
33
+ pyopenrivercam-0.8.8.dist-info/METADATA,sha256=Ww1gmQfOwPQAcV2ZSbhZhNcVPgzNMK1iTq85VoRyfTE,11633
34
+ pyopenrivercam-0.8.8.dist-info/RECORD,,
pyorc/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """pyorc: free and open-source image-based surface velocity and discharge."""
2
2
 
3
- __version__ = "0.8.6"
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
pyorc/api/cameraconfig.py CHANGED
@@ -115,6 +115,8 @@ class CameraConfig:
115
115
  self.height = height
116
116
  self.width = width
117
117
  self.is_nadir = is_nadir
118
+ self.camera_matrix = camera_matrix
119
+ self.dist_coeffs = dist_coeffs
118
120
  self.rvec = rvec
119
121
  self.tvec = tvec
120
122
  if crs is not None:
@@ -132,19 +134,14 @@ class CameraConfig:
132
134
  self.lens_position = None
133
135
  if gcps is not None:
134
136
  self.set_gcps(**gcps)
135
- if camera_matrix is None or dist_coeffs is None:
136
- if self.is_nadir:
137
- # with nadir, no perspective can be constructed, hence, camera matrix and dist coeffs will be set
138
- # to default values
139
- self.camera_matrix = cv.get_cam_mtx(self.height, self.width)
140
- self.dist_coeffs = cv.DIST_COEFFS
141
- # camera pars are incomplete and need to be derived
142
- else:
143
- self.set_intrinsic(camera_matrix=camera_matrix)
137
+ if self.is_nadir:
138
+ # with nadir, no perspective can be constructed, hence, camera matrix and dist coeffs will be set
139
+ # to default values
140
+ self.camera_matrix = cv.get_cam_mtx(self.height, self.width)
141
+ self.dist_coeffs = cv.DIST_COEFFS
142
+ # camera pars are incomplete and need to be derived
144
143
  else:
145
- # camera matrix and dist coeffs can also be set hard
146
- self.camera_matrix = camera_matrix
147
- self.dist_coeffs = dist_coeffs
144
+ self.calibrate()
148
145
  if calibration_video is not None:
149
146
  self.set_lens_calibration(calibration_video, plot=False)
150
147
  if bbox is not None:
@@ -324,12 +321,12 @@ class CameraConfig:
324
321
  tvec_cam += self.gcps_mean
325
322
  # transform back to world
326
323
  rvec, tvec = cv.pose_world_to_camera(rvec_cam, tvec_cam)
327
- return _, rvec, tvec
324
+ return rvec, tvec
328
325
 
329
326
  @property
330
327
  def rvec(self):
331
328
  """Return rvec from precise N point solution."""
332
- return self.pnp[1].tolist() if self._rvec is None else self._rvec
329
+ return self.pnp[0].tolist() if self._rvec is None else self._rvec
333
330
 
334
331
  @rvec.setter
335
332
  def rvec(self, _rvec):
@@ -399,11 +396,11 @@ class CameraConfig:
399
396
  @property
400
397
  def tvec(self):
401
398
  """Return tvec from precise N point solution."""
402
- return self.pnp[2].tolist() if self._tvec is None else self._tvec
399
+ return self.pnp[1].tolist() if self._tvec is None else self._tvec
403
400
 
404
401
  @tvec.setter
405
402
  def tvec(self, _tvec):
406
- self._tvec = _tvec.tolist if isinstance(_tvec, np.ndarray) else _tvec
403
+ self._tvec = _tvec.tolist() if isinstance(_tvec, np.ndarray) else _tvec
407
404
 
408
405
  def set_lens_calibration(
409
406
  self,
@@ -629,6 +626,17 @@ class CameraConfig:
629
626
  dist_wall = (dist_shore**2 + depth**2) ** 0.5
630
627
  return dist_wall
631
628
 
629
+ def get_extrinsic(self):
630
+ """Return rotation and translation vector based on control points and intrinsic parameters."""
631
+ # solve rvec and tvec with reduced coordinates, this ensure that the solvepnp solution is stable.
632
+ _, rvec, tvec = cv.solvepnp(self.gcps_reduced, self.gcps["src"], self.camera_matrix, self.dist_coeffs)
633
+ # ensure that rvec and tvec are corrected for the fact that mean gcp location was subtracted
634
+ rvec_cam, tvec_cam = cv.pose_world_to_camera(rvec, tvec)
635
+ tvec_cam += self.gcps_mean
636
+ # transform back to world
637
+ rvec, tvec = cv.pose_world_to_camera(rvec_cam, tvec_cam)
638
+ return rvec, tvec
639
+
632
640
  def z_to_h(self, z: float) -> float:
633
641
  """Convert z coordinates of bathymetry to height coordinates in local reference (e.g. staff gauge).
634
642
 
@@ -861,39 +869,129 @@ class CameraConfig:
861
869
  f"a list of lists of 4 coordinates must be given, resulting in (4, "
862
870
  f"2) shape. Current shape is {corners.shape} "
863
871
  )
872
+ assert self.gcps["z_0"] is not None, "The water level must be set before the bounding box can be established."
864
873
 
865
874
  # get homography
866
875
  corners_xyz = self.unproject_points(corners, np.ones(4) * self.gcps["z_0"])
867
876
  bbox = cv.get_aoi(corners_xyz, resolution=self.resolution)
868
877
  self.bbox = bbox
869
878
 
870
- def set_intrinsic(
871
- self,
872
- camera_matrix: Optional[List[List]] = None,
873
- dist_coeffs: Optional[List[List]] = None,
874
- ):
875
- """Set lens and distortion parameters.
879
+ def set_bbox_from_width_length(self, points: List[List[float]]):
880
+ """Establish bbox based on three provided points.
881
+
882
+ The points are provided in the original camera perspective as [col, row] and require that a water level
883
+ has already been set in order to project them in a feasible way.
876
884
 
877
- If not provided, they are derived by optimizing pnp fitting together with optimizing the focal length.
885
+ first point : left bank (seen in downstream direction)
886
+ second point : right bank
887
+ third point : selected upstream or downstream of the two points.
888
+
889
+ The last point defines how large the bounding box is in up-and-downstream direction. A user should attempt to
890
+ choose the first two points roughly in the middle of the intended bounding box. The last point is then
891
+ used to estimate the length perpendicular to the line between the first two points. The bounding box is
892
+ extended in both directions with the same length.
878
893
 
879
894
  Parameters
880
895
  ----------
881
- camera_matrix : Optional[List[List]]
882
- A defined camera matrix to set as intrinsic parameters. If not provided, it will use default values or
883
- those derived from ground control points (GCPs) if available.
896
+ points : list of lists (3)
897
+ [columns, row] coordinates in original camera perspective without any undistortion applied
898
+
899
+ """
900
+ assert np.array(points).shape == (3, 2), (
901
+ f"a list of lists of 3 coordinates must be given, resulting in (3, "
902
+ f"2) shape. Current shape is {np.array(points).shape} "
903
+ )
904
+ assert self.gcps["z_0"] is not None, "The water level must be set before the bounding box can be established."
905
+ # get homography
906
+ points_xyz = self.unproject_points(points, np.ones(3) * self.gcps["z_0"])
907
+ bbox = cv.get_aoi(points_xyz, resolution=self.resolution, method="width_length")
908
+ self.bbox = bbox
884
909
 
885
- dist_coeffs : Optional[List[List]]
886
- Distortion coefficients to be used for the camera. If not provided, it will use default values or those
887
- derived from GCPs if available.
910
+ def rotate_translate_bbox(self, angle: float = None, xoff: float = None, yoff: float = None):
911
+ """Rotate and translate the bounding box.
912
+
913
+ Parameters
914
+ ----------
915
+ angle : float, optional
916
+ Rotation angle in radians (anti-clockwise) around the center of the bounding box
917
+ xoff : float, optional
918
+ Translation distance in x direction in CRS units
919
+ yoff : float, optional
920
+ Translation distance in y direction in CRS units
921
+
922
+ Returns
923
+ -------
924
+ CameraConfig
925
+ New CameraConfig instance with rotated and translated bounding box
888
926
 
889
927
  """
890
- if camera_matrix is not None and dist_coeffs is not None:
891
- # both are provided by user, so no fitting needed
892
- self.camera_matrix = camera_matrix
893
- self.dist_coeffs = dist_coeffs
894
- return
928
+ # Make a deep copy of current config
929
+ new_config = copy.deepcopy(self)
930
+
931
+ # Get the current bbox
932
+ bbox = new_config.bbox
933
+ if bbox is None:
934
+ return new_config
935
+
936
+ # Apply rotation if specified
937
+ if angle is not None:
938
+ print(angle)
939
+ # # Convert to radians
940
+ # angle = np.radians(rotation)
941
+ # Get centroid as origin
942
+ centroid = bbox.centroid
943
+ # Apply rotation around centroid
944
+ bbox = shapely.affinity.rotate(
945
+ bbox,
946
+ angle,
947
+ origin=centroid,
948
+ use_radians=True,
949
+ )
895
950
 
896
- if hasattr(self, "gcps"):
951
+ # Now perform translation. Get coordinates of corners
952
+ coords = list(bbox.exterior.coords)
953
+
954
+ # Get unit vectors of x and y directions
955
+ p1 = np.array(coords[0])
956
+ p2 = np.array(coords[1]) # second point
957
+ p3 = np.array(coords[2]) # third point
958
+
959
+ x_vec = p2 - p1
960
+ y_vec = p3 - p2
961
+
962
+ x_vec = x_vec / np.linalg.norm(x_vec)
963
+ y_vec = y_vec / np.linalg.norm(y_vec)
964
+ # Project translations onto these vectors
965
+ dx = 0 if xoff is None else xoff * x_vec[0]
966
+ dy = 0 if xoff is None else xoff * x_vec[1]
967
+
968
+ dx -= 0 if yoff is None else yoff * y_vec[0]
969
+ dy -= 0 if yoff is None else yoff * y_vec[1]
970
+
971
+ # Apply translation
972
+ bbox = shapely.affinity.translate(bbox, xoff=dx, yoff=dy)
973
+ new_config.bbox = bbox
974
+ return new_config
975
+
976
+ def calibrate(
977
+ self,
978
+ ):
979
+ """Calibrate camera parameters using ground control.
980
+
981
+ If nothing provided, they are derived by optimizing pnp fitting together with optimizing the focal length
982
+ and two radial distortion coefficients (k1, k2).
983
+
984
+ You may also provide camera matrix or distortion coefficients, which will only optimize
985
+ the remainder parameters.
986
+
987
+ As a result, the following is updated on the CameraConfig instance:
988
+ - camera_matrix: the 3x3 camera matrix
989
+ - dist_coeffs: the 5x1 distortion coefficients
990
+ - rvec: the 3x1 rotation vector
991
+ - tvec: the 3x1 translation vector
992
+ """
993
+ if hasattr(self, "gcps") and (self.camera_matrix is None or self.dist_coeffs is None):
994
+ # some calibration is needed, and there are GCPs available for it
897
995
  if len(self.gcps["src"]) >= 4:
898
996
  self.camera_matrix, self.dist_coeffs, err = cv.optimize_intrinsic(
899
997
  self.gcps["src"],
@@ -902,9 +1000,14 @@ class CameraConfig:
902
1000
  self.height,
903
1001
  self.width,
904
1002
  lens_position=self.lens_position,
905
- camera_matrix=camera_matrix,
906
- dist_coeffs=dist_coeffs,
1003
+ camera_matrix=self.camera_matrix,
1004
+ dist_coeffs=self.dist_coeffs,
907
1005
  )
1006
+ # finally, also derive the rvec and tvec if camera matrix and distortion coefficients are known
1007
+ if self.camera_matrix is not None and self.dist_coeffs is not None:
1008
+ rvec, tvec = self.get_extrinsic()
1009
+ self.rvec = rvec
1010
+ self.tvec = tvec
908
1011
 
909
1012
  def set_gcps(
910
1013
  self, src: List[List], dst: List[List], z_0: float, h_ref: Optional[float] = None, crs: Optional[Any] = None
@@ -146,12 +146,14 @@ 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
- x, y, z = g.x, g.y, g.z
156
+ x, y, z = g.x.values, g.y.values, g.z.values
155
157
  else:
156
158
  x, y, z = list(map(list, zip(*cross_section)))
157
159
 
@@ -244,17 +246,33 @@ class CrossSection:
244
246
  diff_xy = np.array(point2_xy) - np.array(point1_xy)
245
247
  return np.arctan2(diff_xy[1], diff_xy[0])
246
248
 
249
+ @property
250
+ def distance_camera(self):
251
+ """Estimate distance of mean coordinate of cross section to camera position."""
252
+ coord_mean = np.mean(self.cs_linestring.coords, axis=0)
253
+ return np.sum((self.camera_config.estimate_lens_position() - coord_mean) ** 2) ** 0.5
254
+
247
255
  @property
248
256
  def idx_closest_point(self):
249
257
  """Determine index of point in cross-section, closest to the camera."""
250
- return self.d.argmin()
258
+ return 0 if self.d[0] < self.d[-1] else len(self.d) - 1
251
259
 
252
260
  @property
253
261
  def idx_farthest_point(self):
254
262
  """Determine index of point in cross-section, farthest from the camera."""
255
- return self.d.argmax()
263
+ return 0 if self.d[0] > self.d[-1] else len(self.d) - 1
256
264
 
257
- def get_cs_waterlevel(self, h: float, sz=False) -> geometry.LineString:
265
+ @property
266
+ def within_image(self):
267
+ """Check if any of the points of the cross section fall inside the image objective."""
268
+ # check if cross section is visible within the image objective
269
+ pix = self.camera_config.project_points(np.array(list(map(list, self.cs_linestring.coords))), within_image=True)
270
+ # check which points fall within the image objective
271
+ within_image = np.all([pix[:, 0] >= 0, pix[:, 0] < 1920, pix[:, 1] >= 0, pix[:, 1] < 1080], axis=0)
272
+ # check if there are any points within the image objective and return result
273
+ return bool(np.any(within_image))
274
+
275
+ def get_cs_waterlevel(self, h: float, sz=False, extend_by=None) -> geometry.LineString:
258
276
  """Retrieve LineString of water surface at cross-section at a given water level.
259
277
 
260
278
  Parameters
@@ -263,18 +281,44 @@ class CrossSection:
263
281
  water level [m]
264
282
  sz : bool, optional
265
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
266
286
 
267
287
  Returns
268
288
  -------
269
289
  geometry.LineString
270
- 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)
271
291
 
272
292
  """
273
293
  # get water level in camera config vertical datum
274
294
  z = self.camera_config.h_to_z(h)
275
295
  if sz:
276
- return geometry.LineString(zip(self.s, [z] * len(self.s)))
277
- 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)))
278
322
 
279
323
  def get_csl_point(
280
324
  self, h: Optional[float] = None, l: Optional[float] = None, camera: bool = False, swap_y_coords: bool = False
@@ -586,16 +630,32 @@ class CrossSection:
586
630
  Wetted surface as a polygon, in Y-Z projection.
587
631
 
588
632
  """
589
- 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]
590
637
  # create polygon by making a union
591
- 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)))
592
646
  if len(pol) == 0:
593
- 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)]
594
652
  elif len(pol) > 1:
595
- raise ValueError("Water level is crossed by multiple polygons.")
596
- else:
597
- pol = pol[0]
598
- 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)
599
659
 
600
660
  def get_wetted_surface(self, h: float, camera: bool = False, swap_y_coords=False) -> geometry.Polygon:
601
661
  """Retrieve a wetted surface for a given water level, as a geometry.Polygon.
@@ -617,13 +677,16 @@ class CrossSection:
617
677
 
618
678
 
619
679
  """
620
- pol = self.get_wetted_surface_sz(h=h)
621
- coords = [[self.interp_x_from_s(p[0]), self.interp_y_from_s(p[0]), p[1]] for p in pol.exterior.coords]
622
- if camera:
623
- coords_proj = self.camera_config.project_points(coords, swap_y_coords=swap_y_coords)
624
- return geometry.Polygon(coords_proj)
625
- else:
626
- 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)
627
690
 
628
691
  def get_line_of_interest(self, bank: BANK_OPTIONS = "far") -> List[float]:
629
692
  """Retrieve the points of interest within the cross-section for water level detection.
@@ -670,18 +733,26 @@ class CrossSection:
670
733
  offset: float = 0.0,
671
734
  padding: float = 0.5,
672
735
  length: float = 2.0,
736
+ min_z: Optional[float] = None,
737
+ max_z: Optional[float] = None,
673
738
  min_samples: int = 50,
674
739
  ):
675
740
  """Retrieve a histogram score for a given l-value."""
676
741
  l = x[0]
677
- # print(l)
742
+ if min_z is not None:
743
+ if self.interp_z(l) < min_z:
744
+ # return worst score
745
+ return 2.0 + np.abs(self.interp_z(l) - min_z)
746
+ elif max_z is not None:
747
+ if self.interp_z(l) > max_z:
748
+ return 2.0 + np.abs(self.interp_z(l) - max_z)
678
749
  pol1 = self.get_csl_pol(l=l, offset=offset, padding=(0, padding), length=length, camera=True)[0]
679
750
  pol2 = self.get_csl_pol(l=l, offset=offset, padding=(-padding, 0), length=length, camera=True)[0]
680
751
  # get intensity values within polygons
681
752
  ints1 = cv.get_polygon_pixels(img, pol1)
682
753
  ints2 = cv.get_polygon_pixels(img, pol2)
683
754
  if ints1.size < min_samples or ints2.size < min_samples:
684
- # return a strong penalty score value
755
+ # return a strong penalty score value if there are too few samples
685
756
  return 2.0
686
757
  _, _, norm_counts1 = _histogram(ints1, normalize=True, bin_size=bin_size)
687
758
  bin_centers, bin_edges, norm_counts2 = _histogram(ints2, normalize=True, bin_size=bin_size)
@@ -857,12 +928,20 @@ class CrossSection:
857
928
  plt.axes
858
929
 
859
930
  """
860
- surf = self.get_planar_surface(h=h, length=length, offset=offset, swap_y_coords=swap_y_coords, camera=camera)
861
- if camera:
862
- p = plot_helpers.plot_polygon(surf, ax=ax, label="surface", **kwargs)
863
- else:
864
- p = plot_helpers.plot_3d_polygon(surf, ax=ax, label="surface", **kwargs)
865
- 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
+ )
866
945
 
867
946
  def plot_bottom_surface(
868
947
  self, length: float = 2.0, offset: float = 0.0, camera: bool = False, ax=None, swap_y_coords=False, **kwargs
@@ -1003,6 +1082,10 @@ class CrossSection:
1003
1082
  length: float = 2.0,
1004
1083
  padding: float = 0.5,
1005
1084
  offset: float = 0.0,
1085
+ min_h: Optional[float] = None,
1086
+ max_h: Optional[float] = None,
1087
+ min_z: Optional[float] = None,
1088
+ max_z: Optional[float] = None,
1006
1089
  ) -> float:
1007
1090
  """Detect water level optically from provided image.
1008
1091
 
@@ -1030,11 +1113,32 @@ class CrossSection:
1030
1113
  left and right of hypothesized water line at -padding and +padding.
1031
1114
  offset : float, optional
1032
1115
  perpendicular offset of the waterline from the cross-section [m], by default 0.0
1116
+ min_h : float, optional
1117
+ minimum water level to try detection [m]. If not provided, the minimum water level is taken from the
1118
+ cross section.
1119
+ max_h : float, optional
1120
+ maximum water level to try detection [m]. If not provided, the maximum water level is taken from the
1121
+ cross section.
1122
+ min_z : float, optional
1123
+ same as min_h but using z-coordinates instead of local datum, min_z overrules min_h
1124
+ max_z : float, optional
1125
+ same as max_z but using z-coordinates instead of local datum, max_z overrules max_h
1033
1126
 
1034
1127
  """
1035
- """Attempt to detect the water line level along the cross-section, using a provided pre-treated image."""
1128
+ if min_z is None:
1129
+ if min_h is not None:
1130
+ min_z = self.camera_config.h_to_z(min_h)
1131
+ min_z = np.maximum(min_z, self.z.min())
1132
+ if max_z is None:
1133
+ if max_h is not None:
1134
+ max_z = self.camera_config.h_to_z(max_h)
1135
+ max_z = np.minimum(max_z, self.z.max())
1136
+ if min_z and max_z:
1137
+ if min_z > max_z:
1138
+ raise ValueError("Minimum water level is higher than maximum water level.")
1139
+
1036
1140
  if len(img.shape) == 3:
1037
- # flatten image first
1141
+ # flatten image first if it his a time dimension
1038
1142
  img = img.mean(axis=2)
1039
1143
  assert (
1040
1144
  img.shape[0] == self.camera_config.height
@@ -1043,12 +1147,13 @@ class CrossSection:
1043
1147
  img.shape[1] == self.camera_config.width
1044
1148
  ), f"Image width {img.shape[1]} is not the same as camera_config width {self.camera_config.width}"
1045
1149
  # determine the relevant start point if only one is used
1150
+ # import pdb;pdb.set_trace()
1046
1151
  l_min, l_max = self.get_line_of_interest(bank=bank)
1047
1152
  opt = differential_evolution(
1048
1153
  self.get_histogram_score,
1049
1154
  popsize=50,
1050
1155
  bounds=[(l_min, l_max)],
1051
- args=(img, bin_size, offset, padding, length),
1156
+ args=(img, bin_size, offset, padding, length, min_z, max_z),
1052
1157
  atol=0.01, # one mm
1053
1158
  )
1054
1159
  z = self.interp_z(opt.x[0])