pyopenrivercam 0.8.10__py3-none-any.whl → 0.8.12__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.10
3
+ Version: 0.8.12
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
- pyorc/__init__.py,sha256=ZeURmlD1_OTTOXcunlmrtV0H9un_mP9Q4Cr0NlSU-uo,524
1
+ pyorc/__init__.py,sha256=osrK3G2RA5IsF1ka-QHSPa7gXf7JCXQuUWK8RnR1I1A,524
2
2
  pyorc/const.py,sha256=Ia0KRkm-E1lJk4NxQVPDIfN38EBB7BKvxmwIHJrGPUY,2597
3
- pyorc/cv.py,sha256=CTv0TbbcKeSQmKsX8mdVDXpSkhKZmr8SgT20YXMvZ0s,49156
3
+ pyorc/cv.py,sha256=fXGqT8vBn9-z6UxS5ho7thP9VQll9RrYHJW5KnUJQjo,50250
4
4
  pyorc/helpers.py,sha256=90TDtka0ydAydv3g5Dfc8MgtuSt0_9D9-HOtffpcBds,30636
5
5
  pyorc/plot_helpers.py,sha256=gLKslspsF_Z4jib5jkBv2wRjKnHTbuRFgkp_PCmv-uU,1803
6
6
  pyorc/project.py,sha256=CGKfICkQEpFRmh_ZeDEfbQ-wefJt7teWJd6B5IPF038,7747
@@ -8,12 +8,12 @@ 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
10
  pyorc/api/cameraconfig.py,sha256=NP9F7LhPO3aO6FRWkrGl6XpX8O3K59zfTtaYR3Kujqw,65419
11
- pyorc/api/cross_section.py,sha256=un7_VFHMOpBM8FE7lQnZIsaxidnABzFWlyaDHIUfzoA,52039
11
+ pyorc/api/cross_section.py,sha256=MH0AEw5K1Kc1ClZeQRBUYkShZYVk41fshLn6GzCZAas,65212
12
12
  pyorc/api/frames.py,sha256=Kls4mpU_4hoUaXs9DJf2S6RHyp2D5emXJkAQWvvT39U,24300
13
- pyorc/api/mask.py,sha256=COsL4fxz-Rsn-wgpojpJ1se4FGA8CZ_R1jx3iVUYB30,16462
13
+ pyorc/api/mask.py,sha256=-owe66kte2ob3_Zndf21JR-LyEX_1mECbHOuqNfzuMc,16507
14
14
  pyorc/api/orcbase.py,sha256=C23QTKOyxHUafyJsq_t7xn_BzAEvf4DDfzlYAopons8,4189
15
- pyorc/api/plot.py,sha256=-rDmEccwpJXojCyBEKFCd8NpBwLhcZ8tsOq62n26zu4,30898
16
- pyorc/api/transect.py,sha256=KU0ZW_0NqYD4jeDxvuWJi7X06KqrcgO9afP7QmWuixA,14162
15
+ pyorc/api/plot.py,sha256=MxIEIS8l46bUaca0GtMazx8-k2_TbfQLrPCPAjuWos8,31082
16
+ pyorc/api/transect.py,sha256=wENKWt0u0lHtT0lPYv47faHf_iAN9Mmeev-vwWjnz6E,13382
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
@@ -23,12 +23,12 @@ pyorc/cli/log.py,sha256=Vg8GznmrEPqijfW6wv4OCl8R00Ld_fVt-ULTitaDijY,2824
23
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=UFjxmq5Uhk8wnBLScAyTaVWTPTCnH9hJdKOYBFrGZ_Y,33288
26
+ pyorc/service/velocimetry.py,sha256=bPI1OdN_fi0gZES08mb7yqCS_4I-lKSZ2JvWSGTRD1E,34434
27
27
  pyorc/velocimetry/__init__.py,sha256=lYM7oJZWxgJ2SpE22xhy7pBYcgkKFHMBHYmDvvMbtZk,148
28
28
  pyorc/velocimetry/ffpiv.py,sha256=CYUjgwnB5osQmJ83j3E00B9P0_hS-rFuhyvufxKXtag,17487
29
29
  pyorc/velocimetry/openpiv.py,sha256=6BxsbXLzT4iEq7v08G4sOhVlYFodUpY6sIm3jdCxNMs,13149
30
- pyopenrivercam-0.8.10.dist-info/entry_points.txt,sha256=Cv_WI2Y6QLnPiNCXGli0gS4WAOAeMoprha1rAR3vdRE,44
31
- pyopenrivercam-0.8.10.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
32
- pyopenrivercam-0.8.10.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
33
- pyopenrivercam-0.8.10.dist-info/METADATA,sha256=05xRhY6LmvJNoWvlGH1KkoAiVHb-rGQcya7gYWxHmdw,11641
34
- pyopenrivercam-0.8.10.dist-info/RECORD,,
30
+ pyopenrivercam-0.8.12.dist-info/entry_points.txt,sha256=Cv_WI2Y6QLnPiNCXGli0gS4WAOAeMoprha1rAR3vdRE,44
31
+ pyopenrivercam-0.8.12.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
32
+ pyopenrivercam-0.8.12.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
33
+ pyopenrivercam-0.8.12.dist-info/METADATA,sha256=hU0j9dsG6ksjRTM6UdMLVJ15TGzGQ9lW1mHhxDKxJy0,11641
34
+ pyopenrivercam-0.8.12.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.10"
3
+ __version__ = "0.8.12"
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
@@ -13,7 +13,7 @@ from matplotlib import patheffects
13
13
  from scipy.interpolate import interp1d
14
14
  from scipy.optimize import differential_evolution
15
15
  from shapely import affinity, geometry
16
- from shapely.ops import polygonize
16
+ from shapely.ops import polygonize, split
17
17
 
18
18
  from pyorc import cv, plot_helpers
19
19
 
@@ -278,7 +278,7 @@ class CrossSection:
278
278
  Parameters
