Skip to content

Commit 950f50f

Browse files
authored
Merge pull request #17 from hmakelin/hmakelin-height
Enable use of elevation data for pose estimation
2 parents 654e5b7 + d063213 commit 950f50f

File tree

8 files changed

+121
-27
lines changed

8 files changed

+121
-27
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ You will need to have [NVIDIA Container Toolkit][2] for Docker installed.
2727
```bash
2828
git clone https://github.com/hmakelin/gisnav-docker.git
2929
cd gisnav-docker
30-
docker-compose build --build-arg WITH_GISNAV px4-sitl
30+
docker-compose build px4-sitl
3131
```
3232

3333
> **Note** The build for the `px4-sitl` image takes a long time, especially if you are building it for the first time.

config/typhoon_h480__ksql_airport.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ mock_gps_node:
44
url: 'http://localhost:80/wms'
55
version: '1.3.0'
66
layers: ['imagery']
7+
elevation_layers: ['osm-buildings']
78
styles: ['']
89
srs: 'EPSG:4326'
910
image_format: 'image/jpeg'
1011
request_timeout: 10
1112
misc:
1213
attitude_deviation_threshold: 10 # degrees
1314
max_pitch: 30 # 30 # Used by _should_match
14-
min_match_altitude: 80
15+
min_match_altitude: 50
1516
map_update:
1617
update_delay: 1 # Keep low (e.g. 1 second) if vehicle flying fast or gimbal_projection: True
1718
gimbal_projection: True

