pyopenrivercam 0.9.2__py3-none-any.whl → 0.9.3__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.9.2
3
+ Version: 0.9.3
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,14 +1,14 @@
1
- pyorc/__init__.py,sha256=UQuU9xy-pxY7XonoYJqzQU1RinDVb6mgc3FAsaWrA0g,523
1
+ pyorc/__init__.py,sha256=1lhSZ9vOEHMu0j54z2mVnf14JyHHoCSLxAGnh6Lf0hY,523
2
2
  pyorc/const.py,sha256=Ia0KRkm-E1lJk4NxQVPDIfN38EBB7BKvxmwIHJrGPUY,2597
3
- pyorc/cv.py,sha256=t2ZR4eyGbiwlIaGHysOheWdaDQuqpWLKjcTiAUzWAR0,50261
3
+ pyorc/cv.py,sha256=0U7Bwe29tRLSJXIpAxStrwGElheBt54_GslexREcw50,50953
4
4
  pyorc/helpers.py,sha256=jed0YyywnpvsZS-8mcA7Lfzn9np1MTlmVLE_PDn2QY0,30454
5
- pyorc/plot_helpers.py,sha256=gLKslspsF_Z4jib5jkBv2wRjKnHTbuRFgkp_PCmv-uU,1803
5
+ pyorc/plot_helpers.py,sha256=mlDsiTqd2VMJTvUFOnnAcW_EIOVtChX37F-zISP__-M,1921
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=AhQgYozpbMcSxtNyi6WfHSKHeSRgDQqFhLO0-VrQKiU,3171
9
9
  pyorc/api/__init__.py,sha256=k2OQQH4NrtXTuVm23d0g_SX6H5DhnKC9_kDyzJ4dWdk,428
10
- pyorc/api/cameraconfig.py,sha256=NP9F7LhPO3aO6FRWkrGl6XpX8O3K59zfTtaYR3Kujqw,65419
11
- pyorc/api/cross_section.py,sha256=MH0AEw5K1Kc1ClZeQRBUYkShZYVk41fshLn6GzCZAas,65212
10
+ pyorc/api/cameraconfig.py,sha256=6cEhPGf55H_zLu7K3wKAFz3-zide4jFgddYnycZuZ1c,65449
11
+ pyorc/api/cross_section.py,sha256=eS2tiZ9kMiVlFGBfpz5qTHXAw7Bb6UPURJu4zmAhBjM,75029
12
12
  pyorc/api/frames.py,sha256=BnglhmHdbKlIip5tym3x-aICOpQRmm853109A7JkWk8,23189
13
13
  pyorc/api/mask.py,sha256=A3TRMqi30L4N491C4FoYY0zvV1GwQ1U31OEkCJp_Nzc,16698
14
14
  pyorc/api/orcbase.py,sha256=C23QTKOyxHUafyJsq_t7xn_BzAEvf4DDfzlYAopons8,4189
@@ -26,8 +26,8 @@ pyorc/service/camera_config.py,sha256=OsRLpe5jd-lu6HT4Vx5wEg554CMS-IKz-q62ir4VbP
26
26
  pyorc/service/velocimetry.py,sha256=bPI1OdN_fi0gZES08mb7yqCS_4I-lKSZ2JvWSGTRD1E,34434
27
27
  pyorc/velocimetry/__init__.py,sha256=5oShoMocCalcCZuIsBqlZlqQuKJgDDBUvXQIo-uqFPA,88
28
28
  pyorc/velocimetry/ffpiv.py,sha256=92XDgzCW4mEZ5ow82zV0APOhfDc1OVftBjKqYdw1zzc,17494