279
279
  ----------
280
280
  h : float
281
- water level [m]
281
+ water level [m].
282
282
  sz : bool, optional
283
283
  If set, return water level line in y-z projection, by default False.
284
284
  extend_by : float, optional
@@ -613,16 +613,23 @@ class CrossSection:
613
613
  raise ValueError("Amount of water line crossings must be 2 for a planar surface estimate.")
614
614
  return geometry.Polygon(list(wls[0].coords) + list(wls[1].coords[::-1]))
615
615
 
616
- def get_wetted_surface_sz(self, h: float) -> geometry.Polygon:
617
- """Retrieve a wetted surface perpendicular to flow direction (SZ) for a water level, as a geometry.Polygon.
616
+ def get_wetted_surface_sz(
617
+ self, h: float, perimeter: bool = False
618
+ ) -> Union[geometry.MultiPolygon, geometry.MultiLineString]:
619
+ """Retrieve a wetted surface or perimeter perpendicular to flow direction (SZ) for a water level.
620
+
621
+ This returns a `geometry.MultiPolygon` when a surface is requested (`perimeter=False`), and
622
+ `geometry.MultiLineString` when a perimeter is requested (`perimeter=True`).
618
623
 
619
- This is a useful method for instance to estimate m2 wetted surface for a given water level in the cross
620
- section.
624
+ This is a useful method for instance to estimate m2 wetted surface or m wetted perimeter length for a given
625
+ water level in the cross section.
621
626
 
622
627
  Parameters
623
628
  ----------
624
629
  h : float
625
630
  water level [m]
631
+ perimeter : bool, optional
632
+ If set to True, return a linestring with the wetted perimeter instead.
626
633
 
627
634
  Returns
628
635
  -------
@@ -630,6 +637,12 @@ class CrossSection:
630
637
  Wetted surface as a polygon, in Y-Z projection.
631
638
 
