Source code for polar2grid.add_coastlines

#!/usr/bin/env python3
# encoding: utf-8
# Copyright (C) 2016-2021 Space Science and Engineering Center (SSEC),
# University of Wisconsin-Madison.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# This file is part of the polar2grid software package. Polar2grid takes
# satellite observation data, remaps it, and writes it to a file format for
#     input into another program.
# Documentation: http://www.ssec.wisc.edu/software/polar2grid/
#
# Written by David Hoese    July 2016
# University of Wisconsin-Madison
# Space Science and Engineering Center
# 1225 West Dayton Street
# Madison, WI  53706
# david.hoese@ssec.wisc.edu
"""Script to add coastlines and borders to a geotiff while also creating a PNG."""
from __future__ import annotations

import argparse
import logging
import os
import sys

import numpy as np
import rasterio
from aggdraw import Font
from PIL import Image, ImageFont
from pkg_resources import resource_filename as get_resource_filename
from pycoast import ContourWriterAGG
from pydecorate import DecoratorAGG
from pyresample.utils import get_area_def_from_raster
from trollimage.colormap import Colormap

from polar2grid.utils.config import add_polar2grid_config_paths

LOG = logging.getLogger(__name__)
PYCOAST_DIR = os.environ.get("GSHHS_DATA_ROOT")


