0
|
1 #!/usr/bin/env python3
|
|
2 # -*- coding: utf-8 -*-
|
|
3 # should honor GDALBIN, if set, else use PATH
|
|
4 # https://opengislab.com/blog/2016/4/2/usgs-geopdf-to-geotif-with-gdal
|
|
5 # gdalinfo -json -mdd LAYERS gunk.pdf
|
|
6 # build list of all non-Map.Frame.* layers
|
|
7 # to that add Map_Frame.Projection_and_Grids
|
|
8 # First:
|
|
9 # 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
|
|
10 # Then:
|
|
11 # gdal_translate /tmp/xxxxx.tif gunk.tif -of GTiff -srcwin xoff yoff xsize ysize
|
|
12 # Need to filter out all errors containing: insufficient arguments for Marked Content
|
|
13 # also fiter out: More than 1000 errors or warnings have been reported. No more will be reported from now.
|
|
14
|
|
15 # I m p o r t s
|
|
16
|
|
17 import os, sys
|
|
18 import argparse
|
|
19 import json
|
|
20 import subprocess, shutil
|
|
21 import locale
|
|
22 from abc import abstractmethod
|
|
23 from enum import IntEnum
|
|
24 from tempfile import TemporaryDirectory
|
|
25
|
|
26 # PIL is ...weird... and needs both of these.
|
|
27 import PIL
|
|
28 from PIL import Image
|
|
29
|
|
30 # V a r i a b l e s
|
|
31
|
|
32 MYNAME = os.path.basename(sys.argv[0])
|
|
33 estat = 0
|
|
34 ENCODING = "UTF-8"
|
|
35 ENVIRON = os.environ.copy()
|
|
36 for k in ENVIRON.keys():
|
|
37 if k.startswith("LC_"): del(ENVIRON[k])
|
|
38 ENVIRON["LANG"] = ENVIRON["LC_ALL"] = "C." + ENCODING
|
|
39
|
|
40 # C l a s s e s
|
|
41
|
|
42 # A horizontal or vertical strip of an image. Assumes RGB images with 8-bit
|
|
43 # color.
|
|
44 class Strip:
|
|
45 def __init__(self, based_on, index):
|
|
46 if not isinstance(based_on, PIL.Image.Image):
|
|
47 raise TypeError("Expecting RGB PIL.Image object for based_on.")
|
|
48 if based_on.mode != "RGB":
|
|
49 raise ValueError("Expecting RGB PIL.Image object for based_on.")
|
|
50 self._index = index
|
|
51 self._based_on = based_on
|
|
52
|
|
53 @property
|
|
54 def index(self):
|
|
55 return self._index
|
|
56
|
|
57 @abstractmethod
|
|
58 def __len__(self):
|
|
59 pass
|
|
60
|
|
61 @abstractmethod
|
|
62 def __getitem__(self, key):
|
|
63 pass
|
|
64
|
|
65 @abstractmethod
|
|
66 def __setitem__(self, key, value):
|
|
67 pass
|
|
68
|
|
69 def is_all_white(self):
|
|
70 for i in range(len(self)):
|
|
71 if self[i] != (255, 255, 255):
|
|
72 return False
|
|
73 return True
|
|
74
|
|
75 # A horizontal strip of an image.
|
|
76 class Row(Strip):
|
|
77 def __len__(self):
|
|
78 return self._based_on.width
|
|
79
|
|
80 def __getitem__(self, key):
|
|
81 return self._based_on.getpixel((key, self._index))
|
|
82
|
|
83 def __setitem__(self, key, value):
|
|
84 self._based_on.setpixel((key, self._index), value)
|
|
85
|
|
86 # A vertical strip of an image.
|
|
87 class Column(Strip):
|
|
88 def __len__(self):
|
|
89 return self._based_on.height
|
|
90
|
|
91 def __getitem__(self, key):
|
|
92 return self._based_on.getpixel((self._index, key))
|
|
93
|
|
94 def __setitem__(self, key, value):
|
|
95 self._based_on.setpixel((self._index, key), value)
|
|
96
|
|
97 # A direction in which to search.
|
|
98 class Direction(IntEnum):
|
|
99 DESCENDING = -1
|
|
100 ASCENDING = 1
|
|
101
|
|
102 # An orientation in which to search.
|
|
103 class Orientation(IntEnum):
|
|
104 VERTICAL = 0
|
|
105 HORIZONTAL = 1
|
|
106
|
|
107 class TopoTilerException(Exception):
|
|
108 pass
|
|
109
|
|
110 # F u n c t i o n s
|
|
111
|
|
112 def find_edge(image, orientation, direction):
|
|
113 """
|
|
114 Find an edge in an image with a white border.
|
|
115 """
|
|
116 if orientation == Orientation.VERTICAL:
|
|
117 what = "column"
|
|
118 strip = Column
|
|
119 maxn = image.width - 1
|
|
120 else:
|
|
121 what = "row"
|
|
122 strip = Row
|
|
123 maxn = image.height - 1
|
|
124 # Establish outer bound and sanity-check it.
|
|
125 outer = 0 if direction == Direction.ASCENDING else maxn
|
|
126 if not strip(image, outer).is_all_white():
|
|
127 raise TopoTilerException("Unexpected nonwhite edge ({0} = {1})!".format(what, outer))
|
|
128 # Establish inner bound and sanity-check it.
|
|
129 inner = outer + direction * maxn // 4
|
|
130 if strip(image, inner).is_all_white():
|
|
131 raise TopoTilerException("Unexpected white strip ({0} = {1})!".format(what, inner))
|
|
132 while abs(outer - inner) > 1:
|
|
133 new = (outer + inner) // 2
|
|
134 if strip(image, new).is_all_white():
|
|
135 outer = new
|
|
136 else:
|
|
137 inner = new
|
|
138 return inner
|
|
139
|
|
140 def positive_int(string):
|
|
141 """
|
|
142 Specify a positive integer argument.
|
|
143 """
|
|
144 try:
|
|
145 ret = int(string)
|
|
146 except ValueError as e:
|
|
147 raise argparse.ArgumentTypeError(e.message)
|
|
148 if ret <= 0:
|
|
149 raise argparse.ArgumentTypeError("%r is 0 or negative" % string)
|
|
150 return ret
|
|
151
|
|
152 def gdalcmd(command, *args):
|
|
153 """
|
|
154 Build gdal command vector for subprocess.Popen
|
|
155 """
|
|
156 # print('$', command, ' '.join(args)) # debug
|
|
157 gdalbin = os.environ.get("GDALBIN")
|
|
158 cmd = shutil.which(command) if gdalbin is None else os.path.join(gdalbin, command)
|
|
159 ret = list(args)
|
|
160 ret.insert(0, cmd)
|
|
161 return ret
|
|
162
|
|
163 _MUZZLE = [
|
|
164 "insufficient arguments for Marked Content",
|
|
165 "More than 1000 errors or warnings have been reported."
|
|
166 ]
|
|
167 def drainout(stream):
|
|
168 """
|
|
169 Drain an output/error stream, muzzling the meaningless babble.
|
|
170 """
|
|
171 for line in stream:
|
|
172 printit = True
|
|
173 for m in _MUZZLE:
|
|
174 if m in line:
|
|
175 printit = False
|
|
176 break
|
|
177 if printit:
|
|
178 sys.stderr.write(line)
|
|
179
|
|
180 def waitfor(proc):
|
|
181 global estat
|
|
182 command_name = os.path.basename(proc.args[0])
|
|
183 status = proc.wait()
|
|
184 if status < 0:
|
|
185 sys.stderr.write("{0}: {1} killed by signal {2}\n".format(MYNAME, command_name, -status))
|
|
186 estat = 1
|
|
187 elif status > 0:
|
|
188 sys.stderr.write("{0}: {1} exited with status {2}\n".format(MYNAME, command_name, status))
|
|
189 estat = 1
|
|
190
|
|
191 _BLACKLIST = set(["Map_Frame.Projection_and_Grids", "Map_Frame.Terrain.Shaded_Relief"])
|
|
192 def get_default_layers(name):
|
|
193 """
|
|
194 Given a file, analyze it, and get the list of layers to not include
|
|
195 when rendering it.
|
|
196 """
|
|
197 proc = subprocess.Popen(
|
|
198 gdalcmd("gdalinfo", "-json", "-mdd", "LAYERS", name),
|
|
199 stdout=subprocess.PIPE, stderr=sys.stderr, env=ENVIRON, encoding=ENCODING)
|
|
200 data = json.load(proc.stdout)
|
|
201 waitfor(proc)
|
|
202 layers = set(["Map_Frame"])
|
|
203 for layer in data["metadata"]["LAYERS"].values():
|
|
204 if layer.startswith("Map_Frame.") and layer not in _BLACKLIST:
|
|
205 layers.add(layer)
|
|
206 return ",".join(layers)
|
|
207
|
|
208 # M a i n P r o g r a m
|
|
209
|
|
210 # Parse command-line arguments.
|
|
211 parser = argparse.ArgumentParser(description='Render GeoPDF into GeoTIFF suitable for tiling.')
|
|
212 parser.add_argument('--layers', '-l', type=str,
|
|
213 help='List of layers to include (see documentation for default).')
|
|
214 parser.add_argument('--resolution', '-r', type=positive_int, default=300,
|
|
215 help='Resolution to render at in DPI.')
|
|
216 parser.add_argument('file', nargs=1,
|
|
217 help='File to read (must be a GeoPDF).')
|
|
218 try:
|
|
219 args = parser.parse_args()
|
|
220 except SystemExit:
|
|
221 sys.exit(2)
|
|
222
|
|
223 # File must end with '.pdf' (case insensitive), or else!
|
|
224 if not isinstance(args.file, str):
|
|
225 # How silly, it gave us a list or a tuple, despite only one of them!
|
|
226 args.file = args.file[0]
|
|
227 if not args.file.lower().endswith('.pdf'):
|
|
228 sys.steder.write("{0}: input file must end with .pdf (case insensitive)\n".format(MYNAME))
|
|
229
|
|
230 # Default the set of layers to delete, if necessary
|
|
231 if args.layers is None:
|
|
232 args.layers = get_default_layers(args.file)
|
|
233
|
|
234 with TemporaryDirectory() as td:
|
|
235 # Get scratch file name. This goes under TMPDIR; if the default temporary
|
|
236 # area is too small, set that environment variable accordingly!
|
|
237 tf = os.path.join(td, "temp.tiff")
|
|
238
|
|
239 # Render. The result will have undesired margins.
|
|
240 sys.stdout.write("Rendering (may take a while)...\n")
|
|
241 sys.stdout.flush()
|
|
242 proc = subprocess.Popen(
|
|
243 gdalcmd("gdal_translate", "-q", args.file, tf, "-of", "GTiff", "--config", "GDAL_PDF_LAYERS", args.layers, "--config", "GDAL_PDF_DPI", str(args.resolution)),
|
|
244 stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=ENVIRON, encoding=ENCODING)
|
|
245 drainout(proc.stderr)
|
|
246 waitfor(proc)
|
|
247 sys.stdout.write("Done!\n")
|
|
248
|
|
249 # Determine crop marquee.
|
|
250 with Image.open(tf) as im:
|
|
251 minx = find_edge(im, Orientation.VERTICAL, Direction.ASCENDING)
|
|
252 maxx = find_edge(im, Orientation.VERTICAL, Direction.DESCENDING)
|
|
253 miny = find_edge(im, Orientation.HORIZONTAL, Direction.ASCENDING)
|
|
254 maxy = find_edge(im, Orientation.HORIZONTAL, Direction.DESCENDING)
|
|
255
|
|
256 # Determine output file name.
|
|
257 outf = os.path.splitext(args.file)[0] + ".tiff"
|
|
258
|
|
259 # Crop.
|
|
260 proc = subprocess.Popen(
|
|
261 gdalcmd("gdal_translate", "-q", tf, outf, "-of", "GTiff", "-srcwin", str(minx), str(miny), str(maxx-minx+1), str(maxy-miny+1)),
|
|
262 stdout=subprocess.DEVNULL, stderr=sys.stderr)
|
|
263 waitfor(proc)
|
|
264
|
|
265 # AMF...
|
|
266 sys.exit(estat)
|