docs/pages/developer_guide.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ You can use the below snippets to get started with your own :class:`.PoseEstimat
247247
# TODO: implement initializer
248248
raise NotImplementedError
249249
250-
def estimate_pose(query, reference, k, guess):
250+
def estimate_pose(query, reference, k, guess = None, elevation_ref = None):
251251
"""Returns pose between query and reference images"""
252252
# Do your pose estimation magic here
253253
#r = ... # np.ndarray of shape (3, 3)
@@ -271,7 +271,7 @@ If you want to create a :class:`.KeypointPoseEstimator`, you can also start with
271271
272272
class MyPoseEstimator(KeypointPoseEstimator):
273273
274-
def __init__(self, ):
274+
def __init__(self):
275275
# TODO: implement initializer
276276
raise NotImplementedError
277277

gisnav/data.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,19 +160,28 @@ def __post_init__(self):
160160
@dataclass(frozen=True)
161161
class MapData(_ImageHolder):
162162
"""Keeps map frame related data in one place and protects it from corruption."""
163-
bbox: GeoBBox
163+
bbox: GeoSquare
164+
elevation: Optional[Img] = None # Optional elevation raster
165+
166+
def __post_init__(self):
167+
"""Post-initialization validity check."""
168+
if self.elevation is not None:
169+
assert self.elevation.arr.shape[0:2], self.image.arr.shape[0:2]
170+
assert_ndim(self.elevation.arr, 2) # Grayscale image expected
164171

165172

166173
# noinspection PyClassHasNoInit
167174
@dataclass(frozen=True)
168175
class ContextualMapData(_ImageHolder):
169176
"""Contains the rotated and cropped map image for _match estimation"""
170177
image: Img = field(init=False) # This is the cropped and rotated map which is same size as the camera frames
178+
elevation: Optional[Img] = field(init=None) # Rotated elevation raster (optional) in meters
171179
rotation: float # radians
172180
crop: Dim # Same value will also be found at image.dim (but not at initialization)
173181
map_data: MapData # This is the original (square) map with padding
174182
pix_to_wgs84: np.ndarray = field(init=False)
175183
mock_data: bool = False # Indicates that this was used for field of view guess (mock map data)
184+
altitude_scaling: Optional[float] = None # altitude scaling (elevation raster meters -> camera pixels)
176185

177186
def _pix_to_wgs84(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
178187
"""Returns tuple of affine 2D transformation matrix for converting matched pixel coordinates to WGS84 coordinates
@@ -214,17 +223,19 @@ def _pix_to_wgs84(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]
214223

215224
return pix_to_wgs84_ # , unrotated_to_wgs84, uncropped_to_unrotated, pix_to_uncropped
216225

217-
def _rotate_and_crop_map(self) -> np.ndarray:
226+
def _rotate_and_crop_map(self, elevation: bool = False) -> np.ndarray:
218227
"""Rotates map counter-clockwise and then crops a dimensions-sized part from the middle.
219228
220229
Map needs padding so that a circle with diameter of the diagonal of the img_size rectangle is enclosed in map.
221230
231+
:param elevation: Set True to do rotation on elevation raster instead
222232
:return: Rotated and cropped map raster
223233
"""
224-
cx, cy = tuple(np.array(self.map_data.image.arr.shape[0:2]) / 2)
234+
image = self.map_data.image if not elevation else self.map_data.elevation
235+
cx, cy = tuple(np.array(image.arr.shape[0:2]) / 2)
225236
degrees = math.degrees(self.rotation)
226237
r = cv2.getRotationMatrix2D((cx, cy), degrees, 1.0)
227-
map_rotated = cv2.warpAffine(self.map_data.image.arr, r, self.map_data.image.arr.shape[1::-1])
238+
map_rotated = cv2.warpAffine(image.arr, r, image.arr.shape[1::-1])
228239
map_cropped = self._crop_center(map_rotated, self.crop)
229240
#if visualize:
230241
#cv2.imshow('padded', self.map_data.image.arr)
@@ -256,6 +267,10 @@ def _crop_center(img: np.ndarray, dimensions: Dim) -> np.ndarray:
256267
def __post_init__(self):
257268
"""Set computed fields after initialization."""
258269
object.__setattr__(self, 'image', Img(self._rotate_and_crop_map()))
270+
if self.map_data.elevation is not None:
271+
object.__setattr__(self, 'elevation', Img(self._rotate_and_crop_map(True)))
272+
else:
273+
object.__setattr__(self, 'elevation', None)
259274
object.__setattr__(self, 'pix_to_wgs84', self._pix_to_wgs84())
260275

261276

gisnav/nodes/base_node.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from gisnav.data import Dim, TimePair, ImageData, MapData, CameraData, Attitude, DataValueError, \
2727
InputData, ImagePair, AsyncPoseQuery, AsyncWMSQuery, ContextualMapData, FixedCamera, Img, Pose, Position
2828
from gisnav.geo import GeoPoint, GeoSquare, GeoTrapezoid
29-
from gisnav.assertions import assert_type, assert_len
29+
from gisnav.assertions import assert_type, assert_len, assert_ndim, assert_shape
3030
from gisnav.pose_estimators.pose_estimator import PoseEstimator
3131
from gisnav.wms import WMSClient
3232

@@ -61,6 +61,14 @@ class BaseNode(Node, ABC):
6161
have just one layer for high resolution aerial or satellite imagery.
6262
"""
6363

64+
ROS_D_WMS_ELEVATION_LAYERS = []
65+
"""Default WMS request elevation layers parameter
66+
67+
.. note::
68+
This is an optional elevation layer that makes the pose estimation more accurate especially when flying at low
69+
altitude. It is assumed to be grayscale.
70+
"""
71+
6472
ROS_D_WMS_STYLES = ['']
6573
"""Default WMS request styles parameter
6674
@@ -472,6 +480,16 @@ def _gimbal_device_set_attitude(self, value: Optional[GimbalDeviceSetAttitude])
472480
# endregion
473481

474482
# region Computed Properties
483+
@property
484+
def _altitude_scaling(self) -> Optional[float]:
485+
"""Returns camera focal length divided by camera altitude in meters."""
486+
if self._camera_data is not None and self._altitude_agl is not None:
487+
return self._camera_data.fx / self._altitude_agl
488+
else:
489+
self.get_logger().warn('Could not estimate elevation scale because camera focal length and/or vehicle '
490+
'altitude is unknown.')
491+
return None
492+
475493
@property
476494
def _r_guess(self) -> Optional[np.ndarray]:
477495
"""Gimbal rotation matrix guess (based on :class:`px4_msgs.GimbalDeviceSetAttitude` message)
@@ -679,7 +697,8 @@ def _estimation_inputs(self) -> Optional[Tuple[InputData, ContextualMapData]]:
679697
self.get_logger().warn(f'Camera yaw unknown, cannot estimate pose.')
680698
return
681699

682-
contextual_map_data = ContextualMapData(rotation=camera_yaw, map_data=self._map_data, crop=self._img_dim)
700+
contextual_map_data = ContextualMapData(rotation=camera_yaw, map_data=self._map_data, crop=self._img_dim,
701+
altitude_scaling=self._altitude_scaling)
683702
return input_data, contextual_map_data
684703
# endregion
685704

@@ -932,7 +951,7 @@ def _gimbal_device_set_attitude_callback(self, msg: GimbalDeviceSetAttitude) ->
932951
# endregion
933952

934953
#region WMSWorkerCallbacks
935-
def _wms_pool_worker_callback(self, result: List[MapData]) -> None:
954+
def _wms_pool_worker_callback(self, result: Tuple[np.ndarray, Optional[np.ndarray]]) -> None:
936955
"""Handles result from :meth:`gisnav.wms.worker`.
937956
938957
Saves received result to :py:attr:`~_map_data. The result should be a collection containing a single
@@ -941,11 +960,17 @@ def _wms_pool_worker_callback(self, result: List[MapData]) -> None:
941960
:param result: Results from the asynchronous call
942961
:return:
943962
"""
944-
map_ = result
963+
map_, elevation = result
945964
assert_type(map_, np.ndarray)
965+
if elevation is not None:
966+
assert_ndim(elevation, 2)
967+
assert_shape(elevation, map_.shape[0:2])
968+
946969
# Should already have received camera info so _map_size_with_padding should not be None
947970
assert map_.shape[0:2] == self._map_size_with_padding, 'Decoded map is not of specified size.'
948-
map_data = MapData(bbox=self._wms_query.geobbox, image=Img(map_))
971+
972+
elevation = Img(elevation) if elevation is not None else None
973+
map_data = MapData(bbox=self._wms_query.geobbox, image=Img(map_), elevation=elevation)
949974
self.get_logger().info(f'Map received for bbox: {map_data.bbox.bounds}.')
950975
self._map_data = map_data
951976

@@ -1024,6 +1049,7 @@ def _request_new_map(self, bbox: GeoSquare) -> None:
10241049

10251050
# Build and send WMS request
10261051
layers = self.get_parameter('wms.layers').get_parameter_value().string_array_value
1052+
elevation_layers = self.get_parameter('wms.elevation_layers').get_parameter_value().string_array_value
10271053
styles = self.get_parameter('wms.styles').get_parameter_value().string_array_value
10281054
srs_str = self.get_parameter('wms.srs').get_parameter_value().string_value
10291055
image_format = self.get_parameter('wms.image_format').get_parameter_value().string_value
@@ -1036,10 +1062,13 @@ def _request_new_map(self, bbox: GeoSquare) -> None:
10361062
f'results were not yet ready.'
10371063
self.get_logger().info(f'Requesting map for bbox: {bbox.bounds}, layers: {layers}, srs: {srs_str}, format: '
10381064
f'{image_format}.')
1065+
args = [layers, styles, bbox.bounds, self._map_size_with_padding, srs_str, image_format]
1066+
if len(elevation_layers) > 0:
1067+
args.append(elevation_layers)
10391068
self._wms_query = AsyncWMSQuery(
10401069
result=self._wms_pool.apply_async(
10411070
WMSClient.worker,
1042-
(layers, styles, bbox.bounds, self._map_size_with_padding, srs_str, image_format),
1071+
tuple(args),
10431072
callback=self._wms_pool_worker_callback,
10441073
error_callback=self._wms_pool_worker_error_callback
10451074
),
@@ -1296,10 +1325,18 @@ def _estimate(self, image_pair: ImagePair, input_data: InputData) -> None:
12961325
"""
12971326
assert self._pose_estimation_query is None or self._pose_estimation_query.result.ready()
12981327
pose_guess = None if self._pose_guess is None else tuple(self._pose_guess)
1328+
1329+
# Scale elevation raster from meters to camera native pixels
1330+
elevation = image_pair.ref.elevation.arr if image_pair.ref.elevation is not None else None
1331+
if elevation is not None:
1332+
elevation = elevation * image_pair.ref.altitude_scaling if image_pair.ref.altitude_scaling is not None \
1333+
else None
1334+
12991335
self._pose_estimation_query = AsyncPoseQuery(
13001336
result=self._pose_estimator_pool.apply_async(
13011337
self._pose_estimator.worker,
1302-
(image_pair.qry.image.arr, image_pair.ref.image.arr, image_pair.qry.camera_data.k, pose_guess),
1338+
(image_pair.qry.image.arr, image_pair.ref.image.arr, image_pair.qry.camera_data.k, pose_guess,
1339+
elevation),
13031340
callback=self._pose_estimation_worker_callback,
13041341
error_callback=self._pose_estimation_worker_error_callback
13051342
),

gisnav/pose_estimators/keypoint_pose_estimator.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ def _find_matching_keypoints(self, query: np.ndarray, reference: np.ndarray) \
4242
pass
4343

4444
def estimate_pose(self, query: np.ndarray, reference: np.ndarray, k: np.ndarray,
45-
guess: Optional[Tuple[np.ndarray, np.ndarray]]) -> Optional[Tuple[np.ndarray, np.ndarray]]:
45+
guess: Optional[Tuple[np.ndarray, np.ndarray]] = None,
46+
elevation_reference: Optional[np.ndarray] = None) \
47+
-> Optional[Tuple[np.ndarray, np.ndarray]]:
4648
"""Returns pose between given query and reference images, or None if no pose could not be estimated
4749
4850
Uses :class:`._find_matching_keypoints` to estimate matching keypoints before estimating pose.
@@ -51,15 +53,27 @@ def estimate_pose(self, query: np.ndarray, reference: np.ndarray, k: np.ndarray,
5153
:param reference: The second (reference) image for pose estimation
5254
:param k: Camera intrinsics matrix (3, 3)
5355
:param guess: Optional initial guess for camera pose
56+
:param elevation_reference: Optional elevation raster (same size resolution as reference image, grayscale)
5457
:return: Pose tuple consisting of a rotation (3, 3) and translation (3, 1) np.ndarrays
5558
"""
59+
if elevation_reference is not None:
60+
assert elevation_reference.shape[0:2] == reference.shape[0:2]
5661
matched_keypoints = self._find_matching_keypoints(query, reference)
5762
if matched_keypoints is None or len(matched_keypoints[0]) < self._min_matches:
5863
return None # Not enough matching keypoints found
5964

