changeset 0:c20b5314774f

Initial commit.
author David Barts <n5jrn@me.com>
date Tue, 24 Aug 2021 17:40:00 -0700
parents
children d268ae31f94b
files Readme.rst make-tile
diffstat 2 files changed, 355 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Readme.rst	Tue Aug 24 17:40:00 2021 -0700
@@ -0,0 +1,89 @@
+TopoTiler Readme
+================
+
+What Does This Do?
+------------------
+
+This program allows you to turn USGS GeoPDF files (such as those from the
+`National Map <https://nationalmap.gov>`_ program) into tileable GeoTIFF
+files.
+
+By "tileable" I mean suitable for tiling into a complete raster
+layer with gdalwarp or some other such tool. This implies that the so-called
+"map collar" (i.e. the margin and any notation therein, basically anything
+other than the represenataional body of the map) is removed and cropped
+away.
+
+Why Do This?
+------------
+
+It allows one to use such maps as the basis for a raster layer in QGIS
+and other GIS programs.
+
+The National Map products are very nice, high-quality topo maps. Their greatest
+flaw is that they are distributed only as GeoPDF documents, and such a format
+is really only useful for generating printed output (and not as input to a GIS).
+Rendering such documents into tileable raster data is the easiest workaround
+to this drawback.
+
+Why Is This Only a Command-Line Program?
+----------------------------------------
+
+Two reasons:
+
+1. I have not yet had time to write a GUI front end for it. I wrote TopoTiler
+   to serve an occasional need of mine, and a command-line utility works well
+   enough.
+2. I am not yet certain if there is really a need to. A lot of that depends
+   on the need for TopoTiler in the first place. If this program proves useful
+   to a lot of people, and the consensus among its user base is that a GUI
+   would be beneficial, I will probably add one.
+   
+Prerequisites
+-------------
+
+This program requires `python3 <https://python.org/>`_ (specifically, Python 3.9 or better), the
+`Pillow Python library <https://python-pillow.org/>`_, and
+`GDAL <https://gdal.org/>`_ to be installed.
+
+make-tile
+---------
+
+This is the executable script to run.
+
+Synopsis
+^^^^^^^^
+
+``make-tile`` [``-l``/``--layers`` *list*] [``-r``/``--resolution`` *dpi*] *file*
+
+Convert USGS GeoPDF to tileable GeoTIFF. Input files must have an
+extension of ``.pdf`` (case insensitive). Output files will be generated to
+match each input file, with an extension of ``.tiff``.
+
+Arguments
+^^^^^^^^^
+
+-l/--layers
+        Comma-separated list of layers to include. See below for default.
+-r/--resolution
+        Output resolution in DPI. Default is 300.
+        
+Environment
+^^^^^^^^^^^
+
+``GDALBIN`` may be set to the directory containing the GDAL executables. If
+so, they will only be looked for there. Else the standard execution path
+will be searched.
+
+``TMPDIR`` may be set to a place to write temporary files. Doing so is a
+good idea if ``make-tile`` runs out of space in the default temporary files
+area (this program makes very large scratch files).
+
+Layers
+^^^^^^
+
+By default, all ``Map_Frame`` layers, except for
+``Map_Frame.Projection_and_Grids`` and
+``Map_Frame.Terrain.Shaded_Relief``, will be included. This behavior can be
+changed with the ``--layers`` option. 
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/make-tile	Tue Aug 24 17:40:00 2021 -0700
@@ -0,0 +1,266 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# should honor GDALBIN, if set, else use PATH
+# https://opengislab.com/blog/2016/4/2/usgs-geopdf-to-geotif-with-gdal
+# gdalinfo -json -mdd LAYERS gunk.pdf
+# build list of all non-Map.Frame.* layers
+# to that add Map_Frame.Projection_and_Grids
+# First:
+# gdal_translate gunk.pdf /tmp/xxxxx.tif -of GTiff --config GDAL_PDF_LAYERS_OFF Map_Collar,Map_Frame.Projections_and_Grids,Map_Frame.Terrain.Shaded_Relief,Images.Orthoimage --config GDAL_PDF_DPI 300
+# Then:
+# gdal_translate /tmp/xxxxx.tif gunk.tif -of GTiff -srcwin xoff yoff xsize ysize
+# Need to filter out all errors containing: insufficient arguments for Marked Content
+# also fiter out: More than 1000 errors or warnings have been reported. No more will be reported from now.
+
+# I m p o r t s
+
+import os, sys
+import argparse
+import json
+import subprocess, shutil
+import locale
+from abc import abstractmethod
+from enum import IntEnum
+from tempfile import TemporaryDirectory
+
+# PIL is ...weird... and needs both of these.
+import PIL
+from PIL import Image
+
+# V a r i a b l e s
+
+MYNAME = os.path.basename(sys.argv[0])
+estat = 0
+ENCODING = "UTF-8"
+ENVIRON = os.environ.copy()
+for k in ENVIRON.keys():
+    if k.startswith("LC_"): del(ENVIRON[k])
+ENVIRON["LANG"] = ENVIRON["LC_ALL"] = "C." + ENCODING
+
+# C l a s s e s
+
+# A horizontal or vertical strip of an image. Assumes RGB images with 8-bit
+# color.
+class Strip:
+    def __init__(self, based_on, index):
+        if not isinstance(based_on, PIL.Image.Image):
+            raise TypeError("Expecting RGB PIL.Image object for based_on.")
+        if based_on.mode != "RGB":
+            raise ValueError("Expecting RGB PIL.Image object for based_on.")
+        self._index = index
+        self._based_on = based_on
+
+    @property
+    def index(self):
+        return self._index
+
+    @abstractmethod
+    def __len__(self):
+        pass
+
+    @abstractmethod
+    def __getitem__(self, key):
+        pass
+
+    @abstractmethod
+    def __setitem__(self, key, value):
+        pass
+
+    def is_all_white(self):
+        for i in range(len(self)):
+            if self[i] != (255, 255, 255):
+                return False
+        return True
+
+# A horizontal strip of an image.
+class Row(Strip):
+    def __len__(self):
+        return self._based_on.width
+
+    def __getitem__(self, key):
+        return self._based_on.getpixel((key, self._index))
+
+    def __setitem__(self, key, value):
+        self._based_on.setpixel((key, self._index), value)
+
+# A vertical strip of an image.
+class Column(Strip):
+    def __len__(self):
+        return self._based_on.height
+
+    def __getitem__(self, key):
+        return self._based_on.getpixel((self._index, key))
+
+    def __setitem__(self, key, value):
+        self._based_on.setpixel((self._index, key), value)
+
+# A direction in which to search.
+class Direction(IntEnum):
+    DESCENDING = -1
+    ASCENDING = 1
+
+# An orientation in which to search.
+class Orientation(IntEnum):
+    VERTICAL = 0
+    HORIZONTAL = 1
+
+class TopoTilerException(Exception):
+    pass
+
+# F u n c t i o n s
+
+def find_edge(image, orientation, direction):
+    """
+    Find an edge in an image with a white border.
+    """
+    if orientation == Orientation.VERTICAL:
+        what = "column"
+        strip = Column
+        maxn = image.width - 1
+    else:
+        what = "row"
+        strip = Row
+        maxn = image.height - 1
+    # Establish outer bound and sanity-check it.
+    outer = 0 if direction == Direction.ASCENDING else maxn
+    if not strip(image, outer).is_all_white():
+        raise TopoTilerException("Unexpected nonwhite edge ({0} = {1})!".format(what, outer))
+    # Establish inner bound and sanity-check it.
+    inner = outer + direction * maxn // 4
+    if strip(image, inner).is_all_white():
+        raise TopoTilerException("Unexpected white strip ({0} = {1})!".format(what, inner))
+    while abs(outer - inner) > 1:
+        new = (outer + inner) // 2
+        if strip(image, new).is_all_white():
+            outer = new
+        else:
+            inner = new
+    return inner
+
+def positive_int(string):
+    """
+    Specify a positive integer argument.
+    """
+    try:
+        ret = int(string)
+    except ValueError as e:
+        raise argparse.ArgumentTypeError(e.message)
+    if ret <= 0:
+        raise argparse.ArgumentTypeError("%r is 0 or negative" % string)
+    return ret
+
+def gdalcmd(command, *args):
+    """
+    Build gdal command vector for subprocess.Popen
+    """
+    # print('$', command, ' '.join(args))  # debug
+    gdalbin = os.environ.get("GDALBIN")
+    cmd = shutil.which(command) if gdalbin is None else os.path.join(gdalbin, command)
+    ret = list(args)
+    ret.insert(0, cmd)
+    return ret
+
+_MUZZLE = [
+    "insufficient arguments for Marked Content",
+    "More than 1000 errors or warnings have been reported."
+]
+def drainout(stream):
+    """
+    Drain an output/error stream, muzzling the meaningless babble.
+    """
+    for line in stream:
+        printit = True
+        for m in _MUZZLE:
+            if m in line:
+                printit = False
+                break
+        if printit:
+            sys.stderr.write(line)
+
+def waitfor(proc):
+    global estat
+    command_name = os.path.basename(proc.args[0])
+    status = proc.wait()
+    if status < 0:
+        sys.stderr.write("{0}: {1} killed by signal {2}\n".format(MYNAME, command_name, -status))
+        estat = 1
+    elif status > 0:
+        sys.stderr.write("{0}: {1} exited with status {2}\n".format(MYNAME, command_name, status))
+        estat = 1
+
+_BLACKLIST = set(["Map_Frame.Projection_and_Grids", "Map_Frame.Terrain.Shaded_Relief"])
+def get_default_layers(name):
+    """
+    Given a file, analyze it, and get the list of layers to not include
+    when rendering it.
+    """
+    proc = subprocess.Popen(
+        gdalcmd("gdalinfo", "-json", "-mdd", "LAYERS", name),
+        stdout=subprocess.PIPE, stderr=sys.stderr, env=ENVIRON, encoding=ENCODING)
+    data = json.load(proc.stdout)
+    waitfor(proc)
+    layers = set(["Map_Frame"])
+    for layer in data["metadata"]["LAYERS"].values():
+        if layer.startswith("Map_Frame.") and layer not in _BLACKLIST:
+            layers.add(layer)
+    return ",".join(layers)
+
+# M a i n   P r o g r a m
+
+# Parse command-line arguments.
+parser = argparse.ArgumentParser(description='Render GeoPDF into GeoTIFF suitable for tiling.')
+parser.add_argument('--layers', '-l', type=str,
+    help='List of layers to include (see documentation for default).')
+parser.add_argument('--resolution', '-r', type=positive_int, default=300,
+    help='Resolution to render at in DPI.')
+parser.add_argument('file', nargs=1,
+    help='File to read (must be a GeoPDF).')
+try:
+    args = parser.parse_args()
+except SystemExit:
+    sys.exit(2)
+
+# File must end with '.pdf' (case insensitive), or else!
+if not isinstance(args.file, str):
+    # How silly, it gave us a list or a tuple, despite only one of them!
+    args.file = args.file[0]
+if not args.file.lower().endswith('.pdf'):
+    sys.steder.write("{0}: input file must end with .pdf (case insensitive)\n".format(MYNAME))
+
+# Default the set of layers to delete, if necessary
+if args.layers is None:
+    args.layers = get_default_layers(args.file)
+
+with TemporaryDirectory() as td:
+    # Get scratch file name. This goes under TMPDIR; if the default temporary
+    # area is too small, set that environment variable accordingly!
+    tf = os.path.join(td, "temp.tiff")
+
+    # Render. The result will have undesired margins.
+    sys.stdout.write("Rendering (may take a while)...\n")
+    sys.stdout.flush()
+    proc = subprocess.Popen(
+        gdalcmd("gdal_translate", "-q", args.file, tf, "-of", "GTiff", "--config", "GDAL_PDF_LAYERS", args.layers, "--config", "GDAL_PDF_DPI", str(args.resolution)),
+        stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=ENVIRON, encoding=ENCODING)
+    drainout(proc.stderr)
+    waitfor(proc)
+    sys.stdout.write("Done!\n")
+
+    # Determine crop marquee.
+    with Image.open(tf) as im:
+        minx = find_edge(im, Orientation.VERTICAL, Direction.ASCENDING)
+        maxx = find_edge(im, Orientation.VERTICAL, Direction.DESCENDING)
+        miny = find_edge(im, Orientation.HORIZONTAL, Direction.ASCENDING)
+        maxy = find_edge(im, Orientation.HORIZONTAL, Direction.DESCENDING)
+
+    # Determine output file name.
+    outf = os.path.splitext(args.file)[0] + ".tiff"
+
+    # Crop.
+    proc = subprocess.Popen(
+        gdalcmd("gdal_translate", "-q", tf, outf, "-of", "GTiff", "-srcwin", str(minx), str(miny), str(maxx-minx+1), str(maxy-miny+1)),
+        stdout=subprocess.DEVNULL, stderr=sys.stderr)
+    waitfor(proc)
+
+# AMF...
+sys.exit(estat)