"""A vanilla REST interface to a ``TileSource``.
This is intended for use in JupyterLab and not intended to be used as a full
fledged REST API. Only two endpoints are exposed with minimal options:
* `/metadata`
* `/tile?z={z}&x={x}&y={y}&encoding=png`
We use Tornado because it is Jupyter's web server and will not require Jupyter
users to install any additional dependencies. Also, Tornado doesn't require us
to manage a separate thread for the web server.
Please note that this webserver will not work with Classic Notebook and will
likely lead to crashes. This is only for use in JupyterLab.
"""
import json
import os
import weakref
from large_image.exceptions import TileSourceXYZRangeError
from large_image.tilesource.utilities import JSONDict
try:
import ipyleaflet
except ImportError: # pragma: no cover
ipyleaflet = None
[docs]
def launch_tile_server(tile_source, port=0):
import tornado.httpserver
import tornado.netutil
import tornado.web
class RequestManager:
def __init__(self, tile_source):
self._tile_source_ = weakref.ref(tile_source)
self._ports = ()
@property
def tile_source(self):
return self._tile_source_()
@tile_source.setter
def tile_source(self, source):
self._tile_source_ = weakref.ref(source)
@property
def ports(self):
return self._ports
@property
def port(self):
return self.ports[0]
manager = RequestManager(tile_source)
# NOTE: set `ports` manually after launching server
class TileSourceMetadataHandler(tornado.web.RequestHandler):
"""REST endpoint to get image metadata."""
def get(self):
self.write(json.dumps(manager.tile_source.getMetadata()))
self.set_header('Content-Type', 'application/json')
class TileSourceTileHandler(tornado.web.RequestHandler):
"""REST endpoint to serve tiles from image in slippy maps standard."""
def get(self):
x = int(self.get_argument('x'))
y = int(self.get_argument('y'))
z = int(self.get_argument('z'))
encoding = self.get_argument('encoding', 'PNG')
try:
tile_binary = manager.tile_source.getTile(x, y, z, encoding=encoding)
except TileSourceXYZRangeError as e:
self.clear()
self.set_status(404)
self.finish(f'<html><body>{e}</body></html>')
else:
self.write(tile_binary)
self.set_header('Content-Type', 'image/png')
app = tornado.web.Application([
(r'/metadata', TileSourceMetadataHandler),
(r'/tile', TileSourceTileHandler),
])
sockets = tornado.netutil.bind_sockets(port, '')
server = tornado.httpserver.HTTPServer(app)
server.add_sockets(sockets)
manager._ports = tuple(s.getsockname()[1] for s in sockets)
return manager
[docs]
class Map:
"""
An IPyLeafletMap representation of a large image.
"""
def __init__(self, *, ts=None, metadata=None, url=None, gc=None, id=None, resource=None):
"""
Specify the large image to be used with the IPyLeaflet Map. One of (a)
a tile source, (b) metadata dictionary and tile url, (c) girder client
and item or file id, or (d) girder client and resource path must be
specified.
:param ts: a TileSource.
:param metadata: a metadata dictionary as returned by a tile source or
a girder item/{id}/tiles endpoint.
:param url: a slippy map template url to fetch tiles (e.g.,
.../item/{id}/tiles/zxy/{z}/{x}/{y}?params=...)
:param gc: an authenticated girder client.
:param id: an item id that exists on the girder client.
:param resource: a girder resource path of an item or file that exists
on the girder client.
"""
self._layer = self._map = self._metadata = None
self._ts = ts
if (not url or not metadata) and gc and (id or resource):
fileId = None
if id is None:
entry = gc.get('resource/lookup', parameters={'path': resource})
if entry:
if entry.get('_modelType') == 'file':
fileId = entry['_id']
id = entry['itemId'] if entry.get('_modelType') == 'file' else entry['_id']
if id:
try:
metadata = gc.get(f'item/{id}/tiles')
except Exception:
pass
if metadata:
url = gc.urlBase + f'item/{id}/tiles' + '/zxy/{z}/{x}/{y}'
if metadata.get('geospatial'):
suffix = '?projection=EPSG:3857&encoding=PNG'
metadata = gc.get(f'item/{id}/tiles' + suffix)
if metadata.get('geospatial') and metadata.get('projection'):
url += suffix
self._id = id
else:
self._ts = self._get_temp_source(gc, fileId or id)
if url and metadata:
self._metadata = metadata
self._url = url
self._layer = self.make_layer(metadata, url)
self._map = self.make_map(metadata)
if ipyleaflet:
def _ipython_display_(self):
from IPython.display import display
if self._map:
return display(self._map)
if self._ts:
t = self._ts.as_leaflet_layer()
return display(self._ts._map.make_map(
self._ts.metadata, t, self._ts.getCenter(srs='EPSG:4326')))
def _get_temp_source(self, gc, id):
"""
If the server isn't large_image enabled, download the file to view it.
"""
import tempfile
import large_image
try:
item = gc.get(f'item/{id}')
except Exception:
item = None
if not item:
file = gc.get(f'file/{id}')
else:
file = gc.get(f'item/{id}/files', parameters={'limit': 1})[0]
self._tempfile = tempfile.NamedTemporaryFile(suffix='.' + file['name'].split('.', 1)[-1])
gc.downloadFile(file['_id'], self._tempfile)
return large_image.open(self._tempfile.name)
[docs]
def make_layer(self, metadata, url, **kwargs):
"""
Create an ipyleaflet tile layer given large_image metadata and a tile
url.
"""
from ipyleaflet import TileLayer
self._geospatial = metadata.get('geospatial') and metadata.get('projection')
if 'bounds' not in kwargs and not self._geospatial:
kwargs = kwargs.copy()
kwargs['bounds'] = [[0, 0], [metadata['sizeY'], metadata['sizeX']]]
layer = TileLayer(
url=url,
# attribution='Tiles served with large-image',
min_zoom=0,
max_native_zoom=metadata['levels'] - 1,
max_zoom=20,
tile_size=metadata['tileWidth'],
**kwargs,
)
self._layer = layer
if not self._metadata:
self._metadata = metadata
return layer
[docs]
def make_map(self, metadata, layer=None, center=None):
"""
Create an ipyleaflet map given large_image metadata, an optional
ipyleaflet layer, and the center of the tile source.
"""
from ipyleaflet import Map, basemaps, projections
try:
default_zoom = metadata['levels'] - metadata['sourceLevels']
except KeyError:
default_zoom = 0
self._geospatial = metadata.get('geospatial') and metadata.get('projection')
if self._geospatial:
# TODO: better handle other projections
crs = projections.EPSG3857
else:
crs = dict(
name='PixelSpace',
custom=True,
# Why does this need to be 256?
resolutions=[2 ** (metadata['levels'] - 1 - l) for l in range(20)],
# This works but has x and y reversed
proj4def='+proj=longlat +axis=esu',
bounds=[[0, 0], [metadata['sizeY'], metadata['sizeX']]],
# Why is origin X, Y but bounds Y, X?
origin=[0, metadata['sizeY']],
# This almost works to fix the x, y reversal, but
# - bounds are weird and other issues occur
# proj4def='+proj=longlat +axis=seu',
# bounds=[[-metadata['sizeX'],-metadata['sizeY']],
# [metadata['sizeX'],metadata['sizeY']]],
# origin=[0,0],
)
layer = layer or self._layer
if center is None:
if 'bounds' in metadata and 'projection' in metadata:
import pyproj
bounds = metadata['bounds']
center = (
(bounds['ymax'] + bounds['ymin']) / 2,
(bounds['xmax'] + bounds['xmin']) / 2,
)
transf = pyproj.Transformer.from_crs(
metadata['projection'], 'EPSG:4326', always_xy=True)
center = tuple(transf.transform(center[1], center[0])[::-1])
else:
center = (metadata['sizeY'] / 2, metadata['sizeX'] / 2)
m = Map(
crs=crs,
basemap=basemaps.OpenStreetMap.Mapnik if self._geospatial else layer,
center=center,
zoom=default_zoom,
max_zoom=metadata['levels'] + 1,
min_zoom=0,
scroll_wheel_zoom=True,
dragging=True,
attribution_control=False,
)
if self._geospatial:
m.add_layer(layer)
self._map = m
return m
@property
def layer(self):
return self._layer
@property
def map(self):
return self._map
@property
def metadata(self):
return JSONDict(self._metadata)
@property
def id(self):
return getattr(self, '_id', None)
[docs]
def to_map(self, coordinate):
"""
Convert a coordinate from the image or projected image space to the map
space.
:param coordinate: a two-tuple that is x, y in pixel space or x, y in
image projection space.
:returns: a two-tuple that is in the map space coordinates.
"""
x, y = coordinate[:2]
if self._geospatial:
import pyproj
transf = pyproj.Transformer.from_crs(
self._metadata['projection'], 'EPSG:4326', always_xy=True)
return tuple(transf.transform(x, y)[::-1])
else:
return self._metadata['sizeY'] - y, x
[docs]
def from_map(self, coordinate):
"""
:param coordinate: a two-tuple that is in the map space coordinates.
:returns: a two-tuple that is x, y in pixel space or x, y in image
projection space.
"""
y, x = coordinate[:2]
if self._geospatial:
import pyproj
transf = pyproj.Transformer.from_crs(
'EPSG:4326', self._metadata['projection'], always_xy=True)
return transf.transform(x, y)
else:
return x, self._metadata['sizeY'] - y
[docs]
class IPyLeafletMixin:
"""Mixin class to support interactive visualization in JupyterLab.
This class implements ``_ipython_display_`` with ``ipyleaflet``
to display an interactive image visualizer for the tile source
in JupyterLab.
Install `ipyleaflet <https://github.com/jupyter-widgets/ipyleaflet>`_
to interactively visualize tile sources in JupyterLab.
For remote JupyterHub environments, you may need to configure
the class variables ``JUPYTER_HOST`` or ``JUPYTER_PROXY``.
If ``JUPYTER_PROXY`` is set, it overrides ``JUPYTER_HOST``.
Use ``JUPYTER_HOST`` to set the host name of the machine such
that the tile URL can be accessed at
``'http://{JUPYTER_HOST}:{port}'``.
Use ``JUPYTER_PROXY`` to leverage ``jupyter-server-proxy`` to
proxy the tile serving port through Jupyter's authenticated web
interface. This is useful in Docker and cloud JupyterHub
environments. You can set the environment variable
``LARGE_IMAGE_JUPYTER_PROXY`` to control the default value of
``JUPYTER_PROXY``. If ``JUPYTER_PROXY`` is set to ``True``, the
default will be ``'/proxy/`` which will work for most Docker
Jupyter configurations. If in a cloud JupyterHub environment,
this will get a bit more nuanced as the
``JUPYTERHUB_SERVICE_PREFIX`` may need to prefix the
``'/proxy/'``.
To programmatically set these values:
.. code::
from large_image.tilesource.jupyter import IPyLeafletMixin
# Only set one of these values
# Use a custom domain (avoids port proxying)
IPyLeafletMixin.JUPYTER_HOST = 'mydomain'
# Proxy in a standard JupyterLab environment
IPyLeafletMixin.JUPYTER_PROXY = True # defaults to `/proxy/`
# Proxy in a cloud JupyterHub environment
IPyLeafletMixin.JUPYTER_PROXY = '/jupyter/user/username/proxy/'
# See if ``JUPYTERHUB_SERVICE_PREFIX`` is in the environment
# variables to improve this
"""
JUPYTER_HOST = '127.0.0.1'
JUPYTER_PROXY = os.environ.get('LARGE_IMAGE_JUPYTER_PROXY', False)
def __init__(self, *args, **kwargs):
self._jupyter_server_manager = None
self._map = Map()
if ipyleaflet:
self.to_map = self._map.to_map
self.from_map = self._map.from_map
[docs]
def as_leaflet_layer(self, **kwargs):
# NOTE: `as_leaflet_layer` is supported by ipyleaflet.Map.add
if self._jupyter_server_manager is None:
# Must relaunch to ensure style updates work
self._jupyter_server_manager = launch_tile_server(self)
else:
# Must update the source on the manager in case the previous reference is bad
self._jupyter_server_manager.tile_source = self
port = self._jupyter_server_manager.port
if self.JUPYTER_PROXY:
if isinstance(self.JUPYTER_PROXY, str):
base_url = f'{self.JUPYTER_PROXY.rstrip("/")}/{port}'
else:
base_url = f'/proxy/{port}'
else:
base_url = f'http://{self.JUPYTER_HOST}:{port}'
# Use repr in URL params to prevent caching across sources/styles
endpoint = f'tile?z={{z}}&x={{x}}&y={{y}}&encoding=png&repr={self.__repr__()}'
return self._map.make_layer(self.metadata, f'{base_url}/{endpoint}')
# Only make _ipython_display_ available if ipyleaflet is installed
if ipyleaflet:
def _ipython_display_(self):
from IPython.display import display
t = self.as_leaflet_layer()
return display(self._map.make_map(self.metadata, t, self.getCenter(srs='EPSG:4326')))
@property
def iplmap(self):
"""
If using ipyleaflets, get access to the map object.
"""
return self._map.map