diff --git a/apidoc/conf.py b/apidoc/conf.py index 6a29f73e2be4c18b46f83a15dfbd84d9448312ae..ff39e3029272a455f440c137aba81b4ac60c7857 100644 --- a/apidoc/conf.py +++ b/apidoc/conf.py @@ -63,10 +63,11 @@ copyright = u'{}, Paul McCarthy, FMRIB Centre'.format(date.year) # built documents. # # The short X.Y version. -import fsl -version = fsl.__version__ +import fsl.version as fslversion +version = fslversion.__version__ + # The full version, including alpha/beta/rc tags. -release = version +release = '{} ({})'.format(version, fslversion.__vcs_version__) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -277,7 +278,7 @@ texinfo_documents = [ epub_title = u'FSLpy' epub_author = u'Paul McCarthy' epub_publisher = u'Paul McCarthy' -epub_copyright = u'{}, Paul McCarthy'.format(date.year) +epub_copyright = u'{}, Paul McCarthy, FMRIB Centre, Oxford'.format(date.year) # The basename for the epub file. It defaults to the project name. #epub_basename = u'FSLpy' diff --git a/apidoc/images/x11_slicecanvas_show_bug.png b/apidoc/images/x11_slicecanvas_show_bug.png new file mode 100644 index 0000000000000000000000000000000000000000..6dbc92468d1b47814e384ffbee4cc1a3352018c6 Binary files /dev/null and b/apidoc/images/x11_slicecanvas_show_bug.png differ diff --git a/fsl/__init__.py b/fsl/__init__.py index a91349f8ac761c39bdc92728805fe17151cc4f82..d80e672baeaf78374a52a522603e8d4d5d981a6b 100644 --- a/fsl/__init__.py +++ b/fsl/__init__.py @@ -60,7 +60,6 @@ import importlib import threading import subprocess - import fsl.tools as tools import fsl.utils.settings as fslsettings @@ -82,75 +81,126 @@ def main(args=None): :arg args: Command line arguments. If not provided, ``sys.argv`` is used. """ - # Search the environment for FSLDIR - fsldir = os.environ.get('FSLDIR', None) - fslEnvActive = fsldir is not None - if args is None: args = sys.argv[1:] - allTools = _getFSLToolNames() - fslTool, args, toolArgs = _parseArgs(args, allTools) + # Get a list of all available tools, + # and parse the top-level arguments + allTools = _getFSLToolNames() + fslTool, namespace, toolArgv = _parseTopLevelArgs(args, allTools) - # Is this a GUI tool? - if fslTool.interface is not None: + # GUI or command-line tool? + if fslTool.interface is not None: _runGUITool(fslTool, toolArgv) + else: _runCLITool(fslTool, toolArgv) - import wx - # The main interface is created on the - # wx.MainLoop, because it is difficult - # to force immediate GUI refreshes when - # not running on the main loop - this - # is important for, e.g. FSLEyes, which - # displays status updates to the user - # while it is loading overlays and - # setting up the interface. - # - # To make this work, this buildGUI - # function is called on a separate thread - # (so it is executed after wx.MainLoop - # has been called), but it schedules its - # work to be done on the wx.MainLoop. - def buildGUI(): - def realBuild(): - - if fslTool.context is not None: ctx = fslTool.context(toolArgs) - else: ctx = None - - frame = _buildGUI(toolArgs, fslTool, ctx, fslEnvActive) - frame.Show() - - # See comment below - dummyFrame.Destroy() - - _fslDirWarning(frame, fslTool.toolName, fslEnvActive) - - if args.wxinspect: - import wx.lib.inspection - wx.lib.inspection.InspectionTool().Show() - - time.sleep(0.1) - wx.CallAfter(realBuild) - - # Create the wx.App object, and create a dummy - # frame. If we don't create a dummy frame, the - # wx.MainLoop call will just return immediately. - # The buildGUI function above will kill the dummy - # frame when it has created the real interface. - app = wx.App() - dummyFrame = wx.Frame(None) - - threading.Thread(target=buildGUI).start() - app.MainLoop() - - # Or is this a CLI tool? - elif fslTool.execute is not None: - - if fslTool.context is not None: ctx = fslTool.context(toolArgs) - else: ctx = None +def _runGUITool(fslTool, toolArgv): + """Runs the given ``FSLTool``, which is assumed to be a GUI tool. + + :arg fslTool: The ``FSLTool`` to run - see the :func:`_loadFSLTool` + function. + + :arg toolArgv: Unparsed tool-specific command line arguments. + """ + import wx + + # Create the wx.App object befor fslTool.init, + # in case it does GUI stuff. Also create a dummy + # frame - if we don't create a dummy frame, the + # wx.MainLoop call below will just return + # immediately. + # + # The buildGUI function below will kill the dummy + # frame when it has created the real interface. + app = wx.App() + dummyFrame = wx.Frame(None) + + # Call the tool's init + # function if there is one + if fslTool.init is not None: initVal = fslTool.init() + else: initVal = None + + # 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, e.g. + # FSLEyes, which displays status updates + # to the user while it is loading overlays + # and setting up the interface. + # + # To make this work, this buildGUI + # function is called on a separate thread + # (so it is executed after wx.MainLoop + # has been called), but it schedules its + # work to be done on the wx.MainLoop. + def buildGUI(): + def realBuild(): + + # Parse the tool-specific + # command line arguments + toolNamespace = _parseToolArgs(fslTool, toolArgv) + + # Call the tool context function + if fslTool.context is not None: + ctx = fslTool.context(toolNamespace, initVal) + else: + ctx = None + + # Build the GUI + frame = _buildGUI(toolNamespace, fslTool, ctx) + frame.Show() + + # See comment about the + # dummy frame below + dummyFrame.Destroy() + + # Sleep a bit so the main thread (on + # which wx.MainLoop is running) can + # start. + time.sleep(0.01) + wx.CallAfter(_fslDirWarning, + None, + fslTool.toolName, + 'FSLDIR' in os.environ) + wx.CallAfter(realBuild) + + threading.Thread(target=buildGUI).start() + app.MainLoop() + + +def _runCLITool(fslTool, toolArgv): + """Runs the given ``FSLTool``, which is assumed to be a command-line (i.e. + non-GUI) tool. + + :arg fslTool: The ``FSLTool`` to run - see the :func:`_loadFSLTool` + function. + + :arg toolArgv: Unparsed tool-specific command line arguments. + """ + + if fslTool.execute is None: + return + + # Call the tool's init + # function if there is one + if fslTool.init is not None: initVal = fslTool.init() + else: initVal = None + + # Parse the tool-specific + # command line arguments + namespace = _parseToolArgs(fslTool, toolArgv) + + initVal = None + ctx = None + + if fslTool.init is not None: initVal = fslTool.init() + if fslTool.context is not None: ctx = fslTool.context(namespace, + initVal) - _fslDirWarning(None, fslTool.toolName, fslEnvActive) - fslTool.execute(toolArgs, ctx) + _fslDirWarning(None, fslTool.toolName, 'FSLDIR' in os.environ) + fslTool.execute(namespace, ctx) def runTool(toolName, args, **kwargs): @@ -204,7 +254,7 @@ def _getFSLToolNames(): # directory, should work for both frozen and # unfrozen apps. if getattr(sys, 'frozen', False): - allTools = ['fsleyes', 'render', 'bet', 'flirt', 'feat'] + allTools = ['fsleyes', 'render'] else: allTools = [mod for _, mod, _ in pkgutil.iter_modules(tools.__path__)] @@ -224,6 +274,7 @@ def _loadFSLTool(moduleName): ``moduleName`` The tool module name. ``toolName`` The tool name. ``helpPage`` The tool help page URL. + ``init`` An initialisation function. ``parseArgs`` A function to parse tool arguments. ``context`` A function to generate the tool context. ``interface`` A function to create the tool interface. @@ -239,6 +290,7 @@ def _loadFSLTool(moduleName): # Each FSL tool module may specify several things toolName = getattr(module, 'FSL_TOOLNAME', None) helpPage = getattr(module, 'FSL_HELPPAGE', 'index') + init = getattr(module, 'FSL_INIT', None) parseArgs = getattr(module, 'FSL_PARSEARGS', None) context = getattr(module, 'FSL_CONTEXT', None) interface = getattr(module, 'FSL_INTERFACE', None) @@ -267,6 +319,7 @@ def _loadFSLTool(moduleName): fsltool.moduleName = moduleName fsltool.toolName = toolName fsltool.helpPage = helpPage + fsltool.init = init fsltool.parseArgs = parseArgs fsltool.context = context fsltool.interface = interface @@ -276,20 +329,18 @@ def _loadFSLTool(moduleName): return fsltool -def _parseArgs(argv, allTools): - """Creates a command line :class:`argparse.ArgumentParser` which will - process general arguments for ``fslpy`` and arguments for all FSL tools - which have defined their own command line arguments. - - Parses the command line arguments, configures logging verbosity, and - returns a tuple containing the following: +def _parseTopLevelArgs(argv, allTools): + """Parses top-level command line arguments. This involves parsing arguments + which are shared across all tools. Also identifies the tool to be invoked + and configures logging verbosity. Returns a tuple containing the + following: - The ``FSLTool`` instance (see :func:`_loadFSLTool`). - The :class:`argparse.Namespace` instance containing parsed arguments. - - The return value of the ``FSLTool.parseArgs`` function (see - :func:`_loadFSLTool`). + - All remaining unparsed command line arguments (to be passed to the + :func:`_parseToolArgs` function). """ epilog = 'Type fslpy help <tool> for program-specific help. ' \ @@ -317,10 +368,6 @@ def _parseArgs(argv, allTools): '-m', '--memory', action='store_true', help='Output memory events (implied if -v is set)') - parser.add_argument( - '-w', '--wxinspect', action='store_true', - help='Run wx inspection tool') - parser.add_argument('tool', help='FSL program to run', nargs='?') # No arguments at all? @@ -353,7 +400,6 @@ def _parseArgs(argv, allTools): # the top level args fslArgv = argv[:firstPos + 1] toolArgv = argv[ firstPos + 1:] - namespace = parser.parse_args(fslArgv) # Version number @@ -449,14 +495,28 @@ def _parseArgs(argv, allTools): # things if its logging level has been # set to DEBUG, so we import it now so # it can set itself up. - import fsl.utils.trace + traceLogger = logging.getLogger('fsl.utils.trace') + if traceLogger.getEffectiveLevel() <= logging.DEBUG: + import fsl.utils.trace fslTool = _loadFSLTool(namespace.tool) - if fslTool.parseArgs is not None: toolArgs = fslTool.parseArgs(toolArgv) - else: toolArgs = None + return fslTool, namespace, toolArgv + + +def _parseToolArgs(tool, argv): + """Parses tool-specific command-line arguments. Returns the result of + calling the ``FSL_PARSEARGS`` attribute of the given tool, or ``None`` + if the tool does not have the function. + + :arg tool: The ``FSLTool`` to be invoked. + :arg argv: Command line arguments to be parsed. + """ + + if tool.parseArgs is not None: toolNamespace = tool.parseArgs(argv) + else: toolNamespace = None - return fslTool, namespace, toolArgs + return toolNamespace def _fslDirWarning(parent, toolName, fslEnvActive): @@ -515,16 +575,13 @@ def _fslDirWarning(parent, toolName, fslEnvActive): log.warn(warnmsg) -def _buildGUI(args, fslTool, toolCtx, fslEnvActive): +def _buildGUI(args, fslTool, toolCtx): """Builds a :mod:`wx` GUI for the tool. :arg fslTool: The ``FSLTool`` instance (see :func:`_loadFSLTool`). :arg toolCtx: The tool context, as returned by the ``FSLTool.context`` function. - - :arg fslEnvActive: Set to ``True`` if ``$FSLDIR`` is set, ``False`` - otherwise. """ import wx diff --git a/fsl/data/featimage.py b/fsl/data/featimage.py index b18193e5b9c630a122fe9fe77a05c98f9815b10b..59cc9ce8929c1171d019dee99498d32fe8531138 100644 --- a/fsl/data/featimage.py +++ b/fsl/data/featimage.py @@ -286,8 +286,8 @@ class FEATImage(fslimage.Image): """ if not fullmodel: - contrast = np.array(contrast) - contrast /= np.sqrt((contrast ** 2).sum()) + contrast = np.array(contrast) + contrast = contrast / np.sqrt((contrast ** 2).sum()) x, y, z = xyz numEVs = self.numEVs() diff --git a/fsl/data/melodicresults.py b/fsl/data/melodicresults.py index bcc60cb1449c332a92956e520f90e5fe7ab03127..d2254a3a2c8caa820079b00b2ac3cdd9083085c0 100644 --- a/fsl/data/melodicresults.py +++ b/fsl/data/melodicresults.py @@ -286,7 +286,7 @@ class MelodicClassification(props.HasProperties): labels = [self.getDisplayLabel(l) for l in self.labels[c]] allLabels.append(labels) - saveMelodicLabelFile(self.__melImage.getMelodicDir(), + saveMelodicLabelFile(self.__melimage.getMelodicDir(), allLabels, filename) diff --git a/fsl/data/strings.py b/fsl/data/strings.py index 11a892c760ae26c5dc069340a3041f877a607f39..b60c9d252983b9ac6522d2b00725ea89fd723d5d 100644 --- a/fsl/data/strings.py +++ b/fsl/data/strings.py @@ -912,6 +912,7 @@ about = { 'email' : 'paulmc@fmrib.ox.ac.uk', 'company' : u'\u00A9 FMRIB Centre, Oxford, UK', 'version' : 'FSLeyes version: {}', + 'vcsVersion' : 'Internal version: {}', 'glVersion' : 'OpenGL version: {}', 'glRenderer' : 'OpenGL renderer: {}', 'software' : textwrap.dedent( diff --git a/fsl/fsleyes/about.py b/fsl/fsleyes/about.py index 49a31bd675d0f9d11f41b359a6ce61009f033c05..23a3c7a0a645042a7c8628f8222ee1e12b26a071 100644 --- a/fsl/fsleyes/about.py +++ b/fsl/fsleyes/about.py @@ -38,43 +38,27 @@ class AboutDialog(wx.Dialog): splashimg = splashbmp.ConvertToImage() # Create all the widgets - splashPanel = imagepanel.ImagePanel(self, splashimg) - authorLabel = wx.StaticText(self) - emailLabel = wx.StaticText(self) - companyLabel = wx.StaticText(self) - versionLabel = wx.StaticText(self) - glVersionLabel = wx.StaticText(self) - glRendererLabel = wx.StaticText(self) - softwareField = wx.TextCtrl( self, - size=(-1, 200), - style=(wx.TE_LEFT | - wx.TE_RICH | - wx.TE_MULTILINE | - wx.TE_READONLY | - wx.TE_AUTO_URL)) - closeButton = wx.Button( self, id=wx.ID_CANCEL) + splashPanel = imagepanel.ImagePanel(self, splashimg) + textPanel = wx.TextCtrl(self, + size=(-1, 200), + style=(wx.TE_LEFT | + wx.TE_RICH | + wx.TE_MULTILINE | + wx.TE_READONLY | + wx.TE_AUTO_URL)) + closeButton = wx.Button(self, id=wx.ID_CANCEL) # Set foreground/background colours - objs = [self, - authorLabel, - emailLabel, - companyLabel, - versionLabel, - glVersionLabel, - glRendererLabel, - softwareField] - - for obj in objs: - obj.SetBackgroundColour('#000000') - obj.SetForegroundColour('#ffffff') - - softwareField.SetDefaultStyle(wx.TextAttr('#ffffff', wx.NullColour)) + textPanel.SetBackgroundColour('#000000') + textPanel.SetForegroundColour('#ffffff') + textPanel.SetDefaultStyle(wx.TextAttr('#ffffff', wx.NullColour)) # Create / retrieve all the content - verStr = version.__version__ - glVerStr = gl.glGetString(gl.GL_VERSION) - glRenStr = gl.glGetString(gl.GL_RENDERER) - swlibs = strings.about['libs'] + verStr = version.__version__ + vcsVerStr = version.__vcs_version__ + glVerStr = gl.glGetString(gl.GL_VERSION) + glRenStr = gl.glGetString(gl.GL_RENDERER) + swlibs = strings.about['libs'] swVersions = [] for lib in swlibs: @@ -90,10 +74,11 @@ class AboutDialog(wx.Dialog): swVersions.append(swVer) - verStr = strings.about['version'] .format(verStr) - glVerStr = strings.about['glVersion'] .format(glVerStr) - glRenStr = strings.about['glRenderer'].format(glRenStr) - swStr = strings.about['software'] .format(*swVersions) + verStr = strings.about['version'] .format(verStr) + vcsVerStr = strings.about['vcsVersion'].format(vcsVerStr) + glVerStr = strings.about['glVersion'] .format(glVerStr) + glRenStr = strings.about['glRenderer'].format(glRenStr) + swStr = strings.about['software'] .format(*swVersions) # Tack the license file contents onto # the end of the software description. @@ -109,47 +94,26 @@ class AboutDialog(wx.Dialog): swStr = swStr.strip() # Set the widget content - authorLabel .SetLabel(strings.about['author']) - emailLabel .SetLabel(strings.about['email']) - companyLabel .SetLabel(strings.about['company']) - versionLabel .SetLabel(verStr) - glVersionLabel .SetLabel(glVerStr) - glRendererLabel.SetLabel(glRenStr) - softwareField .SetValue(swStr) - closeButton .SetLabel('Close') + infoStr = '\n'.join((verStr, + strings.about['company'], + strings.about['author'], + strings.about['email'], + vcsVerStr, + glVerStr, + glRenStr)) - # Arrange the widgets - mainSizer = wx.BoxSizer(wx.VERTICAL) - row1Sizer = wx.BoxSizer(wx.HORIZONTAL) - row2Sizer = wx.BoxSizer(wx.HORIZONTAL) - row3Sizer = wx.BoxSizer(wx.HORIZONTAL) - row4Sizer = wx.BoxSizer(wx.HORIZONTAL) - - row1Sizer.Add(versionLabel, flag=wx.EXPAND) - row1Sizer.Add((1, 1), flag=wx.EXPAND, proportion=1) - row1Sizer.Add(authorLabel, flag=wx.EXPAND) - - row2Sizer.Add(companyLabel, flag=wx.EXPAND) - row2Sizer.Add((1, 1), flag=wx.EXPAND, proportion=1) - row2Sizer.Add(emailLabel, flag=wx.EXPAND) - - row3Sizer.Add(glVersionLabel, flag=wx.EXPAND) - row3Sizer.Add((1, 1), flag=wx.EXPAND, proportion=1) - row4Sizer.Add(glRendererLabel, flag=wx.EXPAND) - row4Sizer.Add((1, 1), flag=wx.EXPAND, proportion=1) - rowargs = {'border' : 3, - 'flag' : wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM} + textPanel .SetValue(infoStr + '\n\n' + swStr) + closeButton.SetLabel('Close') + + # Arrange the widgets + sizer = wx.BoxSizer(wx.VERTICAL) - mainSizer.Add(splashPanel) - mainSizer.Add(row1Sizer, **rowargs) - mainSizer.Add(row2Sizer, **rowargs) - mainSizer.Add(row3Sizer, **rowargs) - mainSizer.Add(row4Sizer, **rowargs) - mainSizer.Add(softwareField, flag=wx.EXPAND, proportion=1) - mainSizer.Add(closeButton, flag=wx.EXPAND) + sizer.Add(splashPanel) + sizer.Add(textPanel, flag=wx.EXPAND, proportion=1) + sizer.Add(closeButton, flag=wx.EXPAND) - self.SetSizer(mainSizer) + self.SetSizer(sizer) self.Layout() self.Fit() diff --git a/fsl/fsleyes/actions/__init__.py b/fsl/fsleyes/actions/__init__.py index 8d128acd88e40c084d5dcdf687ee13fe4b4c2e4e..f611bb0add0fb1ec9467fedd9345dc7d1e41f5ec 100644 --- a/fsl/fsleyes/actions/__init__.py +++ b/fsl/fsleyes/actions/__init__.py @@ -300,6 +300,10 @@ class ActionFactory(object): @otherCustomAction(arg1=8) def myAction3(self): # do things here + + + .. todo:: Merge/replace this class with the :class:`.memoize.Instanceify` + decorator. """ diff --git a/fsl/fsleyes/actions/about.py b/fsl/fsleyes/actions/about.py index 651e241ca32e421bbeb494a8fda9a2f14000a085..3ac176406023890f22522158ac3f01994a7afbae 100644 --- a/fsl/fsleyes/actions/about.py +++ b/fsl/fsleyes/actions/about.py @@ -9,8 +9,7 @@ displays an about dialog for *FSLeyes*. """ -import action -import fsl.fsleyes.about as aboutdlg +import action class AboutAction(action.Action): @@ -36,6 +35,8 @@ class AboutAction(action.Action): def __showDialog(self): """Creates and shows an :class:`.AboutDialog`. """ + import fsl.fsleyes.about as aboutdlg + dlg = aboutdlg.AboutDialog(self.__frame) dlg.Show() dlg.CentreOnParent() diff --git a/fsl/fsleyes/actions/diagnosticreport.py b/fsl/fsleyes/actions/diagnosticreport.py index 33f54a6608960a02970bbcd697566d164a7bb311..56b08e81efc5e59faa697e58f10f2aedaa0ad667 100644 --- a/fsl/fsleyes/actions/diagnosticreport.py +++ b/fsl/fsleyes/actions/diagnosticreport.py @@ -104,14 +104,15 @@ class DiagnosticReportAction(action.Action): ('name', ovl.name), ('source', ovl.dataSource)])) - report['Platform'] = platform.platform() - report['Python'] = '{} {}'.format(platform.python_version(), - platform.python_compiler()) - report['Version'] = version.__version__ - report['OpenGL'] = self.__openGLReport() - report['Settings'] = self.__settingsReport() - report['Layout'] = perspectives.serialisePerspective(self.__frame) - report['Overlays'] = overlays + report['Platform'] = platform.platform() + report['Python'] = '{} {}'.format(platform.python_version(), + platform.python_compiler()) + report['Version'] = version.__version__ + report['VCS Version'] = version.__vcs_version__ + report['OpenGL'] = self.__openGLReport() + report['Settings'] = self.__settingsReport() + report['Layout'] = perspectives.serialisePerspective(self.__frame) + report['Overlays'] = overlays report['Master display context'] = self.__displayContextReport( self.__overlayList, diff --git a/fsl/fsleyes/colourmaps.py b/fsl/fsleyes/colourmaps.py index e8b5bdd7900d6ae9e23bfe7ad0e089929647974d..d8c234d8a645f893f48251a94334099dc2424f1b 100644 --- a/fsl/fsleyes/colourmaps.py +++ b/fsl/fsleyes/colourmaps.py @@ -372,13 +372,13 @@ def registerLookupTable(lut, lutChoice = opts.getProp('lut') lutChoice.addChoice(lut, - alternate=[lut.name, key], + alternate=list(set((lut.name, key))), instance=opts) # and for any future label overlays fsldisplay.LabelOpts.lut.addChoice( lut, - alternate=[lut.name, key]) + alternate=list(set((lut.name, key)))) return lut diff --git a/fsl/fsleyes/controls/atlasoverlaypanel.py b/fsl/fsleyes/controls/atlasoverlaypanel.py index b2057b6b825cf12cbb1af574a1f1f030d83537a2..dd3859e4ba5064614c1fca159ae05b08d66b9308 100644 --- a/fsl/fsleyes/controls/atlasoverlaypanel.py +++ b/fsl/fsleyes/controls/atlasoverlaypanel.py @@ -244,12 +244,23 @@ class AtlasOverlayPanel(fslpanel.FSLEyesPanel): def addToRegionList(label, i): - regionList.Append(label.name) - widget = OverlayListWidget(regionList, - atlasDesc.atlasID, - self.__atlasPanel, - label.index) - regionList.SetItemWidget(i, widget) + # If the user kills this panel while + # the region list is being updated, + # suppress wx complaints. + # + # TODO You could make a chain of + # async.idle functions. instead of + # scheduling them all at once + try: + regionList.Append(label.name) + widget = OverlayListWidget(regionList, + atlasDesc.atlasID, + self.__atlasPanel, + label.index) + regionList.SetItemWidget(i, widget) + + except wx.PyDeadObjectError: + pass log.debug('Creating region list for {} ({})'.format( atlasDesc.atlasID, id(regionList))) @@ -273,31 +284,38 @@ class AtlasOverlayPanel(fslpanel.FSLEyesPanel): # displayed before). def changeAtlasList(): - filterStr = self.__regionFilter.GetValue().lower().strip() - regionList.ApplyFilter(filterStr, ignoreCase=True) + # See comment above about + # suppressing wx complaints + try: - self.__updateAtlasState(atlasIdx) + filterStr = self.__regionFilter.GetValue().lower().strip() + regionList.ApplyFilter(filterStr, ignoreCase=True) - status.update(strings.messages[self, 'regionsLoaded'].format( - atlasDesc.name)) - log.debug('Showing region list for {} ({})'.format( - atlasDesc.atlasID, id(regionList))) + self.__updateAtlasState(atlasIdx) + + status.update(strings.messages[self, 'regionsLoaded'].format( + atlasDesc.name)) + log.debug('Showing region list for {} ({})'.format( + atlasDesc.atlasID, id(regionList))) - old = self.__regionSizer.GetItem(1).GetWindow() + old = self.__regionSizer.GetItem(1).GetWindow() - if old is not None: - old.Show(False) + if old is not None: + old.Show(False) - regionList.Show(True) - self.__regionSizer.Remove(1) + regionList.Show(True) + self.__regionSizer.Remove(1) - self.__regionSizer.Insert(1, - regionList, - flag=wx.EXPAND, - proportion=1) - self.__regionSizer.Layout() + self.__regionSizer.Insert(1, + regionList, + flag=wx.EXPAND, + proportion=1) + self.__regionSizer.Layout() - self.Enable() + self.Enable() + + except wx.PyDeadObjectError: + pass async.idle(changeAtlasList) diff --git a/fsl/fsleyes/controls/atlaspanel.py b/fsl/fsleyes/controls/atlaspanel.py index c397bfef25d88022c1bd4e68973f1c304a54fa77..656bb740a3a61857f8729f3bb539fb432e05539f 100644 --- a/fsl/fsleyes/controls/atlaspanel.py +++ b/fsl/fsleyes/controls/atlaspanel.py @@ -192,6 +192,9 @@ class AtlasPanel(fslpanel.FSLEyesPanel): self.__enabledOverlays = None self.__infoPanel .destroy() self.__overlayPanel .destroy() + + self._overlayList.removeListener('overlays', self._name) + fslpanel.FSLEyesPanel.destroy(self) diff --git a/fsl/fsleyes/controls/clusterpanel.py b/fsl/fsleyes/controls/clusterpanel.py index 9e2a01f7785d343f2a361aff4255f6a3877acd20..c24ae0cc2b2979a3c1c52a97f18dbc03ec29803f 100644 --- a/fsl/fsleyes/controls/clusterpanel.py +++ b/fsl/fsleyes/controls/clusterpanel.py @@ -439,15 +439,26 @@ class ClusterPanel(fslpanel.FSLEyesPanel): # WidgetGrid panels for overlays # that have been removed from the # list. - for overlay in self.__featImages.keys(): + for overlay in list(self.__featImages.keys()): if overlay not in self._overlayList: - featImage = self.__featImages .pop(overlay) - grids = self.__clusterGrids.pop(featImage) - - for grid in grids: - self.__mainSizer.Detach(grid) - grid.Destroy() + featImage = self.__featImages.pop(overlay) + + # Has the feat image associated with + # this overlay also been removed? + if featImage is overlay or \ + featImage not in self._overlayList: + + # The grid widgets for the feat image + # associated with this overlay may + # have already been destroyed. + try: grids = self.__clusterGrids.pop(featImage) + except KeyError: grids = [] + + for grid in grids: + if grid is not None: + self.__mainSizer.Detach(grid) + grid.Destroy() self.__selectedOverlayChanged() self.__enableOverlayButtons() @@ -493,6 +504,12 @@ class ClusterPanel(fslpanel.FSLEyesPanel): return overlay = self._displayCtx.getSelectedOverlay() + + # Overlay is in-memory + if overlay.dataSource is None: + self.__disable(strings.messages[self, 'notFEAT']) + return + featDir = featresults.getAnalysisDir(overlay.dataSource) # No FEAT analysis, or not an Image, diff --git a/fsl/fsleyes/controls/locationpanel.py b/fsl/fsleyes/controls/locationpanel.py index c6ae6accac2ec859938e7c904411db7bcacb05dc..dc03aa7a8ef33f500d714edd3f22285c617493da 100644 --- a/fsl/fsleyes/controls/locationpanel.py +++ b/fsl/fsleyes/controls/locationpanel.py @@ -10,6 +10,7 @@ panel which shows information about the current display location. import logging +import itertools as it import wx import wx.html as wxhtml @@ -20,6 +21,7 @@ import props import pwidgets.floatspin as floatspin +import fsl.utils.typedict as td import fsl.utils.transform as transform import fsl.data.image as fslimage import fsl.data.constants as constants @@ -70,6 +72,18 @@ class LocationPanel(fslpanel.FSLEyesPanel): display the current :attr:`.DisplayContext.location` as-is (i.e. in the display coordinate system); furthermore, the voxel location controls will be disabled. + + + **Location updates** + + + The :data:`DISPLAYOPTS_BOUNDS` and :data:`DISPLAYOPTS_INFO` dictionaries + contain lists of property names that the :class:`.LocationPanel` listens + on for changes, so it knows when the location widgets, and information + about the currenty location, need to be refreshed. For example, when the + :attr`.Nifti1Opts.volume` property of a :class:`.Nifti1` overlay changes, + the volume index, and potentially the overlay information, needs to be + updated. """ @@ -98,24 +112,14 @@ class LocationPanel(fslpanel.FSLEyesPanel): fslpanel.FSLEyesPanel.__init__(self, parent, overlayList, displayCtx) - # The world and voxel locations dispalyed by the LocationPanel - # are only really relevant to volumetric (i.e. NIFTI) overlay - # types. However, other overlay types (e.g. Model instances) - # may have an associated 'reference' image, from which details - # of the coordinate system may be obtained. - # - # When the current overlay is either an Image instance, or has - # an associated reference image, this attributes is used to - # store a reference to the image. - self.__refImage = None - - # When the currently selected overlay is 4D, - # this attribute will refer to the - # corresponding DisplayOpts instance, which - # has a volume property that controls the - # volume - see e.g. the Nifti1Opts class. This - # attribute is set in _selectedOverlayChanged. - self.__volumeTarget = None + # Whenever the selected overlay changes, + # a reference to it and its DisplayOpts + # instance is stored, as property listeners + # are registered on it (and need to be + # de-registered later on). + self.__registeredOverlay = None + self.__registeredDisplay = None + self.__registeredOpts = None self.__column1 = wx.Panel(self) self.__column2 = wx.Panel(self) @@ -221,6 +225,8 @@ class LocationPanel(fslpanel.FSLEyesPanel): self._displayCtx .removeListener('selectedOverlay', self._name) self._displayCtx .removeListener('location', self._name) + self.__deregisterOverlay() + fslpanel.FSLEyesPanel.destroy(self) @@ -272,81 +278,155 @@ class LocationPanel(fslpanel.FSLEyesPanel): def __selectedOverlayChanged(self, *a): """Called when the :attr:`.DisplayContext.selectedOverlay` or - :class:`.OverlayList` is changed. Refreshes the ``LocationPanel`` - interface accordingly. + :class:`.OverlayList` is changed. Registered with the new overlay, + and refreshes the ``LocationPanel`` interface accordingly. """ - self.__updateReferenceImage() - self.__updateWidgets() + self.__deregisterOverlay() if len(self._overlayList) == 0: + self.__updateWidgets() self.__updateLocationInfo() - return + + else: + self.__registerOverlay() + self.__updateWidgets() + self.__displayLocationChanged() + + + def __registerOverlay(self): + """Registers property listeners with the :class:`.Display` and + :class:`.DisplayOpts` instances associated with the currently + selected overlay. + """ - # Register a listener on the DisplayOpts - # instance of the currently selected overlay, - # so we can update the location if the - # overlay bounds change. overlay = self._displayCtx.getSelectedOverlay() - for ovl in self._overlayList: - display = self._displayCtx.getDisplay(ovl) - opts = display.getDisplayOpts() - - if ovl is overlay: - opts.addListener('bounds', - self._name, - self.__overlayBoundsChanged, - overwrite=True) - else: - opts.removeListener('bounds', self._name) - # Refresh the world/voxel location properties - self.__displayLocationChanged() + if overlay is None: + return + display = self._displayCtx.getDisplay(overlay) + opts = display.getDisplayOpts() + + self.__registeredOverlay = overlay + self.__registeredDisplay = display + self.__registeredOpts = opts + + # The properties that we need to + # listen for are specified in the + # DISPLAYOPTS_BOUNDS and + # DISPLAYOPTS_INFO dictionaries. + boundPropNames = DISPLAYOPTS_BOUNDS.get(opts, [], allhits=True) + infoPropNames = DISPLAYOPTS_INFO .get(opts, [], allhits=True) + boundPropNames = it.chain(*boundPropNames) + infoPropNames = it.chain(*infoPropNames) + + # DisplayOpts instances get re-created + # when an overlay type is changed, so + # we need to re-register when this happens. + display.addListener('overlayType', + self._name, + self.__selectedOverlayChanged) + + for n in boundPropNames: + opts.addListener(n, self._name, self.__boundsOptsChanged) + for n in infoPropNames: + opts.addListener(n, self._name, self.__infoOptsChanged) + + # Enable the volume widget if the + # overlay is a 4D image, and bind + # the widget to the volume property + # of the associated Nifti1Opts + # instance + is4D = isinstance(overlay, fslimage.Nifti1) and \ + len(overlay.shape) >= 4 and \ + overlay.shape[3] > 1 + + if is4D: + props.bindWidget( + self.__volume, opts, 'volume', floatspin.EVT_FLOATSPIN) - def __overlayBoundsChanged(self, *a): - """Called when the :attr:`.DisplayOpts.bounds` property associated - with the currently selected overlay changes. Updates the - ``LocationPanel`` interface accordingly. - """ + self.__volume.SetRange(0, overlay.shape[3] - 1) + self.__volume.SetValue(opts.volume) - self.__updateReferenceImage() - self.__updateWidgets() - self.__displayLocationChanged() - + self.__volume .Enable() + self.__volumeLabel.Enable() + else: + self.__volume.SetRange(0, 0) + self.__volume.SetValue(0) + self.__volume.Disable() - def __updateReferenceImage(self): - """Called by the :meth:`__selectedOverlayChanged` and - :meth:`__overlayBoundsChanged` methods. Looks at the currently - selected overlay, and figures out if there is a reference image - that can be used to transform between display, world, and voxel - coordinate systems. + + def __deregisterOverlay(self): + """De-registers property listeners with the :class:`.Display` and + :class:`.DisplayOpts` instances associated with the previously + registered overlay. """ - - refImage = None - # Look at the currently selected overlay, and - # see if there is an associated NIFTI image - # that can be used as a reference image - if len(self._overlayList) > 0: + opts = self.__registeredOpts + display = self.__registeredDisplay + overlay = self.__registeredOverlay + + if overlay is None: + return - overlay = self._displayCtx.getSelectedOverlay() - refImage = self._displayCtx.getReferenceImage(overlay) + self.__registeredOpts = None + self.__registeredDisplay = None + self.__registeredOverlay = None - log.debug('Reference image for overlay {}: {}'.format( - overlay, refImage)) + boundPropNames = DISPLAYOPTS_BOUNDS.get(opts, [], allhits=True) + infoPropNames = DISPLAYOPTS_INFO .get(opts, [], allhits=True) + boundPropNames = it.chain(*boundPropNames) + infoPropNames = it.chain(*infoPropNames) + + if display is not None: + display.removeListener('overlayType', self._name) + + for p in boundPropNames: opts.removeListener(p, self._name) + for p in infoPropNames: opts.removeListener(p, self._name) + + is4D = isinstance(overlay, fslimage.Nifti1) and \ + len(overlay.shape) >= 4 and \ + overlay.shape[3] > 1 + + if is4D: + props.unbindWidget(self.__volume, + opts, + 'volume', + floatspin.EVT_FLOATSPIN) + + + def __boundsOptsChanged(self, *a): + """Called when a :class:`.DisplayOpts` property associated + with the currently selected overlay, and listed in the + :data:`DISPLAYOPTS_BOUNDS` dictionary, changes. Refreshes the + ``LocationPanel`` interface accordingly. + """ + self.__updateWidgets() + self.__displayLocationChanged() - self.__refImage = refImage + + def __infoOptsChanged(self, *a): + """Called when a :class:`.DisplayOpts` property associated + with the currently selected overlay, and listed in the + :data:`DISPLAYOPTS_INFO` dictionary, changes. Refreshes the + ``LocationPanel`` interface accordingly. + """ + self.__displayLocationChanged() def __updateWidgets(self): """Called by the :meth:`__selectedOverlayChanged` and - :meth:`__overlayBoundsChanged` methods. Enables/disables the + :meth:`__displayOptsChanged` methods. Enables/disables the voxel/world location and volume controls depending on the currently selected overlay (or reference image). """ - refImage = self.__refImage + overlay = self.__registeredOverlay + opts = self.__registeredOpts + + if overlay is not None: refImage = opts.getReferenceImage() + else: refImage = None haveRef = refImage is not None @@ -406,42 +486,6 @@ class LocationPanel(fslpanel.FSLEyesPanel): self.enableNotification('worldLocation') self.enableNotification('voxelLocation') - ############### - # Volume widget - ############### - - # Unbind any listeners between the previous - # reference image and the volume widget - if self.__volumeTarget is not None: - props.unbindWidget(self.__volume, - self.__volumeTarget, - 'volume', - floatspin.EVT_FLOATSPIN) - - self.__volumeTarget = None - self.__volume.SetValue(0) - self.__volume.SetRange(0, 0) - - # Enable/disable the volume widget if the - # overlay is a 4D image, and bind/unbind - # the widget to the volume property of - # the associated Nifti1Opts instance - if haveRef and refImage.is4DImage(): - opts = self._displayCtx.getOpts(refImage) - self.__volumeTarget = opts - - props.bindWidget( - self.__volume, opts, 'volume', floatspin.EVT_FLOATSPIN) - - self.__volume.SetRange(0, refImage.shape[3] - 1) - self.__volume.SetValue(opts.volume) - - self.__volume .Enable() - self.__volumeLabel.Enable() - else: - self.__volume .Disable() - self.__volumeLabel.Disable() - def __displayLocationChanged(self, *a): """Called when the :attr:`.DisplayContext.location` changes. @@ -461,7 +505,8 @@ class LocationPanel(fslpanel.FSLEyesPanel): between the three location properties. """ - if len(self._overlayList) == 0: return + if len(self._overlayList) == 0: return + if self.__registeredOverlay is None: return self.__prePropagate() self.__propagate('display', 'voxel') @@ -476,7 +521,8 @@ class LocationPanel(fslpanel.FSLEyesPanel): :attr:`.DisplayContext.location` properties. """ - if len(self._overlayList) == 0: return + if len(self._overlayList) == 0: return + if self.__registeredOverlay is None: return self.__prePropagate() self.__propagate('world', 'voxel') @@ -491,7 +537,8 @@ class LocationPanel(fslpanel.FSLEyesPanel): :attr:`.DisplayContext.location` properties. """ - if len(self._overlayList) == 0: return + if len(self._overlayList) == 0: return + if self.__registeredOverlay is None: return self.__prePropagate() self.__propagate('voxel', 'world') @@ -534,8 +581,10 @@ class LocationPanel(fslpanel.FSLEyesPanel): elif source == 'voxel': coords = self.voxelLocation.xyz elif source == 'world': coords = self.worldLocation.xyz - if self.__refImage is not None: - opts = self._displayCtx.getOpts(self.__refImage) + refImage = self.__registeredOpts.getReferenceImage() + + if refImage is not None: + opts = self._displayCtx.getOpts(refImage) xformed = opts.transformCoords([coords], source, target, @@ -574,7 +623,8 @@ class LocationPanel(fslpanel.FSLEyesPanel): in the :class:`.OverlayList`. """ - if len(self._overlayList) == 0: + if len(self._overlayList) == 0 or \ + self.__registeredOverlay is None: self.__info.SetPage('') return @@ -616,3 +666,25 @@ class LocationPanel(fslpanel.FSLEyesPanel): self.__info.SetPage('<br>'.join(lines)) self.__info.Refresh() + + +DISPLAYOPTS_BOUNDS = td.TypeDict({ + 'DisplayOpts' : ['bounds'], + 'ModelOpts' : ['refImage'], +}) +"""Different :class:`.DisplayOpts` types have different properties which +affect the current overlay bounds. Therefore, when the current overlay +changes (as dictated by the :attr:`.DisplayContext.selectedOverlay` +property),the :meth:`__registerOverlay` method registers property +listeners on the properties specified in this dictionary. +""" + + +DISPLAYOPTS_INFO = td.TypeDict({ + 'Nifti1Opts' : ['volume'], +}) +"""Different :class:`.DisplayOpts` types have different properties which +affect the current overlay location information. Therefore, when the current +overlay changes the :meth:`__registerOverlay` method registers property +listeners on the properties specified in this dictionary. +""" diff --git a/fsl/fsleyes/controls/lookuptablepanel.py b/fsl/fsleyes/controls/lookuptablepanel.py index 12d4b57e7306fb4f6e444b8272d7ccfd9fb01d7a..c7a5ad2e2dfaad174251387f019400cb46279887 100644 --- a/fsl/fsleyes/controls/lookuptablepanel.py +++ b/fsl/fsleyes/controls/lookuptablepanel.py @@ -79,7 +79,6 @@ class LookupTablePanel(fslpanel.FSLEyesPanel): :arg overlayList: The :class:`.OverlayList` instance. :arg displayCtx: The :class:`.DisplayContext` instance. """ - fslpanel.FSLEyesPanel.__init__(self, parent, overlayList, displayCtx) @@ -137,6 +136,9 @@ class LookupTablePanel(fslpanel.FSLEyesPanel): self.__selectedOpts = None self.__selectedLut = None + # See the __createLabelList method + self.__labelListCreateKey = 0 + overlayList.addListener('overlays', self._name, self.__selectedOverlayChanged) @@ -219,24 +221,58 @@ class LookupTablePanel(fslpanel.FSLEyesPanel): ``LookupTable``. """ + # The label list is created asynchronously on + # the wx.Idle loop, because it can take some + # time for big lookup tables. In the event + # that the list needs to be re-created (e.g. + # the current lookup table is changed), this + # attribute is used so that scheduled creation + # routines (the addLabel function defined + # below) can tell whether they should cancel. + myCreateKey = (self.__labelListCreateKey + 1) % 65536 + self.__labelListCreateKey = myCreateKey + log .debug( 'Creating lookup table label list') status.update('Creating lookup table label list...', timeout=None) + self.Disable() self.__labelList.Clear() lut = self.__selectedLut nlabels = len(lut.labels) - def addLabel(i, label): - self.__labelList.Append(label.displayName()) - widget = LabelWidget(self, lut, label.value()) - self.__labelList.SetItemWidget(i, widget) + def addLabel(labelIdx): + + # If the user closes this panel while the + # label list is being created, wx will + # complain when we try to append things + # to a widget that has been destroyed. + try: + + # A new request to re-create the list has + # been made - cancel this creation chain. + if self.__labelListCreateKey != myCreateKey: + return + + label = lut.labels[labelIdx] + widget = LabelWidget(self, lut, label.value()) + + self.__labelList.Append(label.displayName()) + self.__labelList.SetItemWidget(labelIdx, widget) - if i == nlabels - 1: - status.update('Lookup table label list created.') + if labelIdx == nlabels - 1: + status.update('Lookup table label list created.') + self.Enable() + else: + async.idle(addLabel, labelIdx + 1) + + except wx.PyDeadObjectError: + pass - for i, label in enumerate(lut.labels): - async.idle(addLabel, i, label) + # If this is a new lut, it + # won't have any labels + if len(lut.labels) == 0: self.Enable() + else: async.idle(addLabel, 0) def __setLut(self, lut): @@ -248,6 +284,9 @@ class LookupTablePanel(fslpanel.FSLEyesPanel): set to the new ``LookupTable``. """ + if self.__selectedLut == lut: + return + log.debug('Selecting lut: {}'.format(lut)) if self.__selectedLut is not None: @@ -319,7 +358,7 @@ class LookupTablePanel(fslpanel.FSLEyesPanel): log.debug('Creating and registering new ' 'LookupTable: {}'.format(name)) - lut = fslcmaps.LookupTable(name) + lut = fslcmaps.LookupTable(key=name, name=name) fslcmaps.registerLookupTable(lut, self._overlayList, self._displayCtx) self.__updateLutChoices() @@ -346,7 +385,7 @@ class LookupTablePanel(fslpanel.FSLEyesPanel): log.debug('Creating and registering new ' 'LookupTable {} (copied from {})'.format(newName, oldName)) - lut = fslcmaps.LookupTable(newName) + lut = fslcmaps.LookupTable(key=newName, name=newName) for label in self.__selectedLut.labels: lut.set(label.value(), @@ -609,10 +648,20 @@ class LabelWidget(wx.Panel): # Disable the LutPanel listener, otherwise # it will recreate the label list (see - # LookupTablePanel._createLabelList) - self.__lut.disableListener('labels', self.__lutPanel._name) + # LookupTablePanel._createLabelList). + # + # We check to see if a listener exists, + # because the panel will only register + # a listener on label overlays. + toggle = self.__lut.hasListener('labels', self.__lutPanel._name) + + if toggle: + self.__lut.disableListener('labels', self.__lutPanel._name) + self.__lut.set(self.__value, enabled=self.__enableBox.GetValue()) - self.__lut.enableListener('labels', self.__lutPanel._name) + + if toggle: + self.__lut.enableListener('labels', self.__lutPanel._name) def __onColour(self, ev): @@ -623,10 +672,16 @@ class LabelWidget(wx.Panel): newColour = self.__colourButton.GetColour() newColour = [c / 255.0 for c in newColour] - # See comment in __onEnable - self.__lut.disableListener('labels', self.__lutPanel._name) + # See comment in __onEnable + toggle = self.__lut.hasListener('labels', self.__lutPanel._name) + + if toggle: + self.__lut.disableListener('labels', self.__lutPanel._name) + self.__lut.set(self.__value, colour=newColour) - self.__lut.enableListener('labels', self.__lutPanel._name) + + if toggle: + self.__lut.enableListener('labels', self.__lutPanel._name) class NewLutDialog(wx.Dialog): @@ -698,7 +753,7 @@ class NewLutDialog(wx.Dialog): """Called when the user confirms the dialog. Saves the name that the user entered, and closes the dialog. """ - self.__enteredName = self._name.GetValue() + self.__enteredName = self.__name.GetValue() self.EndModal(wx.ID_OK) diff --git a/fsl/fsleyes/controls/overlaydisplaypanel.py b/fsl/fsleyes/controls/overlaydisplaypanel.py index d2367f601628cd5833fa9b34daf55af844f0bd05..d2ba8a793a5dc07c5a85cfbfec4cfe87d9c93452 100644 --- a/fsl/fsleyes/controls/overlaydisplaypanel.py +++ b/fsl/fsleyes/controls/overlaydisplaypanel.py @@ -205,8 +205,9 @@ class OverlayDisplayPanel(fslpanel.FSLEyesPanel): to :class:`.Display` or :class:`.DisplayOpts` properties. """ - - self.__widgets.ClearGroup(groupName) + + self.__widgets.ClearGroup( groupName) + self.__widgets.RenameGroup(groupName, strings.labels[self, target]) dispProps = _DISPLAY_PROPS.get(target, [], allhits=True) dispProps = functools.reduce(lambda a, b: a + b, dispProps) diff --git a/fsl/fsleyes/controls/overlaylistpanel.py b/fsl/fsleyes/controls/overlaylistpanel.py index 30b909eb6cf3c61bda1b52133f4720c157a09ab0..82d364e88537aae0dcb1dc30cd83f9dc2677240d 100644 --- a/fsl/fsleyes/controls/overlaylistpanel.py +++ b/fsl/fsleyes/controls/overlaylistpanel.py @@ -278,7 +278,7 @@ class ListItemWidget(wx.Panel): """ - disabledFG = '#CCCCCC' + disabledFG = '#888888' """This colour is used as the foreground (text) colour for overlays where their :attr:`.Display.enabled` property is ``False``. """ diff --git a/fsl/fsleyes/controls/plotcontrolpanel.py b/fsl/fsleyes/controls/plotcontrolpanel.py index acf562087c1dc9191d60a6f9828e61e5ea152001..ae5121829eae209ef38d473464451dce2364be69 100644 --- a/fsl/fsleyes/controls/plotcontrolpanel.py +++ b/fsl/fsleyes/controls/plotcontrolpanel.py @@ -349,12 +349,12 @@ class PlotControlPanel(fslpanel.FSLEyesPanel): if self.__widgets.HasGroup('currentDSSettings'): self.__widgets.RenameGroup( 'currentDSSettings', - strings.labels[self, 'currentDSettings'].format(display.name)) + strings.labels[self, 'currentDSSettings'].format(display.name)) if self.__widgets.HasGroup('customDSSettings'): self.__widgets.RenameGroup( 'customDSSettings', - strings.labels[self, 'customDSettings'].format(display.name)) + strings.labels[self, 'customDSSettings'].format(display.name)) def __selectedOverlayChanged(self, *a): diff --git a/fsl/fsleyes/displaycontext/displaycontext.py b/fsl/fsleyes/displaycontext/displaycontext.py index abc3ccb45e79567079e0a786b34e28a10ca0da31..cf47b27405032b0a1bdb266d8f49eaa286977401 100644 --- a/fsl/fsleyes/displaycontext/displaycontext.py +++ b/fsl/fsleyes/displaycontext/displaycontext.py @@ -276,7 +276,9 @@ class DisplayContext(props.SyncableHasProperties): self.detachFromParent() - self.__overlayList.removeListener('overlays', self.__name) + self.__overlayList.removeListener('overlays', self.__name) + self .removeListener('syncOverlayDisplay', self.__name) + self .removeListener('displaySpace', self.__name) for overlay, display in self.__displays.items(): display.destroy() diff --git a/fsl/fsleyes/displaycontext/labelopts.py b/fsl/fsleyes/displaycontext/labelopts.py index 43482104f0ea8eaf5d6ecec520c9e54705f94732..126341754dbf1aac327d177b8d32193cb466fbae 100644 --- a/fsl/fsleyes/displaycontext/labelopts.py +++ b/fsl/fsleyes/displaycontext/labelopts.py @@ -55,7 +55,7 @@ class LabelOpts(volumeopts.Nifti1Opts): volumeopts.Nifti1Opts.__init__(self, overlay, *args, **kwargs) luts = fslcm.getLookupTables() - alts = [[l.name, l.key] for l in luts] + alts = [list(set((l.name, l.key))) for l in luts] lutChoice = self.getProp('lut') lutChoice.setChoices(luts, alternates=alts) diff --git a/fsl/fsleyes/frame.py b/fsl/fsleyes/frame.py index 277e73e466a1a66b2471e388a1a9c1dca87d25e2..cc39af989e0ab96db11c8f28eef795f2ae90d790 100644 --- a/fsl/fsleyes/frame.py +++ b/fsl/fsleyes/frame.py @@ -124,10 +124,44 @@ class FSLEyesFrame(wx.Frame): self.__displayCtx = displayCtx self.__mainPanel = wx.Panel(self) self.__statusBar = wx.StaticText(self) + + # Even though the FSLEyesFrame does not allow + # panels to be floated, I am specifying the + # docking guide style for complicated reasons... + # + # Each ViewPanel contained in this FSLEyesFrame + # has an AuiManager of its own; these child + # AuiManagers do support floating of their + # child panels. However, it seems that when + # a floating child panel of a ViewPanel is + # docked, this top-level AuiManager is called + # to draw the docking guides. This is because + # the wx.lib.agw.aui.framemanager.GetManager + # function uses the wx event handling system + # to figure out which AuiManager should be used + # to maange the docking (which is a ridiculous + # way to do this, in my opinion). + # + # Anyway, this means that the docking guides + # will be drawn according to the style set up + # in this AuiManager, instead of the ViewPanel + # AuiManager, which is the one that is actually + # managing the panel being docked. + # + # This wouldn't be a problem, if not for the fact + # that, when running over SSH/X11, the default + # docking guides seem to get sized incorrectly, + # and look terrible (probably related to the + # AuiDockingGuide monkey-patch at the bottom of + # viewpanel.py). + # + # This problem does not occur with the aero/ + # whidbey guides. self.__auiManager = aui.AuiManager( self.__mainPanel, - agwFlags=(aui.AUI_MGR_RECTANGLE_HINT | + agwFlags=(aui.AUI_MGR_RECTANGLE_HINT | aui.AUI_MGR_NO_VENETIAN_BLINDS_FADE | + aui.AUI_MGR_AERO_DOCKING_GUIDES | aui.AUI_MGR_LIVE_RESIZE)) self.__sizer = wx.BoxSizer(wx.VERTICAL) @@ -488,14 +522,19 @@ class FSLEyesFrame(wx.Frame): dctx .destroy() # If the removed panel was the centre - # pane, or if there is only one panel - # left, move another panel to the centre + # pane, move another panel to the centre numPanels = len(self.__viewPanels) wasCentre = paneInfo.dock_direction_get() == aui.AUI_DOCK_CENTRE if numPanels >= 1 and wasCentre: paneInfo = self.__auiManager.GetPane(self.__viewPanels[0]) - paneInfo.Centre().Dockable(False).CaptionVisible(numPanels > 1) + paneInfo.Centre() + + # If there is only one panel + # left, hide its title bar + if numPanels == 1: + paneInfo = self.__auiManager.GetPane(self.__viewPanels[0]) + paneInfo.Dockable(False).CaptionVisible(False) # If there's only one panel left, # and it is a canvas panel, sync diff --git a/fsl/fsleyes/gl/annotations.py b/fsl/fsleyes/gl/annotations.py index 201e2d75745524071fd790d7b92d68f4de47fa98..e27bb06325da71705e07255ceceff08efd0c3825 100644 --- a/fsl/fsleyes/gl/annotations.py +++ b/fsl/fsleyes/gl/annotations.py @@ -31,10 +31,11 @@ import logging import numpy as np import OpenGL.GL as gl -import fsl.fsleyes.gl.globject as globject -import fsl.fsleyes.gl.routines as glroutines -import fsl.fsleyes.gl.textures as textures -import fsl.utils.transform as transform +import fsl.fsleyes.gl.globject as globject +import fsl.fsleyes.gl.routines as glroutines +import fsl.fsleyes.gl.resources as glresources +import fsl.fsleyes.gl.textures as textures +import fsl.utils.transform as transform log = logging.getLogger(__name__) @@ -95,21 +96,27 @@ class Annotations(object): def line(self, *args, **kwargs): """Queues a line for drawing - see the :class:`Line` class. """ hold = kwargs.pop('hold', False) - return self.obj(Line(*args, **kwargs), hold) + obj = Line(self.__xax, self.__yax, *args, **kwargs) + + return self.obj(obj, hold) def rect(self, *args, **kwargs): """Queues a rectangle for drawing - see the :class:`Rectangle` class. """ hold = kwargs.pop('hold', False) - return self.obj(Rect(*args, **kwargs), hold) + obj = Rect(self.__xax, self.__yax, *args, **kwargs) + + return self.obj(obj, hold) def grid(self, *args, **kwargs): """Queues a voxel grid for drawing - see the :class:`VoxelGrid` class. """ hold = kwargs.pop('hold', False) - return self.obj(VoxelGrid(*args, **kwargs), hold) + obj = VoxelGrid(self.__xax, self.__yax, *args, **kwargs) + + return self.obj(obj, hold) def selection(self, *args, **kwargs): @@ -117,7 +124,9 @@ class Annotations(object): class. """ hold = kwargs.pop('hold', False) - return self.obj(VoxelSelection(*args, **kwargs), hold) + obj = VoxelSelection(self.__xax, self.__yax, *args, **kwargs) + + return self.obj(obj, hold) def obj(self, obj, hold=False): @@ -236,9 +245,13 @@ class AnnotationObject(globject.GLSimpleObject): :meth:`globject.GLObject.draw` method. """ - def __init__(self, xform=None, colour=None, width=None): + def __init__(self, xax, yax, xform=None, colour=None, width=None): """Create an ``AnnotationObject``. + :arg xax: Initial display X axis + + :arg yax: Initial display Y axis + :arg xform: Transformation matrix which will be applied to all vertex coordinates. @@ -246,7 +259,7 @@ class AnnotationObject(globject.GLSimpleObject): :arg width: Line width to use for the annotation. """ - globject.GLSimpleObject.__init__(self) + globject.GLSimpleObject.__init__(self, xax, yax) self.colour = colour self.width = width @@ -269,13 +282,17 @@ class Line(AnnotationObject): 2D line. """ - def __init__(self, xy1, xy2, *args, **kwargs): + def __init__(self, xax, yax, xy1, xy2, *args, **kwargs): """Create a ``Line`` annotation. The ``xy1`` and ``xy2`` coordinate tuples should be in relation to the axes which map to the horizontal/vertical screen axes on the target canvas. + :arg xax: Initial display X axis + + :arg yax: Initial display Y axis + :arg xy1: Tuple containing the (x, y) coordinates of one endpoint. :arg xy2: Tuple containing the (x, y) coordinates of the second @@ -284,7 +301,7 @@ class Line(AnnotationObject): All other arguments are passed through to :meth:`AnnotationObject.__init__`. """ - AnnotationObject.__init__(self, *args, **kwargs) + AnnotationObject.__init__(self, xax, yax, *args, **kwargs) self.xy1 = xy1 self.xy2 = xy2 @@ -310,18 +327,22 @@ class Rect(AnnotationObject): 2D rectangle. """ - def __init__(self, xy, w, h, *args, **kwargs): + def __init__(self, xax, yax, xy, w, h, *args, **kwargs): """Create a :class:`Rect` annotation. - :arg xy: Tuple specifying bottom left of the rectangle, in the display - coordinate system. - :arg w: Rectangle width. - :arg h: Rectangle height. + :arg xax: Initial display X axis + + :arg yax: Initial display Y axis + + :arg xy: Tuple specifying bottom left of the rectangle, in the display + coordinate system. + :arg w: Rectangle width. + :arg h: Rectangle height. All other arguments are passed through to :meth:`AnnotationObject.__init__`. """ - AnnotationObject.__init__(self, *args, **kwargs) + AnnotationObject.__init__(self, xax, yax, *args, **kwargs) self.xy = xy self.w = w self.h = h @@ -371,6 +392,8 @@ class VoxelGrid(AnnotationObject): def __init__(self, + xax, + yax, selectMask, displayToVoxMat, voxToDisplayMat, @@ -379,6 +402,10 @@ class VoxelGrid(AnnotationObject): **kwargs): """Create a ``VoxelGrid`` annotation. + :arg xax: Initial display X axis + + :arg yax: Initial display Y axis + :arg selectMask: A 3D numpy array, the same shape as the image being annotated (or a sub-space of the image - see the ``offsets`` argument), which is @@ -403,7 +430,7 @@ class VoxelGrid(AnnotationObject): """ kwargs['xform'] = voxToDisplayMat - AnnotationObject.__init__(self, *args, **kwargs) + AnnotationObject.__init__(self, xax, yax, *args, **kwargs) if offsets is None: offsets = [0, 0, 0] @@ -453,6 +480,8 @@ class VoxelSelection(AnnotationObject): def __init__(self, + xax, + yax, selection, displayToVoxMat, voxToDisplayMat, @@ -461,6 +490,10 @@ class VoxelSelection(AnnotationObject): **kwargs): """Create a ``VoxelSelection`` annotation. + :arg xax: Initial display X axis + + :arg yax: Initial display Y axis + :arg selection: A :class:`.Selection` instance which defines the voxels to be highlighted. @@ -484,7 +517,7 @@ class VoxelSelection(AnnotationObject): :meth:`AnnotationObject.__init__` method. """ - AnnotationObject.__init__(self, *args, **kwargs) + AnnotationObject.__init__(self, xax, yax, *args, **kwargs) if offsets is None: offsets = [0, 0, 0] @@ -493,9 +526,13 @@ class VoxelSelection(AnnotationObject): self.displayToVoxMat = displayToVoxMat self.voxToDisplayMat = voxToDisplayMat self.offsets = offsets + + texName = '{}_{}'.format(type(self).__name__, id(selection)) - self.texture = textures.SelectionTexture( - '{}_{}'.format(type(self).__name__, id(selection)), + self.texture = glresources.get( + texName, + textures.SelectionTexture, + texName, selection) @@ -503,7 +540,7 @@ class VoxelSelection(AnnotationObject): """Must be called when this ``VoxelSelection`` is no longer needed. Destroys the :class:`.SelectionTexture`. """ - self.texture.destroy() + glresources.delete(self.texture.getTextureName()) self.texture = None diff --git a/fsl/fsleyes/gl/gl14/gllinevector_funcs.py b/fsl/fsleyes/gl/gl14/gllinevector_funcs.py index a823ff9d1ec732ad5f5d31d94bd0375f6d9f7a4e..27ce4e28b2df72353f0f9c0fdfc80b2a72155458 100644 --- a/fsl/fsleyes/gl/gl14/gllinevector_funcs.py +++ b/fsl/fsleyes/gl/gl14/gllinevector_funcs.py @@ -126,8 +126,8 @@ def updateShaderState(self): glvector_funcs.updateFragmentShaderState(self) - shape = np.array(list(image.shape[:3]) + [0], dtype=np.float32) - invShape = 1.0 / shape + shape = list(image.shape[:3]) + invShape = [1.0 / s for s in shape] + [0] offset = [0.5, 0.5, 0.5, 0.0] self.shader.load() diff --git a/fsl/fsleyes/gl/gl14/glvolume_funcs.py b/fsl/fsleyes/gl/gl14/glvolume_funcs.py index 8fe4900c9eaacdfaa52a846189815915b18c24d2..c86d8992b633b554a43e784ffe4758ad5a11595b 100644 --- a/fsl/fsleyes/gl/gl14/glvolume_funcs.py +++ b/fsl/fsleyes/gl/gl14/glvolume_funcs.py @@ -98,15 +98,16 @@ def updateShaderState(self): clipping = [clipLo, clipHi, invClip, imageIsClip] negCmap = [useNegCmap, texZero, 0, 0] - self.shader.setVertParam('imageShape', shape) - self.shader.setFragParam('imageShape', shape) - self.shader.setFragParam('voxValXform', voxValXform) - self.shader.setFragParam('clipping', clipping) - self.shader.setFragParam('negCmap', negCmap) + changed = False + changed |= self.shader.setVertParam('imageShape', shape) + changed |= self.shader.setFragParam('imageShape', shape) + changed |= self.shader.setFragParam('voxValXform', voxValXform) + changed |= self.shader.setFragParam('clipping', clipping) + changed |= self.shader.setFragParam('negCmap', negCmap) self.shader.unload() - return True + return changed def preDraw(self): diff --git a/fsl/fsleyes/gl/gl21/gllinevector_vert.glsl b/fsl/fsleyes/gl/gl21/gllinevector_vert.glsl index 678ca2753f26228e3b6a2fce17bce2b0bf976cd7..349ead0534c023cc155f462f02016122d3c278a5 100644 --- a/fsl/fsleyes/gl/gl21/gllinevector_vert.glsl +++ b/fsl/fsleyes/gl/gl21/gllinevector_vert.glsl @@ -64,10 +64,12 @@ varying vec4 fragColourFactor; void main(void) { - vec3 texCoord; - vec3 vector; - vec3 voxCoord; - vec3 vertVoxCoord; + + vec3 texCoord; + vec3 vector; + vec3 voxCoord; + vec3 vertVoxCoord; + float vectorLen; /* * The vertVoxCoord vector contains the floating @@ -105,11 +107,28 @@ void main(void) { * texture range of [0,1] to the original * data range */ - vector *= voxValXform[0].x; - vector += voxValXform[3].x; + vector *= voxValXform[0].x; + vector += voxValXform[3].x; + vectorLen = length(vector); + + /* + * Kill the vector if its length is 0. + * We have to be tolerant of errors, + * because of the transformation to/ + * from the texture data range. This + * may end up being too tolerant. + */ + if (vectorLen < 0.0001) { + fragColourFactor = vec4(0, 0, 0, 0); + return; + } - // Scale the vector so it has length 0.5 - vector /= 2 * length(vector); + /* + * Scale the vector so it has length + * 0.5. Note that here we are assuming + * that all vectors are of length 1. + */ + vector /= 2 * vectorLen; /* * Scale the vector by the minimum voxel length, diff --git a/fsl/fsleyes/gl/gllabel.py b/fsl/fsleyes/gl/gllabel.py index a91e381fb3ccc1198bd9acc36e85f09666550e86..dcc501d01c96e0bff8125b95073f4cc797838282 100644 --- a/fsl/fsleyes/gl/gllabel.py +++ b/fsl/fsleyes/gl/gllabel.py @@ -43,25 +43,34 @@ class GLLabel(globject.GLImageObject): """ - def __init__(self, image, display): + def __init__(self, image, display, xax, yax): """Create a ``GLLabel``. :arg image: The :class:`.Image` instance. :arg display: The associated :class:`.Display` instance. + :arg xax: Initial display X axis + :arg yax: Initial display Y axis """ - globject.GLImageObject.__init__(self, image, display) - - lutTexName = '{}_lut'.format(self.name) + globject.GLImageObject.__init__(self, image, display, xax, yax) + lutTexName = '{}_lut'.format(self.name) self.lutTexture = textures.LookupTableTexture(lutTexName) self.imageTexture = None - self.refreshImageTexture() + # The shader attribute will be created + # by the gllabel_funcs module + self.shader = None + + self.addListeners() + self.refreshLutTexture() - fslgl.gllabel_funcs.init(self) - self.addListeners() + def onTextureReady(): + fslgl.gllabel_funcs.init(self) + self.notify() + + async.wait([self.refreshImageTexture()], onTextureReady) def destroy(self): @@ -82,7 +91,9 @@ class GLLabel(globject.GLImageObject): """Returns ``True`` if this ``GLLabel`` is ready to be drawn, ``False`` otherwise. """ - return self.imageTexture is not None and self.imageTexture.ready() + return self.shader is not None and \ + self.imageTexture is not None and \ + self.imageTexture.ready() def addListeners(self): @@ -101,8 +112,8 @@ class GLLabel(globject.GLImageObject): def shaderUpdate(*a): if self.ready(): - if fslgl.gllabel_funcs.updateShaderState(self): - self.notify() + fslgl.gllabel_funcs.updateShaderState(self) + self.notify() def shaderCompile(*a): fslgl.gllabel_funcs.compileShaders(self) @@ -121,7 +132,8 @@ class GLLabel(globject.GLImageObject): if self.__lut is not None: self.__lut.addListener('labels', self.name, lutUpdate) - lutUpdate() + self.refreshLutTexture() + shaderUpdate() def imageRefresh(*a): async.wait([self.refreshImageTexture()], shaderUpdate) @@ -220,7 +232,8 @@ class GLLabel(globject.GLImageObject): textures.ImageTexture, texName, self.image, - notify=False) + notify=False, + volume=opts.volume) self.imageTexture.register(self.name, self.__imageTextureChanged) diff --git a/fsl/fsleyes/gl/gllinevector.py b/fsl/fsleyes/gl/gllinevector.py index 1549f9bdd5e9eb1313455054f429d2668b074814..ac662957d89ad45d62cf002702049d46f04bd9f4 100644 --- a/fsl/fsleyes/gl/gllinevector.py +++ b/fsl/fsleyes/gl/gllinevector.py @@ -53,12 +53,13 @@ class GLLineVector(glvector.GLVector): """ - def __init__(self, image, display): + def __init__(self, image, display, xax, yax): """Create a ``GLLineVector`` instance. :arg image: An :class:`.Image` or :class:`.TensorImage` instance. - :arg display: The associated :class:`.Display` instance. + :arg xax: Initial display X axis + :arg yax: Initial display Y axis """ # If the overlay is a TensorImage, use the @@ -70,6 +71,8 @@ class GLLineVector(glvector.GLVector): glvector.GLVector.__init__(self, image, display, + xax, + yax, vectorImage=vecImage, init=lambda: fslgl.gllinevector_funcs.init( self)) @@ -281,9 +284,10 @@ class GLLineVertices(object): lens = np.sqrt(x ** 2 + y ** 2 + z ** 2) # scale the vector lengths to 0.5 - vertices[:, :, :, 0] = 0.5 * x / lens - vertices[:, :, :, 1] = 0.5 * y / lens - vertices[:, :, :, 2] = 0.5 * z / lens + with np.errstate(invalid='ignore'): + vertices[:, :, :, 0] = 0.5 * x / lens + vertices[:, :, :, 1] = 0.5 * y / lens + vertices[:, :, :, 2] = 0.5 * z / lens # Scale the vector data by the minimum # voxel length, so it is a unit vector diff --git a/fsl/fsleyes/gl/glmask.py b/fsl/fsleyes/gl/glmask.py index bf144ec17b8f76ad14f39cd38114b4e2fc86f9d6..2b1ef20c014640ef3622ba5e55949ccdcf85e652 100644 --- a/fsl/fsleyes/gl/glmask.py +++ b/fsl/fsleyes/gl/glmask.py @@ -53,9 +53,9 @@ class GLMask(glvolume.GLVolume): def shaderUpdate(*a): if self.ready(): - if fslgl.glvolume_funcs.updateShaderState(self): - self.notify() - + fslgl.glvolume_funcs.updateShaderState(self) + self.notify() + def shaderCompile(*a): fslgl.glvolume_funcs.compileShaders(self) shaderUpdate() @@ -72,7 +72,7 @@ class GLMask(glvolume.GLVolume): resolution = opts.resolution self.imageTexture.set(volume=volume, resolution=resolution) - async.wait([self.refreshThread()], shaderUpdate) + async.wait([self.refreshImageTexture()], shaderUpdate) display.addListener('alpha', name, colourUpdate, weak=False) display.addListener('brightness', name, colourUpdate, weak=False) diff --git a/fsl/fsleyes/gl/glmodel.py b/fsl/fsleyes/gl/glmodel.py index 0dd61055d9b2631825d14e2eab06a83d2104b9b2..0e348889ee4716e777f7d36b571e24ed68c7e74b 100644 --- a/fsl/fsleyes/gl/glmodel.py +++ b/fsl/fsleyes/gl/glmodel.py @@ -65,16 +65,20 @@ class GLModel(globject.GLObject): """ - def __init__(self, overlay, display): + def __init__(self, overlay, display, xax, yax): """Create a ``GLModel``. :arg overlay: A :class:`.Model` overlay. :arg display: A :class:`.Display` instance defining how the ``overlay`` is to be displayed. + + :arg xax: Initial display X axis + + :arg yax: Initial display Y axis """ - globject.GLObject.__init__(self) + globject.GLObject.__init__(self, xax, yax) self.shader = None self.overlay = overlay diff --git a/fsl/fsleyes/gl/globject.py b/fsl/fsleyes/gl/globject.py index 3c03d030e594c6b14bb1c216246b07a4e20bd83b..72fbc67e0dfc7730cdc7c262d8f5179be7afcd52 100644 --- a/fsl/fsleyes/gl/globject.py +++ b/fsl/fsleyes/gl/globject.py @@ -50,7 +50,7 @@ def getGLObjectType(overlayType): return typeMap.get(overlayType, None) -def createGLObject(overlay, display): +def createGLObject(overlay, display, xax, yax): """Create :class:`GLObject` instance for the given overlay, as specified by the :attr:`.Display.overlayType` property. @@ -58,10 +58,14 @@ def createGLObject(overlay, display): :arg display: A :class:`.Display` instance describing how the overlay should be displayed. + + :arg xax: Initial display X axis + + :arg yax: Initial display Y axis """ ctr = getGLObjectType(display.overlayType) - if ctr is not None: return ctr(overlay, display) + if ctr is not None: return ctr(overlay, display, xax, yax) else: return None @@ -104,7 +108,11 @@ class GLObject(notifier.Notifier): Sub-class implementations must do the following: - - Call :meth:`__init__`. + - Call :meth:`__init__`. A ``GLObject.__init__`` sub-class method must + have the following signature:: + + def __init__(self, overlay, display, xax, yax) + - Call :meth:`notify` whenever its OpenGL representation changes. @@ -132,23 +140,26 @@ class GLObject(notifier.Notifier): """ - def __init__(self): - """Create a :class:`GLObject`. The constructor adds one attribute + def __init__(self, xax, yax): + """Create a :class:`GLObject`. The constructor adds one attribute to this instance, ``name``, which is simply a unique name for this - instance, and gives default values to the ``xax``, ``yax``, and - ``zax`` attributes. + instance, and gives values to the ``xax``, ``yax``, and ``zax`` + attributes. Subclass implementations must call this method, and should also perform any necessary OpenGL initialisation, such as creating textures. + + :arg xax: Initial display X axis + :arg yax: Initial display Y axis """ # Give this instance a name, and set # initial values for the display axes self.name = '{}_{}'.format(type(self).__name__, id(self)) - self.xax = 0 - self.yax = 1 - self.zax = 2 + self.xax = xax + self.yax = yax + self.zax = 3 - xax - yax log.memory('{}.init ({})'.format(type(self).__name__, id(self))) @@ -285,9 +296,9 @@ class GLSimpleObject(GLObject): be called. """ - def __init__(self): + def __init__(self, xax, yax): """Create a ``GLSimpleObject``. """ - GLObject.__init__(self) + GLObject.__init__(self, xax, yax) def ready(self): @@ -315,7 +326,7 @@ class GLImageObject(GLObject): of :class:`.Nifti1` instances. """ - def __init__(self, image, display): + def __init__(self, image, display, xax, yax): """Create a ``GLImageObject``. This constructor adds the following attributes to this instance: @@ -333,9 +344,13 @@ class GLImageObject(GLObject): :arg image: The :class:`.Nifti1` instance :arg display: An associated :class:`.Display` instance. + + :arg xax: Initial display X axis + + :arg yax: Initial display Y axis """ - GLObject.__init__(self) + GLObject.__init__(self, xax, yax) self.image = image self.display = display self.displayOpts = display.getDisplayOpts() diff --git a/fsl/fsleyes/gl/glrgbvector.py b/fsl/fsleyes/gl/glrgbvector.py index 5d290dd506ace290f48abe8b9bebda278f5970ad..4c25bd4f51f9639617c81ad32f3d6120c65e4420 100644 --- a/fsl/fsleyes/gl/glrgbvector.py +++ b/fsl/fsleyes/gl/glrgbvector.py @@ -59,11 +59,13 @@ class GLRGBVector(glvector.GLVector): """ - def __init__(self, image, display): + def __init__(self, image, display, xax, yax): """Create a ``GLRGBVector``. :arg image: An :class:`.Image` or :class:`.TensorImage` instance. :arg display: The associated :class:`.Display` instance. + :arg xax: Initial display X axis + :arg yax: Initial display Y axis """ # If the overlay is a TensorImage, use the @@ -75,6 +77,8 @@ class GLRGBVector(glvector.GLVector): glvector.GLVector.__init__(self, image, display, + xax, + yax, prefilter=np.abs, vectorImage=vecImage, init=lambda: fslgl.glrgbvector_funcs.init( diff --git a/fsl/fsleyes/gl/gltensor.py b/fsl/fsleyes/gl/gltensor.py index 96f8e7d68bbd91d840a1136ea41e9baf33005279..064adc63da314730aa0fc9fe847cfafa28de6ec5 100644 --- a/fsl/fsleyes/gl/gltensor.py +++ b/fsl/fsleyes/gl/gltensor.py @@ -28,7 +28,7 @@ class GLTensor(glvector.GLVector): """ - def __init__(self, image, display): + def __init__(self, image, display, xax, yax): """Create a ``GLTensor``. Calls the :func:`.gl21.gltensor_funcs.init` function. @@ -36,10 +36,16 @@ class GLTensor(glvector.GLVector): :arg display: The :class:`.Display` instance associated with the ``image``. + + :arg xax: Initial display X axis + + :arg yax: Initial display Y axis """ glvector.GLVector.__init__(self, image, display, + xax, + yax, prefilter=np.abs, vectorImage=image.V1(), init=lambda: fslgl.gltensor_funcs.init( diff --git a/fsl/fsleyes/gl/glvector.py b/fsl/fsleyes/gl/glvector.py index 6e97d1efb9e5b4583ef75eed188087364d178eda..dc86bd4bdeb4f6a49a76ea37ab755d7279bb56a6 100644 --- a/fsl/fsleyes/gl/glvector.py +++ b/fsl/fsleyes/gl/glvector.py @@ -96,6 +96,8 @@ class GLVector(globject.GLImageObject): def __init__(self, image, display, + xax, + yax, prefilter=None, vectorImage=None, init=None): @@ -116,6 +118,10 @@ class GLVector(globject.GLImageObject): :arg display: A :class:`.Display` object which describes how the image is to be displayed. + :arg xax: Initial display X axis + + :arg yax: Initial display Y axis + :arg prefilter: An optional function which filters the data before it is stored as a 3D texture. See :class:`.ImageTexture`. Whether or not this function @@ -145,7 +151,7 @@ class GLVector(globject.GLImageObject): raise ValueError('Image must be 4 dimensional, with 3 volumes ' 'representing the XYZ vector angles') - globject.GLImageObject.__init__(self, image, display) + globject.GLImageObject.__init__(self, image, display, xax, yax) name = self.name @@ -257,8 +263,8 @@ class GLVector(globject.GLImageObject): def shaderUpdate(*a): if self.ready(): - if self.updateShaderState(): - self.notify() + self.updateShaderState() + self.notify() def modUpdate( *a): self.deregisterAuxImage('modulate') @@ -531,6 +537,9 @@ class GLVector(globject.GLImageObject): # Right? if unsynced: texName = '{}_unsync_{}'.format(texName, id(opts)) + + if opts is not None: volume = opts.volume + else: volume = 0 tex = glresources.get( texName, @@ -538,6 +547,7 @@ class GLVector(globject.GLImageObject): texName, image, normalise=norm, + volume=volume, notify=False) tex.register(self.name, self.__textureChanged) diff --git a/fsl/fsleyes/gl/glvolume.py b/fsl/fsleyes/gl/glvolume.py index 55e13c20218776114c953bb8b4dac8401575e79c..5e4f869aaf3b627144774f08077fd11bdbb96f69 100644 --- a/fsl/fsleyes/gl/glvolume.py +++ b/fsl/fsleyes/gl/glvolume.py @@ -142,16 +142,20 @@ class GLVolume(globject.GLImageObject): """ - def __init__(self, image, display): + def __init__(self, image, display, xax, yax): """Create a ``GLVolume`` object. :arg image: An :class:`.Image` object. :arg display: A :class:`.Display` object which describes how the image is to be displayed. + + :arg xax: Initial display X axis + + :arg yax: Initial display Y axis """ - globject.GLImageObject.__init__(self, image, display) + globject.GLImageObject.__init__(self, image, display, xax, yax) # Add listeners to this image so the view can be # updated when its display properties are changed @@ -257,69 +261,32 @@ class GLVolume(globject.GLImageObject): display properties are changed. """ - image = self.image display = self.display opts = self.displayOpts - lName = self.name + name = self.name - def update(*a): - self.notify() + crPVs = opts.getPropVal('clippingRange').getPropertyValueList() - def shaderUpdate(*a): - if self.ready(): - if fslgl.glvolume_funcs.updateShaderState(self): - self.notify() + display .addListener('alpha', name, self._alphaChanged) + opts .addListener('displayRange', name, + self._displayRangeChanged) - def colourUpdate(*a): - self.refreshColourTextures() - if self.ready(): - shaderUpdate() - - def imageRefresh(*a): - async.wait([self.refreshImageTexture()], shaderUpdate) - - def imageUpdate(*a): - volume = opts.volume - resolution = opts.resolution - - if opts.interpolation == 'none': interp = gl.GL_NEAREST - else: interp = gl.GL_LINEAR - - self.imageTexture.set(volume=volume, - interp=interp, - resolution=resolution, - notify=False) - - waitfor = [self.imageTexture.refreshThread()] - - if self.clipTexture is not None: - self.clipTexture.set(interp=interp, - resolution=resolution, - notify=False) - waitfor.append(self.clipTexture.refreshThread()) - - async.wait(waitfor, shaderUpdate) - - def clipUpdate(*a): - self.deregisterClipImage() - self.registerClipImage() - async.wait([self.refreshClipTexture()], shaderUpdate) - - display.addListener('alpha', lName, colourUpdate, weak=False) - opts .addListener('displayRange', lName, colourUpdate, weak=False) - opts .addListener('clippingRange', lName, shaderUpdate, weak=False) - opts .addListener('clipImage', lName, clipUpdate, weak=False) - opts .addListener('invertClipping', lName, shaderUpdate, weak=False) - opts .addListener('cmap', lName, colourUpdate, weak=False) - opts .addListener('negativeCmap', lName, colourUpdate, weak=False) - opts .addListener('useNegativeCmap', - lName, colourUpdate, weak=False) - opts .addListener('invert', lName, colourUpdate, weak=False) - opts .addListener('volume', lName, imageUpdate, weak=False) - opts .addListener('resolution', lName, imageUpdate, weak=False) - opts .addListener('interpolation', lName, imageUpdate, weak=False) - opts .addListener('transform', lName, update, weak=False) - image .addListener('data', lName, update, weak=False) + crPVs[0].addListener(name, self._lowClippingRangeChanged) + crPVs[1].addListener(name, self._highClippingRangeChanged) + + opts .addListener('clipImage', name, self._clipImageChanged) + opts .addListener('invertClipping', name, + self._invertClippingChanged) + opts .addListener('cmap', name, self._cmapChanged) + opts .addListener('negativeCmap', name, self._cmapChanged) + opts .addListener('useNegativeCmap', name, + self._useNegativeCmapChanged) + opts .addListener('invert', name, self._invertChanged) + opts .addListener('volume', name, self._volumeChanged) + opts .addListener('interpolation', name, + self._interpolationChanged) + opts .addListener('resolution', name, self._resolutionChanged) + opts .addListener('transform', name, self._transformChanged) # Save a flag so the removeDisplayListeners # method knows whether it needs to de-register @@ -331,12 +298,15 @@ class GLVolume(globject.GLImageObject): self.__syncListenersRegistered = opts.getParent() is not None if self.__syncListenersRegistered: - opts.addSyncChangeListener( - 'volume', lName, imageRefresh, weak=False) - opts.addSyncChangeListener( - 'resolution', lName, imageRefresh, weak=False) - opts.addSyncChangeListener( - 'interpolation', lName, imageRefresh, weak=False) + opts.addSyncChangeListener('volume', + name, + self._imageSyncChanged) + opts.addSyncChangeListener('resolution', + name, + self._imageSyncChanged) + opts.addSyncChangeListener('interpolation', + name, + self._imageSyncChanged) def removeDisplayListeners(self): @@ -347,29 +317,30 @@ class GLVolume(globject.GLImageObject): image = self.image display = self.display opts = self.displayOpts - - lName = self.name - - display.removeListener( 'alpha', lName) - opts .removeListener( 'displayRange', lName) - opts .removeListener( 'clippingRange', lName) - opts .removeListener( 'clipImage', lName) - opts .removeListener( 'invertClipping', lName) - opts .removeListener( 'cmap', lName) - opts .removeListener( 'negativeCmap', lName) - opts .removeListener( 'useNegativeCmap', lName) - opts .removeListener( 'cmap', lName) - opts .removeListener( 'invert', lName) - opts .removeListener( 'volume', lName) - opts .removeListener( 'resolution', lName) - opts .removeListener( 'interpolation', lName) - opts .removeListener( 'transform', lName) - image .removeListener( 'data', lName) + name = self.name + crPVs = opts.getPropVal('clippingRange').getPropertyValueList() + + display .removeListener( 'alpha', name) + opts .removeListener( 'displayRange', name) + crPVs[0].removeListener(name) + crPVs[1].removeListener(name) + opts .removeListener( 'clipImage', name) + opts .removeListener( 'invertClipping', name) + opts .removeListener( 'cmap', name) + opts .removeListener( 'negativeCmap', name) + opts .removeListener( 'useNegativeCmap', name) + opts .removeListener( 'cmap', name) + opts .removeListener( 'invert', name) + opts .removeListener( 'volume', name) + opts .removeListener( 'resolution', name) + opts .removeListener( 'interpolation', name) + opts .removeListener( 'transform', name) + image .removeListener( 'data', name) if self.__syncListenersRegistered: - opts.removeSyncChangeListener('volume', lName) - opts.removeSyncChangeListener('resolution', lName) - opts.removeSyncChangeListener('interpolation', lName) + opts.removeSyncChangeListener('volume', name) + opts.removeSyncChangeListener('resolution', name) + opts.removeSyncChangeListener('interpolation', name) def testUnsynced(self): @@ -384,7 +355,144 @@ class GLVolume(globject.GLImageObject): not self.displayOpts.isSyncedToParent('volume') or not self.displayOpts.isSyncedToParent('resolution') or not self.displayOpts.isSyncedToParent('interpolation')) + + + def _alphaChanged(self, *a): + """Called when the :attr:`.Display.alpha` property changes. """ + self.refreshColourTextures() + self.notify() + + + def _displayRangeChanged(self, *a): + """Called when the :attr:`.VolumeOpts.displayRange` property changes. + """ + self.refreshColourTextures() + if fslgl.glvolume_funcs.updateShaderState(self): + self.notify() + + + def _lowClippingRangeChanged(self, *a): + """Called when the low :attr:`.VolumeOpts.clippingRange` property + changes. Separate listeners are used for the low and high clipping + values to avoid unnecessary duplicate refreshes in the event that the + :attr:`.VolumeOpts.linkLowRanges` or + :attr:`.VolumeOpts.linkHighRanges` flags are set. + """ + if self.displayOpts.linkLowRanges: + return + + if fslgl.glvolume_funcs.updateShaderState(self): + self.notify() + + def _highClippingRangeChanged(self, *a): + """Called when the high :attr:`.VolumeOpts.clippingRange` property + changes (see :meth:`_lowClippingRangeChanged`). + """ + if self.displayOpts.linkHighRanges: + return + + if fslgl.glvolume_funcs.updateShaderState(self): + self.notify() + + + def _clipImageChanged(self, *a): + """Called when the :attr:`.VolumeOpts.clipImage` property changes. + """ + def onRefresh(): + if fslgl.glvolume_funcs.updateShaderState(self): + self.notify() + self.deregisterClipImage() + self.registerClipImage() + async.wait([self.refreshClipTexture()], onRefresh) + + + def _invertClippingChanged(self, *a): + """Called when the :attr:`.VolumeOpts.invertClipping` property changes. + """ + if fslgl.glvolume_funcs.updateShaderState(self): + self.notify() + + + def _cmapChanged(self, *a): + """Called when the :attr:`.VolumeOpts.cmap` or + :attr:`.VolumeOpts.negativeCmap` properties change. + """ + self.refreshColourTextures() + self.notify() + + + def _useNegativeCmapChanged(self, *a): + """Called when the :attr:`.VolumeOpts.useNegativeCmap` property + changes. + """ + if fslgl.glvolume_funcs.updateShaderState(self): + self.notify() + + + def _invertChanged(self, *a): + """Called when the :attr:`.VolumeOpts.invert` property changes. """ + self.refreshColourTextures() + fslgl.glvolume_funcs.updateShaderState(self) + self.notify() + + + def _volumeChanged(self, *a): + """Called when the :attr:`.Nifti1Opts.volume` property changes. """ + opts = self.displayOpts + volume = opts.volume + resolution = opts.resolution + + if opts.interpolation == 'none': interp = gl.GL_NEAREST + else: interp = gl.GL_LINEAR + + self.imageTexture.set(volume=volume, + interp=interp, + resolution=resolution, + notify=False) + + waitfor = [self.imageTexture.refreshThread()] + + if self.clipTexture is not None: + self.clipTexture.set(interp=interp, + resolution=resolution, + notify=False) + waitfor.append(self.clipTexture.refreshThread()) + + def onRefresh(): + fslgl.glvolume_funcs.updateShaderState(self) + self.notify() + + async.wait(waitfor, onRefresh) + + + def _interpolationChanged(self, *a): + """Called when the :attr:`.Nifti1Opts.interpolation` property changes. + """ + self._volumeChanged() + + + def _resolutionChanged(self, *a): + """Called when the :attr:`.Nifti1Opts.resolution` property changes. + """ + self._volumeChanged() + + + def _transformChanged(self, *a): + """Called when the :attr:`.Nifti1Opts.transform` property changes. + """ + self.notify() + + + def _imageSyncChanged(self, *a): + """Called when the synchronisation state of the + :attr:`.Nifti1Opts.volume`, :attr:`.Nifti1Opts.resolution`, or + :attr:`.VolumeOpts.interpolation` properties change. + """ + self.refreshImageTexture() + fslgl.glvolume_funcs.updateShaderState(self) + self.notify() + def refreshImageTexture(self): """Refreshes the :class:`.ImageTexture` used to store the @@ -423,7 +531,8 @@ class GLVolume(globject.GLImageObject): self.image, interp=interp, resolution=opts.resolution, - notify=False) + notify=False, + volume=opts.volume) self.imageTexture.register(self.name, self.__textureChanged) diff --git a/fsl/fsleyes/gl/resources.py b/fsl/fsleyes/gl/resources.py index 7df347ca5ec5e3303f5539490c0da153fc5d6f67..a80ef172a9a8407f2a62d33b519bd1ef0cb973fd 100644 --- a/fsl/fsleyes/gl/resources.py +++ b/fsl/fsleyes/gl/resources.py @@ -46,7 +46,7 @@ texture, and will increase its reference count:: interp=gl.GL_LINEAR) -.. note:: Here, we have used ``'myTexture'`` as the resource key in practice, +.. note:: Here, we have used ``'myTexture'`` as the resource key. In practice, you will need to use something that is guaranteed to be unique throughout your application. diff --git a/fsl/fsleyes/gl/shaders/arbp/program.py b/fsl/fsleyes/gl/shaders/arbp/program.py index c11c9e17166e12c76d595f803ef376095041b064..26445466247a42cc9fc127a838ce23fc3be76663 100644 --- a/fsl/fsleyes/gl/shaders/arbp/program.py +++ b/fsl/fsleyes/gl/shaders/arbp/program.py @@ -18,6 +18,7 @@ import OpenGL.raw.GL._types as gltypes import OpenGL.GL.ARB.fragment_program as arbfp import OpenGL.GL.ARB.vertex_program as arbvp +import fsl.utils.memoize as memoize import parse @@ -207,12 +208,17 @@ class ARBPShader(object): gl.glDisableClientState(gl.GL_TEXTURE_COORD_ARRAY) + @memoize.Instanceify(memoize.skipUnchanged) def setVertParam(self, name, value): """Sets the value of the specified vertex program parameter. .. note:: It is assumed that the value is either a sequence of length 4 (for vector parameters), or a ``numpy`` array of shape ``(n, 4)`` (for matrix parameters). + + .. note:: This method is decorated by the + :func:`.memoize.skipUnchanged` decorator, which returns + ``True`` if the value was changed, and ``False`` otherwise. """ pos = self.vertParamPositions[name] @@ -225,10 +231,15 @@ class ARBPShader(object): arbvp.GL_VERTEX_PROGRAM_ARB, pos + i, row[0], row[1], row[2], row[3]) - + + @memoize.Instanceify(memoize.skipUnchanged) def setFragParam(self, name, value): """Sets the value of the specified vertex program parameter. See :meth:`setVertParam` for infomration about possible values. + + .. note:: This method is decorated by the + :func:`.memoize.skipUnchanged` decorator, which returns + ``True`` if the value was changed, and ``False`` otherwise. """ pos = self.fragParamPositions[name] value = np.array(value, dtype=np.float32).reshape((-1, 4)) diff --git a/fsl/fsleyes/gl/shaders/glsl/program.py b/fsl/fsleyes/gl/shaders/glsl/program.py index c39c8a23d0902ce32b5a6c9cd652878c8d65b975..f17ee31fa4f1713913dcb38e2eb9bd3123a7923b 100644 --- a/fsl/fsleyes/gl/shaders/glsl/program.py +++ b/fsl/fsleyes/gl/shaders/glsl/program.py @@ -18,6 +18,8 @@ import OpenGL.GL.ARB.instanced_arrays as arbia import parse +import fsl.utils.memoize as memoize + log = logging.getLogger(__name__) @@ -149,12 +151,6 @@ class GLSLShader(object): self.vertUniforms, self.fragUniforms) - # We cache the most recent value for - # every uniform. When a call to set() - # is made, if the value is unchanged, - # we skip the GL call. - self.values = {n : None for n in self.positions.keys()} - # Buffers for vertex attributes self.buffers = {} @@ -239,31 +235,20 @@ class GLSLShader(object): gl.glDeleteBuffers(1, gltypes.GLuint(buf)) self.program = None - + + @memoize.Instanceify(memoize.skipUnchanged) def set(self, name, value): """Sets the value for the specified GLSL ``uniform`` variable. The ``GLSLShader`` keeps a copy of the value of every uniform, to avoid unnecessary GL calls. - :returns: ``True`` if the value was changed, ``False`` otherwise. + + .. note:: This method is decorated by the + :func:`.memoize.skipUnchanged` decorator, which returns + ``True`` if the value was changed, ``False`` otherwise. """ - oldVal = self.values[name] - - oldIsArray = isinstance(oldVal, np.ndarray) - newIsArray = isinstance(value, np.ndarray) - isarray = oldIsArray or newIsArray - - if oldIsArray and (not newIsArray): value = np.array(value) - if newIsArray and (not oldIsArray): oldVal = np.array(oldVal) - - if isarray: nochange = np.all(oldVal == value) - else: nochange = oldVal == value - - if nochange: - return False - vPos = self.positions[name] vType = self.types[ name] @@ -278,8 +263,6 @@ class GLSLShader(object): setfunc(vPos, value) - return True - def setAtt(self, name, value, divisor=None): """Sets the value for the specified GLSL ``attribute`` variable. diff --git a/fsl/fsleyes/gl/slicecanvas.py b/fsl/fsleyes/gl/slicecanvas.py index 76297f1552907a3c08c414edd471bf0bf67ff9ca..ef31b1c1eb7bac03aa3ac27c27a6853c79b42e1e 100644 --- a/fsl/fsleyes/gl/slicecanvas.py +++ b/fsl/fsleyes/gl/slicecanvas.py @@ -136,6 +136,7 @@ class SliceCanvas(props.HasProperties): panDisplayBy centreDisplayAt panDisplayToShow + zoomTo getAnnotations """ @@ -244,14 +245,15 @@ class SliceCanvas(props.HasProperties): :class:`.DisplayContext`, and :class:`.Display` instances, and destroys OpenGL representations of all overlays. """ - self.removeListener('zax', self.name) - self.removeListener('pos', self.name) - self.removeListener('displayBounds', self.name) - self.removeListener('showCursor', self.name) - self.removeListener('invertX', self.name) - self.removeListener('invertY', self.name) - self.removeListener('zoom', self.name) - self.removeListener('renderMode', self.name) + self.removeListener('zax', self.name) + self.removeListener('pos', self.name) + self.removeListener('displayBounds', self.name) + self.removeListener('showCursor', self.name) + self.removeListener('invertX', self.name) + self.removeListener('invertY', self.name) + self.removeListener('zoom', self.name) + self.removeListener('renderMode', self.name) + self.removeListener('resolutionLimit', self.name) self.overlayList.removeListener('overlays', self.name) self.displayCtx .removeListener('bounds', self.name) @@ -375,8 +377,6 @@ class SliceCanvas(props.HasProperties): """Pans the display so the given x/y position is in the centre. """ xcentre, ycentre = self.getDisplayCentre() - - # move to the new centre self.panDisplayBy(xpos - xcentre, ypos - ycentre) @@ -414,6 +414,56 @@ class SliceCanvas(props.HasProperties): self.panDisplayBy(xoff, yoff) + def zoomTo(self, xlo, xhi, ylo, yhi): + """Zooms the canvas to the given rectangle, specified in + horizontal/vertical display coordinates. + """ + + # We are going to convert the rectangle specified by + # the inputs into a zoom value, set the canvas zoom + # level, and then centre the canvas on the rectangle. + + # Middle of the rectangle, used + # at the end for centering + xmid = xlo + (xhi - xlo) / 2.0 + ymid = ylo + (yhi - ylo) / 2.0 + + # Size of the rectangle + rectXlen = abs(xhi - xlo) + rectYlen = abs(yhi - ylo) + + if rectXlen == 0: return + if rectYlen == 0: return + + # Size of the canvas limits, + # and the zoom value limits + xmin, xmax = self.displayBounds.getLimits(0) + ymin, ymax = self.displayBounds.getLimits(1) + zoommin = self.getConstraint('zoom', 'minval') + zoommax = self.getConstraint('zoom', 'maxval') + + xlen = xmax - xmin + ylen = ymax - ymin + zoomlen = zoommax - zoommin + + # Calculate the ratio of the + # rectangle to the canvas limits + xratio = rectXlen / xlen + yratio = rectYlen / ylen + ratio = max(xratio, yratio) + + # Calculate the zoom from this ratio - + # this is the inverse of the zoom->canvas + # bounds calculation, as implemented in + # _applyZoom. + zoom = 100.0 / ratio + zoom = ((zoom - zoommin) / zoomlen) ** (1.0 / 3.0) + zoom = zoommin + zoom * zoomlen + + self.zoom = zoom + self.centreDisplayAt(xmid, ymid) + + def getAnnotations(self): """Returns an :class:`.Annotations` instance, which can be used to annotate the canvas. @@ -802,10 +852,12 @@ class SliceCanvas(props.HasProperties): self._glObjects.pop(overlay) return - globj = globject.createGLObject(overlay, display) + globj = globject.createGLObject(overlay, + display, + self.xax, + self.yax) if globj is not None: - globj.setAxes(self.xax, self.yax) globj.register(self.name, self._refresh) self._glObjects[overlay] = globj @@ -955,8 +1007,27 @@ class SliceCanvas(props.HasProperties): if self.zoom == 100.0: return (xmin, xmax, ymin, ymax) + # The zoom is specified as a value + # between 100 and 5000 (the minval/ + # maxaval). To make the zoom smoother + # at low levels, we re-scale this + # value to be exponential across the + # range. + minzoom = self.getConstraint('zoom', 'minval') + maxzoom = self.getConstraint('zoom', 'maxval') + + # Transform zoom from [100 - 5000] into + # [0.0 - 1.0], then turn it from linear + # [0.0 - 1.0] to exponential [0.0 - 1.0], + # and then finally back to [100 - 5000]. + zoom = (self.zoom - minzoom) / (maxzoom - minzoom) + zoom = minzoom + (zoom ** 3) * (maxzoom - minzoom) + + # Then turn zoom from [100 - 5000] + # to [1.0 - 0.0] - this value is + # then used to scale the given bounds + zoomFactor = 100.0 / zoom bounds = self.displayBounds - zoomFactor = 100.0 / self.zoom xlen = xmax - xmin ylen = ymax - ymin @@ -1006,9 +1077,9 @@ class SliceCanvas(props.HasProperties): oldLoc=None): """Called on canvas resizes, overlay bound changes, and zoom changes. - Calculates the bounding box, in display coordinates, to be displayed on - the canvas. Stores this bounding box in the displayBounds property. If - any of the parameters are not provided, the + Calculates the bounding box, in display coordinates, to be displayed + on the canvas. Stores this bounding box in the :attr:`displayBounds` + property. If any of the parameters are not provided, the :attr:`.DisplayContext.bounds` are used. diff --git a/fsl/fsleyes/gl/wxglslicecanvas.py b/fsl/fsleyes/gl/wxglslicecanvas.py index 6f789e77f5760b0f3c8f0069d8776e9031d47416..d56f5dd0eccc05283b1b56a60444800a317ab86a 100644 --- a/fsl/fsleyes/gl/wxglslicecanvas.py +++ b/fsl/fsleyes/gl/wxglslicecanvas.py @@ -43,3 +43,29 @@ class WXGLSliceCanvas(slicecanvas.SliceCanvas, self._updateDisplayBounds() ev.Skip() self.Bind(wx.EVT_SIZE, onResize) + + + def Show(self, show): + """Overrides ``GLCanvas.Show``. When running over SSH/X11, it doesn't + seem to be possible to hide a ``GLCanvas`` - the most recent scene + displayed on the canvas seems to persist, does not get overridden, and + gets drawn on top of other things in the interface: + + .. image:: images/x11_slicecanvas_show_bug.png + :scale: 50% + :align: center + + This is not ideal, and I have no idea why it occurs. The only + workaround that I've found to work is, instead of hiding the canvas, + to set its size to 0. So this method does just that. + """ + + if not show: + self.SetMinSize((0, 0)) + self.SetMaxSize((0, 0)) + self.SetSize( (0, 0)) + + + def Hide(self): + """Overrides ``GLCanvas.Hide``. Calls :meth:`Show`. """ + self.Show(False) diff --git a/fsl/fsleyes/icons/splash/splash.png b/fsl/fsleyes/icons/splash/splash.png index 9b01fb2e575a4be3ea8fc9ca9bed9e35d9c3f71a..1d5dc9770dfb12db7c137004464db0ddd1f55158 100644 Binary files a/fsl/fsleyes/icons/splash/splash.png and b/fsl/fsleyes/icons/splash/splash.png differ diff --git a/fsl/fsleyes/icons/splash/splash.xcf b/fsl/fsleyes/icons/splash/splash.xcf index 14d491e4bb34878bd2ac2baa1dfd3f3dae27350e..8aadf6e83dca3c9b00639772d61f9c0f937abf5e 100644 Binary files a/fsl/fsleyes/icons/splash/splash.xcf and b/fsl/fsleyes/icons/splash/splash.xcf differ diff --git a/fsl/fsleyes/overlay.py b/fsl/fsleyes/overlay.py index a90abfc50743e7d453c74ab2365ef9a9151cc9bc..daad912f809b5c56b98acfcef985a491ff37690d 100644 --- a/fsl/fsleyes/overlay.py +++ b/fsl/fsleyes/overlay.py @@ -65,6 +65,7 @@ import os.path as op import props import fsl.data.strings as strings +import fsl.data.image as fslimage import fsl.utils.settings as fslsettings import fsl.utils.status as status import fsl.utils.async as async @@ -157,9 +158,27 @@ class OverlayList(props.HasProperties): """Returns the first overlay with the given ``name`` or ``dataSource``, or ``None`` if there is no overlay with said ``name``/``dataSource``. """ + + if name is None: + return None + for overlay in self.overlays: - if overlay.name == name or overlay.dataSource == name: + + if overlay.name == name: return overlay + + if overlay.dataSource is None: + return None + + # Ignore file extensions for NIFTI1 images. + if isinstance(overlay, fslimage.Image): + if fslimage.removeExt(overlay.dataSource) == \ + fslimage.removeExt(name): + return overlay + else: + if overlay.dataSource == name: + return overlay + return None diff --git a/fsl/fsleyes/perspectives.py b/fsl/fsleyes/perspectives.py index 22868462afb53637a18d61bd67713350fab414fd..bbc0bb076c6383f279d228f1693c0a0f65085fb2 100644 --- a/fsl/fsleyes/perspectives.py +++ b/fsl/fsleyes/perspectives.py @@ -210,7 +210,8 @@ def serialisePerspective(frame): the ``AuiManager.SavePerspective`` and ``AuiManager.SavePaneInfo`` methods. One of these strings consists of: - - A name. + - A name, `'layout1'` or `'layout2'`, specifying the AUI version + (this will always be at least `'layout2'` for *FSLeyes*). - A set of key-value set of key-value pairs defining the top level panel layout. @@ -222,7 +223,7 @@ def serialisePerspective(frame): separated with '|' characters, and the pane-level key-value pairs separated with a ';' character. For example: - layoutName|key1=value1|name=Pane1;caption=Pane 1|\ + layout2|key1=value1|name=Pane1;caption=Pane 1|\ name=Pane2;caption=Pane 2|doc_size(5,0,0)=22| This function queries each of the AuiManagers, and extracts the following: @@ -585,16 +586,33 @@ def _getPanelProps(panel): VIEWPANEL_PROPS = { - 'OrthoPanel' : [['syncLocation', 'syncOverlayOrder', 'syncOverlayDisplay'], - ['showCursor', 'bgColour', 'cursorColour', - 'showColourBar', 'colourBarLocation', 'showXCanvas', - 'showYCanvas', 'showZCanvas', 'showLabels', + 'OrthoPanel' : [['syncLocation', + 'syncOverlayOrder', + 'syncOverlayDisplay', + 'movieRate'], + ['showCursor', + 'bgColour', + 'cursorColour', + 'showColourBar', + 'colourBarLocation', + 'showXCanvas', + 'showYCanvas', + 'showZCanvas', + 'showLabels', 'layout'] ], - 'LightBoxPanel' : [['syncLocation', 'syncOverlayOrder', 'syncOverlayDisplay'], - ['showCursor', 'bgColour', 'cursorColour', - 'showColourBar', 'colourBarLocation', 'zax', - 'showGridLines', 'highlightSlice']]} + 'LightBoxPanel' : [['syncLocation', + 'syncOverlayOrder', + 'syncOverlayDisplay', + 'movieRate'], + ['showCursor', + 'bgColour', + 'cursorColour', + 'showColourBar', + 'colourBarLocation', + 'zax', + 'showGridLines', + 'highlightSlice']]} BUILT_IN_PERSPECTIVES = collections.OrderedDict(( diff --git a/fsl/fsleyes/platform.py b/fsl/fsleyes/platform.py index 9575f46e2c79d9783d2fadce441caee1e64751b7..305873b86f37349087584830dbe8b6ab4fe55d48 100644 --- a/fsl/fsleyes/platform.py +++ b/fsl/fsleyes/platform.py @@ -5,6 +5,8 @@ # Author: Paul McCarthy <pauldmccarthy@gmail.com> # +import os + haveGui = False wxFlavour = None wxPlatform = None @@ -16,18 +18,28 @@ WX_PHOENIX = 2 WX_MAC = 1 WX_GTK = 2 +class Platform(object): + def __init__(self): -try: - import wx - haveGui = True - -except ImportError: - haveGui = False + self.haveGui = False + self.wxFlavour = None + self.wxPlatform = None + try: + import wx + self.haveGui = True -if 'phoenix' in wx.PlatformInformation: wxFlavour = WX_PHOENIX -else: wxFlavour = WX_PYTHON + except ImportError: + pass + if self.haveGui: + if 'phoenix' in wx.PlatformInformation: + self.wxFlavour = WX_PHOENIX + + if 'MAC' in wx.Platform: + self.wxPlatform = WX_MAC -if 'MAC' in wx.Platform: wxPlatform = WX_MAC -elif 'GTK' in wx.Platform: wxPlatform = WX_GTK + # TODO Make Platform a notifier, so + # things can register to listen + # for changes to $FSLDIR + self.fsldir = os.environ['FSLDIR'] diff --git a/fsl/fsleyes/profiles/lightboxviewprofile.py b/fsl/fsleyes/profiles/lightboxviewprofile.py index 5d032af1fd169af189e97c32aac0695e67ebbfc4..742a241ee65d38ef966f934960c2dbf09f6eff03 100644 --- a/fsl/fsleyes/profiles/lightboxviewprofile.py +++ b/fsl/fsleyes/profiles/lightboxviewprofile.py @@ -11,6 +11,7 @@ import logging import fsl.fsleyes.profiles as profiles +import fsl.utils.async as async log = logging.getLogger(__name__) @@ -76,7 +77,12 @@ class LightBoxViewProfile(profiles.Profile): if wheel > 0: wheel = -1 elif wheel < 0: wheel = 1 - self._viewPanel.getCanvas().topRow += wheel + # See comment in OrthoViewProfile._zoomModeMouseWheel + # about timeout + def update(): + self._viewPanel.getCanvas().topRow += wheel + + async.idle(update, timeout=0.1) def _viewModeLeftMouseDrag(self, ev, canvas, mousePos, canvasPos): @@ -106,4 +112,10 @@ class LightBoxViewProfile(profiles.Profile): if wheel > 0: wheel = 50 elif wheel < 0: wheel = -50 - self._viewPanel.getSceneOptions().zoom += wheel + + # see comment in OrthoViewProfile._zoomModeMouseWheel + # about timeout + def update(): + self._viewPanel.getSceneOptions().zoom += wheel + + async.idle(update, timeout=0.1) diff --git a/fsl/fsleyes/profiles/orthoeditprofile.py b/fsl/fsleyes/profiles/orthoeditprofile.py index 62c1947e23741653e10fc9130a616b0a8ba12ad6..f547e1950aa40dc6dc943681f0f0ae2e347304a0 100644 --- a/fsl/fsleyes/profiles/orthoeditprofile.py +++ b/fsl/fsleyes/profiles/orthoeditprofile.py @@ -17,6 +17,7 @@ import numpy as np import props import fsl.data.image as fslimage import fsl.data.strings as strings +import fsl.utils.async as async import fsl.utils.dialog as fsldlg import fsl.utils.status as status import fsl.fsleyes.actions as actions @@ -193,7 +194,9 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): self.__xcanvas = viewPanel.getXCanvas() self.__ycanvas = viewPanel.getYCanvas() self.__zcanvas = viewPanel.getZCanvas() - self.__selAnnotation = None + self.__xselAnnotation = None + self.__yselAnnotation = None + self.__zselAnnotation = None self.__selecting = False self.__currentOverlay = None @@ -238,14 +241,29 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): editor.removeListener('canRedo', self._name) editor.destroy() - if self.__selAnnotation is not None: - self.__selAnnotaiton.destroy() + xannot = self.__xcanvas.getAnnotations() + yannot = self.__ycanvas.getAnnotations() + zannot = self.__zcanvas.getAnnotations() + + if self.__xselAnnotation is not None: + xannot.dequeue(self.__xselAnnotation, hold=True) + self.__xselAnnotaiton.destroy() + + if self.__yselAnnotation is not None: + yannot.dequeue(self.__yselAnnotation, hold=True) + self.__yselAnnotaiton.destroy() + + if self.__zselAnnotation is not None: + zannot.dequeue(self.__zselAnnotation, hold=True) + self.__zselAnnotaiton.destroy() self.__editors = None self.__xcanvas = None self.__ycanvas = None self.__zcanvas = None - self.__selAnnotation = None + self.__xselAnnotation = None + self.__yselAnnotation = None + self.__zselAnnotation = None self.__currentOverlay = None orthoviewprofile.OrthoViewProfile.destroy(self) @@ -255,14 +273,26 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): """Destroys all :mod:`.annotations`, and calls :meth:`.OrthoViewProfile.deregister`. """ - if self.__selAnnotation is not None: - sa = self.__selAnnotation - self.__xcanvas.getAnnotations().dequeue(sa, hold=True) - self.__ycanvas.getAnnotations().dequeue(sa, hold=True) - self.__zcanvas.getAnnotations().dequeue(sa, hold=True) - sa.destroy() - - self.__selAnnotation = None + + xannot = self.__xcanvas.getAnnotations() + yannot = self.__ycanvas.getAnnotations() + zannot = self.__zcanvas.getAnnotations() + + if self.__xselAnnotation is not None: + xannot.dequeue(self.__xselAnnotation, hold=True) + self.__xselAnnotation.destroy() + + if self.__yselAnnotation is not None: + yannot.dequeue(self.__yselAnnotation, hold=True) + self.__yselAnnotation.destroy() + + if self.__zselAnnotation is not None: + zannot.dequeue(self.__zselAnnotation, hold=True) + self.__zselAnnotation.destroy() + + self.__xselAnnotation = None + self.__yselAnnotation = None + self.__zselAnnotation = None orthoviewprofile.OrthoViewProfile.deregister(self) @@ -358,7 +388,7 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): editor.getSelection().enableNotification('selection') self.__selectionChanged() - self.__selAnnotation.texture.refresh() + self.__xselAnnotation.texture.refresh() self._viewPanel.Refresh() @@ -373,12 +403,14 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): editor = self.__editors[self.__currentOverlay] + # See comment in undo method + # about disabling notification editor.getSelection().disableNotification('selection') editor.redo() editor.getSelection().enableNotification('selection') self.__selectionChanged() - self.__selAnnotation.texture.refresh() + self.__xselAnnotation.texture.refresh() self._viewPanel.Refresh() @@ -408,8 +440,14 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): Updates the :mod:`.annotations` colours accordingly. """ - if self.__selAnnotation is not None: - self.__selAnnotation.colour = self.selectionOverlayColour + if self.__xselAnnotation is not None: + self.__xselAnnotation.colour = self.selectionOverlayColour + + if self.__yselAnnotation is not None: + self.__yselAnnotation.colour = self.selectionOverlayColour + + if self.__zselAnnotation is not None: + self.__zselAnnotation.colour = self.selectionOverlayColour def __setFillValueLimits(self, overlay): @@ -482,14 +520,21 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): zannot = self.__zcanvas.getAnnotations() # Clear the selection annotation - if self.__selAnnotation is not None: - xannot.dequeue(self.__selAnnotation, hold=True) - yannot.dequeue(self.__selAnnotation, hold=True) - zannot.dequeue(self.__selAnnotation, hold=True) + if self.__xselAnnotation is not None: + xannot.dequeue(self.__xselAnnotation, hold=True) + self.__xselAnnotation.destroy() + + if self.__yselAnnotation is not None: + yannot.dequeue(self.__yselAnnotation, hold=True) + self.__yselAnnotation.destroy() - self.__selAnnotation.destroy() + if self.__zselAnnotation is not None: + zannot.dequeue(self.__zselAnnotation, hold=True) + self.__zselAnnotation.destroy() - self.__selAnnotation = None + self.__xselAnnotation = None + self.__yselAnnotation = None + self.__zselAnnotation = None # Remove property listeners from the # editor/selection instances associated @@ -603,15 +648,33 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): # Create a selection annotation and # queue it on the canvases for drawing - self.__selAnnotation = annotations.VoxelSelection( + self.__xselAnnotation = annotations.VoxelSelection( + self.__xcanvas.xax, + self.__xcanvas.yax, + editor.getSelection(), + opts.getTransform('pixdim', 'voxel'), + opts.getTransform('voxel', 'pixdim'), + colour=self.selectionOverlayColour) + + self.__yselAnnotation = annotations.VoxelSelection( + self.__ycanvas.xax, + self.__ycanvas.yax, editor.getSelection(), opts.getTransform('pixdim', 'voxel'), opts.getTransform('voxel', 'pixdim'), colour=self.selectionOverlayColour) + + self.__zselAnnotation = annotations.VoxelSelection( + self.__zcanvas.xax, + self.__zcanvas.yax, + editor.getSelection(), + opts.getTransform('pixdim', 'voxel'), + opts.getTransform('voxel', 'pixdim'), + colour=self.selectionOverlayColour) - xannot.obj(self.__selAnnotation, hold=True) - yannot.obj(self.__selAnnotation, hold=True) - zannot.obj(self.__selAnnotation, hold=True) + xannot.obj(self.__xselAnnotation, hold=True) + yannot.obj(self.__yselAnnotation, hold=True) + zannot.obj(self.__zselAnnotation, hold=True) self._viewPanel.Refresh() @@ -673,9 +736,11 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): kwargs = {'colour' : self.selectionCursorColour, 'width' : 2} - cursors = [annotations.Rect((0, 0), 0, 0, **kwargs), - annotations.Rect((0, 0), 0, 0, **kwargs), - annotations.Rect((0, 0), 0, 0, **kwargs)] + cursors = [] + + for c in canvases: + r = annotations.Rect(c.xax, c.yax, (0, 0), 0, 0, **kwargs) + cursors.append(r) # If we are running in a low # performance mode, the cursor @@ -775,16 +840,16 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): On all lower performance settings, only the source canvas is updated. """ perf = self._viewPanel.getSceneOptions().performance - if perf == 4: - if mousePos is None or canvasPos is None: - self._viewPanel.Refresh() - - # If running in high performance mode, we make - # the canvas location track the edit cursor - # location, so that the other two canvases - # update to display the current cursor location. - else: - self._navModeLeftMouseDrag(ev, canvas, mousePos, canvasPos) + + # If running in high performance mode, we make + # the canvas location track the edit cursor + # location, so that the other two canvases + # update to display the current cursor location. + if perf == 4 and \ + (mousePos is not None) and \ + (canvasPos is not None): + self._navModeLeftMouseDrag(ev, canvas, mousePos, canvasPos) + else: canvas.Refresh() @@ -868,10 +933,17 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): voxel = self.__getVoxelLocation(canvasPos) - if voxel is not None: + if voxel is None: + return + + # See comment in OrthoViewProfile._zoomModeMouseWheel + # about timeout + def update(): self.__drawCursorAnnotation(canvas, voxel) self.__refreshCanvases(ev, canvas) + async.idle(update, timeout=0.1) + def _deselModeLeftMouseDown(self, ev, canvas, mousePos, canvasPos): """Handles mouse down events in ``desel`` mode. @@ -1056,17 +1128,24 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): dataRange = opts.dataMax - opts.dataMin step = 0.01 * dataRange - if wheel > 0: self.intensityThres += step - elif wheel < 0: self.intensityThres -= step + if wheel > 0: offset = step + elif wheel < 0: offset = -step else: return - if self.__selecting: - - voxel = self.__getVoxelLocation(canvasPos) - if voxel is not None: - self.__selintSelect(voxel, canvas) - self.__refreshCanvases(ev, canvas) + # See comment in OrthoViewProfile._zoomModeMouseWheel + # about timeout + def update(): + self.intensityThres += offset + if self.__selecting: + + voxel = self.__getVoxelLocation(canvasPos) + + if voxel is not None: + self.__selintSelect(voxel, canvas) + self.__refreshCanvases(ev, canvas) + + async.idle(update, timeout=0.1) def _chradModeMouseWheel(self, ev, canvas, wheel, mousePos, canvasPos): @@ -1077,14 +1156,22 @@ class OrthoEditProfile(orthoviewprofile.OrthoViewProfile): select-by-intensity is re-run at the current mouse location. """ - if wheel > 0: self.searchRadius -= 5 - elif wheel < 0: self.searchRadius += 5 + if wheel > 0: offset = -5 + elif wheel < 0: offset = 5 else: return - if self.__selecting: - - voxel = self.__getVoxelLocation(canvasPos) + # See comment in OrthoViewProfile._zoomModeMouseWheel + # about timeout + def update(): + + self.searchRadius += offset + + if self.__selecting: + + voxel = self.__getVoxelLocation(canvasPos) + + if voxel is not None: + self.__selintSelect(voxel, canvas) + self.__refreshCanvases(ev, canvas) - if voxel is not None: - self.__selintSelect(voxel, canvas) - self.__refreshCanvases(ev, canvas) + async.idle(update, timeout=0.1) diff --git a/fsl/fsleyes/profiles/orthoviewprofile.py b/fsl/fsleyes/profiles/orthoviewprofile.py index d23595f5a21593498bd54f7574c9b8826232c131..fd878510772238621c38dba732fc5c338a47957e 100644 --- a/fsl/fsleyes/profiles/orthoviewprofile.py +++ b/fsl/fsleyes/profiles/orthoviewprofile.py @@ -16,6 +16,7 @@ import numpy as np import fsl.fsleyes.profiles as profiles import fsl.fsleyes.actions as actions +import fsl.utils.async as async import fsl.data.image as fslimage import fsl.data.constants as constants @@ -332,7 +333,11 @@ class OrthoViewProfile(profiles.Profile): elif ch in ('+', '='): dirs[canvas.zax] = 1 elif ch in ('-', '_'): dirs[canvas.zax] = -1 - self._displayCtx.location.xyz = self.__offsetLocation(*dirs) + def update(): + self._displayCtx.location.xyz = self.__offsetLocation(*dirs) + + # See comment in _zoomModeMouseWheel about timeout + async.idle(update, timeout=0.1) ##################### @@ -357,7 +362,11 @@ class OrthoViewProfile(profiles.Profile): pos = self.__offsetLocation(*dirs) - self._displayCtx.location[canvas.zax] = pos[canvas.zax] + def update(): + self._displayCtx.location[canvas.zax] = pos[canvas.zax] + + # See comment in _zoomModeMouseWheel about timeout + async.idle(update, timeout=0.1) #################### @@ -378,7 +387,17 @@ class OrthoViewProfile(profiles.Profile): """ if wheel > 0: wheel = 50 elif wheel < 0: wheel = -50 - canvas.zoom += wheel + + # Over SSH/X11, mouse wheel events seem to get queued, + # and continue to get processed after the user has + # stopped spinning the mouse wheel, which is super + # frustrating. So we do the update asynchronously, and + # set a time out to drop the event, and prevent the + # horribleness from happening. + def update(): + canvas.zoom += wheel + + async.idle(update, timeout=0.1) def _zoomModeChar(self, ev, canvas, key): @@ -451,31 +470,12 @@ class OrthoViewProfile(profiles.Profile): canvas.getAnnotations().dequeue(self.__lastRect) self.__lastRect = None - rectXlen = abs(canvasPos[canvas.xax] - canvasDownPos[canvas.xax]) - rectYlen = abs(canvasPos[canvas.yax] - canvasDownPos[canvas.yax]) - - if rectXlen == 0: return - if rectYlen == 0: return - - rectXmid = (canvasPos[canvas.xax] + canvasDownPos[canvas.xax]) / 2.0 - rectYmid = (canvasPos[canvas.yax] + canvasDownPos[canvas.yax]) / 2.0 - - xlen = self._displayCtx.bounds.getLen(canvas.xax) - ylen = self._displayCtx.bounds.getLen(canvas.yax) - - xzoom = xlen / rectXlen - yzoom = ylen / rectYlen - zoom = min(xzoom, yzoom) * 100.0 - maxzoom = canvas.getConstraint('zoom', 'maxval') + xlo = min(canvasPos[canvas.xax], canvasDownPos[canvas.xax]) + xhi = max(canvasPos[canvas.xax], canvasDownPos[canvas.xax]) + ylo = min(canvasPos[canvas.yax], canvasDownPos[canvas.yax]) + yhi = max(canvasPos[canvas.yax], canvasDownPos[canvas.yax]) - if zoom >= maxzoom: - zoom = maxzoom - - if zoom > canvas.zoom: - canvas.zoom = zoom - canvas.centreDisplayAt(rectXmid, rectYmid) - - canvas.Refresh() + canvas.zoomTo(xlo, xhi, ylo, yhi) ################### @@ -491,12 +491,12 @@ class OrthoViewProfile(profiles.Profile): If the target canvas is not zoomed in, this has no effect. """ - - if canvasPos is None: - return mouseDownPos, canvasDownPos = self.getMouseDownLocation() + if canvasPos is None: return + if canvasDownPos is None: return + xoff = canvasPos[canvas.xax] - canvasDownPos[canvas.xax] yoff = canvasPos[canvas.yax] - canvasDownPos[canvas.yax] @@ -519,7 +519,11 @@ class OrthoViewProfile(profiles.Profile): elif key == wx.WXK_RIGHT: xoff = 2 else: return - canvas.panDisplayBy(xoff, yoff) + def update(): + canvas.panDisplayBy(xoff, yoff) + + # See comment in _zoomModeMouseWheel about timeout + async.idle(update, timeout=0.1) ############# diff --git a/fsl/fsleyes/profiles/profilemap.py b/fsl/fsleyes/profiles/profilemap.py index 16c365c1a12a98a5934f6d7ac48c69db10faf070..bc73b0a80af58f55295d745950c5a01407650cb0 100644 --- a/fsl/fsleyes/profiles/profilemap.py +++ b/fsl/fsleyes/profiles/profilemap.py @@ -159,8 +159,8 @@ altHandlerMap = { (('desel', 'MiddleMouseDrag'), ('pan', 'LeftMouseDrag')), (('selint', 'MiddleMouseDrag'), ('pan', 'LeftMouseDrag')), - (('sel', 'MouseWheel'), ('chsize', 'MiddleMouse')), - (('desel', 'MouseWheel'), ('chsize', 'MiddleMouse')), + (('sel', 'MouseWheel'), ('chsize', 'MouseWheel')), + (('desel', 'MouseWheel'), ('chsize', 'MouseWheel')), (('selint', 'MouseWheel'), ('chthres', 'MouseWheel')), # Keyboard navigation works in the diff --git a/fsl/fsleyes/views/histogrampanel.py b/fsl/fsleyes/views/histogrampanel.py index 4a3ac926813bbdd0d7f2b6c852d212fe1f757985..19e4d79dc8d3bbd787c36388872ffd73ae9ed208 100644 --- a/fsl/fsleyes/views/histogrampanel.py +++ b/fsl/fsleyes/views/histogrampanel.py @@ -112,8 +112,9 @@ class HistogramPanel(plotpanel.OverlayPlotPanel): """Shows/hides a :class:`.HistogramControlPanel`. See :meth:`.ViewPanel.togglePanel`. """ - self.togglePanel( - histogramcontrolpanel.HistogramControlPanel, self, location=wx.TOP) + self.togglePanel(histogramcontrolpanel.HistogramControlPanel, + self, + location=wx.RIGHT) def getActions(self): diff --git a/fsl/fsleyes/views/orthopanel.py b/fsl/fsleyes/views/orthopanel.py index f69bb26294f6e493aaac73fca4944a71cf78d36d..7ecba10f2c17fe7b12bd62dfaa9fb05b6d517d89 100644 --- a/fsl/fsleyes/views/orthopanel.py +++ b/fsl/fsleyes/views/orthopanel.py @@ -328,21 +328,23 @@ class OrthoPanel(canvaspanel.CanvasPanel): bg = self.getSceneOptions().bgColour fg = colourmaps.complementaryColour(bg) - bg = [int(round(c * 255)) for c in bg] - fg = [int(round(c * 255)) for c in fg] + # All wxwidgets things need colours + # to be specified between 0 and 255 + intbg = [int(round(c * 255)) for c in bg] + intfg = [int(round(c * 255)) for c in fg] - self.getContentPanel().SetBackgroundColour(bg) - self.getContentPanel().SetForegroundColour(fg) + self.getContentPanel().SetBackgroundColour(intbg) + self.getContentPanel().SetForegroundColour(intfg) cbCanvas = self.getColourBarCanvas() if cbCanvas is not None: cbCanvas.textColour = fg - self.__xcanvas.SetBackgroundColour(bg) - self.__ycanvas.SetBackgroundColour(bg) - self.__zcanvas.SetBackgroundColour(bg) + self.__xcanvas.SetBackgroundColour(intbg) + self.__ycanvas.SetBackgroundColour(intbg) + self.__zcanvas.SetBackgroundColour(intbg) - self.__setLabelColours(bg, fg) + self.__setLabelColours(intbg, intfg) self.Refresh() self.Update() @@ -406,8 +408,10 @@ class OrthoPanel(canvaspanel.CanvasPanel): for canvas, labels, show in zip(canvases, allLabels, shows): + # See WXGLSliceCanvas.Show for + # details of a horrible bug, and + # equally horrible workaround.. canvas.Show(show) - self.__canvasSizer.Show(canvas, show) for label in labels.values(): self.__canvasSizer.Show(label, show and opts.showLabels) @@ -456,11 +460,8 @@ class OrthoPanel(canvaspanel.CanvasPanel): :class:`.SliceCanvas`. """ - sceneOpts = self.getSceneOptions() - allLabels = self.__xLabels.values() + \ - self.__yLabels.values() + \ - self.__zLabels.values() - + sopts = self.getSceneOptions() + # Are we showing or hiding the labels? if len(self._overlayList) == 0: show = False @@ -470,19 +471,23 @@ class OrthoPanel(canvaspanel.CanvasPanel): # Labels are only supported if we # have a volumetric reference image - if overlay is None: show = False - elif sceneOpts.showLabels: show = True - else: show = False + if overlay is None: showLabels = False + elif sopts.showLabels: showLabels = True + else: showLabels = False - for lbl in allLabels: - self.__canvasSizer.Show(lbl, show) + canvases = [self.__xcanvas, self.__ycanvas, self.__zcanvas] + allLabels = [self.__xLabels, self.__yLabels, self.__zLabels] + shows = [sopts.showXCanvas, sopts.showYCanvas, sopts.showZCanvas] + + for canvas, labels, show in zip(canvases, allLabels, shows): + for lbl in labels.values(): + self.__canvasSizer.Show(lbl, show and showLabels) # If we're hiding the labels, do no more - if not show: + if not showLabels: self.PostSizeEvent() return - log.debug('Refreshing orientation labels ' 'according to {}'.format(overlay.name)) @@ -505,7 +510,7 @@ class OrthoPanel(canvaspanel.CanvasPanel): log.debug('Y orientation: {} - {}'.format(ylo, yhi)) log.debug('Z orientation: {} - {}'.format(zlo, zhi)) - bg = sceneOpts.bgColour + bg = sopts.bgColour fg = colourmaps.complementaryColour(bg) bg = [int(round(c * 255)) for c in bg] fg = [int(round(c * 255)) for c in fg] @@ -761,11 +766,14 @@ class OrthoPanel(canvaspanel.CanvasPanel): space, space, space] # Add all those widgets to the grid sizer - flag = wx.ALIGN_CENTRE_HORIZONTAL | wx.ALIGN_CENTRE_VERTICAL + flag = wx.ALIGN_CENTRE_HORIZONTAL | wx.ALIGN_CENTRE_VERTICAL + canvases = [self.__xcanvas, self.__ycanvas, self.__zcanvas] for w in widgets: - self.__canvasSizer.Add(w, flag=flag) - + + if w in canvases: self.__canvasSizer.Add(w, flag=flag | wx.EXPAND) + else: self.__canvasSizer.Add(w, flag=flag) + self.getContentPanel().SetSizer(self.__canvasSizer) # Calculate/ adjust the appropriate sizes diff --git a/fsl/fsleyes/views/plotpanel.py b/fsl/fsleyes/views/plotpanel.py index a234b1d83486ea62f30910c72e3e2185e0606ea0..6b7b2afa153f26e0ef0425dcbba7878348e9154b 100644 --- a/fsl/fsleyes/views/plotpanel.py +++ b/fsl/fsleyes/views/plotpanel.py @@ -399,6 +399,11 @@ class PlotPanel(viewpanel.ViewPanel): for ds in toPlot: xlim, ylim = self.__drawOneDataSeries(ds, preproc, **plotArgs) + + if (xlim[1] - xlim[0] < 0.0000000001) or \ + (ylim[1] - ylim[0] < 0.0000000001): + continue + xlims.append(xlim) ylims.append(ylim) @@ -942,6 +947,8 @@ class OverlayPlotPanel(PlotPanel): for ovl in self._overlayList: if ovl in self.__dataSeries: continue + + log.debug('Creating a DataSeries for overlay {}'.format(ovl)) ds, refreshTargets, refreshProps = self.createDataSeries(ovl) diff --git a/fsl/fsleyes/views/powerspectrumpanel.py b/fsl/fsleyes/views/powerspectrumpanel.py index 893eaebcec425812c6949f2565d7ac460ae9e9aa..5715c0cc05ddcdcfb24cce51b701ec5736ec9919 100644 --- a/fsl/fsleyes/views/powerspectrumpanel.py +++ b/fsl/fsleyes/views/powerspectrumpanel.py @@ -116,7 +116,7 @@ class PowerSpectrumPanel(plotpanel.OverlayPlotPanel): """ self.togglePanel(pscontrol.PowerSpectrumControlPanel, self, - location=wx.TOP) + location=wx.RIGHT) @actions.toggleControlAction(plotlistpanel.PlotListPanel) diff --git a/fsl/fsleyes/views/timeseriespanel.py b/fsl/fsleyes/views/timeseriespanel.py index dde4ece00d06acd3781cdc225d853b026c900286..b68a7880bac0942c4f9af6da454dd481279394ec 100644 --- a/fsl/fsleyes/views/timeseriespanel.py +++ b/fsl/fsleyes/views/timeseriespanel.py @@ -17,6 +17,7 @@ import props import plotpanel import fsl.data.featimage as fslfeatimage +import fsl.data.featresults as featresults import fsl.data.melodicimage as fslmelimage import fsl.data.image as fslimage import fsl.fsleyes.actions as actions @@ -171,7 +172,7 @@ class TimeSeriesPanel(plotpanel.OverlayPlotPanel): """ self.togglePanel(timeseriescontrolpanel.TimeSeriesControlPanel, self, - location=wx.TOP) + location=wx.RIGHT) def getActions(self): @@ -204,10 +205,15 @@ class TimeSeriesPanel(plotpanel.OverlayPlotPanel): tss = [self.getDataSeries(o) for o in overlays] tss = [ts for ts in tss if ts is not None] - for i, ts in enumerate(list(tss)): + # Include all of the extra model series + # for all FEATTimeSeries instances + newTss = [] + for ts in tss: if isinstance(ts, plotting.FEATTimeSeries): - tss.pop(i) - tss = tss[:i] + ts.getModelTimeSeries() + tss[i:] + newTss += ts.getModelTimeSeries() + else: + newTss.append(ts) + tss = newTss for ts in tss: @@ -241,24 +247,55 @@ class TimeSeriesPanel(plotpanel.OverlayPlotPanel): data to be plotted), a tuple of ``None`` values is returned. """ - if not (isinstance(overlay, fslimage.Image) and overlay.is4DImage()): + if not isinstance(overlay, fslimage.Image): return None, None, None - if isinstance(overlay, fslfeatimage.FEATImage): - ts = plotting.FEATTimeSeries(self, overlay, self._displayCtx) + if overlay.dataSource is not None: + featPath = featresults.getAnalysisDir(overlay.dataSource) + else: + featPath = None + + # Is this a FEAT filtered_func_data image, + # or an image in a FEAT directory? + if isinstance(overlay, fslfeatimage.FEATImage) or featPath is not None: + + dataPath = featresults.getDataFile(featPath) + featImage = self._overlayList.find(dataPath) + + # If this is an image in a FEAT directory, but the + # filtered_func_data for that FEAT directory has + # not been loaded, we show nothing. + if not isinstance(overlay, fslfeatimage.FEATImage) and \ + featImage is None: + return None, None, None + + # If the filtered_func for this FEAT analysis + # has been loaded, we show its time series. + overlay = featImage + ts = plotting.FEATTimeSeries(self, + overlay, + self._displayCtx) targets = [self._displayCtx] propNames = ['location'] - + + # If this is a melodic IC image, and we are + # currently configured to plot component ICs, + # we use a MelodicTimeSeries object. elif isinstance(overlay, fslmelimage.MelodicImage) and \ self.plotMelodicICs: ts = plotting.MelodicTimeSeries(self, overlay, self._displayCtx) targets = [self._displayCtx.getOpts(overlay)] - propNames = ['volume'] - - else: + propNames = ['volume'] + + # Otherwise we just plot + # bog-standard 4D voxel data + elif len(overlay.shape) == 4 and overlay.shape[3] > 1: ts = plotting.VoxelTimeSeries(self, overlay, self._displayCtx) targets = [self._displayCtx] - propNames = ['location'] + propNames = ['location'] + + else: + return None, None, None ts.colour = self.getOverlayPlotColour(overlay) ts.alpha = 1 diff --git a/fsl/fsleyes/views/viewpanel.py b/fsl/fsleyes/views/viewpanel.py index 5c890174579ff3f3fc7a42c01fb842c1540498c6..1d6f62879c4142ec349b887960e1bb2f1fcbcf70 100644 --- a/fsl/fsleyes/views/viewpanel.py +++ b/fsl/fsleyes/views/viewpanel.py @@ -9,6 +9,7 @@ for all of the *FSLeyes view* panels. See the :mod:`~fsl.fsleyes` package documentation for more details. """ + import logging import wx @@ -111,12 +112,16 @@ class ViewPanel(fslpanel.FSLEyesPanel): self.__centrePanel = None self.__panels = {} + # See note in FSLEyesFrame about + # the user of aero docking guides. self.__auiMgr = aui.AuiManager( self, agwFlags=(aui.AUI_MGR_RECTANGLE_HINT | aui.AUI_MGR_NO_VENETIAN_BLINDS_FADE | aui.AUI_MGR_ALLOW_FLOATING | - aui.AUI_MGR_LIVE_RESIZE)) + aui.AUI_MGR_AERO_DOCKING_GUIDES | + aui.AUI_MGR_LIVE_RESIZE)) + self.__auiMgr.Bind(aui.EVT_AUI_PANE_CLOSE, self.__onPaneClose) # Use a different listener name so that subclasses @@ -552,37 +557,36 @@ class ViewPanel(fslpanel.FSLEyesPanel): ev.Skip() panel = ev.GetPane().window - log.debug('Panel closed: {}'.format(type(panel).__name__)) - if isinstance(panel, (fslpanel .FSLEyesPanel, fsltoolbar.FSLEyesToolBar)): - self.__panels.pop(type(panel)) - - # calling fslpanel.FSLEyesPanel.destroy() - # here - wx.Destroy is done below - panel.destroy() - - # Even when the user closes a pane, - # AUI does not detach said pane - - # we have to do it manually - self.__auiMgr.DetachPane(panel) - self.__auiMgrUpdate() - - # WTF AUI. Sometimes this method gets called - # twice for a panel, the second time with a - # reference to a wx._wxpyDeadObject; in such - # situations, the Destroy method call below - # will result in an exception being raised. - else: - return + panel = self.__panels.pop(type(panel), None) + + # WTF AUI. Sometimes this method gets called + # twice for a panel, the second time with a + # reference to a wx._wxpyDeadObject; in such + # situations, the Destroy method call below + # will result in an exception being raised. + if panel is not None: - panel.Destroy() + log.debug('Panel closed: {}'.format(type(panel).__name__)) + + # calling fslpanel.FSLEyesPanel.destroy() + # here - wx.Destroy is done below + panel.destroy() + # Even when the user closes a pane, + # AUI does not detach said pane - + # we have to do it manually + self.__auiMgr.DetachPane(panel) + self.__auiMgrUpdate() + + panel.Destroy() # -# Here I am monkey patching the wx.agw.aui.framemanager.AuiFloatingFrame -# __init__ method. +# Here I am monkey patching the +# wx.agw.aui.framemanager.AuiFloatingFrame.__init__ method. +# # # I am doing this because I have observed some strange behaviour when running # a remote instance of this application over an SSH/X11 session, with the X11 @@ -627,3 +631,30 @@ def AuiFloatingFrame__init__(*args, **kwargs): # Patch my constructor in to the class definition. AuiFloatingFrame__real__init__ = aui.AuiFloatingFrame.__init__ aui.AuiFloatingFrame.__init__ = AuiFloatingFrame__init__ + +# I am also monkey-patching the wx.lib.agw.aui.AuiDockingGuide.__init__ method, +# because in this instance, when running over SSH/X11, the wx.FRAME_TOOL_WINDOW +# style seems to result in the docking guide frames being given title bars, +# which is quite undesirable. +def AuiDockingGuide__init__(*args, **kwargs): + + if 'style' in kwargs: + style = kwargs['style'] + + # This is the default style, as defined + # in the AuiDockingGuide constructor + else: + style = (wx.FRAME_TOOL_WINDOW | + wx.FRAME_STAY_ON_TOP | + wx.FRAME_NO_TASKBAR | + wx.NO_BORDER) + + style &= ~wx.FRAME_TOOL_WINDOW + + kwargs['style'] = style + + return AuiDockingGuide__real__init__(*args, **kwargs) + + +AuiDockingGuide__real__init__ = aui.AuiDockingGuide.__init__ +aui.AuiDockingGuide.__init__ = AuiDockingGuide__init__ diff --git a/fsl/tools/__init__.py b/fsl/tools/__init__.py index 6ba4bb324e857835896cdfc3e30545e702ca90d9..8d866cebef2e1fc732db7e6d460cd7bd3564bb49 100644 --- a/fsl/tools/__init__.py +++ b/fsl/tools/__init__.py @@ -27,6 +27,11 @@ package) a tool module must provide the following module level attributes: ``FSL_HELPPAGE`` Optional. A URL to a web page providing help/documentation. +``FSL_INIT`` Optional. A function which is called called before the + tool arguments are parsed. The return value of this function + will be passed on to the ``FSL_CONTEXT`` function (if it is + provided). + ``FSL_PARSEARGS`` Optional. A function which is given a list of command line arguments specific to the tool. The function should parse the arguments and return, for example, an :mod:`argparse` @@ -63,16 +68,20 @@ asked to run a ``fslpy`` tool: import fsl.tools.mytool as mytool -2. Calls the ``FSL_PARSEARGS`` function:: +2. Calls the ``FSL_INIT`` function:: + + initVal = mytool.FSL_INIT() + +3. Calls the ``FSL_PARSEARGS`` function:: parsedArgs = mytool.FSL_PARSEARGS(cmdLineArgs) -3. Calls the ``FSL_CONTEXT`` function, giving it the value returned by +4. Calls the ``FSL_CONTEXT`` function, giving it the value returned by ``FSL_PARSEARGS``:: - context = mytool.FSL_CONTEXT(parsedArgs) + context = mytool.FSL_CONTEXT(parsedArgs, initVal) -4. +5. a. If the tool is a command line application, calls the ``FSL_EXECUTE`` function, passing it the arguments and the context:: diff --git a/fsl/tools/bet.py b/fsl/tools/bet.py index 7d026c9100ea41e333e542ac8cf7b3f6bfc9106b..cb0fe09ff5ecc79c9cb954f8c0a515fb66850210 100644 --- a/fsl/tools/bet.py +++ b/fsl/tools/bet.py @@ -337,6 +337,6 @@ def runBet(parent, opts): FSL_TOOLNAME = 'BET' FSL_HELPPAGE = 'bet' -FSL_CONTEXT = lambda args: Options() +FSL_CONTEXT = lambda *a: Options() FSL_INTERFACE = interface FSL_ACTIONS = [('Run BET', runBet)] diff --git a/fsl/tools/feat.py b/fsl/tools/feat.py index 759335e5ae3a38cbd89e57ef45271217ff7824a0..b0a30e73efbbdc35ef3b9a6e0a8fe24c3b5032b2 100644 --- a/fsl/tools/feat.py +++ b/fsl/tools/feat.py @@ -552,5 +552,5 @@ def interface(parent, args, featOpts): FSL_TOOLNAME = 'FEAT' FSL_HELPPAGE = 'feat' -FSL_CONTEXT = lambda args: Options() +FSL_CONTEXT = lambda *a: Options() FSL_INTERFACE = interface diff --git a/fsl/tools/flirt.py b/fsl/tools/flirt.py index 609252fa2282b17509531a4388be34a00a41e1bd..b89c3bcac26afadda4ca41afa2186bd834271d2b 100644 --- a/fsl/tools/flirt.py +++ b/fsl/tools/flirt.py @@ -196,6 +196,6 @@ def interface(parent, args, opts): FSL_TOOLNAME = 'FLIRT' FSL_HELPPAGE = 'flirt' -FSL_CONTEXT = lambda args: Options() +FSL_CONTEXT = lambda *a: Options() FSL_INTERFACE = interface FSL_ACTIONS = [('Run FLIRT', runFlirt)] diff --git a/fsl/tools/fsleyes.py b/fsl/tools/fsleyes.py index edc8fd4a35707d410dfd48b9aab8f4852670e040..fe0201e218de4329362e63059bd184dcaa9614e6 100644 --- a/fsl/tools/fsleyes.py +++ b/fsl/tools/fsleyes.py @@ -25,18 +25,30 @@ import logging import textwrap import argparse -import fsl.fsleyes.fsleyes_parseargs as fsleyes_parseargs -import fsl.fsleyes.displaycontext as displaycontext -import fsl.fsleyes.perspectives as perspectives -import fsl.fsleyes.overlay as fsloverlay -import fsl.utils.status as status -import fsl.utils.async as async -import fsl.data.strings as strings +import fsl.fsleyes.perspectives as perspectives +import fsl.utils.status as status +import fsl.utils.async as async +import fsl.data.strings as strings log = logging.getLogger(__name__) +def init(): + """Creates and returns a :class:`.FSLEyesSplash` frame. """ + + import fsl.fsleyes.splash as fslsplash + + frame = fslsplash.FSLEyesSplash(None) + + frame.CentreOnScreen() + frame.Show() + frame.Refresh() + frame.Update() + + return frame + + def parseArgs(argv): """Parses the given ``fsleyes`` command line arguments. See the :mod:`.fsleyes_parseargs` module for details on the ``fsleyes`` command @@ -45,6 +57,8 @@ def parseArgs(argv): :arg argv: command line arguments for ``fsleyes``. """ + import fsl.fsleyes.fsleyes_parseargs as fsleyes_parseargs + parser = argparse.ArgumentParser( add_help=False, formatter_class=argparse.RawDescriptionHelpFormatter) @@ -72,7 +86,7 @@ def parseArgs(argv): description) -def context(args): +def context(args, splash): """Creates the ``fsleyes`` context. This function does a few things: @@ -88,7 +102,9 @@ def context(args): 4. Loads all of the overlays which were passed in on the command line. - :arg args: Parsed command line arguments (see :func:`parseArgs`). + :arg args: Parsed command line arguments (see :func:`parseArgs`). + + :arg splash: The :class:`.FSLEyesSplash` frame, created in :func:`init`. :returns: a tuple containing: - the :class:`.OverlayList` @@ -96,36 +112,28 @@ def context(args): - the :class:`.FSLEyesSplash` frame """ - import fsl.fsleyes.splash as fslsplash - - # Create a splash screen, and use it - # to initialise the OpenGL context + import fsl.fsleyes.overlay as fsloverlay + import fsl.fsleyes.fsleyes_parseargs as fsleyes_parseargs + import fsl.fsleyes.displaycontext as displaycontext + import fsl.fsleyes.gl as fslgl + import props + props.initGUI() + # The splash screen is used as the parent of the dummy # canvas created by the gl.getWXGLContext function; the # splash screen frame is returned by this function, and - # passed through to the interface function above, which - # takes care of destroying it. - frame = fslsplash.FSLEyesSplash(None) - - frame.CentreOnScreen() - frame.Show() - frame.Refresh() - frame.Update() - - import props - import fsl.fsleyes.gl as fslgl - - props.initGUI() + # passed through to the interface function below, which + # takes care of destroying it. # force the creation of a wx.glcanvas.GLContext object, # and initialise OpenGL version-specific module loads. - fslgl.getWXGLContext(frame) + fslgl.getWXGLContext(splash) fslgl.bootstrap(args.glversion) # Redirect status updates # to the splash frame - status.setTarget(frame.SetStatus) + status.setTarget(splash.SetStatus) # Create the overlay list (only one of these # ever exists) and the master DisplayContext. @@ -153,7 +161,7 @@ def context(args): # be updated with the currently loading overlay name fsleyes_parseargs.applyOverlayArgs(args, overlayList, displayCtx) - return overlayList, displayCtx, frame + return overlayList, displayCtx, splash def interface(parent, args, ctx): @@ -181,9 +189,10 @@ def interface(parent, args, ctx): :returns: the :class:`.FSLEyesFrame` that was created. """ - import wx - import fsl.fsleyes.frame as fsleyesframe - import fsl.fsleyes.views as views + import wx + import fsl.fsleyes.fsleyes_parseargs as fsleyes_parseargs + import fsl.fsleyes.frame as fsleyesframe + import fsl.fsleyes.views as views overlayList, displayCtx, splashFrame = ctx @@ -219,7 +228,21 @@ def interface(parent, args, ctx): splashFrame.Hide() splashFrame.Refresh() splashFrame.Update() - wx.CallLater(250, splashFrame.Close) + + # In certain instances under Linux/GTK, + # closing the splash screen will crash + # the application. No idea why. So if + # running GTK, we leave the splash + # screen hidden, but not closed, and + # close it when the main frame is + # closed. + if wx.Platform == '__WXGTK__': + def onFrameDestroy(ev): + ev.Skip() + splashFrame.Close() + frame.Bind(wx.EVT_WINDOW_DESTROY, onFrameDestroy) + else: + wx.CallLater(250, splashFrame.Close) # If a perspective has been specified, # we load the perspective @@ -283,8 +306,10 @@ def about(frame, ctx): FSL_TOOLNAME = 'FSLeyes' +FSL_HELPPAGE = 'http://users.fmrib.ox.ac.uk/~paulmc/fsleyes/' FSL_INTERFACE = interface FSL_CONTEXT = context +FSL_INIT = init FSL_PARSEARGS = parseArgs FSL_ACTIONS = [(strings.actions['AboutAction'], about), (strings.actions['DiagnosticReportAction'], diagnosticReport)] diff --git a/fsl/tools/render.py b/fsl/tools/render.py index 0352af31984ac4271d4c58d4878b7f23e94af78e..327e97ed667e8b2911ea9ad517a8359aea1862cf 100644 --- a/fsl/tools/render.py +++ b/fsl/tools/render.py @@ -520,7 +520,7 @@ def parseArgs(argv): return namespace -def context(args): +def context(args, *a, **kwa): # Create an image list and display context. # The DisplayContext, Display and DisplayOpts diff --git a/fsl/utils/async.py b/fsl/utils/async.py index 73330044b4653df87e8142df5a3178b8a367ae05..801d0debbc0516623ca095390c0ea5431182b7a3 100644 --- a/fsl/utils/async.py +++ b/fsl/utils/async.py @@ -31,6 +31,8 @@ task to run. It waits until all the threads have finished, and then runs the task (via :func:`idle`). """ + +import time import Queue import logging import threading @@ -125,12 +127,18 @@ def _wxIdleLoop(ev): ev.Skip() - try: task, args, kwargs = _idleQueue.get_nowait() - except Queue.Empty: return + try: + task, schedtime, timeout, args, kwargs = _idleQueue.get_nowait() + except Queue.Empty: + return + + name = getattr(task, '__name__', '<unknown>') + now = time.time() + elapsed = now - schedtime - name = getattr(task, '__name__', '<unknown>') - log.debug('Running function ({}) on wx idle loop'.format(name)) - task(*args, **kwargs) + if timeout == 0 or (elapsed < timeout): + log.debug('Running function ({}) on wx idle loop'.format(name)) + task(*args, **kwargs) if _idleQueue.qsize() > 0: ev.RequestMore() @@ -141,6 +149,12 @@ def idle(task, *args, **kwargs): :arg task: The task to run. + :arg timeout: Optional. If provided, must be provided as a keyword + argument. Specifies a time out, in seconds. If this + amount of time passes before the function gets + scheduled to be called on the idle loop, the function + is not called, and is dropped from the queue. + All other arguments are passed through to the task function. If a ``wx.App`` is not running, the task is called directly. @@ -149,6 +163,9 @@ def idle(task, *args, **kwargs): global _idleRegistered global _idleTasks + schedtime = time.time() + timeout = kwargs.pop('timeout', 0) + if _haveWX(): import wx @@ -159,7 +176,7 @@ def idle(task, *args, **kwargs): name = getattr(task, '__name__', '<unknown>') log.debug('Scheduling idle task ({}) on wx idle loop'.format(name)) - _idleQueue.put_nowait((task, args, kwargs)) + _idleQueue.put_nowait((task, schedtime, timeout, args, kwargs)) else: log.debug('Running idle task directly') diff --git a/fsl/utils/dialog.py b/fsl/utils/dialog.py index 92ff78d09800dd9c4cd404a2e2bb6130dc5c7e53..f922d06d13e3bc08c04e28df179f14f8cd4a72c2 100644 --- a/fsl/utils/dialog.py +++ b/fsl/utils/dialog.py @@ -710,7 +710,7 @@ class FSLDirDialog(wx.Dialog): class CheckBoxMessageDialog(wx.Dialog): """A ``wx.Dialog`` which displays a message, a ``wx.CheckBox``, and - an "Ok" button. + an *Ok* button. """ @@ -735,13 +735,13 @@ class CheckBoxMessageDialog(wx.Dialog): :arg cbState: Initial state for the ``wx.CheckBox``. - :arg btnText: Text to show on the button. Defaults to "OK". + :arg btnText: Text to show on the button. Defaults to *OK*. :arg icon: A ``wx`` icon identifier (e.g. ``wx.ICON_EXCLAMATION``). :arg style: Passed through to the ``wx.Dialog.__init__`` method. - Defaults to ``wx.DEFAULT_DIALOG_STYLE`. + Defaults to ``wx.DEFAULT_DIALOG_STYLE``. """ if style is None: style = wx.DEFAULT_DIALOG_STYLE diff --git a/fsl/utils/memoize.py b/fsl/utils/memoize.py index cf40efe67f514c82119bad87f0e2c33324bb17b4..07f6f1242643c7c25288e789639e4bf0de8d712b 100644 --- a/fsl/utils/memoize.py +++ b/fsl/utils/memoize.py @@ -10,10 +10,14 @@ a function: .. autosummary:: :nosignatures: + Instanceify memoizeMD5 + skipUnchanged """ + import hashlib +import functools def memoizeMD5(func): @@ -46,3 +50,129 @@ def memoizeMD5(func): return result return wrapper + + +def skipUnchanged(func): + """This decorator is intended for use with *setter* functions - a function + which accepts a name and a value, and is intended to set some named + attribute to the given value. + + This decorator keeps a cache of name-value pairs. When the decorator is + called with a specific name and value, the cache is checked and, if the + given value is the same as the cached value, the decorated function is + *not* called. If the given value is different from the cached value (or + there is no value), the decorated function is called. + + .. note:: This decorator ignores the return value of the decorated + function. + + :returns: ``True`` if the underlying setter function was called, ``False`` + otherwise. + """ + + import numpy as np + + cache = {} + + def wrapper(name, value, *args, **kwargs): + + oldVal = cache.get(name, None) + + if oldVal is not None: + + oldIsArray = isinstance(oldVal, np.ndarray) + newIsArray = isinstance(value, np.ndarray) + isarray = oldIsArray or newIsArray + + if isarray: nochange = np.all(oldVal == value) + else: nochange = oldVal == value + + if nochange: + return False + + func(name, value, *args, **kwargs) + + cache[name] = value + + return True + + return wrapper + + +class Instanceify(object): + """This class is intended to be used to decorate other decorators, so they + can be applied to instance methods. For example, say we have the following + class:: + + class Container(object): + + def __init__(self): + self.__items = {} + + @skipUnchanged + def set(self, name, value): + self.__items[name] = value + + + Given this definition, a single :func:`skipUnchanged` decorator will be + created and shared amongst all ``Container`` instances. This is not ideal, + as the value cache created by the :func:`skipUnchanged` decorator should + be associated with a single ``Container`` instance. + + + By redefining the ``Container`` class definition like so:: + + + class Container(object): + + def __init__(self): + self.__items = {} + + @Instanceify(skipUnchanged) + def set(self, name, value): + self.__items[name] = value + + + a separate :func:`skipUnchanged` decorator is created for, and associated + with, every ``Container`` instance. + + + This is achieved because an ``Instanceify`` instance is a descriptor. When + first accessed as an instance attribute, an ``Instanceify`` instance will + create the real decorator function, and replace itself on the instance. + """ + + + def __init__(self, realDecorator): + """Create an ``Instanceify`` decorator. + + :arg realDecorator: A reference to the decorator that is to be + *instance-ified*. + """ + + self.__realDecorator = realDecorator + self.__func = None + + + def __call__(self, func): + """Called immediately after :meth:`__init__`, and passed the method + that is to be decorated. + """ + self.__func = func + return self + + + def __get__(self, instance, cls): + """When an ``Instanceify`` instance is accessed as an attribute of + another object, it will create the real (instance-ified) decorator, + and replace itself on the instance with the real decorator. + """ + + if instance is None: + return self.__func + + method = functools.partial(self.__func, instance) + decMethod = self.__realDecorator(method) + + setattr(instance, self.__func.__name__, decMethod) + return functools.update_wrapper(decMethod, self.__func) diff --git a/fsl/version.py b/fsl/version.py index 5a716b7ffda64bab500bcc50d360455c85e832f7..78d9ac1425f75fb6169845181152303c96cf8708 100644 --- a/fsl/version.py +++ b/fsl/version.py @@ -10,9 +10,14 @@ version number and information. .. autosummary:: __version__ + __vcs_version__ .. todo:: Define a formal ``fslpy`` version number updating scheme. """ -__version__ = '0.1' +__version__ = '0.9a' """Current version number, as a string. """ + + +__vcs_version__ = '0.9a' +"""VCS (Version Control System) version number, for internal use. """ diff --git a/fsleyes_doc/ic_classification.rst b/fsleyes_doc/ic_classification.rst index d581adea681d0ac2bc68c2af1efb7186e65c806c..5404f75ef23e5c6e956ead798ecb59e27ac1613e 100644 --- a/fsleyes_doc/ic_classification.rst +++ b/fsleyes_doc/ic_classification.rst @@ -11,9 +11,12 @@ IC classification .. sidebar:: I can't navigate with the keyboard! - Under OSX, you may need to enable *Full keyboard access* for the - melodic classification panel to work with keyboard - navigation/focus. This setting can be changed through *System - Preferences* |right_arrow| *Keyboard* |right_arrow| *Shortcuts*, - and changing *Full Keyboard Access* to *All controls*. - + Under OSX, you may have focus-related issues while navigating + around the IC classification panel with the keyboard. + + If this is happening to you, you may need to enable *Full + keyboard access* for the melodic classification panel to work + with keyboard navigation/focus. This setting can be changed + through *System Preferences* |right_arrow| *Keyboard* + |right_arrow| *Shortcuts*, and changing *Full Keyboard Access* to + *All controls*.