From 6600105b0eca6a5da2c98436a66fd1eea1c58a76 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Wed, 21 May 2025 17:40:01 +0200 Subject: [PATCH 01/24] wip --- raster_loader/io/common.py | 18 +++++++++++++++++- raster_loader/tests/bigquery/test_io.py | 4 ++++ raster_loader/tests/databricks/test_io.py | 1 + raster_loader/tests/snowflake/test_io.py | 4 ++++ setup.cfg | 1 + 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/raster_loader/io/common.py b/raster_loader/io/common.py index b9e2bb1..15bd3b8 100644 --- a/raster_loader/io/common.py +++ b/raster_loader/io/common.py @@ -4,6 +4,7 @@ import shapely import sys import zlib +import gdal from raster_loader._version import __version__ from collections import Counter @@ -203,6 +204,18 @@ def get_color_table(raster_dataset: rasterio.io.DatasetReader, band: int): return None +def get_value_labels(dataset_uri: str, band: int): + try: + dataset = gdal.Open(dataset_uri) # dataset_uri is path to .tif file + band = dataset.GetRasterBand(band) + # https://gdal.org/en/stable/doxygen/classGDALRasterBand.html#a024b33f6ceaa9c8f1f077b072982c1e0 + rat = band.GetDefaultRAT() + # TODO: Convert to dict + return rat + except ValueError: + return None + + def rasterio_metadata( file_path: str, bands_info: List[Tuple[int, str]], @@ -284,6 +297,7 @@ def rasterio_metadata( "name": band_field_name(band_name, band, band_rename_function), "colorinterp": band_colorinterp, "colortable": get_color_table(raster_dataset, band), + "valuelabels": get_value_labels(file_path, band), "stats": stats, "nodata": band_nodata, } @@ -311,6 +325,7 @@ def rasterio_metadata( "colorinterp": e["colorinterp"], "nodata": e["nodata"], "colortable": e["colortable"], + "valuelabels": e["valuelabels"], } for e in bands_metadata ] @@ -937,7 +952,8 @@ def is_valid_raster_dataset(raster_dataset: rasterio.io.DatasetReader) -> bool: def band_without_stats(band): return { k: band[k] - for k in set(list(band.keys())) - set(["stats", "colorinterp", "colortable"]) + for k in set(list(band.keys())) + - set(["stats", "colorinterp", "colortable", "valuelabels"]) } diff --git a/raster_loader/tests/bigquery/test_io.py b/raster_loader/tests/bigquery/test_io.py index f7a9e45..b982b76 100644 --- a/raster_loader/tests/bigquery/test_io.py +++ b/raster_loader/tests/bigquery/test_io.py @@ -483,6 +483,7 @@ def test_rasterio_to_table_overwrite(*args, **kwargs): }, "nodata": "0", "colortable": None, + "valuelabels": None, } ], "num_blocks": 1, @@ -649,6 +650,7 @@ def test_rasterio_to_table_invalid_raster(*args, **kwargs): }, "nodata": "0", "colortable": None, + "valuelabels": None, } ], "num_blocks": 1, @@ -744,6 +746,7 @@ def test_get_labels(*args, **kwargs): }, "nodata": "0", "colortable": None, + "valuelabels": None, } ], "num_blocks": 1, @@ -802,6 +805,7 @@ def test_rasterio_to_bigquery_with_compression(*args, **kwargs): }, "nodata": "0", "colortable": None, + "valuelabels": None, } ], "num_blocks": 1, diff --git a/raster_loader/tests/databricks/test_io.py b/raster_loader/tests/databricks/test_io.py index 1991bdb..82c34f7 100644 --- a/raster_loader/tests/databricks/test_io.py +++ b/raster_loader/tests/databricks/test_io.py @@ -391,6 +391,7 @@ def test_rasterio_to_table(*args, **kwargs): "nodata": "0", "colorinterp": "red", "colortable": None, + "valuelabels": None, } ], "num_blocks": 1, diff --git a/raster_loader/tests/snowflake/test_io.py b/raster_loader/tests/snowflake/test_io.py index d38c9e4..0437b7d 100644 --- a/raster_loader/tests/snowflake/test_io.py +++ b/raster_loader/tests/snowflake/test_io.py @@ -462,6 +462,7 @@ def test_rasterio_to_table_overwrite(*args, **kwargs): "nodata": "0", "colorinterp": "red", "colortable": None, + "valuelabels": None, } ], "num_blocks": 1, @@ -629,6 +630,7 @@ def test_rasterio_to_table_invalid_raster(*args, **kwargs): "nodata": "0", "colorinterp": "red", "colortable": None, + "valuelabels": None, } ], "num_blocks": 1, @@ -712,6 +714,7 @@ def test_append_with_different_resolution(*args, **kwargs): "nodata": "0", "colorinterp": "red", "colortable": None, + "valuelabels": None, } ], "num_blocks": 1, @@ -772,6 +775,7 @@ def test_rasterio_to_snowflake_with_compression(*args, **kwargs): "nodata": "0", "colorinterp": "red", "colortable": None, + "valuelabels": None, } ], "num_blocks": 1, diff --git a/setup.cfg b/setup.cfg index f0c293b..c85070c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,6 +41,7 @@ install_requires = shapely>=1.7.1 quadbin>=0.2.0 tqdm>=4.64.1 + GDAL>=3.4.0 zip_safe = False [options.entry_points] From 27cb1b368845ae08b9e109a3e13ce69df7e75847 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Thu, 22 May 2025 09:35:26 +0200 Subject: [PATCH 02/24] ... --- raster_loader/io/common.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/raster_loader/io/common.py b/raster_loader/io/common.py index 15bd3b8..dfac9a3 100644 --- a/raster_loader/io/common.py +++ b/raster_loader/io/common.py @@ -206,11 +206,12 @@ def get_color_table(raster_dataset: rasterio.io.DatasetReader, band: int): def get_value_labels(dataset_uri: str, band: int): try: + # TODO: Do we support other formats than "column-name -> str-value"? dataset = gdal.Open(dataset_uri) # dataset_uri is path to .tif file band = dataset.GetRasterBand(band) # https://gdal.org/en/stable/doxygen/classGDALRasterBand.html#a024b33f6ceaa9c8f1f077b072982c1e0 rat = band.GetDefaultRAT() - # TODO: Convert to dict + # TODO: Convert to "dict of dicts"? return rat except ValueError: return None From 90ba4531d3983e26bcf25c171839a23b1dfc29fa Mon Sep 17 00:00:00 2001 From: juanrmn Date: Mon, 2 Jun 2025 15:57:28 +0200 Subject: [PATCH 03/24] wip --- raster_loader/io/common.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/raster_loader/io/common.py b/raster_loader/io/common.py index dfac9a3..27e49c9 100644 --- a/raster_loader/io/common.py +++ b/raster_loader/io/common.py @@ -4,13 +4,13 @@ import shapely import sys import zlib -import gdal from raster_loader._version import __version__ from collections import Counter from typing import Dict, Callable, Iterable, List, Tuple, Union from affine import Affine from shapely import wkt # Can not use directly from shapely.wkt +from osgeo import gdal import rio_cogeo import rasterio @@ -298,7 +298,7 @@ def rasterio_metadata( "name": band_field_name(band_name, band, band_rename_function), "colorinterp": band_colorinterp, "colortable": get_color_table(raster_dataset, band), - "valuelabels": get_value_labels(file_path, band), + # "valuelabels": get_value_labels(file_path, band), "stats": stats, "nodata": band_nodata, } @@ -326,7 +326,7 @@ def rasterio_metadata( "colorinterp": e["colorinterp"], "nodata": e["nodata"], "colortable": e["colortable"], - "valuelabels": e["valuelabels"], + # "valuelabels": e["valuelabels"], } for e in bands_metadata ] @@ -953,8 +953,8 @@ def is_valid_raster_dataset(raster_dataset: rasterio.io.DatasetReader) -> bool: def band_without_stats(band): return { k: band[k] - for k in set(list(band.keys())) - - set(["stats", "colorinterp", "colortable", "valuelabels"]) + for k in set(list(band.keys())) - set(["stats", "colorinterp", "colortable"]) + # - set(["stats", "colorinterp", "colortable", "valuelabels"]) } From a5d7b21865f4ba56aa1d90cdb739b39586573571 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Mon, 2 Jun 2025 17:18:57 +0200 Subject: [PATCH 04/24] wip --- raster_loader/io/common.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/raster_loader/io/common.py b/raster_loader/io/common.py index 27e49c9..9e6b948 100644 --- a/raster_loader/io/common.py +++ b/raster_loader/io/common.py @@ -10,7 +10,6 @@ from typing import Dict, Callable, Iterable, List, Tuple, Union from affine import Affine from shapely import wkt # Can not use directly from shapely.wkt -from osgeo import gdal import rio_cogeo import rasterio @@ -206,13 +205,15 @@ def get_color_table(raster_dataset: rasterio.io.DatasetReader, band: int): def get_value_labels(dataset_uri: str, band: int): try: - # TODO: Do we support other formats than "column-name -> str-value"? - dataset = gdal.Open(dataset_uri) # dataset_uri is path to .tif file - band = dataset.GetRasterBand(band) - # https://gdal.org/en/stable/doxygen/classGDALRasterBand.html#a024b33f6ceaa9c8f1f077b072982c1e0 - rat = band.GetDefaultRAT() - # TODO: Convert to "dict of dicts"? - return rat + # from osgeo import gdal # Move the import here + # # TODO: Do we support other formats than "column-name -> str-value"? + # dataset = gdal.Open(dataset_uri) # dataset_uri is path to .tif file + # band = dataset.GetRasterBand(band) + + # rat = band.GetDefaultRAT() + # # TODO: Convert to "dict of dicts"? + # return rat + return {"testing": "testing!!"} except ValueError: return None @@ -298,7 +299,7 @@ def rasterio_metadata( "name": band_field_name(band_name, band, band_rename_function), "colorinterp": band_colorinterp, "colortable": get_color_table(raster_dataset, band), - # "valuelabels": get_value_labels(file_path, band), + "valuelabels": get_value_labels(file_path, band), "stats": stats, "nodata": band_nodata, } @@ -326,7 +327,7 @@ def rasterio_metadata( "colorinterp": e["colorinterp"], "nodata": e["nodata"], "colortable": e["colortable"], - # "valuelabels": e["valuelabels"], + "valuelabels": e["valuelabels"], } for e in bands_metadata ] @@ -953,8 +954,8 @@ def is_valid_raster_dataset(raster_dataset: rasterio.io.DatasetReader) -> bool: def band_without_stats(band): return { k: band[k] - for k in set(list(band.keys())) - set(["stats", "colorinterp", "colortable"]) - # - set(["stats", "colorinterp", "colortable", "valuelabels"]) + for k in set(list(band.keys())) + - set(["stats", "colorinterp", "colortable", "valuelabels"]) } From fb3b246a3feeb8a4ca8e642a0b5b22428bcda1b3 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Mon, 9 Jun 2025 12:43:36 +0200 Subject: [PATCH 05/24] picking columns. wip --- raster_loader/io/common.py | 31 +++++++++---- raster_loader/valuelabels.py | 87 ++++++++++++++++++++++++++++++++++++ setup.cfg | 2 +- 3 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 raster_loader/valuelabels.py diff --git a/raster_loader/io/common.py b/raster_loader/io/common.py index 9e6b948..491d7ac 100644 --- a/raster_loader/io/common.py +++ b/raster_loader/io/common.py @@ -10,7 +10,9 @@ from typing import Dict, Callable, Iterable, List, Tuple, Union from affine import Affine from shapely import wkt # Can not use directly from shapely.wkt +from osgeo import gdal +from raster_loader.valuelabels import get_labels_column_idx, get_values_column_idx import rio_cogeo import rasterio import quadbin @@ -21,6 +23,8 @@ ) from raster_loader.utils import warnings +gdal.UseExceptions() + DEFAULT_COG_BLOCK_SIZE = 256 DEFAULT_TYPES_NODATA_VALUES = { @@ -205,15 +209,24 @@ def get_color_table(raster_dataset: rasterio.io.DatasetReader, band: int): def get_value_labels(dataset_uri: str, band: int): try: - # from osgeo import gdal # Move the import here - # # TODO: Do we support other formats than "column-name -> str-value"? - # dataset = gdal.Open(dataset_uri) # dataset_uri is path to .tif file - # band = dataset.GetRasterBand(band) - - # rat = band.GetDefaultRAT() - # # TODO: Convert to "dict of dicts"? - # return rat - return {"testing": "testing!!"} + dataset = gdal.Open(dataset_uri) # dataset_uri is path to .tif file + band = dataset.GetRasterBand(band) + + rat = band.GetDefaultRAT() + if rat is None: + return None + + # Pick columns for values and labels + values_column_idx = get_values_column_idx(rat) + labels_column_idx = get_labels_column_idx(rat) + + # Convert RAT to dictionary + value_labels = {} + for i in range(rat.GetRowCount()): + value = rat.GetValueAsInt(i, values_column_idx) + label = rat.GetValueAsString(i, labels_column_idx) + value_labels[value] = label + return value_labels except ValueError: return None diff --git a/raster_loader/valuelabels.py b/raster_loader/valuelabels.py new file mode 100644 index 0000000..31fdef1 --- /dev/null +++ b/raster_loader/valuelabels.py @@ -0,0 +1,87 @@ +from osgeo import gdal +from nltk import bigrams + +# TODO Add nltk dependency (3.9.1) + +DEFAULT_VALUES_COLUMN_IDX = 0 +DEFAULT_LABELS_COLUMN_IDX = 1 + + +def get_values_column_idx(rat: gdal.RasterAttributeTable) -> int: + values_column_idx = DEFAULT_VALUES_COLUMN_IDX + for col_idx in range(rat.GetColumnCount()): + col_name = rat.GetNameOfCol(col_idx) + if col_name.lower() == "value": + values_column_idx = col_idx + + return values_column_idx + + +def get_labels_column_idx(rat: gdal.RasterAttributeTable) -> int: + """ + Get the column index of the labels column in a Raster Attribute Table (RAT). + + This function uses a heuristic approach to identify the most likely column + containing descriptive labels by: + 1. Excluding the column already selected for values + 2. Ranking remaining columns based on: + - Number of unique values in the column + - Number of meaningful text bigrams (pairs of words) in the column + 3. Selecting the column with the highest combined score + + Returns: + int: The index of the column most likely to contain labels + """ + labels_column_idx = DEFAULT_LABELS_COLUMN_IDX + unique_values_count = count_column_unique_values(rat) + values_column_idx = get_values_column_idx(rat) + bigrams_count = count_bigrams(rat) + + # Ranked columns by (unique values count * bigrams count) + ranked_columns = {} + for col_idx in range(rat.GetColumnCount()): + if col_idx == values_column_idx: + # Skip the already selected column for values + continue + ranked_columns[col_idx] = unique_values_count[col_idx] * bigrams_count[col_idx] + + # Sort the ranked columns by the rank + ranked_columns = sorted(ranked_columns.items(), key=lambda x: x[1], reverse=True) + + # Get the column with the highest rank + if ranked_columns[0][1] > 0: + labels_column_idx = ranked_columns[0][0] + else: + # If no bigrams found, select column with most unique values + labels_column_idx = max(unique_values_count.items(), key=lambda x: x[1])[0] + + return labels_column_idx + + +# Return a dictionary with the column_idx and the number of unique values in it +def count_column_unique_values(rat: gdal.RasterAttributeTable) -> dict[int, int]: + unique_values_count = {} + for col_idx in range(rat.GetColumnCount()): + col_unique_values = set() + for row_idx in range(rat.GetRowCount()): + value = rat.GetValueAsString(row_idx, col_idx) + col_unique_values.add(value) + unique_values_count[col_idx] = len(col_unique_values) + + return unique_values_count + + +def count_bigrams(rat: gdal.RasterAttributeTable) -> dict[int, int]: + bigrams_count = {} + for col_idx in range(rat.GetColumnCount()): + column_text = "" + for row_idx in range(rat.GetRowCount()): + column_text += rat.GetValueAsString(row_idx, col_idx).lower() + " " + bigrams_list = set(bigrams(column_text.split())) + # Remove bigrams which are not text + bigrams_list = [ + bg for bg in bigrams_list if bg[0].isalpha() and bg[1].isalpha() + ] + bigrams_count[col_idx] = len(bigrams_list) + + return bigrams_count diff --git a/setup.cfg b/setup.cfg index c85070c..eacacd6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,7 +34,7 @@ install_requires = click>=8.1.3 db-dtypes>=1.0.5 pandas>=1.3.4 - pyarrow>=10.0.1 + pyarrow>=10.0.1,<17.0.0 pyproj>=3.2.1 rasterio>=1.3a3 rio-cogeo>=3.5.0 From 1b08b5ea8dc18cce2b43b51b665efa4c6a2056f8 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Mon, 9 Jun 2025 12:43:36 +0200 Subject: [PATCH 06/24] picking relevant columns from RAT for valuelabels --- raster_loader/io/common.py | 17 +---- raster_loader/valuelabels.py | 119 +++++++++++++++++++++++++++++++++++ setup.cfg | 3 +- 3 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 raster_loader/valuelabels.py diff --git a/raster_loader/io/common.py b/raster_loader/io/common.py index 9e6b948..364d585 100644 --- a/raster_loader/io/common.py +++ b/raster_loader/io/common.py @@ -11,6 +11,7 @@ from affine import Affine from shapely import wkt # Can not use directly from shapely.wkt +from raster_loader.valuelabels import get_value_labels import rio_cogeo import rasterio import quadbin @@ -21,6 +22,7 @@ ) from raster_loader.utils import warnings + DEFAULT_COG_BLOCK_SIZE = 256 DEFAULT_TYPES_NODATA_VALUES = { @@ -203,21 +205,6 @@ def get_color_table(raster_dataset: rasterio.io.DatasetReader, band: int): return None -def get_value_labels(dataset_uri: str, band: int): - try: - # from osgeo import gdal # Move the import here - # # TODO: Do we support other formats than "column-name -> str-value"? - # dataset = gdal.Open(dataset_uri) # dataset_uri is path to .tif file - # band = dataset.GetRasterBand(band) - - # rat = band.GetDefaultRAT() - # # TODO: Convert to "dict of dicts"? - # return rat - return {"testing": "testing!!"} - except ValueError: - return None - - def rasterio_metadata( file_path: str, bands_info: List[Tuple[int, str]], diff --git a/raster_loader/valuelabels.py b/raster_loader/valuelabels.py new file mode 100644 index 0000000..ddb5e1c --- /dev/null +++ b/raster_loader/valuelabels.py @@ -0,0 +1,119 @@ +from osgeo import gdal +from nltk import bigrams + +DEFAULT_VALUES_COLUMN_IDX = 0 +DEFAULT_LABELS_COLUMN_IDX = 1 + +gdal.UseExceptions() + + +def get_value_labels(dataset_uri: str, band: int): + """ + Get the value labels for a given dataset and band. + """ + print(f"Computing value labels for band {band}") + try: + dataset = gdal.Open(dataset_uri) # dataset_uri is path to .tif file + band = dataset.GetRasterBand(band) + + rat = band.GetDefaultRAT() + if rat is None: + print(f"\tNo Raster Attribute Table (RAT) found for band {band}") + return None + + # Pick columns for values and labels + values_column_idx = get_values_column_idx(rat) + labels_column_idx = get_labels_column_idx(rat) + + print(f"\tSelected column for Values: {rat.GetNameOfCol(values_column_idx)}") + print(f"\tSelected column for Labels: {rat.GetNameOfCol(labels_column_idx)}") + + # Convert RAT to dictionary + value_labels = {} + for i in range(rat.GetRowCount()): + value = rat.GetValueAsInt(i, values_column_idx) + label = rat.GetValueAsString(i, labels_column_idx) + value_labels[value] = label + return value_labels + except ValueError: + return None + + +def get_values_column_idx(rat: gdal.RasterAttributeTable) -> int: + values_column_idx = DEFAULT_VALUES_COLUMN_IDX + for col_idx in range(rat.GetColumnCount()): + col_name = rat.GetNameOfCol(col_idx) + if col_name.lower() == "value": + values_column_idx = col_idx + + return values_column_idx + + +def get_labels_column_idx(rat: gdal.RasterAttributeTable) -> int: + """ + Get the column index of the labels column in a Raster Attribute Table (RAT). + + This function uses a heuristic approach to identify the most likely column + containing descriptive labels by: + 1. Excluding the column already selected for values + 2. Ranking remaining columns based on: + - Number of unique values in the column + - Number of meaningful text bigrams (pairs of words) in the column + 3. Selecting the column with the highest combined score + + Returns: + int: The index of the column most likely to contain labels + """ + labels_column_idx = DEFAULT_LABELS_COLUMN_IDX + unique_values_count = count_column_unique_values(rat) + values_column_idx = get_values_column_idx(rat) + bigrams_count = count_bigrams(rat) + + # Ranked columns by (unique values count * bigrams count) + ranked_columns = {} + for col_idx in range(rat.GetColumnCount()): + if col_idx == values_column_idx: + # Skip the already selected column for values + continue + ranked_columns[col_idx] = unique_values_count[col_idx] * bigrams_count[col_idx] + + # Sort the ranked columns by the rank + ranked_columns = sorted(ranked_columns.items(), key=lambda x: x[1], reverse=True) + + # Get the column with the highest rank + if ranked_columns[0][1] > 0: + labels_column_idx = ranked_columns[0][0] + else: + # If no bigrams found, select column with most unique values + labels_column_idx = max(unique_values_count.items(), key=lambda x: x[1])[0] + + return labels_column_idx + + +# Return a dictionary with the column_idx and the number of unique values in it +def count_column_unique_values(rat: gdal.RasterAttributeTable) -> dict[int, int]: + unique_values_count = {} + for col_idx in range(rat.GetColumnCount()): + col_unique_values = set() + for row_idx in range(rat.GetRowCount()): + value = rat.GetValueAsString(row_idx, col_idx) + col_unique_values.add(value) + unique_values_count[col_idx] = len(col_unique_values) + + return unique_values_count + + +def count_bigrams(rat: gdal.RasterAttributeTable) -> dict[int, int]: + bigrams_count = {} + for col_idx in range(rat.GetColumnCount()): + column_text = "" + for row_idx in range(rat.GetRowCount()): + column_text += rat.GetValueAsString(row_idx, col_idx).lower() + " " + bigrams_list = set(bigrams(column_text.split())) + # Remove bigrams which are not text + bigrams_list = [ + bg for bg in bigrams_list if bg[0].isalpha() and bg[1].isalpha() + ] + bigrams_count[col_idx] = len(bigrams_list) + + return bigrams_count diff --git a/setup.cfg b/setup.cfg index c85070c..91c0e49 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,7 +34,7 @@ install_requires = click>=8.1.3 db-dtypes>=1.0.5 pandas>=1.3.4 - pyarrow>=10.0.1 + pyarrow>=10.0.1,<17.0.0 pyproj>=3.2.1 rasterio>=1.3a3 rio-cogeo>=3.5.0 @@ -42,6 +42,7 @@ install_requires = quadbin>=0.2.0 tqdm>=4.64.1 GDAL>=3.4.0 + nltk>=3.9.1 zip_safe = False [options.entry_points] From 0c5e03d5948372b4b651807e2a7ae403d0162454 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Mon, 9 Jun 2025 15:56:34 +0200 Subject: [PATCH 07/24] ... --- setup.cfg | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 91c0e49..636d519 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,7 +41,6 @@ install_requires = shapely>=1.7.1 quadbin>=0.2.0 tqdm>=4.64.1 - GDAL>=3.4.0 nltk>=3.9.1 zip_safe = False From af361d63bb62787907bd89c91dffe4baee0dd2ac Mon Sep 17 00:00:00 2001 From: juanrmn Date: Mon, 9 Jun 2025 16:02:09 +0200 Subject: [PATCH 08/24] adding gdal deps --- docker/raster_loader/Dockerfile | 1 + setup.cfg | 1 + 2 files changed, 2 insertions(+) diff --git a/docker/raster_loader/Dockerfile b/docker/raster_loader/Dockerfile index 34165e6..e76f04c 100644 --- a/docker/raster_loader/Dockerfile +++ b/docker/raster_loader/Dockerfile @@ -18,6 +18,7 @@ RUN apt-get update \ postgresql-client \ wget \ python3-pip \ + libgdal-dev \ && apt-get clean -y && rm -rf /var/lib/apt/lists/* # Using a non-privileged user to own our code diff --git a/setup.cfg b/setup.cfg index 636d519..91c0e49 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,6 +41,7 @@ install_requires = shapely>=1.7.1 quadbin>=0.2.0 tqdm>=4.64.1 + GDAL>=3.4.0 nltk>=3.9.1 zip_safe = False From 9aa96d6a6f309773c2948c7c7b92dbdc4f95b92b Mon Sep 17 00:00:00 2001 From: juanrmn Date: Mon, 9 Jun 2025 16:54:54 +0200 Subject: [PATCH 09/24] tweak gdal versions --- docker/raster_loader/Dockerfile | 3 +-- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docker/raster_loader/Dockerfile b/docker/raster_loader/Dockerfile index e76f04c..00d4c23 100644 --- a/docker/raster_loader/Dockerfile +++ b/docker/raster_loader/Dockerfile @@ -1,4 +1,4 @@ -FROM osgeo/gdal:3.2.0 +FROM osgeo/gdal:ubuntu-small-3.6.0 ENV HOMEAPP=/code ENV PATH=$PATH:$HOMEAPP/.local/bin @@ -18,7 +18,6 @@ RUN apt-get update \ postgresql-client \ wget \ python3-pip \ - libgdal-dev \ && apt-get clean -y && rm -rf /var/lib/apt/lists/* # Using a non-privileged user to own our code diff --git a/setup.cfg b/setup.cfg index 91c0e49..494aa08 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,7 +41,7 @@ install_requires = shapely>=1.7.1 quadbin>=0.2.0 tqdm>=4.64.1 - GDAL>=3.4.0 + GDAL>=3.6.0 nltk>=3.9.1 zip_safe = False From 9c1bed507a595aa5eaf9c549a6dd8798d3764279 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Mon, 9 Jun 2025 16:58:02 +0200 Subject: [PATCH 10/24] tweak gdal versions --- docker/raster_loader/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/raster_loader/Dockerfile b/docker/raster_loader/Dockerfile index 00d4c23..dbc995b 100644 --- a/docker/raster_loader/Dockerfile +++ b/docker/raster_loader/Dockerfile @@ -18,6 +18,7 @@ RUN apt-get update \ postgresql-client \ wget \ python3-pip \ + libgdal-dev \ && apt-get clean -y && rm -rf /var/lib/apt/lists/* # Using a non-privileged user to own our code From 8d7068cd809dc3fa9a6bb6ca2a92a7148aa65f1c Mon Sep 17 00:00:00 2001 From: juanrmn Date: Mon, 9 Jun 2025 17:24:15 +0200 Subject: [PATCH 11/24] fix CI workflow --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ff489c..f2503a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 + - name: Install GDAL dependencies + run: sudo apt install -y libgdal-dev - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: From 3add091a4f0511e0f07119b842b7799d3a765d86 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Mon, 9 Jun 2025 17:29:35 +0200 Subject: [PATCH 12/24] trying fixed minimal version of gdal --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 494aa08..af22161 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,7 +41,7 @@ install_requires = shapely>=1.7.1 quadbin>=0.2.0 tqdm>=4.64.1 - GDAL>=3.6.0 + GDAL==3.6.0 nltk>=3.9.1 zip_safe = False From 616522a0552d840bbb83a911ad68a468d1d1bc30 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Tue, 10 Jun 2025 09:30:47 +0200 Subject: [PATCH 13/24] a bit of refactor --- raster_loader/cli/bigquery.py | 2 +- raster_loader/cli/databricks.py | 2 +- raster_loader/cli/snowflake.py | 2 +- raster_loader/errors.py | 46 ------------ raster_loader/geo.py | 112 ---------------------------- raster_loader/io/bigquery.py | 4 +- raster_loader/io/common.py | 10 +-- raster_loader/io/databricks.py | 7 +- raster_loader/io/snowflake.py | 4 +- raster_loader/tests/test_utils.py | 2 +- raster_loader/utils.py | 70 ------------------ raster_loader/valuelabels.py | 119 ------------------------------ 12 files changed, 18 insertions(+), 362 deletions(-) delete mode 100644 raster_loader/errors.py delete mode 100644 raster_loader/geo.py delete mode 100644 raster_loader/utils.py delete mode 100644 raster_loader/valuelabels.py diff --git a/raster_loader/cli/bigquery.py b/raster_loader/cli/bigquery.py index f504fa2..9da92ce 100644 --- a/raster_loader/cli/bigquery.py +++ b/raster_loader/cli/bigquery.py @@ -4,7 +4,7 @@ import click from functools import wraps, partial -from raster_loader.utils import get_default_table_name +from raster_loader.lib.utils import get_default_table_name from raster_loader.io.bigquery import BigQueryConnection, AccessTokenCredentials diff --git a/raster_loader/cli/databricks.py b/raster_loader/cli/databricks.py index c5767de..6631807 100644 --- a/raster_loader/cli/databricks.py +++ b/raster_loader/cli/databricks.py @@ -4,7 +4,7 @@ import click from functools import wraps, partial -from raster_loader.utils import get_default_table_name +from raster_loader.lib.utils import get_default_table_name from raster_loader.io.databricks import DatabricksConnection diff --git a/raster_loader/cli/snowflake.py b/raster_loader/cli/snowflake.py index d0056eb..8908218 100644 --- a/raster_loader/cli/snowflake.py +++ b/raster_loader/cli/snowflake.py @@ -4,7 +4,7 @@ import click from functools import wraps, partial -from raster_loader.utils import get_default_table_name, check_private_key +from raster_loader.lib.utils import get_default_table_name, check_private_key from raster_loader.io.snowflake import SnowflakeConnection diff --git a/raster_loader/errors.py b/raster_loader/errors.py deleted file mode 100644 index dfefd80..0000000 --- a/raster_loader/errors.py +++ /dev/null @@ -1,46 +0,0 @@ -def import_error_bigquery(): # pragma: no cover - msg = ( - "Google Cloud BigQuery client is not installed.\n" - "Please install Google Cloud BigQuery dependencies to use this function.\n" - 'run `pip install -U raster-loader"[bigquery]"` to install from pypi.' - ) - raise ImportError(msg) - - -def import_error_snowflake(): # pragma: no cover - msg = ( - "Snowflake client is not installed.\n" - "Please install Snowflake dependencies to use this function.\n" - 'run `pip install -U raster-loader"[snowflake]"` to install from pypi.' - ) - raise ImportError(msg) - - -def import_error_databricks(): - raise ImportError( - "The databricks-connect package is required and must match your " - "Databricks Runtime version.\n" - "For example, if your cluster uses DBR 15.1, " - "run `pip install databricks-connect==15.1`\n" - "You can find your cluster's DBR version in the Databricks UI " - "under Compute > Your Cluster > Configuration > Databricks Runtime version.\n" - "Or you can run the following SQL query from your cluster:\n" - "`SELECT current_version()`" - ) - - -class IncompatibleRasterException(Exception): - def __init__(self): - self.message = ( - "The input raster must be a GoogleMapsCompatible raster.\n" - "You can make your raster compatible " - "by converting it using the following command:\n" - "gdalwarp -of COG -co TILING_SCHEME=GoogleMapsCompatible " - "-co COMPRESS=DEFLATE -co OVERVIEWS=IGNORE_EXISTING -co ADD_ALPHA=NO " - "-co RESAMPLING=NEAREST -co BLOCKSIZE=512 " - ".tif .tif" - ) - - -def error_not_google_compatible(): # pragma: no cover - raise IncompatibleRasterException() diff --git a/raster_loader/geo.py b/raster_loader/geo.py deleted file mode 100644 index f68be2a..0000000 --- a/raster_loader/geo.py +++ /dev/null @@ -1,112 +0,0 @@ -import math -import functools -import json - - -def coord_range(start_x, start_y, end_x, end_y, num_subdivisions): - num_subdivisions = max(num_subdivisions, 1) - return [ - [ - start_x + (end_x - start_x) * i / num_subdivisions, - start_y + (end_y - start_y) * i / num_subdivisions, - ] - for i in range(0, num_subdivisions + 1) - ] - - -def norm_lon(x): - return x - 360.0 if x > 180.0 else x + 360.0 if x <= -180.0 else x - - -def norm_coords(coords): - return [[norm_lon(point[0]), point[1]] for point in coords] - - -def polygon_geography(rings, format, normalize_coords): - if normalize_coords: - rings = [norm_coords(coords) for coords in rings] - - if format == "wkt": - return polygon_wkt(rings) - elif format == "geojson": - return polygon_geojson(rings) - else: - raise ValueError(f"Invalid geography format {format}") - - -def polygon_wkt(rings): - return ( - "POLYGON(" - + ",".join( - [ - "(" - + ",".join( - [" ".join([str(coord) for coord in point]) for point in coords] - ) - + ")" - for coords in rings - ] - ) - + ")" - ) - - -def polygon_geojson(rings): - return json.dumps({"type": "Polygon", "coordinates": rings}) - - -def coords_to_geography(coords, format, whole_earth): - # remove too-close coordinates cause they cause errors - # in BigQuery's ST_GEOGFROMGEOJSON - def are_too_close(point1, point2): - return ( - math.fabs(point1[0] - point2[0]) <= 1e-13 - and math.fabs(point1[1] - point2[1]) <= 1e-13 - ) - - def filter_near_points(coords, point): - previous = None if not coords else coords[-1] - if not previous or not are_too_close(previous, point): - coords.append(point) - return coords - - coords = functools.reduce(filter_near_points, coords, []) - - # now let's make sure the initial and final points are exactly the same - if coords[0] != coords[-1]: - # replace the last point; never mind, it must be very close - coords[-1] = coords[0] - return polygon_geography([coords], format, not whole_earth) - - -def raster_bounds(raster_dataset, transformer, format): - min_x = 0 - min_y = 0 - max_x = raster_dataset.width - max_y = raster_dataset.height - - x_subdivisions = math.ceil((max_x - min_x) / 64.0) - y_subdivisions = math.ceil((max_y - min_y) / 64.0) - pixel_coords = ( - # SW -> SE - coord_range(min_x, max_y, max_x, max_y, x_subdivisions) - # SE -> NE - + coord_range(max_x, max_y, max_x, min_y, y_subdivisions) - # NE -> NW - + coord_range(max_x, min_y, min_x, min_y, x_subdivisions) - # NW -> SW - + coord_range(min_x, min_y, min_x, max_y, y_subdivisions) - ) - coords = [ - transformer.transform(*(raster_dataset.transform * (x, y))) - for x, y in pixel_coords - ] - lon_NW, _ = transformer.transform(*(raster_dataset.transform * (min_x, min_y))) - lon_NE, _ = transformer.transform(*(raster_dataset.transform * (max_x, min_y))) - lon_SW, _ = transformer.transform(*(raster_dataset.transform * (min_x, max_y))) - lon_SE, _ = transformer.transform(*(raster_dataset.transform * (max_x, max_y))) - whole_earth = ( - math.fabs(lon_NW - lon_NE) >= 360.0 and math.fabs(lon_SW - lon_SE) >= 360 - ) - - return coords_to_geography(coords, format, whole_earth) diff --git a/raster_loader/io/bigquery.py b/raster_loader/io/bigquery.py index e22951d..806e6c2 100644 --- a/raster_loader/io/bigquery.py +++ b/raster_loader/io/bigquery.py @@ -6,8 +6,8 @@ from itertools import chain from raster_loader import __version__ -from raster_loader.errors import import_error_bigquery, IncompatibleRasterException -from raster_loader.utils import ask_yes_no_question, batched +from raster_loader.lib.errors import import_error_bigquery, IncompatibleRasterException +from raster_loader.lib.utils import ask_yes_no_question, batched from raster_loader.io.common import ( check_metadata_is_compatible, get_number_of_blocks, diff --git a/raster_loader/io/common.py b/raster_loader/io/common.py index 364d585..fa3ead3 100644 --- a/raster_loader/io/common.py +++ b/raster_loader/io/common.py @@ -10,17 +10,17 @@ from typing import Dict, Callable, Iterable, List, Tuple, Union from affine import Affine from shapely import wkt # Can not use directly from shapely.wkt +from raster_loader.lib.valuelabels import get_value_labels -from raster_loader.valuelabels import get_value_labels import rio_cogeo import rasterio import quadbin -from raster_loader.geo import raster_bounds -from raster_loader.errors import ( +from raster_loader.lib.geo import raster_bounds +from raster_loader.lib.errors import ( error_not_google_compatible, ) -from raster_loader.utils import warnings +from raster_loader.lib.utils import warnings DEFAULT_COG_BLOCK_SIZE = 256 @@ -510,7 +510,7 @@ def compute_quantiles(data: List[Union[int, float]], cast_function: Callable) -> def get_stats( raster_dataset: rasterio.io.DatasetReader, band: int -) -> rasterio.Statistics: +) -> rasterio._io.Statistics: """Get statistics for a raster band.""" try: # stats method is supported since rasterio 1.4.0 and statistics diff --git a/raster_loader/io/databricks.py b/raster_loader/io/databricks.py index 7164dea..7f363bf 100644 --- a/raster_loader/io/databricks.py +++ b/raster_loader/io/databricks.py @@ -3,8 +3,11 @@ import rasterio from itertools import chain -from raster_loader.errors import import_error_databricks, IncompatibleRasterException -from raster_loader.utils import ask_yes_no_question, batched +from raster_loader.lib.errors import ( + import_error_databricks, + IncompatibleRasterException, +) +from raster_loader.lib.utils import ask_yes_no_question, batched from raster_loader.io.common import ( check_metadata_is_compatible, get_number_of_blocks, diff --git a/raster_loader/io/snowflake.py b/raster_loader/io/snowflake.py index 6ecd9c1..df97564 100644 --- a/raster_loader/io/snowflake.py +++ b/raster_loader/io/snowflake.py @@ -5,12 +5,12 @@ from itertools import chain from typing import Iterable, List, Tuple -from raster_loader.errors import ( +from raster_loader.lib.errors import ( IncompatibleRasterException, import_error_snowflake, ) -from raster_loader.utils import ask_yes_no_question, batched +from raster_loader.lib.utils import ask_yes_no_question, batched from raster_loader.io.common import ( rasterio_metadata, diff --git a/raster_loader/tests/test_utils.py b/raster_loader/tests/test_utils.py index 24dfdde..5b1e892 100644 --- a/raster_loader/tests/test_utils.py +++ b/raster_loader/tests/test_utils.py @@ -1,7 +1,7 @@ import builtins from unittest.mock import patch -from raster_loader.utils import ask_yes_no_question +from raster_loader.lib.utils import ask_yes_no_question def test_ask_yes_no_question_answer_yes(): diff --git a/raster_loader/utils.py b/raster_loader/utils.py deleted file mode 100644 index c1e40d6..0000000 --- a/raster_loader/utils.py +++ /dev/null @@ -1,70 +0,0 @@ -from itertools import islice -import os -import re -import uuid -import warnings - - -def ask_yes_no_question(question: str) -> bool: - """Ask a yes or no question and return True or False.""" - yes_choices = ["yes", "y"] - no_choices = ["no", "n"] - - while True: - user_input = input(question) - if user_input.lower() in yes_choices: - return True - elif user_input.lower() in no_choices: - return False - else: # pragma: no cover - print("Type yes or no") - continue - - -def batched(iterable, n): - "Batch data into tuples of length n. The last batch may be shorter." - # batched('ABCDEFG', 3) --> ABC DEF G - if n < 1: # pragma: no cover - raise ValueError("n must be at least one") - it = iter(iterable) - while batch := tuple(islice(it, n)): # noqa - yield batch - - -def get_default_table_name(base_path: str, band): - table = os.path.basename(base_path).split(".")[0] - table = "_".join([table, "band", str(band), str(uuid.uuid4())]) - return re.sub(r"[^a-zA-Z0-9_-]", "_", table) - - -def check_private_key(private_key_path: str, private_key_passphrase: str): - # Check that the private key file exists - if not os.path.exists(private_key_path): - raise ValueError(f"Private key file {private_key_path} not found") - - with open(private_key_path, "r") as f: - private_key = f.read() - if ( - private_key.startswith("-----BEGIN ENCRYPTED PRIVATE KEY-----") - and private_key_passphrase is None - ): - raise ValueError( - "The private key file is encrypted. Please provide a passphrase." - ) - - -# Modify the __init__ so that self.line = "" instead of None -def new_init( - self, message, category, filename, lineno, file=None, line=None, source=None -): - self.message = message - self.category = category - self.filename = filename - self.lineno = lineno - self.file = file - self.line = "" - self.source = source - self._category_name = category.__name__.upper() if category else None - - -warnings.WarningMessage.__init__ = new_init diff --git a/raster_loader/valuelabels.py b/raster_loader/valuelabels.py deleted file mode 100644 index ddb5e1c..0000000 --- a/raster_loader/valuelabels.py +++ /dev/null @@ -1,119 +0,0 @@ -from osgeo import gdal -from nltk import bigrams - -DEFAULT_VALUES_COLUMN_IDX = 0 -DEFAULT_LABELS_COLUMN_IDX = 1 - -gdal.UseExceptions() - - -def get_value_labels(dataset_uri: str, band: int): - """ - Get the value labels for a given dataset and band. - """ - print(f"Computing value labels for band {band}") - try: - dataset = gdal.Open(dataset_uri) # dataset_uri is path to .tif file - band = dataset.GetRasterBand(band) - - rat = band.GetDefaultRAT() - if rat is None: - print(f"\tNo Raster Attribute Table (RAT) found for band {band}") - return None - - # Pick columns for values and labels - values_column_idx = get_values_column_idx(rat) - labels_column_idx = get_labels_column_idx(rat) - - print(f"\tSelected column for Values: {rat.GetNameOfCol(values_column_idx)}") - print(f"\tSelected column for Labels: {rat.GetNameOfCol(labels_column_idx)}") - - # Convert RAT to dictionary - value_labels = {} - for i in range(rat.GetRowCount()): - value = rat.GetValueAsInt(i, values_column_idx) - label = rat.GetValueAsString(i, labels_column_idx) - value_labels[value] = label - return value_labels - except ValueError: - return None - - -def get_values_column_idx(rat: gdal.RasterAttributeTable) -> int: - values_column_idx = DEFAULT_VALUES_COLUMN_IDX - for col_idx in range(rat.GetColumnCount()): - col_name = rat.GetNameOfCol(col_idx) - if col_name.lower() == "value": - values_column_idx = col_idx - - return values_column_idx - - -def get_labels_column_idx(rat: gdal.RasterAttributeTable) -> int: - """ - Get the column index of the labels column in a Raster Attribute Table (RAT). - - This function uses a heuristic approach to identify the most likely column - containing descriptive labels by: - 1. Excluding the column already selected for values - 2. Ranking remaining columns based on: - - Number of unique values in the column - - Number of meaningful text bigrams (pairs of words) in the column - 3. Selecting the column with the highest combined score - - Returns: - int: The index of the column most likely to contain labels - """ - labels_column_idx = DEFAULT_LABELS_COLUMN_IDX - unique_values_count = count_column_unique_values(rat) - values_column_idx = get_values_column_idx(rat) - bigrams_count = count_bigrams(rat) - - # Ranked columns by (unique values count * bigrams count) - ranked_columns = {} - for col_idx in range(rat.GetColumnCount()): - if col_idx == values_column_idx: - # Skip the already selected column for values - continue - ranked_columns[col_idx] = unique_values_count[col_idx] * bigrams_count[col_idx] - - # Sort the ranked columns by the rank - ranked_columns = sorted(ranked_columns.items(), key=lambda x: x[1], reverse=True) - - # Get the column with the highest rank - if ranked_columns[0][1] > 0: - labels_column_idx = ranked_columns[0][0] - else: - # If no bigrams found, select column with most unique values - labels_column_idx = max(unique_values_count.items(), key=lambda x: x[1])[0] - - return labels_column_idx - - -# Return a dictionary with the column_idx and the number of unique values in it -def count_column_unique_values(rat: gdal.RasterAttributeTable) -> dict[int, int]: - unique_values_count = {} - for col_idx in range(rat.GetColumnCount()): - col_unique_values = set() - for row_idx in range(rat.GetRowCount()): - value = rat.GetValueAsString(row_idx, col_idx) - col_unique_values.add(value) - unique_values_count[col_idx] = len(col_unique_values) - - return unique_values_count - - -def count_bigrams(rat: gdal.RasterAttributeTable) -> dict[int, int]: - bigrams_count = {} - for col_idx in range(rat.GetColumnCount()): - column_text = "" - for row_idx in range(rat.GetRowCount()): - column_text += rat.GetValueAsString(row_idx, col_idx).lower() + " " - bigrams_list = set(bigrams(column_text.split())) - # Remove bigrams which are not text - bigrams_list = [ - bg for bg in bigrams_list if bg[0].isalpha() and bg[1].isalpha() - ] - bigrams_count[col_idx] = len(bigrams_list) - - return bigrams_count From c0591dbe5115e8f8783e176d780f471ba5ea6bfb Mon Sep 17 00:00:00 2001 From: juanrmn Date: Tue, 10 Jun 2025 09:41:42 +0200 Subject: [PATCH 14/24] adding lib init --- raster_loader/lib/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 raster_loader/lib/__init__.py diff --git a/raster_loader/lib/__init__.py b/raster_loader/lib/__init__.py new file mode 100644 index 0000000..e69de29 From ab7f5e98e4bc86e0a2b83dabc63d4314e6ae5f49 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Tue, 10 Jun 2025 09:43:24 +0200 Subject: [PATCH 15/24] adding lib --- raster_loader/lib/errors.py | 46 ++++++++++++ raster_loader/lib/geo.py | 112 +++++++++++++++++++++++++++++ raster_loader/lib/utils.py | 70 ++++++++++++++++++ raster_loader/lib/valuelabels.py | 119 +++++++++++++++++++++++++++++++ 4 files changed, 347 insertions(+) create mode 100644 raster_loader/lib/errors.py create mode 100644 raster_loader/lib/geo.py create mode 100644 raster_loader/lib/utils.py create mode 100644 raster_loader/lib/valuelabels.py diff --git a/raster_loader/lib/errors.py b/raster_loader/lib/errors.py new file mode 100644 index 0000000..dfefd80 --- /dev/null +++ b/raster_loader/lib/errors.py @@ -0,0 +1,46 @@ +def import_error_bigquery(): # pragma: no cover + msg = ( + "Google Cloud BigQuery client is not installed.\n" + "Please install Google Cloud BigQuery dependencies to use this function.\n" + 'run `pip install -U raster-loader"[bigquery]"` to install from pypi.' + ) + raise ImportError(msg) + + +def import_error_snowflake(): # pragma: no cover + msg = ( + "Snowflake client is not installed.\n" + "Please install Snowflake dependencies to use this function.\n" + 'run `pip install -U raster-loader"[snowflake]"` to install from pypi.' + ) + raise ImportError(msg) + + +def import_error_databricks(): + raise ImportError( + "The databricks-connect package is required and must match your " + "Databricks Runtime version.\n" + "For example, if your cluster uses DBR 15.1, " + "run `pip install databricks-connect==15.1`\n" + "You can find your cluster's DBR version in the Databricks UI " + "under Compute > Your Cluster > Configuration > Databricks Runtime version.\n" + "Or you can run the following SQL query from your cluster:\n" + "`SELECT current_version()`" + ) + + +class IncompatibleRasterException(Exception): + def __init__(self): + self.message = ( + "The input raster must be a GoogleMapsCompatible raster.\n" + "You can make your raster compatible " + "by converting it using the following command:\n" + "gdalwarp -of COG -co TILING_SCHEME=GoogleMapsCompatible " + "-co COMPRESS=DEFLATE -co OVERVIEWS=IGNORE_EXISTING -co ADD_ALPHA=NO " + "-co RESAMPLING=NEAREST -co BLOCKSIZE=512 " + ".tif .tif" + ) + + +def error_not_google_compatible(): # pragma: no cover + raise IncompatibleRasterException() diff --git a/raster_loader/lib/geo.py b/raster_loader/lib/geo.py new file mode 100644 index 0000000..f68be2a --- /dev/null +++ b/raster_loader/lib/geo.py @@ -0,0 +1,112 @@ +import math +import functools +import json + + +def coord_range(start_x, start_y, end_x, end_y, num_subdivisions): + num_subdivisions = max(num_subdivisions, 1) + return [ + [ + start_x + (end_x - start_x) * i / num_subdivisions, + start_y + (end_y - start_y) * i / num_subdivisions, + ] + for i in range(0, num_subdivisions + 1) + ] + + +def norm_lon(x): + return x - 360.0 if x > 180.0 else x + 360.0 if x <= -180.0 else x + + +def norm_coords(coords): + return [[norm_lon(point[0]), point[1]] for point in coords] + + +def polygon_geography(rings, format, normalize_coords): + if normalize_coords: + rings = [norm_coords(coords) for coords in rings] + + if format == "wkt": + return polygon_wkt(rings) + elif format == "geojson": + return polygon_geojson(rings) + else: + raise ValueError(f"Invalid geography format {format}") + + +def polygon_wkt(rings): + return ( + "POLYGON(" + + ",".join( + [ + "(" + + ",".join( + [" ".join([str(coord) for coord in point]) for point in coords] + ) + + ")" + for coords in rings + ] + ) + + ")" + ) + + +def polygon_geojson(rings): + return json.dumps({"type": "Polygon", "coordinates": rings}) + + +def coords_to_geography(coords, format, whole_earth): + # remove too-close coordinates cause they cause errors + # in BigQuery's ST_GEOGFROMGEOJSON + def are_too_close(point1, point2): + return ( + math.fabs(point1[0] - point2[0]) <= 1e-13 + and math.fabs(point1[1] - point2[1]) <= 1e-13 + ) + + def filter_near_points(coords, point): + previous = None if not coords else coords[-1] + if not previous or not are_too_close(previous, point): + coords.append(point) + return coords + + coords = functools.reduce(filter_near_points, coords, []) + + # now let's make sure the initial and final points are exactly the same + if coords[0] != coords[-1]: + # replace the last point; never mind, it must be very close + coords[-1] = coords[0] + return polygon_geography([coords], format, not whole_earth) + + +def raster_bounds(raster_dataset, transformer, format): + min_x = 0 + min_y = 0 + max_x = raster_dataset.width + max_y = raster_dataset.height + + x_subdivisions = math.ceil((max_x - min_x) / 64.0) + y_subdivisions = math.ceil((max_y - min_y) / 64.0) + pixel_coords = ( + # SW -> SE + coord_range(min_x, max_y, max_x, max_y, x_subdivisions) + # SE -> NE + + coord_range(max_x, max_y, max_x, min_y, y_subdivisions) + # NE -> NW + + coord_range(max_x, min_y, min_x, min_y, x_subdivisions) + # NW -> SW + + coord_range(min_x, min_y, min_x, max_y, y_subdivisions) + ) + coords = [ + transformer.transform(*(raster_dataset.transform * (x, y))) + for x, y in pixel_coords + ] + lon_NW, _ = transformer.transform(*(raster_dataset.transform * (min_x, min_y))) + lon_NE, _ = transformer.transform(*(raster_dataset.transform * (max_x, min_y))) + lon_SW, _ = transformer.transform(*(raster_dataset.transform * (min_x, max_y))) + lon_SE, _ = transformer.transform(*(raster_dataset.transform * (max_x, max_y))) + whole_earth = ( + math.fabs(lon_NW - lon_NE) >= 360.0 and math.fabs(lon_SW - lon_SE) >= 360 + ) + + return coords_to_geography(coords, format, whole_earth) diff --git a/raster_loader/lib/utils.py b/raster_loader/lib/utils.py new file mode 100644 index 0000000..c1e40d6 --- /dev/null +++ b/raster_loader/lib/utils.py @@ -0,0 +1,70 @@ +from itertools import islice +import os +import re +import uuid +import warnings + + +def ask_yes_no_question(question: str) -> bool: + """Ask a yes or no question and return True or False.""" + yes_choices = ["yes", "y"] + no_choices = ["no", "n"] + + while True: + user_input = input(question) + if user_input.lower() in yes_choices: + return True + elif user_input.lower() in no_choices: + return False + else: # pragma: no cover + print("Type yes or no") + continue + + +def batched(iterable, n): + "Batch data into tuples of length n. The last batch may be shorter." + # batched('ABCDEFG', 3) --> ABC DEF G + if n < 1: # pragma: no cover + raise ValueError("n must be at least one") + it = iter(iterable) + while batch := tuple(islice(it, n)): # noqa + yield batch + + +def get_default_table_name(base_path: str, band): + table = os.path.basename(base_path).split(".")[0] + table = "_".join([table, "band", str(band), str(uuid.uuid4())]) + return re.sub(r"[^a-zA-Z0-9_-]", "_", table) + + +def check_private_key(private_key_path: str, private_key_passphrase: str): + # Check that the private key file exists + if not os.path.exists(private_key_path): + raise ValueError(f"Private key file {private_key_path} not found") + + with open(private_key_path, "r") as f: + private_key = f.read() + if ( + private_key.startswith("-----BEGIN ENCRYPTED PRIVATE KEY-----") + and private_key_passphrase is None + ): + raise ValueError( + "The private key file is encrypted. Please provide a passphrase." + ) + + +# Modify the __init__ so that self.line = "" instead of None +def new_init( + self, message, category, filename, lineno, file=None, line=None, source=None +): + self.message = message + self.category = category + self.filename = filename + self.lineno = lineno + self.file = file + self.line = "" + self.source = source + self._category_name = category.__name__.upper() if category else None + + +warnings.WarningMessage.__init__ = new_init diff --git a/raster_loader/lib/valuelabels.py b/raster_loader/lib/valuelabels.py new file mode 100644 index 0000000..ddb5e1c --- /dev/null +++ b/raster_loader/lib/valuelabels.py @@ -0,0 +1,119 @@ +from osgeo import gdal +from nltk import bigrams + +DEFAULT_VALUES_COLUMN_IDX = 0 +DEFAULT_LABELS_COLUMN_IDX = 1 + +gdal.UseExceptions() + + +def get_value_labels(dataset_uri: str, band: int): + """ + Get the value labels for a given dataset and band. + """ + print(f"Computing value labels for band {band}") + try: + dataset = gdal.Open(dataset_uri) # dataset_uri is path to .tif file + band = dataset.GetRasterBand(band) + + rat = band.GetDefaultRAT() + if rat is None: + print(f"\tNo Raster Attribute Table (RAT) found for band {band}") + return None + + # Pick columns for values and labels + values_column_idx = get_values_column_idx(rat) + labels_column_idx = get_labels_column_idx(rat) + + print(f"\tSelected column for Values: {rat.GetNameOfCol(values_column_idx)}") + print(f"\tSelected column for Labels: {rat.GetNameOfCol(labels_column_idx)}") + + # Convert RAT to dictionary + value_labels = {} + for i in range(rat.GetRowCount()): + value = rat.GetValueAsInt(i, values_column_idx) + label = rat.GetValueAsString(i, labels_column_idx) + value_labels[value] = label + return value_labels + except ValueError: + return None + + +def get_values_column_idx(rat: gdal.RasterAttributeTable) -> int: + values_column_idx = DEFAULT_VALUES_COLUMN_IDX + for col_idx in range(rat.GetColumnCount()): + col_name = rat.GetNameOfCol(col_idx) + if col_name.lower() == "value": + values_column_idx = col_idx + + return values_column_idx + + +def get_labels_column_idx(rat: gdal.RasterAttributeTable) -> int: + """ + Get the column index of the labels column in a Raster Attribute Table (RAT). + + This function uses a heuristic approach to identify the most likely column + containing descriptive labels by: + 1. Excluding the column already selected for values + 2. Ranking remaining columns based on: + - Number of unique values in the column + - Number of meaningful text bigrams (pairs of words) in the column + 3. Selecting the column with the highest combined score + + Returns: + int: The index of the column most likely to contain labels + """ + labels_column_idx = DEFAULT_LABELS_COLUMN_IDX + unique_values_count = count_column_unique_values(rat) + values_column_idx = get_values_column_idx(rat) + bigrams_count = count_bigrams(rat) + + # Ranked columns by (unique values count * bigrams count) + ranked_columns = {} + for col_idx in range(rat.GetColumnCount()): + if col_idx == values_column_idx: + # Skip the already selected column for values + continue + ranked_columns[col_idx] = unique_values_count[col_idx] * bigrams_count[col_idx] + + # Sort the ranked columns by the rank + ranked_columns = sorted(ranked_columns.items(), key=lambda x: x[1], reverse=True) + + # Get the column with the highest rank + if ranked_columns[0][1] > 0: + labels_column_idx = ranked_columns[0][0] + else: + # If no bigrams found, select column with most unique values + labels_column_idx = max(unique_values_count.items(), key=lambda x: x[1])[0] + + return labels_column_idx + + +# Return a dictionary with the column_idx and the number of unique values in it +def count_column_unique_values(rat: gdal.RasterAttributeTable) -> dict[int, int]: + unique_values_count = {} + for col_idx in range(rat.GetColumnCount()): + col_unique_values = set() + for row_idx in range(rat.GetRowCount()): + value = rat.GetValueAsString(row_idx, col_idx) + col_unique_values.add(value) + unique_values_count[col_idx] = len(col_unique_values) + + return unique_values_count + + +def count_bigrams(rat: gdal.RasterAttributeTable) -> dict[int, int]: + bigrams_count = {} + for col_idx in range(rat.GetColumnCount()): + column_text = "" + for row_idx in range(rat.GetRowCount()): + column_text += rat.GetValueAsString(row_idx, col_idx).lower() + " " + bigrams_list = set(bigrams(column_text.split())) + # Remove bigrams which are not text + bigrams_list = [ + bg for bg in bigrams_list if bg[0].isalpha() and bg[1].isalpha() + ] + bigrams_count[col_idx] = len(bigrams_list) + + return bigrams_count From 0480946339f0c07fe6d108fd165fa1de17c59794 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Tue, 10 Jun 2025 15:53:18 +0200 Subject: [PATCH 16/24] simplify things --- raster_loader/lib/valuelabels.py | 38 ++++++++++++++++---------------- setup.cfg | 1 - 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/raster_loader/lib/valuelabels.py b/raster_loader/lib/valuelabels.py index ddb5e1c..ee5521b 100644 --- a/raster_loader/lib/valuelabels.py +++ b/raster_loader/lib/valuelabels.py @@ -1,5 +1,4 @@ from osgeo import gdal -from nltk import bigrams DEFAULT_VALUES_COLUMN_IDX = 0 DEFAULT_LABELS_COLUMN_IDX = 1 @@ -23,7 +22,7 @@ def get_value_labels(dataset_uri: str, band: int): # Pick columns for values and labels values_column_idx = get_values_column_idx(rat) - labels_column_idx = get_labels_column_idx(rat) + labels_column_idx = get_labels_column_idx(rat, [values_column_idx]) print(f"\tSelected column for Values: {rat.GetNameOfCol(values_column_idx)}") print(f"\tSelected column for Labels: {rat.GetNameOfCol(labels_column_idx)}") @@ -49,7 +48,9 @@ def get_values_column_idx(rat: gdal.RasterAttributeTable) -> int: return values_column_idx -def get_labels_column_idx(rat: gdal.RasterAttributeTable) -> int: +def get_labels_column_idx( + rat: gdal.RasterAttributeTable, skip_columns: list[int] = [] +) -> int: """ Get the column index of the labels column in a Raster Attribute Table (RAT). @@ -58,7 +59,7 @@ def get_labels_column_idx(rat: gdal.RasterAttributeTable) -> int: 1. Excluding the column already selected for values 2. Ranking remaining columns based on: - Number of unique values in the column - - Number of meaningful text bigrams (pairs of words) in the column + - Number of different words in the column 3. Selecting the column with the highest combined score Returns: @@ -66,16 +67,17 @@ def get_labels_column_idx(rat: gdal.RasterAttributeTable) -> int: """ labels_column_idx = DEFAULT_LABELS_COLUMN_IDX unique_values_count = count_column_unique_values(rat) - values_column_idx = get_values_column_idx(rat) - bigrams_count = count_bigrams(rat) + + unique_words_count = count_column_unique_words(rat) # Ranked columns by (unique values count * bigrams count) ranked_columns = {} for col_idx in range(rat.GetColumnCount()): - if col_idx == values_column_idx: - # Skip the already selected column for values + if col_idx in skip_columns: continue - ranked_columns[col_idx] = unique_values_count[col_idx] * bigrams_count[col_idx] + ranked_columns[col_idx] = ( + unique_values_count[col_idx] * unique_words_count[col_idx] + ) # Sort the ranked columns by the rank ranked_columns = sorted(ranked_columns.items(), key=lambda x: x[1], reverse=True) @@ -103,17 +105,15 @@ def count_column_unique_values(rat: gdal.RasterAttributeTable) -> dict[int, int] return unique_values_count -def count_bigrams(rat: gdal.RasterAttributeTable) -> dict[int, int]: - bigrams_count = {} +def count_column_unique_words(rat: gdal.RasterAttributeTable) -> dict[int, int]: + columns_words_count = {} for col_idx in range(rat.GetColumnCount()): column_text = "" for row_idx in range(rat.GetRowCount()): column_text += rat.GetValueAsString(row_idx, col_idx).lower() + " " - bigrams_list = set(bigrams(column_text.split())) - # Remove bigrams which are not text - bigrams_list = [ - bg for bg in bigrams_list if bg[0].isalpha() and bg[1].isalpha() - ] - bigrams_count[col_idx] = len(bigrams_list) - - return bigrams_count + words_list = set(column_text.split()) + # Remove words which are not text + words_list = [w for w in words_list if w.isalpha()] + columns_words_count[col_idx] = len(words_list) + + return columns_words_count diff --git a/setup.cfg b/setup.cfg index af22161..66ec761 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,7 +42,6 @@ install_requires = quadbin>=0.2.0 tqdm>=4.64.1 GDAL==3.6.0 - nltk>=3.9.1 zip_safe = False [options.entry_points] From b8ff870d141fe651cf02152f5b6a4e88f44dd575 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Tue, 10 Jun 2025 17:36:46 +0200 Subject: [PATCH 17/24] adding new parameter for interactive column selection --- raster_loader/cli/bigquery.py | 8 +++++ raster_loader/cli/databricks.py | 8 +++++ raster_loader/cli/snowflake.py | 8 +++++ raster_loader/io/bigquery.py | 2 ++ raster_loader/io/common.py | 5 ++- raster_loader/io/databricks.py | 2 ++ raster_loader/io/snowflake.py | 2 ++ raster_loader/lib/valuelabels.py | 54 ++++++++++++++++++++++++-------- setup.cfg | 2 +- 9 files changed, 76 insertions(+), 15 deletions(-) diff --git a/raster_loader/cli/bigquery.py b/raster_loader/cli/bigquery.py index 9da92ce..65e4989 100644 --- a/raster_loader/cli/bigquery.py +++ b/raster_loader/cli/bigquery.py @@ -101,6 +101,12 @@ def bigquery(args=None): type=int, default=6, ) +@click.option( + "--interactive-value-labels", + help="Interactive selection of columns from Raster Attribute Table (RAT) for value labels.", + default=False, + is_flag=True, +) @catch_exception() def upload( file_path, @@ -119,6 +125,7 @@ def upload( exact_stats=False, basic_stats=False, compression_level=6, + interactive_value_labels=False, ): from raster_loader.io.common import ( get_number_of_blocks, @@ -194,6 +201,7 @@ def upload( basic_stats=basic_stats, compress=compress, compression_level=compression_level, + interactive_value_labels=interactive_value_labels, ) click.echo("Raster file uploaded to Google BigQuery") diff --git a/raster_loader/cli/databricks.py b/raster_loader/cli/databricks.py index 6631807..f533b08 100644 --- a/raster_loader/cli/databricks.py +++ b/raster_loader/cli/databricks.py @@ -115,6 +115,12 @@ def databricks(args=None): type=int, default=6, ) +@click.option( + "--interactive-value-labels", + help="Interactive selection of columns from Raster Attribute Table (RAT) for value labels.", + default=False, + is_flag=True, +) @catch_exception() def upload( server_hostname, @@ -136,6 +142,7 @@ def upload( exact_stats=False, basic_stats=False, compression_level=6, + interactive_value_labels=False, ): from raster_loader.io.common import ( get_number_of_blocks, @@ -213,6 +220,7 @@ def upload( basic_stats=basic_stats, compress=compress, compression_level=compression_level, + interactive_value_labels=interactive_value_labels, ) click.echo("Raster file uploaded to Databricks") diff --git a/raster_loader/cli/snowflake.py b/raster_loader/cli/snowflake.py index 8908218..9ff5050 100644 --- a/raster_loader/cli/snowflake.py +++ b/raster_loader/cli/snowflake.py @@ -123,6 +123,12 @@ def snowflake(args=None): type=int, default=6, ) +@click.option( + "--interactive-value-labels", + help="Interactive selection of columns from Raster Attribute Table (RAT) for value labels.", + default=False, + is_flag=True, +) @catch_exception() def upload( account, @@ -148,6 +154,7 @@ def upload( exact_stats=False, basic_stats=False, compression_level=6, + interactive_value_labels=False, ): from raster_loader.io.common import ( get_number_of_blocks, @@ -255,6 +262,7 @@ def upload( basic_stats=basic_stats, compress=compress, compression_level=compression_level, + interactive_value_labels=interactive_value_labels, ) click.echo("Raster file uploaded to Snowflake") diff --git a/raster_loader/io/bigquery.py b/raster_loader/io/bigquery.py index 806e6c2..8b2d3aa 100644 --- a/raster_loader/io/bigquery.py +++ b/raster_loader/io/bigquery.py @@ -111,6 +111,7 @@ def upload_raster( basic_stats: bool = False, compress: bool = False, compression_level: int = 6, + interactive_value_labels: bool = False, ): """Write a raster file to a BigQuery table.""" print("Loading raster file to BigQuery...") @@ -139,6 +140,7 @@ def upload_raster( exact_stats, basic_stats, compress=compress, + interactive_value_labels=interactive_value_labels, ) overviews_records_gen = rasterio_overview_to_records( diff --git a/raster_loader/io/common.py b/raster_loader/io/common.py index fa3ead3..8632a2c 100644 --- a/raster_loader/io/common.py +++ b/raster_loader/io/common.py @@ -212,6 +212,7 @@ def rasterio_metadata( exact_stats: bool = False, basic_stats: bool = False, compress: bool = False, + interactive_value_labels: bool = False, ): """Open a raster file with rasterio.""" raster_info = rio_cogeo.cog_info(file_path).dict() @@ -286,7 +287,9 @@ def rasterio_metadata( "name": band_field_name(band_name, band, band_rename_function), "colorinterp": band_colorinterp, "colortable": get_color_table(raster_dataset, band), - "valuelabels": get_value_labels(file_path, band), + "valuelabels": get_value_labels( + file_path, band, interactive_value_labels + ), "stats": stats, "nodata": band_nodata, } diff --git a/raster_loader/io/databricks.py b/raster_loader/io/databricks.py index 7f363bf..ead64b1 100644 --- a/raster_loader/io/databricks.py +++ b/raster_loader/io/databricks.py @@ -144,6 +144,7 @@ def upload_raster( basic_stats: bool = False, compress: bool = False, compression_level: int = 6, + interactive_value_labels: bool = False, ): """Write a raster file to a Databricks table.""" # Wait for cluster to be ready before starting the upload @@ -177,6 +178,7 @@ def upload_raster( exact_stats, basic_stats, compress=compress, + interactive_value_labels=interactive_value_labels, ) overviews_records_gen = rasterio_overview_to_records( diff --git a/raster_loader/io/snowflake.py b/raster_loader/io/snowflake.py index df97564..3934811 100644 --- a/raster_loader/io/snowflake.py +++ b/raster_loader/io/snowflake.py @@ -208,6 +208,7 @@ def upload_raster( basic_stats: bool = False, compress: bool = False, compression_level: int = 6, + interactive_value_labels: bool = False, ) -> bool: """Write a raster file to a Snowflake table.""" @@ -243,6 +244,7 @@ def band_rename_function(x): exact_stats, basic_stats, compress, + interactive_value_labels, ) overviews_records_gen = rasterio_overview_to_records( diff --git a/raster_loader/lib/valuelabels.py b/raster_loader/lib/valuelabels.py index ee5521b..106778b 100644 --- a/raster_loader/lib/valuelabels.py +++ b/raster_loader/lib/valuelabels.py @@ -1,3 +1,4 @@ +import click from osgeo import gdal DEFAULT_VALUES_COLUMN_IDX = 0 @@ -6,26 +7,48 @@ gdal.UseExceptions() -def get_value_labels(dataset_uri: str, band: int): +def get_value_labels(dataset_uri: str, band_idx: int, interactive: bool = False): """ Get the value labels for a given dataset and band. """ - print(f"Computing value labels for band {band}") + print(f"Computing value labels for band {band_idx}") try: dataset = gdal.Open(dataset_uri) # dataset_uri is path to .tif file - band = dataset.GetRasterBand(band) + band = dataset.GetRasterBand(band_idx) rat = band.GetDefaultRAT() if rat is None: - print(f"\tNo Raster Attribute Table (RAT) found for band {band}") + print(f"No Raster Attribute Table (RAT) found for band {band_idx}") return None - # Pick columns for values and labels - values_column_idx = get_values_column_idx(rat) - labels_column_idx = get_labels_column_idx(rat, [values_column_idx]) - - print(f"\tSelected column for Values: {rat.GetNameOfCol(values_column_idx)}") - print(f"\tSelected column for Labels: {rat.GetNameOfCol(labels_column_idx)}") + print(f"Available columns in Raster Attribute Table (RAT) for band {band_idx}:") + for col_idx in range(rat.GetColumnCount()): + print(f"\t{col_idx}: {rat.GetNameOfCol(col_idx)}") + + if interactive: + values_column_idx = click.prompt( + f"Introduce the column index for Values for band {band_idx}", + type=int, + default=DEFAULT_VALUES_COLUMN_IDX, + ) + labels_column_idx = click.prompt( + f"Introduce the column index for Labels for band {band_idx}", + type=int, + default=DEFAULT_LABELS_COLUMN_IDX, + ) + else: + # Guess columns for values and labels + values_column_idx = get_values_column_idx(rat) + labels_column_idx = get_labels_column_idx(rat, [values_column_idx]) + + print( + f"\tSelected column for Values: " + f"[{values_column_idx}: {rat.GetNameOfCol(values_column_idx)}]" + ) + print( + f"\tSelected column for Labels: " + f"[{labels_column_idx}: {rat.GetNameOfCol(labels_column_idx)}]" + ) # Convert RAT to dictionary value_labels = {} @@ -56,11 +79,16 @@ def get_labels_column_idx( This function uses a heuristic approach to identify the most likely column containing descriptive labels by: - 1. Excluding the column already selected for values + 1. Excluding columns specified in skip_columns (typically the values column) 2. Ranking remaining columns based on: - Number of unique values in the column - - Number of different words in the column - 3. Selecting the column with the highest combined score + - Number of unique words in the column + 3. Selecting the column with the highest combined score (unique values * unique words) + If no column has a score > 0, falls back to selecting the column with most unique values + + Args: + rat: GDAL Raster Attribute Table + skip_columns: List of column indices to exclude from consideration Returns: int: The index of the column most likely to contain labels diff --git a/setup.cfg b/setup.cfg index 66ec761..5b0bcb2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -70,6 +70,6 @@ all = %(databricks)s [flake8] -max-line-length = 88 +max-line-length = 100 ignore = E203 W503 From 478d5f50fdd548e8cbd94dad6ee4524eaa01f180 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Wed, 11 Jun 2025 18:07:49 +0200 Subject: [PATCH 18/24] adding custom valuelabels option --- raster_loader/cli/bigquery.py | 39 +++++++++++++++++++++++++++----- raster_loader/io/bigquery.py | 8 ++++--- raster_loader/io/common.py | 10 ++++---- raster_loader/lib/valuelabels.py | 22 ++++++++++++++++-- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/raster_loader/cli/bigquery.py b/raster_loader/cli/bigquery.py index 65e4989..3646bad 100644 --- a/raster_loader/cli/bigquery.py +++ b/raster_loader/cli/bigquery.py @@ -1,3 +1,4 @@ +import json import os from urllib.parse import urlparse @@ -22,6 +23,15 @@ def wrapper(*args, **kwargs): return wrapper +def validate_band_valuelabels(_, __, value): + try: + return [json.loads(item) if item != "None" else None for item in value] + except json.JSONDecodeError: + raise click.BadParameter( + "Invalid JSON format. Please provide a valid JSON object." + ) + + @click.group(context_settings=dict(help_option_names=["-h", "--help"])) def bigquery(args=None): """ @@ -102,10 +112,25 @@ def bigquery(args=None): default=6, ) @click.option( - "--interactive-value-labels", - help="Interactive selection of columns from Raster Attribute Table (RAT) for value labels.", - default=False, - is_flag=True, + "--rat-valuelabels-mode", + help="The Raster Attribute Table (RAT) will be used for valuelabels if present. " + "If 'auto', the columns will be selected automatically based on its content. " + "If 'interactive', the user will be asked to select the columns interactively.", + type=click.Choice(["auto", "interactive"]), + default="auto", +) +@click.option( + "--band-valuelabels", + help="Custom data for valuelabels in JSON format, or 'None' to use the RAT if present. " + "i.e: '{: , : , ...}'. " + "Could repeat --band-valuelabels to specify multiple bands data, " + "and they'll be considered in the same order as they are in the file. " + "Note that any position can be 'None' to use the RAT for the corresponding band. " + "Also see --rat-valuelabels-mode parameter.", + type=str, + default=[], + multiple=True, + callback=validate_band_valuelabels, ) @catch_exception() def upload( @@ -125,7 +150,8 @@ def upload( exact_stats=False, basic_stats=False, compression_level=6, - interactive_value_labels=False, + rat_valuelabels_mode="auto", + band_valuelabels=[], ): from raster_loader.io.common import ( get_number_of_blocks, @@ -201,7 +227,8 @@ def upload( basic_stats=basic_stats, compress=compress, compression_level=compression_level, - interactive_value_labels=interactive_value_labels, + rat_valuelabels_mode=rat_valuelabels_mode, + band_valuelabels=band_valuelabels, ) click.echo("Raster file uploaded to Google BigQuery") diff --git a/raster_loader/io/bigquery.py b/raster_loader/io/bigquery.py index 8b2d3aa..eecf667 100644 --- a/raster_loader/io/bigquery.py +++ b/raster_loader/io/bigquery.py @@ -18,7 +18,7 @@ update_metadata, ) -from typing import Iterable, List, Tuple +from typing import Dict, Iterable, List, Tuple from functools import partial try: @@ -111,7 +111,8 @@ def upload_raster( basic_stats: bool = False, compress: bool = False, compression_level: int = 6, - interactive_value_labels: bool = False, + rat_valuelabels_mode: str = "auto", + band_valuelabels: List[Dict[int, str]] = [], ): """Write a raster file to a BigQuery table.""" print("Loading raster file to BigQuery...") @@ -140,7 +141,8 @@ def upload_raster( exact_stats, basic_stats, compress=compress, - interactive_value_labels=interactive_value_labels, + rat_valuelabels_mode=rat_valuelabels_mode, + band_valuelabels=band_valuelabels, ) overviews_records_gen = rasterio_overview_to_records( diff --git a/raster_loader/io/common.py b/raster_loader/io/common.py index 8632a2c..3809092 100644 --- a/raster_loader/io/common.py +++ b/raster_loader/io/common.py @@ -10,7 +10,7 @@ from typing import Dict, Callable, Iterable, List, Tuple, Union from affine import Affine from shapely import wkt # Can not use directly from shapely.wkt -from raster_loader.lib.valuelabels import get_value_labels +from raster_loader.lib.valuelabels import get_band_valuelabels import rio_cogeo import rasterio @@ -212,7 +212,8 @@ def rasterio_metadata( exact_stats: bool = False, basic_stats: bool = False, compress: bool = False, - interactive_value_labels: bool = False, + rat_valuelabels_mode: str = "auto", + band_valuelabels: List[Dict[int, str]] = [], ): """Open a raster file with rasterio.""" raster_info = rio_cogeo.cog_info(file_path).dict() @@ -281,14 +282,15 @@ def rasterio_metadata( band_nodata = band_value_as_string( raster_dataset, band, band_nodata_value(raster_dataset, band) ) + meta = { "band": band, "type": raster_band_type(raster_dataset, band), "name": band_field_name(band_name, band, band_rename_function), "colorinterp": band_colorinterp, "colortable": get_color_table(raster_dataset, band), - "valuelabels": get_value_labels( - file_path, band, interactive_value_labels + "valuelabels": get_band_valuelabels( + file_path, band, band_valuelabels, rat_valuelabels_mode ), "stats": stats, "nodata": band_nodata, diff --git a/raster_loader/lib/valuelabels.py b/raster_loader/lib/valuelabels.py index 106778b..3cdd733 100644 --- a/raster_loader/lib/valuelabels.py +++ b/raster_loader/lib/valuelabels.py @@ -1,3 +1,4 @@ +from typing import Literal import click from osgeo import gdal @@ -7,7 +8,24 @@ gdal.UseExceptions() -def get_value_labels(dataset_uri: str, band_idx: int, interactive: bool = False): +def get_band_valuelabels( + file_path: str, + band: int, + band_valuelabels: list[dict[int, str]], + rat_valuelabels_mode: Literal["auto", "interactive"], +) -> dict[int, str]: + if len(band_valuelabels) >= band and band_valuelabels[band - 1] is not None: + # Using valuelabels provided by the user + valuelabels = band_valuelabels[band - 1] + else: + # Computing valuelabels from the Raster Attribute Table (RAT) + valuelabels = get_valuelabels_from_rat(file_path, band, rat_valuelabels_mode) + return valuelabels + + +def get_valuelabels_from_rat( + dataset_uri: str, band_idx: int, mode: Literal["auto", "interactive"] +) -> dict[int, str]: """ Get the value labels for a given dataset and band. """ @@ -25,7 +43,7 @@ def get_value_labels(dataset_uri: str, band_idx: int, interactive: bool = False) for col_idx in range(rat.GetColumnCount()): print(f"\t{col_idx}: {rat.GetNameOfCol(col_idx)}") - if interactive: + if mode == "interactive": values_column_idx = click.prompt( f"Introduce the column index for Values for band {band_idx}", type=int, From 7da576fdedf0874925774014380be0d8f04e08ba Mon Sep 17 00:00:00 2001 From: juanrmn Date: Thu, 12 Jun 2025 08:59:46 +0200 Subject: [PATCH 19/24] modify the rest of providers --- raster_loader/cli/bigquery.py | 38 +++++++++++++------------------- raster_loader/cli/databricks.py | 31 +++++++++++++++++++++----- raster_loader/cli/snowflake.py | 31 +++++++++++++++++++++----- raster_loader/io/bigquery.py | 6 ++--- raster_loader/io/common.py | 4 ++-- raster_loader/io/databricks.py | 8 ++++--- raster_loader/io/snowflake.py | 8 ++++--- raster_loader/lib/valuelabels.py | 13 +++++++++++ 8 files changed, 93 insertions(+), 46 deletions(-) diff --git a/raster_loader/cli/bigquery.py b/raster_loader/cli/bigquery.py index 3646bad..8451a84 100644 --- a/raster_loader/cli/bigquery.py +++ b/raster_loader/cli/bigquery.py @@ -1,4 +1,3 @@ -import json import os from urllib.parse import urlparse @@ -7,6 +6,7 @@ from raster_loader.lib.utils import get_default_table_name from raster_loader.io.bigquery import BigQueryConnection, AccessTokenCredentials +from raster_loader.lib.valuelabels import validate_band_valuelabels def catch_exception(func=None, *, handle=Exception): @@ -23,15 +23,6 @@ def wrapper(*args, **kwargs): return wrapper -def validate_band_valuelabels(_, __, value): - try: - return [json.loads(item) if item != "None" else None for item in value] - except json.JSONDecodeError: - raise click.BadParameter( - "Invalid JSON format. Please provide a valid JSON object." - ) - - @click.group(context_settings=dict(help_option_names=["-h", "--help"])) def bigquery(args=None): """ @@ -111,27 +102,28 @@ def bigquery(args=None): type=int, default=6, ) -@click.option( - "--rat-valuelabels-mode", - help="The Raster Attribute Table (RAT) will be used for valuelabels if present. " - "If 'auto', the columns will be selected automatically based on its content. " - "If 'interactive', the user will be asked to select the columns interactively.", - type=click.Choice(["auto", "interactive"]), - default="auto", -) @click.option( "--band-valuelabels", help="Custom data for valuelabels in JSON format, or 'None' to use the RAT if present. " "i.e: '{: , : , ...}'. " - "Could repeat --band-valuelabels to specify multiple bands data, " - "and they'll be considered in the same order as they are in the file. " - "Note that any position can be 'None' to use the RAT for the corresponding band. " + "Could repeat --band-valuelabels to specify multiple bands data. " + "They will be considered in the order they appear in the file. " + "Note that you can set any value to 'None' to use the RAT for the corresponding band. " "Also see --rat-valuelabels-mode parameter.", type=str, default=[], multiple=True, callback=validate_band_valuelabels, ) +@click.option( + "--rat-valuelabels-mode", + help="The Raster Attribute Table (RAT) will be used for valuelabels if it's present. " + "If 'auto' (default), two columns will be chosen automatically for " + "Values and Labels based on their content. " + "If 'interactive', the user will be prompted to select the columns.", + type=click.Choice(["auto", "interactive"]), + default="auto", +) @catch_exception() def upload( file_path, @@ -150,8 +142,8 @@ def upload( exact_stats=False, basic_stats=False, compression_level=6, - rat_valuelabels_mode="auto", band_valuelabels=[], + rat_valuelabels_mode="auto", ): from raster_loader.io.common import ( get_number_of_blocks, @@ -227,8 +219,8 @@ def upload( basic_stats=basic_stats, compress=compress, compression_level=compression_level, - rat_valuelabels_mode=rat_valuelabels_mode, band_valuelabels=band_valuelabels, + rat_valuelabels_mode=rat_valuelabels_mode, ) click.echo("Raster file uploaded to Google BigQuery") diff --git a/raster_loader/cli/databricks.py b/raster_loader/cli/databricks.py index f533b08..6e4c877 100644 --- a/raster_loader/cli/databricks.py +++ b/raster_loader/cli/databricks.py @@ -6,6 +6,7 @@ from raster_loader.lib.utils import get_default_table_name from raster_loader.io.databricks import DatabricksConnection +from raster_loader.lib.valuelabels import validate_band_valuelabels def catch_exception(func=None, *, handle=Exception): @@ -116,10 +117,26 @@ def databricks(args=None): default=6, ) @click.option( - "--interactive-value-labels", - help="Interactive selection of columns from Raster Attribute Table (RAT) for value labels.", - default=False, - is_flag=True, + "--band-valuelabels", + help="Custom data for valuelabels in JSON format, or 'None' to use the RAT if present. " + "i.e: '{: , : , ...}'. " + "Could repeat --band-valuelabels to specify multiple bands data. " + "They will be considered in the order they appear in the file. " + "Note that you can set any value to 'None' to use the RAT for the corresponding band. " + "Also see --rat-valuelabels-mode parameter.", + type=str, + default=[], + multiple=True, + callback=validate_band_valuelabels, +) +@click.option( + "--rat-valuelabels-mode", + help="The Raster Attribute Table (RAT) will be used for valuelabels if it's present. " + "If 'auto' (default), two columns will be chosen automatically for " + "Values and Labels based on their content. " + "If 'interactive', the user will be prompted to select the columns.", + type=click.Choice(["auto", "interactive"]), + default="auto", ) @catch_exception() def upload( @@ -142,7 +159,8 @@ def upload( exact_stats=False, basic_stats=False, compression_level=6, - interactive_value_labels=False, + band_valuelabels=[], + rat_valuelabels_mode="auto", ): from raster_loader.io.common import ( get_number_of_blocks, @@ -220,7 +238,8 @@ def upload( basic_stats=basic_stats, compress=compress, compression_level=compression_level, - interactive_value_labels=interactive_value_labels, + band_valuelabels=band_valuelabels, + rat_valuelabels_mode=rat_valuelabels_mode, ) click.echo("Raster file uploaded to Databricks") diff --git a/raster_loader/cli/snowflake.py b/raster_loader/cli/snowflake.py index 9ff5050..0ab294b 100644 --- a/raster_loader/cli/snowflake.py +++ b/raster_loader/cli/snowflake.py @@ -6,6 +6,7 @@ from raster_loader.lib.utils import get_default_table_name, check_private_key from raster_loader.io.snowflake import SnowflakeConnection +from raster_loader.lib.valuelabels import validate_band_valuelabels def catch_exception(func=None, *, handle=Exception): @@ -124,10 +125,26 @@ def snowflake(args=None): default=6, ) @click.option( - "--interactive-value-labels", - help="Interactive selection of columns from Raster Attribute Table (RAT) for value labels.", - default=False, - is_flag=True, + "--band-valuelabels", + help="Custom data for valuelabels in JSON format, or 'None' to use the RAT if present. " + "i.e: '{: , : , ...}'. " + "Could repeat --band-valuelabels to specify multiple bands data. " + "They will be considered in the order they appear in the file. " + "Note that you can set any value to 'None' to use the RAT for the corresponding band. " + "Also see --rat-valuelabels-mode parameter.", + type=str, + default=[], + multiple=True, + callback=validate_band_valuelabels, +) +@click.option( + "--rat-valuelabels-mode", + help="The Raster Attribute Table (RAT) will be used for valuelabels if it's present. " + "If 'auto' (default), two columns will be chosen automatically for " + "Values and Labels based on their content. " + "If 'interactive', the user will be prompted to select the columns.", + type=click.Choice(["auto", "interactive"]), + default="auto", ) @catch_exception() def upload( @@ -154,7 +171,8 @@ def upload( exact_stats=False, basic_stats=False, compression_level=6, - interactive_value_labels=False, + band_valuelabels=[], + rat_valuelabels_mode="auto", ): from raster_loader.io.common import ( get_number_of_blocks, @@ -262,7 +280,8 @@ def upload( basic_stats=basic_stats, compress=compress, compression_level=compression_level, - interactive_value_labels=interactive_value_labels, + band_valuelabels=band_valuelabels, + rat_valuelabels_mode=rat_valuelabels_mode, ) click.echo("Raster file uploaded to Snowflake") diff --git a/raster_loader/io/bigquery.py b/raster_loader/io/bigquery.py index eecf667..8bfa032 100644 --- a/raster_loader/io/bigquery.py +++ b/raster_loader/io/bigquery.py @@ -18,7 +18,7 @@ update_metadata, ) -from typing import Dict, Iterable, List, Tuple +from typing import Dict, Iterable, List, Literal, Tuple from functools import partial try: @@ -111,8 +111,8 @@ def upload_raster( basic_stats: bool = False, compress: bool = False, compression_level: int = 6, - rat_valuelabels_mode: str = "auto", band_valuelabels: List[Dict[int, str]] = [], + rat_valuelabels_mode: Literal["auto", "interactive"] = "auto", ): """Write a raster file to a BigQuery table.""" print("Loading raster file to BigQuery...") @@ -141,8 +141,8 @@ def upload_raster( exact_stats, basic_stats, compress=compress, - rat_valuelabels_mode=rat_valuelabels_mode, band_valuelabels=band_valuelabels, + rat_valuelabels_mode=rat_valuelabels_mode, ) overviews_records_gen = rasterio_overview_to_records( diff --git a/raster_loader/io/common.py b/raster_loader/io/common.py index 3809092..7d97116 100644 --- a/raster_loader/io/common.py +++ b/raster_loader/io/common.py @@ -7,7 +7,7 @@ from raster_loader._version import __version__ from collections import Counter -from typing import Dict, Callable, Iterable, List, Tuple, Union +from typing import Dict, Callable, Iterable, List, Literal, Tuple, Union from affine import Affine from shapely import wkt # Can not use directly from shapely.wkt from raster_loader.lib.valuelabels import get_band_valuelabels @@ -212,8 +212,8 @@ def rasterio_metadata( exact_stats: bool = False, basic_stats: bool = False, compress: bool = False, - rat_valuelabels_mode: str = "auto", band_valuelabels: List[Dict[int, str]] = [], + rat_valuelabels_mode: Literal["auto", "interactive"] = "auto", ): """Open a raster file with rasterio.""" raster_info = rio_cogeo.cog_info(file_path).dict() diff --git a/raster_loader/io/databricks.py b/raster_loader/io/databricks.py index ead64b1..ee4c1a7 100644 --- a/raster_loader/io/databricks.py +++ b/raster_loader/io/databricks.py @@ -18,7 +18,7 @@ update_metadata, ) -from typing import Iterable, List, Tuple +from typing import Dict, Iterable, List, Literal, Tuple try: from databricks.connect import DatabricksSession @@ -144,7 +144,8 @@ def upload_raster( basic_stats: bool = False, compress: bool = False, compression_level: int = 6, - interactive_value_labels: bool = False, + band_valuelabels: List[Dict[int, str]] = [], + rat_valuelabels_mode: Literal["auto", "interactive"] = "auto", ): """Write a raster file to a Databricks table.""" # Wait for cluster to be ready before starting the upload @@ -178,7 +179,8 @@ def upload_raster( exact_stats, basic_stats, compress=compress, - interactive_value_labels=interactive_value_labels, + band_valuelabels=band_valuelabels, + rat_valuelabels_mode=rat_valuelabels_mode, ) overviews_records_gen = rasterio_overview_to_records( diff --git a/raster_loader/io/snowflake.py b/raster_loader/io/snowflake.py index 3934811..893321a 100644 --- a/raster_loader/io/snowflake.py +++ b/raster_loader/io/snowflake.py @@ -3,7 +3,7 @@ import pandas as pd from itertools import chain -from typing import Iterable, List, Tuple +from typing import Dict, Iterable, List, Literal, Tuple from raster_loader.lib.errors import ( IncompatibleRasterException, @@ -208,7 +208,8 @@ def upload_raster( basic_stats: bool = False, compress: bool = False, compression_level: int = 6, - interactive_value_labels: bool = False, + band_valuelabels: List[Dict[int, str]] = [], + rat_valuelabels_mode: Literal["auto", "interactive"] = "auto", ) -> bool: """Write a raster file to a Snowflake table.""" @@ -244,7 +245,8 @@ def band_rename_function(x): exact_stats, basic_stats, compress, - interactive_value_labels, + band_valuelabels, + rat_valuelabels_mode, ) overviews_records_gen = rasterio_overview_to_records( diff --git a/raster_loader/lib/valuelabels.py b/raster_loader/lib/valuelabels.py index 3cdd733..9c31cd2 100644 --- a/raster_loader/lib/valuelabels.py +++ b/raster_loader/lib/valuelabels.py @@ -1,3 +1,4 @@ +import json from typing import Literal import click from osgeo import gdal @@ -163,3 +164,15 @@ def count_column_unique_words(rat: gdal.RasterAttributeTable) -> dict[int, int]: columns_words_count[col_idx] = len(words_list) return columns_words_count + + +def validate_band_valuelabels(_, __, value): + """ + Validate the band valuelabels parameter for click library callback. + """ + try: + return [json.loads(item) if item != "None" else None for item in value] + except json.JSONDecodeError: + raise click.BadParameter( + "Invalid JSON format. Please provide a valid JSON object." + ) From 30f456aedd3ea90a92e0f48ee0fb8b2aed503fb1 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Tue, 17 Jun 2025 11:24:50 +0200 Subject: [PATCH 20/24] minor changes --- raster_loader/lib/valuelabels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/raster_loader/lib/valuelabels.py b/raster_loader/lib/valuelabels.py index 9c31cd2..0870e06 100644 --- a/raster_loader/lib/valuelabels.py +++ b/raster_loader/lib/valuelabels.py @@ -61,11 +61,11 @@ def get_valuelabels_from_rat( labels_column_idx = get_labels_column_idx(rat, [values_column_idx]) print( - f"\tSelected column for Values: " + f"Selected RAT column for Values: " f"[{values_column_idx}: {rat.GetNameOfCol(values_column_idx)}]" ) print( - f"\tSelected column for Labels: " + f"Selected RAT column for Labels: " f"[{labels_column_idx}: {rat.GetNameOfCol(labels_column_idx)}]" ) From fe270ca79205581bc2e028434989dd7803d58e77 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Tue, 17 Jun 2025 13:26:04 +0200 Subject: [PATCH 21/24] adding tests --- raster_loader/lib/valuelabels.py | 2 +- raster_loader/tests/bigquery/test_cli.py | 54 ++++++++++++++++++ raster_loader/tests/fixtures/cog_rat.tif | Bin 0 -> 51058 bytes .../tests/fixtures/cog_rat.tif.vat.dbf | Bin 0 -> 81890 bytes 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 raster_loader/tests/fixtures/cog_rat.tif create mode 100644 raster_loader/tests/fixtures/cog_rat.tif.vat.dbf diff --git a/raster_loader/lib/valuelabels.py b/raster_loader/lib/valuelabels.py index 0870e06..61c6fe0 100644 --- a/raster_loader/lib/valuelabels.py +++ b/raster_loader/lib/valuelabels.py @@ -16,7 +16,7 @@ def get_band_valuelabels( rat_valuelabels_mode: Literal["auto", "interactive"], ) -> dict[int, str]: if len(band_valuelabels) >= band and band_valuelabels[band - 1] is not None: - # Using valuelabels provided by the user + print(f"Using the provided valuelabels for band {band}") valuelabels = band_valuelabels[band - 1] else: # Computing valuelabels from the Raster Attribute Table (RAT) diff --git a/raster_loader/tests/bigquery/test_cli.py b/raster_loader/tests/bigquery/test_cli.py index 5478e94..014ee9e 100644 --- a/raster_loader/tests/bigquery/test_cli.py +++ b/raster_loader/tests/bigquery/test_cli.py @@ -10,6 +10,7 @@ here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) fixtures = os.path.join(here, "fixtures") tiff = os.path.join(fixtures, "mosaic_cog.tif") +tiff_with_rat = os.path.join(fixtures, "cog_rat.tif") @patch("raster_loader.cli.bigquery.BigQueryConnection.upload_raster", return_value=None) @@ -308,6 +309,59 @@ def test_bigquery_describe(*args, **kwargs): assert result.exit_code == 0 +@patch( + "raster_loader.cli.bigquery.BigQueryConnection.upload_records", return_value=None +) +@patch("raster_loader.cli.bigquery.BigQueryConnection.__init__", return_value=None) +def test_bigquery_upload_rat_valuelabels_mode_auto_default(*args, **kwargs): + runner = CliRunner() + result = runner.invoke( + main, + [ + "bigquery", + "upload", + "--file_path", + f"{tiff_with_rat}", + "--project", + "project", + "--dataset", + "dataset", + "--table", + "table", + ], + ) + assert result.exit_code == 1 + assert "Selected RAT column for Values: [0: Value]" in result.output + assert "Selected RAT column for Labels: [1: Class]" in result.output + + +@patch( + "raster_loader.cli.bigquery.BigQueryConnection.upload_records", return_value=None +) +@patch("raster_loader.cli.bigquery.BigQueryConnection.__init__", return_value=None) +def test_bigquery_upload_custom_valuelabels(*args, **kwargs): + runner = CliRunner() + result = runner.invoke( + main, + [ + "bigquery", + "upload", + "--file_path", + f"{tiff}", + "--project", + "project", + "--dataset", + "dataset", + "--table", + "table", + "--band-valuelabels", + '{"90": "Woody Wetlands", "52": "Shrub/Scrub"}', + ], + ) + assert result.exit_code == 1 + assert "Using the provided valuelabels for band 1" in result.output + + def test_info(*args, **kwargs): runner = CliRunner() result = runner.invoke(main, ["info"]) diff --git a/raster_loader/tests/fixtures/cog_rat.tif b/raster_loader/tests/fixtures/cog_rat.tif new file mode 100644 index 0000000000000000000000000000000000000000..ba49903e3793a369968ddc4f282e2cf8410f84d7 GIT binary patch literal 51058 zcmeFZ2UHZ>(kR?RkR*blBBFo+5kU|UP!I-0LBu3eO94L4-uy;77cu5$ zng*v%nqp7rox+^g!?JtwpD@Dc8e%c$PwJi5Jdd%&8ta_Foc|qbq^HA1vx#AKOtIz$ zXU&fM-eh)O$G`|_Gtx0N!ydt&xM-$liaoDqqNih~caq=mEXMLI*5Irz#@Iy1%;1EP z9#-$9ftdm3tmat^zZA3?f>4|g3XuTfLcl-Zbr8hyJIsqTasCNj_dCpq;J5q^v&ZlF z6Ri0sSQ~*MaSE z@b$m-zlOm1k(`hef=@-@!blEi7Xl9>FyR#k^b&zfcpxYYff4bb!zza$4n73^!_ZJ)-}H2L_Mh>^#ecr~2YmVRjvepb{T1Kb{8!9ByC#$WJNEnc|9VddDl7Y= zm%IBP(BJ2Orv|QG9UA&8y^kNCJozgwC+Dvi2+GdJVn2TTEB=4k?5{0pXncqm6wl-`fJaCfWKn?*>!#Wf5#p> zwtxSB#ZFKEasB_ztN!Ty_xZnT;5a*78$0_rI63$@Xz@!)9XIfC_K@;)@Nm}LeOAX< zZ?}{a&cngYQ**bOfsw&keXObOX*~q3rH{k8x;q;?T=Uk&d0cbwal7R1d|Z(|`mbcH zFc@R3k)DN~(Z3|7r1Zzo4Bj~Acu-=-uuJ#}2|1QIS#i*!@y$(opt1IG2k#1Qq z(fOkl^;>R7QXo6Kvwwa+b8r<#iXoj)yoHfGkQlTM$(ShU(H>|$`@B(z>$kJ)9!q^w zsiQ}v6s1hOoqe5f2aTPtJ39E_t_T04AsB*$As8W!B=_Hr|5;5S8UBA(6G+T2{!!cFV)1KD}qKLq{Bb`{9O_JQ?>lZApfoi{$ou2PX+zEBKW6j z`F{}kFR~bt*8a$11O)zfXR$}Z|NAV)&Z_OdXE8RMzvaKKiT|9%{Vwh~W`W73u~ zzR@}+m~MZg|JKR>F^2%sgHjz)FPAc$?=2h~+}{y}xE%tD4H~6O8jNF_Hai9kT0NIE zM8Kz)9NtYb^Yb>4e0`rw8vlmT$$niE(zG-($#e@iFYi>`O6~PgCJNHZA*ghx)eNZ>t^5DJMkQb~{xHum|W`@}As!JVL$y5rx3wR$s zdBfsu&ybIP6Uh{CJKdRo;A!m{Qt%eH9UDS{2}pOUh>=Tm--*yCbj8Wgn+8MKv^@0z zd_Hfml;Ob=Zic_+XOxMF*Z*R;!HmV09bn`viVPdG28% zV!w=_V$bZ*zV!u3E7@;Kt()+(Q!WEFbdn5)>N1E|o8R zG2R{ipDgqIIt-+Ha$xk}b`?ZnrZktndt_FS3Tzf_yD}zV!;I)XTvI%eKWIIGPeF7 zV~28fkcOyNQE0&{z`~wVGYjp@0~v0Qe{7aQ##$n}X^}JH)ZjZL&usY`j1{a5!FEIA zbjl}{_bEyphy{Fczdj!6xNUy{wMM=9=ssG3J|QWDrnSZ+hAXd7E9t{Avp~lMQ#C3B z4G**qZc|3+TGRGfoiE!g_5KZ>?E4B>i?e;8WX?FJun^8#v}BEzN!{oGNQOW>wXZ_0 z@JT&`utGm?zXH^priW%aNAtNhG=Vtr+SS!fkEjxJHr%HXdt27rjdOKfAd7~$*a#UF zpI~9*@`FTjKa+TRt1wh-Iky=r?nypk7cJ&geT#jXvL!z9AhYxq&kEkQX0GMRxAnNL zeqZfkc%8>cCgr6B@l_o;-*$E{F~8Kd+J#3&oCl&~Geh#?LDlur z_Sq8Sig^lvm~zk3dqmS7(Om0XOV;Z+5;T$7u!L;5YGw0R?!V9k^o~_hyIBmmBfku* ztV4VYL0{BDT)fQ=1w^!~RF}SUPEq)B)y({}5S2Da#8w@(L1=Z)8T*L&PDh0PXy=Mg zVJ^tTD{P2E1bY`GGCMJ0z4ES)w(ZLlek>k6(Qc1;Q$nR$6O2~u#&OCoKhAFtX9(ogaI-_w z_i8n?O0oM?K_h*Bp@7d)z*dYMYW%6cOxLnfQx4XqG@a-Kn&p*h1DBFKtCg=!{FMG_ z`RNQYbg()=abjSEL=hSO^zh{$xYEpE+HMCy;Z;rr_sx0VdCnp|hpH1SpvU7O(%pfR z`{%#tNg+NkGnho>D8HcIW<og{^)y37QOuPHiktod`a+HRle1l+Vr>WMv%S!i&) zEL@cVmjagNl4s$d$@NeU#A6mMDGy^5=|V}kN}2sDy)d&HmBE9EBloqMuN1DJDwEJy z-{Pb?{Yt|CGQND5`>Jo*gXLvyM2XxLY80!)3m20#>iv?-jlbu^UEm^j1+_%PKAp0_s z^Q2;h?t_FbuohldJo0KOBW7YqV*-3&Kr(dFYQR@SQM(6|Zo$-=!ptRB(@*S<9N|u| zMXy3FX{vRF>`@Bxva%?aw^MfPOF`$JM#7`#=ua|E#QtJQ@y?s!a-3~~Y;Mwny%(n_ zTdcncd9+a$VPg%+V9#d=?Zbmf3V|$aDI>S>;_z6zT}Y5_fjP*CnHJFI48Y(@;k@HIfTKRQfPJ^A>PYfpcA6yb3 zsa+)u@(s^yPc^U8h{QMf*1pj}6!pbS@}$O4vS{xtDRC!7TFYR9$&ciDi!8)nE?2L+ zV+=7giX$rM?n~Jz0Va_kyKN3aOnt@|_ef(%dEmnU9iLf4XT4K&A4A)2+^)(6GsH7I zIgt`2W@;#B>qPbEf>6yQ$O0Z`n0K|sG01HvFQ_Y@LT2=vx$4tuN}lh|oi3XPYl_L+ zb|WHQK<59#8$J_S3k{B`8%acmYG1o_2a)0ulF}L?E%2eMU%*E)TInQ0p>VFnlf>5m zl?Wd7Vzga)>h*iipoT4YhN&wHOCO$J-pCfx7q==0!)FPzn{8NMzy5)1my4>>a8kQ# zhQ6;`o7or?Ho1rGCeuR$K2>Vsi7O*NlCx*4uq)n^3Ew_*B28OnhLUqU;4r0DB6a67 zi^dmyTpQt|ZmvZ~@0%lG-q10MkR3upkJE{BVulh(s>-#e{xTdN;QLx$QK%2mv-M^} zBsC_JhJcUO{s00Z*UiD8&n*u{YRf;pTkZP+2a>Yrf&+vOYeMG&va~qkhughHvIPR? zLMbFy(csYJH$crdm_4p5DBCXamL=@HWBLba(!lE>nQ3WWS;ist1}6VDmfwPso77h= z(5t#uvRIn&zB7d(MhbD8l=R~~-=L59T3>Z(o=bJJB3^tIZZ;vY#`_C+E_me8S?2+6 zaRb}oE+aYotdpqyYP1w%f1HE#aA>Gi5{Cr~bAg$y6!fMxF)JOwc2eCAm0q9}92( za}VmNKf74etEBFJoiE9KICPfEVoKO62c59dm{-iY#y3n|94HZgB-Vb4&G|s}-6NVQ zQ`XheGfaH`SNswz>e(xD8KA$zac|d3?fNGWV$PLng>V{qz6ma)6Q6JF@704IqNUe5 zNYkK*Cl&69nH{>{y;Fn#at^~%*^}#g?;>X4w&sUU-C7g;fQ;ZHWD=AG6B0D}u6 zMyAg6DKk(_$DAt*@_q>d)j2Pds*`sL_K$y3U%{UFF=u=QgucfP)Khw(npJfhj$V02 z6b=knu-If23&H$46J-fFLQ|W1?9`fVHQ(2>AtX7hp_n+LXCLama-gsS)vuGvDzf?7 z;2#z|?N~PjO4CI2xU}TRyIw*96%LG_%p$tv8OQMdDgxpIi3VyOiuynT#VgU5~M)qaU(+U>&3c=k$ z!8w`FOs=mLD^@I-z?VT3@3a0F{Cf&9obGmT3z#pxf@LnUh}YKCf=+c>SF40jnhFqi zjY3G|fgN=GNAy0Q8YZJA($Bi5B<0|Qu`NE97Pw0C%LD`L(7bw@y6*+Pa!Ro%Wn215 zjm_vzi481f5NM^p^S_nrhoPniFhcB|E{d zJ+1p{dsj$aG)cRz*~-ur{LJII>Sr)Ezc=LO4{K9aD2-Sc5i&)lMSx|uh$4w-wBDO02&*Z}_(l)&M0g6#P+sVqZ}vg1;nK6+eG-VRUvUi|{`EcxJr z??ClI9a21sAxBVlM3&4kQ|c}jZlgN$l!lO*mIuyF8Z6>Y5ro9awji5(jfpSjx^Wyc zWry}5VSM$8(2jgS{TPcUtugq#tAlir`pRlvt(-wB{Ek6P)K_8rI3Kjlky~~lvAJkD z@qj-NyL}SrCG1dfpQSbcGY)h_BOMbi89j5OwH@7!Q2n%D#AYJ&=x!T%kf6`*yJKAR z+dWcfV=?Lddnyrs5|NR4S3;e{aRU>5n3K)3fp5=8xJmF15`h%@GFcykEc;N%4ZoyU zdR_mlh?YXA(!%4?qgLN$9KSuzQq>TO2y!`&&~ZHvugNvB!VxF2X|Kah4I!EOxiRA^ zzzFHH(6d6mIJTqlz>yVdNbd$tpgSn8j_)4{125Qmouz#MXQ{W5(ZN6Q8Eo-`v=-22 zFuoK0_m0_>sVH@$XouefQ1bD`}1cF%*bn0lQ<{dzno-g)>1C+2*iud zTgOHZVR@Mk8iUpVao40K6X2p%Bw-M6W@rxttC~}O0AhsFC$YjqO(!s3OM6|L>I3oT z=8yIjX(<-#!6l87)ue+ro?Nm;XH2jj)|r|O1g-&Bx5+8#*+>9Ft(x2DQP7%Y#{@$8 zR`R?A43y3W3IQOz)U41;T#`i1<4#;$2f+Ouv2_4!uW_CSXg#OCGyhoE)wZpIy87|TOvKJ7a;kmS3+i!a$sWb-g z0;7dcwOAQ@2@L?OcB zx#}*u^~u|X0oIaPOvwewF`#m|=D`^H3(y$d`PBP{;0UP_cAd>fq^k9}ce`aCD4MFd z+dbdTssn|>RsFdn&5@4KiVoO&4Zl;zjB_{=cI8aw=}N#X6L zhtA@kp^BJGX&@uyAe9TVhHYHDeEb%Cat$w}Z?ucr+U-HeVI5n6ouJJjg6$P;y>UM$ znYfz{%JW=dcY6DJk6Z3*ZT7_KjY>bFii ztx&|fGnjF#IAbeZx&EFvFNG!fp)1^(sMK>%+%2>ihZ_i(>T5baN1FP&+#PhJ{qU8x zWA7MQ(y2-iHr{9<;A??=6rJQR+(HW!(Ap-p^G&IYPeU6$zk=|w6*!)occ+c-fHP#@ zys?7cjd)d|fMYLxwK4jr{5jOh^?1cO$6 zLKiYB&^uyR5pIjD79%Q`z!TzTymPNF7+iUAw}UN3k?J*-VP#vq8(ubd;yAU6IQMfBDk5Zi1IW)$+?n@(jEf4K*4(b= zu;L31aOL!EuYE>}=4 z9Y3lznNHWs16iXYtd1ScWyeH&CJi3&%&ZX3T7-k#%^weIqF<;(>XPnMyrZ~f!b{1b zFeBFztHz7-N&K8U2_l;#-abXELq4Y(OLDIiE4tjX|HW!LpZ!R1e0Q3k4=h&)2siKf zEz4*SzTodcC9;6ZYhUraRpVUo`K25n;JdytLx+%%O8BV^Oh(@m+|}z`$?#4g#xJBpyDv ze4+=Mvdp5t;adyN++d$h2GSAEN*c~z4z}tiiJ;hhk}p+uG|by#`HwuU?cCz@U7qi; zah)b(AaFV3(FU^n(4aNtTO(gd((LlISur>#jpa4v%j|kiO#xt!?KQl106k5 zon3cEPCW6y6I_Gz+!e8LmAIaJimw0Bs4+$AE%1?^s5?gf36+;4(gf{>Z)#y6sF zAxICm6`Pw(G!zJQHoKNxGJF~0u!`7hG#)-x?QphyUX`YO3v5YLQS~N#g5T8in8qdB zORiyGTr?K=EN)bX5)e*&IGqZ@5&3-re4W_q_dzw@#cD4$?m3?(JP#v;60Q3VB z&Tf+1As&NYlZ@V|zz=yIt`Ap$^Tyi-!MKA7e0B7hp%boeMapoLBnVl~qxu2#4BW}F zaDW#8x^@E3Azs%hE3p52L%W|#?Csl;H7b0=g0d8w z%XTKP>1OT`PfeWP?R2PY?7ggK&QVMk;Dv}UuQc15UsYX~E_}yhtxI_Lq=xX$d;C)S z6p0n$AI^1t4(wuRAS-+B&s^FK0fb)s18Dc+;^gr*-LsWS*A6iat(j)A+Q;vc&oHZx zDxktgyGF7&u06kCwCS>*3uAK{n%d246P4B(xf?oBRFPyxzIH~_J zlMegHkT+jUMTxyIJDA^W>`xuMPp~_A()!a`>h}#`kQ1!)+o+5eO59OS5%Y}gQWNQiLLvv);rhJB zi+%WVmbo)NAaXRO6wZ#-wp9pi2kEfWGmyV18pOUySW6x@SSUPdP)I>teh2>qxy7e| zpDQF_z9(R0Xk4i}^7Bn|DU{p7Q4g3-Ya~i~%f%m++j{|>?i43}vQgiKY@ei1-+bq5 z{QLNjpL?gYwgigkR6b0tY#0u;Tl(_T6Wz<6RJ=J`L#_bXbvLeWqxftXj8miLg|SRe zuJBv%$Ff<~QFUhN_n;;tNUG4{!$mn*ln&iR3Cvzj+joR5tMo%|umcrMo>N-fQ>N@Q z(Dh=xbkni3Yv*7ABb5HLEoQl-DGsMEHi0j@94ennS?nIbovun%&DlJUPERdJ7Cpb|?2K%*H^cASLO(<+IlUaf9yAUc33N{3KJ<=xLkOUeZGo4}vf| zW;}_unPW&xJNd};+6c6W*yibZY&mtVpt_Umt$n`rWQ+R`y!N?c66l!JXB!cZ50fl` ze)yMciMRXMjRcD_g95uEy=9ob+UeLm8){qVd;Q0huXH zZU_{QZoW^v_lYp;UDDB}4t1%fBhLn;?=nlwLbnduFe+ zoII#F%xlvza69iuU}4<6U+xUT%-$^>5*eJe<|Qoz-xZeVx-Wk7#ymJ-e448Er`3Y6XtP5(bj=yq})_n9mLMWY+}S zUFLWs^yAHAceZllRGvm)b5YMUnz0*0_dyZ~N>d2KuT)uW_w@7clvsz2wT+#sU7lVh zu&s0AWo_}H6Iylm3hv$de3}3LBIQ#U`(Dz%1Ch`V(i75&XM1--`dnXCce$Ru5rz2r zr%l}2?(w{KKQgWm_C6Z9O4r4{WTym`1x_gyib&O>Hnd}x@azr?e3Kws=<`przz?|} zz63**@QmO}+V)hW_eQyA*tywP1kH_*Do1zy_|RT0H-vfJE4#luIY{3&9^=YU+swl( z5Fj{9p$bokjh!FR(yWs}DOy0g=FtQ*FF8V$;Ly1r4y7*FOBQ+1ynY;fd|M9m&RaoD z&xHL8tr(c6wFX+bl`fz1ap*%)FHSRyZu3}Dz9}IPK5`NnE{I8ILiYas5lP=dUpZZ4ILsI0vY; z34-ERn*}(fl$4V%ZuCo;J;-og5<+k)*?H@$o(Y_vI_MciR{5?`8Nv?Tm)jK`c4$h^ z2EJN%NCdA)i{h8UoGR-7lIrTMV>c+WekWnmv99mW2P@$vM_gGc0bDFfJf@=k4Gf@a z-vWvcc$7^-;+T-Wh1$91^@^54GQ;{S_!iLLz~jnh{s^!25}J6-tngE>x6D!oUF<>2 zTt0gSJ$YL{uQ5p-|Lq6$&$a#W=RB)_nKNo=~vPGI9R zu*tbAE~j!R4^m?gy0Rk}QVv!dyB+VK-@;;N zH*2gVL<(v?tL^MaJ9GDC$K8YPgTJC0O^X$tip`({DmnHatBec%BG{Wpi&~dzqykx0 zRf|E^vXCFJoFrAGHAZNq_MpN6s;g^96uAYNT)D=3g6vNOnuYqdG!s432oP}ljD2rPeG&K zc8g|s$tBoD1l+W^7gFhFy#uuE9{G5e9eQqmlXjKTw9P}^qvQt`XV$PvM&+O>R9+hw zTS@P;l2zkY;f*NV=Q@&Y?-J6_=vqJy z0tIOA1CR-7^jgFt@xV%*3thI)#buur?9rx_&Np{L?)sKbvhIJ#agg2Z4zU+;$76m% zhcIt_kSFt;pNpoNpD*H{igKinte*3Vl)~z?l<=|h4%Z3pF{V+GKYZn!*koEBRAdSr z;W^r+|1?3Q+5I*UJCS=`S;H80?X{5h!%A3smgo-sG=AAVD*i<2NZSPJ*PRbvJl1Sh zVr_Y$loz%FkU8oI@o;?t2RkZ%xHPs%${t!=Le+}p0`)*|dAH$b6LVwYw|;8BM#ALd z!_u?@X~=2=|Ip!Y687D8UzFdLK6odIU!>zVqgpBKrRK?w=PmTh~FhCKExTlZ%n6q{0cORU7S%KHz?`s z7=5%9<+_w%c&XL+VO{ef4kJ^W(JeKdo!n&+Z$qD64BOP>1o)$gM>?Y=AFPwoKXcIb zF)>rDS-eS6zsDY0|xzFQUpQLZwc`Ud^UKv7av7!nTc1 z!%opy_pK)-JtZ*CiJZr36l1XIMpO`Pgd(3-h@F3w8Np6)!3`HS6zp*wR98H zQHGJyi?mn1}gwQ957J>h3Vt)KJARyNcPsi;lpD+>)vr|%X--y z59XQZh3lgW*e2_cCDA;kA*11ELTUR5GV0WEgBUpYH;22fJ{EcX;jP+4P{c2M_i9j7 zbXivBWG?mE=-w)=q_(891#fgOPsu0)?_12gMRpK*VD#}#vX#g%YyXb=%lCzThTTqm z$kx@_jPuZ28a5BLf1sfgT<@uyRonHIU8vHRw4u%omlj<(T1|dr0e#T|2b*u~RbRii zqb=4{P+RTk#$QFG(M#O5k0w)ZzjpY?U3$*bTav8Th1rlDd`f{~O09LZcK3DCHUnfT z?ip{*ea)&G;xU_R!qYch`}uQNoSh2}ul9l`W+m%>#B3$Zsqb%^eq{>REM8oOf}*_| zVgHBcp=(w+qx>0jKsD4PIQeNOc4*Yy_$}hUlFqhml(mFfSKi;a8@Amlb6m!h(|t1s zDHCEF$t~uJ;0Sb(Q}KR8QSjmSAokFZ4rdwN3YIwje(ZQ+OulsEtr9K zMIF@_Fn%y8otcj_gG>vW*!eHboji8VzLz6&;kms#Wlia9QqWV7WPlnhS)r}7zh5dg zhxp2~!6rKS`356LgY;7jGq`;4@QHF#`~q1H*@RSl>er@KVTYO@2@^h%-@N5PQn|DB zTGtx7e07>{7@zPeVD%fzO}irFLGnb#-rzotOjrNXn91oMz*=dz9Wln~z_{Re&2%4v zJmq>8*T_!QXSu8OzG^1yWsw3k7j~huAOtWkB)5rXe%hMFkDE2>T8!aPV z(zeU+uGs3N^$>ndotEdBYU`Iw(a$?bVNZ=JZ;3d?5f|)mzpofqvas1ohek~kwX*pH zWkxNRxsA8VicYN?zPCYJ`-^7V=a;W04Ub>Zm)Z%Xi$!~r7=V0{PKs~5?v5ud#uc-- zBpxNc5%j+~KQwceHnm+F#(0x{wdMrTur)SwUndx(s%?E_U< zuk9F$VCFS80J8<^S;ZI%Lvb^?mpoU;!-;V`@1-${PUqtuU48LpgNxxyK~jJY#Dw_<#cgfciMyhZ95CyKWp26 z;NF%!gy`O;3nfkgRuo$*P_9Yi38r3!ZHv6VF-(a6xA;ovBQOJMb;XwS58}wUb^oXqA#jPq4ek`Uh**XE;#F4`! zb_%9COU}PoL~F29?}DYL%9f6YhthBDf+V!L_BE;?1x6@F}Q7| z(w-bXVPdH0jEj1XfwP|o=|h&EvB*y3{WMf`igqzyxT-7ql=+4waM>^Z083d9o02!b zovA1sZ_^$hOX++{N){kxEx6jCz4zWSXfWH+w|7^_`%jd&ef4A)+?F-$@Pp`;q?-y^ zAYYeoY?dIlh1YiRVH$RtL|@UEe62ISzhzS$*i`dg@9}`@ns9oVavw{14_bLsG^R2V zPwmD(AG{lBXo<3(B}a2@y=FIkxPqwP6Ka%TsZ4M&(~j($v{ymz-N7ljieH$C1gPg` zLPl?Q_AnztmX#bivN&&!OiH(rs9(^`sH6G5V%BgjI4hMYiwcOh-I-~9KWU5LPlG~-HV~%P zutPH#*2PYD5=(Oeeh;E~O(ksGa= zw~Sdw25X~6NE@Im{+IrFF_4MYDU2E&!#1=HdCoeYb*P6BzRGlT22;MZoL@LHISSYntuCx#g1yhF`%Bmh7-}A zd9AJU8S;apmZZ-;&;)AJt22p2erOGUOO&bRrky^H!6(=XWEI^uU0KtjKNYy8=0;k; zyEv>!R8KzuyaWQ$t$SJ)(%8j3HwN|`7JVY)O^cJ*5t_YgClenb%EWdWk@ZDGG}m{c zYReKi*FP@td;u2Gr_f#Yp%;fi$8ieDlY*|8=Xn^`%>Sk8+Y&Yq?NJl(+3x)@Uh|w%B_dVKMH0IfZI@8dRT&X!CeOf z5L1`FCI?m;amSb#BjNWz{P;U{)4+$$qii7cjC-^N^u8tGUHUgNc?ll=mbsf# zGkgx9nfMK!7nddTVkv_koPb6ghw+vD*cpBK-~maGshtN{Jr6it&hQ+mIcGIIvzMpn zD)Q`&LbvQrHfV{(yQ#&17>V}me)#i9APw==JkaKl9z43Q zw&K`VOcRd-rnQBUE?B2FjSfgR4OXl6`rxJLwT4g9_weT+Oo>zoSM_j97MnwsqM^5- zYs`!m2em|$$w5|fyxejYVj>~O-n7-}o%pU?NvA1N@h`argNCnIYdO~VTd&QJ#pu~l z>f>k*soHHz@znnD<^Ta$F>3~sWmx+@R;!qU8z%1=>9D<=ho+sFdio6fLL%B~TX(QG zn{q?GjI6uhGo#70pXh9<)N5<%DD6*UXL$kT+`{zkh{3RSQRvN*{%gB;>+-;H3Ou{VoKG$dY>$4H)+D>p(QDTzx2lM5g`SC2-`83?@ILAz>Bka!MXwIJp3R_5d z=Y}v+&pG$*%UHwJm3;l7b!4B!jX^Ii?Z*{Z(aanfe><%&?{+c_VYe#950i_?v!m@y zbxbALWDfhzQjB=QVabQJE31+)&yh9X=a(3gq#16)0q{m^*nzZDuO18>zjVX?`oZV? zhS1DwsT8i!Qb@UlYf*em5+xkHGO{j?N!_h&$H{w@(3hil(eoLyJ3W8rM_9EeGegyW z;5*dYoWCmNra{BS3K}BQX@09vfZdWBm7Wg3I58p+Je5SXx)k(#r>iBJke2rYAoRF> zGo5{~-=IPs!wednsyo>Gl6kYG;a6pCis`3fNplmkEVq&@Eu#PA z2H&Cdh3d>M&eQ|uAyew`?dhH#%VW?o`OKMoOB>yKX4_o#&ld^cqa(E07^agv<~bc< z6ZlRJ)7y~OZi3|0v>GGt0QpcegDlC<-lRQV8cDbUQbZ)mnV9A;{#~7zqY$qtYk-Cw z!`(K%^Ef-fnp|a3x)iUq-ZeP5$9lsQt~pUps>xG{#7TI3a233;hGK=F+pLTw(Kvah zmd&IFS-W*4OGAI(;yLlWm5hv7T19|p^)y&(|2DuFVIBw7HHS z*B_QQHG8oh#RVh=eSG#J@M`7I&ydOl;>XU}(~S~5yrqjR(6+vxYa}f=JHO>s3g&}O zyXwPYmKW21@@Aavz%kG$ITB0$ww^GMn=p0DS?W;=tiJIWw4+Z7R974p5e2U%(MQvR zwBRU9!t{6Fn%*LDsORYWeA{+w|6NC?Tbac!g4-V4r-$m}D460F8eRo7#baeL&{)Xc!CnV5(i~BUF67iz zflrzuOURd0EF7A_K@Z%YlJY>zb-L)OwyB3QVC=l)Y}<1FZEZVBU!_Q`9~|k4eQa#U z=)$+}V^MqogZ=}%%~oK$pQjM~Wl|En>qCvswvq;=KeWFHR4ouKRRiOv2(V+)s3OC) z+FEXfhk9V{794lrx`YsCqAP{c0!_?rNz^%i#8oKZxlk)@8DF=LMa1ST4n}h)zqcuJgucV8 zjWfpY1_U<^bb;8FJTQ&E+60@@bXS>gBwN9GiwBF;b*Cm)IUa%T>QXSev5w9p<+Os; zFT+cX+crx+2uo92(GPS52_tnx4Cqr*3T^RM<9{^NOq}*$$ymU@1iWPk)i7BC646Jj z_oHn!9Tw=h`Lr7tYL(V2u1e;D{`^1;cx|XNy+qn(!yUK_*t5`qP4EWF1c`alq@IRV zE~HZem(noM@rQ{ zQMX4J2l8FJ1S~O}QA5$BuE4?%v7Z$oqa9HGGCFV%e94q_Y3blyl1{gs=mn-Zp@Os; z6#sddPT|~FlYArud0rbCr-AFo@51+>&2Vjs+t7jUSz9!yk_z&MEYYomw9-CokvHfW zC#~Y%d4pwIK1;!Gj;IP`EI?Xm2NA*5fez_+;M>52;y^hVwm5^}+pyHTtbsuU`D^{^ zEb#`@zuYEjNFie(j1@JsH>2n(SXluREnezDtJnY<_?GlDd38cEQRdEg4>Gzwz+=-f zUluWxHU2GWxU-$1c>Eyq=K>aHeK{@tog+wPjrSD- zb4FV+7NkkaK0Ut&%&dV4{m;pruz&?!lEr&*aR}B&t9MuBgKtp}_P!5q!IlodDM2sj z)0(<0e`Ygy((^&|QK-pY#^+|@41SsOr!<;EsUON$je8f*sE-#2BK2dIDhsHUtRti~ zl6BzaS+IAu&6wfc0e^psK8AqI{{^zKqi@k_3vI?hfBw$WzYn$-0{=c@|Gy|5fq9;5 z%l&q+Usy&`X}ckRy|%F4H*+Ur&3r*8VBOf5nt6x#%Df`=`i4$sU9L0xzheC7eb$H< zoN-bHHco9)I^Uf!4h1)!X-0nFssepD@fqljp_brywmi+G~R6BZ7HO0X1>cu@yFuor5hqs z`W;g%wyd_7_w6FG^0KpnCNb23L;Kbf+uuwx0tO8yNGB#rGc|CO><>oDxVF;wI+yeE zTgEUQX2#wcBy@=89UmELja9RfGOQcNTvX?c^3Rj3QaBC5s`IyR{5%^8E6;|k#1~{D z{1udioDXFgzD-BiEa{qELFX9vXN>ibgstE807A(FVlSCv#(iX!F4-uGFf5F{LQF(3 z!$Gs6^0he6j4{po4&$2! zo;A(lP^xN&omlt}8M4#(P*c-xFq$5jys(D7NcjF!Ij`uT_VrMfWx}!1U>=B7Y_RQm z33IX(FHmr@|Bhh|=s}w=hAR(&3`wwpDRlq9jYjrVYCi5!s3392J9ga>fZ=@DYg*rC znPWzC=%vSDhX$bA@A*NGS_rsHW~jYcMz_NJBor6O~AMvxnt9 z)`REV)s26<+{rOIP1#r*7$@6JR2zP89CCmJ>$gWK6+wHbkjTq%)KSTjGv zAMu>0f$PGqQbj6ic9|nQdSQ(uk8rs)$&1m`pdZ1C;v;q9mp9&nvs<=>hz~7dTPw73 z&nO?~ZkjifkIVSk2UAJYO`_E+B+a_E6~(J_=)@YkRE7b|^~}Uq^85&~-NF?w!?D)_4v!XZhxqhTCoA5Rv*wKj7`!uN~VX$shh4uo?n0VUGb){wTHON?-I# zSU5+)Z34*C@JWIwcgO@Uyw&R7LL75^iAFugf8#KCF;+JTonCX)V})I?54e0HBlH(K z>e7|_qxf~9zX0T1z!F#^+41Zq(2EPFzL;7&aU5C$cKLDqe814~UpixvZ->In!%W6W zme|Dr^B13gk`k;zBrPg^Yq8!U2!>(zuC_%=CXWf;!a`n3RRpTEOaN&SwIz+qz!)_8oTNBnu7gh{Q?i&JRLsNiPe`GLnn+uxHnEFQICxWYYGY(M(FdS_uEKXES9 z`JBe#D2s!;9<3uB-H2Wyoy?9C9{9#p%Ot+?l?mWEx($^KBws4dasf4zJC7X78D>cA z`h`i?T$4ftn?XL2+J&&Cr_lZyo!R!({u2Jsg>MsBVvVqg*xpX`eMwtp0-zmk-AZdh zX{_rUr-*Z|85NA+)KQ~%2<>7Yo3%ZgN51wxP+O^BbWBBRooi@&pd_A|enfk|Nr6jXg@ z<2xeee4k!`Ks3|bp;YMRK4CL{-lAIq&_)$j@PMpju4oHa_PctoDTE9c))7SC)%lCopK++2ejwRRC0Fb* z;bK}gd{r3X0J_IvhWf6Y&KUH-^z={dIM#j%A2i|0C+j2l&S;ZX7$ealhM?c`rkbPdDR28DBspr9Au znOfX=s||-(uJ=gm36z7wduWagV1WhH9R%MojT`XM>sC?oAtxtJ!{TJ1xTW=MAzgCT zRC_Z@XF-j*;8oY;P#`w(}0WS#VGN)u>T z5qhu$V(Qhm*A}K|Rz9I1FS@TKun~!Sp%4*aMm-2A_ve=y2XYasoobwAb3;y>?p#6H z!~@RoRkK(>#>l3({1nGjwJ2|xWOf;2?j;o3oQk$U=1Ikvr z0cQzGUzy;6sm)G8M}cw+&6?2rVEcEDn=`oG!(DBlc>{C>C%Nmvi~7xHjr(g}H4qXW z+n3O(J>p0V$lp?rAuiAEuQe_cuN_z~tja{EplVro(d>o`wqpaGW>RWfhdqeE;unw# z8{AykDv4f}b3+xWL1G*_QK-|wfOJ7yc_grW9Cro6t=vr6UDUsRUS`7rdR1O~c1D?N z={ohoFA+k(FXiG}H#g5K@j(sOESWufW>KB3TDnqlrgySan7G)mLME#K5{LHBu1Q`I zO!ZscbwynE`nK?pA#7(eE(Wp@o_IS?6%E%;)2)TM^MGw`$z$62%eUc!O$RdYhO6Xl zHyGk!>mi3v5UEzK?l8#iS}u&fW&4zJ>2i`ELB)szOHWreG3IjXo}ux&GYt2LhnV2bfxzWQWLO%bOZ#X1RE$wRZwXOT|oq? z(h=!hG;~M+1(YhiN(X7uOCaRDdH%omL#`_`bMMaXNisXxnVsc`8wv|q;WEo{e+oDg z$IDzFR8SNu;vVn;?oh9}k!uJ^4By|P*|rXXto7)33F!b@Dpqs}MY%exe!q&FOxxQ* z0hZAP+E=DW`SJwIRBwZJbCsfqQTpp7D`?4}-yr*wQQC^p5*0EvUR2y(XUi3)3>?8d ztcrT80J)ODmxC;i?$X?2zwbgj5h1jlP*Y2J<@ASyworUlAUoe?>oc1`RAKFr zygF{~i=Tsz1Q2*VKG-(6MSHHR?J+)(eb4;nD{nXbR%>i=(od?40fxKhbA2(D*8ZRh zrXudQH!*lQssdX6Q)2^y`#EU0^((Yi^C7Wbt?{r|-+lAph%xeelJzjhsb6ec2uEPxx1QN;;gIOUUrk{q_+w_g#p)cd=z4P$mm*2K!#SJ@wi8{Q6Ip zEvS?E=t}}fwFY)<&;ZE`4#%qQsK&#tS#;@X(=FhqqbF;jUo`VH%MGdo>|`_=&u=M9 zoL3u|kHb@%uEoRxEqcp6=(`u~_oHzKvdZLlGR_2i_vfs@@%ew?ah2@xwCfbqS0`1G zs#O_1a2zdxenqq=GL|ip5&-P~sGhtYKBGx`y!jo&UXWlpPFC{UTvHK5sAU}c$AcaIXPPRXfzqxpmtHXhWEfY{Sk($qN1uLFK zAU=i9ZnMr9=Mi%4C}y2n>6A_RL@Bp4M6!|m4L(Qk-7MGFrK+40CDkT&r(MF@I3-hU z#smerd~4GgXt679pp*kQBbLEqh||B*+L>EfZF?Od7o!-wOe3^9Z4B^LI~1J1y9MMP zADZiuqZgif0!X5ao8I7T=Aagec|R2*kOPHhyakj%LeEHwwt-lOJM+mVoXj(_V6K;T zI9c}~tn6Z)D|gki=xy;ho5c-f(o^Mlk2M5pX+G|bCu{d0RjBdrufoZ0i~`HIL5^MP zEU08C91ikxL}O=Jeo-I}Q;fmm-vY%)X?(hv`}utN!ltLrXz=;Ca@D77=qVl~yMo{J z2h%r$sPP#RXsUZbFXHA|_FisAq!mGDcCYm@lYv08X=kBczuH+YpOjT#Cm?+U)D^44ZPuXED!jQ1<>;2@ zl*`7enJK1)d*0e?6M7dHE$~Wu-8;Ym{3cK&ZEMzcm>ZVQ;v8Dwbq)&fQ+))*b+0kF zW~I`%#H`5@jL^z+!=8!|zkzw(o8eOfUDWs0o>=KARt&5JkDjlUal|~mFgCs6!Sibc zN1c96&KQ zY4EqT-jvkA$AMlGN19av2q&QA3$ZF{JN|=+ct$-1Tueb^_xdZkf+|a29GX=>FpzAO}TWv*yq8S?&#FcD- z)ML8lLyYQ6X6L&4TvoFcu;K%;uJ`%E9cUlH)FNihv5$(W=Bt>!s8JAYG%5m>z+oVc z-T_@obQGC|U=un>2$*}C>K9E6Bn}OBhpnE0PhEc-k?8{&e4uo@KWI>fF!W0 znFru+KzJEP-vACUn^3@J2@oUIZGoJJq=B!Ym-%S<0NrL*VzOVhDe)rm2*jMpk;rAGE^QsAluWh zl7_Z22E0e*8jJ4VRfHG6j6Wdhn8-5!%ur2n8RtR$xiel>gt@rjemW|%2Yl<=m}SMb z#Imb;Z@YVN%ttnU0n!WJP@a|1Pa-XEkpZ`Q;OS3==SmSrH9kLf?@~AsT}uD?runD= zbep`v8sfQ-y2DEo?3fRXRymXY<<|HLBjBUVr!n6&5VN5eK$}8tyu5Z%?o7X%t5&bp zQ8sqNlJ$RJgFGfkd%vXBD58lAF>=Y0ITdspU8^0r1C(+E#E6DWM>^ri2Kq%2uKYQr zD)G9~gVlYlOQ+{~Rm70qot1r5iNGIc7(#Y8H3k?!9Zez;Fp~2v@un^!g!A}|wwk6G z-h-LS>Bw`F9&qL7myB;pqmnCYTw_JZ(Q4Y0%Z0&**C}6->4T_Z_rUUW$F)apFV95c z7nz+`tQAFP#H`PdDrBK$^VyK2w<9XrcR9H0S7_&W}$14}|LR&4Z^m z0L)v)yR#EyiN@<0uIooTm6M-oH9Jbs#BKw&g;6TNm6gA|v??G^cS1S%!Hb{8gJ*}c z?KYqSIJQNS<&>9x(NOn?A$ru8F^0gAsJa!=n3wsu%P=v@$i^(-^idn|@rZYXm{S&T z#cYOmW}ZKj;^JWR2dqRZuqGD}0_&!RezaC=2#_iQ3Dh<{IU3h*&>$HH87dK&Ugh3t zA0Yf{(^Zs8#%DkdC!Ba|!2np!hNgkkP_LT$G3EK3P=ko`hpkMeq7+BXb0Ye5jV!~ zxRG0(Zi*CoO~1JiM9A5`nQC_-{aSte!Rf7oQk7G?AJf0$4wajTn5yI+=1RY(ZY-`> z*9%iV#K7h|2m{Qa*gte*&nV0IC>|1zo6_;5Setzc$8Y$C-fT}@b2}Z$ZS7~v_uHLp z-&NggF2l6Wt!Au%jt)O>vB!7!Z8 zxS@W`e85#@J$vIz@A)W;c?0XsAi2{abpq7iYqV|*>NqrX?5{;H8Vrq4XxobsO_~V& zRxoD5Fx8XH^ruw$D!U7zq0t4N(AUH=1l@)aH;aK@3I($n_o}L@xKLQ#y8HPxS<44X}r_+J5Uc z{#@81)BnUZmzG~@2ziZmKqLNDTRaRIB&b5}2|iJ532oaMIo3j6Gm9K6+yHm#Y{mk~)==43gaW#5(SuC>wVvyo^+V>@F}6z?cFqY?Eo-z9 z;<^Eyy&i%S9~WBK`@yb=I$kvUFg4KhKxgD1y+HH2Ugyh4agT`M?HLTVUm=hqAb1sJ z|774w{W0U==)>P2rQ^sdy}-A%+c0MzKkEhQJ{eQaP1-5yFt8J2@i|w^*Ug^cUpo>Ns!Q^$%Z*4YBT8WNI!jEO`D%-&R^wAc}l>DWEOYu z_6_$L0oSXRhJ<>gVb^00H)$(~2{qulL}C6LNqL+8U#%NtjZs?qpItLjY3w-S%~ZV( znWA0Q_K0GA(=NjSHfCO~&5%I8mh|3*_>vjA%3i)lZMRvJApiarfbslHb-#gcD(ZEp zq$*Sa%%C!5RsB)-inKEYjI`1VhOha@rdpH&5)dA0i0gDDGxV`()pk`jq}ZMN7F7}i zK%LWKOtW zPy(2zFzT(>G|Zh?1>Rf@m|2EOd~V8G>Uqg6^d!eW$~wSx_!i5^fmDYp&69&2(n2)w zAE|kr6C}KOP*6#7wx0=2-qbj>K7GFX_|Fun=$Z>E`VDsESFT@>)66`{@5v%F^GoFa z0=~px^&q(@)utQS4%GR_&&1rT9WN@ywaSPFoe6sFdI09}b?U#b8Bsy~HIrmscacp0 zF(%Tie<16_V=n)|HjgQ)0L9YWhSmR?`G~=ZlnqZ_Jk}Ib0E&6J4dn(l@!0qq_IT^E zTh^6mTEMldlco1yY#Yu2NvZ!M*vO?*4CMMflS;?usWX$+sLjG>SCs7B2Ho z*vT)=?o*dL7vSe7XI)UU0Oh;~QNvW%a9Fc39l}>q!!l;f*H;7B9paAc_$aFl3o zL~Xrt5{JmkJM78_*hD|R9A==80DfS^gue9M8U)2D)FhHg6uzzzM2@1&RdF?2@p9ej zsiA5LO`KSa)T+Cbi6rYc>l^GB1zwRge*|}ud}p2!qh$*w6BSZ*9PPAe?{F&@?{ivx zsF3}WU|li_*P;t>cl~_J>d*EsGh*ZZ33{0w%?P;WkT~u~oD-A8)cYCDq1Yy~bEQ%P zPAp_lgD9zZN~*5<1^?sI7YJf|xLSe|AqqaaEg(z}UIh2}LabL&+-$s*J16W0vW(f> zhL7-AE4uPtP(;EmTeh3NgvLq6H=Ho%?`~#9W;U%$7s*9iJ|M)b97o627X@@26=eKb(c&wBU z>ziw;R6TRckX01zHsTD~ihjf~*f_mda%IvzzwteLkiQT{qJCPy=#gY}FZcj2(_C+9 z=ggP$^p7tR|IsT$r$kbwOhr6V1ntfz_@_TwD3DTGGBeL zLN31P#e04b>)uc(aNgcXMbejSD>67m6NM_f=d_adQ3Jr-7Nm7TJ=Pfi=y5KGwJdLe zAY_cUe|LwXwPb-%7!&EM_W1)jly-{|%0qJG$*w`n0+hZ9IAt{5+@5HEf1mi02`G;o z6^dEexr5jga*c_+n~^rA2C3E{OMw(ibKNt3lx=kpL|Rk-=9053kFdu|14BP+ z>F_qfZSqg6mSto-^GL1asoXXa<@+T0L>iU&gbcvwkoM7~M}BLC?e>J4aVym$MxYfx zVzm)PieM9Uxb$E;|0U>F-vIyJ7{C>Ei-XE8pP+@t!z~1!eV}n`?l6%lXEi7NdQp)a z@9gqto(sItlvRP4O)EB|XC64FMQG&~ts2i(@%-m1GyQ_eR-(jO>obMc;E}rfj+<^#{xeW=t7%lw-2|rrHQ1Y zbWqe(CUy84pFj-wBP+SJpFka6$R|H6%-`TF*36IoPk235VfX<2;F4U4L4{3aULL{B zyL`WsJ5Co_3pEc=49j(szoehehGMX97jL1%s4D*DM2)h)=>|s&LzYnyQyJo9Jgv4X zet!x3$;B0Ex?0@NzmJ@;(+>!RU4YaWWec$-4X)+e;%GdpK36O<9Ti32c;}sVv!C!P z3R8aM;0D^|uIz(AiuBI6FP5yK)*sj>?%zwM|-JT;r@-p>tGd9AkU( zGAH-=G5gA8+yf`840ZglQyuzDXaCBLV+o6Mh1fyDcDZazX453$=Rexz_xDm}oSB%U zCCJUD6=F5V#Q>I9czQlI@20(sx9G4qs6VT`t6iaa2=qx+?ehDonv>s@5Vq?hwML(> zPSaN#1+WM%UPA?)f2w`VXSH4n6<9zK#LbY&Q^~I_7Y6^7UI{vRM>T&AM!0?|-s`Q} zd`U;_B*ADVjQW_pEIX!F_rU zl%k|yW&sMx0*FCEoG{>OSeURns+$ml_Fvi!>H3ep!~GcIQuaI-PHtwWkc>7`_|d=& z^n#Z|<$u1Qs7?285=2jZXvjB%6z8AAl}wz7aZE|ST4^Tu_}d9N0#pX9fJW;!^MhUx zGLqkR-ax&a@Gt7)zAuaa@Dk>Atm`Be8cuO(~03Sit@OslI z3t}T5R~mQ`j$nQCf(fkT62S0(rT!X^l3T}ae)@o)Gy9m_d~qG{>V&tFe2lIa?MdcH zO0w*T)qy3jiai?c2Z|p47qydKu7UeCh|KT(B_qKCH)w&s)DyqaBObk?Jp8=C$6K%& zY4lVq{?Lv%p)P>oKA`SGUH1f|xasw`JHy*a`>Z3M6VwYyI!M&MbxiB0ekbC~u>t%; z765aZmF6ds64>YQ0?abxKkpKLPlKU~0ER=6rVsn@1o;Fo3y}XjNYL;F+?=d?%z1zd~Hel=XNwbUsQo9YV_1wwTLdTj0~~; zJ#%AzE(5|CT&~hPPK8b`o|9VT8Rp(ik*&L!v67>?XO#PR_gw zsK7PPRa5C}MEgmtA_~NI+el*y>X^)xwve5Z_kjP76QO8#KwHtwP7cGcw{w{`DO<z;9tjupOFB3{3+aUc3Rt4?>E0KiUrh-PkExVn^+DSC|L~(?rP% zm;n$H1u&4rO(qcb6=MI_j9adW6tvoZ@Pm@nyo|PwcviU=YkF5{5Sgz<<6Whd-M^Q4Au}^$jCAkcx5**&V0me+z8H!COq` zQKY2}P6|$lWkim;fh>LYwCW3n-X_Y0tbJ^RW1vX3vpkx4B7Nb;O?wr4ngus=WS(H+RxM;Aw=NnCV+B2=a1HKFxy~4MM)XF$} z{`FmO2llnhQJ3@Oi#I645&n$FmSNusUDovQgB(OJ%0nJ(unPH35M?^Zv42eHMNuSQ zkq0l67}lf|h#cLUL~O?Z!88A=kPK+?25(x)LQ4LKG9aL$a`1Dvh4t-;Yi86W;0^Ns z0fkrLbu{tUK+`-Z2^jMcoOwWeF3I>7Z~9q^#E7l=UrQrfQc$RfnWU36gq2f}fs}5M z;S*m^82HRXf_+f5~zQ`|#9b(p!7dIAibw(yQ=>{|(xWk~HW-UeahX{{Ji@zY~fq!gKJZqNGWZ zc+mp#ApX*#3-RFX|23@B)$c{=l9M_M|6gYn0Lwv+IcW~BNe}jxJos~kv=ciZMr!@v zBnx_h@rF^Au}cEI6{5Ae@>YOi}IKl8VYR z5U(ohO`zGu5+sY??WLF;fOZ@+YRMX-RiAacGo%bHaoA=83U9}z`zKyZ0o_QLBjl9o zc5^rC5>dQ}(qEjO>nkYHWcfdL?i<%gfU_12i5ChIVHja?S`!xX)2Iqre|%9%(mus6 zNlW16z>3L9S#i=n`!5!@d484F^_84rQfCs2NHuULrzh*5wYm6;FB=BrNCvsb1Ms4y z|Fz6Z+*tho%JQVt%h2E_0=Q2}(mC`L53V3}BI4`JvaN`o4mmM43m$lS?&HDKFZP8uE?^%bbW|x=TlWua= zNIM<4%7RMCZBs)bAHIh{*Dfw`ArdaUL@$Ib8QhE?FJZ?7JIP?22?MY2loIzb6$(Ud zgZpX0%Wgon#8)o2ej)i^`DgnB=I%DKU_gnAln4m!%UV9Z`!I|Eav`o;MIG&9lS@1N z@pA^F_eW(xB$3`K!z$)Kp~k4oBpRFGL0jogGgC59wUHSe38t)zUF@x(V)jwSdV922 z>JxJuj!I^P!MKhd@DG8J3(ZyXJ&Uka+P|!Mf_j5Rd%Mp39H=}q^XNb#Lp3fMtoVPe z^q_nTkM7K@4^*64%H)80I-O$Ai{N#oiVeZ|9eVRM6O_kjl#c_kk=&b#E{PlXx^cZh z3f$SdbG$&f^3u5DDyXKa&zmMY9-)WwIM$c}1%_2@rYM~KjV8Y$-q^(9a!P@z!zW>+8D z!uQ%?%X!nI^Eh4c{zi+ssAx10wbT2cY)bDteTaFo{_ZY+Eal(5s^5$di@LmpNL=yR zjs6vQV?q0ff*F!=utOnkDR{=^2MK)SojFZ}g}x0b<=GJ4nWbG+h=dKpwiuxXvG$$;XA6=|s0+y>3 zz#h19#?N;1B+p_!*qgc3g(|}JN_roQ`C%O)X3QbjQaMniEPsUhrF+9;)q1fLSNQP8 z1J_k0%U4A|E1h@v7$mC^9(2#9jFKgj!3USrpYH@-XTC@UM+-VVHU=H>!N03`Q!Lg` ze)NSH79hq(B|usSLK%SY*pqG6RHc+*6B%E>W1jWvLION7C*JhVVIwh+bhf| z!RA-KbZh*Op?|=eU1ADCe=+VuZq+uX01@bQ->34!7y9SDg)i#j7@GQHD!6&{_?Dd54PDPg+kSL)+_o9o@s z_t?;z1wW_bQ5eH8R{d5URT}nDgU$3(XTa=>@eyQ3%*HbPTj~jT{9yfyfGRBmo2c9R zTk6u8u*@07?|q@yf4sfte>LpI=8I4(@2~TN)IW17C*$s zopVMrl$#=KO6W))BVp$k1gO$cC>v~CcV)M85T5WvS5EmQoZoBs8Ztusl_$}_Toh-#6qqc6)VkG^ zcP-$|V}V0>VK7mRdzTj`;+2TxJiwe9z!V5!M7yA-+8iPqIl??#&8 z3X!}5abg#%Jz0ud}y%#`X zmY%@z6!aeMdbU}a?XI$ccVa2bUaza82x}AK(29-7=$e$cIWlrnR2{IPL!_@qoptrz z>ae}-=f|=IT(#b@Gqty5$Mr2eOTO9&1$`HmzpYT(om6m>)rVx3e-@wBl|Dz+%3fT2 zNfSTN-+APXKhR<9%WaDB^0@-`+h=k1JLQDPH$7$L2mlJGg>&8&WtHHe7-XomN$fyt7% z$Pazr-0WpRAzr<2CbnDB!_#-L>_Cc$MMRRv1-ZQNIf#@;x4$Syi&h91)*Oe$@IMD4 z)@@+@{mSXzL(YE-NAk}&ncTUro{huaW{!^sQDwBna=e~B@B-je7$#=am9+ihxa!Nn z>o8w|gnJy{8DpU`MqLuG6Wbs{d#Tv(*y6Sd6IS}`((r9t)Dy1xYw4KZ`!CqRDPctE z7w{bf*Wt{KyKHOC2rZrA%$zCRl{C~@9Cj{TdRy7FVJ$q3C#8|bLi@K`tL~BI!&j?z zbMBj1uD&}{JV1?NRu#+FMd<*%i^xIRrvPKh_zqAsCG=uHXWsic-Tcgg9ouwBO*4;b zhN9B@Zg|wjPvw(nWU3NK`-6UsEcQEaRnU_tE1q?)EW2rPAFK{S|B-U!7e>W`k9iR- zP|`hJhLZP#pLeL~APEOsEb0xfmpLQtvwHda(A@phcL=w+lz0PeD-~73FI~EzTE!U` zwRC!t<5bbC3F^pEI;V@ha9i8KbX?qS5s}|NZ05azZn+RAkH{fkx)r|sr^jzfTh@Q3 z7xd?6fBEl^(X#og@_*u1zCWo?ofGR^Ch)bqSk80O#YY(X7Fxgl&AJ;EexprELH5e) zZXM@T;Q*U}%Nk#wcv*`!ttAHVvOa?p?u;+{yeyrB(;(yiNqMMm9~oQZfR9(KXPC-D4yGr9Lc$CR_ETp?y^fNNf)Q<-lmxP5QrciyLy2k#MEi3>*A_ zX}%bSOJ~L@OU7qEpg>O|DN}#32X_D?p9g0DG#0D5YKAr4piO)?a~~Y%CaQyO*EvxU zpm8Z+Kn}T;|66qx5I7EtUhv!-|7CJlWaYqA{kkq`R|IQK1XEArevvgcaUv9ng$`7U z2nQmFhRiRa9-{eG(Z4Ovl|dq-D-h=iQ5bM+B71U=y1V7n05KIMX`=IYZrV8)$q7Rb z1Q$^WSeLP}R0$)xg8p9k+R5Drp9!D(rA<@?K3=_jNLDefcTp(7cD_F2sR_KvFtc0u zx(RjiFQ%PP0KjBd@>@g{7RRsgdM&^T`t{r7BLoNcutTuft_I0;7gvzIKOH)4(qo5A z(^ttXTc0RMasr?!7N$N)uUlDiso&r&n`92n|MRKv(-SCI_ zH_3tx;uwq5Wvi%ANX!SzJ})Tyf*kK}p{e5g5qwUM z)>PLF@WG?__2M0gRobkoh0Z>4AA3L(iuk}~*)>#_f!YFibDZX$gcbL`l3KyK4^oa0kxG_ zNMqZ(FX}oHswFD-wAYj>_K?l9u?I>|VfB7YAH84Bx>Ab$Pju-yAgBCFMAjwI9`~R5 zN^V*b8NJoBmT5^n?*bj*QJ0In4Bsg1$v|Y;E!`Tu@#TBEHAmQ;WtY>gRu@fNGJae< ztKMAM{P6a+(QX0Ti|O`BC*`};^Mg8AC0G2}t@@2fHz5`T}tvU|pIPU&Npc8{o2 zD=F^Q7<$~5{zm+!)Hyv|_YECZ>A*NMT1&a)JgS@VQ+`+xdRxq(f8I|_mO@^QJH>bmq}N%-*-*8{JxPf>_(KqukT&;7Rt}TH zsanQ9Wu&&vK<<%NoApc8R#Rq1)!^r{_hnKk_m7n6AD>juO&8MJ5XTPy%y;^-5Ffs| zXVG=XUk-re&$u44Vh1G}F zzaG6?@Q7O4$;Y^@3Sw`INf6$J3@=lV-AWy4DP)-{uMe>rGx1;kMvgza_!)btl@TQK zhLx4it{KNRhHrC6Di*`UZm-2`Je`J!YI?J4Ynhom^)H0zw;7jL-r-YIjeQ}7z{9(N z;CL(_P39WeA@+ck7kzq@7~um8eT6zy>`4A5wfeMC%Z~4Ptk5zpAVc|W zD(Zf_!dEuH1O{gXylH^c8ti1$VC#V=FUuf5bfpwprX4H* zVb`X_aO01HU89>PXLVAi=?uGS-96U~*C)WRwL_6gOvWJn z#8{i%cMzRJQ8hC!QQ}^s6AbysboPn-g*Q?BncAsg;zd?{o7J6zB*hD0eU_3^6>F};r}3gZ0;Pbr!Eyq3n z$lOyDo}=^@9@P!_;N1w%ITlfoknn*QG~kDE$ntH~3kLb;E6&!}MMZ=*GLql#!>#6a zJvko`qca@nvvM+d7jt<z|KY55ij~NQ0`iEU~xmPMS%eUQ{>sC3{71JCgI7{??rA zMaF|^w}|h`GVGprl;u7j{=I}^Jn_6hEem?QVte6nkMHLNo28QS4SK>z%Y>mf2@@us zUX=D%!{#I3^fy7k_FXSi?qK{*knpg zZV<>9JQ6QrAg&> zrznUg{679_q36Y`;Sfi3xJcrj*Woox&tDz}{aPV21qXV!pK$>s=snby!RFs9>+j3< z)=r-X(mS1M5tA>ipbFnlr%yCfsJ1;+&%74!)FrVpL(ub&ZaP>>IWO|cs3P%wd@Y3+ zCpJCK`mxfJjgzO8)enFfvbd6Xk8q$xyK*fU#6K`4CSAd?9 z+ls!=71VuPE>AVw_Xv-A;XK5fZ6|V{&u=`-4bTXVPJ{mluNf>?1_ppK*ow2~2mEd(N+Cg3^`FwjrXX%TI-lsn(%VN6+W}!7a#6-rXA7D zpA8uC=N`cKbCgjrV5T$f=n5bUvK`$QyfD%QK8)5EpKgvWwUM%4$Vz8dTVEW2g6HVz z!s)!X*<6UbtyTo=+I>jwJs|TOULpDSbr3q1^;sM8!`FGAv)y-~Dmi-E41wzTJw3yE z7Z}%GE3~N`qpn-7It!sW4mRJL)BH^^#jgGh5-ABdK((?{$R)JE6?lN%9B(|%OKny0 zER=GVmgg(PBYMRk=y>sofu#W(DF{+u>bh1L$R$Nj~JXj zPwUGnLbpVEi3dJ9#O9tA8dbf!UIu48er|_O+I4&= zgU)zZVxo_6qIwAMt}%M}&-v9&pSPMi{7ze+ zm2~KtI6hD;UK4-x`q{6Rl_ucRZ+JT-@L+`cCQw+-_bsFhv7~2fjJ5$LsJvpe0v-Nr z3%hbYU_fU)O6%OqWC4PG=ZO~uPW#S2iGUgYgGY>y&$-zcZ{R~XsfbMu4*7K_qX}4! zo1BZ8e)OJJMG?!+@s=+=Fu?XPx*zNb0cONv@?*fnQ$wwPEd^$T@%3FT?i zt#YUj)jv2$U-KZsxqZHXv0=(?f~6j(v0c z`BHC6%ky^ZZE3RuN}Gdk-KdfEfkA`tcOxIXdxys)5O#sHKN$626K_)EVioQFjm52i zvwLHA-s#YywT&+x8V1aks!p-%8WhKrpVX#ZjHgp#MMJDcZQ%D_+1{K<2S2emH3%6N zJC=q-QgBK^oC;l~LbcM*E51Qq;Z(k&UZgZ2OaDf8f9tXvzDhRVnI`Rjpr;AYbxmi- z3H#QQ3L%W31V8QYbM<8W{=9#>Y1V!(rFG#R;S*E*9j}94wxIOu3^=8~l`P)wuWJ{y zKNqUzt=FG*7M}1o#e-YYob>-(w6_2#qkzX6@OPHK9GXjBUXXM?tg`zNeg_K}-;yS4 zefeNQ?UL+2$#7tUp9AJ|(3=d(>1mNKZW-b;!< z{pm1us|o4a`;}Kfsjj1Hc@)|Z;we2v&M-C7pZ@zIA zRflYN`mg$}3oQyGEtE;_#o9N#s6$}N&6jlQ1Nv7O`4H!5t2N|v*oUHBMg#O!7-tsq zpw-k*3BW~aHnN3EGN?;=3M|=OuYbELzAvy}4SDBi?DRo*>GBFlwS6-q2<_gQ!u>Sm z0V4vRTETF?s=6J;%$;4uPFR36d~g;&=G37wdjNSwT_ud$Ow< zdMOBsB@?s9GN!P-RY$KUoqF0mmBz|~u#S*&h=6+&P4BwoiA>U8!U6rfa~NWgk@*{p zKb>Rw0V3%4GVxEXfz|B)ru6$(u}YBt-3qt58FEd|7*s!0o?r<>>3HaiNW<<`@PnkY|2z_9q$5} zF;o0@MyEsN<@JP#z~T%kVx|pg-h*qVbCg+aM{~PIj`8QEK$ym_2%NhSkBuu|$wfVN zrpD1LU*|Su4o4Rd)?d4pzoQE&VuZBB)|V8zEnR6haQZ@7Em~UOxDpC($nAEza)$X> z7>|!xgyVXy18#u76}>v*TWX7|)7C0!)+-?C!c%x@C%U>aWgB*9BH&)twG8Y#>FXty zpX}abxHi@%iU|axvD#J(9LNotK>l@G&2iwy3$pHJ6?-AW8Jk25{!+{Wj#3jnQbZeu zYzebMv=bh|-V898c|Y|i|2g&geFUYL3Si^HlRt-x_w?vP`K~~iGsx^+#^wH#2gJvt zUc1cKt%^dh4f&n$3@x5RjhyHBxJK@HPINq`_T5rvdU*>a^>y5SpYF&AlxtV#7@Ua2 zeJFD2u2h#gIKDE;tU7MFt=+9aCiWG;ng02^#ltiJM9c7fupuyGE1K!wu-|J!dQ_H7 zq1x52pu>Hz))(fk8TyFAktiYwIsIw>~)o zStoiMDn@ni<@15@@82MQ#ludX17&qR^eX1mabkkh}sbNr!1+17mfBW}YM_on^`sk$xum_m6uU#^a#C4N3NKM&04a>4a#PKen-;S2 zh7d7;$5xWzwY11|&Y47wQ7tx^lonBSv zHRGjn-%8QvhZ7Twf&t!!_CJu(0^lb5{G>A>&Vk|4y=4>!U$&;CfHn6?Ckda%X!zPd zFpN(N-39LkMSiUIn-D2M(xGDN-+rIC*{I~bhi9$-v3_81NZ1KT1-C<5=<05HNrv`K zfzzYt2P+^O-em^H^UL@pa`Gv_hjR{^gI3eXy(^@?Heba>dVMemscdtg-P$ z5f#r3JGJG)rm8Ka_=K|{@k|JncosxzgXmRrMrrrz%K_^?|tF_Ji|G{gebkdG{e;+bYGzUljA6 zX?^9{CW=3+&iApCf}b2i^$a^NPw$8Gm!0(g<`{Q(>Pbojjq{O*4~gSxe>!1Pz*e=a z|Ct`B-@^|@JO-*0%bp*ftql=gKCS`7PBX>`d^|1I>#Bl50ZY8y#c)mhLEnJETsI>9 zrqib?Giz9(xrqbEzDeQSgLgY$+}d$yJ5I!g;B9*%tuxt!n5yTHLYd;S3on61(B>@X zcZGmJ*8rs`QD!focmTQ0acPnyl z90S!7B%7jgICkU$hV#5`bsfN8(8I$}J5?aJ``M=Psvg0=jWKyoPjQU$0ITxZliZwz zm?Mnh0C;rGi(v$f3~=;r_e|i)w-co=+H)UP{avZzPuhTdP72PYBOT&uLF0UG)7!;} z7Uk$=Xm0EKT~%BDeQ=cCDd4fEPLbDZ1s$h(YN7XM_;e~iGSoY892J)i=HeNJKvW8$ zh!M%`_2^G-4|pc5I?wwv7IU%E=?X98LTo@)__n_2KG{E~{N2Rj#7S{m@#5Mo3i1ys zXdkW!^>lD{!pQ@t!8NY(GTujdr(><1es_O5^phll8_=cPV#y3S>-3yN&?AcM#C-in zV8!9?_CiSq*0GcG#GQ8dulk*n2mGkU zY$?SVX$2+;rS3xB8o2yRZ*mL#Jwnpw09^k=960tP8sgV`DpS~*u8H{8wVzUFJ9pkq z_%pmSU~tD4t?HxxVidMuZY&ZjsSiJf?_aB}di_kF+P0O)3J7D{@hfH`PBde4|7Xyh zImm4bxtMjwHJtbf03eek0jQtF50qD#?dGzmdnzEapBD z+lr70#-4<=b8hdBDlOmZeSp)uF{IYd5Qxaj6-WDx;AO?Jie{W0Ypw#~kQndO_>!_zKOc7Y3r$0iTvf>iMqb2xgB(#qcLy1$7+l4@o4bR2z&=cD^ zi{0)D(#=KQ|or2d)okqQ+}9?-=mz$&lHWyW`DV8Bsxq2 zaG~aK=SYK4zd)K_gzP%>niPNg#K3FH8`#IDJc%#2o*EKXv2V+bs45;}UqKk<;9BIq zt6hiCc<}nK8UB(#8M@!>V3Br$KW)CKJ>KXEDG6Wu1cMPyt0vJ9k4UoHjwML)KCfMO zqs|kgg!j-hkv|Fa>N?;n*5!26CY}um^PY{+rDO^&gq27lSm{gatHu*cisJ85m5NYn zJrJ|wI+lr6RF^Q)=q)_}V_@@hBGcMm&Dci6f`Y?th)aSd+iW)M8pVw4V;+muCXt>X zx)8o62RgJyi?UTGKp_*CXGf@i;Al4i?r#?QRp0H<3KulFtZy&<8N~y;W=gzYU+D`e z`OT3DFVzCkpT`dFahnqlG1QVkD=b8QM_2`knP6CL6(Xsd_sW)6WPx67m5_i{3b-w* z!0PoFLrsVao0K(}{Aozj6RzRCl)UYH-jv=;2Cs(B)!k*|qNoMtyWRwArMzMR$sLse)f@|q#;+XEQdfbPLR zFY+8k*eO4A{3=8vJz!Fy@%GM-r%Y8=3G?tZ+h3v~ryIqkil&`msof=<4o9bZxzRVU zNtw3z|ZLlo!lqkv*MqZ_T*NEYI9-S3977z8_ zlVzf+Q85AKPIz^2Pwn9Y2^$5iD_YkD76i2-Y%1f-Wy!Btp)lHGvRI?EPO@zFW473N zbXJ_l{(WXg(*i)K89-xt5HC>)YqLG8_;&7P2)QHtSIB=(zTDA=Rh_zbA`3Z?&#tc% z^7`ZZ1?`~3R%AQW=Ys^7tj*Yl(fo948K=~blnKyB?vU*UlV&SPuSxSD2Wf-DeNS_T zi*J*YNS-|HIx&|j?vKTF%NgV(0m>xlEl0}@(I_6%0&Gmu-Pc_r($O0NBj8TQ=#awM z4Ot-i`O2YIE(Sd9UU_IxdY|;m$ zjUHE?vIPD7>H?z(JG3Y+V#wz*Z<+LbZ*iS_DOeaLeMOERS8(DnwMsL8kMJ^#BAz5) z7rvXZGA)NSY6y$mw{TtBamZvJ7~_DN`CDh9W-BjRX)wNu__cmn@8Q~xlZ8~?5uB~V z+5U@ne@71)AS)Hq2K8;a_KsrCz#kg7N$fYd^t2wnWJ6Zub#c6*P_MKJXD@+gkHZem{IR&F|fPHke{b1`5~#8000 z`+J&@7q*dilrSQ@v{sl&UV=x}Ss8rX+jfC2=~$xdU}VGEtUX{A!uAw@9wHiVlKr6v zDA-Fpa^s&tTzdbc+mESt7~$0;x&QNN)4Y3aaGfqWjpwXP%m%s-nJLzvm+85>uI|+6 zpRa}!+spaP#>;T1Lx{D`eabnV9b%5#W?5DEHAHf!^I&Fbam4}9084~)VmpLqC5H5q zZx}9%7lW{PHgI8%d6?ixb9-LqNu=>3H0Bx9e)EqW_g{|A_E+%D1FGTbr?t^9mPX8CIyKeGuIB>CNX4q$cMi(8h1s`i%5xd6|Rd??zFgTXfwwgZuhrjX}u6x@GeyLB+y|gV6;E>WpKur)Vi>$l6SyOi zwgr7r;1w~oP{Z?t39J9zFg^}3j7p+*%Uj5dRVTcK#IGIHp#zDP3o?Lan;2WAh*ttS0E^Xqgf&9pO zqCVGnkI*}x$sh&u4bMO922$>5brJF^Ms4jJ(Wj06a}ETucs+j&g#@Z|3D!(%P}BnE z`V?io$W3BZMG~o$6EY~;#33Bxe9j?sX#z|O;{0@fxq?l3%s#AZX#co-X!J9_I^N(F zQt$G^(G0d{KXw&#kfEW>yPAwu>WNe7;rGrXWOyocM#Dm}H`1kmXXB(z^R6`Ax(D?ZeDcKVUiLEfi?jjC&1;C?2OcNz?d)%yV=3doypb+I-;`&EES2MD|}{ zf(wYCSdz~PlVr-dTq{jcg>NugfL)Dv+|IcI>+cR!T0lG`T^o3>8&xI^;-pO^BcuT1 z!HnK4^}BbwpRm$CZ1ltC;*v9?BZn;?7wYii!Tc)FONy(0AaF9O^4P;a>$|I9Qp|Gw z$A0a0oCu=O0M$|}p%?e4cM)-f<(&%cUXqRgb;uNGroOga5lGvYdKt^`z`V;#yLLvN zzD5sZ)+&hTHJ*Qo+6>uSFdy^!sQY@ADARTCLXkYtJ-I}Qy_%+Dl~DMa#^zwaa}l`~ z(2WdZ^}9u2sX0=5S}ZM%~8&4yIR771hanEfpbllGpBOyO@|2I80w(^KJq+1h9Vj3(e0O+OB81DkEL+ zA55EK(LJbLvN7{V6;d|h_@z!Q;pEA;pf*lr6Gu)LJj?el^OBvuptqWbETRL-DCwfE z1r~DvwmC3-!Nl5lxY|p-VR7}H->p<&YdzY$>w5^GG|FHZ(?{pr%uuohrjK88_i;Y&S{Px=WuD}5O=pEe>WK{e8lE6+j zP=bd$2k}Y*KMhQ(^H9pFd^P<^j$`PcP|`^jD*uXEb&|)`HBa^BRjs7BaSt_I6GvN= zE$$I1mvD`rbF-e;l$pI6mTwp7vg2;XG|mck*~I@*hOTV+!8YH|rR_RbD8;i6d7_Tf zd1Tp>H)w9OgYC`M6XDer{APNsHiG_myl$>?zihWAbtL4l3I%B1M$BZUM@1`Wcbu`= zbv`4*R`e%z%1@~<2D!2}@vuicCAi_Fl`=S`L)*S6BisK>=d>q{JA2R8;ncEoCk0%- zr|~Wi?~qR1IwwSfc2~Bw%c=kLI*b3&RveL;1XXN-4%Iwfu2g9VgJ-`WkISkI-`F~S z2v*?^fupzylFXKuiG)rTPMGb*M286#lP%w)Sub_&)Z<0m^n}en`XLhV4fZq zbW#f!1MDSKm1GN?w@f{ZEu?`XtUIX%B{hBrC{0tYkfkvOy{p95)Rzj0<#xXH=U(<010dY*@E2lua2$-|Kc8 zxc(WYCW8PG1<7je90ce3?A#}H@S-f_#vvsZmGCU9Xoys`C4$8EyZRH7qluVsvR$gS zgtsBs+Z#i2WrbL!dED{!2y5=a$>{C`y3o7FkAq;#s<%!PD|HJb+$jFx3+3RBZ?(IB z1I2sNY||>i)`7COzp(9fOo9vI?F`DIs$dlw7$$XVI2~;JXpd;Wlvvk#^{Mxu#j-P4 zZL`|wY5&M?#GC#Bp`9Sto)Ui!wEzz(!Hl_DTfjd~)+4c?RFJ*!-j^=SiC@y6aQUX?n6-6bRrJLX_tsrOS1{i2;0A-$F9Ruy_Ql( zA|(ev8ZgfhsA<+Id-Y57{YwD7gF;~I)qd<45#hS8;l~v|lkS*rs``@mF2TQ(pvQeQ zy;{Ud^c3<;6SYN5fUU+k`}NPk7E`5<>vGEbnE0w~_Qn-wZ|-_Oj&WWOBJ4@#+vvzj z(=$D=I@nO5_gi9i=BXnQ@eK{hI~iBp4v|*_oy^)<%e7w#$Ykk#v_A-?Nd_`elYDSC zN{q*Mf^_$u;K2MFq4sFE*I+7b=Q7oLG8)r~8C&WSD~f8I?ncm`?)c1SQwLdyU{i>f zjVq#6mvMCD`KjgF3vxlMFUaYy?3ws zFJv#WzyOp1gbh#MhtuiacC@@7vgS1)3(omT8Ym%!uXv9S5yWtrhWbTq%#*xO4{iHxY^!&Uv= z!Z(}BK1>%ygs+$~H`b3x8!sAyCEqj*NZB$UTxSZ*GtzvNfli+w^TL*@+0*Uh6c!d!~9;+fU zwgq9Q{u5r5-r!~oOt@77FR>Vq*G6f}8mXd?!4uT%l7Rtt)`wRppwWT>YP!*ATnGB% zKNpSe-T9veu9IZ(ubh7yto|ZgXO+dK?#ll#7RHLaxjAgze;M-s@8kpi?;ja!y}G^3 zy)NLNG%WG&2aU_auAcsQ1bJ1~fw$K$e@_aeLc`Zk`tI-G6WMaGX}p|rEh9N!wMwq* zb9ZsqQHNlw6OxN{Q&+-+kJFO^f>psL6}M6}zH7Z_p+CcVK#rr;>0!}%e5-eyO%ipP z8YEQ^ci%0KE9XgbDdB1GzOB*jOlVVpip@OX7x;tHfCMQ6r$UJ=?Gy0+ym80__)4le zcP5B`G&Twq%{S~Ca$f_b<4a{PzzZ!g9wwZYGeiuMuL)LVfGvgV zs@N8)nscJKo&U;7CBtMWlz+>IN7L5G4;gl58Ps<`{jkN{n_8DYEcC>bXG#lj*3I$d zKRfQtjlkph&!jA3y0&uyf}ca@p4GpgxfxuNj;L{Ok2tb`eoDPw!1i9(k^_t@9jMel z&~5*XT$V6#ZWgthI~#*H%$&1L9w1_A))ODt_wU1cDMRS@IQS0}+GJb%e&?9^t+?zx zu}K-ja+wsw5xPJ}60izxIxcKzMGTi-#mIQe6v z~uVdICOTuSR&%g|#@^bP#h3;n zeiuj$i*9;d&hJ58asQcKCzKzqq2|122x;Ho)wQzKfZirW0Z+sf-e-U5C+!g$70NGT zch-j_20cjL(3x*Z8eNy=)K3t;hTK2@r8Ut5|F_`W1)~IzdwT&b2E8k>jPruc?|;Uy zy|)FPZ&=xav9+F!`{uvx4~V_D2ZOvHtg`kuk(5B~6Hm!? zJbl*AbLXMk`=5%x{2d53e&hxIKF*o1elidSz4cd7yz@9O%L{)i`fYOzE8=CRna<VoDU}iDl zU1R)id+Ao^D*tq|R{J;Q7BFqRH7Tpjkze1S?t@zHL6CnqxfR3s=8PtlS(V~Wi;8xZ z*ycD7WxI_<%w{jNF=C!~+2qdtEW*f0(3gkAPyDF1a@s`pZ*NFhd`bEd;rbxMoQQNH zzJ|igd*WYDkwihrEa9fDgJ8a3j^g01Ex!wxvUiBKF}a$^!=k!}{8J>NWf!ERM!xfu zy=FXov_;QoP3V(+L=wBy;kF=xBAI-Gq|3R$ax~bv@;jS+f$Vze*w+48Pf$7KJsY#g zGp?M{e6%ez1D{$tbIj-NTuf!fvFD2_{>0?@L!YX*le5AmYek%N<1+){E2r6R8-ybz z<=J;zX4piA_lxY_$SgiD+D!tzaHrGx2`&14jcXcf-=O63N~24s*7gpcS%7pnU_JnG z7{m!;FRGdNZc6pjsj1}B-kG0W1CGVOc0%2b;B5HZmKlG zS0n^yqwX7=wGdZ1p{129Y={jmj?2>A>7<#BxOq7n&PdH}L(|kP*PaBB+$bfSbI|4Z zz%D5U-);Wf`c6|cK#LgjTz!SB za^$MM58OUN+<+j>;Mj9@xqkSsLHCzkS`qTRiJKHnMC;hPY+=(}1xxK%ygTsz*px%*)`Ufr+EtvK5* z{mkPjv()ql{bho&eLt)xLn_&W651)(x=b$iX)8W( z<~=BheX*Xu(()un9v6gEs6jde)yJ%7<&SR&TMxAh`DjFhPD3AtUUQ%AzjJ8E9Dy}< zEwk8DemN{lu0$)2?UiC$b}T<<^+ZA5%uJLtoSs?2^s6OOnM5(N;g#X125bDdl|3e& zrq1j&IVS?sbMaaXk2rTJ6uD!>YnLVE_mUCzd>H!r^c3mCQD6i~l?7fP=konfEll$G zZRh=l^p@GO!Gj3gQf3VodeW+_S!m%^=&M)awG*D(D#T(<*>UmU`$k8uSL4z<$mc`b zk|JAEQT;)=^(Nlcf!DQ??>%rJ!f0js7o~3nt?AI?LGHUn{iF30yIbO+=XK=?51A8L zH+P!%3_oR(eLjO18{!>I>VK^WSvtk zIqXw=OPno`3+5{b?^DLTU%g$gg*HZ6YwT!423{Ufz+!n)i!F;ks>A!4#tlbq6zv(3 zthuAt|$2qmg{x)=$9i%zgM85MJ zylkZUx31}iIz$?s_s~T2+JOk;b-#X+RGdA7_$()<{qJq;jL`EL-Y9-@TSW*u*|6*} zfj6LBc}#p2?ZIUSrXanurl4cZ{A#NxYh+XUeX6{pcIcsq7ZJf;B}M*lH4>Yz*<+Zg zs(_LIk^HD7$#e-tvaNTnH2nCZf*cU-ThFQ53&s-FcADHuV7%Xv_zkIii!ideK#jBS zB8xvuBI!jjE=V@$>RBOq>FUSfP{kkJ+1wq*5gbb_fP$+zd4 zBbbPw z2PiydsKyy&Tn!qG2{x#i z?nxcB*W6#yU5Dud_}MNX)samaWrFjSrF||2<`;;S=ie0eU6y%Lr8InU@wx^4a)Iid zNWA*NoRipB;w}Gj@VZP4Q8n}MFNf3cS{6_E))`_!(o;W!bLuJM(c5bWHy{VzakFrv z%z3~#KXMfAICN*{)a8_(zi2Rep+@*bL>6Y7RTnJfz`x4EB`~jss=xiMuj#m>I~}0y z#V@0!)Y~A^*ZG7Iu%5R9HsWe3ion!L4Y^51A!`WhcE}H#$#rb>d@^d^RC<)SFTb8i zI68p|@J;~>1xbA_=j>|+m!D!C7eJ7O)0;j9KBE0!`36-H>5K^l71`_~<@(6XIk-j2 zw{rf=nmt=XN`-S8v_&)VQvFaohkt)MwQ2q3sJ){~_e3gUV{!?E53j$t=H#DXy>sg2 z;9*PMa#9L3MHWAEr{`qV%=vTbGw9co+ve6ZwTU!ny_ejp% z2?D$KI12tMA1|ec31_y_pc#u5oLJtgF8a`BOfX8BA=bC(%DN6_brfbDLMDgBVgE3} zG9CET@}AM94`90e*t>TvgEdJ%=J@ByqsQ>(wu}VG-zl|s&qDZxHJ3rmTxfHT`L|me zK?k9AOmE8gB7wkgze*jwxs4t`abbWEw+@e$_66a!<0v~T~S z5_Bem7U6v{r>~dGTErHO0cTWbhVOXi%9t2Tj&k0l;=OZ?q6WyauDh3OBl-g9(G_GNEj*&Z2!1^!1574sEKky^ZFl zf5kmhDbxv;Du5oUy@+|CQ$>wattQsZ2spw#wB^k9+kn6}|hy8YdujzBB>6G=;2q6sTvxIq?;{q3On=4ZH4{(wW&%OMi8{!>? zpH!;U0Ue>EhODhhlp!rkA^&fP^uSt*Q2K?_iqwtG`K%w4T#%v&YsY}~yHON}PI5F4 z;%<$za1pai?;aMl!&0^m%Qi#b4vH{sLrc5glRQ8=o=?-g6{2r9*ZSf6^4xv8zjUQAtBu*NQk*r_j`rN1Tv8lh%yqhcI;Nh!r0ceJunM?EPo_Q z*Em(x)6Y$O&icH)nA_dgx1xG-s=J^5dVl`EU;OUuXJ=>sKl}6eFQ(|K{cO`ThOVs+Z;CyN|y<`+B&nYd-w`mv!lf&(Ds-aPu^O^7~KPfBx*`_kX#* zdwc#9zyH-fjraI<)G{xBRP z)7w>OX<2s$X;TO01Rt#9tKm4@++3YsFFwB*UcA1!{ZBxQdD&3qs$qFucMB7Iu#P0{ zDD_gS@6_A`AFLxuI~@369ZA~Zzz6F{(hdhcSVyXRn$k$z$++!p~iHolE+=vg> zk?Jm6_?`$5WZ&~Jz4j;Z!8&s19%{VKI!kCLs}I(Z>sX()7i`gp z57v?Eo+cQtS$B>2U>#Z3QAir`!8)?6!;WtS>u5(evuX~uXsf;ztmBjO<9N1y@x}Sg z<>L2W-(LLd#p`kQ#dycv`#*$1n09pxrd>T*f9hJt3f8fze;$U_$&MPU57x2LwT{Bm zh!57W(zOl;K3K=Z_D)uHkd?;jgLSNA9ggaQb*y9^4t%hVm8`>o57x1&M?66`azD~d z+qQ2F>sa?6{z%IqR(A@V>!(ZCu#QiMi>u4W+sFIG-)@h?{X+;?^G>E^J$##-I6c%e zp%Z+tj=#Pej^CDNnj+&{^z%*)+G1UiPVm7xzP$SHa2XoiSc++1PuBPAYiV75Y=RHg z(HPGQI@U6`cwOWs_+TCDI>KY&n)lX-57yDHYt9>twQR%(>sZS=N}(9>!8+O1Ly>HR6MH^p|WzA$78! zMtrc2jjnYRo<@AIj*YCtfe+TP(X|c-K3GS4YPB>Hcd`xMEv#ePe;5~@b=$Xvb!_Xw zVPtq_e6WtK?yxF_)5jp^x^-+}9a~w410SqoE9-FJgLQ0mt;2y2*0Gg!IPk$bwz3Wf zK3K)7gAhaKMz*3n)+R2qpp@o29f3-;r_9jxQC+&eatKs;~_#}C` z*(%zUb3OX)bLmhI=_dGK9qloYXvjY9ud(`I9lOf?|`Ade6Wt4tiyp1*0GayIPk$b+LNrMk+_p>+qZ{x?E4Sn!n5u=_OOnx z&hH-{kHd>s=kMd<%k4#I{dG)>b_Kj!#~#-4^6~oN>fQL_;mgI#uC40l}6%Dgw~Nx@WDFvvW`+TMtrc2y{yB557x1l zbvW?BI`(Z(Q(6f-83!+{W3Ou+rDkmS4zP|lx3`z?7jK4#a>735QD1o=wVw6tht2(k zoPE!GfOQ;nhn1uHU>yhDVdcOF>o~|d9Qa@z?Q&dcB<^I~b-+6Ms}JI`Tl5nJ{jGhI z-AQ})xcKb&U>yfphokyn9S2#58DCn$I>uG8hwW4EI|R-( z>qtvj$GA%PjJuAsgmsLogwKo*){&Odj z9cd};u;YVu^gvn--VX#gj3?VZSVy;I;d>q*tRuB#U*UTe--&E;g_9R*9I|~~AH2*a zPf6Bc$4Bc(vJN{wT8F~P9fOzFp>T2sK3a#u$sPD;9SSFR;G=c4Y}L|A*vWX{LhDdC zd8rvAK3a#u$?f=}Eg_9R*+~W0hlWiZZL*e8Oe6$XQlRNOyI(kS{X(sAq9K5s+g_D<( zvEgGjc~UsJ10StprE4Ab>Z5fioZL};v<`)nJMhsu6i)8IN9#~HxdR`qL*e8Oe6$XQ zlRNOyIuuUsz(?y)IJq4kv&mC?Sx#vs>|~QRW|OD2tYa)1BR*QkT6b94@zFY31b5hh zTRLv6K3a#u$sPD;9SSFR;G=aYoZNwr)}e562R>Sd!pR-@XdMbCx8q|rc~UsJ10Sz- zD4g7ZkJhn~b&ULMhs_&Whr-Dn)ko`4IJpBKt)so5sk9PyG7er^hr-EA%^2~~I@+t* z!&aUHAFV^-GeT8F~P9r$P+3MY5qqje~p+<}kQp>T3LK4z0Ag_Aq*(K>dz!)oMbJ7Kk>bts(N zQGK)yg_Aq*(K-}P?!ZUuP&l~*AFV^-rgnk9UrsFlfuaz z_-Gw_S;xrFW*y8XPkY^AWyeSBP&m0`@X|V3IB01l>|`9gv<`)nmzpu+qje~p+<}kQ zp>T2sK3a#u$sPD;9SSG6<6|~?I`pk(X(#B^A#kq0{*c+^N#W$BVhF9To8aTM4uz9D z@Xrgnk10StJ;p7f{v<`)nJMhsu6i)8IN9#~HxdR`qL*e9Re3{wgfijJ=IpV0cc2|idyRyMf#z@ASVvYixug1E z9a-7r4t%hVyxf(aN1u#)URXz7?xUkC{Ws!+b!26e+wn1*JS&^rfe&|BWo45)@Xrghi10StJ+2js;I< z+<}kQp=@#oK3a#e$sPD;9m*zm;G=aYo7{nq)}d^22R>SdvdJCzXdTKXci^LSD4X1Y zkJh1Vayvd|lV@dSdvdJCzXdTKXci^LSD4X1YkJh1VatA(I zhqB2X_-Gx Date: Tue, 17 Jun 2025 13:48:00 +0200 Subject: [PATCH 22/24] removing gh non-working test --- raster_loader/tests/bigquery/test_cli.py | 27 ------------------ raster_loader/tests/fixtures/cog_rat.tif | Bin 51058 -> 0 bytes .../tests/fixtures/cog_rat.tif.vat.dbf | Bin 81890 -> 0 bytes 3 files changed, 27 deletions(-) delete mode 100644 raster_loader/tests/fixtures/cog_rat.tif delete mode 100644 raster_loader/tests/fixtures/cog_rat.tif.vat.dbf diff --git a/raster_loader/tests/bigquery/test_cli.py b/raster_loader/tests/bigquery/test_cli.py index 014ee9e..bb5d5b8 100644 --- a/raster_loader/tests/bigquery/test_cli.py +++ b/raster_loader/tests/bigquery/test_cli.py @@ -10,7 +10,6 @@ here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) fixtures = os.path.join(here, "fixtures") tiff = os.path.join(fixtures, "mosaic_cog.tif") -tiff_with_rat = os.path.join(fixtures, "cog_rat.tif") @patch("raster_loader.cli.bigquery.BigQueryConnection.upload_raster", return_value=None) @@ -309,32 +308,6 @@ def test_bigquery_describe(*args, **kwargs): assert result.exit_code == 0 -@patch( - "raster_loader.cli.bigquery.BigQueryConnection.upload_records", return_value=None -) -@patch("raster_loader.cli.bigquery.BigQueryConnection.__init__", return_value=None) -def test_bigquery_upload_rat_valuelabels_mode_auto_default(*args, **kwargs): - runner = CliRunner() - result = runner.invoke( - main, - [ - "bigquery", - "upload", - "--file_path", - f"{tiff_with_rat}", - "--project", - "project", - "--dataset", - "dataset", - "--table", - "table", - ], - ) - assert result.exit_code == 1 - assert "Selected RAT column for Values: [0: Value]" in result.output - assert "Selected RAT column for Labels: [1: Class]" in result.output - - @patch( "raster_loader.cli.bigquery.BigQueryConnection.upload_records", return_value=None ) diff --git a/raster_loader/tests/fixtures/cog_rat.tif b/raster_loader/tests/fixtures/cog_rat.tif deleted file mode 100644 index ba49903e3793a369968ddc4f282e2cf8410f84d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51058 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 Date: Tue, 8 Jul 2025 12:31:38 +0200 Subject: [PATCH 23/24] remove RAT support --- .github/workflows/ci.yml | 2 - docker/raster_loader/Dockerfile | 3 +- raster_loader/cli/bigquery.py | 16 +--- raster_loader/cli/databricks.py | 16 +--- raster_loader/cli/snowflake.py | 16 +--- raster_loader/io/bigquery.py | 4 +- raster_loader/io/common.py | 7 +- raster_loader/io/databricks.py | 4 +- raster_loader/io/snowflake.py | 4 +- raster_loader/lib/valuelabels.py | 160 +------------------------------ setup.cfg | 3 +- 11 files changed, 16 insertions(+), 219 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2503a7..8ff489c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,6 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 - - name: Install GDAL dependencies - run: sudo apt install -y libgdal-dev - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: diff --git a/docker/raster_loader/Dockerfile b/docker/raster_loader/Dockerfile index dbc995b..34165e6 100644 --- a/docker/raster_loader/Dockerfile +++ b/docker/raster_loader/Dockerfile @@ -1,4 +1,4 @@ -FROM osgeo/gdal:ubuntu-small-3.6.0 +FROM osgeo/gdal:3.2.0 ENV HOMEAPP=/code ENV PATH=$PATH:$HOMEAPP/.local/bin @@ -18,7 +18,6 @@ RUN apt-get update \ postgresql-client \ wget \ python3-pip \ - libgdal-dev \ && apt-get clean -y && rm -rf /var/lib/apt/lists/* # Using a non-privileged user to own our code diff --git a/raster_loader/cli/bigquery.py b/raster_loader/cli/bigquery.py index 8451a84..8ab20f7 100644 --- a/raster_loader/cli/bigquery.py +++ b/raster_loader/cli/bigquery.py @@ -104,26 +104,16 @@ def bigquery(args=None): ) @click.option( "--band-valuelabels", - help="Custom data for valuelabels in JSON format, or 'None' to use the RAT if present. " + help="Custom data for valuelabels in JSON format, or 'None'. " "i.e: '{: , : , ...}'. " "Could repeat --band-valuelabels to specify multiple bands data. " "They will be considered in the order they appear in the file. " - "Note that you can set any value to 'None' to use the RAT for the corresponding band. " - "Also see --rat-valuelabels-mode parameter.", + "Note that you can set any value to 'None' to omit valuelabels for that band. ", type=str, default=[], multiple=True, callback=validate_band_valuelabels, ) -@click.option( - "--rat-valuelabels-mode", - help="The Raster Attribute Table (RAT) will be used for valuelabels if it's present. " - "If 'auto' (default), two columns will be chosen automatically for " - "Values and Labels based on their content. " - "If 'interactive', the user will be prompted to select the columns.", - type=click.Choice(["auto", "interactive"]), - default="auto", -) @catch_exception() def upload( file_path, @@ -143,7 +133,6 @@ def upload( basic_stats=False, compression_level=6, band_valuelabels=[], - rat_valuelabels_mode="auto", ): from raster_loader.io.common import ( get_number_of_blocks, @@ -220,7 +209,6 @@ def upload( compress=compress, compression_level=compression_level, band_valuelabels=band_valuelabels, - rat_valuelabels_mode=rat_valuelabels_mode, ) click.echo("Raster file uploaded to Google BigQuery") diff --git a/raster_loader/cli/databricks.py b/raster_loader/cli/databricks.py index 6e4c877..95c64de 100644 --- a/raster_loader/cli/databricks.py +++ b/raster_loader/cli/databricks.py @@ -118,26 +118,16 @@ def databricks(args=None): ) @click.option( "--band-valuelabels", - help="Custom data for valuelabels in JSON format, or 'None' to use the RAT if present. " + help="Custom data for valuelabels in JSON format, or 'None'. " "i.e: '{: , : , ...}'. " "Could repeat --band-valuelabels to specify multiple bands data. " "They will be considered in the order they appear in the file. " - "Note that you can set any value to 'None' to use the RAT for the corresponding band. " - "Also see --rat-valuelabels-mode parameter.", + "Note that you can set any value to 'None' to omit valuelabels for that band. ", type=str, default=[], multiple=True, callback=validate_band_valuelabels, ) -@click.option( - "--rat-valuelabels-mode", - help="The Raster Attribute Table (RAT) will be used for valuelabels if it's present. " - "If 'auto' (default), two columns will be chosen automatically for " - "Values and Labels based on their content. " - "If 'interactive', the user will be prompted to select the columns.", - type=click.Choice(["auto", "interactive"]), - default="auto", -) @catch_exception() def upload( server_hostname, @@ -160,7 +150,6 @@ def upload( basic_stats=False, compression_level=6, band_valuelabels=[], - rat_valuelabels_mode="auto", ): from raster_loader.io.common import ( get_number_of_blocks, @@ -239,7 +228,6 @@ def upload( compress=compress, compression_level=compression_level, band_valuelabels=band_valuelabels, - rat_valuelabels_mode=rat_valuelabels_mode, ) click.echo("Raster file uploaded to Databricks") diff --git a/raster_loader/cli/snowflake.py b/raster_loader/cli/snowflake.py index 0ab294b..d91af69 100644 --- a/raster_loader/cli/snowflake.py +++ b/raster_loader/cli/snowflake.py @@ -126,26 +126,16 @@ def snowflake(args=None): ) @click.option( "--band-valuelabels", - help="Custom data for valuelabels in JSON format, or 'None' to use the RAT if present. " + help="Custom data for valuelabels in JSON format, or 'None'. " "i.e: '{: , : , ...}'. " "Could repeat --band-valuelabels to specify multiple bands data. " "They will be considered in the order they appear in the file. " - "Note that you can set any value to 'None' to use the RAT for the corresponding band. " - "Also see --rat-valuelabels-mode parameter.", + "Note that you can set any value to 'None' to omit valuelabels for that band. ", type=str, default=[], multiple=True, callback=validate_band_valuelabels, ) -@click.option( - "--rat-valuelabels-mode", - help="The Raster Attribute Table (RAT) will be used for valuelabels if it's present. " - "If 'auto' (default), two columns will be chosen automatically for " - "Values and Labels based on their content. " - "If 'interactive', the user will be prompted to select the columns.", - type=click.Choice(["auto", "interactive"]), - default="auto", -) @catch_exception() def upload( account, @@ -172,7 +162,6 @@ def upload( basic_stats=False, compression_level=6, band_valuelabels=[], - rat_valuelabels_mode="auto", ): from raster_loader.io.common import ( get_number_of_blocks, @@ -281,7 +270,6 @@ def upload( compress=compress, compression_level=compression_level, band_valuelabels=band_valuelabels, - rat_valuelabels_mode=rat_valuelabels_mode, ) click.echo("Raster file uploaded to Snowflake") diff --git a/raster_loader/io/bigquery.py b/raster_loader/io/bigquery.py index 8bfa032..d87413d 100644 --- a/raster_loader/io/bigquery.py +++ b/raster_loader/io/bigquery.py @@ -18,7 +18,7 @@ update_metadata, ) -from typing import Dict, Iterable, List, Literal, Tuple +from typing import Dict, Iterable, List, Tuple from functools import partial try: @@ -112,7 +112,6 @@ def upload_raster( compress: bool = False, compression_level: int = 6, band_valuelabels: List[Dict[int, str]] = [], - rat_valuelabels_mode: Literal["auto", "interactive"] = "auto", ): """Write a raster file to a BigQuery table.""" print("Loading raster file to BigQuery...") @@ -142,7 +141,6 @@ def upload_raster( basic_stats, compress=compress, band_valuelabels=band_valuelabels, - rat_valuelabels_mode=rat_valuelabels_mode, ) overviews_records_gen = rasterio_overview_to_records( diff --git a/raster_loader/io/common.py b/raster_loader/io/common.py index 7d97116..fb92037 100644 --- a/raster_loader/io/common.py +++ b/raster_loader/io/common.py @@ -7,7 +7,7 @@ from raster_loader._version import __version__ from collections import Counter -from typing import Dict, Callable, Iterable, List, Literal, Tuple, Union +from typing import Dict, Callable, Iterable, List, Tuple, Union from affine import Affine from shapely import wkt # Can not use directly from shapely.wkt from raster_loader.lib.valuelabels import get_band_valuelabels @@ -213,7 +213,6 @@ def rasterio_metadata( basic_stats: bool = False, compress: bool = False, band_valuelabels: List[Dict[int, str]] = [], - rat_valuelabels_mode: Literal["auto", "interactive"] = "auto", ): """Open a raster file with rasterio.""" raster_info = rio_cogeo.cog_info(file_path).dict() @@ -289,9 +288,7 @@ def rasterio_metadata( "name": band_field_name(band_name, band, band_rename_function), "colorinterp": band_colorinterp, "colortable": get_color_table(raster_dataset, band), - "valuelabels": get_band_valuelabels( - file_path, band, band_valuelabels, rat_valuelabels_mode - ), + "valuelabels": get_band_valuelabels(file_path, band, band_valuelabels), "stats": stats, "nodata": band_nodata, } diff --git a/raster_loader/io/databricks.py b/raster_loader/io/databricks.py index ee4c1a7..a2772fd 100644 --- a/raster_loader/io/databricks.py +++ b/raster_loader/io/databricks.py @@ -18,7 +18,7 @@ update_metadata, ) -from typing import Dict, Iterable, List, Literal, Tuple +from typing import Dict, Iterable, List, Tuple try: from databricks.connect import DatabricksSession @@ -145,7 +145,6 @@ def upload_raster( compress: bool = False, compression_level: int = 6, band_valuelabels: List[Dict[int, str]] = [], - rat_valuelabels_mode: Literal["auto", "interactive"] = "auto", ): """Write a raster file to a Databricks table.""" # Wait for cluster to be ready before starting the upload @@ -180,7 +179,6 @@ def upload_raster( basic_stats, compress=compress, band_valuelabels=band_valuelabels, - rat_valuelabels_mode=rat_valuelabels_mode, ) overviews_records_gen = rasterio_overview_to_records( diff --git a/raster_loader/io/snowflake.py b/raster_loader/io/snowflake.py index 893321a..04699b6 100644 --- a/raster_loader/io/snowflake.py +++ b/raster_loader/io/snowflake.py @@ -3,7 +3,7 @@ import pandas as pd from itertools import chain -from typing import Dict, Iterable, List, Literal, Tuple +from typing import Dict, Iterable, List, Tuple from raster_loader.lib.errors import ( IncompatibleRasterException, @@ -209,7 +209,6 @@ def upload_raster( compress: bool = False, compression_level: int = 6, band_valuelabels: List[Dict[int, str]] = [], - rat_valuelabels_mode: Literal["auto", "interactive"] = "auto", ) -> bool: """Write a raster file to a Snowflake table.""" @@ -246,7 +245,6 @@ def band_rename_function(x): basic_stats, compress, band_valuelabels, - rat_valuelabels_mode, ) overviews_records_gen = rasterio_overview_to_records( diff --git a/raster_loader/lib/valuelabels.py b/raster_loader/lib/valuelabels.py index 61c6fe0..dae4e05 100644 --- a/raster_loader/lib/valuelabels.py +++ b/raster_loader/lib/valuelabels.py @@ -1,171 +1,17 @@ import json -from typing import Literal import click -from osgeo import gdal - -DEFAULT_VALUES_COLUMN_IDX = 0 -DEFAULT_LABELS_COLUMN_IDX = 1 - -gdal.UseExceptions() def get_band_valuelabels( - file_path: str, - band: int, - band_valuelabels: list[dict[int, str]], - rat_valuelabels_mode: Literal["auto", "interactive"], -) -> dict[int, str]: + file_path: str, band: int, band_valuelabels: list[dict[int, str]] +) -> dict[int, str] | None: + valuelabels = None if len(band_valuelabels) >= band and band_valuelabels[band - 1] is not None: print(f"Using the provided valuelabels for band {band}") valuelabels = band_valuelabels[band - 1] - else: - # Computing valuelabels from the Raster Attribute Table (RAT) - valuelabels = get_valuelabels_from_rat(file_path, band, rat_valuelabels_mode) return valuelabels -def get_valuelabels_from_rat( - dataset_uri: str, band_idx: int, mode: Literal["auto", "interactive"] -) -> dict[int, str]: - """ - Get the value labels for a given dataset and band. - """ - print(f"Computing value labels for band {band_idx}") - try: - dataset = gdal.Open(dataset_uri) # dataset_uri is path to .tif file - band = dataset.GetRasterBand(band_idx) - - rat = band.GetDefaultRAT() - if rat is None: - print(f"No Raster Attribute Table (RAT) found for band {band_idx}") - return None - - print(f"Available columns in Raster Attribute Table (RAT) for band {band_idx}:") - for col_idx in range(rat.GetColumnCount()): - print(f"\t{col_idx}: {rat.GetNameOfCol(col_idx)}") - - if mode == "interactive": - values_column_idx = click.prompt( - f"Introduce the column index for Values for band {band_idx}", - type=int, - default=DEFAULT_VALUES_COLUMN_IDX, - ) - labels_column_idx = click.prompt( - f"Introduce the column index for Labels for band {band_idx}", - type=int, - default=DEFAULT_LABELS_COLUMN_IDX, - ) - else: - # Guess columns for values and labels - values_column_idx = get_values_column_idx(rat) - labels_column_idx = get_labels_column_idx(rat, [values_column_idx]) - - print( - f"Selected RAT column for Values: " - f"[{values_column_idx}: {rat.GetNameOfCol(values_column_idx)}]" - ) - print( - f"Selected RAT column for Labels: " - f"[{labels_column_idx}: {rat.GetNameOfCol(labels_column_idx)}]" - ) - - # Convert RAT to dictionary - value_labels = {} - for i in range(rat.GetRowCount()): - value = rat.GetValueAsInt(i, values_column_idx) - label = rat.GetValueAsString(i, labels_column_idx) - value_labels[value] = label - return value_labels - except ValueError: - return None - - -def get_values_column_idx(rat: gdal.RasterAttributeTable) -> int: - values_column_idx = DEFAULT_VALUES_COLUMN_IDX - for col_idx in range(rat.GetColumnCount()): - col_name = rat.GetNameOfCol(col_idx) - if col_name.lower() == "value": - values_column_idx = col_idx - - return values_column_idx - - -def get_labels_column_idx( - rat: gdal.RasterAttributeTable, skip_columns: list[int] = [] -) -> int: - """ - Get the column index of the labels column in a Raster Attribute Table (RAT). - - This function uses a heuristic approach to identify the most likely column - containing descriptive labels by: - 1. Excluding columns specified in skip_columns (typically the values column) - 2. Ranking remaining columns based on: - - Number of unique values in the column - - Number of unique words in the column - 3. Selecting the column with the highest combined score (unique values * unique words) - If no column has a score > 0, falls back to selecting the column with most unique values - - Args: - rat: GDAL Raster Attribute Table - skip_columns: List of column indices to exclude from consideration - - Returns: - int: The index of the column most likely to contain labels - """ - labels_column_idx = DEFAULT_LABELS_COLUMN_IDX - unique_values_count = count_column_unique_values(rat) - - unique_words_count = count_column_unique_words(rat) - - # Ranked columns by (unique values count * bigrams count) - ranked_columns = {} - for col_idx in range(rat.GetColumnCount()): - if col_idx in skip_columns: - continue - ranked_columns[col_idx] = ( - unique_values_count[col_idx] * unique_words_count[col_idx] - ) - - # Sort the ranked columns by the rank - ranked_columns = sorted(ranked_columns.items(), key=lambda x: x[1], reverse=True) - - # Get the column with the highest rank - if ranked_columns[0][1] > 0: - labels_column_idx = ranked_columns[0][0] - else: - # If no bigrams found, select column with most unique values - labels_column_idx = max(unique_values_count.items(), key=lambda x: x[1])[0] - - return labels_column_idx - - -# Return a dictionary with the column_idx and the number of unique values in it -def count_column_unique_values(rat: gdal.RasterAttributeTable) -> dict[int, int]: - unique_values_count = {} - for col_idx in range(rat.GetColumnCount()): - col_unique_values = set() - for row_idx in range(rat.GetRowCount()): - value = rat.GetValueAsString(row_idx, col_idx) - col_unique_values.add(value) - unique_values_count[col_idx] = len(col_unique_values) - - return unique_values_count - - -def count_column_unique_words(rat: gdal.RasterAttributeTable) -> dict[int, int]: - columns_words_count = {} - for col_idx in range(rat.GetColumnCount()): - column_text = "" - for row_idx in range(rat.GetRowCount()): - column_text += rat.GetValueAsString(row_idx, col_idx).lower() + " " - words_list = set(column_text.split()) - # Remove words which are not text - words_list = [w for w in words_list if w.isalpha()] - columns_words_count[col_idx] = len(words_list) - - return columns_words_count - - def validate_band_valuelabels(_, __, value): """ Validate the band valuelabels parameter for click library callback. diff --git a/setup.cfg b/setup.cfg index 5b0bcb2..33bdf7b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,14 +34,13 @@ install_requires = click>=8.1.3 db-dtypes>=1.0.5 pandas>=1.3.4 - pyarrow>=10.0.1,<17.0.0 + pyarrow>=10.0.1 pyproj>=3.2.1 rasterio>=1.3a3 rio-cogeo>=3.5.0 shapely>=1.7.1 quadbin>=0.2.0 tqdm>=4.64.1 - GDAL==3.6.0 zip_safe = False [options.entry_points] From b7d3076a665b5d7688a219b63a86f9ac5b2e1c57 Mon Sep 17 00:00:00 2001 From: juanrmn Date: Tue, 8 Jul 2025 12:44:44 +0200 Subject: [PATCH 24/24] fix python 3.9 syntax --- raster_loader/lib/valuelabels.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/raster_loader/lib/valuelabels.py b/raster_loader/lib/valuelabels.py index dae4e05..3b5d56b 100644 --- a/raster_loader/lib/valuelabels.py +++ b/raster_loader/lib/valuelabels.py @@ -1,10 +1,11 @@ import json import click +from typing import Dict, List, Optional def get_band_valuelabels( - file_path: str, band: int, band_valuelabels: list[dict[int, str]] -) -> dict[int, str] | None: + file_path: str, band: int, band_valuelabels: List[Dict[int, str]] +) -> Optional[Dict[int, str]]: valuelabels = None if len(band_valuelabels) >= band and band_valuelabels[band - 1] is not None: print(f"Using the provided valuelabels for band {band}")