6065
mkp1, mkp2 = matched_keypoints
61-
padding = np.array([[0]] * len(mkp1))
62-
mkp2_3d = np.hstack((mkp2, padding)) # Set world z-coordinates to zero
66+
67+
if elevation_reference is None:
68+
# Set world z values to zero (assume planar ground surface and estimate homography)
69+
z_values = np.array([[0]] * len(mkp1))
70+
else:
71+
# Z values are assumed to be in camera native coordinates (not in meters)
72+
x, y = np.transpose(np.floor(mkp2).astype(int)) # do floor first before converting to int
73+
z_values = elevation_reference[y, x].reshape(-1, 1) # y axis first
74+
75+
mkp2_3d = np.hstack((mkp2, z_values))
76+
6377
dist_coeffs = np.zeros((4, 1))
6478
use_guess = guess is not None
6579
r, t = guess if use_guess else (None, None)

gisnav/pose_estimators/pose_estimator.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,23 @@ def initializer(class_: type, *args) -> None:
3333

3434
@staticmethod
3535
def worker(query: np.ndarray, reference: np.ndarray, k: np.ndarray,
36-
guess: Optional[Tuple[np.ndarray, np.ndarray]]) -> Optional[Tuple[np.ndarray, np.ndarray]]:
36+
guess: Optional[Tuple[np.ndarray, np.ndarray]] = None,
37+
elevation_reference: Optional[np.ndarray] = None) -> Optional[Tuple[np.ndarray, np.ndarray]]:
3738
"""Returns pose between provided images, or None if pose cannot be estimated
3839
3940
:param query: The first (query) image for pose estimation
4041
:param reference: The second (reference) image for pose estimation
4142
:param k: Camera intrinsics matrix of shape (3, 3)
4243
:param guess: Optional initial guess for camera pose
44+
:param elevation_reference: Optional elevation raster (same size resolution as reference image, grayscale)
4345
:return: Pose tuple consisting of a rotation (3, 3) and translation (3, 1) numpy arrays
4446
"""
4547
pose_estimator: PoseEstimator = globals()[PoseEstimator.POSE_ESTIMATOR_GLOBAL_VAR]
4648