29
- pyopenrivercam-0.9.2.dist-info/entry_points.txt,sha256=Cv_WI2Y6QLnPiNCXGli0gS4WAOAeMoprha1rAR3vdRE,44
30
- pyopenrivercam-0.9.2.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
31
- pyopenrivercam-0.9.2.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
32
- pyopenrivercam-0.9.2.dist-info/METADATA,sha256=LjzGioSQjEn7ku6f8v6N0jti7SiJvMw7Le1HQwboeas,12566
33
- pyopenrivercam-0.9.2.dist-info/RECORD,,
29
+ pyopenrivercam-0.9.3.dist-info/entry_points.txt,sha256=Cv_WI2Y6QLnPiNCXGli0gS4WAOAeMoprha1rAR3vdRE,44
30
+ pyopenrivercam-0.9.3.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
31
+ pyopenrivercam-0.9.3.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
32
+ pyopenrivercam-0.9.3.dist-info/METADATA,sha256=253-bK9Du3taufc0fAYU9QnZemPfRqUJm2NjbrFKBAU,12566
33
+ pyopenrivercam-0.9.3.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.9.2"
3
+ __version__ = "0.9.3"
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
@@ -459,6 +459,7 @@ class CameraConfig:
459
459
  z_a: Optional[float] = None,
460
460
  within_image: Optional[bool] = False,
461
461
  expand_exterior=True,
462
+ exterior_split=400,
462
463
  ) -> Polygon:
463
464
  """Get bounding box.
464
465
 
@@ -485,6 +486,8 @@ class CameraConfig:
485
486
  Set to True to make an attempt to remove parts of the polygon that lie outside of the image field of view
486
487
  expand_exterior : bool, optional
487
488
  Set to True to expand the corner points to more points. This is particularly useful for plotting purposes.
489
+ exterior_split : int, optional
490
+ Amount of subline segments to split bounding box in
488
491
 
489
492
  Returns
490
493
  -------
@@ -516,7 +519,7 @@ class CameraConfig:
516
519
  # distortion on image frame, and to plot partial coverage in the real-world coordinates
517
520
  coords_expand = np.zeros((0, 2))
518
521
  for n in range(0, len(coords) - 1):
519
- new_coords = np.linspace(coords[n], coords[n + 1], 100)
522
+ new_coords = np.linspace(coords[n], coords[n + 1], exterior_split // 4)
520
523
  coords_expand = np.r_[coords_expand, new_coords]
521
524
  coords = coords_expand
522
525
  if not z_a:
@@ -915,9 +918,9 @@ class CameraConfig:
915
918
  angle : float, optional
916
919
  Rotation angle in radians (anti-clockwise) around the center of the bounding box
917
920
  xoff : float, optional
918
- Translation distance in x direction in CRS units
921
+ Translation distance in x direction in m.
919
922
  yoff : float, optional
920
- Translation distance in y direction in CRS units
923
+ Translation distance in y direction in m.
921
924
 
922
925
  Returns
923
926
  -------
@@ -935,9 +938,6 @@ class CameraConfig:
935
938
 
936
939
  # Apply rotation if specified
937
940
  if angle is not None:
938
- print(angle)
939
- # # Convert to radians
940
- # angle = np.radians(rotation)
941
941
  # Get centroid as origin
942
942
  centroid = bbox.centroid
943
943
  # Apply rotation around centroid
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import copy
5
6
  import warnings
6
7
  from typing import Dict, List, Literal, Optional, Tuple, Union
7
8
 
@@ -12,10 +13,11 @@ import numpy as np
12
13
  from matplotlib import patheffects
13
14
  from scipy.interpolate import interp1d
14
15
  from scipy.optimize import differential_evolution
15
- from shapely import affinity, geometry
16
+ from shapely import affinity, force_2d, force_3d, geometry
17
+ from shapely.affinity import rotate, translate
16
18
  from shapely.ops import polygonize, split
17
19
 
18
- from pyorc import cv, plot_helpers
20
+ from .cameraconfig import CameraConfig, cv, plot_helpers
19
21
 
20
22
  MODES = Literal["camera", "geographic", "cross_section"]
21
23
  PATH_EFFECTS = [
@@ -36,6 +38,37 @@ WETTED_KWARGS = {
36
38
  }
37
39
 
38
40
 
41
+ def _fit_line(x, y):
42
+ """Fit straight (xy direction) line to points in a LineString.
43
+
44
+ Parameters
45
+ ----------
46
+ x : list[float]
47
+ x-coordinates of points forming a line
48
+ y : list[float]
49
+ y-coordinates of points forming a line
50
+
51
+ Returns
52
+ -------
53
+ slope, intercept, angle: float, float, float
54
+ line characteristics, angle is in radians
55
+
56
+ """
57
+ # Use PCA to fit the best line
58
+ ps = np.column_stack([x, y])
59
+ centr = ps.mean(axis=0)
60
+ ps_centered = ps - centr
61
+
62
+ # SVD to find principal direction
63
+ _, _, vh = np.linalg.svd(ps_centered)
64
+ direc = vh[0] # First principal component is the line direction
65
+
66
+ # Calculate angle of the line
67
+ ang = np.arctan2(direc[1], direc[0])
68
+
69
+ return centr, direc, ang
70
+
71
+
39
72
  def _make_angle_lines(csl_points, angle_perp, length, offset):
40
73
  """Make lines at cross section points, perpendicular to cross section orientation using angle and offset."""
41
74
  # move points
@@ -98,7 +131,7 @@ class CrossSection:
98
131
  def __repr__(self):
99
132
  return str(self.cs_linestring)
100
133
 
101
- def __init__(self, camera_config, cross_section: Union[gpd.GeoDataFrame, List[List]]):
134
+ def __init__(self, camera_config: CameraConfig, cross_section: Union[gpd.GeoDataFrame, List[List]]):
102
135
  """Initialize a cross-section representation.
