Skip to content

Commit

Permalink
Merge pull request #78 from astrofrog/load-fits
Browse files Browse the repository at this point in the history
Added support for displaying FITS data
  • Loading branch information
astrofrog committed Apr 6, 2018
2 parents c1a0e15 + bd5073f commit c6c73e3
Show file tree
Hide file tree
Showing 11 changed files with 207 additions and 36 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ env:
global:
- SETUP_XVFB=True
- CONDA_CHANNELS="astrofrog/label/dev"
- CONDA_DEPENDENCIES="astropy qtpy traitlets ipywidgets>=7.0 ipyevents widgetsnbextension pyqt pytest requests nomkl matplotlib beautifulsoup4 lxml jupyterlab"
- CONDA_DEPENDENCIES="astropy qtpy traitlets ipywidgets>=7.0 ipyevents widgetsnbextension pyqt pytest requests nomkl matplotlib beautifulsoup4 lxml jupyterlab flask flask-cors"
- PIP_DEPENDENCIES="sphinx-automodapi numpydoc sphinx_rtd_theme pytest-faulthandler"
matrix:
- PYTHON_VERSION=2.7
Expand Down
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
0.4.0 (unreleased)
------------------

- Added ``load_fits_data`` method for Qt and Jupyter clients. [#78]

- Fix compatibility with Jupyter Lab. [#63, #65]

- Added GUI controls for imagery layers. [#64]
Expand Down
2 changes: 1 addition & 1 deletion appveyor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ environment:
# to the matrix section.

CONDA_CHANNELS: "astrofrog/label/dev conda-forge"
CONDA_DEPENDENCIES: "astropy qtpy traitlets ipywidgets>=7.0 ipyevents widgetsnbextension pyqt pytest requests matplotlib"
CONDA_DEPENDENCIES: "astropy qtpy traitlets ipywidgets>=7.0 ipyevents widgetsnbextension pyqt pytest requests matplotlib flask flask-cors"
PIP_DEPENDENCIES: "pytest-faulthandler"

matrix:
Expand Down
18 changes: 6 additions & 12 deletions lib/wwt.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,9 @@ var WWTView = widgets.DOMWidgetView.extend({
// page. We use the same HTML file as for the Qt client.
var div = document.createElement("div");

try {
// This should work for Jupyter notebook
nbextensions = requirejs.s.contexts._.config.paths.nbextensions;
} catch(e) {
// We should get here in Jupyter lab
nbextensions = null;
}
this.wwt_base_url = require('@jupyterlab/coreutils').PageConfig.getBaseUrl();

if (nbextensions == null) {
div.innerHTML = "<iframe width='100%' height='400' style='border: none;' src='wwt.html'></iframe>"
} else {
div.innerHTML = "<iframe width='100%' height='400' style='border: none;' src='" + nbextensions + "/pywwt/wwt.html'></iframe>"
}
div.innerHTML = "<iframe width='100%' height='400' style='border: none;' src='" + this.wwt_base_url + "wwt/wwt.html'></iframe>"

this.el.appendChild(div);

Expand Down Expand Up @@ -122,6 +112,10 @@ var WWTView = widgets.DOMWidgetView.extend({
}
}

if (msg['url'] != null && msg['url'].slice(4) == '/wwt') {
msg['url'] = this.wwt_base_url + msg['url'];
}

this.wwt_window.wwt_apply_json_message(this.wwt_window.wwt, msg);

}
Expand Down
22 changes: 16 additions & 6 deletions pywwt/core.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from traitlets import HasTraits, observe, validate, TraitError
from astropy import units as u
from astropy.time import Time
Expand Down Expand Up @@ -266,8 +267,8 @@ def _validate_scale(self, proposal):

def track_object(self, obj):
"""
Focus the viewer on a particular object while in solar system mode.
Available objects include the Sun, the planets, the Moon, Jupiter's
Focus the viewer on a particular object while in solar system mode.
Available objects include the Sun, the planets, the Moon, Jupiter's
Galilean moons, and Pluto.
Parameters
Expand Down Expand Up @@ -308,7 +309,7 @@ def set_view(self, mode):
'panorama']
ss_levels = ['solar_system', 'milky_way', 'universe']
ss_mode = '3D Solar System View'

if mode in available:
self._send_msg(event='set_viewer_mode', mode=mode)
if mode == 'sky' or mode == 'panorama':
Expand All @@ -320,12 +321,12 @@ def set_view(self, mode):
self.current_mode = mode
else:
raise ValueError('the given mode does not exist')

self.reset_view()

def reset_view(self):
"""
Reset the current view mode's coordinates and field of view to
Reset the current view mode's coordinates and field of view to
their original states.
"""
if self.current_mode == 'sky':
Expand All @@ -345,7 +346,7 @@ def reset_view(self):
fov=1e14*u.deg, instant=False)
if self.current_mode == 'panorama':
pass

def load_image_collection(self, url):
"""
Load a collection of layers for possible use in the viewer.
Expand Down Expand Up @@ -489,3 +490,12 @@ def add_collection(self, points, **kwargs):
"""
collection = CircleCollection(self, points, **kwargs)
return collection

def _validate_fits_data(self, filename):
if not os.path.exists(filename):
raise Exception("File {0} does not exist".format(filename))
from astropy.wcs import WCS
wcs = WCS(filename)
projection = wcs.celestial.wcs.ctype[0][4:]
if projection != '-TAN':
raise ValueError("Only -TAN FITS files are supported at the moment")
88 changes: 88 additions & 0 deletions pywwt/data_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import os
import time
import socket
import logging
from hashlib import md5
from threading import Thread

__all__ = ['get_data_server']


def get_data_server(verbose=True):
"""
This starts up a flask server and returns a handle to a DataServer
object which can be used to register files to serve.
"""

from flask import Flask
from flask_cors import CORS

class FlaskWrapper(Flask):

port = None
host = None

def run(self, *args, **kwargs):
self.host = kwargs.get('host', None)
self.port = kwargs.get('port', None)
try:
super(FlaskWrapper, self).run(*args, **kwargs)
finally:
self.host = None
self.port = None

app = FlaskWrapper('DataServer')
CORS(app)
if verbose:
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)

class DataServer(object):

def __init__(self):
self._files = {}
self._thread = Thread(target=self.start_app)
self._thread.daemon = True
self._thread.start()
self._app = app
while self._app.host is None and self._app.port is None:
time.sleep(0.1)

@property
def port(self):
return self._app.port

@property
def host(self):
return self._app.host

def start_app(self):
host = socket.gethostbyname('localhost')
for port in range(8000, 9000):
try:
return app.run(host=host, port=port)
except Exception:
pass
raise Exception("Could not start up data server")

def serve_file(self, filename, real_name=True, extension=''):
with open(filename, 'rb') as f:
content = f.read()
if real_name:
hash = os.path.basename(filename)
else:
hash = md5(content).hexdigest() + extension
self._files[hash] = os.path.abspath(filename)
return 'http://' + self.host + ':' + str(self.port) + '/data/' + hash

def get_file_contents(self, hash):
with open(self._files[hash], 'rb') as f:
return f.read()

ds = DataServer()

@app.route("/data/<hash>")
def data(hash):
return ds.get_file_contents(hash)

return ds
14 changes: 14 additions & 0 deletions pywwt/jupyter.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ipyevents import Event as DOMListener

from .core import BaseWWTWidget
from .jupyter_server import serve_file

__all__ = ['WWTJupyterWidget']

Expand Down Expand Up @@ -49,6 +50,19 @@ def _default_layout(self):
def _send_msg(self, **kwargs):
self.send(kwargs)

def load_fits_data(self, filename):
"""
Load a FITS file.
Paramters
---------
filename : str
The filename of the FITS file to display.
"""
self._validate_fits_data(filename)
url = serve_file(filename, extension='.fits')
self._send_msg(event='load_fits', url=url)

@property
def layer_controls(self):
if self._controls is None:
Expand Down
63 changes: 51 additions & 12 deletions pywwt/jupyter_server.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,73 @@
import os
import json
from hashlib import md5
from tornado import web
from notebook.utils import url_path_join
from notebook.base.handlers import IPythonHandler

__all__ = ['load_jupyter_server_extension']


STATIC_DIR = os.path.join(os.path.dirname(__file__), 'nbextension', 'static')
CONFIG = os.path.expanduser('~/.pywwt')


class WWTHTMLHandler(IPythonHandler):
def get(self):
with open(os.path.join(STATIC_DIR, 'wwt.html')) as f:
content = f.read()
self.finish(content)
class WWTFileHandler(IPythonHandler):

def get(self, filename):

filename = os.path.basename(filename)

# First we check if this is a standard file in the static directory
if os.path.exists(os.path.join(STATIC_DIR, filename)):
path = os.path.join(STATIC_DIR, filename)
else:
# If not, we open the config file which should contain a JSON
# dictionary with filenames and paths.
if not os.path.exists(CONFIG):
raise web.HTTPError(404)
with open(CONFIG) as f:
config = json.load(f)
if filename in config['paths']:
path = config['paths'][filename]
else:
raise web.HTTPError(404)

class WWTJSHandler(IPythonHandler):
def get(self):
with open(os.path.join(STATIC_DIR, 'wwt_json_api.js')) as f:
with open(path, 'rb') as f:
content = f.read()

self.finish(content)


def serve_file(path, extension=''):

if not os.path.exists(path):
raise ValueError("Path {0} does not exist".format(path))

hash = md5(path.encode('utf-8')).hexdigest() + extension

with open(CONFIG) as f:
config = json.load(f)

if hash not in config['paths']:

config['paths'][hash] = os.path.abspath(path)

with open(CONFIG, 'w') as f:
json.dump(config, f)

return '/wwt/' + hash


def load_jupyter_server_extension(nb_server_app):

web_app = nb_server_app.web_app
host_pattern = '.*$'

route_pattern = url_path_join(web_app.settings['base_url'], '/wwt.html')
web_app.add_handlers(host_pattern, [(route_pattern, WWTHTMLHandler)])
if not os.path.exists(CONFIG):
config = {'paths': {}}
with open(CONFIG, 'w') as f:
json.dump(config, f)

route_pattern = url_path_join(web_app.settings['base_url'], '/wwt_json_api.js')
web_app.add_handlers(host_pattern, [(route_pattern, WWTJSHandler)])
route_pattern = url_path_join(web_app.settings['base_url'], '/wwt/(.*)')
web_app.add_handlers(host_pattern, [(route_pattern, WWTFileHandler)])
4 changes: 4 additions & 0 deletions pywwt/nbextension/static/wwt_json_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ function wwt_apply_json_message(wwt, msg) {
wwt.gotoRaDecZoom(msg['ra'], msg['dec'], msg['fov'], msg['instant']);
break;

case 'load_fits':
wwt.loadFits(msg['url']);
break;

case 'setting_set':
var name = msg['setting'];
wwt.settings["set_" + name](msg['value']);
Expand Down
24 changes: 21 additions & 3 deletions pywwt/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from .core import BaseWWTWidget
from .logger import logger
from .data_server import get_data_server

__all__ = ['WWTQtClient']

Expand Down Expand Up @@ -96,15 +97,15 @@ def _check_ready(self):

class WWTQtWidget(QtWidgets.QWidget):

def __init__(self, parent=None):
def __init__(self, url=None, parent=None):

super(WWTQtWidget, self).__init__(parent=parent)

self.web = QWebEngineView()
self.page = WWTQWebEnginePage()
self.page.setView(self.web)
self.web.setPage(self.page)
self.web.setHtml(WWT_HTML)
self.web.setUrl(QtCore.QUrl(url))

layout = QtWidgets.QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
Expand Down Expand Up @@ -162,7 +163,11 @@ def __init__(self, block_until_ready=False, size=None):
if app is None:
app = QtWidgets.QApplication([''])

self.widget = WWTQtWidget()
self._data_server = get_data_server()
self._data_server.serve_file(WWT_JSON_FILE, real_name=True)
wwt_url = self._data_server.serve_file(WWT_HTML_FILE, real_name=True)

self.widget = WWTQtWidget(url=wwt_url)
if size is not None:
self.widget.resize(*size)
self.widget.show()
Expand All @@ -186,6 +191,19 @@ def _send_msg(self, async=True, **kwargs):
msg = json.dumps(kwargs)
return self.widget._run_js("wwt_apply_json_message(wwt, {0});".format(msg), async=async)

def load_fits_data(self, filename):
"""
Load a FITS file.
Paramters
---------
filename : str
The filename of the FITS file to display.
"""
self._validate_fits_data(filename)
url = self._data_server.serve_file(filename, extension='.fits')
self._send_msg(event='load_fits', url=url)

def render(self, filename):
"""
Saves a screenshot of the viewer's current state.
Expand Down
Loading

0 comments on commit c6c73e3

Please sign in to comment.