632
639
  """
640
+
641
+ def avg_y(line):
642
+ """Compute average y-coordinate of a line."""
643
+ ys = [p[1] for p in line.coords]
644
+ return sum(ys) / len(ys)
645
+
633
646
  wl = self.get_cs_waterlevel(
634
647
  h=h, sz=True, extend_by=0.1
635
648
  ) # extend a small bit to guarantee crossing with the bottom coordinates
@@ -642,6 +655,18 @@ class CrossSection:
642
655
  if bottom_points[-1].y < zl:
643
656
  bottom_points.append(geometry.Point(bottom_points[-1].x, zl + 0.1))
644
657
  bottom_line = geometry.LineString(bottom_points)
658
+ if perimeter:
659
+ wl_z = wl.coords[0][-1]
660
+ split_segments = split(bottom_line, wl)
661
+ filtered = []
662
+ for seg in split_segments.geoms:
663
+ seg_z = avg_y(seg)
664
+ if seg_z < wl_z:
665
+ # segment is below water level, add to perimeter
666
+ filtered.append(seg)
667
+
668
+ return geometry.MultiLineString(filtered)
669
+ # return wetted_perim
645
670
  pol = list(polygonize(wl.union(bottom_line)))
646
671
  if len(pol) == 0:
647
672
  # create infinitely small polygon at lowest z coordinate
@@ -1074,6 +1099,182 @@ class CrossSection:
1074
1099
  )
1075
1100
  return ax
1076
1101
 
1102
+ def _preprocess_level_range(
1103
+ self,
1104
+ min_h: Optional[float] = None,
1105
+ max_h: Optional[float] = None,
1106
+ min_z: Optional[float] = None,
1107
+ max_z: Optional[float] = None,
1108
+ ):
1109
+ """Set minimum and maximum z-levels (if not provided) based on the camera config."""
1110
+ if min_z is None:
1111
+ if min_h is not None:
1112
+ min_z = self.camera_config.h_to_z(min_h)
1113
+ min_z = np.maximum(min_z, self.z.min())
1114
+ if max_z is None:
1115
+ if max_h is not None:
1116
+ max_z = self.camera_config.h_to_z(max_h)
1117
+ max_z = np.minimum(max_z, self.z.max())
1118
+ if min_z and max_z:
1119
+ # check for z_min < z_max
1120
+ if min_z > max_z:
1121
+ raise ValueError("Minimum water level is higher than maximum water level.")
1122
+ return min_z, max_z
1123
+
1124
+ def _preprocess_l_range(self, l_min: float, l_max: float, ds_max=0.5, dz_max=0.02) -> List[float]:
1125
+ """Generate a list of evaluation points between l_min and l_max for water level detection.
1126
+
1127
+ Controls on evaluation are that vertically (using `self.z`), points are at least `dz_max` meters apart and
1128
+ horizontally (using `self.s`), points are included if the distance between them exceeds `dz_min` meters.
1129
+
1130
+ Parameters
1131
+ ----------
1132
+ l_min: float
1133
+ Minimum l value for evaluation.
1134
+ l_max: float
1135
+ Maximum l value for evaluation.
1136
+ ds_max : float, optional
1137
+ maximum step size between evaluation points in horizontal direction, by default 0.5.
1138
+ dz_max : float, optional
1139
+ maximum step size between evaluation points in vertical direction, by default 0.02.
1140
+
1141
+ Returns
1142
+ -------
1143
+ Tuple[List[float], List[float]]
1144
+ lists of l-coordinates and associated z-coordinates for evaluation.
1145
+
1146
+ """
1147
+ # # Initial list of evaluation points
1148
+ # l_range = []
1149
+ # z_range = []
1150
+ # Start with the first point within the range
1151
+ current_l = l_min
1152
+ last_z = None
1153
+ last_s = None
1154
+
1155
+ # Add all points from self.l that lie within [l_min, l_max]
1156
+ valid_l_indices = (self.l >= l_min) & (self.l <= l_max) # Logical filter
1157
+ l_range = list(self.l[valid_l_indices])
1158
+ z_range = list(self.z[valid_l_indices])
1159
+
1160
+ # Iterate over the self.l values by interpolating within the range
1161
+ while current_l <= l_max:
1162
+ # Interpolate x, y, z, and s using the available properties
1163
+ # x = self.interp_x(current_l)
1164
+ # y = self.interp_y(current_l)
1165
+ z = self.interp_z(current_l)
1166
+ s = self.interp_s_from_l(current_l)
1167
+
1168
+ # Append the point if it satisfies the required criteria
1169
+ if last_z is None or last_s is None or abs(z - last_z) >= dz_max or abs(s - last_s) >= ds_max:
1170
+ l_range.append(current_l)
1171
+ z_range.append(z)
1172
+ last_z = z
1173
+ last_s = s
1174
+
1175
+ # Increment l
1176
+ current_l += 0.01 # Increment in steps small enough to meet any constraint
1177
+
1178
+ # add the final l
1179
+ if current_l > l_max:
1180
+ l_range.append(l_max)
1181
+ z_range.append(self.interp_z(l_max))
1182
+
1183
+ # sort l_range and z_range by incremental l_range
1184
+ sorted_indices = np.argsort(l_range)
1185
+ l_range = np.array(l_range)[sorted_indices]
1186
+ z_range = np.array(z_range)[sorted_indices]
1187
+
1188
+ return l_range, z_range
1189
+
1190
+ def _water_level_score_range(
1191
+ self,
1192
+ img: np.ndarray,
1193
+ bank: BANK_OPTIONS = "far",
1194
+ bin_size: int = 5,
1195
+ length: float = 2.0,
1196
+ padding: float = 0.5,
1197
+ offset: float = 0.0,
1198
+ ds_max: Optional[float] = 0.5,
1199
+ dz_max: Optional[float] = 0.02,
1200
+ min_h: Optional[float] = None,
1201
+ max_h: Optional[float] = None,
1202
+ min_z: Optional[float] = None,
1203
+ max_z: Optional[float] = None,
1204
+ ) -> float:
1205
+ """Evaluate a range of scores for water level detection.
1206
+
1207
+ This computes the histogram score for a range of l values and returns all scores for further evaluation.
1208
+ The histogram score is a measure of dissimilarity (low means very dissimilar) of pixel intensity distributions
1209
+ left and right of hypothesized water lines. The evaluation points along the cross section (l-values) are
1210
+ determined by two parameters max_ds (maximum step in horizontal direction) and max_dz (maximum step in vertical
1211
+ direction).
1212
+
1213
+ Parameters
1214
+ ----------
1215
+ img : np.ndarray
1216
+ image (uint8) used to estimate water level from.
1217
+ bank: Literal["far", "near", "both"], optional
1218
+ select from which bank to detect the water level. Use this if camera is positioned in a way that only
1219
+ one shore is clearly distinguishable and not obscured. Typically you will use "far" if the camera is
1220
+ positioned on one bank, aimed perpendicular to the flow. Use "near" if not the full cross section is
1221
+ visible, but only the part nearest the camera. And leave empty when both banks are clearly visible and
1222
+ approximately the same in distance (e.g. middle of a bridge). If not provided, the bank is detected based
1223
+ on the best estimate from both banks.
1224
+ bin_size : int, optional
1225
+ Size of bins for histogram calculation of the provided image intensities, default 5.
1226
+ length : float, optional
1227
+ length of the waterline [m], by default 2.0
1228
+ padding : float, optional
1229
+ amount of distance [m] to extend the polygon beyond the waterline, by default 0.5. Two polygons are drawn
1230
+ left and right of hypothesized water line at -padding and +padding.
1231
+ offset : float, optional
1232
+ perpendicular offset of the waterline from the cross-section [m], by default 0.0
1233
+ ds_max : float, optional
1234
+ maximum step size between evaluation points in horizontal direction, by default 0.5.
1235
+ dz_max : float, optional
1236
+ maximum step size between evaluation points in vertical direction, by default 0.02.
1237
+ min_h : float, optional
1238
+ minimum water level to try detection [m]. If not provided, the minimum water level is taken from the
1239
+ cross section.
1240
+ max_h : float, optional
1241
+ maximum water level to try detection [m]. If not provided, the maximum water level is taken from the
1242
+ cross section.
1243
+ min_z : float, optional
1244
+ same as min_h but using z-coordinates instead of local datum, min_z overrules min_h
1245
+ max_z : float, optional
1246
+ same as max_z but using z-coordinates instead of local datum, max_z overrules max_h
1247
+
1248
+ """
1249
+ l_min, l_max = self.get_line_of_interest(bank=bank)
1250
+ min_z, max_z = self._preprocess_level_range(min_h, max_h, min_z, max_z)
1251
+ l_range, z_range = self._preprocess_l_range(l_min=l_min, l_max=l_max, ds_max=ds_max, dz_max=dz_max)
1252
+ if len(img.shape) == 3:
1253
+ # flatten image first if it his a time dimension
1254
+ img = img.mean(axis=2)
1255
+ assert (
1256
+ img.shape[0] == self.camera_config.height
1257
+ ), f"Image height {img.shape[0]} is not the same as camera_config height {self.camera_config.height}"
1258
+ assert (
1259
+ img.shape[1] == self.camera_config.width
1260
+ ), f"Image width {img.shape[1]} is not the same as camera_config width {self.camera_config.width}"
1261
+
1262
+ # gridded results
1263
+ results = [
1264
+ self.get_histogram_score(
1265
+ x=[l],
1266
+ img=img,
1267
+ bin_size=bin_size,
1268
+ offset=offset,
1269
+ padding=padding,
1270
+ length=length,
1271
+ min_z=min_z,
1272
+ max_z=max_z,
1273
+ )
1274
+ for l in l_range
1275
+ ]
1276
+ return l_range, z_range, results
1277
+
1077
1278
  def detect_water_level(
1078
1279
  self,
1079
1280
  img: np.ndarray,
@@ -1087,11 +1288,11 @@ class CrossSection:
1087
1288
  min_z: Optional[float] = None,
1088
1289
  max_z: Optional[float] = None,
1089
1290
  ) -> float:
1090
- """Detect water level optically from provided image.
1291
+ """Detect water level from provided image through optimization.
1091
1292
 
1092
1293
  Water level detection is done by first detecting the water line along the cross-section by comparisons
1093
1294
  of distribution functions left and right of hypothesized water lines, and then looking up the water level
1094
- associated with the water line location.
1295
+ associated with the water line location. A differential evolution optimization is used to find the optimum.
1095
1296
 
1096
1297
  Parameters
1097
1298
  ----------
@@ -1124,19 +1325,14 @@ class CrossSection:
1124
1325
  max_z : float, optional
1125
1326
  same as max_z but using z-coordinates instead of local datum, max_z overrules max_h
1126
1327
 
1127
- """
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.")
1328
+ Returns
1329
+ -------
1330
+ float
1331
+ Most likely water level, according to optimization and scoring (most distinct intensity PDF)
1139
1332
 
