diff --git a/src/data/__init__.py b/src/data/__init__.py index 99f113a..78ef919 100644 --- a/src/data/__init__.py +++ b/src/data/__init__.py @@ -1 +1,2 @@ -from .remote_sensing_data_downloader import * +from .remote_sensing_data_fetcher import RemoteSensingDataFetcher +from .web_map_service import WebMapService, WebMapServiceProtocol diff --git a/src/data/exceptions.py b/src/data/exceptions.py new file mode 100644 index 0000000..2008ba4 --- /dev/null +++ b/src/data/exceptions.py @@ -0,0 +1,113 @@ +from natsort import natsorted + + +class WMSError(Exception): + + def __init__(self, + message='Invalid web map service in the config!'): + """ + | Initializer method + + :param str message: message + :returns: None + :rtype: None + """ + super().__init__(message) + + +class WMSConnectionError(WMSError): + + def __init__(self, + url, + passed_exception): + """ + | Initializer method + + :param str url: url + :param Exception passed_exception: passed exception + :returns: None + :rtype: None + """ + message = ( + 'Invalid url in the config!\n' + f'An exception is raised while connecting to the web map service ({url}).\n' + f'{passed_exception}') + + super().__init__(message) + + +class WMSEPSGCodeError(WMSError): + + def __init__(self, + epsg_code, + epsg_codes_valid): + """ + | Initializer method + + :param int epsg_code: epsg code + :param list[int] epsg_codes_valid: valid epsg codes + :returns: None + :rtype: None + """ + if len(epsg_codes_valid) == 1: + epsg_codes_valid = epsg_codes_valid[0] + else: + epsg_codes_valid = natsorted(epsg_codes_valid) + + epsg_codes_valid = ( + f"{', '.join(map(str, epsg_codes_valid[:-1]))} " + f'or {epsg_codes_valid[-1]}') + + message = ( + 'Invalid epsg_code in the config!\n' + f'Expected {epsg_codes_valid}, got {epsg_code} instead.') + + super().__init__(message) + + +class WMSFetchingError(WMSError): + + def __init__(self, + url, + passed_exception): + """ + | Initializer method + + :param str url: url + :param Exception passed_exception: passed exception + :returns: None + :rtype: None + """ + message = ( + f'An exception is raised while fetching the image from the web map service ({url}).\n' + f'{passed_exception}') + + super().__init__(message) + + +class WMSLayerError(WMSError): + + def __init__(self, + layer, + layers_valid): + """ + | Initializer method + + :param str layer: layer + :param list[str] layers_valid: valid layers + :returns: None + :rtype: None + """ + if len(layers_valid) == 1: + layers_valid = layers_valid[0] + else: + layers_valid = natsorted(layers_valid) + layers_valid = ( + f"{', '.join(map(str, layers_valid[:-1]))} " + f'or {layers_valid[-1]}') + + message = ( + 'Invalid layer in the config!\n' + f'Expected {layers_valid}, got {layer} instead.') + + super().__init__(message) diff --git a/src/data/remote_sensing_data_downloader.py b/src/data/remote_sensing_data_downloader.py deleted file mode 100644 index 5858497..0000000 --- a/src/data/remote_sensing_data_downloader.py +++ /dev/null @@ -1,110 +0,0 @@ -from io import BytesIO - -import numpy as np -from PIL import Image -from owslib.wms import WebMapService - -import src.utils.settings as settings - - -class RemoteSensingDataDownloader: - def __init__(self, - wms_url, - wms_layer, - epsg_code, - clip_border): - """ - | Constructor method - - :param str wms_url: url of the web map service - :param str wms_layer: layer of the web map service - :param int epsg_code: epsg code of the coordinate reference system - :param bool clip_border: if True, the image size is increased by the border size - :returns: None - :rtype: None - """ - assert isinstance(wms_url, str) - - assert isinstance(wms_layer, str) - - assert isinstance(epsg_code, int) - - assert isinstance(clip_border, bool) - - self.wms = WebMapService(wms_url) - self.wms_layer = wms_layer - self.epsg_code = epsg_code - self.clip_border = clip_border - - def get_bounding_box(self, coordinates): - """ - | Returns the bounding box of a tile. - - :param (int, int) coordinates: coordinates (x_min, y_max) - :returns: bounding_box (x_min, y_min, x_max, y_max) - :rtype: (int, int, int, int) - """ - assert isinstance(coordinates, tuple) - assert len(coordinates) == 2 - assert all(isinstance(coordinate, int) for coordinate in coordinates) - - x_min, y_max = coordinates - - if self.clip_border: - bounding_box = (x_min - settings.BORDER_SIZE_METERS, - y_max - settings.IMAGE_SIZE_METERS - settings.BORDER_SIZE_METERS, - x_min + settings.IMAGE_SIZE_METERS + settings.BORDER_SIZE_METERS, - y_max + settings.BORDER_SIZE_METERS) - else: - bounding_box = (x_min, - y_max - settings.IMAGE_SIZE_METERS, - x_min + settings.IMAGE_SIZE_METERS, - y_max) - - return bounding_box - - def get_response(self, bounding_box): - """ - | Wrapper of owslib.wms.WebMapService.getmap().read() - | Returns a response (byte stream) of the web map service. - - :param (int, int, int, int) bounding_box: bounding_box (x_min, y_min, x_max, y_max) - :returns: response - :rtype: bytes - """ - assert isinstance(bounding_box, tuple) - assert len(bounding_box) == 4 - assert all(isinstance(coordinate, int) for coordinate in bounding_box) - assert bounding_box[0] < bounding_box[2] and bounding_box[1] < bounding_box[3] - - image_size = settings.IMAGE_SIZE + 2 * settings.BORDER_SIZE if self.clip_border else settings.IMAGE_SIZE - - response = self.wms.getmap(layers=[self.wms_layer], - srs=f'EPSG:{self.epsg_code}', - bbox=bounding_box, - format='image/tiff', - size=(image_size, image_size), - bgcolor='#000000').read() - - return response - - def get_image(self, coordinates): - """ - | Returns an image. - - :param (int, int) coordinates: coordinates (x_min, y_max) - :returns: image - :rtype: np.ndarray[np.uint8] - """ - assert isinstance(coordinates, tuple) - assert len(coordinates) == 2 - assert all(isinstance(coordinate, int) for coordinate in coordinates) - - bounding_box = self.get_bounding_box(coordinates) - response = self.get_response(bounding_box) - - with Image.open(BytesIO(response)) as file: - # noinspection PyTypeChecker - image = np.array(file, dtype=np.uint8) - - return image diff --git a/src/data/remote_sensing_data_fetcher.py b/src/data/remote_sensing_data_fetcher.py new file mode 100644 index 0000000..e41fee1 --- /dev/null +++ b/src/data/remote_sensing_data_fetcher.py @@ -0,0 +1,100 @@ +import numpy as np # noqa: F401 (used for type hinting) + +from src.utils.settings import ( + IMAGE_SIZE, + IMAGE_SIZE_METERS, + PADDING_SIZE, + PADDING_SIZE_METERS) + +from .web_map_service import WebMapServiceProtocol # noqa: F401 (used for type hinting) + + +class RemoteSensingDataFetcher: + + def __init__(self, + web_map_service, + layer, + epsg_code): + """ + | Initializer method + + :param WebMapServiceProtocol web_map_service: web map service + :param str layer: layer + :param int epsg_code: epsg code + :returns: None + :rtype: None + """ + assert isinstance(layer, str) + + assert isinstance(epsg_code, int) + + self.web_map_service = web_map_service + self.layer = layer + self.epsg_code = epsg_code + + @staticmethod + def compute_bounding_box(coordinates, + apply_padding=False): + """ + | Returns the bounding box of a tile. + + :param (int, int) coordinates: coordinates (x_min, y_max) + :param bool apply_padding: if True, the bounding box is increased by PADDING_SIZE_METERS + :returns: bounding box (x_min, y_min, x_max, y_max) + :rtype: (int, int, int, int) + """ + assert isinstance(coordinates, tuple) + assert len(coordinates) == 2 + assert all(isinstance(coordinate, int) for coordinate in coordinates) + + assert isinstance(apply_padding, bool) + + x_min, y_max = coordinates + + if apply_padding: + bounding_box = ( + (x_min - PADDING_SIZE_METERS, + y_max - IMAGE_SIZE_METERS - PADDING_SIZE_METERS, + x_min + IMAGE_SIZE_METERS + PADDING_SIZE_METERS, + y_max + PADDING_SIZE_METERS)) + + else: + bounding_box = ( + (x_min, + y_max - IMAGE_SIZE_METERS, + x_min + IMAGE_SIZE_METERS, + y_max)) + + return bounding_box + + def fetch_image(self, + coordinates, + apply_padding=False): + """ + | Returns the fetched image. + + :param (int, int) coordinates: coordinates (x_min, y_max) + :param bool apply_padding: if True, the image size is increased by PADDING_SIZE + :returns: fetched image + :rtype: np.ndarray[np.uint8] + """ + assert isinstance(coordinates, tuple) + assert len(coordinates) == 2 + assert all(isinstance(coordinate, int) for coordinate in coordinates) + + assert isinstance(apply_padding, bool) + + bounding_box = self.compute_bounding_box(coordinates=coordinates, + apply_padding=apply_padding) + + if apply_padding: + image_size = IMAGE_SIZE + 2 * PADDING_SIZE + else: + image_size = IMAGE_SIZE + + image = self.web_map_service.fetch_image(layer=self.layer, + bounding_box=bounding_box, + image_size=image_size, + epsg_code=self.epsg_code) + + return image diff --git a/src/data/tests/__init__.py b/src/data/tests/__init__.py index 8f9fd4e..e69de29 100644 --- a/src/data/tests/__init__.py +++ b/src/data/tests/__init__.py @@ -1 +0,0 @@ -from .test_remote_sensing_data_downloader import * diff --git a/src/data/tests/conftest.py b/src/data/tests/conftest.py index 33de231..73f3816 100644 --- a/src/data/tests/conftest.py +++ b/src/data/tests/conftest.py @@ -1,40 +1,35 @@ +import unittest.mock as mock + import pytest -from unittest import mock -from src.data.remote_sensing_data_downloader import RemoteSensingDataDownloader +from src.data.remote_sensing_data_fetcher import RemoteSensingDataFetcher +from src.data.web_map_service import WebMapServiceProtocol -@pytest.fixture(scope='session') -@mock.patch('src.data.remote_sensing_data_downloader.WebMapService', return_value=mock.MagicMock) -def remote_sensing_data_downloader_no_clip_border(_mocked_wms): +@pytest.fixture(scope='function') +def remote_sensing_data_fetcher_with_mocked_web_map_service(mocked_web_map_service): """ - | Returns a remote_sensing_data_downloader instance. - | Border clipping is not used. + | Returns a remote sensing data fetcher object with a mocked web map service. - :returns: remote_sensing_data_downloader - :rtype: RemoteSensingDataDownloader + :param WebMapServiceProtocol mocked_web_map_service: mocked web map service fixture + :returns: remote sensing data fetcher fixture + :rtype: (RemoteSensingDataFetcher, WebMapServiceProtocol) """ - remote_sensing_data_downloader = RemoteSensingDataDownloader(wms_url='https://www.wms.de/wms_url', - wms_layer='wms_layer', - epsg_code=25832, - clip_border=False) + remote_sensing_data_fetcher = RemoteSensingDataFetcher(web_map_service=mocked_web_map_service, + layer='test_layer', + epsg_code=25832) - return remote_sensing_data_downloader + return remote_sensing_data_fetcher, mocked_web_map_service -@pytest.fixture(scope='session') -@mock.patch('src.data.remote_sensing_data_downloader.WebMapService', return_value=mock.MagicMock) -def remote_sensing_data_downloader_clip_border(_mocked_wms): +@pytest.fixture(scope='function') +def mocked_web_map_service(): """ - | Returns a remote_sensing_data_downloader instance. - | Border clipping is used. + | Returns a mocked web map service object. - :returns: remote_sensing_data_downloader - :rtype: RemoteSensingDataDownloader + :returns: mocked web map service fixture + :rtype: WebMapServiceProtocol """ - remote_sensing_data_downloader = RemoteSensingDataDownloader(wms_url='https://www.wms.de/wms_url', - wms_layer='wms_layer', - epsg_code=25832, - clip_border=True) - - return remote_sensing_data_downloader + mocked_web_map_service = mock.Mock(spec=WebMapServiceProtocol) + mocked_web_map_service.url = 'https://wms.com' + return mocked_web_map_service diff --git a/src/data/tests/data/__init__.py b/src/data/tests/data/__init__.py index 4c0b0d5..e69de29 100644 --- a/src/data/tests/data/__init__.py +++ b/src/data/tests/data/__init__.py @@ -1 +0,0 @@ -from .data_test_get_bounding_box import * diff --git a/src/data/tests/data/data_test_exceptions.py b/src/data/tests/data/data_test_exceptions.py new file mode 100644 index 0000000..12d4019 --- /dev/null +++ b/src/data/tests/data/data_test_exceptions.py @@ -0,0 +1,21 @@ +data_test_WMSEPSGCodeError = ( + [((0, [1]), + r'Invalid epsg_code in the config!\n' + 'Expected 1, got 0 instead.'), + ((0, [1, 2]), + r'Invalid epsg_code in the config!\n' + 'Expected 1 or 2, got 0 instead.'), + ((0, [1, 2, 3]), + r'Invalid epsg_code in the config!\n' + 'Expected 1, 2 or 3, got 0 instead.')]) + +data_test_WMSLayerError = ( + [(('z', ['a']), + r'Invalid layer in the config!\n' + 'Expected a, got z instead.'), + (('z', ['a', 'b']), + r'Invalid layer in the config!\n' + 'Expected a or b, got z instead.'), + (('z', ['a', 'b', 'c']), + r'Invalid layer in the config!\n' + 'Expected a, b or c, got z instead.')]) diff --git a/src/data/tests/data/data_test_get_bounding_box.py b/src/data/tests/data/data_test_get_bounding_box.py deleted file mode 100644 index f0e656a..0000000 --- a/src/data/tests/data/data_test_get_bounding_box.py +++ /dev/null @@ -1,13 +0,0 @@ -parameters_get_bounding_box_no_clip_border = [((0, 0), (0, -256, 256, 0)), - ((-128, 128), (-128, -128, 128, 128)), - ((-512, -512), (-512, -768, -256, -512)), - ((512, -512), (512, -768, 768, -512)), - ((512, 512), (512, 256, 768, 512)), - ((-512, 512), (-512, 256, -256, 512))] - -parameters_get_bounding_box_clip_border = [((0, 0), (-64, -320, 320, 64)), - ((-128, 128), (-192, -192, 192, 192)), - ((-512, -512), (-576, -832, -192, -448)), - ((512, -512), (448, -832, 832, -448)), - ((512, 512), (448, 192, 832, 576)), - ((-512, 512), (-576, 192, -192, 576))] diff --git a/src/data/tests/data/data_test_get_image.tiff b/src/data/tests/data/data_test_get_image.tiff deleted file mode 100644 index 2912b72..0000000 Binary files a/src/data/tests/data/data_test_get_image.tiff and /dev/null differ diff --git a/src/data/tests/data/tests_data_generator.py b/src/data/tests/data/tests_data_generator.py deleted file mode 100644 index 04150a2..0000000 --- a/src/data/tests/data/tests_data_generator.py +++ /dev/null @@ -1,28 +0,0 @@ -import numpy as np -from PIL import Image - - -image_top_left = np.full(shape=(640, 640, 3), - fill_value=np.array([0, 51, 102]), - dtype=np.uint8) -image_top_right = np.full(shape=(640, 640, 3), - fill_value=np.array([153, 204, 255]), - dtype=np.uint8) -image_bottom_left = np.full(shape=(640, 640, 3), - fill_value=np.array([255, 204, 153]), - dtype=np.uint8) -image_bottom_right = np.full(shape=(640, 640, 3), - fill_value=np.array([102, 51, 0]), - dtype=np.uint8) - -image_top = np.concatenate((image_top_left, - image_top_right), - axis=1) -image_bottom = np.concatenate((image_bottom_left, - image_bottom_right), - axis=1) -expected = np.concatenate((image_top, - image_bottom), - axis=0) - -Image.fromarray(expected).save('data_test_get_image.tiff') diff --git a/src/data/tests/test_exceptions.py b/src/data/tests/test_exceptions.py new file mode 100644 index 0000000..f7efc54 --- /dev/null +++ b/src/data/tests/test_exceptions.py @@ -0,0 +1,111 @@ +import pytest + +from src.data.exceptions import ( + WMSConnectionError, + WMSEPSGCodeError, + WMSError, + WMSFetchingError, + WMSLayerError) + +from .data.data_test_exceptions import ( + data_test_WMSEPSGCodeError, + data_test_WMSLayerError) + + +def test_WMSError_default(): + """ + | Tests the default message of WMSError. + + :returns: None + :rtype: None + """ + expected = 'Invalid web map service in the config!' + + with pytest.raises(WMSError, match=expected): + raise WMSError() + + +def test_WMSError(): + """ + | Tests WMSError. + + :returns: None + :rtype: None + """ + message = 'Test message.' + + expected = 'Test message.' + + with pytest.raises(WMSError, match=expected): + raise WMSError(message=message) + + +def test_WMSConnectionError(): + """ + | Tests WMSConnectionError. + + :returns: None + :rtype: None + """ + url = 'https://invalid.wms.com' + passed_exception = Exception('Test message.') + + expected = ( + r'Invalid url in the config!\n' + r'An exception is raised while connecting to the web map service \(https://invalid.wms.com\).\n' + 'Test message.') + + with pytest.raises(WMSConnectionError, match=expected): + raise WMSConnectionError(url=url, + passed_exception=passed_exception) + + +@pytest.mark.parametrize('test_input, expected', data_test_WMSEPSGCodeError) +def test_WMSEPSGCodeError(test_input, + expected): + """ + | Tests WMSEPSGCodeError. + + :param (int, list[int]) test_input: epsg_code, epsg_codes_valid + :param str expected: message + :returns: None + :rtype: None + """ + with pytest.raises(WMSEPSGCodeError, match=expected): + raise WMSEPSGCodeError(epsg_code=test_input[0], + epsg_codes_valid=test_input[1]) + + +def test_WMSFetchingError(): + """ + | Tests WMSFetchingError. + + :returns: None + :rtype: None + """ + url = 'https://invalid.wms.com' + passed_exception = Exception('Test message.') + + expected = ( + r'An exception is raised while fetching the image from the web map service \(https://invalid.wms.com\).\n' + 'Test message.') + + with pytest.raises(WMSFetchingError, match=expected): + raise WMSFetchingError(url=url, + passed_exception=passed_exception) + + +@pytest.mark.parametrize('test_input, expected', data_test_WMSLayerError) +def test_WMSLayerError(test_input, + expected): + """ + | Tests WMSLayerError. + + :param (str, list[str]) test_input: layer, layers_valid + :param str expected: message + :returns: None + :rtype: None + """ + with pytest.raises(WMSLayerError, match=expected): + raise WMSLayerError(layer=test_input[0], + layers_valid=test_input[1]) diff --git a/src/data/tests/test_remote_sensing_data_downloader.py b/src/data/tests/test_remote_sensing_data_downloader.py deleted file mode 100644 index f94cd12..0000000 --- a/src/data/tests/test_remote_sensing_data_downloader.py +++ /dev/null @@ -1,169 +0,0 @@ -from io import BytesIO -from pathlib import Path -from unittest import mock - -import numpy as np -import pytest -from PIL import Image - -from src.data.remote_sensing_data_downloader import RemoteSensingDataDownloader -from src.data.tests.data import * - -DATA_DIR_PATH = Path(__file__).resolve().parents[0] / 'data' - -with BytesIO() as output: - Image.open(DATA_DIR_PATH / 'data_test_get_image.tiff').save(output, format='TIFF') - mocked_response = output.getvalue() - - -@mock.patch('src.data.remote_sensing_data_downloader.WebMapService', return_value=mock.MagicMock) -def test_init(mocked_wms): - """ - | Tests __init__() with a mocked web map service. - - :param mock.MagicMock mocked_wms: mocked web map service - :returns: None - :rtype: None - """ - remote_sensing_data_downloader = RemoteSensingDataDownloader(wms_url='https://www.wms.de/wms_url', - wms_layer='wms_layer', - epsg_code=25832, - clip_border=False) - - assert isinstance(remote_sensing_data_downloader, RemoteSensingDataDownloader) - assert list(remote_sensing_data_downloader.__dict__.keys()) == ['wms', 'wms_layer', 'epsg_code', 'clip_border'] - assert isinstance(remote_sensing_data_downloader.wms, type(mock.MagicMock)) - mocked_wms.assert_called_once_with('https://www.wms.de/wms_url') - assert isinstance(remote_sensing_data_downloader.wms_layer, str) - assert remote_sensing_data_downloader.wms_layer == 'wms_layer' - assert isinstance(remote_sensing_data_downloader.epsg_code, int) - assert remote_sensing_data_downloader.epsg_code == 25832 - assert isinstance(remote_sensing_data_downloader.clip_border, bool) - assert remote_sensing_data_downloader.clip_border is False - - -@pytest.mark.parametrize('test_input, expected', parameters_get_bounding_box_no_clip_border) -def test_get_bounding_box_no_clip_border(test_input, - expected, - remote_sensing_data_downloader_no_clip_border): - """ - | Tests get_bounding_box() with different coordinates. - | Border clipping is not used. - - :param (int, int) test_input: coordinates (x_min, y_max) - :param (int, int, int, int) expected: bounding box (x_min, y_min, x_max, y_max) - :returns: None - :rtype: None - """ - bounding_box = remote_sensing_data_downloader_no_clip_border.get_bounding_box(coordinates=test_input) - - for coordinate in bounding_box: - assert isinstance(coordinate, int) - - assert bounding_box == expected - - -@pytest.mark.parametrize('test_input, expected', parameters_get_bounding_box_clip_border) -def test_get_bounding_box_clip_border(test_input, - expected, - remote_sensing_data_downloader_clip_border): - """ - | Tests get_bounding_box() with different coordinates. - | Border clipping is used. - - :param (int, int) test_input: coordinates (x_min, y_max) - :param (int, int, int, int) expected: bounding box (x_min, y_min, x_max, y_max) - :returns: None - :rtype: None - """ - bounding_box = remote_sensing_data_downloader_clip_border.get_bounding_box(coordinates=test_input) - - for coordinate in bounding_box: - assert isinstance(coordinate, int) - - assert bounding_box == expected - - -@mock.patch('src.data.remote_sensing_data_downloader.WebMapService', return_value=mock.MagicMock) -def test_get_response_no_clip_border(mocked_wms): - """ - | Tests get_response() with a mocked web map service. - | Border clipping is not used. - - :param mock.MagicMock mocked_wms: mocked web map service - :returns: None - :rtype: None - """ - remote_sensing_data_downloader = RemoteSensingDataDownloader(wms_url='https://www.wms.de/wms_url', - wms_layer='wms_layer', - epsg_code=25832, - clip_border=False) - - mocked_wms_instance = mocked_wms.return_value - mocked_wms_instance.getmap = mock.MagicMock() - - remote_sensing_data_downloader.get_response(bounding_box=(0, 0, 256, 256)) - - mocked_wms_instance.getmap.assert_called_once_with(layers=['wms_layer'], - srs='EPSG:25832', - bbox=(0, 0, 256, 256), - format='image/tiff', - size=(1280, 1280), - bgcolor='#000000') - - -@mock.patch('src.data.remote_sensing_data_downloader.WebMapService', return_value=mock.MagicMock) -def test_get_response_clip_border(mocked_wms): - """ - | Tests get_response() with a mocked web map service. - | Border clipping is used. - - :param mock.MagicMock mocked_wms: mocked web map service - :returns: None - :rtype: None - """ - remote_sensing_data_downloader = RemoteSensingDataDownloader(wms_url='https://www.wms.de/wms_url', - wms_layer='wms_layer', - epsg_code=25832, - clip_border=True) - - mocked_wms_instance = mocked_wms.return_value - mocked_wms_instance.getmap = mock.MagicMock() - - remote_sensing_data_downloader.get_response(bounding_box=(-64, -64, 320, 320)) - - mocked_wms_instance.getmap.assert_called_once_with(layers=['wms_layer'], - srs='EPSG:25832', - bbox=(-64, -64, 320, 320), - format='image/tiff', - size=(1920, 1920), - bgcolor='#000000') - - -@mock.patch('src.data.remote_sensing_data_downloader.WebMapService', return_value=mock.MagicMock) -@mock.patch.object(RemoteSensingDataDownloader, 'get_response') -def test_get_image(mocked_get_response, _mocked_wms): - """ - | Tests get_image() with a mocked get_response() and a mocked web map service. - - :param mock.MagicMock mocked_get_response: mocked get_response() - :param mock.MagicMock _mocked_wms: mocked web map service - :returns: None - :rtype: None - """ - remote_sensing_data_downloader = RemoteSensingDataDownloader(wms_url='https://www.wms.de/wms_url', - wms_layer='wms_layer', - epsg_code=25832, - clip_border=False) - - mocked_get_response.return_value = mocked_response - - image = remote_sensing_data_downloader.get_image(coordinates=(0, 256)) - - with Image.open(DATA_DIR_PATH / 'data_test_get_image.tiff') as file: - # noinspection PyTypeChecker - expected = np.array(file, dtype=np.uint8) - - mocked_get_response.assert_called_once_with((0, 0, 256, 256)) - assert image.dtype == expected.dtype - np.testing.assert_array_equal(image, expected) diff --git a/src/data/tests/test_remote_sensing_data_fetcher.py b/src/data/tests/test_remote_sensing_data_fetcher.py new file mode 100644 index 0000000..7c7a8e1 --- /dev/null +++ b/src/data/tests/test_remote_sensing_data_fetcher.py @@ -0,0 +1,28 @@ +from src.data.remote_sensing_data_fetcher import RemoteSensingDataFetcher +from src.data.web_map_service import WebMapServiceProtocol # noqa: F401 (used for type hinting) + + +def test_init(mocked_web_map_service): + """ + | Tests __init__(). + + :param WebMapServiceProtocol mocked_web_map_service: mocked web map service fixture + :returns: None + :rtype: None + """ + layer = 'layer_test' + epsg_code = 25832 + + remote_sensing_data_fetcher = RemoteSensingDataFetcher(web_map_service=mocked_web_map_service, + layer=layer, + epsg_code=epsg_code) + + assert isinstance(remote_sensing_data_fetcher, RemoteSensingDataFetcher) + attributes = ['web_map_service', 'layer', 'epsg_code'] + assert list(vars(remote_sensing_data_fetcher).keys()) == attributes + + assert remote_sensing_data_fetcher.web_map_service.url == 'https://wms.com' + assert isinstance(remote_sensing_data_fetcher.layer, str) + assert remote_sensing_data_fetcher.layer == layer + assert isinstance(remote_sensing_data_fetcher.epsg_code, int) + assert remote_sensing_data_fetcher.epsg_code == epsg_code diff --git a/src/data/web_map_service.py b/src/data/web_map_service.py new file mode 100644 index 0000000..473d3c3 --- /dev/null +++ b/src/data/web_map_service.py @@ -0,0 +1,140 @@ +from io import BytesIO +from typing import Protocol + +import numpy as np +import owslib.wms +from PIL import Image + +from .exceptions import WMSConnectionError, WMSFetchingError + + +class WebMapServiceProtocol(Protocol): + + def get_layers(self): + """ + | Returns the layers. + + :returns: layers + :rtype: list[str] + """ + ... + + def get_epsg_codes(self, + layer): + """ + | Returns the epsg codes. + + :param str layer: layer + :returns: epsg codes + :rtype: list[int] + """ + ... + + def fetch_image(self, + layer, + bounding_box, + image_size, + epsg_code): + """ + | Returns the fetched image. + + :param str layer: layer + :param (int, int, int, int) bounding_box: bounding box (x_min, y_min, x_max, y_max) + :param int image_size: image size in pixels + :param int epsg_code: epsg code + :returns: fetched image + :rtype: np.ndarray[np.uint8] + :raises WMSFetchingError: if an exception is raised while fetching the image + """ + ... + + +class WebMapService: + + def __init__(self, + url): + """ + | Initializer method + + :param str url: url + :returns: None + :rtype: None + :raises WMSConnectionError: if an exception is raised while connecting to the web map service + """ + assert isinstance(url, str) + + self.url = url + + try: + self._session = owslib.wms.WebMapService(url=self.url, + version='1.1.1') + + except Exception as e: + raise WMSConnectionError(url=self.url, + passed_exception=e) + + def get_layers(self): + """ + | Returns the layers. + + :returns: layers + :rtype: list[str] + """ + return [*self._session.contents] + + def get_epsg_codes(self, + layer): + """ + | Returns the epsg codes. + + :param str layer: layer + :returns: epsg codes + :rtype: list[int] + """ + assert isinstance(layer, str) + + return [int(epsg_code[5:]) for epsg_code in self._session[layer].crsOptions] + + def fetch_image(self, + layer, + bounding_box, + image_size, + epsg_code): + """ + | Returns the fetched image. + + :param str layer: layer + :param (int, int, int, int) bounding_box: bounding box (x_min, y_min, x_max, y_max) + :param int image_size: image size in pixels + :param int epsg_code: epsg code + :returns: fetched image + :rtype: np.ndarray[np.uint8] + :raises WMSFetchingError: if an exception is raised while fetching the image + """ + assert isinstance(layer, str) + + assert isinstance(bounding_box, tuple) + assert len(bounding_box) == 4 + assert all(isinstance(coordinate, int) for coordinate in bounding_box) + assert bounding_box[0] < bounding_box[2] and bounding_box[1] < bounding_box[3] + + assert isinstance(image_size, int) + + assert isinstance(epsg_code, int) + + try: + data = self._session.getmap(layers=[layer], + srs=f'EPSG:{epsg_code}', + bbox=bounding_box, + format='image/tiff', + size=(image_size, image_size), + bgcolor='#000000').read() + + except Exception as e: + raise WMSFetchingError(url=self.url, + passed_exception=e) + + with Image.open(BytesIO(data)) as file: + image = np.array(file, dtype=np.uint8) + + return image diff --git a/src/parsing/config.py b/src/parsing/config.py index 2206cb2..a27385f 100644 --- a/src/parsing/config.py +++ b/src/parsing/config.py @@ -5,14 +5,18 @@ import numpy as np import pydantic from natsort import natsorted -from owslib.wms import WebMapService from pydantic import root_validator, validator -from src.parsing.config_exceptions import ( +from src.data import WebMapService + +from src.data.exceptions import ( + WMSEPSGCodeError, + WMSLayerError) + +from src.parsing.exceptions import ( BoundingBoxLengthError, BoundingBoxNotDefinedError, BoundingBoxValueError, - EPSGCodeError, GeoDataEmptyError, GeoDataFormatError, GeoDataGeometryError, @@ -22,9 +26,7 @@ OutputDirNotFoundError, PrefixError, SieveSizeError, - TileSizeError, - WMSConnectionError, - WMSLayerError) + TileSizeError) class WMS(pydantic.BaseModel): @@ -40,17 +42,8 @@ def validate_url(cls, :param str value: url :returns: validated url :rtype: str - :raises WMSConnectionError: if an exception occurs while connecting to the web map service - (the exception raised by owslib is passed) """ - try: - _ = WebMapService(url=value, - version='1.1.1') - - except Exception as e: - raise WMSConnectionError(url=value, - passed_exception=e) - + _ = WebMapService(url=value) return value @validator('layer') @@ -64,19 +57,10 @@ def validate_layer(cls, :param dict[str, Any] values: values :returns: validated layer :rtype: str - :raises WMSConnectionError: if an exception occurs while connecting to the web map service - (the exception raised by owslib is passed) - :raises WMSLayerError: if layer is not a valid layer of the web map service + :raises WMSLayerError: if layer is not a valid layer """ - try: - web_map_service = WebMapService(url=values['url'], - version='1.1.1') - - except Exception as e: - raise WMSConnectionError(url=value, - passed_exception=e) - - layers_valid = [*web_map_service.contents] + web_map_service = WebMapService(url=values['url']) + layers_valid = web_map_service.get_layers() if value not in layers_valid: raise WMSLayerError(layer=value, @@ -105,25 +89,19 @@ def validate_epsg_code(cls, :param dict[str, Any] values: values :returns: validated epsg_code :rtype: int - :raises EPSGCodeError: if epsg_code is not a valid epsg code of the web map services + :raises WMSEPSGCodeError: if epsg_code is not a valid epsg code """ - wms_rgb = WebMapService(url=values['rgb'].url, - version='1.1.1') - - wms_nir = WebMapService(url=values['nir'].url, - version='1.1.1') - - epsg_codes_valid_rgb = [int(epsg_code[5:]) - for epsg_code in wms_rgb[values['rgb'].layer].crsOptions] + web_map_service_rgb = WebMapService(url=values['rgb'].url) + web_map_service_nir = WebMapService(url=values['nir'].url) - epsg_codes_valid_nir = [int(epsg_code[5:]) - for epsg_code in wms_nir[values['nir'].layer].crsOptions] + epsg_codes_valid_rgb = web_map_service_rgb.get_epsg_codes(values['rgb'].layer) + epsg_codes_valid_nir = web_map_service_nir.get_epsg_codes(values['nir'].layer) epsg_codes_valid = list(set(epsg_codes_valid_rgb) & set(epsg_codes_valid_nir)) if value not in epsg_codes_valid: - raise EPSGCodeError(epsg_code=value, - epsg_codes_valid=epsg_codes_valid) + raise WMSEPSGCodeError(epsg_code=value, + epsg_codes_valid=epsg_codes_valid) return value diff --git a/src/parsing/config_exceptions.py b/src/parsing/exceptions.py similarity index 72% rename from src/parsing/config_exceptions.py rename to src/parsing/exceptions.py index 8e7d9f1..0813bc7 100644 --- a/src/parsing/config_exceptions.py +++ b/src/parsing/exceptions.py @@ -1,7 +1,5 @@ from pathlib import Path # noqa: F401 (used for type hinting) -from natsort import natsorted - class BoundingBoxError(Exception): @@ -67,35 +65,6 @@ def __init__(self, super().__init__(message) -class EPSGCodeError(Exception): - - def __init__(self, - epsg_code, - epsg_codes_valid): - """ - | Initializer method - - :param int epsg_code: epsg code - :param list[int] epsg_codes_valid: valid epsg codes - :returns: None - :rtype: None - """ - if len(epsg_codes_valid) == 1: - epsg_codes_valid = epsg_codes_valid[0] - else: - epsg_codes_valid = natsorted(epsg_codes_valid) - - epsg_codes_valid = ( - f"{', '.join(map(str, epsg_codes_valid[:-1]))} " - f'or {epsg_codes_valid[-1]}') - - message = ( - 'Invalid epsg_code in the config!\n' - f'Expected {epsg_codes_valid}, got {epsg_code} instead.') - - super().__init__(message) - - class GeoDataError(Exception): def __init__(self, @@ -301,66 +270,3 @@ def __init__(self, f'Expected a number greater than 0, got {tile_size} instead.') super().__init__(message) - - -class WMSError(Exception): - - def __init__(self, - message='Invalid web map service in the config!'): - """ - | Initializer method - - :param str message: message - :returns: None - :rtype: None - """ - super().__init__(message) - - -class WMSConnectionError(WMSError): - - def __init__(self, - url, - passed_exception): - """ - | Initializer method - - :param str url: url of the web map service - :param Exception passed_exception: passed exception - :returns: None - :rtype: None - """ - message = ( - 'Invalid url in the config!\n' - f'An exception occurred while connecting to the web map service ({url}).\n' - f'{passed_exception}') - - super().__init__(message) - - -class WMSLayerError(WMSError): - - def __init__(self, - layer, - layers_valid): - """ - | Initializer method - - :param str layer: layer of the web map service - :param list[str] layers_valid: valid layers of the web map service - :returns: None - :rtype: None - """ - if len(layers_valid) == 1: - layers_valid = layers_valid[0] - else: - layers_valid = natsorted(layers_valid) - layers_valid = ( - f"{', '.join(map(str, layers_valid[:-1]))} " - f'or {layers_valid[-1]}') - - message = ( - 'Invalid layer in the config!\n' - f'Expected {layers_valid}, got {layer} instead.') - - super().__init__(message) diff --git a/src/parsing/tests/data/data_test_config_exceptions.py b/src/parsing/tests/data/data_test_exceptions.py similarity index 85% rename from src/parsing/tests/data/data_test_config_exceptions.py rename to src/parsing/tests/data/data_test_exceptions.py index 6f108bd..9b702c5 100644 --- a/src/parsing/tests/data/data_test_config_exceptions.py +++ b/src/parsing/tests/data/data_test_exceptions.py @@ -21,17 +21,6 @@ r'Expected 4 coordinates \(x_min, y_min, x_max, y_max\) with x_min < x_max and y_min < y_max, ' r'got \(-1, -1, -2, -2\) instead.')]) -data_test_EPSGCodeError = ( - [((0, [1]), - r'Invalid epsg_code in the config!\n' - 'Expected 1, got 0 instead.'), - ((0, [1, 2]), - r'Invalid epsg_code in the config!\n' - 'Expected 1 or 2, got 0 instead.'), - ((0, [1, 2, 3]), - r'Invalid epsg_code in the config!\n' - 'Expected 1, 2 or 3, got 0 instead.')]) - data_test_GeoDataEmptyError = ( [(('path_test', Path(r'path\to\geo_data.gpkg')), r'Invalid path_test in the config!\n' @@ -97,14 +86,3 @@ (11, r'Invalid sieve_size in the config!\n' 'Expected a number in the range of 0 to 10, got 11 instead.')]) - -data_test_WMSLayerError = ( - [(('z', ['a']), - r'Invalid layer in the config!\n' - 'Expected a, got z instead.'), - (('z', ['a', 'b']), - r'Invalid layer in the config!\n' - 'Expected a or b, got z instead.'), - (('z', ['a', 'b', 'c']), - r'Invalid layer in the config!\n' - 'Expected a, b or c, got z instead.')]) diff --git a/src/parsing/tests/test_config.py b/src/parsing/tests/test_config.py index a7c5f32..afc819d 100644 --- a/src/parsing/tests/test_config.py +++ b/src/parsing/tests/test_config.py @@ -3,7 +3,7 @@ from src.parsing.config import ( Postprocessing) -from src.parsing.config_exceptions import ( +from src.parsing.exceptions import ( SieveSizeError) from .data.data_test_config import ( diff --git a/src/parsing/tests/test_config_exceptions.py b/src/parsing/tests/test_exceptions.py similarity index 76% rename from src/parsing/tests/test_config_exceptions.py rename to src/parsing/tests/test_exceptions.py index ab2b159..9faebfb 100644 --- a/src/parsing/tests/test_config_exceptions.py +++ b/src/parsing/tests/test_exceptions.py @@ -2,12 +2,11 @@ import pytest -from src.parsing.config_exceptions import ( +from src.parsing.exceptions import ( BoundingBoxError, BoundingBoxLengthError, BoundingBoxNotDefinedError, BoundingBoxValueError, - EPSGCodeError, GeoDataError, GeoDataEmptyError, GeoDataFormatError, @@ -18,15 +17,11 @@ OutputDirNotFoundError, PrefixError, SieveSizeError, - TileSizeError, - WMSError, - WMSConnectionError, - WMSLayerError) + TileSizeError) -from .data.data_test_config_exceptions import ( +from .data.data_test_exceptions import ( data_test_BoundingBoxLengthError, data_test_BoundingBoxValueError, - data_test_EPSGCodeError, data_test_GeoDataEmptyError, data_test_GeoDataFormatError, data_test_GeoDataGeometryError, @@ -34,8 +29,7 @@ data_test_GeoDataNotFoundError, data_test_GeoDataTypeError, data_test_OutputDirNotFoundError, - data_test_SieveSizeError, - data_test_WMSLayerError) + data_test_SieveSizeError) def test_BoundingBoxError_default(): @@ -109,22 +103,6 @@ def test_BoundingBoxValueError(test_input, raise BoundingBoxValueError(bounding_box=test_input) -@pytest.mark.parametrize('test_input, expected', data_test_EPSGCodeError) -def test_EPSGCodeError(test_input, - expected): - """ - | Tests EPSGCodeError. - - :param (int, list[int]) test_input: epsg_code, epsg_codes_valid - :param str expected: message - :returns: None - :rtype: None - """ - with pytest.raises(EPSGCodeError, match=expected): - raise EPSGCodeError(epsg_code=test_input[0], - epsg_codes_valid=test_input[1]) - - def test_GeoDataError_default(): """ | Tests the default message of GeoDataError. @@ -310,67 +288,3 @@ def test_TileSizeError(): with pytest.raises(TileSizeError, match=expected): raise TileSizeError(tile_size=tile_size) - - -def test_WMSError_default(): - """ - | Tests the default message of WMSError. - - :returns: None - :rtype: None - """ - expected = 'Invalid web map service in the config!' - - with pytest.raises(WMSError, match=expected): - raise WMSError() - - -def test_WMSError(): - """ - | Tests WMSError. - - :returns: None - :rtype: None - """ - message = 'Test message.' - - expected = 'Test message.' - - with pytest.raises(WMSError, match=expected): - raise WMSError(message=message) - - -def test_WMSConnectionError(): - """ - | Tests WMSConnectionError. - - :returns: None - :rtype: None - """ - url = 'https://invalid.wms.com' - passed_exception = Exception('Test message.') - - expected = ( - r'Invalid url in the config!\n' - r'An exception occurred while connecting to the web map service \(https://invalid.wms.com\).\n' - 'Test message.') - - with pytest.raises(WMSConnectionError, match=expected): - raise WMSConnectionError(url=url, - passed_exception=passed_exception) - - -@pytest.mark.parametrize('test_input, expected', data_test_WMSLayerError) -def test_WMSLayerError(test_input, - expected): - """ - | Tests WMSLayerError. - - :param (str, list[str]) test_input: layer, layers_valid - :param str expected: message - :returns: None - :rtype: None - """ - with pytest.raises(WMSLayerError, match=expected): - raise WMSLayerError(layer=test_input[0], - layers_valid=test_input[1])