| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512 |
- #!/usr/bin/env python
- """A slippy map GUI.
- Implements a tiled slippy map using Tk canvas. Displays map tiles using
- whatever projection the tiles are in and only knows about tile coordinates,
- (as opposed to geospatial coordinates.) This assumes that the tile-space is
- organized as a power-of-two pyramid, with the origin in the upper left corner.
- This currently has several spots that are hard-coded for 256x256 tiles, even
- though MapOverlay tries to track this.
- Supports mouse-based pan and zoom as well as tile upsampling while waiting
- for new tiles to load. The map to display is specified by a MapOverlay, and
- added to the GUI on creation or manually using addOverlay()
- gui = MapClient(MakeOverlay(mapid))
- Tiles are referenced using a key of (level, x, y) throughout.
- Several of the functions are named to match the Google Maps Javascript API,
- and therefore violate style guidelines.
- """
- # TODO(user):
- # 1) Add a zoom bar.
- # 2) When the move() is happening inside the Drag function, it'd be
- # a good idea to use a semaphore to keep new tiles from being added
- # and subsequently moved.
- from collections import abc
- import functools
- import io
- import math
- import queue
- import sys
- import threading
- import tkinter as Tkinter
- import urllib.request
- # check if the Python imaging libraries used by the mapclient module are
- # installed
- try:
- # Python3
- from PIL import ImageTk # pylint: disable=g-import-not-at-top
- from PIL import Image # pylint: disable=g-import-not-at-top
- except ImportError:
- try:
- # Python2
- import ImageTk # pylint: disable=g-import-not-at-top
- import Image # pylint: disable=g-import-not-at-top
- except ImportError:
- print("""
- ERROR: A Python library (PIL) used by the Earth Engine API mapclient module
- was not found. Information on PIL can be found at:
- http://pypi.python.org/pypi/PIL
- """)
- raise
- try:
- pass
- except ImportError:
- print("""
- ERROR: A Python library (Tkinter) used by the Earth Engine API mapclient
- module was not found. Instructions for installing Tkinter can be found at:
- http://tkinter.unpythonic.net/wiki/How_to_install_Tkinter
- """)
- raise
- # The default URL to fetch tiles from. We could pull this from the EE library,
- # however this doesn't have any other dependencies on that yet, so let's not.
- BASE_URL = 'https://earthengine.googleapis.com'
- # This is a URL pattern for creating an overlay from the google maps base map.
- # The z, x and y arguments at the end correspond to level, x, y here.
- DEFAULT_MAP_URL_PATTERN = ('http://mt1.google.com/vt/lyrs=m@176000000&hl=en&'
- 'src=app&z=%d&x=%d&y=%d')
- class MapClient(threading.Thread):
- """A simple discrete zoom level map viewer."""
- def __init__(self, opt_overlay=None, opt_width=1024, opt_height=768):
- """Initialize the MapClient UI.
- Args:
- opt_overlay: A mapoverlay to display. If not specified, the default
- Google Maps basemap is used.
- opt_width: The default width of the frame to construct.
- opt_height: The default height of the frame to construct.
- """
- threading.Thread.__init__(self)
- self.ready = False # All initialization is done.
- self.tiles = {} # The cached stack of images at each grid cell.
- self.tktiles = {} # The cached PhotoImage at each grid cell.
- self.level = 2 # Starting zoom level
- self.origin_x = None # The map origin x offset at the current level.
- self.origin_y = None # The map origin y offset at the current level.
- self.parent = None # A handle to the top level Tk widget.
- self.frame = None # A handle to the Tk frame.
- self.canvas = None # A handle to the Tk canvas
- self.width = opt_width
- self.height = opt_height
- self.anchor_x = None # Drag anchor.
- self.anchor_y = None # Drag anchor.
- # Map origin offsets; start at the center of the map.
- self.origin_x = (-(2 ** self.level) * 128) + self.width / 2
- self.origin_y = (-(2 ** self.level) * 128) + self.height / 2
- if not opt_overlay:
- # Default to a google maps basemap
- opt_overlay = MapOverlay(DEFAULT_MAP_URL_PATTERN)
- # The array of overlays are displayed as last on top.
- self.overlays = [opt_overlay]
- self.start()
- def run(self):
- """Set up the user interface."""
- width = self.width
- height = self.height
- # Build the UI
- self.parent = Tkinter.Tk()
- self.frame = frame = Tkinter.Frame(self.parent, width=width, height=height)
- frame.pack(fill=Tkinter.BOTH, expand=Tkinter.YES)
- self.canvas = canvas = Tkinter.Canvas(frame,
- width=self.GetFrameSize()[0],
- height=self.GetFrameSize()[1])
- canvas.pack(side=Tkinter.TOP, fill=Tkinter.BOTH, expand=Tkinter.YES)
- canvas.create_rectangle(0, 0, self.GetMapSize()[0], self.GetMapSize()[1],
- fill='#888888')
- canvas.bind('<Button-1>', self.ClickHandler)
- canvas.bind('<ButtonRelease-1>', self.ReleaseHandler)
- # Button-4 and Button-5 are scroll wheel up/down events.
- canvas.bind('<Button-4>', functools.partial(self.Zoom, direction=1))
- canvas.bind('<Button-5>', functools.partial(self.Zoom, direction=-1))
- canvas.bind('<Double-Button-1>', functools.partial(self.Zoom, direction=1))
- frame.bind('<Configure>', self.ResizeHandler)
- frame.bind_all('<Key>', self.KeypressHandler)
- def SetReady():
- self.ready = True
- self.parent.after_idle(SetReady)
- self.parent.mainloop()
- def addOverlay(self, overlay): # pylint: disable=g-bad-name
- """Add an overlay to the map."""
- self.overlays.append(overlay)
- self.LoadTiles()
- def GetFrameSize(self):
- if self.frame:
- return (int(self.frame.cget('width')), int(self.frame.cget('height')))
- else:
- return (self.width, self.height)
- def GetMapSize(self):
- if self.frame:
- return (int(self.canvas.cget('width')), int(self.canvas.cget('height')))
- else:
- return (self.width, self.height)
- def GetViewport(self):
- """Return the visible portion of the map as [xlo, ylo, xhi, yhi]."""
- width, height = self.GetMapSize()
- # pylint: disable=invalid-unary-operand-type
- return [-self.origin_x, -self.origin_y,
- -self.origin_x + width, -self.origin_y + height]
- def LoadTiles(self):
- """Refresh the entire map."""
- # Start with the overlay on top.
- if not self.ready:
- return
- for i, overlay in reversed(list(enumerate(self.overlays))):
- tile_list = overlay.CalcTiles(self.level, self.GetViewport())
- for key in tile_list:
- overlay.getTile(key, functools.partial(
- self.AddTile, key=key, overlay=overlay, layer=i))
- def Flush(self):
- """Empty out all the image fetching queues."""
- for overlay in self.overlays:
- overlay.Flush()
- def CompositeTiles(self, key):
- """Composite together all the tiles in this cell into a single image."""
- composite = None
- for layer in sorted(self.tiles[key]):
- image = self.tiles[key][layer]
- if not composite:
- composite = image.copy()
- else:
- composite.paste(image, (0, 0), image)
- return composite
- def AddTile(self, image, key, overlay, layer):
- """Add a tile to the map.
- This keeps track of the tiles for each overlay in each grid cell.
- As new tiles come in, all the tiles in a grid cell are composited together
- into a new tile and any old tile for that spot is replaced.
- Args:
- image: The image tile to display.
- key: A tuple containing the key of the image (level, x, y)
- overlay: The overlay this tile belongs to.
- layer: The layer number this overlay corresponds to. Only used
- for caching purposes.
- """
- # TODO(user): This function is called from multiple threads, and
- # could use some synchronization, but it seems to work.
- if self.level == key[0]: # Don't add late tiles from another level.
- self.tiles[key] = self.tiles.get(key, {})
- self.tiles[key][layer] = image
- newtile = self.CompositeTiles(key)
- if key not in self.tktiles:
- newtile = ImageTk.PhotoImage(newtile)
- xpos = key[1] * overlay.TILE_WIDTH + self.origin_x
- ypos = key[2] * overlay.TILE_HEIGHT + self.origin_y
- self.canvas.create_image(
- xpos, ypos, anchor=Tkinter.NW, image=newtile, tags=['tile', key])
- self.tktiles[key] = newtile # Hang on to the new tile.
- else:
- self.tktiles[key].paste(newtile)
- def Zoom(self, event, direction):
- """Zoom the map.
- Args:
- event: The event that caused this zoom request.
- direction: The direction to zoom. +1 for higher zoom, -1 for lower.
- """
- if self.level + direction >= 0:
- # Discard everything cached in the MapClient, and flush the fetch queues.
- self.Flush()
- self.canvas.delete(Tkinter.ALL)
- self.tiles = {}
- self.tktiles = {}
- if direction > 0:
- self.origin_x = self.origin_x * 2 - event.x
- self.origin_y = self.origin_y * 2 - event.y
- else:
- self.origin_x = (self.origin_x + event.x) / 2
- self.origin_y = (self.origin_y + event.y) / 2
- self.level += direction
- self.LoadTiles()
- def ClickHandler(self, event):
- """Records the anchor location and sets drag handler."""
- self.anchor_x = event.x
- self.anchor_y = event.y
- self.canvas.bind('<Motion>', self.DragHandler)
- def DragHandler(self, event):
- """Updates the map position and anchor position."""
- dx = event.x - self.anchor_x
- dy = event.y - self.anchor_y
- if dx or dy:
- self.canvas.move(Tkinter.ALL, dx, dy)
- self.origin_x += dx
- self.origin_y += dy
- self.anchor_x = event.x
- self.anchor_y = event.y
- def ReleaseHandler(self, unused_event):
- """Unbind drag handler and redraw."""
- self.canvas.unbind('<Motion>')
- self.LoadTiles()
- def ResizeHandler(self, event):
- """Handle resize events."""
- # There's a 2 pixel border.
- self.canvas.config(width=event.width - 2, height=event.height - 2)
- self.LoadTiles()
- def CenterMap(self, lon, lat, opt_zoom=None):
- """Center the map at the given lon, lat and zoom level."""
- if self.canvas:
- self.Flush()
- self.canvas.delete(Tkinter.ALL)
- self.tiles = {}
- self.tktiles = {}
- width, height = self.GetMapSize()
- if opt_zoom is not None:
- self.level = opt_zoom
- # From maps/api/javascript/geometry/mercator_projection.js
- mercator_range = 256.0
- scale = 2 ** self.level
- origin_x = (mercator_range / 2.0) * scale
- origin_y = (mercator_range / 2.0) * scale
- pixels_per_lon_degree = (mercator_range / 360.0) * scale
- pixels_per_lon_radian = (mercator_range / (2 * math.pi)) * scale
- x = origin_x + (lon * pixels_per_lon_degree)
- siny = math.sin(lat * math.pi / 180.0)
- # Prevent sin() overflow.
- e = 1 - 1e-15
- if siny > e:
- siny = e
- elif siny < -e:
- siny = -e
- y = origin_y + (0.5 * math.log((1 + siny) / (1 - siny)) *
- -pixels_per_lon_radian)
- self.origin_x = -x + width / 2
- self.origin_y = -y + height / 2
- self.LoadTiles()
- def KeypressHandler(self, event):
- """Handle keypress events."""
- if event.char == 'q' or event.char == 'Q':
- self.parent.destroy()
- class MapOverlay(object):
- """A class representing a map overlay."""
- TILE_WIDTH = 256
- TILE_HEIGHT = 256
- MAX_CACHE = 1000 # The maximum number of tiles to cache.
- _images = {} # The tile cache, keyed by (url, level, x, y).
- _lru_keys = [] # Keys to the cached tiles, for cache ejection.
- def __init__(self, url, tile_fetcher=None):
- """Initialize the MapOverlay."""
- self.url = url
- self.tile_fetcher = tile_fetcher
- # Make 10 workers.
- self.queue = queue.Queue()
- self.fetchers = [MapOverlay.TileFetcher(self) for unused_x in range(10)]
- self.constant = None
- def getTile(self, key, callback): # pylint: disable=g-bad-name
- """Get the requested tile.
- If the requested tile is already cached, it's returned (sent to the
- callback) directly. If it's not cached, a check is made to see if
- a lower-res version is cached, and if so that's interpolated up, before
- a request for the actual tile is made.
- Args:
- key: The key of the tile to fetch.
- callback: The callback to call when the tile is available. The callback
- may be called more than once if a low-res version is available.
- """
- result = self.GetCachedTile(key)
- if result:
- callback(result)
- else:
- # Interpolate what we have and put the key on the fetch queue.
- self.queue.put((key, callback))
- self.Interpolate(key, callback)
- def Flush(self):
- """Empty the tile queue."""
- while not self.queue.empty():
- self.queue.get_nowait()
- def CalcTiles(self, level, bbox):
- """Calculate which tiles to load based on the visible viewport.
- Args:
- level: The level at which to calculate the required tiles.
- bbox: The viewport coordinates as a tuple (xlo, ylo, xhi, yhi])
- Returns:
- The list of tile keys to fill the given viewport.
- """
- tile_list = []
- for y in range(
- int(bbox[1] / MapOverlay.TILE_HEIGHT),
- int(bbox[3] / MapOverlay.TILE_HEIGHT + 1)):
- for x in range(
- int(bbox[0] / MapOverlay.TILE_WIDTH),
- int(bbox[2] / MapOverlay.TILE_WIDTH + 1)):
- tile_list.append((level, x, y))
- return tile_list
- def Interpolate(self, key, callback):
- """Upsample a lower res tile if one is available.
- Args:
- key: The tile key to upsample.
- callback: The callback to call when the tile is ready.
- """
- level, x, y = key
- delta = 1
- result = None
- while level - delta > 0 and result is None:
- prevkey = (level - delta, x / 2, y / 2)
- result = self.GetCachedTile(prevkey)
- if not result:
- (_, x, y) = prevkey
- delta += 1
- if result:
- px = (key[1] % 2 ** delta) * MapOverlay.TILE_WIDTH / 2 ** delta
- py = (key[2] % 2 ** delta) * MapOverlay.TILE_HEIGHT / 2 ** delta
- image = (result.crop([px, py,
- px + MapOverlay.TILE_WIDTH / 2 ** delta,
- py + MapOverlay.TILE_HEIGHT / 2 ** delta])
- .resize((MapOverlay.TILE_WIDTH, MapOverlay.TILE_HEIGHT)))
- callback(image)
- def PutCacheTile(self, key, image):
- """Insert a new tile in the cache and eject old ones if it's too big."""
- cache_key = (self.url,) + key
- MapOverlay._images[cache_key] = image
- MapOverlay._lru_keys.append(cache_key)
- while len(MapOverlay._lru_keys) > MapOverlay.MAX_CACHE:
- remove_key = MapOverlay._lru_keys.pop(0)
- try:
- MapOverlay._images.pop(remove_key)
- except KeyError:
- # Just in case someone removed this before we did.
- pass
- def GetCachedTile(self, key):
- """Returns the specified tile if it's in the cache."""
- cache_key = (self.url,) + key
- return MapOverlay._images.get(cache_key, None)
- class TileFetcher(threading.Thread):
- """A threaded URL fetcher."""
- def __init__(self, overlay):
- threading.Thread.__init__(self)
- self.overlay = overlay
- self.setDaemon(True)
- self.start()
- def run(self):
- """Pull URLs off the ovelay's queue and call the callback when done."""
- while True:
- (key, callback) = self.overlay.queue.get()
- # Check one more time that we don't have this yet.
- if not self.overlay.GetCachedTile(key):
- (level, x, y) = key
- if x >= 0 and y >= 0 and x <= 2 ** level-1 and y <= 2 ** level-1:
- try:
- if self.overlay.tile_fetcher is not None:
- data = self.overlay.tile_fetcher.fetch_tile(x=x, y=y, z=level)
- else:
- url = self.overlay.url % key
- data = urllib.request.urlopen(url).read()
- except Exception as e: # pylint: disable=broad-except
- print(e, file=sys.stderr)
- else:
- # PhotoImage can't handle alpha on LA images.
- image = Image.open(io.BytesIO(data)).convert('RGBA')
- callback(image)
- self.overlay.PutCacheTile(key, image)
- def MakeOverlay(mapid, baseurl=BASE_URL):
- """Create an overlay from a mapid."""
- url = (baseurl + '/map/' + mapid['mapid'] + '/%d/%d/%d?token=' +
- mapid['token'])
- return MapOverlay(url, tile_fetcher=mapid['tile_fetcher'])
- #
- # A global MapClient instance for addToMap convenience.
- #
- map_instance = None
- # pylint: disable=g-bad-name
- def addToMap(eeobject, vis_params=None, *unused_args):
- """Adds a layer to the default map instance.
- Args:
- eeobject: the object to add to the map.
- vis_params: a dictionary of visualization parameters. See
- ee.data.getMapId().
- *unused_args: unused arguments, left for compatibility with the JS API.
- This call exists to be an equivalent to the playground addToMap() call.
- It uses a global MapInstance to hang on to "the map". If the MapInstance
- isn't initialized, this creates a new one.
- """
- # Flatten any lists to comma separated strings.
- if vis_params:
- vis_params = dict(vis_params)
- for key in vis_params:
- item = vis_params.get(key)
- if (isinstance(item, abc.Iterable) and not isinstance(item, str)):
- vis_params[key] = ','.join([str(x) for x in item])
- overlay = MakeOverlay(eeobject.getMapId(vis_params))
- global map_instance
- if not map_instance:
- map_instance = MapClient()
- map_instance.addOverlay(overlay)
- def centerMap(lng, lat, zoom): # pylint: disable=g-bad-name
- """Center the default map instance at the given lat, lon and zoom values."""
- global map_instance
- if not map_instance:
- map_instance = MapClient()
- map_instance.CenterMap(lng, lat, zoom)
|