Source code for fsleyes.main

#
# fsleyes.py - Image viewer.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the entry point to *FSLeyes*, the FSL image viewer.

Call the :func:`main` function to start the main FSLeyes application.

The :func:`embed` function can be called to open a :class:`.FSLeyesFrame`
within an existing application.

See the :mod:`fsleyes` package documentation for more details on ``fsleyes``.


.. note:: Even though ``fsleyes`` (this module) and :mod:`fsleyes.render` (the
          off-screen renderer) are intended to be separate applications, the
          current version of PyInstaller (3.x) does not support bundling of
          multiple executables
          (https://github.com/pyinstaller/pyinstaller/issues/1527).

          So at this point in time, :mod:`.fsleyes.render` can be invoked via
          ``fsleyes.main`` by passing ``'render'`` as the first argument,
          e.g.::

              python -m fsleyes.main render ...
"""


import functools as ft
import os.path   as op
import              os
import              sys
import              signal
import              logging
import              textwrap

import wx
import wx.adv

from   fsl.utils.platform import platform as fslplatform
import fsl.utils.idle                     as idle
import fsleyes_widgets.utils.status       as status

import                       fsleyes
import fsleyes.strings    as strings
import fsleyes.splash     as fslsplash
import fsleyes.cliserver  as cliserver
import fsleyes.colourmaps as colourmaps


# wx.ModalDialogHook does not exist in wxPython < 4

if fslplatform.wxFlavour in (fslplatform.WX_PYTHON, fslplatform.WX_UNKNOWN):
    class ModalDialogHook(object):
        def Register(self):
            pass

    wx.ModalDialogHook = ModalDialogHook


log = logging.getLogger(__name__)


[docs]class FSLeyesApp(wx.App): """FSLeyes-specific sub-class of ``wx.App``. """ class ModalHook(wx.ModalDialogHook): """Keeps track of any modal dialogs/windows that are opened. Modal dialogs can interfere with shutdown, as they run their own event loop. Therefore we keep a reference is kept to all opened modal dialogs, so we can manually shut them down if needed (see the :func:`main` function). """ def __init__(self, *args, **kwargs): wx.ModalDialogHook.__init__(self, *args, **kwargs) self.modals = set() def Enter(self, dlg): self.modals.add(dlg) return wx.ID_NONE def Exit(self, dlg): self.modals.discard(dlg)
[docs] def __init__(self): """Create a ``FSLeyesApp``. """ self.__overlayList = None self.__displayCtx = None self.__modalHook = FSLeyesApp.ModalHook() self.__modalHook.Register() wx.App.__init__(self, clearSigInt=False) self.SetAppName('FSLeyes') try: self.__icon = wx.adv.TaskBarIcon(iconType=wx.adv.TBI_DOCK) self.__icon.SetIcon(wx.Icon( op.join(fsleyes.assetDir, 'assets', 'icons', 'app_icon.png'))) except Exception: self.__icon = None
@property def modals(self): """Returns a list of all currently open modal windows. """ return list(self.__modalHook.modals)
[docs] def SetOverlayListAndDisplayContext(self, overlayList, displayCtx): """References to the :class:`.OverlayList` and master :class:`.DisplayContext` must be passed to the ``FSLeyesApp`` via this method. """ self.__overlayList = overlayList self.__displayCtx = displayCtx
[docs] def MacReopenApp(self): """On OSX, make sure that the FSLeyes frame is restored if it is minimised, and (e.g.) the dock icon is clicked. """ frame = self.GetTopWindow() frame.Iconize(False) frame.Raise()
[docs] def MacOpenFile(self, filename): """On OSX, support opening files via context menu, and files dropped on the application icon. """ self.MacOpenFiles([filename])
[docs] def MacOpenURL(self, url): """On OSX, support opening files via a ``fsleyes://`` url. """ if self.__overlayList is None: return import fsleyes_widgets.utils.status as status import fsleyes.strings as strings import fsleyes.parseargs as parseargs import fsleyes.actions.applycommandline as applycommandline errTitle = strings.titles[ self, 'openURLError'] errMsg = strings.messages[self, 'openURLError'] with status.reportIfError(errTitle, errMsg): applycommandline.applyCommandLineArgs( self.__overlayList, self.__displayCtx, parseargs.fsleyesUrlToArgs(url))
[docs] def MacOpenFiles(self, filenames): """On OSX, support opening files via context menu, and files dropped on the application icon. """ if self.__overlayList is None: return import fsleyes.actions.loadoverlay as loadoverlay import fsleyes.autodisplay as autodisplay def onLoad(paths, overlays): if len(overlays) == 0: return self.__overlayList.extend(overlays) if self.__displayCtx.autoDisplay: for overlay in overlays: autodisplay.autoDisplay(overlay, self.__overlayList, self.__displayCtx) loadoverlay.loadOverlays( filenames, onLoad=onLoad, inmem=self.__displayCtx.loadInMemory)
[docs]def main(args=None): """*FSLeyes* entry point. Shows a :class:`.FSLeyesSplash` screen, parses command line arguments, and shows a :class:`.FSLeyesFrame`. Returns an exit code. """ if args is None: args = sys.argv[1:] # Hack to allow render to # be called via fsleyes.main if len(args) >= 1 and args[0] == 'render': import fsleyes.render as render render.main(args[1:]) sys.exit(0) # the fsleyes.initialise function figures # out the path to asset files (e.g. cmaps) fsleyes.initialise() # Hook which allows us to run a jupyter # notebook server from a frozen version # of FSLeyes if len(args) >= 1 and args[0] == 'notebook': from fsleyes.actions.notebook import nbmain fsleyes.configLogging() sys.exit(nbmain(args)) # initialise colour maps - this must be # done before parsing arguments, as if # the user asks for help, available # colourmaps/luts will be listed. colourmaps.init() # Function to bootstrap the GUI - keep # reading below. def initgui(): # First thing's first. Create a wx.App, # and initialise the FSLeyes package. app = FSLeyesApp() # Create a splash screen frame splash = fslsplash.FSLeyesSplash(None) return app, splash # If it looks like the user is asking for # help, or using cliserver to pass arguments # to an existing FSLeyes instance, then we # parse command line arguments before # creating a wx.App and showing the splash # screen. This means that FSLeyes help/ # version information can be retrieved # without a display, and hopefully fairly # quickly. # # Otherwise we create the app and splash # screen first, so the splash screen gets # shown as soon as possible. Arguments # will get parsed in the init function below. # # The argparse.Namespace object is kept in a # list so it can be shared between the sub- # functions below # # If argument parsing bombs out, we put the # exit code here and return it at the bottom. namespace = [None] exitCode = [0] # user asking for help - parse args first if (len(args) > 0) and (args[0] in ('-V', '-h', '-fh', '-cs', '--version', '--help', '--fullhelp', '--cliserver')): namespace = [parseArgs(args)] app, splash = initgui() # otherwise parse arguments on wx.MainLoop # below else: app, splash = initgui() # We are going do all processing on the # wx.MainLoop, so the GUI can be shown # as soon as possible, and because it is # difficult to force immediate GUI # refreshes when not running on the main # loop - this is important for FSLeyes, # which displays status updates to the # user while it is loading overlays and # setting up the interface. # # All of the work is defined in a series # of functions, which are chained together # via ugly callbacks, but which are # ultimately scheduled and executed on the # wx main loop. def init(splash): # See FSLeyesSplash.Show # for horribleness. splash.Show() # Parse command line arguments if necessary. # If arguments are invalid, the parseargs # module will raise SystemExit. try: if namespace[0] is None: errmsg = strings.messages['main.parseArgs.error'] errtitle = strings.titles[ 'main.parseArgs.error'] with status.reportIfError(errtitle, errmsg, raiseError=True): namespace[0] = parseArgs(args) # But the wx.App.MainLoop eats SystemExit # exceptions for unknown reasons, and # causes the application to exit # immediately. This makes testing FSLeyes # (e.g. code coverage) impossible. So I'm # catching SystemExit here, and then # telling the wx.App to exit gracefully. except (SystemExit, Exception) as e: app.ExitMainLoop() exitCode[0] = getattr(e, 'code', 1) return # Configure logging (this has to be done # after cli arguments have been parsed, # but before initialise is called). fsleyes.configLogging(namespace[0].verbose, namespace[0].noisy) # Initialise sub-modules/packages. The # buildGui function is passed through # as a callback, which gets called when # initialisation is complete. initialise(splash, namespace[0], buildGui) def buildGui(): # Now the main stuff - create the overlay # list and the master display context, # and then create the FSLeyesFrame. overlayList, displayCtx = makeDisplayContext(namespace[0], splash) app.SetOverlayListAndDisplayContext(overlayList, displayCtx) frame = makeFrame(namespace[0], displayCtx, overlayList, splash) app.SetTopWindow(frame) frame.Show() # Check that $FSLDIR is set, complain # to the user if it isn't if not namespace[0].skipfslcheck: wx.CallAfter(fslDirWarning, frame) # Check for updates. Ignore point # releases, otherwise users might # get swamped with update notifications. if namespace[0].updatecheck: import fsleyes.actions.updatecheck as updatecheck wx.CallAfter(updatecheck.UpdateCheckAction(), showUpToDateMessage=False, showErrorMessage=False, ignorePoint=False) # start notebook server if namespace[0].notebookFile is not None: namespace[0].notebook = True namespace[0].notebookFile = op.abspath(namespace[0].notebookFile) if namespace[0].notebook: from fsleyes.actions.notebook import NotebookAction frame.menuActions[NotebookAction](namespace[0].notebookFile) # start CLI server if namespace[0].cliserver: cliserver.runserver(overlayList, displayCtx) # Shut down cleanly on sigint/sigterm. # We do this so that any functions # registered with atexit will actually # get called. nsignals = [0] def sigHandler(signo, frame): log.debug('Signal received - FSLeyes is shutting down...') # first signal - try to exit cleanly if nsignals[0] == 0: nsignals[0] += 1 exitCode[0] = signo # kill any modal windows # that are open for mdlg in app.modals: mdlg.EndModal(wx.ID_CANCEL) wx.CallAfter(app.ExitMainLoop) # subsequent signals - exit immediately else: sys.exit(signo) signal.signal(signal.SIGINT, sigHandler) signal.signal(signal.SIGTERM, sigHandler) # Note: If no wx.Frame is created, the # wx.MainLoop call will exit immediately, # even if we have scheduled something via # wx.CallAfter. In this case, we have # already created the splash screen, so # all is well. wx.CallAfter(init, splash) # under mac, use appnope to make sure # we don't get put to sleep. This is # primarily for the jupyter notebook # integration - if the user is working # with a notebook in the web browser, # macos might put FSLeyes to sleep, # causing the kernel to become # unresponsive. try: import appnope appnope.nope() except ImportError: pass app.MainLoop() shutdown() return exitCode[0]
[docs]def embed(mkFrame=True, **kwargs): """Initialise FSLeyes and create a :class:`.FSLeyesFrame`, when running within another application. .. note:: In most cases, this function must be called from the ``wx.MainLoop``. :arg mkFrame: Defaults to ``True``. If ``False``, FSLeyes is initialised, but a :class:`.FSLeyesFrame` is not created. If you set this to ``False``, you must ensure that a ``wx.App`` object exists before calling this function. :returns: A tuple containing: - The :class:`.OverlayList` - The master :class:`.DisplayContext` - The :class:`.FSLeyesFrame` (or ``None``, if ``makeFrame is False``). All other arguments are passed to :meth:`.FSLeyesFrame.__init__`. """ import fsleyes_props as props import fsleyes.gl as fslgl import fsleyes.frame as fslframe import fsleyes.overlay as fsloverlay import fsleyes.displaycontext as fsldc # initialise must be called before # a FSLeyesApp gets created, as it # tries to access app_icon.png fsleyes.initialise() app = wx.GetApp() ownapp = app is None if ownapp and (mkFrame is False): raise RuntimeError('If mkFrame is False, you ' 'must create a wx.App before ' 'calling fsleyes.main.embed') if ownapp: app = FSLeyesApp() colourmaps.init() props.initGUI() called = [False] ret = [None] def until(): return called[0] def ready(): fslgl.bootstrap() overlayList = fsloverlay.OverlayList() displayCtx = fsldc.DisplayContext(overlayList) if mkFrame: frame = fslframe.FSLeyesFrame( None, overlayList, displayCtx, **kwargs) else: frame = None if ownapp: app.SetOverlayListAndDisplayContext(overlayList, displayCtx) # Keep a ref to prevent the app from being GC'd frame._embed_app = app called[0] = True ret[0] = (overlayList, displayCtx, frame) fslgl.getGLContext(ready=ready, raiseErrors=True) idle.block(10, until=until) if ret[0] is None: raise RuntimeError('Failed to start FSLeyes') return ret[0]
[docs]def initialise(splash, namespace, callback): """Called by :func:`main`. Bootstraps/Initialises various parts of *FSLeyes*. The ``callback`` function is asynchronously called when the initialisation is complete. :arg splash: The :class:`.FSLeyesSplash` screen. :arg namespace: The ``argparse.Namespace`` object containing parsed command line arguments. :arg callback: Function which is called when initialisation is done. """ import fsl.utils.settings as fslsettings import fsleyes_props as props import fsleyes.gl as fslgl props.initGUI() # The save/load directory defaults # to the current working directory. curDir = op.normpath(os.getcwd()) # But if we are running as a frozen application, check to # see if FSLeyes has been started by the system (e.g. # double-clicking instead of being called from the CLI). # # If so, we set the save/load directory # to the user's home directory instead. if fslplatform.frozen: fsleyesDir = op.dirname(__file__) # If we're a frozen OSX application, # we need to adjust the FSLeyes dir # (which will be: # [install_dir]/FSLeyes.app/Contents/MacOS/fsleyes/), # # Because the cwd will default to: # [install_dir]/ if fslplatform.os == 'Darwin': fsleyesDir = op.normpath(op.join(fsleyesDir, '..', '..', '..', '..')) # Similar adjustment for linux elif fslplatform.os == 'Linux': fsleyesDir = op.normpath(op.join(fsleyesDir, '..')) if curDir == fsleyesDir: curDir = op.expanduser('~') fslsettings.write('loadSaveOverlayDir', curDir) # Initialise silly things if namespace.bumMode: import fsleyes.icons as icons icons.BUM_MODE = True # Set notebook server port fslsettings.write('fsleyes.notebook.port', namespace.notebookPort) # This is called by fsleyes.gl.getGLContext # when the GL context is ready to be used. def realCallback(): fslgl.bootstrap(namespace.glversion) callback() try: # Force the creation of a wx.glcanvas.GLContext object, # and initialise OpenGL version-specific module loads. fslgl.getGLContext(ready=realCallback) except Exception: log.error('Unable to initialise OpenGL!', exc_info=True) splash.Destroy() sys.exit(1)
[docs]def shutdown(): """Called when FSLeyes exits normally (i.e. the user closes the window). Does some final clean-up before exiting. """ import fsl.utils.settings as fslsettings # Clear the cached directory for loading/saving # files - when FSLeyes starts up, we want it to # default to the current directory. fslsettings.delete('loadSaveOverlayDir')
[docs]def parseArgs(argv): """Parses the given ``fsleyes`` command line arguments. See the :mod:`.parseargs` module for details on the ``fsleyes`` command line interface. :arg argv: command line arguments for ``fsleyes``. """ import fsleyes.parseargs as parseargs import fsleyes.layouts as layouts import fsleyes.version as version parser = parseargs.ArgumentParser( add_help=False, formatter_class=parseargs.FSLeyesHelpFormatter) serveraction = ft.partial(cliserver.CLIServerAction, allArgs=argv) parser.add_argument('-r', '--runscript', metavar='SCRIPTFILE', help='Run custom FSLeyes script') parser.add_argument('-cs', '--cliserver', action=serveraction, help='Pass all command-line arguments ' 'to a single FSLeyes instance') # We include the list of available # layouts in the help description allLayouts = list(layouts.BUILT_IN_LAYOUTS.keys()) + \ list(layouts.getAllLayouts()) name = 'fsleyes' prolog = 'FSLeyes version {}\n'.format(version.__version__) description = textwrap.dedent("""\ FSLeyes - the FSL image viewer. Use the '--scene' option to load a saved layout ({layouts}). If no '--scene' is specified, a default layout is shown or the previous layout is restored. If a script is provided via the '--runscript' argument, it is assumed that the script sets up the scene. """.format(layouts=', '.join(allLayouts))) # Options for configuring the scene are # managed by the parseargs module return parseargs.parseArgs(parser, argv, name, prolog=prolog, desc=description, argOpts=['-r', '--runscript'])
[docs]def makeDisplayContext(namespace, splash): """Creates the top-level *FSLeyes* :class:`.DisplayContext` and :class:`.OverlayList` . This function does the following: 1. Creates the :class:`.OverlayList` and the top level :class:`.DisplayContext`. 2. Loads and configures all of the overlays which were passed in on the command line. :arg namesace: Parsed command line arguments (see :func:`parseArgs`). :arg splash: The :class:`.FSLeyesSplash` frame, created in :func:`init`. :returns: a tuple containing: - the :class:`.OverlayList` - the master :class:`.DisplayContext` """ import fsleyes_widgets.utils.status as status import fsleyes.overlay as fsloverlay import fsleyes.parseargs as parseargs import fsleyes.displaycontext as displaycontext # Splash status update must be # performed on the main thread. def splashStatus(msg): wx.CallAfter(splash.SetStatus, msg) # Redirect status updates # to the splash frame status.setTarget(splashStatus) # Create the overlay list (only one of these # ever exists) and the master DisplayContext. # A new DisplayContext instance will be # created for every new view that is opened # in the FSLeyesFrame, but all child # DisplayContext instances will be linked to # this master one. overlayList = fsloverlay.OverlayList() displayCtx = displaycontext.DisplayContext(overlayList) log.debug('Created overlay list and master DisplayContext ({})'.format( id(displayCtx))) # Load the images - the splash screen status will # be updated with the currently loading overlay name. parseargs.applyMainArgs( namespace, overlayList, displayCtx) parseargs.applyOverlayArgs(namespace, overlayList, displayCtx) return overlayList, displayCtx
[docs]def makeFrame(namespace, displayCtx, overlayList, splash): """Creates the *FSLeyes* interface. This function does the following: 1. Creates the :class:`.FSLeyesFrame` the top-level frame for ``fsleyes``. 2. Configures the frame according to the command line arguments (e.g. ortho or lightbox view). 3. Destroys the splash screen that was created by the :func:`context` function. :arg namespace: Parsed command line arguments, as returned by :func:`parseArgs`. :arg displayCtx: The :class:`.DisplayContext`, as created and returned by :func:`makeDisplayContext`. :arg overlayList: The :class:`.OverlayList`, as created and returned by :func:`makeDisplayContext`. :arg splash: The :class:`.FSLeyesSplash` frame. :returns: the :class:`.FSLeyesFrame` that was created. """ import fsl.utils.idle as idle import fsleyes_widgets.utils.status as status import fsleyes.parseargs as parseargs import fsleyes.frame as fsleyesframe import fsleyes.displaycontext as fsldisplay import fsleyes.layouts as layouts import fsleyes.views.canvaspanel as canvaspanel # Set up the frame scene (a.k.a. layout) # The scene argument can be: # # - The name of a saved (or built-in) layout # # - None, in which case the default or previous # layout is restored, unless a custom script # has been provided. script = namespace.runscript scene = namespace.scene # If a scene/layout or custom script # has not been specified, the default # behaviour is to restore the previous # frame layout. restore = (scene is None) and (script is None) status.update('Creating FSLeyes interface...') frame = fsleyesframe.FSLeyesFrame( None, overlayList, displayCtx, restore, True, fontSize=namespace.fontSize) # Allow files to be dropped # onto FSLeyes to open them dt = fsleyesframe.OverlayDropTarget(overlayList, displayCtx) frame.SetDropTarget(dt) # Make sure the new frame is shown # before destroying the splash screen frame.Show(True) frame.Refresh() frame.Update() # In certain instances under Linux/GTK, # closing the splash screen will crash # the application. No idea why. So we # leave the splash screen hidden, but # not closed, and close it when the main # frame is closed. This also works under # OSX. splash.Hide() splash.Refresh() splash.Update() def onFrameDestroy(ev): ev.Skip() # splash screen may already # have been destroyed try: splash.Close() except Exception: pass frame.Bind(wx.EVT_WINDOW_DESTROY, onFrameDestroy) status.update('Setting up scene...') # Set the default SceneOpts.performance # level so that all created SceneOpts # instances will default to it if namespace.performance is not None: fsldisplay.SceneOpts.performance.setAttribute( None, 'default', namespace.performance) # If a layout has been specified, # we load the layout if namespace.scene is not None: layouts.loadLayout(frame, namespace.scene) # Apply any view-panel specific arguments viewPanels = frame.viewPanels for viewPanel in viewPanels: if not isinstance(viewPanel, canvaspanel.CanvasPanel): continue displayCtx = viewPanel.displayCtx viewOpts = viewPanel.sceneOpts parseargs.applySceneArgs( namespace, overlayList, displayCtx, viewOpts) # If a script has been specified, we run # the script. This has to be done on the # idle loop, because overlays specified # on the command line are loaded on the # idle loop. Therefore, if we schedule the # script on idle (which is a queue), the # script can assume that all overlays have # already been loaded. from fsleyes.actions.runscript import RunScriptAction if script is not None: idle.idle(frame.menuActions[RunScriptAction], script) return frame
[docs]def fslDirWarning(parent): """Checks to see if the ``$FSLDIR`` environment variable is set, or if a FSL installation directory has been saved previously. If not, displays a warning via a :class:`.FSLDirDialog`. :arg parent: A ``wx`` parent object. """ if fslplatform.fsldir is not None: return import fsl.utils.settings as fslsettings # Check settings before # prompting the user fsldir = fslsettings.read('fsldir') if fsldir is not None: fslplatform.fsldir = fsldir return from fsleyes_widgets.dialog import FSLDirDialog dlg = FSLDirDialog(parent, 'FSLeyes', fslplatform.os == 'Darwin') if dlg.ShowModal() == wx.ID_OK: fsldir = dlg.GetFSLDir() log.debug('Setting $FSLDIR to {} (specified ' 'by user)'.format(fsldir)) fslplatform.fsldir = fsldir fslsettings.write('fsldir', fsldir)
if __name__ == '__main__': main()