1333
+ """
1334
+ l_min, l_max = self.get_line_of_interest(bank=bank)
1335
+ min_z, max_z = self._preprocess_level_range(min_h, max_h, min_z, max_z)
1140
1336
  if len(img.shape) == 3:
1141
1337
  # flatten image first if it his a time dimension
1142
1338
  img = img.mean(axis=2)
@@ -1146,9 +1342,7 @@ class CrossSection:
1146
1342
  assert (
1147
1343
  img.shape[1] == self.camera_config.width
1148
1344
  ), f"Image width {img.shape[1]} is not the same as camera_config width {self.camera_config.width}"
1149
- # determine the relevant start point if only one is used
1150
- # import pdb;pdb.set_trace()
1151
- l_min, l_max = self.get_line_of_interest(bank=bank)
1345
+
1152
1346
  opt = differential_evolution(
1153
1347
  self.get_histogram_score,
1154
1348
  popsize=50,
@@ -1167,3 +1361,93 @@ class CrossSection:
1167
1361
  stacklevel=2,
1168
1362
  )
1169
1363
  return h
1364
+
1365
+ def detect_water_level_s2n(
1366
+ self,
1367
+ img: np.ndarray,
1368
+ bank: BANK_OPTIONS = "far",
1369
+ bin_size: int = 5,
1370
+ length: float = 2.0,
1371
+ padding: float = 0.5,
1372
+ offset: float = 0.0,
1373
+ ds_max: Optional[float] = 0.5,
1374
+ dz_max: Optional[float] = 0.02,
1375
+ min_h: Optional[float] = None,
1376
+ max_h: Optional[float] = None,
1377
+ min_z: Optional[float] = None,
1378
+ max_z: Optional[float] = None,
1379
+ ) -> float:
1380
+ """Detect water level optically from provided image, through evaluation of a vector of locations.
1381
+
1382
+ Water level detection is done by first detecting the water line along the cross-section by comparisons
1383
+ of distribution functions left and right of hypothesized water lines, and then looking up the water level
1384
+ associated with the water line location. Because a full vector of results is evaluated, the signal to noise
1385
+ ratio can be evaluated and used to understand the quality of the water level detection. Two parameters are used
1386
+ to control the spacing between l (location) coordinates: `ds_max` controls the maximum step size in horizontal
1387
+ direction, `dz_max` controls the maximum step size in vertical direction.
1388
+
1389
+ Parameters
1390
+ ----------
1391
+ img : np.ndarray
1392
+ image (uint8) used to estimate water level from.
1393
+ bank: Literal["far", "near", "both"], optional
1394
+ select from which bank to detect the water level. Use this if camera is positioned in a way that only
1395
+ one shore is clearly distinguishable and not obscured. Typically you will use "far" if the camera is
1396
+ positioned on one bank, aimed perpendicular to the flow. Use "near" if not the full cross section is
1397
+ visible, but only the part nearest the camera. And leave empty when both banks are clearly visible and
1398
+ approximately the same in distance (e.g. middle of a bridge). If not provided, the bank is detected based
1399
+ on the best estimate from both banks.
1400
+ bin_size : int, optional
1401
+ Size of bins for histogram calculation of the provided image intensities, default 5.
1402
+ length : float, optional
1403
+ length of the waterline [m], by default 2.0.
1404
+ padding : float, optional
1405
+ amount of distance [m] to extend the polygon beyond the waterline, by default 0.5. Two polygons are drawn
1406
+ left and right of hypothesized water line at -padding and +padding.
1407
+ offset : float, optional
1408
+ perpendicular offset of the waterline from the cross-section [m], by default 0.0.
1409
+ ds_max : float, optional
1410
+ maximum step size between evaluation points in horizontal direction, by default 0.5.
1411
+ dz_max : float, optional
1412
+ maximum step size between evaluation points in vertical direction, by default 0.02.
1413
+ min_h : float, optional
1414
+ minimum water level to try detection [m]. If not provided, the minimum water level is taken from the
1415
+ cross section.
1416
+ max_h : float, optional
1417
+ maximum water level to try detection [m]. If not provided, the maximum water level is taken from the
1418
+ cross section.
1419
+ min_z : float, optional
1420
+ same as min_h but using z-coordinates instead of local datum, min_z overrules min_h.
1421
+ max_z : float, optional
1422
+ same as max_z but using z-coordinates instead of local datum, max_z overrules max_h.
1423
+
1424
+ Returns
1425
+ -------
1426
+ float
1427
+ Most likely water level, according to optimization and scoring (most distinct intensity PDF).
1428
+ float
1429
+ Signal to noise ratio, calculated as the mean of all computed scores divided by the optimum (lowest) score
1430
+ found in the entire l-range.
1431
+
1432
+ """
1433
+ l_range, z_range, results = self._water_level_score_range(
1434
+ img=img,
1435
+ bank=bank,
1436
+ bin_size=bin_size,
1437
+ length=length,
1438
+ padding=padding,
1439
+ offset=offset,
1440
+ ds_max=ds_max,
1441
+ dz_max=dz_max,
1442
+ min_h=min_h,
1443
+ max_h=max_h,
1444
+ min_z=min_z,
1445
+ max_z=max_z,
1446
+ )
1447
+ # find optimum (minimum score)
1448
+ idx = np.argmin(results)
1449
+ s2n = np.mean(results) / results[idx]
1450
+ z = z_range[idx]
1451
+ h = self.camera_config.z_to_h(z)
1452
+ # warning if the optimum is on the edge of the search space for l
1453
+ return h, s2n
pyorc/api/mask.py CHANGED
@@ -10,12 +10,11 @@ from pyorc import helpers
10
10
  from pyorc.const import corr, s2n, v_x, v_y
11
11
 
12
12
  commondoc = """
