diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02573ee85dc4fd98ff3730099438c9a815370f72..ea1f0f53eaed04abd8c9db70cba19ef833a56659 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: environment-file: environment.yml auto-activate-base: false auto-update-conda: true - python-version: ${{ matrix.python-version }} + python-version: ${{ matrix.python-version }} - name: Check build environment run: | conda list @@ -41,4 +41,4 @@ jobs: python setup.py install - name: Test Python Package run: | - pytest \ No newline at end of file + pytest -n4 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index cb73010f4a27e46c785db94082a16898b34a4a23..51cb0dac8185c29662825119f2eb9335e7fa68a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ release. ## Unreleased ### 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) ### Changed diff --git a/environment.yml b/environment.yml index 938bf87af05263f99a19c529685d89d812064ab8..792cdf89b07b606594199553573e298dbccc8b20 100644 --- a/environment.yml +++ b/environment.yml @@ -19,9 +19,10 @@ dependencies: - plotly-orca - psutil - pvl + - pytest + - pytest-xdist - pyproj - kalasiris - - pytest - python>=3 - requests - scipy diff --git a/knoten/csm.py b/knoten/csm.py index f80753052a4b4d9c8ff55b02efd29a3e3102b348..da04ae9596101ad51ba9f5e30dabf51cf4fbfc43 100644 --- a/knoten/csm.py +++ b/knoten/csm.py @@ -10,7 +10,7 @@ import requests import scipy.stats from functools import singledispatch -from plio.io.io_gdal import GeoDataset +from knoten.surface import EllipsoidDem from knoten import utils class NumpyEncoder(json.JSONEncoder): @@ -127,7 +127,7 @@ def generate_ground_point(dem, image_pt, camera): GeoDataset object generated from Plio off of a Digital Elevation Model (DEM) image_pt : tuple - Pair of x, y coordinates in pixel space + Pair of x, y (sample, line) coordinates in pixel space camera : object USGSCSM camera model object max_its : int, optional @@ -145,11 +145,10 @@ def generate_ground_point(dem, image_pt, camera): if not isinstance(image_pt, csmapi.ImageCoord): # Support a call where image_pt is in the form (x,y) image_pt = csmapi.ImageCoord(*image_pt) - return camera.imageToGround(image_pt, dem) -@generate_ground_point.register(GeoDataset) -def _(dem, image_pt, camera, max_its = 20, tolerance = 0.001): +@generate_ground_point.register +def _(dem: EllipsoidDem, image_pt, camera, max_its = 20, tolerance = 0.0001, dem_type='radius'): if not isinstance(image_pt, csmapi.ImageCoord): # Support a call where image_pt is in the form (x,y) image_pt = csmapi.ImageCoord(*image_pt) @@ -166,21 +165,23 @@ def _(dem, image_pt, camera, max_its = 20, tolerance = 0.001): intersection.y, intersection.z, errcheck=True) - - px, py = dem.latlon_to_pixel(lat, lon) - height = dem.read_array(1, [px, py, 1, 1])[0][0] - if height == dem.no_data_value: + height = dem.get_height(lat, lon) + if height is None: raise ValueError(f'No DEM height at {lat}, {lon}') - - next_intersection = camera.imageToGround(image_pt, float(height)) - dist = _compute_intersection_distance(intersection, next_intersection) + next_intersection = generate_ground_point(float(height), image_pt, camera) + dist = _compute_intersection_distance(intersection, next_intersection) intersection = next_intersection iterations += 1 if dist < tolerance: break 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): """ Private func that takes two csmapi Ecef objects or other objects with diff --git a/knoten/surface.py b/knoten/surface.py new file mode 100644 index 0000000000000000000000000000000000000000..2b85452b3de3fb4281f9a9f720e6680a91ec7aae --- /dev/null +++ b/knoten/surface.py @@ -0,0 +1,145 @@ +""" +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 diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 215af236191ed6de50acbfc4c74c47993dd5393f..50d5571872f77561ab4e1db558257debe843212f 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -23,7 +23,7 @@ requirements: - python - setuptools run: - - ale>=0.3.2 + - ale>=0.10.0 - python - csmapi>=1.0 - gdal>=3.0.0 @@ -34,7 +34,7 @@ requirements: - requests - scipy - shapely - - usgscsm>=1.3.1 + - usgscsm>=2.0 test: imports: diff --git a/tests/test_csm.py b/tests/test_csm.py index 94fb524ec5daeb83b5b779625c31e40932892f3a..d10320788fca4bb8e7b6985b49eea95e02d6b534 100644 --- a/tests/test_csm.py +++ b/tests/test_csm.py @@ -1,18 +1,21 @@ +from collections import namedtuple from unittest import mock import pytest from plio.io.io_gdal import GeoDataset import csmapi -from knoten import csm +from knoten import csm, surface @pytest.fixture 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.read_array.return_value = [[100]] mock_dem.latlon_to_pixel.return_value = (0.5,0.5) - return mock_dem + mock_surface.dem = mock_dem + return mock_surface @pytest.fixture def mock_sensor(): @@ -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('pyproj.transformer.Transformer.transform', return_value=(0,0,0)) def test_generate_ground_point_with_dtm(mock_get_radii, - mock_compute_intsesection, + mock_compute_intersection, mock_pyproj_transformer, mock_sensor, pt, mock_dem): csm.generate_ground_point(mock_dem, pt, mock_sensor) @@ -48,16 +51,15 @@ def test_generate_ground_point_with_dtm(mock_get_radii, # should always be 2. assert mock_sensor.imageToGround.call_count == 2 -from collections import namedtuple @mock.patch.object(csm, 'get_radii', return_value=(10,10)) @mock.patch('pyproj.transformer.Transformer.transform', return_value=(0,0,0)) def test_generate_ground_point_with_dtm_ndv(mock_get_radii, mock_pyproj_transformer, mock_sensor, pt, mock_dem): - mock_dem.no_data_value = 100 + mock_dem.get_height.return_value = None 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(): Point = namedtuple("Point", 'x, y, z') diff --git a/tests/test_surface.py b/tests/test_surface.py new file mode 100644 index 0000000000000000000000000000000000000000..dcc733a18fd8973bb57f517ceedb3256dc70237e --- /dev/null +++ b/tests/test_surface.py @@ -0,0 +1,68 @@ +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