4749
if guess is not None:
4850
assert_pose(guess)
4951

50-
pose = pose_estimator.estimate_pose(query, reference, k, guess)
52+
pose = pose_estimator.estimate_pose(query, reference, k, guess, elevation_reference)
5153

5254
# Do independent check - child class might not check their output
5355
if pose is not None:
@@ -57,13 +59,15 @@ def worker(query: np.ndarray, reference: np.ndarray, k: np.ndarray,
5759

5860
@abstractmethod
5961
def estimate_pose(self, query: np.ndarray, reference: np.ndarray, k: np.ndarray,
60-
guess: Optional[Tuple[np.ndarray, np.ndarray]]) -> Optional[Tuple[np.ndarray, np.ndarray]]:
62+
guess: Optional[Tuple[np.ndarray, np.ndarray]] = None,
63+
elevation_reference: Optional[np.ndarray] = None) -> Optional[Tuple[np.ndarray, np.ndarray]]:
6164
"""Returns pose between provided images, or None if pose cannot be estimated
6265
6366
:param query: The first (query) image for pose estimation
6467
:param reference: The second (reference) image for pose estimation
6568
:param k: Camera intrinsics matrix of shape (3, 3)
6669
:param guess: Optional initial guess for camera pose
70+
:param elevation_reference: Optional elevation raster (same size resolution as reference image, grayscale)
6771
:return: Pose tuple of rotation (3, 3) and translation (3, 1) numpy arrays, or None if could not estimate
6872
"""
6973
pass

