From b9e984acaf1d070d699c52b6a93a122d667df316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20=C5=BDdila?= Date: Fri, 28 Apr 2023 15:50:24 +0200 Subject: [PATCH] gdal2tiles: added support for JPEG output Fixes #6703 --- autotest/pyscripts/test_gdal2tiles.py | 141 ++++++++++++++++++ doc/source/programs/gdal2tiles.rst | 19 ++- .../gdal-utils/osgeo_utils/gdal2tiles.py | 116 +++++++++++++- 3 files changed, 266 insertions(+), 10 deletions(-) diff --git a/autotest/pyscripts/test_gdal2tiles.py b/autotest/pyscripts/test_gdal2tiles.py index 0b24b8306679..05c1a81f028a 100755 --- a/autotest/pyscripts/test_gdal2tiles.py +++ b/autotest/pyscripts/test_gdal2tiles.py @@ -623,3 +623,144 @@ def test_gdal2tiles_excluded_values(script_path, tmp_path): (12 + 22 + 42) // 3, 255, ) + + +@pytest.mark.require_driver("JPEG") +@pytest.mark.parametrize( + "resampling, expected_stats_z0, expected_stats_z1", + ( + ( + "average", + [ + [0.0, 255.0, 62.789886474609375, 71.57543623020909], + [0.0, 255.0, 62.98188781738281, 70.54545410356597], + [0.0, 255.0, 77.94142150878906, 56.07427114858068], + ], + [ + [0.0, 255.0, 63.620819091796875, 68.38688881060699], + [0.0, 255.0, 63.620819091796875, 68.38688881060699], + [0.0, 255.0, 87.09403991699219, 53.07665243601322], + ], + ), + ( + "antialias", + [ + [0.0, 255.0, 62.66636657714844, 71.70766144632985], + [0.0, 255.0, 62.91070556640625, 70.705889259777], + [0.0, 255.0, 77.78370666503906, 56.251290816620596], + ], + [ + [0.0, 255.0, 63.61163330078125, 68.49625328462534], + [0.0, 255.0, 63.61163330078125, 68.49625328462534], + [0.0, 255.0, 87.04747009277344, 53.1751939061486], + ], + ), + ), +) +def test_gdal2tiles_py_jpeg_3band_input( + script_path, tmp_path, resampling, expected_stats_z0, expected_stats_z1 +): + + if resampling == "antialias" and not pil_available(): + pytest.skip("'antialias' resampling is not available") + + out_dir_jpeg = str(tmp_path / "out_gdal2tiles_smallworld_jpeg") + + test_py_scripts.run_py_script_as_external_script( + script_path, + "gdal2tiles", + "-q -z 0-1 -r " + + resampling + + " --tiledriver=JPEG " + + test_py_scripts.get_data_path("gdrivers") + + f"small_world.tif {out_dir_jpeg}", + ) + + ds = gdal.Open(f"{out_dir_jpeg}/0/0/0.jpg") + got_stats_0 = [ + ds.GetRasterBand(i + 1).ComputeStatistics(approx_ok=0) + for i in range(ds.RasterCount) + ] + + ds = gdal.Open(f"{out_dir_jpeg}/1/0/0.jpg") + got_stats_1 = [ + ds.GetRasterBand(i + 1).ComputeStatistics(approx_ok=0) + for i in range(ds.RasterCount) + ] + + for i in range(ds.RasterCount): + assert got_stats_0[i] == pytest.approx(expected_stats_z0[i], rel=0.05), ( + i, + got_stats_0, + got_stats_1, + ) + + for i in range(ds.RasterCount): + assert got_stats_1[i] == pytest.approx(expected_stats_z1[i], rel=0.05), ( + i, + got_stats_0, + got_stats_1, + ) + + +@pytest.mark.require_driver("JPEG") +@pytest.mark.parametrize( + "resampling, expected_stats_z14, expected_stats_z13", + ( + ( + ( + "average", + [[0.0, 255.0, 44.11726379394531, 61.766206763153946]], + [[0.0, 255.0, 11.057342529296875, 36.182401045647644]], + ), + ( + "antialias", + [[0.0, 255.0, 43.9254150390625, 61.58666064861184]], + [[0.0, 255.0, 11.013427734375, 36.12022842174338]], + ), + ) + ), +) +def test_gdal2tiles_py_jpeg_1band_input( + script_path, tmp_path, resampling, expected_stats_z14, expected_stats_z13 +): + + if resampling == "antialias" and not pil_available(): + pytest.skip("'antialias' resampling is not available") + + out_dir_jpeg = str(tmp_path / "out_gdal2tiles_byte_jpeg") + + test_py_scripts.run_py_script_as_external_script( + script_path, + "gdal2tiles", + "-q -z 13-14 -r " + + resampling + + " --tiledriver=JPEG " + + test_py_scripts.get_data_path("gcore") + + f"byte.tif {out_dir_jpeg}", + ) + + ds = gdal.Open(f"{out_dir_jpeg}/14/2838/9833.jpg") + got_stats_14 = [ + ds.GetRasterBand(i + 1).ComputeStatistics(approx_ok=0) + for i in range(ds.RasterCount) + ] + + ds = gdal.Open(f"{out_dir_jpeg}/13/1419/4916.jpg") + got_stats_13 = [ + ds.GetRasterBand(i + 1).ComputeStatistics(approx_ok=0) + for i in range(ds.RasterCount) + ] + + for i in range(ds.RasterCount): + assert got_stats_14[i] == pytest.approx(expected_stats_z14[i], rel=0.05), ( + i, + got_stats_14, + got_stats_13, + ) + for i in range(ds.RasterCount): + assert got_stats_13[i] == pytest.approx(expected_stats_z13[i], rel=0.05), ( + i, + got_stats_14, + got_stats_13, + ) diff --git a/doc/source/programs/gdal2tiles.rst b/doc/source/programs/gdal2tiles.rst index 79ca7e41e8f6..f46116f11271 100644 --- a/doc/source/programs/gdal2tiles.rst +++ b/doc/source/programs/gdal2tiles.rst @@ -21,7 +21,7 @@ Synopsis [-e] [-a nodata] [-v] [-q] [-h] [-k] [-n] [-u ] [-w ] [-t ] [-c <copyright>] [--processes=<NB_PROCESSES>] [--mpi] [--xyz] - [--tilesize=<PIXELS>] [--tmscompatible] + [--tilesize=<PIXELS>] --tiledriver=<DRIVER> [--tmscompatible] [--excluded-values=<EXCLUDED_VALUES>] [--excluded-values-pct-threshold=<EXCLUDED_VALUES_PCT_THRESHOLD>] [-g <googlekey] [-b <bingkey>] <input_file> [<output_dir>] [<COMMON_OPTIONS>] @@ -139,8 +139,8 @@ can publish a picture without proper georeferencing too. .. option:: --tiledriver=<DRIVER> Which output driver to use for the tiles, determines the file format of the tiles. - Currently PNG and WEBP are supported. Default is PNG. - Additional configuration for the WEBP driver are documented below. + Currently PNG, WEBP and JPEG (JPEG added in GDAL 3.9) are supported. Default is PNG. + Additional configuration for the WEBP and JPEG drivers are documented below. .. versionadded:: 3.6 @@ -266,6 +266,19 @@ The following configuration options are available to further customize the webp GDAL :ref:`WEBP driver <raster.webp>` documentation can be consulted +JPEG options ++++++++++++++ + +JPEG tiledriver support is new to GDAL 3.9. It is enabled by using --tiledriver=JPEG. + +Note that JPEG does not support transparency, hence edge tiles will display black +pixels in areas not covered by the source raster. + +The following configuration options are available to further customize the webp output: + +.. option:: ---jpeg-quality=JPEG_QUALITY + + QUALITY is a integer between 1-100. Default is 75. Examples diff --git a/swig/python/gdal-utils/osgeo_utils/gdal2tiles.py b/swig/python/gdal-utils/osgeo_utils/gdal2tiles.py index 6d255b1a61e3..af8a85bdda35 100644 --- a/swig/python/gdal-utils/osgeo_utils/gdal2tiles.py +++ b/swig/python/gdal-utils/osgeo_utils/gdal2tiles.py @@ -919,7 +919,12 @@ def scale_query_to_tile(dsquery, dstile, options, tilefilename=""): array[:, :, i] = gdalarray.BandReadAsArray( dsquery.GetRasterBand(i + 1), 0, 0, querysize, querysize ) - im = Image.fromarray(array, "RGBA") # Always four bands + if options.tiledriver == "JPEG" and tilebands == 2: + im = Image.fromarray(array[:, :, 0], "L") + elif options.tiledriver == "JPEG" and tilebands == 4: + im = Image.fromarray(array[:, :, 0:3], "RGB") + else: + im = Image.fromarray(array, "RGBA") im1 = im.resize((tile_size, tile_size), Image.LANCZOS) if os.path.exists(tilefilename): im0 = Image.open(tilefilename) @@ -931,6 +936,8 @@ def scale_query_to_tile(dsquery, dstile, options, tilefilename=""): params["lossless"] = True else: params["quality"] = options.webp_quality + elif options.tiledriver == "JPEG": + params["quality"] = options.jpeg_quality im1.save(tilefilename, options.tiledriver, **params) else: @@ -1314,6 +1321,8 @@ def _get_creation_options(options): copts = ["LOSSLESS=True"] else: copts = ["QUALITY=" + str(options.webp_quality)] + elif options.tiledriver == "JPEG": + copts = ["QUALITY=" + str(options.jpeg_quality)] return copts @@ -1430,7 +1439,12 @@ def create_base_tile(tile_job_info: "TileJobInfo", tile_detail: "TileDetail") -> if options.resampling != "antialias": # Write a copy of tile to png/jpg out_drv.CreateCopy( - tilefilename, dstile, strict=0, options=_get_creation_options(options) + tilefilename, + dstile + if tile_job_info.tile_driver != "JPEG" + else remove_alpha_band(dstile), + strict=0, + options=_get_creation_options(options), ) # Remove useless side car file @@ -1465,6 +1479,38 @@ def create_base_tile(tile_job_info: "TileJobInfo", tile_detail: "TileDetail") -> ) +def remove_alpha_band(src_ds): + if ( + src_ds.GetRasterBand(src_ds.RasterCount).GetColorInterpretation() + != gdal.GCI_AlphaBand + ): + return src_ds + + new_band_count = src_ds.RasterCount - 1 + + dst_ds = gdal.GetDriverByName("MEM").Create( + "", + src_ds.RasterXSize, + src_ds.RasterYSize, + new_band_count, + src_ds.GetRasterBand(1).DataType, + ) + + gt = src_ds.GetGeoTransform(can_return_null=True) + if gt: + dst_ds.SetGeoTransform(gt) + srs = src_ds.GetSpatialRef() + if srs: + dst_ds.SetSpatialRef(srs) + + for i in range(1, new_band_count + 1): + src_band = src_ds.GetRasterBand(i) + dst_band = dst_ds.GetRasterBand(i) + dst_band.WriteArray(src_band.ReadAsArray()) + + return dst_ds + + def create_overview_tile( base_tz: int, base_tiles: List[Tuple[int, int]], @@ -1542,7 +1588,35 @@ def create_overview_tile( else: tileposy = 0 - if dsquerytile.RasterCount == tilebands - 1: + if ( + tile_job_info.tile_driver == "JPEG" + and dsquerytile.RasterCount == 3 + and tilebands == 2 + ): + # Input is RGB with R=G=B. Add An alpha band + tmp_ds = mem_driver.Create( + "", dsquerytile.RasterXSize, dsquerytile.RasterYSize, 2 + ) + tmp_ds.GetRasterBand(1).WriteRaster( + 0, + 0, + tile_job_info.tile_size, + tile_job_info.tile_size, + dsquerytile.GetRasterBand(1).ReadRaster(), + ) + mask = bytearray( + [255] * (tile_job_info.tile_size * tile_job_info.tile_size) + ) + tmp_ds.GetRasterBand(2).WriteRaster( + 0, + 0, + tile_job_info.tile_size, + tile_job_info.tile_size, + mask, + ) + tmp_ds.GetRasterBand(2).SetColorInterpretation(gdal.GCI_AlphaBand) + dsquerytile = tmp_ds + elif dsquerytile.RasterCount == tilebands - 1: # assume that the alpha band is missing and add it tmp_ds = mem_driver.CreateCopy("", dsquerytile, 0) tmp_ds.AddBand() @@ -1559,7 +1633,10 @@ def create_overview_tile( ) dsquerytile = tmp_ds elif dsquerytile.RasterCount != tilebands: - raise Exception("Unexpected number of bands in base tile") + raise Exception( + "Unexpected number of bands in base tile. Got %d, expected %d" + % (dsquerytile.RasterCount, tilebands) + ) base_data = dsquerytile.ReadRaster( 0, 0, tile_job_info.tile_size, tile_job_info.tile_size @@ -1584,7 +1661,12 @@ def create_overview_tile( if options.resampling != "antialias": # Write a copy of tile to png/jpg out_driver.CreateCopy( - tilefilename, dstile, strict=0, options=_get_creation_options(options) + tilefilename, + dstile + if tile_job_info.tile_driver != "JPEG" + else remove_alpha_band(dstile), + strict=0, + options=_get_creation_options(options), ) # Remove useless side car file aux_xml = tilefilename + ".aux.xml" @@ -1772,7 +1854,7 @@ def optparse_init() -> optparse.OptionParser: p.add_option( "--tiledriver", dest="tiledriver", - choices=["PNG", "WEBP"], + choices=["PNG", "WEBP", "JPEG"], default="PNG", type="choice", help="which tile driver to use for the tiles", @@ -1883,6 +1965,17 @@ def optparse_init() -> optparse.OptionParser: ) p.add_option_group(g) + # Jpeg options + g = optparse.OptionGroup(p, "JPEG options", "Options for JPEG tiledriver") + g.add_option( + "--jpeg-quality", + dest="jpeg_quality", + type=int, + default=75, + help="quality of jpeg image, integer between 1 and 100, default is 75", + ) + p.add_option_group(g) + p.set_defaults( verbose=False, profile="mercator", @@ -1996,6 +2089,13 @@ def options_post_processing( if options.webp_quality <= 0 or options.webp_quality > 100: exit_with_error("webp_quality should be in the range [1-100]") options.webp_quality = int(options.webp_quality) + elif options.tiledriver == "JPEG": + if gdal.GetDriverByName(options.tiledriver) is None: + exit_with_error("JPEG driver is not available") + + if options.jpeg_quality <= 0 or options.jpeg_quality > 100: + exit_with_error("jpeg_quality should be in the range [1-100]") + options.jpeg_quality = int(options.jpeg_quality) # Output the results if options.verbose: @@ -2113,8 +2213,10 @@ def __init__(self, input_file: str, output_folder: str, options: Options) -> Non self.tiledriver = options.tiledriver if options.tiledriver == "PNG": self.tileext = "png" - else: + elif options.tiledriver == "WEBP": self.tileext = "webp" + else: + self.tileext = "jpg" if options.mpi: makedirs(output_folder) self.tmp_dir = tempfile.mkdtemp(dir=output_folder)