From e4f2bc40bb4c41fa9b8b99918c996384a5cd9198 Mon Sep 17 00:00:00 2001
From: paulfouquet <86932794+paulfouquet@users.noreply.github.com>
Date: Fri, 7 Feb 2025 11:44:34 +1300
Subject: [PATCH] feat: generate hillshade TDE-1369 (#1253)
### Motivation
LINZ wants to offer some standard "out-of-the-box" hillshades to the
elevation data consumers. This feature gives the ability to generate two
standard hillshades from existing DEM TIFF(s):
| [:world_map: Monodirectional or "Greyscale"
hillshade](https://basemaps.linz.govt.nz/@-40.6547814,176.2198739,z3.69?style=23-test-hillshade-7w5ph&i=23-test-hillshade-7w5ph&tileMatrix=NZTM2000Quad&config=6sUk4Za2WKysFTtUuDLY8VZKUUvfvkXoNXGCqB3PC6uKQH1kbhniZaS5JVmTxb8WBZ1RBvwbK3xk8XrDdC37MwSj1sn3XBLwcFxEJf1mR6fHJdoP8LYt624nphascfFHdL8v83CksZqqWKvhcGBLTpHJsL1PeuHMMuCGqUG6eNUAu9FWxajyLD&debug=true&debug.layer=basemaps-23-test-hillshade-7w5ph-color-ramp)
| [:world_map: "Igor"
hillshade](https://basemaps.linz.govt.nz/@-40.1252942,174.5734854,z5?style=24-test-hillshade-fpps6&i=24-test-hillshade-fpps6&tileMatrix=NZTM2000Quad&config=6sUk4Za2WKysFTtUuDLY8VZKUUvfvkXoNXGCqB3PC6uKQH1kbhniuZFFm2BPvcgeALCZJgJ3NRK6Y3kgEH7pKGK5LadeRw3LZDiivVeEE4zGRufribx7q1EAHzMgwvF6vQ6ZqePJHk4Gb54aXzBs4Ey8WXhgp2hUARXq1F6o1AzJ9fHe3anphw&debug=true&debug.layer=basemaps-24-test-hillshade-fpps6-color-ramp)
|
| ------------- | ------------- |
|

|

|
### Modifications
- add a script `generate_hillshade.py`
```
usage: generate_hillshade.py [-h] --from-file FROM_FILE --preset
{greyscale,igor} --target TARGET [--force]
options:
-h, --help show this help message and exit
--from-file FROM_FILE
Specify the path to a json file containing the input
tiffs. Format: [{'output': 'tile1', 'inputs':
['path/input1.tiff', 'path/input2.tiff']}]
--preset {greyscale,igor}
Type of hillshade to generate.
--target TARGET Specify the path to save the generated hillshade to.
--force Regenerate the hillshade TIFF files if already exist.
Defaults to False.
```
### Verification
Manual, automated end-to-end tests, and Argo Workflows runs.
---
.github/workflows/format-tests.yml | 10 ++
README.md | 2 +-
scripts/files/file_tiff.py | 4 +-
scripts/files/tests/file_tiff_test.py | 14 +-
scripts/gdal/gdal_bands.py | 4 +-
scripts/gdal/gdal_commands.py | 44 ++++-
scripts/gdal/gdal_presets.py | 11 +-
scripts/gdal/tests/gdal_bands_test.py | 4 +-
scripts/gdal/tests/gdal_commands_test.py | 8 +-
scripts/generate_hillshade.py | 158 ++++++++++++++++++
scripts/tests/data/hillshade.json | 6 +
.../output/BK39_10000_0101_greyscale.tiff | Bin 0 -> 2332 bytes
.../data/output/BK39_10000_0101_igor.tiff | Bin 0 -> 2285 bytes
13 files changed, 242 insertions(+), 23 deletions(-)
create mode 100644 scripts/generate_hillshade.py
create mode 100644 scripts/tests/data/hillshade.json
create mode 100644 scripts/tests/data/output/BK39_10000_0101_greyscale.tiff
create mode 100644 scripts/tests/data/output/BK39_10000_0101_igor.tiff
diff --git a/.github/workflows/format-tests.yml b/.github/workflows/format-tests.yml
index d575e8690..5171bd959 100644
--- a/.github/workflows/format-tests.yml
+++ b/.github/workflows/format-tests.yml
@@ -40,6 +40,16 @@ jobs:
cmp --silent "${{ runner.temp }}/BK39_10000_0102.tiff" ./scripts/tests/data/output/BK39_10000_0102.tiff
cmp --silent "${{ runner.temp }}/BK39_10000_0101.tiff" ./scripts/tests/data/output/BK39_10000_0101.tiff
+ - name: End to end test - Hillshade Greyscale
+ run: |
+ docker run -v "${{ runner.temp }}:/tmp/" topo-imagery python3 generate_hillshade.py --from-file ./tests/data/hillshade.json --preset greyscale --target /tmp/ --force
+ cmp --silent "${{ runner.temp }}/BK39_10000_0101.tiff" ./scripts/tests/data/output/BK39_10000_0101_greyscale.tiff
+
+ - name: End to end test - Hillshade Igor
+ run: |
+ docker run -v "${{ runner.temp }}:/tmp/" topo-imagery python3 generate_hillshade.py --from-file ./tests/data/hillshade.json --preset igor --target /tmp/ --force
+ cmp --silent "${{ runner.temp }}/BK39_10000_0101.tiff" ./scripts/tests/data/output/BK39_10000_0101_igor.tiff
+
- name: End to end test - Historical Aerial Imagery
run: |
docker run -v "${{ runner.temp }}:/tmp/" topo-imagery python3 standardise_validate.py --from-file ./tests/data/hi.json --preset webp --target-epsg 2193 --source-epsg 2193 --target /tmp/ --collection-id 123 --start-datetime 2023-01-01 --end-datetime 2023-01-01 --gsd 60 --create-footprints=true --current-datetime=2010-09-18T12:34:56Z
diff --git a/README.md b/README.md
index 18e352af9..0dc1f2451 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ The scripts have been implemented to be run inside the Docker container only. Th
docker build --tag=topo-imagery .
```
-- Running `standardising_validate.py` script
+- Example: running `standardising_validate.py` script
This script standardises TIFF files to [COGs](https://www.cogeo.org/) with a creation of a [STAC](https://stacspec.org/) Item file per TIFF containing the metadata.
The input TIFF file paths have to be passed through a `json` file in the following format:
diff --git a/scripts/files/file_tiff.py b/scripts/files/file_tiff.py
index 3d8b8a2c1..5eae4177a 100644
--- a/scripts/files/file_tiff.py
+++ b/scripts/files/file_tiff.py
@@ -6,7 +6,7 @@
from urllib.parse import unquote
from scripts.gdal.gdal_helper import GDALExecutionException, gdal_info, run_gdal
-from scripts.gdal.gdal_presets import DEFAULT_NO_DATA_VALUE, Preset
+from scripts.gdal.gdal_presets import DEFAULT_NO_DATA_VALUE, CompressionPreset
from scripts.gdal.gdalinfo import GdalInfo
@@ -50,7 +50,7 @@ def __init__(
self._errors: list[dict[str, Any]] = []
self._gdalinfo: GdalInfo | None = None
self._srs: bytes | None = None
- if preset == Preset.DEM_LERC.value:
+ if preset == CompressionPreset.DEM_LERC.value:
self._tiff_type = FileTiffType.DEM
else:
self._tiff_type = FileTiffType.IMAGERY
diff --git a/scripts/files/tests/file_tiff_test.py b/scripts/files/tests/file_tiff_test.py
index 7d6571a2e..fd5b950f2 100644
--- a/scripts/files/tests/file_tiff_test.py
+++ b/scripts/files/tests/file_tiff_test.py
@@ -1,5 +1,5 @@
from scripts.files.file_tiff import FileTiff, FileTiffErrorType
-from scripts.gdal.gdal_presets import Preset
+from scripts.gdal.gdal_presets import CompressionPreset
from scripts.gdal.tests.gdalinfo import add_band, add_palette_band, fake_gdal_info
@@ -75,7 +75,7 @@ def test_check_band_count_valid_1_dem() -> None:
gdalinfo = fake_gdal_info()
add_band(gdalinfo)
- file_tiff = FileTiff(["test"], Preset.DEM_LERC.value)
+ file_tiff = FileTiff(["test"], CompressionPreset.DEM_LERC.value)
file_tiff.check_band_count(gdalinfo)
assert not file_tiff.get_errors()
@@ -90,7 +90,7 @@ def test_check_band_count_invalid_alpha_dem() -> None:
add_band(gdalinfo)
add_band(gdalinfo, color_interpretation="Alpha")
- file_tiff = FileTiff(["test"], Preset.DEM_LERC.value)
+ file_tiff = FileTiff(["test"], CompressionPreset.DEM_LERC.value)
file_tiff.check_band_count(gdalinfo)
assert file_tiff.get_errors()
@@ -106,7 +106,7 @@ def test_check_band_count_invalid_3_dem() -> None:
add_band(gdalinfo)
add_band(gdalinfo)
- file_tiff = FileTiff(["test"], Preset.DEM_LERC.value)
+ file_tiff = FileTiff(["test"], CompressionPreset.DEM_LERC.value)
file_tiff.check_band_count(gdalinfo)
assert file_tiff.get_errors()
@@ -165,7 +165,7 @@ def test_check_color_interpretation_valid_dem() -> None:
gdalinfo = fake_gdal_info()
add_band(gdalinfo, color_interpretation="Gray")
- file_tiff = FileTiff(["test"], Preset.DEM_LERC.value)
+ file_tiff = FileTiff(["test"], CompressionPreset.DEM_LERC.value)
file_tiff.check_color_interpretation(gdalinfo)
assert not file_tiff.get_errors()
@@ -180,7 +180,7 @@ def test_check_color_interpretation_invalid_dem() -> None:
add_band(gdalinfo, color_interpretation="Green")
add_band(gdalinfo, color_interpretation="Blue")
- file_tiff = FileTiff(["test"], Preset.DEM_LERC.value)
+ file_tiff = FileTiff(["test"], CompressionPreset.DEM_LERC.value)
file_tiff.check_color_interpretation(gdalinfo)
assert file_tiff.get_errors()
@@ -322,7 +322,7 @@ def test_should_throw_when_encountering_non_integer_no_data_value() -> None:
gdalinfo = fake_gdal_info()
add_palette_band(gdalinfo, colour_table_entries=[[x, x, x, 255] for x in reversed(range(256))], no_data_value="-9999.1")
- file_tiff = FileTiff(["test"], Preset.DEM_LERC.value)
+ file_tiff = FileTiff(["test"], CompressionPreset.DEM_LERC.value)
file_tiff.check_no_data(gdalinfo)
assert file_tiff.get_errors() == [
diff --git a/scripts/gdal/gdal_bands.py b/scripts/gdal/gdal_bands.py
index dab968d6e..6cd26b838 100644
--- a/scripts/gdal/gdal_bands.py
+++ b/scripts/gdal/gdal_bands.py
@@ -1,7 +1,7 @@
from linz_logger import get_log
from scripts.gdal.gdal_helper import gdal_info
-from scripts.gdal.gdal_presets import Preset
+from scripts.gdal.gdal_presets import CompressionPreset
from scripts.gdal.gdalinfo import GdalInfo, GdalInfoBand
@@ -44,7 +44,7 @@ def get_gdal_band_offset(file: str, info: GdalInfo | None = None, preset: str |
if band_grey := find_band(bands, "Gray"):
band_grey_index = str(band_grey["band"])
- if preset == Preset.DEM_LERC.value:
+ if preset == CompressionPreset.DEM_LERC.value:
# return single band if DEM/DSM
return ["-b", band_grey_index]
# Grey scale imagery, set R,G and B to just the grey_band
diff --git a/scripts/gdal/gdal_commands.py b/scripts/gdal/gdal_commands.py
index ecad86dea..f010fd950 100644
--- a/scripts/gdal/gdal_commands.py
+++ b/scripts/gdal/gdal_commands.py
@@ -8,7 +8,8 @@
DEM_LERC,
SCALE_254_ADD_NO_DATA,
WEBP_OVERVIEWS,
- Preset,
+ CompressionPreset,
+ HillshadePreset,
)
from scripts.gdal.gdalinfo import GdalInfo
@@ -30,16 +31,16 @@ def get_gdal_command(preset: str, epsg: int) -> list[str]:
# Force the source projection to an input EPSG
gdal_command.extend(["-a_srs", f"EPSG:{epsg}"])
- if preset == Preset.LZW.value:
+ if preset == CompressionPreset.LZW.value:
gdal_command.extend(SCALE_254_ADD_NO_DATA)
gdal_command.extend(COMPRESS_LZW)
gdal_command.extend(WEBP_OVERVIEWS)
- elif preset == Preset.WEBP.value:
+ elif preset == CompressionPreset.WEBP.value:
gdal_command.extend(COMPRESS_WEBP_LOSSLESS)
gdal_command.extend(WEBP_OVERVIEWS)
- elif preset == Preset.DEM_LERC.value:
+ elif preset == CompressionPreset.DEM_LERC.value:
gdal_command.extend(DEM_LERC)
return gdal_command
@@ -195,3 +196,38 @@ def get_ascii_translate_command() -> list[str]:
"-co",
"COMPRESS=lzw",
]
+
+
+def get_hillshade_command(preset: str) -> list[str]:
+ """Get a `gdaldem` command to create a hillshade based on the provided HillshadePreset.
+
+ Args:
+ preset: a HillshadePreset
+
+ Returns:
+ a `gdaldem` command
+ """
+ gdal_command: list[str] = [
+ "gdaldem",
+ "hillshade",
+ "-compute_edges",
+ "-of",
+ "COG",
+ "-co",
+ "COMPRESS=lerc",
+ "-co",
+ "OVERVIEW_COMPRESS=lerc",
+ "-co",
+ "MAX_Z_ERROR_OVERVIEW=0",
+ "-co",
+ "NUM_THREADS=ALL_CPUS",
+ "-co",
+ "MAX_Z_ERROR=0",
+ ]
+
+ if preset == HillshadePreset.GREYSCALE.value:
+ gdal_command.extend(["-az", "315", "-alt", "45"])
+ elif preset == HillshadePreset.IGOR.value:
+ gdal_command.extend(["-igor"])
+
+ return gdal_command
diff --git a/scripts/gdal/gdal_presets.py b/scripts/gdal/gdal_presets.py
index 0b833df59..b0972f6fc 100644
--- a/scripts/gdal/gdal_presets.py
+++ b/scripts/gdal/gdal_presets.py
@@ -79,7 +79,16 @@
]
-class Preset(str, Enum):
+class CompressionPreset(str, Enum):
+ """Enum for the different compression presets available for standardising TIFFs."""
+
DEM_LERC = "dem_lerc"
LZW = "lzw"
WEBP = "webp"
+
+
+class HillshadePreset(str, Enum):
+ """Enum for the different type of hillshade available for generating from a DEM."""
+
+ GREYSCALE = "greyscale" # Standard/default mono-directional hillshade
+ IGOR = "igor" # Whiter hillshade (see http://maperitive.net/docs/Commands/GenerateReliefImageIgor.html)
diff --git a/scripts/gdal/tests/gdal_bands_test.py b/scripts/gdal/tests/gdal_bands_test.py
index 7a7157565..ec051879c 100644
--- a/scripts/gdal/tests/gdal_bands_test.py
+++ b/scripts/gdal/tests/gdal_bands_test.py
@@ -1,5 +1,5 @@
from scripts.gdal.gdal_bands import get_gdal_band_offset, get_gdal_band_type
-from scripts.gdal.gdal_presets import Preset
+from scripts.gdal.gdal_presets import CompressionPreset
from scripts.gdal.tests.gdalinfo import add_band, add_palette_band, fake_gdal_info
@@ -26,7 +26,7 @@ def test_gdal_grey_bands_dem_detection() -> None:
gdalinfo = fake_gdal_info()
add_band(gdalinfo, color_interpretation="Gray")
- bands = get_gdal_band_offset("some_file.tiff", gdalinfo, Preset.DEM_LERC.value)
+ bands = get_gdal_band_offset("some_file.tiff", gdalinfo, CompressionPreset.DEM_LERC.value)
assert " ".join(bands) == "-b 1"
diff --git a/scripts/gdal/tests/gdal_commands_test.py b/scripts/gdal/tests/gdal_commands_test.py
index 99a54281d..4fff4c202 100644
--- a/scripts/gdal/tests/gdal_commands_test.py
+++ b/scripts/gdal/tests/gdal_commands_test.py
@@ -2,11 +2,11 @@
from scripts.gdal.gdal_commands import get_cutline_command, get_gdal_command
from scripts.gdal.gdal_helper import EpsgNumber
-from scripts.gdal.gdal_presets import Preset
+from scripts.gdal.gdal_presets import CompressionPreset
def test_preset_webp(subtests: SubTests) -> None:
- gdal_command = get_gdal_command(Preset.WEBP.value, epsg=EpsgNumber.NZTM_2000.value)
+ gdal_command = get_gdal_command(CompressionPreset.WEBP.value, epsg=EpsgNumber.NZTM_2000.value)
# Basic cog creation
with subtests.test():
@@ -46,7 +46,7 @@ def test_preset_webp(subtests: SubTests) -> None:
def test_preset_lzw(subtests: SubTests) -> None:
- gdal_command = get_gdal_command(Preset.LZW.value, epsg=EpsgNumber.NZTM_2000.value)
+ gdal_command = get_gdal_command(CompressionPreset.LZW.value, epsg=EpsgNumber.NZTM_2000.value)
# Basic cog creation
with subtests.test():
@@ -86,7 +86,7 @@ def test_preset_lzw(subtests: SubTests) -> None:
def test_preset_dem_lerc(subtests: SubTests) -> None:
- gdal_command = get_gdal_command(Preset.DEM_LERC.value, epsg=EpsgNumber.NZTM_2000.value)
+ gdal_command = get_gdal_command(CompressionPreset.DEM_LERC.value, epsg=EpsgNumber.NZTM_2000.value)
# Basic cog creation
with subtests.test():
assert "COG" in gdal_command
diff --git a/scripts/generate_hillshade.py b/scripts/generate_hillshade.py
new file mode 100644
index 000000000..cfa02d424
--- /dev/null
+++ b/scripts/generate_hillshade.py
@@ -0,0 +1,158 @@
+import argparse
+import os
+import sys
+import tempfile
+from functools import partial
+from multiprocessing import Pool
+
+from linz_logger import get_log
+
+from scripts.cli.cli_helper import InputParameterError, TileFiles, is_argo, load_input_files
+from scripts.files.files_helper import ContentType, is_tiff
+from scripts.files.fs import exists, read, write, write_all
+from scripts.gdal.gdal_commands import get_hillshade_command
+from scripts.gdal.gdal_helper import run_gdal
+from scripts.gdal.gdal_presets import HillshadePreset
+from scripts.logging.time_helper import time_in_ms
+from scripts.standardising import create_vrt
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--from-file",
+ dest="from_file",
+ required=True,
+ help="Specify the path to a json file containing the input tiffs. "
+ "Format: [{'output': 'tile1', 'inputs': ['path/input1.tiff', 'path/input2.tiff']}]",
+ )
+ parser.add_argument(
+ "--preset",
+ dest="preset",
+ required=True,
+ choices=[preset.value for preset in HillshadePreset],
+ help="Type of hillshade to generate.",
+ )
+ parser.add_argument("--target", dest="target", required=True, help="Specify the path to save the generated hillshade to.")
+ parser.add_argument(
+ "--force",
+ dest="force",
+ help="Regenerate the hillshade TIFF files if already exist. Defaults to False.",
+ action="store_true",
+ )
+
+ return parser.parse_args()
+
+
+def create_hillshade(
+ tile: TileFiles,
+ preset: str,
+ target_output: str = "/tmp/",
+ force: bool = False,
+) -> str | None:
+ """Create a hillshade TIFF file from a `TileFiles` which include an output tile with its input TIFFs.
+
+ Args:
+ tile: a TileFiles object with the input TIFFs and the output tile name.
+ preset: a `HillshadePreset` to use. See `gdal.gdal_presets.py`.
+ target_output: path where the output files need to be saved to. Defaults to "/tmp/".
+ force: overwrite existing output file. Defaults to False.
+
+ Returns:
+ The filename of the hillshade TIFF file if created.
+ """
+ hillshade_file_name = tile.output + ".tiff"
+
+ hillshade_file_path = os.path.join(target_output, hillshade_file_name)
+
+ # Already processed can skip processing
+ if exists(hillshade_file_path):
+ if not force:
+ get_log().info("Skipping: hillshade TIFF already exists.", path=hillshade_file_path)
+ return None
+ get_log().info("Overwriting: hillshade TIFF already exists.", path=hillshade_file_path)
+
+ # Download any needed file from S3 ["/foo/bar.tiff", "s3://foo"] => "/tmp/bar.tiff", "/tmp/foo.tiff"
+ with tempfile.TemporaryDirectory() as tmp_path:
+ hillshade_working_path = os.path.join(tmp_path, hillshade_file_name)
+
+ source_files = write_all(tile.inputs, f"{tmp_path}/source/")
+ source_tiffs = [file for file in source_files if is_tiff(file)]
+
+ # Start from base VRT
+ input_file = create_vrt(source_tiffs, tmp_path)
+
+ # Need GDAL to write to temporary location so no broken files end up in the final folder.
+ run_gdal(get_hillshade_command(preset), input_file=input_file, output_file=hillshade_working_path)
+
+ write(hillshade_file_path, read(hillshade_working_path), content_type=ContentType.GEOTIFF.value)
+
+ return hillshade_file_path
+
+
+def run_create_hillshade(
+ todo: list[TileFiles],
+ preset: str,
+ concurrency: int,
+ target_output: str = "/tmp/",
+ force: bool = False,
+) -> list[str]:
+ """Run `create_hillshade()` in parallel (see `concurrency`).
+
+ Args:
+ todo: list of TileFiles (tile name and input files) to hillshade
+ preset: `HillshadePreset` to use. See `gdal.gdal_presets.py`
+ concurrency: number of concurrent tiles to process
+ target_output: output directory path. Defaults to "/tmp/"
+ force: overwrite existing files. Defaults to False.
+
+ Returns:
+ the list of generated hillshade TIFF paths.
+ """
+ with Pool(concurrency) as p:
+ results = [
+ entry
+ for entry in p.map(
+ partial(
+ create_hillshade,
+ preset=preset,
+ target_output=target_output,
+ force=force,
+ ),
+ todo,
+ )
+ if entry is not None
+ ]
+ p.close()
+ p.join()
+
+ return results
+
+
+def main() -> None:
+ start_time = time_in_ms()
+ arguments = parse_args()
+
+ try:
+ tile_files = load_input_files(arguments.from_file)
+ except InputParameterError as e:
+ get_log().error("An error occurred when loading the input file.", error=str(e))
+ sys.exit(1)
+
+ get_log().info(
+ "generate_hillshade_start", gdalVersion=os.environ["GDAL_VERSION"], fileCount=len(tile_files), preset=arguments.preset
+ )
+
+ concurrency: int = 1
+ if is_argo():
+ concurrency = 4
+
+ file_paths = run_create_hillshade(tile_files, arguments.preset, concurrency, arguments.target, arguments.force)
+
+ get_log().info(
+ "generate_hillshade_end", duration=time_in_ms() - start_time, fileCount=len(file_paths), path=arguments.target
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/tests/data/hillshade.json b/scripts/tests/data/hillshade.json
new file mode 100644
index 000000000..030915137
--- /dev/null
+++ b/scripts/tests/data/hillshade.json
@@ -0,0 +1,6 @@
+[
+ {
+ "output": "BK39_10000_0101",
+ "input": ["./tests/data/output/BK39_10000_0101.tiff"]
+ }
+]
diff --git a/scripts/tests/data/output/BK39_10000_0101_greyscale.tiff b/scripts/tests/data/output/BK39_10000_0101_greyscale.tiff
new file mode 100644
index 0000000000000000000000000000000000000000..42b5bca3a701b2292744accdd56e148e8b3d9759
GIT binary patch
literal 2332
zcmebD)M7Zmz`)?{;^-3}91;}j91>IJ!87IK~HiM!DJ=7#J9u7$_uFmZTPQ
z`8Y=UhlbdCy14|$JGr{~2f42n91$#Pq`gn#!
zDk-Go=O$+6*(!PZI=Z{UquN!;&c{DE*axV`MjvF59hZ$hstr(k?HnKs7!4K1K`n^3
zbp%P-6orCm5StMkcI;rUfY`tQ1dB1TY-|Vln2}>+J15ZZY``dEWav|7-~rJSIat87
zrm|m@yNeMpDJtkI_@$OBM5QL?B<7_kgcK#_6_=$J6{jlrrWPe9mgE;HfRxq1)YmY8
z;>ZUmXQTiM#V@VbOTa-33~ET=vM>k$#X+i|feqw6um=(duprj~%y|n;SzJO=N?Jx%
zOq}KJ42*kZ`;T)Qb#IF|_LR9+2IO5AR%^CO6v$B7=VTlA{`zm$VoUkH=aXx#?BoS*
zy#8Bes}Qgwd!onDB*PW2d0U;92*14jv2O38;)utL&2F0Cc37KDw|Zh+cI#`+Nv)D^
zUPkS{YcCf6;%#=ipY>VkSIy10-pbBV^PTUWe#*@>^>o70#nn|y9hQei?akV160qbb
z%k-7D&$nBf70=%i5A-#H?+3dirAU%A$8ZPItCpBH@?pniq<8J*@>yL!v<3y1fdd+_k&
zq=u<&xvLki+p}fq&KtK+E-7hVHM4r-qNUrmAG`Np|FS7X{cGk-T{(09);(u$o|?OK
zL0SL&2{YEN-n4!D&h1MkY+qA3apm$=`%WL9*X06(~rgr0D=u
Gz5@VHWz94I
literal 0
HcmV?d00001
diff --git a/scripts/tests/data/output/BK39_10000_0101_igor.tiff b/scripts/tests/data/output/BK39_10000_0101_igor.tiff
new file mode 100644
index 0000000000000000000000000000000000000000..a7dc429b7dbb99f2942ac3c0811c6cfcb43d8ffb
GIT binary patch
literal 2285
zcmeH{Z%i9y9LJxQicpzxPGg2Mxs@T=+(Jk24QVS^`aq6dd+hGYR*bnEaM@Pdl9Xm<
zW-sQSf$YVM8mg=FvLO_CAYEuogV#5{(8SQ_+!ANDM#VpKz{#BR@9+;k&v7_hVti%d
z3!dce_j!KL@A>ol{+>IA*#_PQ0JQjMRuV)$*dzvdjD9H6K3b$Df$4y5r_;H!-br;H
zN+`W{mJV@2(akjb1j!4VIUY(R((Yxsrd<-p`ylV;xjmAfZsmB>%R-vC$zl>MNI@nb
z)|*2jPctkYV`)K@>LqVTgo4DwHb{%mXWtdz_5>s*(8T%MXp!-2WVnDkz}YFV
z0RT%0u;2_%;m2a~HOy9%%P^Oi-0~a%r6!X(_pahitJrHYumaGuihUNFvDZBILz95+rjwh7Wh}Gp~J&+g23WAu3EB?$L^~A`wr&5p2lTZv8MYU%&AA+G#PTm
zgN7w+F|@ngNQE#K(C}Gpll>%POW9<9InKQlm&^i=SAhy*UnsK~c579jqs3Q;uOd}T
z1(YYK4n>a0v2IF?%dy^rO1xL0{7Sq_PV~en5_Whc{xBdlV)0lVMO1ukxazDSEtYy&
zTpPFx`$?23Htx+lf?qe=Oi!cHS;9BVes`r~TU~>-{9H4z>?q_S`JvIgI;sy~jux^g
zm8!1fqq?@^)%4tfeD|+Fex9jidWhXkoEU*VIo>s1N0{r$>&&3h1%1WLGHt
z`h~Y5x;Ct39}lbJ)7csnR0ooCZ;Wg&rZ;~a>C`gO2j!@ay7p$!v=)Y2XD{Yr;q-#0
z4ZZ`+Tvl?r)s}<(B**tZIQ!wb^B-NfSo~z>^Dn;q>hjlDX6NShZx*h8d+odH-~aIA
zPe1>1WAWGDe*a^s*r#4eWyX(r-h0zFQ=K~5K7U{zZ^m1zPJbe++9ntm#00ivQDlKz`VN0^}ak1^@s6
literal 0
HcmV?d00001