103
136
 
104
137
  The object is geographically aware through the camera configuration.
@@ -107,7 +140,7 @@ class CrossSection:
107
140
 
108
141
  Parameters
109
142
  ----------
110
- camera_config : object
143
+ camera_config : pyorc.CameraConfig
111
144
  Defines the camera configuration, including potential CRS used for processing
112
145
  geographic data consistency.
113
146
  cross_section : GeoDataFrame or list of lists
@@ -268,7 +301,15 @@ class CrossSection:
268
301
  # check if cross section is visible within the image objective
269
302
  pix = self.camera_config.project_points(np.array(list(map(list, self.cs_linestring.coords))), within_image=True)
270
303
  # 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)
304
+ within_image = np.all(
305
+ [
306
+ pix[:, 0] >= 0,
307
+ pix[:, 0] < self.camera_config.width,
308
+ pix[:, 1] >= 0,
309
+ pix[:, 1] < self.camera_config.height,
310
+ ],
311
+ axis=0,
312
+ )
272
313
  # check if there are any points within the image objective and return result
273
314
  return bool(np.any(within_image))
274
315
 
@@ -375,6 +416,8 @@ class CrossSection:
375
416
  raise ValueError(
376
417
  "Cross section is not crossed by water level. Check if water level is within the cross section."
377
418
  )
419
+ # sort the cross_sz points to go from s=0 to s max.
420
+ cross_sz = sorted(cross_sz, key=lambda p: p.x)
378
421
  # find xyz real-world coordinates and turn in to POINT Z list