13
- Returns
14
- -------
15
- mask : xr.DataArray
16
- mask applicable to input dataset with ``ds.velocimetry.filter(mask)``.
17
- If ``inplace=True``, the dataset will be returned masked with ``mask``.
18
-
13
+ Returns
14
+ -------
15
+ mask : xr.DataArray
16
+ mask applicable to input dataset with ``ds.velocimetry.filter(mask)``.
17
+ If ``inplace=True``, the dataset will be returned masked with ``mask``.
19
18
  """
20
19
 
21
20
 
@@ -203,7 +202,7 @@ class _Velocimetry_MaskMethods:
203
202
 
204
203
  @_base_mask(time_allowed=True)
205
204
  def corr(self, tolerance=0.1):
206
- """Mass values with too low correlation.
205
+ """Mask values with too low correlation.
207
206
 
208
207
  Parameters
209
208
  ----------
@@ -304,16 +303,17 @@ class _Velocimetry_MaskMethods:
304
303
  (default: 1) wdw is used to fill wdw_x_min and wdwd_y_min with its negative (-wdw) value, and wdw_y_min and
305
304
  kwargs : dict
306
305
  keyword arguments to pass to ``helpers.stack_window``. These can be:
307
- wdw_x_min : int, optional
308
- window size in negative x-direction of grid (must be negative), overrules wdw in negative x-direction
309
- if set.
310
- wdw_x_max : int, optional
311
- window size in positive x-direction of grid, overrules wdw in positive x-direction if set
312
- wdw_y_min : int, optional
313
- window size in negative y-direction of grid (must be negative), overrules wdw in negative y-direction
314
- if set.
315
- wdw_y_max : int, optional
316
- window size in positive y-direction of grid, overrules wdw in positive x-direction if set.
306
+
307
+ - ``wdw_x_min`` : int, optional
308
+ window size in negative x-direction of grid (must be negative), overrules wdw in negative x-direction if
309
+ set.
310
+ - ``wdw_x_max`` : int, optional
311
+ window size in positive x-direction of grid, overrules wdw in positive x-direction if set.
312
+ - ``wdw_y_min`` : int, optional
313
+ window size in negative y-direction of grid (must be negative), overrules wdw in negative y-direction if
314
+ set.
315
+ - ``wdw_y_max`` : int, optional
316
+ window size in positive y-direction of grid, overrules wdw in positive x-direction if set.
317
317
 
318
318
  """
319
319
  # collect points within a stride, collate and analyze for nan fraction
@@ -326,7 +326,7 @@ class _Velocimetry_MaskMethods:
326
326
  def window_mean(self, tolerance=0.7, wdw=1, mode="or", **kwargs):
327
327
  """Mask values when their value deviates significantly from mean.
328
328
 
329
- This is computed as relative fraction from the mean of its neighbours (inc. itself).
329
+ This is computed as relative fraction from the mean of its neighbours (inc. itself).
330
330
 
331
331
  Parameters
332
332
  ----------
@@ -339,16 +339,17 @@ class _Velocimetry_MaskMethods:
339
339
  be within tolerance.
340
340
  kwargs : dict
341
341
  keyword arguments to pass to ``helpers.stack_window``. These can be:
342
- wdw_x_min : int, optional
343
- window size in negative x-direction of grid (must be negative), overrules wdw in negative x-direction
344
- if set.
345
- wdw_x_max : int, optional
346
- window size in positive x-direction of grid, overrules wdw in positive x-direction if set.
347
- wdw_y_min : int, optional
348
- window size in negative y-direction of grid (must be negative), overrules wdw in negative y-direction
349
- if set.
350
- wdw_y_max : int, optional
351
- window size in positive y-direction of grid, overrules wdw in positive x-direction if set.
342
+
343
+ - ``wdw_x_min`` : int, optional
344
+ window size in negative x-direction of grid (must be negative), overrules wdw in negative x-direction
345
+ if set.
346
+ - ``wdw_x_max`` : int, optional
347
+ window size in positive x-direction of grid, overrules wdw in positive x-direction if set.
348
+ - ``wdw_y_min`` : int, optional
349
+ window size in negative y-direction of grid (must be negative), overrules wdw in negative y-direction
350
+ if set.
351
+ - ``wdw_y_max`` : int, optional
352
+ window size in positive y-direction of grid, overrules wdw in positive x-direction if set.
352
353
 
353
354
  """
354
355
  # collect points within a stride, collate and analyze for median value and deviation
pyorc/api/plot.py CHANGED
@@ -2,12 +2,11 @@
2
2
 
3
3
  import copy
4
4
  import functools
5
+ import warnings
5
6
 
6
7
  import matplotlib.pyplot as plt
7
8
  import matplotlib.ticker as mticker
8
9
  import numpy as np
9
- import warnings
10
-
11
10
  from matplotlib import colors, patheffects
12
11
 
13
12
  from pyorc import helpers
@@ -207,10 +206,9 @@ def _base_plot(plot_func):
207
206
  color="r",
