Skip to content
Snippets Groups Projects
Commit 5d0324b2 authored by Laura, Jason R's avatar Laura, Jason R
Browse files

Adds surface

parent 17bceb72
No related branches found
No related tags found
No related merge requests found
...@@ -33,6 +33,9 @@ jobs: ...@@ -33,6 +33,9 @@ jobs:
auto-activate-base: false auto-activate-base: false
auto-update-conda: true auto-update-conda: true
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Add test requirements
run: |
pip install -r requirements_test.txt
- name: Check build environment - name: Check build environment
run: | run: |
conda list conda list
...@@ -41,4 +44,4 @@ jobs: ...@@ -41,4 +44,4 @@ jobs:
python setup.py install python setup.py install
- name: Test Python Package - name: Test Python Package
run: | run: |
pytest pytest -n4
\ No newline at end of file \ No newline at end of file
...@@ -36,6 +36,8 @@ release. ...@@ -36,6 +36,8 @@ release.
## Unreleased ## Unreleased
### Added ### Added
- `generate_image_coordinate` to `csm.py`. This provides a similar interface to `generate_ground_coordinate` and abstracts away the `csmapi` from the user.
- A surface class (moved from AutoCNet; credit @jessemapel) with support for Ellipsoid DEMs and basic support for raster DEMs readable by the plio.io.io_gdal.GeoDataset. Support is basic because it uses a single pixel intersection and not an interpolated elevation like ISIS does.
- A check to `generate_ground_point` when a GeoDataset is used to raise a `ValueError` if the algorithm intersects a no data value in the passed DEM. This ensures that valid heights are used in the intersection computation. Fixes [#120](https://github.com/DOI-USGS/knoten/issues/120) - A check to `generate_ground_point` when a GeoDataset is used to raise a `ValueError` if the algorithm intersects a no data value in the passed DEM. This ensures that valid heights are used in the intersection computation. Fixes [#120](https://github.com/DOI-USGS/knoten/issues/120)
### Changed ### Changed
......
...@@ -21,7 +21,6 @@ dependencies: ...@@ -21,7 +21,6 @@ dependencies:
- pvl - pvl
- pyproj - pyproj
- kalasiris - kalasiris
- pytest
- python>=3 - python>=3
- requests - requests
- scipy - scipy
......
...@@ -10,7 +10,7 @@ import requests ...@@ -10,7 +10,7 @@ import requests
import scipy.stats import scipy.stats
from functools import singledispatch from functools import singledispatch
from plio.io.io_gdal import GeoDataset from knoten.surface import EllipsoidDem
from knoten import utils from knoten import utils
class NumpyEncoder(json.JSONEncoder): class NumpyEncoder(json.JSONEncoder):
...@@ -127,7 +127,7 @@ def generate_ground_point(dem, image_pt, camera): ...@@ -127,7 +127,7 @@ def generate_ground_point(dem, image_pt, camera):
GeoDataset object generated from Plio off of a Digital Elevation GeoDataset object generated from Plio off of a Digital Elevation
Model (DEM) Model (DEM)
image_pt : tuple image_pt : tuple
Pair of x, y coordinates in pixel space Pair of x, y (line, sample) coordinates in pixel space
camera : object camera : object
USGSCSM camera model object USGSCSM camera model object
max_its : int, optional max_its : int, optional
...@@ -145,11 +145,10 @@ def generate_ground_point(dem, image_pt, camera): ...@@ -145,11 +145,10 @@ def generate_ground_point(dem, image_pt, camera):
if not isinstance(image_pt, csmapi.ImageCoord): if not isinstance(image_pt, csmapi.ImageCoord):
# Support a call where image_pt is in the form (x,y) # Support a call where image_pt is in the form (x,y)
image_pt = csmapi.ImageCoord(*image_pt) image_pt = csmapi.ImageCoord(*image_pt)
return camera.imageToGround(image_pt, dem) return camera.imageToGround(image_pt, dem)
@generate_ground_point.register(GeoDataset) @generate_ground_point.register
def _(dem, image_pt, camera, max_its = 20, tolerance = 0.001): def _(dem: EllipsoidDem, image_pt, camera, max_its = 20, tolerance = 0.0001, dem_type='radius'):
if not isinstance(image_pt, csmapi.ImageCoord): if not isinstance(image_pt, csmapi.ImageCoord):
# Support a call where image_pt is in the form (x,y) # Support a call where image_pt is in the form (x,y)
image_pt = csmapi.ImageCoord(*image_pt) image_pt = csmapi.ImageCoord(*image_pt)
...@@ -166,21 +165,23 @@ def _(dem, image_pt, camera, max_its = 20, tolerance = 0.001): ...@@ -166,21 +165,23 @@ def _(dem, image_pt, camera, max_its = 20, tolerance = 0.001):
intersection.y, intersection.y,
intersection.z, intersection.z,
errcheck=True) errcheck=True)
height = dem.get_height(lat, lon)
px, py = dem.latlon_to_pixel(lat, lon) if height is None:
height = dem.read_array(1, [px, py, 1, 1])[0][0]
if height == dem.no_data_value:
raise ValueError(f'No DEM height at {lat}, {lon}') raise ValueError(f'No DEM height at {lat}, {lon}')
next_intersection = camera.imageToGround(image_pt, float(height)) next_intersection = generate_ground_point(float(height), image_pt, camera)
dist = _compute_intersection_distance(intersection, next_intersection) dist = _compute_intersection_distance(intersection, next_intersection)
intersection = next_intersection intersection = next_intersection
iterations += 1 iterations += 1
if dist < tolerance: if dist < tolerance:
break break
return intersection return intersection
def generate_image_coordinate(ground_pt, camera):
if not isinstance(ground_pt, csmapi.EcefCoord):
ground_pt = csmapi.EcefCoord(*ground_pt)
return camera.groundToImage(ground_pt)
def _compute_intersection_distance(intersection, next_intersection): def _compute_intersection_distance(intersection, next_intersection):
""" """
Private func that takes two csmapi Ecef objects or other objects with Private func that takes two csmapi Ecef objects or other objects with
......
"""
A set of classes that represent the target surface. Each class implements the
get_height and get_radius functions for computing the height and radius respectively
at a given ground location (geocentric latitude and longitude).
"""
import numpy as np
from plio.io.io_gdal import GeoDataset
class EllipsoidDem:
"""
A biaxial ellipsoid surface model.
"""
def __init__(self, semi_major, semi_minor = None):
"""
Create an ellipsoid DEM from a set of radii
Parameters
----------
semi_major : float
The equatorial semi-major radius of the ellipsoid.
semi_minor : float
The polar semi-minor radius of the ellipsoid.
"""
self.a = semi_major
self.b = semi_major
self.c = semi_major
if semi_minor is not None:
self.c = semi_minor
def get_height(self, lat, lon):
"""
Get the height above the ellipsoid at a ground location
Parameters
----------
lat : float
The geocentric latitude in degrees
lon : float
The longitude in degrees
"""
return 0
def get_radius(self, lat, lon):
"""
Get the radius at a ground location
Parameters
----------
lat : float
The geocentric latitude in degrees
lon : float
The longitude in degrees
"""
cos_lon = np.cos(np.deg2rad(lon))
sin_lon = np.sin(np.deg2rad(lon))
cos_lat = np.cos(np.deg2rad(lat))
sin_lat = np.sin(np.deg2rad(lat))
denom = self.b * self.b * cos_lon * cos_lon
denom += self.a * self.a * sin_lon * sin_lon
denom *= self.c * self.c * cos_lat * cos_lat
denom += self.a * self.a * self.b * self.b * sin_lat * sin_lat
radius = (self.a * self.b * self.c) / np.sqrt(denom)
return radius
class GdalDem(EllipsoidDem):
"""
A raster DEM surface model.
"""
def __init__(self, dem, semi_major, semi_minor = None, dem_type=None):
"""
Create a GDAL dem from a dem file
Parameters
----------
dem : str
The DEM file
semi_major : float
The equatorial semi-major radius of the reference ellipsoid.
semi_minor : float
The polar semi-minor radius of the reference ellipsoid.
dem_type : str
The type of DEM, either height above reference ellipsoid or radius.
"""
super().__init__(semi_major, semi_minor)
dem_types = ('height', 'radius')
if dem_type is None:
dem_type = dem_types[0]
if dem_type not in dem_types:
raise ValueError(f'DEM type {dem_type} is not a valid option.')
self.dem = GeoDataset(dem)
self.dem_type = dem_type
def get_raster_value(self, lat, lon):
"""
Get the value of the dem raster at a ground location
Parameters
----------
lat : float
The geocentric latitude in degrees
lon : float
The longitude in degrees
"""
px, py = self.dem.latlon_to_pixel(lat, lon)
value = self.dem.read_array(1, [px, py, 1, 1])[0][0]
if value == self.dem.no_data_value:
return None
return value
def get_height(self, lat, lon):
"""
Get the height above the ellipsoid at a ground location
Parameters
----------
lat : float
The geocentric latitude in degrees
lon : float
The longitude in degrees
"""
height = self.get_raster_value(lat, lon)
if self.dem_type == 'radius' and height is not None:
height -= super().get_radius(lat, lon)
return height
def get_radius(self, lat, lon):
"""
Get the radius at a ground location
Parameters
----------
lat : float
The geocentric latitude in degrees
lon : float
The longitude in degrees
"""
radius = self.get_raster_value(lat, lon)
if self.dem_type == 'height':
radius += super().get_radius(lat, lon)
return radius
pytest
pytest-xdist
\ No newline at end of file
from collections import namedtuple
from unittest import mock from unittest import mock
import pytest import pytest
from plio.io.io_gdal import GeoDataset from plio.io.io_gdal import GeoDataset
import csmapi import csmapi
from knoten import csm from knoten import csm, surface
@pytest.fixture @pytest.fixture
def mock_dem(): def mock_dem():
mock_dem = mock.MagicMock(spec_set=GeoDataset) mock_surface = mock.MagicMock(spec=surface.GdalDem)
mock_dem = mock.MagicMock(spec=GeoDataset)
mock_dem.no_data_value = 10 mock_dem.no_data_value = 10
mock_dem.read_array.return_value = [[100]] mock_dem.read_array.return_value = [[100]]
mock_dem.latlon_to_pixel.return_value = (0.5,0.5) mock_dem.latlon_to_pixel.return_value = (0.5,0.5)
return mock_dem mock_surface.dem = mock_dem
return mock_surface
@pytest.fixture @pytest.fixture
def mock_sensor(): def mock_sensor():
...@@ -39,7 +42,7 @@ def test_generate_ground_point_with_imagecoord(mock_sensor, pt): ...@@ -39,7 +42,7 @@ def test_generate_ground_point_with_imagecoord(mock_sensor, pt):
@mock.patch.object(csm, '_compute_intersection_distance', return_value=0) @mock.patch.object(csm, '_compute_intersection_distance', return_value=0)
@mock.patch('pyproj.transformer.Transformer.transform', return_value=(0,0,0)) @mock.patch('pyproj.transformer.Transformer.transform', return_value=(0,0,0))
def test_generate_ground_point_with_dtm(mock_get_radii, def test_generate_ground_point_with_dtm(mock_get_radii,
mock_compute_intsesection, mock_compute_intersection,
mock_pyproj_transformer, mock_pyproj_transformer,
mock_sensor, pt, mock_dem): mock_sensor, pt, mock_dem):
csm.generate_ground_point(mock_dem, pt, mock_sensor) csm.generate_ground_point(mock_dem, pt, mock_sensor)
...@@ -48,16 +51,15 @@ def test_generate_ground_point_with_dtm(mock_get_radii, ...@@ -48,16 +51,15 @@ def test_generate_ground_point_with_dtm(mock_get_radii,
# should always be 2. # should always be 2.
assert mock_sensor.imageToGround.call_count == 2 assert mock_sensor.imageToGround.call_count == 2
from collections import namedtuple
@mock.patch.object(csm, 'get_radii', return_value=(10,10)) @mock.patch.object(csm, 'get_radii', return_value=(10,10))
@mock.patch('pyproj.transformer.Transformer.transform', return_value=(0,0,0)) @mock.patch('pyproj.transformer.Transformer.transform', return_value=(0,0,0))
def test_generate_ground_point_with_dtm_ndv(mock_get_radii, def test_generate_ground_point_with_dtm_ndv(mock_get_radii,
mock_pyproj_transformer, mock_pyproj_transformer,
mock_sensor, pt, mock_dem): mock_sensor, pt, mock_dem):
mock_dem.no_data_value = 100 mock_dem.get_height.return_value = None
with pytest.raises(ValueError): with pytest.raises(ValueError):
csm.generate_ground_point(mock_dem, pt, mock_sensor) res = csm.generate_ground_point(mock_dem, pt, mock_sensor)
def test__compute_intersection_distance(): def test__compute_intersection_distance():
Point = namedtuple("Point", 'x, y, z') Point = namedtuple("Point", 'x, y, z')
......
import unittest
from unittest import mock
from knoten import surface
class TestEllipsoidDem(unittest.TestCase):
def test_height(self):
test_dem = surface.EllipsoidDem(3396190, 3376200)
self.assertEqual(test_dem.get_height(0, 0), 0)
self.assertEqual(test_dem.get_height(0, 180), 0)
self.assertEqual(test_dem.get_height(90, 100), 0)
def test_radius(self):
test_dem = surface.EllipsoidDem(3396190, 3376200)
self.assertEqual(test_dem.get_radius(0, 0), 3396190)
self.assertEqual(test_dem.get_radius(0, 180), 3396190)
self.assertEqual(test_dem.get_radius(90, 300), 3376200)
def tearDown(self):
pass
class TestGdalDem(unittest.TestCase):
def test_height(self):
with mock.patch('knoten.surface.GeoDataset') as mockDataset:
mockInstance = mockDataset.return_value
mockInstance.latlon_to_pixel.return_value = (1,2)
mockInstance.read_array.return_value = [[100]]
test_dem = surface.GdalDem('TestDem.cub', 3396190, 3376200)
self.assertEqual(test_dem.get_height(0, 0), 100)
self.assertEqual(test_dem.get_height(0, 180), 100)
self.assertEqual(test_dem.get_height(90, 300), 100)
def test_height_from_radius(self):
with mock.patch('knoten.surface.GeoDataset') as mockDataset:
mockInstance = mockDataset.return_value
mockInstance.latlon_to_pixel.return_value = (1,2)
mockInstance.read_array.return_value = [[3396190]]
test_dem = surface.GdalDem('TestDem.cub', 3396190, 3376200, 'radius')
self.assertEqual(test_dem.get_height(0, 0), 0)
self.assertEqual(test_dem.get_height(0, 180), 0)
self.assertEqual(test_dem.get_height(90, 300), 19990)
def test_radius(self):
with mock.patch('knoten.surface.GeoDataset') as mockDataset:
mockInstance = mockDataset.return_value
mockInstance.latlon_to_pixel.return_value = (1,2)
mockInstance.read_array.return_value = [[3396190]]
test_dem = surface.GdalDem('TestDem.cub', 3396190, 3376200, 'radius')
self.assertEqual(test_dem.get_radius(0, 0), 3396190)
self.assertEqual(test_dem.get_radius(0, 180), 3396190)
self.assertEqual(test_dem.get_radius(90, 300), 3396190)
def test_radius_from_height(self):
with mock.patch('knoten.surface.GeoDataset') as mockDataset:
mockInstance = mockDataset.return_value
mockInstance.latlon_to_pixel.return_value = (1,2)
mockInstance.read_array.return_value = [[100]]
test_dem = surface.GdalDem(mockInstance, 3396190, 3376200)
self.assertEqual(test_dem.get_radius(0, 0), 3396290)
self.assertEqual(test_dem.get_radius(0, 180), 3396290)
self.assertEqual(test_dem.get_radius(90, 300), 3376300)
def tearDown(self):
pass
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment