source: osm/applications/editors/josm/plugins/lakewalker/Lakewalker/lakewalker.py@ 6874

Last change on this file since 6874 was 6874, checked in by jrreid, 16 years ago

Update lakewalker plugin to automatically break ways up into sections of length as specified in preferences

File size: 21.8 KB
Line 
1#!/usr/bin/python
2
3# Lakewalker II.
4# 2007-07-09: Initial public release (v0.2) by Dshpak
5# 2007-07-10: v0.3: Added support for OSM input files. Also reduced start_radius_big, and fixed a division-by-zero error in the degenerate case of point_line_distance().
6# 2007-08-04: Added experimental non-recursive Douglas-Peucker algorithm (--dp-nr). Added --startdir option.
7# 2007-08-06: v0.4: Bounding box support (--left, --right, --top, and --bottom), as well as new --josm mode for JOSM integration. This isn't perfect yet, but it's a good start.
8
9"""Lakewalker II - A tool to automatically download and trace Landsat imagery, to generate OpenStreetMap data.
10
11Requires the Python Imaging Library, available from http://www.pythonware.com/products/pil/"""
12
13version="Lakewalker II v0.4"
14
15# TODO:
16# - Accept threshold tags in OSM input files.
17# - Command line options:
18# - direction (deosil/widdershins)
19# - layer to use (monochrome only?)
20# - additional tags for the way
21# - Landsat download retries (count and delay)
22# - radii for loop detection
23# - fname for z12 tile output (list or script)
24# - path to tilesGen for z12 script output
25# - debug mode (extra tags on nodes) (make this non-uploadable?)
26# - The "got stuck" message should output coords
27# - Better "got stuck" detection (detect mini loops)
28# - Offset nodes outwards by a half-pixel or so, to prevent duplicate segments
29# - Automatic threshold detection
30# - (Correct/tested) non-recursive DP simplification
31#
32#
33# For JOSM integration:
34# - Add a --tmpdir option that controls the location of the Landsat
35# tiles, the results text file, and the lake.osm file (unless it's
36# got an absolute path
37# - Add a --nocache option that keeps WMS tiles in memory but not on disk.
38
39import math
40import os
41import urllib
42from PIL import Image
43import OSMReader
44import time
45import optparse
46
47options = None
48
49start_radius_big = 0.001
50start_radius_small = 0.0002
51
52dirs = ((1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1))
53dirnames = ["east", "northeast", "north", "northwest", "west", "southwest", "south", "southeast"]
54dirabbrevs = ["e", "ne", "n", "nw", "w", "sw", "s", "se"]
55
56class FatalError(Exception):
57 pass
58
59def message(s):
60 if options.josm_mode:
61 print "m %s" % s
62 else:
63 print s
64
65def error(s):
66 if options.josm_mode:
67 print "e %s" % s
68 else:
69 print s
70
71class BBox:
72 def __init__(self, top = 90, left = -180, bottom = -90, right = 180):
73 self.left = left
74 self.right = right
75 self.top = top
76 self.bottom = bottom
77 def contains(self, loc):
78 (lat, lon) = loc
79 if lat > self.top or lat < self.bottom:
80 return False
81 if (self.right - self.left) % 360 == 0:
82 return True
83 return (lon - self.left) % 360 <= (self.right - self.left) % 360
84
85def download_landsat(c1, c2, width, height, fname):
86 layer = "global_mosaic_base"
87 #style = "IR1"
88 style = options.wmslayer
89
90 (min_lat, min_lon) = c1
91 (max_lat, max_lon) = c2
92
93 message("Downloading Landsat tile for (%.6f,%.6f)-(%.6f,%.6f)." % (min_lat, min_lon, max_lat, max_lon))
94 url = "http://onearth.jpl.nasa.gov/wms.cgi?request=GetMap&layers=%s&styles=%s&srs=EPSG:4326&format=image/png&bbox=%0.6f,%0.6f,%0.6f,%0.6f&width=%d&height=%d" % (layer, style, min_lon, min_lat, max_lon, max_lat, width, height)
95 #print url
96 try:
97 urllib.urlretrieve(url, fname)
98 except IOError, e:
99 raise FatalError("Error downloading tile: %s" % e.strerror)
100 if not os.path.exists(fname):
101 raise FatalError("Error: Could not retreive url %s" % url)
102
103def xy_to_geo(xy):
104 (x, y) = xy
105 (lat, lon) = (y / float(options.resolution), x / float(options.resolution))
106 return (lat, lon)
107
108def geo_to_xy(geo):
109 (lat, lon) = geo
110 coord = lambda L: math.floor(L * options.resolution + 0.5)
111 (x, y) = (coord(lon), coord(lat))
112 return (x, y)
113
114class WMSManager:
115 def __init__(self):
116 self.images = {}
117
118 def get_tile(self, xy):
119 fail_count = 0
120 im = None
121 while im is None and fail_count < 4:
122 (x, y) = xy
123 bottom_left_xy = (int(math.floor(x / options.tilesize)) * options.tilesize, int(math.floor(y / options.tilesize)) * options.tilesize)
124 top_right_xy = (bottom_left_xy[0] + options.tilesize, bottom_left_xy[1] + options.tilesize)
125 fname = options.wmslayer+"/landsat_%d_%d_xy_%d_%d.png" % (options.resolution, options.tilesize, bottom_left_xy[0], bottom_left_xy[1])
126 im = self.images.get(fname, None)
127 if im is None:
128 if not os.path.exists(fname):
129 bottom_left = xy_to_geo(bottom_left_xy)
130 top_right = xy_to_geo(top_right_xy)
131 download_landsat(bottom_left, top_right, options.tilesize, options.tilesize, fname)
132 if not os.path.exists(fname):
133 raise FatalError("Error: Could not get image file %s" % fname)
134 try:
135 im = Image.open(fname)
136 self.images[fname] = im
137 message("Using imagery in %s for %s." % (fname, xy_to_geo(xy)))
138 except IOError:
139 error("Download was corrupt...Deleting %s..." % fname)
140 os.unlink(fname)
141 im = None
142
143 if im is None:
144 message("Sleeping and retrying download...")
145 time.sleep(4)
146 fail_count = fail_count + 1
147
148 if im is None:
149 #if os.path.exists(fname):
150 # print open(fname).readlines()
151 raise FatalError("Couldn't get image file %s." % fname)
152
153 #return (im, top_left_xy)
154 return (im, bottom_left_xy)
155
156 def get_pixel(self, xy):
157 (x, y) = xy
158 (tile, (tx, ty)) = self.get_tile(xy)
159 tile_xy = (x - tx, (options.tilesize - 1) - (y - ty))
160 #print "%s maps to %s" % (xy, tile_xy)
161 return tile.getpixel(tile_xy)
162
163def trace_lake(loc, threshold, start_dir, bbox):
164 wms = WMSManager()
165 xy = geo_to_xy(loc)
166 nodelist = []
167
168 message("Starting coordinate: %.4f, %.4f" % loc)
169 message("Starting position: %d, %d" % xy)
170
171 if not bbox.contains(loc):
172 raise FatalError("Error: Starting location is outside bounding box!")
173
174 while True:
175 loc = xy_to_geo(xy)
176 if not bbox.contains(loc):
177 break
178
179 v = wms.get_pixel(xy)
180 if v > threshold:
181 break
182
183 xy = (xy[0] + dirs[start_dir][0], xy[1] + dirs[start_dir][1])
184
185 start_xy = xy
186 start_loc = xy_to_geo(xy)
187 message("Found shore at lat %.4f lon %.4f" % start_loc)
188
189 #dirs = ((1, 0), (1, -1), (0, -1), (-1, -1), (-1, 0), (-1, 1), (0, 1), (1, 1))
190 last_dir = start_dir
191
192 detect_loop = False
193
194 for i in xrange(options.maxnodes):
195 if i % 250 == 0:
196 if i > 0:
197 message("%s nodes so far..." % i)
198
199 for d in xrange(1, len(dirs)):
200 new_dir = dirs[(last_dir + d + 4) % 8]
201 test_xy = (xy[0] + new_dir[0], xy[1] + new_dir[1])
202 test_loc = xy_to_geo(test_xy)
203 if not bbox.contains(test_loc):
204 break
205
206 v = wms.get_pixel(test_xy)
207 #print "%s: %s: %s" % (new_dir, test_xy, v)
208 if v > threshold:
209 break
210
211 if d == 8:
212 error("Got stuck.")
213 break
214
215 #print "Moving to %s, direction %s (was %s)" % (test_xy, new_dir, dirs[last_dir])
216 last_dir = (last_dir + d + 4) % 8
217 xy = test_xy
218
219 if xy == start_xy:
220 break
221
222 loc = xy_to_geo(xy)
223 nodelist.append(loc)
224
225 start_proximity = (loc[0] - start_loc[0]) ** 2 + (loc[1] - start_loc[1]) ** 2
226 if detect_loop:
227 if start_proximity < start_radius_small ** 2:
228 break
229 else:
230 if start_proximity > start_radius_big ** 2:
231 detect_loop = True
232 return nodelist
233
234def vertex_reduce(nodes, proximity):
235 test_v = nodes[0]
236 reduced_nodes = [test_v]
237 prox_sq = proximity ** 2
238 for v in nodes:
239 if (v[0] - test_v[0]) ** 2 + (v[1] - test_v[1]) ** 2 > prox_sq:
240 reduced_nodes.append(v)
241 test_v = v
242 return reduced_nodes
243
244def point_line_distance(p0, p1, p2):
245 ((x0, y0), (x1, y1), (x2, y2)) = (p0, p1, p2)
246
247 if x2 == x1 and y2 == y1:
248 # Degenerate cast: the "line" is actually a point.
249 return math.sqrt((x1-x0)**2 + (y1-y0)**2)
250 else:
251 # I don't understand this at all. Thank you, Mathworld.
252 # http://mathworld.wolfram.com/Point-LineDistance2-Dimensional.html
253 return abs((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / math.sqrt((x2-x1)**2 + (y2-y1)**2)
254
255def douglas_peucker(nodes, epsilon):
256 #print "Running DP on %d nodes" % len(nodes)
257 farthest_node = None
258 farthest_dist = 0
259 first = nodes[0]
260 last = nodes[-1]
261
262 for i in xrange(1, len(nodes) - 1):
263 d = point_line_distance(nodes[i], first, last)
264 if d > farthest_dist:
265 farthest_dist = d
266 farthest_node = i
267
268 if farthest_dist > epsilon:
269 seg_a = douglas_peucker(nodes[0:farthest_node+1], epsilon)
270 seg_b = douglas_peucker(nodes[farthest_node:-1], epsilon)
271 #print "Minimized %d nodes to %d + %d nodes" % (len(nodes), len(seg_a), len(seg_b))
272 nodes = seg_a[:-1] + seg_b
273 else:
274 return [nodes[0], nodes[-1]]
275
276 return nodes
277
278def dp_findpoint(nodes, start, end):
279 farthest_node = None
280 farthest_dist = 0
281 #print "dp_findpoint(nodes, %s, %s)" % (start, end)
282 first = nodes[start]
283 last = nodes[end]
284
285 for i in xrange(start + 1, end):
286 d = point_line_distance(nodes[i], first, last)
287 if d > farthest_dist:
288 farthest_dist = d
289 farthest_node = i
290
291 return (farthest_node, farthest_dist)
292
293def douglas_peucker_nonrecursive(nodes, epsilon):
294 #print "Running DP on %d nodes" % len(nodes)
295 command_stack = [(0, len(nodes) - 1)]
296 result_stack = []
297
298 while len(command_stack) > 0:
299 cmd = command_stack.pop()
300 if type(cmd) == tuple:
301 (start, end) = cmd
302 (node, dist) = dp_findpoint(nodes, start, end)
303 if dist > epsilon:
304 command_stack.append("+")
305 command_stack.append((start, node))
306 command_stack.append((node, end))
307 else:
308 result_stack.append((start, end))
309 elif cmd == "+":
310 first = result_stack.pop()
311 second = result_stack.pop()
312 if first[-1] == second[0]:
313 result_stack.append(first + second[1:])
314 #print "Added %s and %s; result is %s" % (first, second, result_stack[-1])
315 else:
316 error("ERROR: Cannot connect nodestrings!")
317 #print first
318 #print second
319 return
320 else:
321 error("ERROR: Can't understand command \"%s\"" % (cmd,))
322 return
323
324 if len(result_stack) == 1:
325 return [nodes[x] for x in result_stack[0]]
326 else:
327 error("ERROR: Command stack is empty but result stack has %d nodes!" % len(result_stack))
328 return
329
330 farthest_node = None
331 farthest_dist = 0
332 first = nodes[0]
333 last = nodes[-1]
334
335 for i in xrange(1, len(nodes) - 1):
336 d = point_line_distance(nodes[i], first, last)
337 if d > farthest_dist:
338 farthest_dist = d
339 farthest_node = i
340
341 if farthest_dist > epsilon:
342 seg_a = douglas_peucker(nodes[0:farthest_node+1], epsilon)
343 seg_b = douglas_peucker(nodes[farthest_node:-1], epsilon)
344 #print "Minimized %d nodes to %d + %d nodes" % (len(nodes), len(seg_a), len(seg_b))
345 nodes = seg_a[:-1] + seg_b
346 else:
347 return [nodes[0], nodes[-1]]
348
349 return nodes
350
351def output_to_josm(lakelist):
352 # Description of JOSM output format:
353 # m text - Status message text, to be displayed in a display window
354 # e text - Error message text
355 # s nnn - Start full node list, nnn tracings following
356 # t nnn - Start tracing node list, nnn nodes following (i.e. there will be one of these for each lake where multiple start points specified)
357 # n lat lon - A node
358 # x - End of data
359 countnodes = 0
360
361 print "s %s" % len(lakelist)
362 for nodelist in lakelist:
363 print "t %s" % len(nodelist)
364 for node in nodelist:
365 print "n %.7f %.7f" % (node[0], node[1])
366
367 countnodes = countnodes + 1
368 if options.MAXLEN == countnodes:
369 print "x"
370 print "t %s" % len(nodelist)
371 print "n %.7f %.7f" % (node[0], node[1])
372 countnodes = 0
373
374 print "x"
375
376def write_osm(f, lakelist, waysize):
377 f.write('<osm version="0.4">')
378 cur_id = -1
379 way_count = 0
380 for nodelist in lakelist:
381 first_node_id = cur_id
382 cur_way = []
383 ways = [cur_way]
384 for loc in nodelist:
385 #f.write(' <node id="%d" lat="%.6f" lon="%.6f"><tag k="order" v="%d"/><tag k="x" v="%d"/><tag k="y" v="%d"/></node>\n' % (cur_id, loc[0], loc[1], -cur_id, geo_to_xy(loc)[0], geo_to_xy(loc)[1]))
386 f.write('<node id="%d" lat="%.6f" lon="%.6f"/>' % (cur_id, loc[0], loc[1]))
387 last_node_id = cur_id
388 cur_id = cur_id - 1
389
390 # print "Nodes: %d, %d" % (first_node_id, last_node_id)
391
392 first_segment_id = cur_id
393 for seg in xrange(first_node_id, last_node_id, -1):
394 f.write('<segment id="%d" from="%d" to="%d"/>' % (cur_id, seg, seg-1))
395 cur_id = cur_id - 1
396 f.write('<segment id="%d" from="%d" to="%d"/>' % (cur_id, last_node_id, first_node_id))
397 last_segment_id = cur_id
398 cur_id = cur_id - 1
399
400 # print "Segments: %d, %d" % (first_segment_id, last_segment_id)
401
402 for seg in xrange(first_segment_id, last_segment_id - 1, -1):
403 if len(cur_way) >= waysize:
404 cur_way = []
405 ways.append(cur_way)
406 cur_way.append(seg)
407 for way in ways:
408 f.write('<way id="%d">' % cur_id)
409 cur_id = cur_id - 1
410 for seg in way:
411 f.write('<seg id="%d"/>' % seg)
412 f.write('<tag k="natural" v="%s"/>' % options.natural_type)
413 f.write('<tag k="source" v="Dshpak_landsat_lakes"/>')
414 f.write('</way>')
415 way_count = way_count + len(ways)
416
417 f.write('</osm>')
418 message("Generated %d %s." % (way_count, ["way", "ways"][way_count > 0]))
419
420def get_locs(infile):
421 nodes = []
422 segments = []
423 ways = []
424 reader = OSMReader.OSMReader()
425 reader.nodeHandler = lambda x: nodes.append(x)
426 reader.segmentHandler = lambda x: segments.append(x)
427 reader.wayHandler = lambda x: ways.append(x)
428 reader.run(file(infile))
429 if len(segments) > 0 or len(ways) > 0:
430 raise FatalError("Error: Input file must only contain nodes -- no segments or ways.")
431 if len(nodes) == 0:
432 raise FatalError("Error: No nodes found in input file.")
433 return [((node.lat, node.lon), options.threshold) for node in nodes]
434
435def main():
436 parser = optparse.OptionParser(version=version)
437
438 parser.add_option("--lat", type="float", metavar="LATITUDE", help="Starting latitude. Required, unless --infile is used..")
439 parser.add_option("--lon", type="float", metavar="LONGITUDE", help="Starting longitude. Required, unless --infile is used.")
440 parser.add_option("--startdir", type="string", default="east", metavar="DIR", help="Direction to travel from start position when seeking land. Defaults to \"east\".")
441 parser.add_option("--infile", "-i", type="string", metavar="FILE", help="OSM file containing nodes representing starting points.")
442 parser.add_option("--out", "-o", default="lake.osm", dest="outfile", metavar="FILE", help="Output filename. Defaults to lake.osm.")
443 parser.add_option("--threshold", "-t", type="int", default="35", metavar="VALUE", help="Maximum gray value to accept as water (based on Landsat IR-1 data). Can be in the range 0-255. Defaults to 35.")
444 parser.add_option("--maxnodes", type="int", default="50000", metavar="N", help="Maximum number of nodes to generate before bailing out. Defaults to 50000.")
445 parser.add_option("--waylength", type="int", dest="MAXLEN", default=500, metavar="MAXLEN", help="Maximum nuber of nodes allowed in one way. Defaults to 250.")
446 parser.add_option("--landsat-res", type="int", default=4000, dest="resolution", metavar="RES", help="Resolution of Landsat tiles, measured in pixels per degree. Defaults to 4000.")
447 parser.add_option("--tilesize", type="int", default=2000, help="Size of one landsat tile, measured in pixels. Defaults to 2000.")
448 parser.add_option("--no-dp", action="store_false", dest="use_dp", default=True, help="Disable Douglas-Peucker line simplification (not recommended)")
449 parser.add_option("--dp-epsilon", type="float", metavar="EPSILON", default=0.0003, help="Accuracy of Douglas-Peucker line simplification, measured in degrees. Lower values give more nodes, and more accurate lines. Defaults to 0.0003.")
450 parser.add_option("--dp-nr", action="store_true", dest="dp_nr", default=False, help="Use experimental non-recursive DP implementation")
451 parser.add_option("--vr", action="store_true", dest="use_vr", default=False, help="Use vertex reduction before applying line simplification (off by default).")
452 parser.add_option("--vr-epsilon", type="float", default=0.0005, metavar="RADIUS", help="Radius used for vertex reduction (measured in degrees). Defaults to 0.0005.")
453 parser.add_option("--water", action="store_const", const="water", dest="natural_type", default="coastline", help="Tag ways as natural=water instead of natural=coastline")
454 parser.add_option("--left", type="float", metavar="LONGITUDE", default=-180, help="Left (west) longitude for bounding box")
455 parser.add_option("--right", type="float", metavar="LONGITUDE", default=180, help="Right (east) longitude for bounding box")
456 parser.add_option("--top", type="float", metavar="LATITUDE", default=90, help="Top (north) latitude for bounding box")
457 parser.add_option("--bottom", type="float", metavar="LATITUDE", default=-90, help="Bottom (south) latitude for bounding box")
458 parser.add_option("--josm", action="store_true", dest="josm_mode", default=False, help="Operate in JOSM plugin mode")
459 parser.add_option("--wms", type="string", dest="wmslayer", default="IR1", help="WMS layer to use")
460
461 global options # Ugly, I know...
462 (options, args) = parser.parse_args()
463
464 if len(args) > 0:
465 parser.print_help()
466 return
467
468 (start_lat, start_lon, infile) = (options.lat, options.lon, options.infile)
469
470 if (start_lat is None or start_lon is None) and infile is None:
471 if not options.josm_mode:
472 parser.print_help()
473 print
474 error("Error: you must specify a starting latitude and longitude.")
475 return
476
477 if infile is not None:
478 if start_lat is not None or start_lon is not None:
479 error("Error: you cannot use both --infile and --lat or --lon.")
480 return
481 try:
482 locs = get_locs(infile)
483 #print locs
484 except FatalError, e:
485 error("%s" % e)
486 return
487 else:
488 locs = [((start_lat, start_lon), options.threshold)]
489
490 dirname = options.startdir.lower()
491 if dirname in dirnames:
492 startdir = dirnames.index(dirname)
493 elif dirname in dirabbrevs:
494 startdir = dirabbrevs.index(dirname)
495 else:
496 error("Error: Can't understand starting direction \"%s\". Vaild options are %s." % (dirname, ", ".join(dirnames + dirabbrevs)))
497 return
498
499 message("Starting direction is %s." % dirnames[startdir])
500
501 bbox = BBox(options.top, options.left, options.bottom, options.right)
502
503 try:
504 lakes = []
505 for (loc, threshold) in locs:
506 nodes = trace_lake(loc, threshold, startdir, bbox)
507
508 message("%d nodes generated." % len(nodes))
509
510 if len(nodes) > 0:
511 if options.use_vr:
512 nodes = vertex_reduce(nodes, options.vr_epsilon)
513 message("After vertex reduction, %d nodes remain." % len(nodes))
514
515 if options.use_dp:
516 try:
517 if options.dp_nr:
518 nodes = douglas_peucker_nonrecursive(nodes, options.dp_epsilon)
519 #print "Final result: %s" % (nodes,)
520 else:
521 nodes = douglas_peucker(nodes, options.dp_epsilon)
522 message("After Douglas-Peucker approximation, %d nodes remain." % len(nodes))
523 except FatalError, e:
524 raise e
525 except:
526 raise FatalError("Line simplification failed -- there are probably too many nodes.")
527
528 lakes.append(nodes)
529
530 if options.josm_mode:
531 output_to_josm(lakes)
532 else:
533 message("Writing to %s" % options.outfile)
534 f = open(options.outfile, "w")
535 write_osm(f, lakes, options.waylength)
536 f.close()
537
538 #tiles = tilelist(nodes)
539
540 #for tile in tiles:
541 # print "./tilesGen.pl xy %d %d; ./upload.pl" % tile
542
543 #print tiles
544 except FatalError, e:
545 error("%s" % e)
546 error("Bailing out...")
547
548if __name__ == "__main__":
549 main()
550
Note: See TracBrowser for help on using the repository browser.