379
422
  cross = [
380
423
  geometry.Point(self.interp_x_from_s(c.xy[0]), self.interp_y_from_s(c.xy[0]), c.xy[1]) for c in cross_sz
@@ -519,7 +562,84 @@ class CrossSection:
519
562
  polygons = [geometry.Polygon(coords) for coords in csl_pol_coords]
520
563
  return polygons
521
564
 
522
- def get_bottom_surface(self, length: float = 2.0, offset: float = 0.0, camera: bool = False, swap_y_coords=False):
565
+ def get_bbox_dry_wet(
566
+ self,
567
+ h: float,
568
+ camera: bool = False,
569
+ swap_y_coords: bool = False,
570
+ dry: bool = False,
571
+ expand_exterior: bool = True,
572
+ exterior_split: int = 100,
573
+ ):
574
+ """Get wet (or dry) part of bounding box, by extending the water line perpendicular to the cross section.
575
+
576
+ Parameters
577
+ ----------
578
+ h : float
579
+ water level [m]
580
+ camera : bool, optional
581
+ If set, return 2D projected polygon, by default False. Note that any plotting does not provide shading or
582
+ ray tracing hence plotting a 2D polygon is not recommended.
583
+ swap_y_coords : bool, optional
584
+ if set, all y-coordinates are swapped so that they fit on a flipped version of the image. This is useful
585
+ in case you plot on ascending y-coordinate axis background images (default: False)
586
+ dry : bool, optional
587
+ If set, the dry parts will be returned, by default False, thus returning wet.
588
+ expand_exterior : bool, optional
589
+ Set to True to expand the corner points to more points. This is particularly useful for plotting purposes.
590
+ exterior_split : int, optional
591
+ Amount of subline segments to split bounding box in
592
+
593
+ Returns
594
+ -------
595
+ shapely.geometry.MultiPolygon
596
+ multipolygon representing the dry or wet part of the bounding box of the camera config (2d if camera=True,
597
+ 3d if camera=False).
598
+
599
+ See Also
600
+ --------
601
+ CrossSection.plot_bbox_dry_wet : Plot the dry and wet parts of the bounding box resulting from this method.
602
+
603
+
604
+
605
+ """
606
+ if self.camera_config.bbox is None:
607
+ raise ValueError("CameraConfig must have a bounding box to use this method.")
608
+ z_water = self.camera_config.h_to_z(h) # 92.1 is exactly hitting a local peak
609
+ geom_plan_2d = force_2d(self.get_planar_surface(h=h, length=10000))
610
+
611
+ if dry:
612
+ # dry pols
613
+ pols = force_3d(self.camera_config.bbox.difference(geom_plan_2d), z=z_water)
614
+ else:
615
+ # wet pol(s)
616
+ pols = force_3d(self.camera_config.bbox.intersection(geom_plan_2d), z=z_water)
617
+ if isinstance(pols, geometry.MultiPolygon):
618
+ pols = pols.geoms
619
+ else:
620
+ pols = [pols]
621
+ pols_proj = []
622
+ for pol in pols:
623
+ coords = [[*p] for p in pol.exterior.coords]
624
+ if camera and len(coords) > 0:
625
+ # also split if required
626
+ if expand_exterior:
627
+ coords_expand = np.zeros((0, 3))
628
+ for n in range(0, len(coords) - 1):
629
+ new_coords = np.linspace(coords[n], coords[n + 1], exterior_split // 4)
630
+ coords_expand = np.r_[coords_expand, new_coords]
631
+ # set expanded coords to original variable
632
+ coords = coords_expand
633
+
634
+ coords_proj = self.camera_config.project_points(coords, swap_y_coords=swap_y_coords, within_image=True)
635
+ pols_proj.append(geometry.Polygon(coords_proj))
636
+ else:
637
+ pols_proj.append(geometry.Polygon(coords))
638
+ return geometry.MultiPolygon(pols_proj)
639
+
640
+ def get_bottom_surface(
641
+ self, length: float = 2.0, offset: float = 0.0, camera: bool = False, swap_y_coords: bool = False
642
+ ):
523
643
  """Retrieve a bottom surface polygon for the entire cross section, expanded over a length.
524
644
 
525
645
  Returns a 2D Polygon if camera is True, 3D if False. Useful in particular for 3d situation plots.
@@ -578,7 +698,7 @@ class CrossSection:
578
698
 
579
699
  def get_planar_surface(
580
700
  self, h: float, length: float = 2.0, offset: float = 0.0, camera: bool = False, swap_y_coords=False
581
- ) -> geometry.Polygon:
701
+ ) -> Union[geometry.Polygon, geometry.MultiPolygon]:
582
702
  """Retrieve a planar water surface for a given water level, as a geometry.Polygon.
583
703
 
584
704
  Returns a 2D Polygon if camera is True, 3D if False
@@ -608,10 +728,50 @@ class CrossSection:
608
728
  CrossSection.plot_planar_surface : Plot the planar surface resulting from this method.
609
729
 
610
730
  """
731
+ # Get the water level crossing points (not camera projected for validation)
732
+ csl_points = self.get_csl_point(h=h, camera=False)
733
+ if len(csl_points) < 2:
734
+ raise ValueError(
735
+ f"Cross section must have at least two points to estimate a planar surface ({len(csl_points)} found)."
736
+ )
611
737
  wls = self.get_csl_line(h=h, offset=offset, length=length, camera=camera, swap_y_coords=swap_y_coords)
612
- if len(wls) != 2:
613
- raise ValueError("Amount of water line crossings must be 2 for a planar surface estimate.")
614
- return geometry.Polygon(list(wls[0].coords) + list(wls[1].coords[::-1]))
738
+
739
+ valid_pairs = []
740
+ for p1, p2, l1, l2 in zip(csl_points[0:-1], csl_points[1:], wls[0:-1], wls[1:]):
741
+ # get s-coordinates
742
+ s1 = self.cs_linestring.project(geometry.Point(p1.x, p1.y))
743
+ s2 = self.cs_linestring.project(geometry.Point(p2.x, p2.y))
744
+
745
+ # check bottom elevation at midpoint
746
+ s_mid = (s1 + s2) / 2
747
+ z_bottom_mid = self.interp_z_from_s(s_mid)
748
+ if z_bottom_mid < p1.z:
749
+ valid_pairs.append((l1, l2))
750
+ if len(valid_pairs) == 0:
751
+ raise ValueError(
752
+ "No valid water level crossings found. Check if water level is within the cross section and if "
753
+ "the cross section is crossed by water."
754
+ )
755
+
756
+ # build the polygons
757
+ polygons = []
758
+ for l1, l2 in valid_pairs:
759
+ pol = geometry.Polygon(list(l1.coords) + list(l2.coords[::-1]))
760
+ if pol.is_valid and not pol.is_empty:
761
+ polygons.append(pol)
762
+
763
+ if len(polygons) == 0:
764
+ raise ValueError(
765
+ "No valid polygons found. Check if water level is within the cross section and if "
766
+ "the cross section is crossed by water."
767
+ )
768
+ elif len(polygons) == 1:
769
+ return polygons[0]
770
+ else:
771
+ return geometry.MultiPolygon(polygons)
772
+ # if len(wls) != 2:
773
+ # raise ValueError("Amount of water line crossings must be 2 for a planar surface estimate.")
774
+ # return geometry.Polygon(list(wls[0].coords) + list(wls[1].coords[::-1]))
615
775
 
616
776
  def get_wetted_surface_sz(
617
777
  self, h: float, perimeter: bool = False
@@ -968,6 +1128,35 @@ class CrossSection:
968
1128
  stacklevel=2,
969
1129
  )
970
1130
 
1131
+ def plot_bbox_dry_wet(
1132
+ self,
1133
+ h: float,
1134
+ camera: bool = False,
1135
+ ax=None,
1136
+ swap_y_coords: bool = False,
1137
+ kwargs_wet: Optional[Dict] = None,
1138
+ kwargs_dry: Optional[Dict] = None,
1139
+ ):
1140
+ """Plot the bounding box of the dry and wet surfaces for a given water level."""
1141
+ if kwargs_wet is None:
1142
+ kwargs_wet = dict(color="b", label="wet")
1143
+ if kwargs_dry is None:
1144
+ kwargs_dry = dict(color="g", label="dry")
1145
+ if ax is None:
1146
+ if camera:
1147
+ ax = plt.axes()
1148
+ else:
1149
+ ax = plt.axes(projection="3d")
1150
+ dry_pols = self.get_bbox_dry_wet(h=h, dry=True, camera=camera, swap_y_coords=swap_y_coords)
1151
+ wet_pols = self.get_bbox_dry_wet(h=h, dry=False, camera=camera, swap_y_coords=swap_y_coords)
1152
+ if camera:
1153
+ _ = plot_helpers.plot_polygon(wet_pols, ax=ax, **kwargs_wet)
1154
+ p = plot_helpers.plot_polygon(dry_pols, ax=ax, **kwargs_dry)
1155
+ else:
1156
+ _ = plot_helpers.plot_3d_polygon(wet_pols, ax=ax, **kwargs_wet)
1157
+ p = plot_helpers.plot_3d_polygon(dry_pols, ax=ax, **kwargs_dry)
1158
+ return p.axes
1159
+
971
1160
  def plot_bottom_surface(
972
1161
  self, length: float = 2.0, offset: float = 0.0, camera: bool = False, ax=None, swap_y_coords=False, **kwargs
973
1162
  ) -> mpl.axes.Axes:
@@ -1099,6 +1288,73 @@ class CrossSection:
1099
1288
  )
1100
1289
  return ax
1101
1290
 
1291
+ def rotate_translate(self, angle: Optional[float] = None, xoff: float = 0.0, yoff: float = 0.0, zoff: float = 0.0):
1292
+ """Rotate and translate cross section to match config.
1293
+
1294
+ Parameters
1295
+ ----------
1296
+ angle : float, optional
1297
+ Rotation angle in radians (anti-clockwise) around the center of the bounding box
1298
+ xoff : float, optional
1299
+ Translation distance in x direction in m.
1300
+ yoff : float, optional
1301
+ Translation distance in y direction in m.
1302
+ zoff : float, optional
1303
+ Translation distance in z direction in m.
1304
+
1305
+ Returns
1306
+ -------
1307
+ CrossSection
1308
+ New transformed cross section instance.
1309
+
1310
+ """
1311
+ # Apply rotation if specified
1312
+ if angle is not None:
1313
+ # Get centroid as origin
1314
+ centroid = self.cs_linestring.centroid
1315
+ # Apply rotation around centroid
1316
+ new_line = rotate(
1317
+ self.cs_linestring,
1318
+ angle,
1319
+ origin=centroid,
1320
+ use_radians=True,
1321
+ )
1322
+ else:
1323
+ new_line = copy.deepcopy(self.cs_linestring)
1324
+ # now translate
1325
+ new_line = translate(new_line, xoff=xoff, yoff=yoff, zoff=zoff)
1326
+ # create a new cross section object
1327
+ return CrossSection(self.camera_config, list(new_line.coords))
1328
+
1329
+ def linearize(self):
1330
+ """Snap cross-section points to a best-fit straight line while preserving Z values.
1331
+
1332
+ The points are projected onto the line, maintaining their relative positions along it.
1333
+
1334
+ Returns
1335
+ -------
1336
+ CrossSection
1337
+ New straightened cross section instance.
1338
+
1339
+ """
1340
+ # Fit straight line
1341
+ centroid, direction, angle = _fit_line(self.x, self.y)
1342
+
1343
+ # Project each point onto the line closest-distance
1344
+ coords = np.column_stack([self.x, self.y])
1345
+ coords_centered = coords - centroid
1346
+
1347
+ # Project onto the line direction
1348
+ projections = np.dot(coords_centered, direction)
1349
+
1350
+ # Calculate new coordinates on the line
1351
+ new_x = centroid[0] + projections * direction[0]
1352
+ new_y = centroid[1] + projections * direction[1]
1353
+
1354
+ # Create new geometries with Z preserved
1355
+ new_points = [[_x, _y, _z] for _x, _y, _z in zip(new_x, new_y, self.z)]
1356
+ return CrossSection(self.camera_config, new_points)
1357
+
1102
1358
  def _preprocess_level_range(
1103
1359
  self,
1104
1360
  min_h: Optional[float] = None,
pyorc/cv.py CHANGED
@@ -1163,11 +1163,17 @@ def optimize_intrinsic(src, dst, height, width, c=2.0, lens_position=None, camer
1163
1163
  k2 = x[param_nr + 1]
1164
1164
  dist_coeffs_sample[0][0] = k1
1165
1165
  dist_coeffs_sample[1][0] = k2
1166
+ fx = camera_matrix_sample[0, 2]
1167
+ fy = camera_matrix_sample[1, 2]
1168
+ r_max = np.sqrt(fx**2 + fy**2) * camera_matrix_sample[0, 0]
1169
+ penalty_k1k2 = radial_monotonicity_penalty(k1, k2, r_max)
1170
+
1166
1171
  else:
1167
1172
  # take the existing distortion coefficients
1168
1173
  dist_coeffs_sample = dist_coeffs.copy()
1169
1174
  k1 = dist_coeffs_sample[0][0]
1170
1175
  k2 = dist_coeffs_sample[1][0]
1176
+ penalty_k1k2 = 0.0
1171
1177
 
1172
1178
  # initialize error
1173
1179
  err = 100
@@ -1195,8 +1201,18 @@ def optimize_intrinsic(src, dst, height, width, c=2.0, lens_position=None, camer
1195
1201
  cam_err = ((_lens_pos - lens_pos2.flatten()) ** 2).sum() ** 0.5
1196
1202
  # TODO: for now camera errors are weighted with 10% needs further investigation
1197
1203
  err = float(0.1 * cam_err + gcp_err) if cam_err is not None else gcp_err
1204
+ # penalize non monotonically increasing functions
1205
+ err += 100 * penalty_k1k2
1198
1206
  return err # assuming gcp pixel distance is about 5 cm
1199
1207
 
1208
+ def radial_monotonicity_penalty(k1, k2, r_max):
1209
+ """Penalty in case radii do not monotonically increase towards the border."""
1210
+ rs = np.linspace(0, r_max, 50)
1211
+ deriv = 1 + 3 * k1 * rs**2 + 5 * k2 * rs**4
1212
+ # penalize negative slope
1213
+ penalty = np.sum(np.clip(-deriv, 0, None))
1214
+ return penalty
1215
+
1200
1216
  # determine optimization bounds
1201
1217
  bounds = []
1202
1218
  if camera_matrix is not None and dist_coeffs is not None:
@@ -1205,8 +1221,8 @@ def optimize_intrinsic(src, dst, height, width, c=2.0, lens_position=None, camer
1205
1221
  if camera_matrix is None:
1206
1222
  bounds.append([float(0.25), float(2)])
1207
1223
  if len(dst) > 4 and dist_coeffs is None:
1208
- bounds.append([-0.9, 0.9]) # k1
1209
- bounds.append([-0.5, 0.5]) # k2
1224
+ bounds.append([-0.5, 0.5]) # k1
1225
+ bounds.append([-0.1, 0.1]) # k2
1210
1226
  else:
1211
1227
  # set a warning if dist_coeffs is provided without sufficient ground control
1212
1228
  if dist_coeffs:
pyorc/plot_helpers.py CHANGED
@@ -22,7 +22,8 @@ def plot_3d_polygon(polygon, ax=None, **kwargs):
22
22
  """Plot a shapely.geometry.Polygon or MultiPolygon on matplotlib 3d ax."""
23
23
  if isinstance(polygon, geometry.MultiPolygon):
24
24
  for pol in polygon.geoms:
25
- p = _plot_3d_pol(pol, ax=ax, **kwargs)
25
+ label = kwargs.pop("label", None)
26
+ p = _plot_3d_pol(pol, ax=ax, label=label, **kwargs)
26
27
  else:
27
28
  p = _plot_3d_pol(polygon, ax=ax, **kwargs)
28
29
  return p
@@ -34,7 +35,8 @@ def plot_polygon(polygon, ax=None, **kwargs):
34
35
  ax = plt.axes()
35
36
  if isinstance(polygon, geometry.MultiPolygon):
36
37
  for pol in polygon.geoms:
37
- patch = plt.Polygon(pol.exterior.coords, **kwargs)
38
+ label = kwargs.pop("label", None)
39
+ patch = plt.Polygon(pol.exterior.coords, label=label, **kwargs)
38
40
  p = ax.add_patch(patch)
39
41
  else:
40
42
  patch = plt.Polygon(polygon.exterior.coords, **kwargs)