208
207
  label="water level",
209
208
  )
210
- except:
209
+ except Exception:
211
210
  warnings.warn(
212
- "Not able to find a unique location for plotting of water level",
213
- stacklevel=2
211
+ "Not able to find a unique location for plotting of water level", stacklevel=2
214
212
  )
215
213
 
216
214
  # draw some depth lines for better visual interpretation.
@@ -620,7 +618,7 @@ def set_default_kwargs(kwargs, method="quiver", mode="local"):
620
618
  kwargs["cmap"] = "rainbow" # the famous rainbow colormap!
621
619
  if "vmin" not in kwargs and "vmax" not in kwargs and "norm" not in kwargs:
622
620
  # set a normalization array
623
- norm = [0, 0.05, 0.1, 0.2, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0]
621
+ norm = [0, 0.05, 0.1, 0.2, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 5.0, 10.0]
624
622
  kwargs["norm"] = colors.BoundaryNorm(norm, ncolors=256, extend="max")
625
623
  if method == "quiver":
626
624
  if "scale" not in kwargs:
@@ -752,8 +750,16 @@ def plot_text(ax, ds, prefix, suffix):
752
750
  yloc = 0.95
753
751
  _ds.transect.get_river_flow(q_name="q")
754
752
  Q = np.abs(_ds.river_flow)
753
+ v_surf = _ds.transect.get_v_surf()
754
+ v_bulk = _ds.transect.get_v_bulk()
755
755
  string = prefix
756
- string += "Water level: {:1.2f} m\nDischarge: {:1.2f} m3/s".format(_ds.transect.h_a, Q.values)
756
+ string += (
757
+ f"$h_a$: {_ds.transect.h_a:1.2f} m | "
758
+ f"$v_{{surf}}$: {v_surf.values:1.2f} m/s | "
759
+ f"$\\overline{{v}}$: {v_bulk.values:1.2f} m/s\n"
760
+ f"$Q$: {Q.values:1.2f} m3/s"
761
+ )
762
+
757
763
  if "q_nofill" in ds:
758
764
  _ds.transect.get_river_flow(q_name="q_nofill")
759
765
  Q_nofill = np.abs(_ds.river_flow)
@@ -765,7 +771,7 @@ def plot_text(ax, ds, prefix, suffix):
765
771
  xloc,
766
772
  yloc,
767
773
  string,
768
- size=24,
774
+ size=18,
769
775
  horizontalalignment="right",
770
776
  verticalalignment="top",
771
777
  path_effects=path_effects,
pyorc/api/transect.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import numpy as np
4
4
  import xarray as xr
5
+ from shapely import geometry
5
6
  from xarray.core import utils
6
7
 
7
8
  from pyorc import helpers
@@ -34,6 +35,26 @@ class Transect(ORCBase):
34
35
  coords = [[_x, _y, _z] for _x, _y, _z in zip(self._obj.xcoords, self._obj.ycoords, self._obj.zcoords)]
35
36
  return CrossSection(camera_config=self.camera_config, cross_section=coords)
36
37
 
38
+ @property
39
+ def wetted_surface_polygon(self) -> geometry.MultiPolygon:
40
+ """Return wetted surface as `shapely.geometry.MultiPolygon` object."""
41
+ return self.cross_section.get_wetted_surface_sz(self.h_a)
42
+
43
+ @property
44
+ def wetted_perimeter_linestring(self) -> geometry.MultiLineString:
45
+ """Return wetted perimeter as `shapely.geometry.MultiLineString` object."""
46
+ return self.cross_section.get_wetted_surface_sz(self.h_a, perimeter=True)
47
+
48
+ @property
49
+ def wetted_surface(self) -> float:
50
+ """Return wetted surface as float."""
51
+ return self.wetted_surface_polygon.area
52
+
53
+ @property
54
+ def wetted_perimeter(self) -> float:
55
+ """Return wetted perimeter as float."""
56
+ return self.wetted_perimeter_linestring.length
57
+
37
58
  def vector_to_scalar(self, v_x="v_x", v_y="v_y"):
38
59
  """Set "v_eff" and "v_dir" variables as effective velocities over cross-section, and its angle.
39
60
 
@@ -129,30 +150,6 @@ class Transect(ORCBase):
129
150
  points_proj = self.camera_config.project_points(points, within_image=within_image, swap_y_coords=True)
130
151
  return points_proj
131
152
 
132
- def get_wetted_perspective(self, h, sample_size=1000):
133
- """Get wetted polygon in camera perspective.
134
-
135
- Parameters
136
- ----------
137
- h : float
138
- The water level value to calculate the surface perspective.
139
- sample_size : int, optional
140
- The number of points to densify the transect with, by default 1000
141
-
142
- Returns
143
- -------
144
- ndarray
145
- A numpy array containing the points forming the wetted polygon perspective.
146
-
147
- """
148
- bottom_points, surface_points = self.get_bottom_surface_z_perspective(h=h, sample_size=sample_size)
149
- # concatenate points reversing one set for preps of a polygon
150
- pol_points = np.concatenate([bottom_points, np.flipud(surface_points)], axis=0)
151
-
152
- # add the first point at the end to close the polygon
153
- pol_points = np.concatenate([pol_points, pol_points[0:1]], axis=0)
154
- return pol_points
155
-
156
153
  def get_depth_perspective(self, h, sample_size=1000, interval=25):
157
154
  """Get line (x, y) pairs that show the depth over several intervals in the wetted part of the cross section.
158
155
 
@@ -177,64 +174,59 @@ class Transect(ORCBase):
177
174
  # make line pairs
178
175
  return list(zip(bottom_points, surface_points))
179
176
 
180
- def get_xyz_perspective(self, trans_mat=None, xs=None, ys=None, mask_outside=True):
181
- """Get camera-perspective column, row coordinates from cross-section locations.
177
+ def get_v_surf(self, v_name="v_eff"):
178
+ """Compute mean surface velocity in locations that are below water level.
182
179
 
