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.
- {pyopenrivercam-0.8.6.dist-info → pyopenrivercam-0.8.8.dist-info}/METADATA +4 -4
- {pyopenrivercam-0.8.6.dist-info → pyopenrivercam-0.8.8.dist-info}/RECORD +16 -16
- pyorc/__init__.py +1 -1
- pyorc/api/cameraconfig.py +140 -37
- pyorc/api/cross_section.py +139 -34
- pyorc/api/frames.py +19 -58
- pyorc/api/plot.py +94 -36
- pyorc/cli/cli_utils.py +7 -9
- pyorc/cli/main.py +12 -3
- pyorc/cv.py +111 -24
- pyorc/helpers.py +23 -0
- pyorc/plot_helpers.py +21 -6
- pyorc/service/velocimetry.py +238 -176
- {pyopenrivercam-0.8.6.dist-info → pyopenrivercam-0.8.8.dist-info}/WHEEL +0 -0
- {pyopenrivercam-0.8.6.dist-info → pyopenrivercam-0.8.8.dist-info}/entry_points.txt +0 -0
- {pyopenrivercam-0.8.6.dist-info → pyopenrivercam-0.8.8.dist-info}/licenses/LICENSE +0 -0
pyorc/api/frames.py
CHANGED
|
@@ -273,54 +273,13 @@ class Frames(ORCBase):
|
|
|
273
273
|
{"xs": xs, "ys": ys, "lon": lons, "lat": lats}, coords, const.GEOGRAPHICAL_ATTRS
|
|
274
274
|
)
|
|
275
275
|
if "rgb" in da_proj.dims and len(da_proj.dims) == 4:
|
|
276
|
-
# ensure that "rgb" is the last dimension
|
|
276
|
+
# ensure that "rgb" is the last dimension and dtype is int
|
|
277
277
|
da_proj = da_proj.transpose("time", "y", "x", "rgb")
|
|
278
|
+
da_proj = da_proj.astype("uint8")
|
|
278
279
|
# in case resolution was changed, overrule the camera_config attribute
|
|
279
280
|
da_proj.attrs.update(camera_config=cc.to_json())
|
|
280
281
|
return da_proj
|
|
281
282
|
|
|
282
|
-
def landmask(self, dilate_iter=10, samples=15):
|
|
283
|
-
"""Attempt to mask out land from water.
|
|
284
|
-
|
|
285
|
-
This is done by assuming that the time standard deviation over mean of land is much
|
|
286
|
-
higher than that of water. An automatic threshold using Otsu thresholding is used to separate and a dilation
|
|
287
|
-
operation is used to make the land mask slightly larger than the exact defined pixels.
|
|
288
|
-
|
|
289
|
-
Parameters
|
|
290
|
-
----------
|
|
291
|
-
dilate_iter : int, optional
|
|
292
|
-
number of dilation iterations to use, to dilate land mask (Default value = 10)
|
|
293
|
-
samples : int, optional
|
|
294
|
-
amount of samples to retrieve from frames for estimating standard deviation and mean. Set to a lower
|
|
295
|
-
number to speed up calculation (Default value = 15)
|
|
296
|
-
|
|
297
|
-
Returns
|
|
298
|
-
-------
|
|
299
|
-
da : xr.DataArray
|
|
300
|
-
filtered frames
|
|
301
|
-
|
|
302
|
-
"""
|
|
303
|
-
time_interval = round(len(self._obj) / samples)
|
|
304
|
-
assert time_interval != 0, f"Amount of frames is too small to provide {samples} samples"
|
|
305
|
-
# ensure attributes are kept
|
|
306
|
-
xr.set_options(keep_attrs=True)
|
|
307
|
-
# compute standard deviation over mean, assuming this value is low over water, and high over land
|
|
308
|
-
std_norm = (self._obj[::time_interval].std(axis=0) / self._obj[::time_interval].mean(axis=0)).load()
|
|
309
|
-
# retrieve a simple 3x3 equal weight kernel
|
|
310
|
-
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
|
|
311
|
-
# dilate the std_norm by some dilation iterations
|
|
312
|
-
dilate_std_norm = cv2.dilate(std_norm.values, kernel, iterations=dilate_iter)
|
|
313
|
-
# rescale result to typical uint8 0-255 range
|
|
314
|
-
img = cv2.normalize(
|
|
315
|
-
dilate_std_norm, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_32F
|
|
316
|
-
).astype(np.uint8)
|
|
317
|
-
# threshold with Otsu thresholding
|
|
318
|
-
ret, thres = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
319
|
-
# mask is where thres is
|
|
320
|
-
mask = thres != 255
|
|
321
|
-
# make mask 3-dimensional
|
|
322
|
-
return self._obj * mask
|
|
323
|
-
|
|
324
283
|
def normalize(self, samples=15):
|
|
325
284
|
"""Remove the temporal mean of sampled frames.
|
|
326
285
|
|
|
@@ -406,6 +365,23 @@ class Frames(ORCBase):
|
|
|
406
365
|
"""
|
|
407
366
|
return np.maximum(np.minimum(self._obj, max), min)
|
|
408
367
|
|
|
368
|
+
def range(self):
|
|
369
|
+
"""Return the range of pixel values through time.
|
|
370
|
+
|
|
371
|
+
Returned array does not have a time dimension. This filter is typically used to detect
|
|
372
|
+
widely changing pixels, e.g. to distinguish moving water from land.
|
|
373
|
+
|
|
374
|
+
Returns
|
|
375
|
+
-------
|
|
376
|
+
xr.DataArray
|
|
377
|
+
Single image (with coordinates) with minimum-maximum range in time [x, y]
|
|
378
|
+
|
|
379
|
+
"""
|
|
380
|
+
range_da = (self._obj.max(dim="time", keep_attrs=True) - self._obj.min(dim="time", keep_attrs=True)).astype(
|
|
381
|
+
self._obj.dtype
|
|
382
|
+
) # ensure dtype out is same as dtype in
|
|
383
|
+
return range_da
|
|
384
|
+
|
|
409
385
|
def reduce_rolling(self, samples=25):
|
|
410
386
|
"""Remove a rolling mean from the frames.
|
|
411
387
|
|
|
@@ -601,21 +577,6 @@ class Frames(ORCBase):
|
|
|
601
577
|
|
|
602
578
|
out.write(img)
|
|
603
579
|
pbar.update(1)
|
|
604
|
-
#
|
|
605
|
-
# pbar = tqdm(self._obj, position=0, leave=True)
|
|
606
|
-
# pbar.set_description("Writing frames")
|
|
607
|
-
# for n, f in enumerate(pbar):
|
|
608
|
-
# if len(f.shape) == 3:
|
|
609
|
-
# img = cv2.cvtColor(np.uint8(f.values), cv2.COLOR_RGB2BGR)
|
|
610
|
-
# else:
|
|
611
|
-
# img = f.values
|
|
612
|
-
# if n == 0:
|
|
613
|
-
# # make a scale between 0 and 255, only with first frame
|
|
614
|
-
# img_min = img.min(axis=0).min(axis=0)
|
|
615
|
-
# img_max = img.max(axis=0).max(axis=0)
|
|
616
|
-
# img = np.uint8(255 * ((img - img_min) / (img_max - img_min)))
|
|
617
|
-
# img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
|
618
|
-
# out.write(img)
|
|
619
580
|
out.release()
|
|
620
581
|
|
|
621
582
|
plot = _frames_plot
|
pyorc/api/plot.py
CHANGED
|
@@ -6,8 +6,7 @@ import functools
|
|
|
6
6
|
import matplotlib.pyplot as plt
|
|
7
7
|
import matplotlib.ticker as mticker
|
|
8
8
|
import numpy as np
|
|
9
|
-
from matplotlib import patheffects
|
|
10
|
-
from matplotlib.collections import QuadMesh
|
|
9
|
+
from matplotlib import colors, patheffects
|
|
11
10
|
|
|
12
11
|
from pyorc import helpers
|
|
13
12
|
|
|
@@ -132,7 +131,7 @@ def _base_plot(plot_func):
|
|
|
132
131
|
|
|
133
132
|
# check if dataset is a transect or not
|
|
134
133
|
is_transect = True if "points" in ref._obj.dims else False
|
|
135
|
-
|
|
134
|
+
kwargs = set_default_kwargs(kwargs, method=plot_func.__name__, mode=mode)
|
|
136
135
|
assert mode in ["local", "geographical", "camera"], 'Mode must be "local", "geographical" or "camera"'
|
|
137
136
|
if mode == "local":
|
|
138
137
|
x = ref._obj["x"].values
|
|
@@ -185,16 +184,26 @@ def _base_plot(plot_func):
|
|
|
185
184
|
planar=False,
|
|
186
185
|
bottom=False,
|
|
187
186
|
)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
ax=ax,
|
|
193
|
-
camera=True,
|
|
194
|
-
swap_y_coords=True,
|
|
195
|
-
color="r",
|
|
196
|
-
label="water level",
|
|
187
|
+
# check if water level is above the lowest level
|
|
188
|
+
check_low = (
|
|
189
|
+
ref._obj.transect.camera_config.h_to_z(ref._obj.transect.h_a)
|
|
190
|
+
> ref._obj.transect.cross_section.z.min()
|
|
197
191
|
)
|
|
192
|
+
check_high = (
|
|
193
|
+
ref._obj.transect.camera_config.h_to_z(ref._obj.transect.h_a)
|
|
194
|
+
< ref._obj.transect.cross_section.z.max()
|
|
195
|
+
)
|
|
196
|
+
if check_low and check_high:
|
|
197
|
+
ref._obj.transect.cross_section.plot_water_level(
|
|
198
|
+
h=ref._obj.transect.h_a,
|
|
199
|
+
length=2.0,
|
|
200
|
+
linewidth=3.0,
|
|
201
|
+
ax=ax,
|
|
202
|
+
camera=True,
|
|
203
|
+
swap_y_coords=True,
|
|
204
|
+
color="r",
|
|
205
|
+
label="water level",
|
|
206
|
+
)
|
|
198
207
|
|
|
199
208
|
# draw some depth lines for better visual interpretation.
|
|
200
209
|
depth_lines = ref._obj.transect.get_depth_perspective(h=ref._obj.transect.h_a)
|
|
@@ -279,23 +288,22 @@ def _frames_plot(ref, ax=None, mode="local", **kwargs):
|
|
|
279
288
|
x = "xp"
|
|
280
289
|
y = "yp"
|
|
281
290
|
assert all(v in ref._obj.coords for v in [x, y]), f'required coordinates "{x}" and/or "{y}" are not available'
|
|
282
|
-
if
|
|
283
|
-
#
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
ref._obj[
|
|
289
|
-
ref._obj.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
# array has dims
|
|
296
|
-
QuadMesh.set_array(primitive, None)
|
|
297
|
-
else:
|
|
291
|
+
if x == "x":
|
|
292
|
+
# use a simple imshow, much faster
|
|
293
|
+
dx = abs(float(ref._obj[x][1] - ref._obj[x][0])) # x grid cell size
|
|
294
|
+
dy = abs(float(ref._obj[y][1] - ref._obj[y][0])) # y grid cell size
|
|
295
|
+
# Calculate extent with half grid cell expansion
|
|
296
|
+
extent = [
|
|
297
|
+
ref._obj[x].min().item() - 0.5 * dx,
|
|
298
|
+
ref._obj[x].max().item() + 0.5 * dx,
|
|
299
|
+
ref._obj[y].min().item() - 0.5 * dy,
|
|
300
|
+
ref._obj[y].max().item() + 0.5 * dy,
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
if x != "x":
|
|
298
304
|
primitive = ax.pcolormesh(ref._obj[x], ref._obj[y], ref._obj, **kwargs)
|
|
305
|
+
else:
|
|
306
|
+
primitive = ax.imshow(ref._obj, origin="upper", extent=extent, aspect="auto", **kwargs)
|
|
299
307
|
# fix axis limits to min and max of extent of frames
|
|
300
308
|
if mode == "geographical":
|
|
301
309
|
ax.set_extent(
|
|
@@ -371,7 +379,8 @@ class _Transect_PlotMethods:
|
|
|
371
379
|
_u = self._obj[v_eff] * np.sin(self._obj[v_dir])
|
|
372
380
|
_v = self._obj[v_eff] * np.cos(self._obj[v_dir])
|
|
373
381
|
s = np.abs(self._obj[v_eff].values)
|
|
374
|
-
x_moved, y_moved = x + _u * dt, y + _v * dt
|
|
382
|
+
# x_moved, y_moved = x + _u * dt, y + _v * dt
|
|
383
|
+
x_moved, y_moved = x + _u, y + _v
|
|
375
384
|
# transform to real-world
|
|
376
385
|
cols_moved, rows_moved = x_moved / camera_config.resolution, y_moved / camera_config.resolution
|
|
377
386
|
rows_moved = camera_config.shape[0] - rows_moved
|
|
@@ -496,9 +505,11 @@ class _Velocimetry_PlotMethods:
|
|
|
496
505
|
"""
|
|
497
506
|
# select lon and lat variables as coordinates
|
|
498
507
|
velocimetry = self._obj.velocimetry
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
508
|
+
v_x = self._obj["v_x"].values
|
|
509
|
+
v_y = self._obj["v_y"].values
|
|
510
|
+
u = v_x / (2 * 1e5) # 1e5 is aobut the amount of meters per degree
|
|
511
|
+
v = -v_y / (2 * 1e5)
|
|
512
|
+
s = (v_x**2 + v_y**2) ** 0.5
|
|
502
513
|
aff = velocimetry.camera_config.transform
|
|
503
514
|
theta = np.arctan2(aff.d, aff.a)
|
|
504
515
|
# rotate velocity vectors along angle theta to match the requested projection. this only changes values
|
|
@@ -519,9 +530,14 @@ class _Velocimetry_PlotMethods:
|
|
|
519
530
|
scalar velocity
|
|
520
531
|
|
|
521
532
|
"""
|
|
522
|
-
u
|
|
523
|
-
|
|
524
|
-
|
|
533
|
+
# u and v should be scaled to a nice proportionality for local plots. This is typically about 2x smaller
|
|
534
|
+
# than the m/s values
|
|
535
|
+
v_x = self._obj["v_x"].values
|
|
536
|
+
v_y = self._obj["v_y"].values
|
|
537
|
+
|
|
538
|
+
u = v_x / 2
|
|
539
|
+
v = -v_y / 2
|
|
540
|
+
s = (v_x**2 + v_y**2) ** 0.5
|
|
525
541
|
return u, v, s
|
|
526
542
|
|
|
527
543
|
def get_uv_camera(self, dt=0.1):
|
|
@@ -552,7 +568,8 @@ class _Velocimetry_PlotMethods:
|
|
|
552
568
|
yi = np.flipud(yi)
|
|
553
569
|
|
|
554
570
|
# follow the velocity vector over a short distance (dt*velocity)
|
|
555
|
-
x_moved, y_moved = xi + self._obj["v_x"] * dt, yi + self._obj["v_y"] * dt
|
|
571
|
+
# x_moved, y_moved = xi + self._obj["v_x"] * dt, yi + self._obj["v_y"] * dt
|
|
572
|
+
x_moved, y_moved = xi + self._obj["v_x"] / 2, yi + self._obj["v_y"] / 2
|
|
556
573
|
# transform to real-world
|
|
557
574
|
cols_moved, rows_moved = x_moved / camera_config.resolution, y_moved / camera_config.resolution
|
|
558
575
|
xs_moved, ys_moved = helpers.get_xs_ys(cols_moved, rows_moved, camera_config.transform)
|
|
@@ -578,10 +595,51 @@ class _Velocimetry_PlotMethods:
|
|
|
578
595
|
return u, v, s
|
|
579
596
|
|
|
580
597
|
|
|
598
|
+
def set_default_kwargs(kwargs, method="quiver", mode="local"):
|
|
599
|
+
"""Set color mapping default kwargs if no vmin and/or vmax is supplied."""
|
|
600
|
+
if mode == "local":
|
|
601
|
+
# width scale is in cm
|
|
602
|
+
width_scale = 0.02
|
|
603
|
+
elif mode == "geographical":
|
|
604
|
+
# widths are in degrees
|
|
605
|
+
width_scale = 0.00000025
|
|
606
|
+
elif mode == "camera":
|
|
607
|
+
# widths in pixels
|
|
608
|
+
width_scale = 3.0
|
|
609
|
+
else:
|
|
610
|
+
raise ValueError("mode must be one of 'local', 'geographical' or 'camera'")
|
|
611
|
+
if "cmap" not in kwargs:
|
|
612
|
+
kwargs["cmap"] = "rainbow" # the famous rainbow colormap!
|
|
613
|
+
if "vmin" not in kwargs and "vmax" not in kwargs and "norm" not in kwargs:
|
|
614
|
+
# set a normalization array
|
|
615
|
+
norm = [0, 0.05, 0.1, 0.2, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0]
|
|
616
|
+
kwargs["norm"] = colors.BoundaryNorm(norm, ncolors=256, extend="max")
|
|
617
|
+
if method == "quiver":
|
|
618
|
+
if "scale" not in kwargs:
|
|
619
|
+
kwargs["scale"] = 1 # larger quiver arrows
|
|
620
|
+
if "units" not in kwargs:
|
|
621
|
+
kwargs["units"] = "xy"
|
|
622
|
+
# for width, it will matter a lot what mode is used, width should be a dimensionless number
|
|
623
|
+
if "width" not in kwargs:
|
|
624
|
+
kwargs["width"] = width_scale
|
|
625
|
+
else:
|
|
626
|
+
kwargs["width"] *= width_scale # multiply
|
|
627
|
+
|
|
628
|
+
return kwargs
|
|
629
|
+
|
|
630
|
+
|
|
581
631
|
@_base_plot
|
|
582
632
|
def quiver(_, x, y, u, v, s=None, ax=None, **kwargs):
|
|
583
633
|
"""Create quiver plot from velocimetry results on new or existing axes.
|
|
584
634
|
|
|
635
|
+
Note that the `width` parameter is always in a unitless scale, with `1` providing a nice-looking default value
|
|
636
|
+
for all plot modes. The default value is usually very nice. If you do want to change:
|
|
637
|
+
|
|
638
|
+
The `scale` parameter defaults to 1, providing a nice looking arrow length. With a smaller (larger) value,
|
|
639
|
+
quivers will become longer (shorter).
|
|
640
|
+
|
|
641
|
+
The `width` parameter is in a unitless scale, with `1` providing a nice-looking default value.
|
|
642
|
+
|
|
585
643
|
Wraps :py:func:`matplotlib:matplotlib.pyplot.quiver`.
|
|
586
644
|
"""
|
|
587
645
|
if "color" in kwargs:
|
pyorc/cli/cli_utils.py
CHANGED
|
@@ -136,7 +136,12 @@ def get_gcps_optimized_fit(src, dst, height, width, c=2.0, camera_matrix=None, d
|
|
|
136
136
|
src_est, jacobian = cv2.projectPoints(_dst, rvec, tvec, camera_matrix, np.array(dist_coeffs))
|
|
137
137
|
src_est = np.array([list(point[0]) for point in src_est])
|
|
138
138
|
dst_est = cv.unproject_points(_src, _dst[:, -1], rvec, tvec, camera_matrix, dist_coeffs)
|
|
139
|
+
# add mean coordinates to estimated locations
|
|
139
140
|
dst_est = np.array(dst_est)[:, 0 : len(coord_mean)] + coord_mean
|
|
141
|
+
# also reverse rvec and tvec to real-world (rw), add mean coordinate, and reverse again.
|
|
142
|
+
rvec_cam, tvec_cam = cv.pose_world_to_camera(rvec, tvec)
|
|
143
|
+
tvec_cam += coord_mean
|
|
144
|
+
rvec, tvec = cv.pose_world_to_camera(rvec_cam, tvec_cam)
|
|
140
145
|
return src_est, dst_est, camera_matrix, dist_coeffs, rvec, tvec, err
|
|
141
146
|
|
|
142
147
|
|
|
@@ -324,15 +329,8 @@ def read_shape_as_gdf(fn=None, geojson=None, gdf=None):
|
|
|
324
329
|
crs = None
|
|
325
330
|
gdf = gpd.GeoDataFrame().from_features(geojson, crs=crs)
|
|
326
331
|
else:
|
|
327
|
-
gdf =
|
|
328
|
-
crs = gdf.crs
|
|
329
|
-
# also read raw json, and check if crs attribute exists
|
|
330
|
-
with open(fn, "r") as f:
|
|
331
|
-
raw_json = json.load(f)
|
|
332
|
-
if "crs" not in raw_json:
|
|
333
|
-
# override the crs
|
|
334
|
-
crs = None
|
|
335
|
-
gdf = gdf.set_crs(None, allow_override=True)
|
|
332
|
+
gdf = helpers.read_shape_safe_crs(fn)
|
|
333
|
+
crs = gdf.crs
|
|
336
334
|
# check if all geometries are points
|
|
337
335
|
assert all([isinstance(geom, Point) for geom in gdf.geometry]), (
|
|
338
336
|
"shapefile may only contain geometries of type " '"Point"'
|
pyorc/cli/main.py
CHANGED
|
@@ -312,9 +312,17 @@ def camera_config(
|
|
|
312
312
|
@click.option(
|
|
313
313
|
"--cross",
|
|
314
314
|
type=click.Path(exists=True, resolve_path=True, dir_okay=False, file_okay=True),
|
|
315
|
+
help="Cross section file (*.geojson). This will be used for discharge estimation if the `transect` "
|
|
316
|
+
" section is provided in your recipe.",
|
|
317
|
+
callback=cli_utils.parse_cross_section_gdf,
|
|
318
|
+
required=False,
|
|
319
|
+
)
|
|
320
|
+
@click.option(
|
|
321
|
+
"--cross_wl",
|
|
322
|
+
type=click.Path(exists=True, resolve_path=True, dir_okay=False, file_okay=True),
|
|
315
323
|
help="Cross section file (*.geojson). If you provide this, you may add water level retrieval settings to the"
|
|
316
|
-
" recipe section
|
|
317
|
-
" [PyORC docs](https://localdevices.github.io/pyorc/user-guide/
|
|
324
|
+
" recipe in the section `water_level`. For more information see"
|
|
325
|
+
" [PyORC docs](https://localdevices.github.io/pyorc/user-guide/cross_section/index.html)",
|
|
318
326
|
callback=cli_utils.parse_cross_section_gdf,
|
|
319
327
|
required=False,
|
|
320
328
|
)
|
|
@@ -333,7 +341,7 @@ def camera_config(
|
|
|
333
341
|
)
|
|
334
342
|
@verbose_opt
|
|
335
343
|
@click.pass_context
|
|
336
|
-
def velocimetry(ctx, output, videofile, recipe, cameraconfig, prefix, h_a, cross, update, lowmem, verbose):
|
|
344
|
+
def velocimetry(ctx, output, videofile, recipe, cameraconfig, prefix, h_a, cross, cross_wl, update, lowmem, verbose):
|
|
337
345
|
"""CLI subcommand for velocimetry."""
|
|
338
346
|
log_level = max(10, 20 - 10 * verbose)
|
|
339
347
|
logger = log.setuplog("velocimetry", os.path.abspath("pyorc.log"), append=False, log_level=log_level)
|
|
@@ -345,6 +353,7 @@ def velocimetry(ctx, output, videofile, recipe, cameraconfig, prefix, h_a, cross
|
|
|
345
353
|
cameraconfig=cameraconfig,
|
|
346
354
|
h_a=h_a,
|
|
347
355
|
cross=cross,
|
|
356
|
+
cross_wl=cross_wl,
|
|
348
357
|
prefix=prefix,
|
|
349
358
|
output=output,
|
|
350
359
|
update=update,
|
pyorc/cv.py
CHANGED
|
@@ -9,7 +9,7 @@ import numpy as np
|
|
|
9
9
|
import rasterio
|
|
10
10
|
from scipy import optimize
|
|
11
11
|
from shapely.affinity import rotate
|
|
12
|
-
from shapely.geometry import LineString, Polygon
|
|
12
|
+
from shapely.geometry import LineString, Point, Polygon
|
|
13
13
|
from tqdm import tqdm
|
|
14
14
|
|
|
15
15
|
from . import helpers
|
|
@@ -88,6 +88,56 @@ def _combine_m(m1, m2):
|
|
|
88
88
|
return m_combi
|
|
89
89
|
|
|
90
90
|
|
|
91
|
+
def _get_aoi_corners(dst_corners, resolution=None):
|
|
92
|
+
polygon = Polygon(dst_corners)
|
|
93
|
+
coords = np.array(polygon.exterior.coords)
|
|
94
|
+
# estimate the angle of the bounding box
|
|
95
|
+
# retrieve average line across AOI
|
|
96
|
+
point1 = (coords[0] + coords[3]) / 2
|
|
97
|
+
point2 = (coords[1] + coords[2]) / 2
|
|
98
|
+
diff = point2 - point1
|
|
99
|
+
angle = np.arctan2(diff[1], diff[0])
|
|
100
|
+
# rotate the polygon over this angle to get a proper bounding box
|
|
101
|
+
polygon_rotate = rotate(polygon, -angle, origin=tuple(dst_corners[0]), use_radians=True)
|
|
102
|
+
|
|
103
|
+
xmin, ymin, xmax, ymax = polygon_rotate.bounds
|
|
104
|
+
if resolution is not None:
|
|
105
|
+
xmin = helpers.round_to_multiple(xmin, resolution)
|
|
106
|
+
xmax = helpers.round_to_multiple(xmax, resolution)
|
|
107
|
+
ymin = helpers.round_to_multiple(ymin, resolution)
|
|
108
|
+
ymax = helpers.round_to_multiple(ymax, resolution)
|
|
109
|
+
|
|
110
|
+
bbox_coords = [(xmin, ymax), (xmax, ymax), (xmax, ymin), (xmin, ymin), (xmin, ymax)]
|
|
111
|
+
bbox = Polygon(bbox_coords)
|
|
112
|
+
# now rotate back
|
|
113
|
+
bbox = rotate(bbox, angle, origin=tuple(dst_corners[0]), use_radians=True)
|
|
114
|
+
return bbox
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _get_aoi_width_length(dst_corners):
|
|
118
|
+
points = [Point(x, y) for x, y, _ in dst_corners]
|
|
119
|
+
linecross = LineString([points[0], points[1]])
|
|
120
|
+
# linecross = LineString(dst_corners[0:2])
|
|
121
|
+
length = np.abs(_get_perpendicular_distance(points[-1], linecross))
|
|
122
|
+
point1 = np.array(dst_corners[0][0:2])
|
|
123
|
+
point2 = np.array(dst_corners[1][0:2])
|
|
124
|
+
diff = np.array(point2 - point1)
|
|
125
|
+
angle = np.arctan2(diff[1], diff[0])
|
|
126
|
+
|
|
127
|
+
# compute xy distance from line to other line making up the bounding box
|
|
128
|
+
xy_diff = np.array([np.sin(-angle) * length, np.cos(angle) * length])
|
|
129
|
+
points_pol = np.array([point1 - xy_diff, point1 + xy_diff, point2 + xy_diff, point2 - xy_diff])
|
|
130
|
+
# always make sure the order of the points of upstream-left, downstream-left, downstream-right, upstream-right
|
|
131
|
+
# if length <= 0:
|
|
132
|
+
# # negative length means the selected length is selected upstream of left-right cross section
|
|
133
|
+
# points_pol = np.array([point1 + xy_diff, point1, point2, point2 + xy_diff])
|
|
134
|
+
# else:
|
|
135
|
+
# # postive means it is selected downstream of left-right cross section
|
|
136
|
+
# points_pol = np.array([point1, point1 + xy_diff, point2 + xy_diff, point2])
|
|
137
|
+
|
|
138
|
+
return Polygon(points_pol)
|
|
139
|
+
|
|
140
|
+
|
|
91
141
|
def _smooth(img, stride):
|
|
92
142
|
"""Blur image through gaussian smoothing.
|
|
93
143
|
|
|
@@ -152,6 +202,52 @@ def _get_dist_coefs(k1):
|
|
|
152
202
|
return dist
|
|
153
203
|
|
|
154
204
|
|
|
205
|
+
def _get_perpendicular_distance(point, line):
|
|
206
|
+
"""Calculate perpendicular distance from point to line.
|
|
207
|
+
|
|
208
|
+
Line is extended if perpendicular distance is larger than the distance to the endpoints.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
point : shapely.geometry.Point
|
|
213
|
+
x, y coordinates of point
|
|
214
|
+
line : shapely.geometry.LineString
|
|
215
|
+
line to calculate distance to
|
|
216
|
+
|
|
217
|
+
Returns
|
|
218
|
+
-------
|
|
219
|
+
float
|
|
220
|
+
perpendicular distance from point to line
|
|
221
|
+
|
|
222
|
+
"""
|
|
223
|
+
# Get coordinates of line endpoints
|
|
224
|
+
p1 = np.array(line.coords[0])
|
|
225
|
+
p2 = np.array(line.coords[1])
|
|
226
|
+
# Convert point to numpy array
|
|
227
|
+
p3 = np.array(point.coords[0])
|
|
228
|
+
|
|
229
|
+
# Calculate line vector
|
|
230
|
+
line_vector = p2 - p1
|
|
231
|
+
# Calculate vector from point to line start
|
|
232
|
+
point_vector = p3 - p1
|
|
233
|
+
|
|
234
|
+
# Calculate unit vector of line
|
|
235
|
+
unit_line = line_vector / np.linalg.norm(line_vector)
|
|
236
|
+
|
|
237
|
+
# Calculate projection length
|
|
238
|
+
projection_length = np.dot(point_vector, unit_line)
|
|
239
|
+
|
|
240
|
+
# Calculate perpendicular vector
|
|
241
|
+
perpendicular_vector = point_vector - projection_length * unit_line
|
|
242
|
+
perpendicular_distance = np.linalg.norm(perpendicular_vector)
|
|
243
|
+
|
|
244
|
+
# Use cross product to calculate side
|
|
245
|
+
cross_product = np.cross(line_vector, point_vector)
|
|
246
|
+
|
|
247
|
+
# Determine the sign of the perpendicular distance
|
|
248
|
+
return perpendicular_distance if cross_product > 0 else -perpendicular_distance
|
|
249
|
+
|
|
250
|
+
|
|
155
251
|
def get_cam_mtx(height, width, c=2.0, focal_length=None):
|
|
156
252
|
"""Compute camera matrix based on the given parameters for height, width, scaling factor, and focal length.
|
|
157
253
|
|
|
@@ -907,15 +1003,19 @@ def get_ortho(img, M, shape, flags=cv2.INTER_AREA):
|
|
|
907
1003
|
return cv2.warpPerspective(img, M, shape, flags=flags)
|
|
908
1004
|
|
|
909
1005
|
|
|
910
|
-
def get_aoi(dst_corners, resolution=None):
|
|
911
|
-
"""Get rectangular AOI from 4 user defined points within frames.
|
|
1006
|
+
def get_aoi(dst_corners, resolution=None, method="corners"):
|
|
1007
|
+
"""Get rectangular AOI from 3 or 4 user defined points within frames.
|
|
912
1008
|
|
|
913
1009
|
Parameters
|
|
914
1010
|
----------
|
|
915
1011
|
dst_corners : np.ndarray
|
|
916
|
-
corners of aoi, in order:
|
|
1012
|
+
corners of aoi, with `method="width_length"` in order: left-bank, right-bank, up/downstream point,
|
|
1013
|
+
with `method="corners"` in order: upstream-left, downstream-left, downstream-right, upstream-right.
|
|
917
1014
|
resolution : float
|
|
918
1015
|
resolution of intended reprojection, used to round the bbox to a whole number of intended pixels
|
|
1016
|
+
method : str
|
|
1017
|
+
can be "corners" or "width_length". With "corners", the AOI is defined by the four corners of the rectangle.
|
|
1018
|
+
With "width" length, the AOI is defined by the width (2 points) and length (1 point) of the rectangle.
|
|
919
1019
|
|
|
920
1020
|
Returns
|
|
921
1021
|
-------
|
|
@@ -923,27 +1023,14 @@ def get_aoi(dst_corners, resolution=None):
|
|
|
923
1023
|
bounding box of aoi (with rotated affine)
|
|
924
1024
|
|
|
925
1025
|
"""
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
angle = np.arctan2(diff[1], diff[0])
|
|
934
|
-
# rotate the polygon over this angle to get a proper bounding box
|
|
935
|
-
polygon_rotate = rotate(polygon, -angle, origin=tuple(dst_corners[0]), use_radians=True)
|
|
936
|
-
xmin, ymin, xmax, ymax = polygon_rotate.bounds
|
|
937
|
-
if resolution is not None:
|
|
938
|
-
xmin = helpers.round_to_multiple(xmin, resolution)
|
|
939
|
-
xmax = helpers.round_to_multiple(xmax, resolution)
|
|
940
|
-
ymin = helpers.round_to_multiple(ymin, resolution)
|
|
941
|
-
ymax = helpers.round_to_multiple(ymax, resolution)
|
|
1026
|
+
if method == "corners":
|
|
1027
|
+
bbox = _get_aoi_corners(dst_corners, resolution)
|
|
1028
|
+
elif method == "width_length":
|
|
1029
|
+
bbox = _get_aoi_width_length(dst_corners)
|
|
1030
|
+
|
|
1031
|
+
else:
|
|
1032
|
+
raise ValueError("method must be 'corners' or 'width_length'")
|
|
942
1033
|
|
|
943
|
-
bbox_coords = [(xmin, ymax), (xmax, ymax), (xmax, ymin), (xmin, ymin), (xmin, ymax)]
|
|
944
|
-
bbox = Polygon(bbox_coords)
|
|
945
|
-
# now rotate back
|
|
946
|
-
bbox = rotate(bbox, angle, origin=tuple(dst_corners[0]), use_radians=True)
|
|
947
1034
|
return bbox
|
|
948
1035
|
|
|
949
1036
|
|
pyorc/helpers.py
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
4
|
import importlib.util
|
|
5
|
+
import json
|
|
5
6
|
|
|
6
7
|
import cv2
|
|
8
|
+
import geopandas as gpd
|
|
7
9
|
import matplotlib.pyplot as plt
|
|
8
10
|
import numpy as np
|
|
9
11
|
import xarray as xr
|
|
@@ -582,6 +584,27 @@ def optimize_log_profile(
|
|
|
582
584
|
return {"z0": z0, "k_max": k_max, "s0": s0, "s1": s1}
|
|
583
585
|
|
|
584
586
|
|
|
587
|
+
def read_shape_safe_crs(fn):
|
|
588
|
+
"""Read a shapefile with geopandas, but ensure that CRS is set to None when not available.
|
|
589
|
+
|
|
590
|
+
This function is required in cases where geometries must be read that do not have a specified CRS. Geopandas
|
|
591
|
+
defaults to WGS84 EPSG 4326 if the CRS is not specified.
|
|
592
|
+
"""
|
|
593
|
+
gdf = gpd.read_file(fn)
|
|
594
|
+
# also read raw json, and check if crs attribute exists
|
|
595
|
+
if isinstance(fn, str):
|
|
596
|
+
with open(fn, "r") as f:
|
|
597
|
+
raw_json = json.load(f)
|
|
598
|
+
else:
|
|
599
|
+
# apparently a file object was provided
|
|
600
|
+
fn.seek(0)
|
|
601
|
+
raw_json = json.load(fn)
|
|
602
|
+
if "crs" not in raw_json:
|
|
603
|
+
# override the crs
|
|
604
|
+
gdf = gdf.set_crs(None, allow_override=True)
|
|
605
|
+
return gdf
|
|
606
|
+
|
|
607
|
+
|
|
585
608
|
def rotate_u_v(u, v, theta, deg=False):
|
|
586
609
|
"""Rotate u and v components of vector counterclockwise by an amount of rotation.
|
|
587
610
|
|
pyorc/plot_helpers.py
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import matplotlib.pyplot as plt
|
|
4
4
|
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
|
5
|
+
from shapely import geometry
|
|
5
6
|
|
|
6
7
|
|
|
7
|
-
def
|
|
8
|
-
"""Plot
|
|
8
|
+
def _plot_3d_pol(polygon, ax=None, **kwargs):
|
|
9
|
+
"""Plot single polygon on matplotlib 3d ax."""
|
|
9
10
|
x, y, z = zip(*polygon.exterior.coords)
|
|
10
11
|
verts = [list(zip(x, y, z))]
|
|
11
12
|
|
|
@@ -17,13 +18,27 @@ def plot_3d_polygon(polygon, ax=None, **kwargs):
|
|
|
17
18
|
return p
|
|
18
19
|
|
|
19
20
|
|
|
21
|
+
def plot_3d_polygon(polygon, ax=None, **kwargs):
|
|
22
|
+
"""Plot a shapely.geometry.Polygon or MultiPolygon on matplotlib 3d ax."""
|
|
23
|
+
if isinstance(polygon, geometry.MultiPolygon):
|
|
24
|
+
for pol in polygon.geoms:
|
|
25
|
+
p = _plot_3d_pol(pol, ax=ax, **kwargs)
|
|
26
|
+
else:
|
|
27
|
+
p = _plot_3d_pol(polygon, ax=ax, **kwargs)
|
|
28
|
+
return p
|
|
29
|
+
|
|
30
|
+
|
|
20
31
|
def plot_polygon(polygon, ax=None, **kwargs):
|
|
21
|
-
"""Plot a shapely.geometry.Polygon on matplotlib ax."""
|
|
22
|
-
# x, y = zip(*polygon.exterior.coords)
|
|
32
|
+
"""Plot a shapely.geometry.Polygon or MultiPolygon on matplotlib ax."""
|
|
23
33
|
if ax is None:
|
|
24
34
|
ax = plt.axes()
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
if isinstance(polygon, geometry.MultiPolygon):
|
|
36
|
+
for pol in polygon.geoms:
|
|
37
|
+
patch = plt.Polygon(pol.exterior.coords, **kwargs)
|
|
38
|
+
p = ax.add_patch(patch)
|
|
39
|
+
else:
|
|
40
|
+
patch = plt.Polygon(polygon.exterior.coords, **kwargs)
|
|
41
|
+
p = ax.add_patch(patch)
|
|
27
42
|
return p
|
|
28
43
|
|
|
29
44
|
|