gisnav/wms.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
import numpy as np
55
import cv2
66

7-
from typing import Tuple, List
7+
from typing import Tuple, List, Optional
88
from owslib.wms import WebMapService
99
from owslib.util import ServiceException
1010

1111
from gisnav.assertions import assert_type, assert_ndim
12+
from gisnav.data import MapData, Img
1213

1314

1415
class WMSClient:
@@ -62,17 +63,22 @@ def initializer(url: str, version_: str, timeout_: int) -> None:
6263
globals()[WMSClient.WMS_CLIENT_GLOBAL_VAR] = WMSClient(url, version_, timeout_)
6364

6465
@staticmethod
65-
def worker(layers: List[str], styles: List[str], bbox: Tuple[float], map_size: Tuple[int, int], srs_str: str, image_format: str) \
66-
-> np.ndarray:
67-
"""Requests one or more map layers from the WMS server
66+
def worker(layers: List[str], styles: List[str], bbox: Tuple[float], map_size: Tuple[int, int], srs_str: str,
67+
image_format: str, elevation_layers: Optional[List[str]] = None) \
68+
-> Tuple[np.ndarray, Optional[np.ndarray]]:
69+
"""Requests one or more map imagery and optional height raster layers from the WMS server
70+
71+
TODO: Currently no support for separate arguments for imagery and height layers. Assumes height layer is
72+
available at same styles and CRS as imagery layer.
6873
6974
:param layers: List of requested map layers
7075
:param styles: Optional styles of same length as layers, use empty strings for default styles
7176
:param bbox: Bounding box of the map as tuple (left, bottom, right, top)
7277
:param map_size: Map size tuple (height, width)
7378
:param srs_str: WMS server SRS
7479
:param image_format: WMS server requested image format
75-
:return: Requested layers stacked into a numpy array raster
80+
:param elevation_layers: Optional layers for height raster
81+
:return: Tuple of imagery and height (Optional) rasters
7682
"""
7783
assert WMSClient.WMS_CLIENT_GLOBAL_VAR in globals()
7884
wms_client: WMSClient = globals()[WMSClient.WMS_CLIENT_GLOBAL_VAR]
@@ -89,4 +95,21 @@ def worker(layers: List[str], styles: List[str], bbox: Tuple[float], map_size: T
8995
map_ = cv2.imdecode(map_, cv2.IMREAD_UNCHANGED)
9096
assert_type(map_, np.ndarray)
9197
assert_ndim(map_, 3)
92-
return map_
98+
99+
elevation = None
100+
if elevation_layers is not None:
101+
try:
102+
elevation = wms_client._wms.getmap(layers=elevation_layers, styles=styles, srs=srs_str, bbox=bbox,
103+
size=map_size, format=image_format,
104+
transparent=False)
105+
except ServiceException as _:
106+
# TODO: handle exception for height layer
107+
pass
108+
109+
if elevation is not None:
110+
elevation = np.frombuffer(elevation.read(), np.uint8) # TODO: handle values above 255?
111+
elevation = cv2.imdecode(elevation, cv2.IMREAD_GRAYSCALE)
112+
assert_type(elevation, np.ndarray)
113+
assert_ndim(elevation, 2) # Grayscale, no color channels
114+
115+
return map_, elevation

0 commit comments

Comments
 (0)