183
180
  Parameters
184
181
  ----------
185
- trans_mat : np.ndarray, optional
186
- perspective transform matrix (Default value = None)
187
- xs : np.array, optional
188
- x-coordinates to transform, derived from self.x if not provided (Default value = None)
189
- ys :
190
- y-coordinates to transform, derived from self.y if not provided (Default value = None)
191
- mask_outside :
192
- values not fitting in the original camera frame are set to NaN (Default value = True)
182
+ v_name : str, optional
183
+ name of variable where surface velocities [m s-1] are stored (Default value = "v_eff")
193
184
 
194
185
  Returns
195
186
  -------
196
- cols : list of ints
197
- columns of locations in original camera perspective
198
- rows : list of ints
199
- rows of locations in original camera perspective
200
-
187
+ xr.DataArray
188
+ mean surface velocities for all provided quantiles or time steps
201
189
 
202
190
  """
203
- if xs is None:
204
- xs = self._obj.x.values
205
- if ys is None:
206
- ys = self._obj.y.values
207
- # compute bathymetry as measured in local height reference (such as staff gauge)
208
- # if self.camera_config.gcps["h_ref"] is None:
209
- # h_ref = 0.0
210
- # else:
211
- # h_ref = self.camera_config.gcps["h_ref"]
212
- hs = self.camera_config.z_to_h(self._obj.zcoords).values
213
- # zs = (self._obj.zcoords - self.camera_config.gcps["z_0"] + h_ref).values
214
- if trans_mat is None:
215
- ms = [self.camera_config.get_M(h, reverse=True, to_bbox_grid=True) for h in hs]
191
+ ## Mean velocity over entire profile
192
+ z_a = self.camera_config.h_to_z(self.h_a)
193
+
194
+ depth = z_a - self._obj.zcoords
195
+ depth[depth < 0] = 0.0
196
+
197
+ # ds.transect.camera_config.get_depth(ds.zcoords, ds.transect.h_a)
198
+ wet_scoords = self._obj.scoords[depth > 0].values
199
+ if len(wet_scoords) == 0:
200
+ # no wet points found. Velocity can only be missing
201
+ v_av = np.nan
202
+ if len(wet_scoords) > 1:
203
+ velocity_int = self._obj[v_name].fillna(0.0).integrate(coord="scoords") # m2/s
204
+ width = (wet_scoords[-1] + (wet_scoords[-1] - wet_scoords[-2]) * 0.5) - (
205
+ wet_scoords[0] - (wet_scoords[1] - wet_scoords[0]) * 0.5
206
+ )
207
+ v_av = velocity_int / width
216
208
  else:
217
- # use user defined M instead
218
- ms = [trans_mat for _ in hs]
219
- # compute row and column position of vectors in original reprojected background image col/row coordinates
220
- cols, rows = zip(
221
- *[
222
- helpers.xy_to_perspective(
223
- x, y, self.camera_config.resolution, trans_mat, reverse_y=self.camera_config.shape[0]
224
- )
225
- for x, y, trans_mat in zip(xs, ys, ms)
226
- ],
227
- )
228
- # ensure y coordinates start at the top in the right orientation
229
- shape_y, shape_x = self.camera_shape
230
- rows = shape_y - np.array(rows)
231
- cols = np.array(cols)
232
- if mask_outside:
233
- # remove values that do not fit in the frames
234
- cols[np.any([cols < 0, cols > self.camera_shape[1]], axis=0)] = np.nan
235
- rows[np.any([rows < 0, rows > self.camera_shape[0]], axis=0)] = np.nan
236
-
237
- return cols, rows
209
+ v_av = self._obj[v_name][:, depth > 0]
210
+ return v_av
211
+
212
+ def get_v_bulk(self, q_name="q"):
213
+ """Compute the bulk velocity.
214
+
215
+ Parameters
216
+ ----------
217
+ q_name : str, optional
218
+ name of variable where depth integrated velocities [m2 s-1] are stored (Default value = "q")
219
+
220
+ Returns
221
+ -------
222
+ xr.DataArray
223
+ bulk velocities for all provided quantiles or time steps
224
+
225
+ """
226
+ discharge = self._obj[q_name].fillna(0.0).integrate(coord="scoords")
227
+ wet_surf = self.wetted_surface
228
+ v_bulk = discharge / wet_surf
229
+ return v_bulk
238
230
 
239
231
  def get_river_flow(self, q_name="q", discharge_name="river_flow"):
240
232
  """Integrate time series of depth averaged velocities [m2 s-1] into cross-section integrated flow [m3 s-1].
pyorc/cv.py CHANGED
@@ -7,6 +7,7 @@ import warnings
7
7
  import cv2
8
8
  import numpy as np
9
9
  import rasterio
10
+ from numba import jit, prange
10
11
  from scipy import optimize
11
12
  from shapely.affinity import rotate
12
13
  from shapely.geometry import LineString, Point, Polygon
@@ -1034,16 +1035,48 @@ def get_aoi(dst_corners, resolution=None, method="corners"):
1034
1035
  return bbox
1035
1036
 
1036
1037
 
1038
+ @jit(nopython=True, cache=True, nogil=True)
1039
+ def numba_extract_pixels(img, mask):
1040
+ """Optimized pixel extraction within a polygon-defined mask."""
1041
+ pixel_values = []
1042
+ rows, cols = img.shape
1043
+ for i in prange(rows):
1044
+ for j in prange(cols):
1045
+ if mask[i, j] == 255:
1046
+ pixel_values.append(img[i, j])
1047
+ return np.array(pixel_values)
1048
+
1049
+
1037
1050
  def get_polygon_pixels(img, pol, reverse_y=False):
1038
1051
  """Get pixel intensities within a polygon."""
1039
1052
  if pol.is_empty:
1040
1053
  return np.array([np.nan])