[docs]def _get_rio_colormap(rio_ds, bidx): try: return rio_ds.colormap(bidx) except ValueError: return None
[docs]def _get_colorbar_vmin_vmax(arg_min, arg_max, metadata, input_dtype): scale = metadata.get("scale", metadata.get("scale_factor")) offset = metadata.get("offset", metadata.get("add_offset")) dtype_min = float(np.iinfo(input_dtype).min) dtype_max = float(np.iinfo(input_dtype).max) if arg_min is None and scale is None: LOG.warning( "Colorbar min/max metadata not found and not provided " "on the command line. Defaulting to data type limits." ) return dtype_min, dtype_max if arg_min is not None: vmin = float(arg_min) vmax = float(arg_max) else: scale, offset = _convert_scale_offset_to_float(scale, offset) vmin, vmax = _vmin_vmax_from_scale_offset(scale, offset, dtype_min, dtype_max) return vmin, vmax
[docs]def _convert_scale_offset_to_float(scale: str, offset: str) -> tuple[float, float]: scale = float(scale) offset = float(offset) if np.isnan(scale) or np.isnan(offset): raise ValueError( "Can't automatically set colorbar limits with " "geotiff metadata as scale/offset are set to " "NaN. This indicates a non-linear enhancement or " "RGB/A composite that was enhanced. These cases " "cannot be represented properly by a colorbar." ) return scale, offset
[docs]def _vmin_vmax_from_scale_offset(scale: float, offset: float, dtype_min: int, dtype_max: int) -> tuple[float, float]: delta = dtype_max - dtype_min vmin = offset vmax = delta * scale + offset # floating point error made it not an integer if abs(vmin - np.round(vmin, 0)) <= 0.001: vmin = np.round(vmin, 0) if abs(vmax - np.round(vmax, 0)) <= 0.001: vmax = np.round(vmax, 0) return vmin, vmax
[docs]def _apply_decorator_alignment(dc, align): if align == "top": dc.align_top() dc.write_horizontally() elif align == "bottom": dc.align_bottom() dc.write_horizontally() elif align == "left": dc.align_left() dc.write_vertically() elif align == "right": dc.align_right() dc.write_vertically()
[docs]def _add_colorbar_to_image(img, font, tick_color, align, **kwargs): dc = DecoratorAGG(img) _apply_decorator_alignment(dc, align) dc.add_scale( font=font, line=tick_color, **kwargs, )
[docs]def get_parser(): parser = argparse.ArgumentParser( description="Add overlays to a GeoTIFF file and save as a PNG file.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) _add_coastlines_arguments(parser) _add_rivers_arguments(parser) _add_grid_arguments(parser) _add_borders_arguments(parser) _add_colorbar_arguments(parser) _add_global_arguments(parser) parser.add_argument("input_tiff", nargs="+", help="Input geotiff(s) to process") return parser
[docs]def _add_coastlines_arguments(parser: argparse.ArgumentParser) -> None: group = parser.add_argument_group("coastlines") group.add_argument("--add-coastlines", action="store_true", help="Add coastlines") group.add_argument( "--coastlines-resolution", choices="clihf", default="i", help="Resolution of coastlines to add (crude, low, intermediate, high, full)", ) group.add_argument( "--coastlines-level", choices=range(1, 7), type=int, default=4, help="Level of detail from the selected resolution dataset", ) group.add_argument( "--coastlines-outline", default=["yellow"], nargs="*", help="Color of coastline lines (color name or 3 RGB integers)", ) group.add_argument("--coastlines-fill", default=None, nargs="*", help="Color of land") group.add_argument("--coastlines-width", default=1.0, type=float, help="Width of coastline lines")
[docs]def _add_rivers_arguments(parser: argparse.ArgumentParser) -> None: group = parser.add_argument_group("rivers") group.add_argument("--add-rivers", action="store_true", help="Add rivers grid") group.add_argument( "--rivers-resolution", choices="clihf", default="c", help="Resolution of rivers to add (crude, low, intermediate, high, full)", ) group.add_argument( "--rivers-level", choices=range(0, 11), type=int, default=5, help="Level of detail for river lines" ) group.add_argument( "--rivers-outline", default=["blue"], nargs="*", help="Color of river lines (color name or 3 RGB integers)" ) group.add_argument("--rivers-width", default=1.0, type=float, help="Width of rivers lines")
[docs]def _add_grid_arguments(parser: argparse.ArgumentParser) -> None: group = parser.add_argument_group("grid") group.add_argument("--add-grid", action="store_true", help="Add lat/lon grid") group.add_argument("--grid-no-text", dest="grid_text", action="store_false", help="Add labels to lat/lon grid") group.add_argument("--grid-text-size", default=32, type=int, help="Lat/lon grid text font size") group.add_argument("--grid-font", default="Vera.ttf", help="Path to TTF font (package provided or custom path)") group.add_argument( "--grid-fill", nargs="*", default=["cyan"], help="Color of grid text (color name or 3 RGB integers)" ) group.add_argument( "--grid-outline", nargs="*", default=["cyan"], help="Color of grid lines (color name or 3 RGB integers)" ) group.add_argument( "--grid-minor-outline", nargs="*", default=["cyan"], help="Color of tick lines (color name or 3 RGB integers)" ) group.add_argument( "--grid-D", nargs=2, default=(10.0, 10.0), type=float, help="Degrees between grid lines (lon, lat)" ) group.add_argument( "--grid-d", nargs=2, default=(2.0, 2.0), type=float, help="Degrees between tick lines (lon, lat)" ) group.add_argument( "--grid-lon-placement", choices=["tl", "lr", "lc", "cc"], default="tb", help="Longitude label placement" ) group.add_argument( "--grid-lat-placement", choices=["tl", "lr", "lc", "cc"], default="lr", help="Latitude label placement" ) group.add_argument("--grid-width", default=1.0, type=float, help="Width of grid lines")
[docs]def _add_borders_arguments(parser: argparse.ArgumentParser) -> None: group = parser.add_argument_group("borders") group.add_argument("--add-borders", action="store_true", help="Add country and/or region borders") group.add_argument( "--borders-resolution", choices="clihf", default="i", help="Resolution of borders to add (crude, low, intermediate, high, full)", ) group.add_argument( "--borders-level", choices=range(1, 4), default=2, type=int, help="Level of detail for border lines" ) group.add_argument( "--borders-outline", default=["white"], nargs="*", help="Color of border lines (color name or 3 RGB integers)" ) group.add_argument("--borders-width", default=1.0, type=float, help="Width of border lines")
[docs]def _add_colorbar_arguments(parser: argparse.ArgumentParser) -> None: group = parser.add_argument_group("colorbar") group.add_argument("--add-colorbar", action="store_true", help="Add colorbar on top of image") group.add_argument( "--colorbar-colormap-file", help=argparse.SUPPRESS, # help="Specify the colormap file that was used to " # "colorize the provided RGB geotiff. Only used if " # "the provided geotiff is RGB/A. Otherwise the " # "geotiff is expected to include the colormap as " # "a geotiff color table.", ) group.add_argument("--colorbar-width", type=int, help="Number of pixels wide") group.add_argument("--colorbar-height", type=int, help="Number of pixels high") group.add_argument( "--colorbar-extend", action="store_true", help="Extend colorbar to full width/height of the image" ) group.add_argument( "--colorbar-tick-marks", type=float, default=5.0, help="Major tick and tick label interval in data units" ) group.add_argument("--colorbar-minor-tick-marks", type=float, default=1.0, help="Minor tick interval in data units") group.add_argument("--colorbar-text-size", default=32, type=int, help="Tick label font size") group.add_argument( "--colorbar-text-color", nargs="*", default=["black"], help="Color of tick text (color name or 3 RGB integers)" ) group.add_argument("--colorbar-font", default="Vera.ttf", help="Path to TTF font (package provided or custom path)") group.add_argument( "--colorbar-align", choices=["left", "top", "right", "bottom"], default="bottom", help="Which side of the image to place the colorbar", ) group.add_argument("--colorbar-vertical", action="store_true", help="DEPRECATED") group.add_argument( "--colorbar-min", type=float, help="Minimum data value of the colorbar." " Defaults to 'min_in' of input metadata or" " minimum value of the data otherwise.", ) group.add_argument( "--colorbar-max", type=float, help="Maximum data value of the colorbar." " Defaults to 'max_in' of input metadata or" " maximum value of the data otherwise.", ) group.add_argument("--colorbar-units", help="Units marker to include in the colorbar text") group.add_argument("--colorbar-title", help="Title shown with the colorbar")
[docs]def _add_global_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--shapes-dir", default=PYCOAST_DIR, help="Specify alternative directory for coastline shape files (default: GSHSS_DATA_ROOT)", ) parser.add_argument( "--cache-dir", default=None, help="Specify directory where cached coastline output can be stored and accessed in later " "executions. The cache will never be cleared by this script. Caching depends on the grid " "of the image and the decorations added to the image.", ) parser.add_argument( "--cache-regenerate", action="store_true", help="Force regeneration of any cached overlays. Requires '--cache-dir'.", ) parser.add_argument( "-o", "--output", dest="output_filename", nargs="+", help="Specify the output filename (default replace '.tif' with '.png')", ) parser.add_argument( "-v", "--verbose", dest="verbosity", action="count", default=0, help="each occurrence increases verbosity 1 level through ERROR-WARNING-INFO-DEBUG (default INFO)", )
[docs]def main(argv=sys.argv[1:]): parser = get_parser() args = parser.parse_args(argv) levels = [logging.ERROR, logging.WARN, logging.INFO, logging.DEBUG] logging.basicConfig(level=levels[min(3, args.verbosity)]) add_polar2grid_config_paths() if args.output_filename is None: args.output_filename = [x[:-3] + "png" for x in args.input_tiff] elif len(args.output_filename) != len(args.input_tiff): LOG.error("Output filenames must be equal to number of input tiffs") return -1 if not (args.add_borders or args.add_coastlines or args.add_grid or args.add_rivers or args.add_colorbar): LOG.error("Please specify one of the '--add-X' options to modify the image") return -1 if args.cache_dir and not os.path.isdir(args.cache_dir): LOG.info(f"Creating cache directory: {args.cache_dir}") os.makedirs(args.cache_dir, exist_ok=True) # we may be dealing with large images that look like decompression bombs # let's turn off the check for the image size in PIL/Pillow Image.MAX_IMAGE_PIXELS = None # gather all options into a single dictionary that we can pass to pycoast pycoast_options = _args_to_pycoast_dict(args) colorbar_kwargs = _args_to_colorbar_kwargs(args) if args.add_colorbar else {} for input_tiff, output_filename in zip(args.input_tiff, args.output_filename): _process_one_image(input_tiff, output_filename, pycoast_options, args.shapes_dir, colorbar_kwargs) return 0
[docs]def _args_to_pycoast_dict(args): opts = {} if args.add_coastlines: opts["coasts"] = _args_to_coastlines_dict(args) if args.add_rivers: opts["rivers"] = _args_to_rivers_dict(args) if args.add_borders: opts["borders"] = _args_to_borders_dict(args) if args.add_grid: opts["grid"] = _args_to_grid_dict(args) if args.cache_dir: opts["cache"] = { # add "add_coastlines" prefix to cached image name "file": os.path.join(args.cache_dir, "add_coastlines"), "regenerate": args.cache_regenerate, } return opts
[docs]def _args_to_coastlines_dict(args): outline = ( args.coastlines_outline[0] if len(args.coastlines_outline) == 1 else tuple(int(x) for x in args.coastlines_outline) ) if args.coastlines_fill: fill = ( args.coastlines_fill[0] if len(args.coastlines_fill) == 1 else tuple(int(x) for x in args.coastlines_fill) ) else: fill = None coasts_dict = { "resolution": args.coastlines_resolution, "level": args.coastlines_level, "width": args.coastlines_width, "outline": outline, "fill": fill, } return coasts_dict
[docs]def _args_to_rivers_dict(args): outline = args.rivers_outline[0] if len(args.rivers_outline) == 1 else tuple(int(x) for x in args.rivers_outline) rivers_dict = { "resolution": args.rivers_resolution, "level": args.rivers_level, "width": args.rivers_width, "outline": outline, } return rivers_dict
[docs]def _args_to_borders_dict(args): outline = args.borders_outline[0] if len(args.borders_outline) == 1 else tuple(int(x) for x in args.borders_outline) borders_dict = { "resolution": args.borders_resolution, "level": args.borders_level, "width": args.borders_width, "outline": outline, } return borders_dict
[docs]def _args_to_grid_dict(args): outline = args.grid_outline[0] if len(args.grid_outline) == 1 else tuple(int(x) for x in args.grid_outline) minor_outline = ( args.grid_minor_outline[0] if len(args.grid_minor_outline) == 1 else tuple(int(x) for x in args.grid_minor_outline) ) fill = args.grid_fill[0] if len(args.grid_fill) == 1 else tuple(int(x) for x in args.grid_fill) font_path = find_font(args.grid_font, args.grid_text_size) font = Font(outline, font_path, size=args.grid_text_size) grid_dict = { "lon_major": args.grid_D[0], "lat_major": args.grid_D[1], "lon_minor": args.grid_d[0], "lat_minor": args.grid_d[1], "font": font, "fill": fill, "outline": outline, "minor_outline": minor_outline, "write_text": args.grid_text, "width": args.grid_width, "lon_placement": args.grid_lon_placement, "lat_placement": args.grid_lat_placement, } return grid_dict
[docs]def _args_to_colorbar_kwargs(args): font_color = args.colorbar_text_color font_color = font_color[0] if len(font_color) == 1 else tuple(int(x) for x in font_color) font_path = find_font(args.colorbar_font, args.colorbar_text_size) # this actually needs an aggdraw font font = Font(font_color, font_path, size=args.colorbar_text_size) if args.colorbar_width is None or args.colorbar_height is None: if args.colorbar_width is not None or args.colorbar_height is not None: LOG.warning( "'--colorbar-width' and '--colorbar-height' were not both specified. " "Forcing '--colorbar-extend'." ) args.colorbar_extend = True colorbar_kwargs = { "font": font, "tick_color": font_color, "cmin": args.colorbar_min, "cmax": args.colorbar_max, "align": args.colorbar_align, "extend": args.colorbar_extend, "tick_marks": args.colorbar_tick_marks, "minor_tick_marks": args.colorbar_minor_tick_marks, "title": args.colorbar_title, "unit": args.colorbar_units, } if args.colorbar_width: colorbar_kwargs["width"] = args.colorbar_width if args.colorbar_height: colorbar_kwargs["height"] = args.colorbar_height return colorbar_kwargs
[docs]def find_font(font_name, size): try: font = ImageFont.truetype(font_name, size) return font.path except IOError: font_path = get_resource_filename("polar2grid.fonts", font_name) if not os.path.exists(font_path): raise ValueError("Font path does not exist: {}".format(font_path)) return font_path
[docs]def _process_one_image( input_tiff: str, output_filename: str, pycoast_options: dict, shapes_dir: str, colorbar_kwargs: dict ) -> None: LOG.info("Creating {} from {}".format(output_filename, input_tiff)) img = Image.open(input_tiff) img_bands = img.getbands() num_bands = len(img_bands) # P = palette which we assume to be an RGBA colormap img = img.convert("RGBA" if num_bands in (2, 4) or "P" in img_bands else "RGB") if pycoast_options: area_id = os.path.splitext(input_tiff[0])[0] area_def = get_area_def_from_raster(input_tiff, area_id=area_id) cw = ContourWriterAGG(shapes_dir) cw.add_overlay_from_dict(pycoast_options, area_def, background=img) if colorbar_kwargs: colorbar_kwargs = colorbar_kwargs.copy() cmin = colorbar_kwargs.pop("cmin") cmax = colorbar_kwargs.pop("cmax") cmap = _get_colormap_object(input_tiff, num_bands, cmin, cmax) _add_colorbar_to_image(img, colormap=cmap, **colorbar_kwargs) img.save(output_filename)
[docs]def _get_colormap_object(input_tiff, num_bands, cmin, cmax): rio_ds = rasterio.open(input_tiff) input_dtype = np.dtype(rio_ds.meta["dtype"]) colormap_csv = rio_ds.tags().get("colormap") rio_ct = _get_rio_colormap(rio_ds, 1) metadata = rio_ds.tags() cmap = _convert_table_to_cmap_or_default_bw(input_dtype, rio_ct, num_bands) if num_bands in (3, 4) and colormap_csv is None: raise ValueError("RGB and RGBA geotiffs must have a colormap " "specified with '--colorbar-colormap-file'.") if num_bands in (3, 4) or colormap_csv is not None: cmap = Colormap.from_file(colormap_csv) vmin, vmax = _get_colorbar_vmin_vmax(cmin, cmax, metadata, input_dtype) cmap = cmap.set_range(vmin, vmax, inplace=False) return cmap
[docs]def _convert_table_to_cmap_or_default_bw(band_dtype, band_ct, band_count): max_val = np.iinfo(band_dtype).max # if we have an alpha band then include the entire colormap # otherwise assume it is using 0 as a fill value start_idx = 1 if band_count == 1 else 0 if band_ct is None: # all black colormap # don't assume anything about the colors or scaling of the image if the # user didn't provide enough information color_iter = ((idx / float(max_val), (0.0, 0.0, 0.0, 1.0)) for idx in range(max_val)) color_iter = list(color_iter) else: color_iter = ((idx / float(max_val), color) for idx, color in sorted(band_ct.items())[start_idx:]) color_iter = ((idx, tuple(x / float(max_val) for x in color)) for idx, color in color_iter) cmap = Colormap(*color_iter) return cmap
if __name__ == "__main__": sys.exit(main())