diff --git a/src/aws/osml/model_runner/inference/feature_utils.py b/src/aws/osml/model_runner/inference/feature_utils.py index 48291ac5..43ad3487 100755 --- a/src/aws/osml/model_runner/inference/feature_utils.py +++ b/src/aws/osml/model_runner/inference/feature_utils.py @@ -5,14 +5,11 @@ import logging import math from datetime import datetime -from io import BufferedReader -from json import dumps from math import degrees, radians -from secrets import token_hex from typing import Any, Callable, Dict, List, Optional, Tuple, Union import shapely -from geojson import Feature, FeatureCollection, LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon, loads +from geojson import Feature, LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon from osgeo import gdal from shapely.geometry.base import BaseGeometry @@ -24,45 +21,67 @@ logger = logging.getLogger(__name__) -def features_to_image_shapes(sensor_model: SensorModel, features: List[Feature]) -> List[BaseGeometry]: +def features_to_image_shapes( + sensor_model: SensorModel, features: List[Feature], skip: Optional[bool] = True +) -> List[BaseGeometry]: """ Convert geojson objects/shapes to shapely shapes :param sensor_model: SensorModel = the model to use for the transform :param features: List[geojson.Features] = the features to convert + :param skip: bool = Raise an exception when a feature can't be transformed to a shape, defaults True. :return: List[BaseGeometry] = a list of shapely shapes + + :raises: ValueError = Indicates one or more features could not be transformed to shapes """ + # Set the list of shapes to return shapes: List[BaseGeometry] = [] - if not features: - return shapes - for feature in features: - if "geometry" not in feature: - raise ValueError("Feature does not contain a valid geometry") - - feature_geometry = feature["geometry"] - - image_coords = convert_nested_coordinate_lists(feature_geometry["coordinates"], sensor_model.world_to_image) - - feature_geometry["coordinates"] = image_coords - - if isinstance(feature_geometry, Point): - shapes.append(shapely.geometry.Point(image_coords)) - elif isinstance(feature_geometry, LineString): - shapes.append(shapely.geometry.LineString(image_coords)) - elif isinstance(feature_geometry, Polygon): - shapes.append(shapely.geometry.shape(feature_geometry)) - elif isinstance(feature_geometry, MultiPoint): - shapes.append(shapely.geometry.MultiPoint(image_coords)) - elif isinstance(feature_geometry, MultiLineString): - shapes.append(shapely.geometry.MultiLineString(image_coords)) - elif isinstance(feature_geometry, MultiPolygon): - shapes.append(shapely.geometry.shape(feature_geometry)) + + # Validate feature input + if features is None: + error = "Input features are None." + if skip is True: + logger.warning(error) + return shapes else: - # Unlikely to get here as we're handling all valid geojson types but if the spec - # ever changes or if a consumer passes in a custom dictionary that isn't valid - # we want to handle it gracefully - raise ValueError("Unable to convert feature due to unrecognized or invalid geometry") + raise ValueError(error) + + for feature in features: + try: + # Ensure there is a geometry and coordinates set for the feature + if "geometry" not in feature or "coordinates" not in feature["geometry"]: + raise ValueError(f"Invalid feature, missing 'geometry' or 'coordinates': {feature}") + + # Extract the base geometry of the GeoJSON feature + feature_geometry = feature["geometry"] + + # Project the coordinates from world coordinates to image coordinates + image_coords = convert_nested_coordinate_lists(feature_geometry["coordinates"], sensor_model.world_to_image) + feature_geometry["coordinates"] = image_coords + + # Covert to a Shapely instance and append to the returned shapes + if isinstance(feature_geometry, Point): + shapes.append(shapely.geometry.Point(image_coords)) + elif isinstance(feature_geometry, LineString): + shapes.append(shapely.geometry.LineString(image_coords)) + elif isinstance(feature_geometry, Polygon): + shapes.append(shapely.geometry.shape(feature_geometry)) + elif isinstance(feature_geometry, MultiPoint): + shapes.append(shapely.geometry.MultiPoint(image_coords)) + elif isinstance(feature_geometry, MultiLineString): + shapes.append(shapely.geometry.MultiLineString(image_coords)) + elif isinstance(feature_geometry, MultiPolygon): + shapes.append(shapely.geometry.shape(feature_geometry)) + else: + error = f"Invalid geometry in: {feature_geometry}" + raise ValueError(error) + except ValueError as err: + error = f"Failed to transform {feature} with error: {err}" + if skip is True: + logger.warning(error) + else: + raise err return shapes @@ -96,71 +115,6 @@ def convert_nested_coordinate_lists(coordinates_or_lists: List, conversion_funct return output_list -def create_mock_feature_collection(payload: BufferedReader, geom=False) -> FeatureCollection: - """ - This function allows us to emulate what we would expect a model to return to MR, a geojson formatted - FeatureCollection. This allows us to bypass using a real model if the NOOP_MODEL_NAME is given as the - model name in the image request. This is the same logic used by our current default dummy model to select - detection points in our pipeline. - - :param payload: BufferedReader = object that holds the data that will be sent to the feature generator - :param geom: Bool = whether or not to return the geom_imcoords field in the geojson - :return: FeatureCollection = feature collection containing the center point of a tile given as a detection point - """ - logging.debug("Creating a fake feature collection to use for testing ModelRunner!") - - # Use GDAL to open the image. The binary payload from the HTTP request is used to create an in-memory - # virtual file system for GDAL which is then opened to decode the image into a dataset which will give us - # access to a NumPy array for the pixels. - temp_ds_name = "/vsimem/" + token_hex(16) - gdal.FileFromMemBuffer(temp_ds_name, payload.read()) - ds = gdal.Open(temp_ds_name) - height, width = ds.RasterYSize, ds.RasterXSize - logging.debug(f"Processing image of size: {width}x{height}") - - # Create a single detection bbox that is at the center of and sized proportionally to the image - - center_xy = width / 2, height / 2 - fixed_object_size_xy = width * 0.1, height * 0.1 - fixed_object_bbox = [ - center_xy[0] - fixed_object_size_xy[0], - center_xy[1] - fixed_object_size_xy[1], - center_xy[0] + fixed_object_size_xy[0], - center_xy[1] + fixed_object_size_xy[1], - ] - - fixed_object_polygon = [ - (center_xy[0] - fixed_object_size_xy[0], center_xy[1] - fixed_object_size_xy[1]), - (center_xy[0] - fixed_object_size_xy[0], center_xy[1] + fixed_object_size_xy[1]), - (center_xy[0] + fixed_object_size_xy[0], center_xy[1] + fixed_object_size_xy[1]), - (center_xy[0] + fixed_object_size_xy[0], center_xy[1] - fixed_object_size_xy[1]), - ] - - # Convert that bbox detection into a sample GeoJSON formatted detection. Note that the world coordinates - # are not normally provided by the model container, so they're defaulted to 0,0 here since GeoJSON features - # require a geometry. - json_results = { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "geometry": {"coordinates": [0.0, 0.0], "type": "Point"}, - "id": token_hex(16), - "properties": { - "detection_score": 1.0, - "feature_types": {"sample_object": 1.0}, - "image_id": token_hex(16), - }, - } - ], - } - if geom is True: - json_results["features"][0]["properties"]["geom_imcoords"] = fixed_object_polygon - else: - json_results["features"][0]["properties"]["bounds_imcoords"] = fixed_object_bbox - return loads(dumps(json_results)) - - def calculate_processing_bounds( ds: gdal.Dataset, roi: Optional[BaseGeometry], sensor_model: Optional[SensorModel] ) -> Optional[Tuple[ImageDimensions, ImageDimensions]]: @@ -193,10 +147,9 @@ def calculate_processing_bounds( world_coordinates_3d.append(coord) else: world_coordinates_3d.append(coord + (0.0,)) - roi_area = features_to_image_shapes( - sensor_model, - [Feature(geometry=Polygon([tuple(world_coordinates_3d)]))], - )[0] + roi_area = features_to_image_shapes(sensor_model, [Feature(geometry=Polygon([tuple(world_coordinates_3d)]))], False)[ + 0 + ] if roi_area.intersects(full_image_area): area_to_process = roi_area.intersection(full_image_area) diff --git a/test/aws/osml/model_runner/inference/test_feature_utils.py b/test/aws/osml/model_runner/inference/test_feature_utils.py index 5209a00c..13a9e48d 100755 --- a/test/aws/osml/model_runner/inference/test_feature_utils.py +++ b/test/aws/osml/model_runner/inference/test_feature_utils.py @@ -10,44 +10,65 @@ import shapely from osgeo import gdal -# GDAL 4.0 will begin using exceptions as the default; at this point the software is written to assume -# no exceptions so we call this explicitly until the software can be updated to match. gdal.DontUseExceptions() class TestFeatureUtils(unittest.TestCase): def test_features_conversion_none(self): + # Test when features list is None; expecting an empty list of shapes from aws.osml.model_runner.inference.feature_utils import features_to_image_shapes shapes = features_to_image_shapes(self.build_gdal_sensor_model(), None) assert len(shapes) == 0 + # Should raise an exception when skip is set to False + with self.assertRaises(ValueError): + features_to_image_shapes(self.build_gdal_sensor_model(), None, False) + def test_features_conversion_no_geometry(self): + # Test when a feature is missing 'geometry'; should be skipped without raising an error from aws.osml.model_runner.inference.feature_utils import features_to_image_shapes malformed_feature = {"id": "test_feature"} - with pytest.raises(ValueError) as e_info: - features_to_image_shapes(self.build_gdal_sensor_model(), [malformed_feature]) - assert str(e_info.value) == "Feature does not contain a valid geometry" + + # Should not raise an exception but rather skip the invalid feature when skip defaults to True + shapes = features_to_image_shapes(self.build_gdal_sensor_model(), [malformed_feature]) + + # Expect no shapes to be returned because the input feature was invalid + assert len(shapes) == 0 + + # Should raise an exception when skip is set to False + with self.assertRaises(ValueError): + features_to_image_shapes(self.build_gdal_sensor_model(), [malformed_feature], False) def test_features_conversion_unsupported_type(self): + # Test when feature has an unsupported geometry type; should be skipped without raising an error from aws.osml.model_runner.inference.feature_utils import features_to_image_shapes malformed_feature = { "id": "test_feature", "geometry": {"type": "NewType", "coordinates": [-77.0364, 38.8976, 0.0]}, } - with pytest.raises(ValueError) as e_info: - features_to_image_shapes(self.build_gdal_sensor_model(), [malformed_feature]) - assert str(e_info.value) == "Unable to convert feature due to unrecognized or invalid geometry" + + # Should not raise an exception but rather skip the unsupported geometry type + shapes = features_to_image_shapes(self.build_gdal_sensor_model(), [malformed_feature]) + + # Expect no shapes to be returned because the input feature was unsupported + assert len(shapes) == 0 + + # Should raise an exception when skip is set to False + with self.assertRaises(ValueError): + features_to_image_shapes(self.build_gdal_sensor_model(), [malformed_feature], False) def test_features_conversion(self): + # Test converting valid geojson features to shapely shapes from aws.osml.model_runner.inference.feature_utils import features_to_image_shapes + # Load our test data into Features with open("./test/data/feature_examples.geojson", "r") as geojson_file: features: List[geojson.Feature] = geojson.load(geojson_file)["features"] - # We should have 1 feature for each of the 6 geojson types + # Expecting 6 features of different geojson types assert len(features) == 6 shapes = features_to_image_shapes(self.build_gdal_sensor_model(), features) @@ -59,7 +80,41 @@ def test_features_conversion(self): assert isinstance(shapes[4], shapely.geometry.Polygon) assert isinstance(shapes[5], shapely.geometry.MultiPolygon) + def test_features_conversion_mixed_skip(self): + # Test with a mix of valid and invalid features; only valid features should be processed + from aws.osml.model_runner.inference.feature_utils import features_to_image_shapes + + with open("./test/data/feature_examples.geojson", "r") as geojson_file: + features: List[geojson.Feature] = geojson.load(geojson_file)["features"] + + # Expecting 6 features of different geojson types + assert len(features) == 6 + + # Add a bad feature to the list + features.append({"id": "test_feature"}) + + # Expecting the function to skip the invalid feature and process only the valid one + shapes = features_to_image_shapes(self.build_gdal_sensor_model(), features) + + # Verify that only the valid feature is processed + assert len(shapes) == 6 + + def test_features_conversion_mixed_no_skip(self): + # Test with a mix of valid and invalid features; only valid features should be processed + from aws.osml.model_runner.inference.feature_utils import features_to_image_shapes + + with open("./test/data/feature_examples.geojson", "r") as geojson_file: + features: List[geojson.Feature] = geojson.load(geojson_file)["features"] + + # Add a bad feature to the list + features.append({"id": "test_feature"}) + + # Should raise an exception when skip is set to False + with self.assertRaises(ValueError): + features_to_image_shapes(self.build_gdal_sensor_model(), features, False) + def test_polygon_feature_conversion(self): + # Test converting a geojson polygon to shapely polygon and verify shape and coordinates from aws.osml.model_runner.inference.feature_utils import features_to_image_shapes sample_image_bounds = [(0, 0), (19584, 0), (19584, 19584), (0, 19584)] @@ -81,12 +136,24 @@ def test_polygon_feature_conversion(self): assert isinstance(shape, shapely.geometry.Polygon) for i in range(0, len(sample_image_bounds)): - print("TEST: " + str(i)) - print("SIB: " + str(sample_image_bounds[i])) - print("SEC: " + str(shape.exterior.coords[i])) assert pytest.approx(sample_image_bounds[i], rel=0.49, abs=0.49) == shape.exterior.coords[i] + def test_convert_nested_coordinate_lists_single_vs_nested(self): + # Directly test the conversion of single vs nested coordinates using a mock conversion function + from aws.osml.model_runner.inference.feature_utils import convert_nested_coordinate_lists + + single_coord = [-77.0364, 38.8976] + nested_coords = [[-77.0364, 38.8976], [-77.0365, 38.8977]] + + converted_single = convert_nested_coordinate_lists(single_coord, lambda x: x) + converted_nested = convert_nested_coordinate_lists(nested_coords, lambda x: x) + + assert isinstance(converted_single, tuple) + assert isinstance(converted_nested, list) + assert len(converted_nested) == 2 + def test_calculate_processing_bounds_no_roi(self): + # Test calculating processing bounds without an ROI; should return full image dimensions from aws.osml.model_runner.inference.feature_utils import calculate_processing_bounds ds, sensor_model = self.get_dataset_and_camera() @@ -96,6 +163,7 @@ def test_calculate_processing_bounds_no_roi(self): assert processing_bounds == ((0, 0), (101, 101)) def test_calculate_processing_bounds_full_image(self): + # Test calculating processing bounds with an ROI that covers the full image; should match full image from aws.osml.model_runner.inference.feature_utils import calculate_processing_bounds from aws.osml.photogrammetry import ImageCoordinate @@ -119,6 +187,7 @@ def test_calculate_processing_bounds_full_image(self): assert processing_bounds == ((0, 0), (101, 101)) def test_calculate_processing_bounds_intersect(self): + # Test calculating processing bounds with an ROI that partially intersects the image from aws.osml.model_runner.inference.feature_utils import calculate_processing_bounds from aws.osml.photogrammetry import ImageCoordinate @@ -139,10 +208,10 @@ def test_calculate_processing_bounds_intersect(self): processing_bounds = calculate_processing_bounds(ds, roi, sensor_model) - # Processing bounds is in ((r, c), (w, h)) assert processing_bounds == ((0, 0), (50, 50)) def test_calculate_processing_bounds_chip(self): + # Test calculating processing bounds for a specific chip within the image; expect a smaller bounding box from aws.osml.model_runner.inference.feature_utils import calculate_processing_bounds from aws.osml.photogrammetry import ImageCoordinate @@ -152,20 +221,21 @@ def test_calculate_processing_bounds_chip(self): chip_lr = sensor_model.image_to_world(ImageCoordinate([70, 90])) min_vals = np.minimum(chip_ul.coordinate, chip_lr.coordinate) max_vals = np.maximum(chip_ul.coordinate, chip_lr.coordinate) - polygon_coords = [] - polygon_coords.append([degrees(min_vals[0]), degrees(min_vals[1])]) - polygon_coords.append([degrees(min_vals[0]), degrees(max_vals[1])]) - polygon_coords.append([degrees(max_vals[0]), degrees(max_vals[1])]) - polygon_coords.append([degrees(max_vals[0]), degrees(min_vals[1])]) - polygon_coords.append([degrees(min_vals[0]), degrees(min_vals[1])]) + polygon_coords = [ + [degrees(min_vals[0]), degrees(min_vals[1])], + [degrees(min_vals[0]), degrees(max_vals[1])], + [degrees(max_vals[0]), degrees(max_vals[1])], + [degrees(max_vals[0]), degrees(min_vals[1])], + [degrees(min_vals[0]), degrees(min_vals[1])], + ] roi = shapely.geometry.Polygon(polygon_coords) processing_bounds = calculate_processing_bounds(ds, roi, sensor_model) - # Processing bounds is in ((r, c), (w, h)) assert processing_bounds == ((15, 10), (60, 75)) def test_get_source_property_not_available(self): + # Test getting source property for unsupported image type; should return None from aws.osml.model_runner.inference.feature_utils import get_source_property ds, sensor_model = self.get_dataset_and_camera() @@ -173,6 +243,7 @@ def test_get_source_property_not_available(self): assert source_property is None def test_get_source_property_exception(self): + # Test getting source property when exception occurs; should handle gracefully and return None from aws.osml.model_runner.inference.feature_utils import get_source_property source_property = get_source_property("./test/data/GeogToWGS84GeoKey5.tif", "NITF", dataset=None) @@ -182,7 +253,7 @@ def test_get_source_property_exception(self): def build_gdal_sensor_model(): from aws.osml.photogrammetry import GDALAffineSensorModel - # Test coordinate calculations using geotransform matrix from sample SpaceNet RIO image + # Create a mock GDAL sensor model for testing transformations transform = [ -43.681640625, 4.487879136029412e-06,