1041
- polygon_coords = list(pol.exterior.coords)
1042
- mask = np.zeros_like(img, dtype=np.uint8)
1043
- cv2.fillPoly(mask, np.array([polygon_coords], np.int32), color=255)
1054
+ min_x, min_y, max_x, max_y = map(int, pol.bounds)
1055
+ # Ensure bounding box remains within the image dimensions
1056
+ min_x = max(min_x, 0)
1057
+ min_y = max(min_y, 0)
1058
+ max_x = min(max_x, img.shape[1])
1059
+ max_y = min(max_y, img.shape[0])
1060
+
1044
1061
  if reverse_y:
1045
- return np.flipud(img)[mask == 255]
1046
- return img[mask == 255]
1062
+ img = np.flipud(img)
1063
+
1064
+ # Crop the image based on the bounding box
1065
+ cropped_img = img[min_y:max_y, min_x:max_x]
1066
+ # reduce polygon coordinates to ensure compatibility with cropped_img
1067
+ cropped_polygon_coords = [(x - min_x, y - min_y) for x, y in pol.exterior.coords]
1068
+
1069
+ mask = np.zeros_like(cropped_img, dtype=np.uint8)
1070
+ if 0 in mask.shape:
1071
+ # no shape in mask, so return empty array instantly
1072
+ return np.array([], dtype=np.uint8)
1073
+ try:
1074
+ cv2.fillPoly(mask, [np.array(cropped_polygon_coords, dtype=np.int32)], color=255)
1075
+ except Exception:
1076
+ import pdb
1077
+
1078
+ pdb.set_trace()
1079
+ return numba_extract_pixels(cropped_img, mask)
1047
1080
 
1048
1081
 
1049
1082
  def optimize_intrinsic(src, dst, height, width, c=2.0, lens_position=None, camera_matrix=None, dist_coeffs=None):
@@ -31,29 +31,46 @@ def get_water_level(
31
31
  n_start: int = 0,
32
32
  n_end: int = 1,
33
33
  method: const.ALLOWED_COLOR_METHODS_WATER_LEVEL = "grayscale",
34
+ s2n_thres: float = 3.0,
34
35
  frames_options: Optional[Dict] = None,
35
36
  water_level_options: Optional[Dict] = None,
37
+ logger: logging.Logger = logger,
36
38
  ):
37
39
  water_level_options = {} if water_level_options is None else water_level_options
38
40
  frames_options = {} if frames_options is None else frames_options
39
-
40
- if method not in ["grayscale", "hue", "sat", "val"]:
41
- raise ValueError(
42
- f"Method {method} not supported for water level detection, choose one"
43
- f" of {const.ALLOWED_COLOR_METHODS_WATER_LEVEL}"
44
- )
45
- da_frames = video.get_frames(method=method)[n_start:n_end]
46
- # preprocess
47
- da_frames = apply_methods(da_frames, "frames", logger=logger, skip_args=["to_video"], **frames_options)
48
- # if preprocessing still results in a time dim, average in time
49
- if "time" in da_frames.dims:
50
- da_mean = da_frames.mean(dim="time")
51
- else:
52
- da_mean = da_frames
53
- # extract the image
54
- img = np.uint8(da_mean.values)
55
- h_a = cross_section.detect_water_level(img, **water_level_options)
56
- return h_a
41
+ # if frames_options is list of dict then continue, if not then make list of dict
42
+ if not isinstance(frames_options, list):
43
+ frames_options = [frames_options]
44
+ for frames_options_ in frames_options:
45
+ # get method from frames_options if available, otherwise use the parent or default one
46
+ method_ = frames_options_.pop("method", method)
47
+ s2n_thres_ = frames_options_.pop("s2n_thres", s2n_thres)
48
+
49
+ if method_ not in ["grayscale", "hue", "sat", "val"]:
50
+ raise ValueError(
51
+ f"Method {method} not supported for water level detection, choose one"
52
+ f" of {const.ALLOWED_COLOR_METHODS_WATER_LEVEL}"
53
+ )
54
+ da_frames = video.get_frames(method=method_)[n_start:n_end]
55
+ # preprocess
56
+ logger.debug(f"Applying preprocessing methods {frames_options_}")
57
+ da_frames = apply_methods(da_frames, "frames", logger=logger, skip_args=["to_video"], **frames_options_)
58
+ # if preprocessing still results in a time dim, average in time
59
+ if "time" in da_frames.dims:
60
+ da_mean = da_frames.mean(dim="time")
61
+ else:
62
+ da_mean = da_frames
63
+ # extract the image
64
+ img = np.uint8(da_mean.values)
65
+ h_a, s2n = cross_section.detect_water_level_s2n(img, **water_level_options)
66
+ if s2n > s2n_thres_:
67
+ # high enough signal-to-noise ratio, so return, otherwise continue with next frame treatment set
68
+ logger.debug(f"Found significant water level at h: {h_a:.3f} m with signal-to-noise: {s2n} > {s2n_thres_}")
69
+ return h_a
70
+ else:
71
+ logger.debug(f"Found water level at h: {h_a:.3f} m with too low signal-to-noise: {s2n} < {s2n_thres_}")
72
+ # if none of frame treatments gives a satisfactory h_a, return None
73
+ return
57
74
 
58
75
 
59
76
  def vmin_vmax_to_norm(opts):
@@ -434,7 +451,8 @@ class VelocityFlowProcessor(object):
434
451
  def water_level(self, **kwargs):
435
452
  try:
436
453
  self.logger.debug("Estimating water level from video by crossing water line with cross section.")
437
- h_a = get_water_level(self.video_obj, cross_section=self.cross_section_wl, **kwargs)
454
+
455
+ h_a = get_water_level(self.video_obj, cross_section=self.cross_section_wl, logger=self.logger, **kwargs)
438
456
  if h_a is None:
439
457
  self.logger.error("Water level could not be estimated from video. Please set a water level with --h_a.")
440
458
  raise click.Abort()