Source code for fsleyes.colourmaps

#
# colourmaps.py - Manage colour maps and lookup tables for overlay rendering.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module manages the colour maps and lookup tables available for overlay
rendering in *FSLeyes*.


The :func:`init` function must be called before any colour maps or lookup
tables can be accessed [*]_.


FSLeyes colour maps and lookup tables are stored in the following locations:


   - ``[assetsbase]/assets/colourmaps/``
   - ``[assetsbase]/assets/luts/``
   - ``[settingsbase]/colourmaps/``
   - ``[settingsbase]/luts/``


where ``[assetsbase]`` is the location of the FSLeyes assets directory (see
the :attr:`fsleyes.assetDir` attribute), and ``[settingsbase]`` is the base
directory use by the :mod:`fsl.utils.settings` module for storing FSLeyes
application settings and data. "Built-in" colour maps and lookup tables are
stored underneath ``[assetsbase]``, and user-added ones are stored under
``[settingsbase]``.


When :func:`init` is called, it searches in the above locations, and attempts
to load all files within which have the suffix ``.cmap`` or ``.lut``
respectively. If a user-added map file has the same name as a built-in map
file, the user-added one will override the built-in.


.. [*] Only the :func:`scanColourMaps` and :func:`scanLookupTables` functions
       may be called before :func:`init` is called.


-----------
Colour maps
-----------


A ``.cmap`` file defines a colour map which may be used to display a range of
intensity values - see the :attr:`.VolumeOpts.cmap` property for an example. A
``.cmap`` file must contain a list of RGB colours, one per line, with each
colour specified by three space-separated floating point values in the range
``0.0 - 1.0``, for example::


        1.000000 0.260217 0.000000
        0.000000 0.687239 1.000000
        0.738949 0.000000 1.000000


This list of RGB values is used to create a :class:`.ListedColormap` object,
which is then registered with the :mod:`matplotlib.cm` module (using the file
name prefix as the colour map name), and thus made available for rendering
purposes.


The following functions are available for managing and accessing colour maps:

.. autosummary::
   :nosignatures:

   scanColourMaps
   getColourMaps
   getColourMap
   getColourMapLabel
   getColourMapFile
   loadColourMapFile
   registerColourMap
   installColourMap
   isColourMapRegistered
   isColourMapInstalled


Display name and ordering
^^^^^^^^^^^^^^^^^^^^^^^^^


For built-in colour maps, a file named ``[assetsbase]/colourmaps/order.txt``
is assumed to contain a list of colour map names, and colour map identifiers,
defining the order in which the colour maps should be displayed to the
user. Any colour maps which are present in ``[assetsbase]/colourmaps/``, but
are not listed in the ``order.txt``, file will be appended to the end of the
list, and their name will be derived from the file name.


If a file ``[settingsbase]/colourmaps/order.txt`` exists, then it will be used
in place of the ``order.txt`` file in ``[assetsbase]``.


User-added colour maps
^^^^^^^^^^^^^^^^^^^^^^


An identifier and display name for all user-added colour maps are added to a
persistent setting called ``fsleyes.colourmaps``, which is a dictionary of ``{
cmapid : displayName }`` mappings. The ``cmapid`` is typically the display
name, converted to lower case, with spaces replaced with underscores. A
user-added colour map with id ``cmapid`` will be saved as
``[settingsbase]/colourmaps/cmapid.cmap``.  All user-added colour maps wll be
displayed after all built-in colour maps, and their order cannot be
customised. Any user-added colour map files which are not present in the
``fsleyes.colourmaps`` dictionary will be given a display name the same as
the colour map ID (which is taken from the file name).


Installing a user-added colour map is a two-step process:

 1. First, the colour map must be *registered*, via the
    :func:`registerColourMap` function. This adds the colour map to the
    register, but does not persist it beyond the current execution
    environment.

 2. Calling the :func:`installColourMap` function will add the colour map
    permanently.


-------------
Lookup tables
-------------


A ``.lut`` file defines a lookup table which may be used to display images
wherein each voxel has a discrete integer label. Each of the possible voxel
values such an image has an associated colour and name. Each line in a
``.lut`` file must specify the label value, RGB colour, and associated name.
The first column (where columns are space-separated) defines the label value,
the second to fourth columns specify the RGB values, and all remaining columns
give the label name. For example::


        1  0.00000 0.93333 0.00000 Frontal Pole
        2  0.62745 0.32157 0.17647 Insular Cortex
        3  1.00000 0.85490 0.72549 Superior Frontal Gyrus


This list of label, colour, and name mappings is used to create a
:class:`LookupTable` instance, which can be used to access the colours and
names associated with each label value.


Once created, ``LookupTable`` instances may be modified - labels can be
added/removed, and the name/colour of existing labels can be modified.  The
:func:`.installLookupTable` method will install a new lookup table, or save
any changes made to an existing one.

Built-in and user-added lookup tables are managed in the same manner as
described for colour maps above.  The following functions are available to
access and manage :class:`LookupTable` instances:

.. autosummary::
   :nosignatures:

   scanLookupTables
   getLookupTables
   getLookupTable
   getLookupTableFile
   loadLookupTableFile
   registerLookupTable
   installLookupTable
   isLookupTableRegistered
   isLookupTableInstalled


-------------
Miscellaneous
-------------


Some utility functions are also kept in this module.  These functions are used
for querying installed colour maps and lookup tables,

.. autosummary::
   :nosignatures:

   fileType
   getCmapDir
   getLutDir
   scanBuiltInCmaps
   scanBuiltInLuts
   scanUserAddedCmaps
   scanUserAddedLuts
   makeValidMapKey
   isValidMapKey

The following functions may be used for calculating the relationship between a
data display range and brightness/contrast scales, and generating/manipulating
colours:


.. autosummary::
   :nosignatures:

   displayRangeToBricon
   briconToDisplayRange
   briconToScaleOffset
   applyBricon
   randomColour
   randomBrightColour
   randomDarkColour
   complementaryColour
"""


import itertools as it
import functools as ft
import os.path   as op
import              os
import              bisect
import              string
import              logging
import              colorsys

from collections import OrderedDict

import          six
import numpy as np

import fsleyes_props      as props
import                       fsleyes
import fsl.utils.settings as fslsettings
import fsl.utils.notifier as notifier
import fsl.data.vest      as vest


log = logging.getLogger(__name__)


[docs]def getCmapDir(): """Returns the directory in which all built-in colour map files are stored. """ return op.join(fsleyes.assetDir, 'assets', 'colourmaps')
[docs]def getLutDir(): """Returns the directory in which all built-in lookup table files are stored. """ return op.join(fsleyes.assetDir, 'assets', 'luts')
[docs]def _walk(dirname, suffix): """Recursively searches ``dirname``, returning a list of all files with the specified ``suffix``. """ matches = [] for dirpath, dirnames, filenames in os.walk(dirname): for filename in filenames: if filename.endswith(suffix): matches.append(op.join(dirpath, filename)) return matches
[docs]def _find(mapid, dirname): """Finds the file associated with the given (built-in) colour map or lookup table ID. """ divs = [i for i, c in enumerate(mapid) if c == '_'] pathopts = it.chain(*[it.combinations(divs, n) for n in range(len(divs) + 1)]) for pathidxs in pathopts: path = mapid for i in pathidxs: path = path[:i] + op.sep + path[i + 1:] path = op.join(dirname, path) if op.exists(path): return path raise ValueError('Cannot find {} in {}'.format(mapid, dirname))
[docs]def scanBuiltInCmaps(): """Returns a list of IDs for all built-in colour maps. """ basedir = getCmapDir() cmapIDs = _walk(basedir, '.cmap') cmapIDs = [op.splitext(i)[0] for i in cmapIDs] cmapIDs = [op.relpath(i, basedir) for i in cmapIDs] cmapIDs = [i.replace(op.sep, '_') for i in cmapIDs] return list(sorted(cmapIDs))
[docs]def scanBuiltInLuts(): """Returns a list of IDs for all built-in lookup tables. """ basedir = getLutDir() lutIDs = _walk(basedir, '.lut') lutIDs = [op.splitext(i)[0] for i in lutIDs] lutIDs = [op.relpath(i, basedir) for i in lutIDs] lutIDs = [i.replace(op.sep, '_') for i in lutIDs] return list(sorted(lutIDs))
[docs]def scanUserAddedCmaps(): """Returns a list of IDs for all user-added colour maps. """ cmapFiles = fslsettings.listFiles('colourmaps/*.cmap') cmapFiles = [op.basename(f) for f in cmapFiles] cmapIDs = [op.splitext(f)[0] for f in cmapFiles] cmapIDs = [m.lower() for m in cmapIDs] return list(sorted(cmapIDs))
[docs]def scanUserAddedLuts(): """Returns a list of IDs for all user-added lookup tables. """ lutFiles = fslsettings.listFiles('luts/*.lut') lutFiles = [op.basename(f) for f in lutFiles] lutIDs = [op.splitext(f)[0] for f in lutFiles] lutIDs = [m.lower() for m in lutIDs] return list(sorted(lutIDs))
[docs]def makeValidMapKey(name): """Turns the given string into a valid key for use as a colour map or lookup table identifier. """ valid = string.ascii_lowercase + string.digits + '_-' key = name.lower().replace(' ', '_') key = ''.join([c for c in key if c in valid]) return key
[docs]def isValidMapKey(key): """Returns ``True`` if the given string is a valid key for use as a colour map or lookup table identifier, ``False`` otherwise. A valid key comprises lower case letters, numbers, underscores and hyphens. """ valid = string.ascii_lowercase + string.digits + '_-' return all([c in valid for c in key])
[docs]def scanColourMaps(): """Scans the colour maps directories, and returns a list containing the names of all colour maps contained within. This function may be called before :func:`init`. """ return scanBuiltInCmaps() + scanUserAddedCmaps()
[docs]def scanLookupTables(): """Scans the lookup tables directories, and returns a list containing the names of all lookup tables contained within. This function may be called before :func:`init`. """ return scanBuiltInLuts() + scanUserAddedLuts()
_cmaps = None """An ``OrderedDict`` which contains all registered colour maps as ``{key : _Map}`` mappings. """ _luts = None """An ``OrderedDict`` which contains all registered lookup tables as ``{key : _Map}`` mappings. """
[docs]def init(force=False): """This function must be called before any of the other functions in this module can be used. It initialises the colour map and lookup table registers, loading all built-in and user-added colour map and lookup table files that exist. :arg force: Forces the registers to be re-initialised, even if they have already been initialised. """ global _cmaps global _luts # Already initialised if not force and (_cmaps is not None) and (_luts is not None): return _cmaps = OrderedDict() _luts = OrderedDict() # Reads the order.txt file from the built-in # /colourmaps/ or /luts/ directory. This file # contains display names and defines the order # in which built-in maps should be displayed. def readOrderTxt(filename): maps = OrderedDict() if not op.exists(filename): return maps with open(filename, 'rt') as f: lines = f.read().split('\n') for line in lines: if line.strip() == '': continue # The order.txt file is assumed to # contain one row per cmap/lut, # where the first word is the key # (the cmap/lut file name prefix), # and the remainder of the line is # the cmap/lut name key, name = line.split(' ', 1) maps[key.strip()] = name.strip() return maps # Reads any display names that have been # defined for user-added colourmaps/luts # (mapType is either 'cmap' or 'lut'). def readDisplayNames(mapType): if mapType == 'cmap': key = 'fsleyes.colourmaps' elif mapType == 'lut': key = 'fsleyes.luts' return fslsettings.read(key, OrderedDict()) # Get all colour map/lut IDs and # paths to the cmap/lut files. We # process cmaps/luts in the same # way, so we loop over all of # these lists, doing colour maps # first, then luts second. mapTypes = ['cmap', 'lut'] builtinDirs = [getCmapDir(), getLutDir()] userDirs = ['colourmaps', 'luts'] allBuiltins = [scanBuiltInCmaps(), scanBuiltInLuts()] allUsers = [scanUserAddedCmaps(), scanUserAddedLuts()] registers = [_cmaps, _luts] for mapType, builtinDir, userDir, builtinIDs, userIDs, register in zip( mapTypes, builtinDirs, userDirs, allBuiltins, allUsers, registers): builtinFiles = ['{}.{}'.format(m, mapType) for m in builtinIDs] builtinFiles = [_find(m, builtinDir) for m in builtinFiles] userFiles = ['{}.{}'.format(m, mapType) for m in userIDs] userFiles = [op.join(userDir, m) for m in userFiles] userFiles = [fslsettings.filePath(m) for m in userFiles] allIDs = builtinIDs + userIDs allFiles = builtinFiles + userFiles allFiles = {mid : mfile for mid, mfile in zip(allIDs, allFiles)} # Read order/display names from order.txt - # if an order.txt file exists in the user # dir, it takes precednece over built-in # order/display names. # # User-added display names may also be in # fslsettings. Any user-added maps with # the same ID as a builtin will override # the builtin. builtinOrder = op.join(builtinDir, 'order.txt') userOrder = op.join(userDir, 'order.txt') if op.exists(userOrder): names = readOrderTxt(userOrder) else: names = readOrderTxt(builtinOrder) names.update(readDisplayNames(mapType)) # any maps which did not have a name # specified in order.txt (or, for # user-added maps, in fslsettings) # are added to the end of the list, # and their name is just set to the # ID (which is equal to the file name # prefix). for mid in allIDs: if mid not in names: names[mid] = mid # Now register all of those maps, # in the order defined by order.txt for mapID, mapName in names.items(): # The user-added {id:name} dict # might contain obsolete/invalid # names, so we ignore keyerrors try: mapFile = allFiles[mapID] except KeyError: continue try: kwargs = {'key' : mapID, 'name' : mapName} if mapType == 'cmap': registerColourMap( mapFile, **kwargs) elif mapType == 'lut': registerLookupTable(mapFile, **kwargs) register[mapID].installed = True register[mapID].mapObj.saved = True except Exception as e: log.warn('Error processing custom {} ' 'file {}: {}'.format(mapType, mapFile, str(e)), exc_info=True)
[docs]def registerColourMap(cmapFile, overlayList=None, displayCtx=None, key=None, name=None): """Loads RGB data from the given file, and registers it as a :mod:`matplotlib` :class:`~matplotlib.colors.ListedColormap` instance. .. note:: If the ``overlayList`` and ``displayContext`` arguments are provided, the ``cmap`` property of all :class:`.VolumeOpts` instances are updated to support the new colour map. :arg cmapFile: Name of a file containing RGB values :arg overlayList: A :class:`.OverlayList` instance which contains all overlays that are being displayed (can be ``None``). :arg displayCtx: A :class:`.DisplayContext` instance describing how the overlays in ``overlayList`` are being displayed. Must be provided if ``overlayList`` is provided. :arg key: Name to give the colour map. If ``None``, defaults to the file name prefix. :arg name: Display name for the colour map. If ``None``, defaults to the ``name``. :returns: The key that the ``ColourMap`` was registered under. """ import matplotlib.cm as mplcm import matplotlib.colors as colors if key is not None and not isValidMapKey(key): raise ValueError('{} is not a valid colour map identifier'.format(key)) if key is None: key = op.basename(cmapFile).split('.')[0] key = makeValidMapKey(key) if name is None: name = key if overlayList is None: overlayList = [] data = loadColourMapFile(cmapFile) cmap = colors.ListedColormap(data, key) log.debug('Loading and registering custom ' 'colour map: {}'.format(cmapFile)) mplcm.register_cmap(key, cmap) _cmaps[key] = _Map(key, name, cmap, cmapFile, False) log.debug('Patching DisplayOpts instances and class ' 'to support new colour map {}'.format(key)) import fsleyes.displaycontext as fsldisplay # A list of all DisplayOpts colour map properties. # n.b. We can't simply list the ColourMapOpts class # here, because it is a mixin, and does not actually # derive from props.HasProperties. # # TODO Any new DisplayOpts sub-types which have a # colour map will need to be patched here cmapProps = [] cmapProps.append((fsldisplay.VolumeOpts, 'cmap')) cmapProps.append((fsldisplay.VolumeOpts, 'negativeCmap')) cmapProps.append((fsldisplay.VectorOpts, 'cmap')) cmapProps.append((fsldisplay.MeshOpts, 'cmap')) cmapProps.append((fsldisplay.MeshOpts, 'negativeCmap')) # Update the colour map properties # for any existing instances for overlay in overlayList: opts = displayCtx.getOpts(overlay) for cls, propName in cmapProps: if isinstance(opts, cls): prop = opts.getProp(propName) prop.addColourMap(key, opts) # and for all future overlays for cls, propName in cmapProps: prop = cls.getProp(propName) prop.addColourMap(key) return key
[docs]def registerLookupTable(lut, overlayList=None, displayCtx=None, key=None, name=None): """Registers the given ``LookupTable`` instance (if ``lut`` is a string, it is assumed to be the name of a ``.lut`` file, which is loaded). .. note:: If the ``overlayList`` and ``displayContext`` arguments are provided, the ``lut`` property of all :class:`.LabelOpts` instances are updated to support the new lookup table. :arg lut: A :class:`LookupTable` instance, or the name of a ``.lut`` file. :arg overlayList: A :class:`.OverlayList` instance which contains all overlays that are being displayed (can be ``None``). :arg displayCtx: A :class:`.DisplayContext` instance describing how the overlays in ``overlayList`` are being displayed. Must be provided if ``overlayList`` is provided. :arg key: Name to give the lookup table. If ``None``, defaults to the file name prefix. :arg name: Display name for the lookup table. If ``None``, defaults to the ``name``. :returns: The :class:`LookupTable` object """ if isinstance(lut, six.string_types): lutFile = lut else: lutFile = None if overlayList is None: overlayList = [] # lut may be either a file name # or a LookupTable instance if lutFile is not None: if key is None: key = op.basename(lutFile).split('.')[0] key = makeValidMapKey(key) if name is None: name = key log.debug('Loading and registering custom ' 'lookup table: {}'.format(lutFile)) lut = LookupTable(key, name, lutFile) else: if key is None: key = lut.key if name is None: name = lut.name lut.key = key lut.name = name # Even though the lut may have been loaded from # a file, it has not necessarily been installed lut.saved = False _luts[key] = _Map(key, name, lut, lutFile, False) log.debug('Patching LabelOpts classes to support ' 'new LookupTable {}'.format(key)) import fsleyes.displaycontext as fsldisplay # See similar situation in the registerColourMap # function above. All DisplayOpts classes which # have a lut property (assumed to be a props.Choice) # must have the new LUT added as an option. lutProps = [] lutProps.append((fsldisplay.LabelOpts, 'lut')) lutProps.append((fsldisplay.MeshOpts, 'lut')) # Update the lut property for # any existing label overlays for overlay in overlayList: opts = displayCtx.getOpts(overlay) for cls, propName in lutProps: if isinstance(opts, cls): prop = opts.getProp(propName) prop.addChoice(lut, alternate=list(set((lut.name, key))), instance=opts) # and for any future label overlays for cls, propName in lutProps: prop = cls.getProp(propName) prop.addChoice(lut, alternate=list(set((lut.name, key)))) return lut
[docs]def getLookupTables(): """Returns a list containing all available lookup tables.""" return [_luts[lutName].mapObj for lutName in _luts.keys()]
[docs]def getLookupTable(key): """Returns the :class:`LookupTable` instance of the specified key/ID.""" return _caseInsensitiveLookup(_luts, key).mapObj
[docs]def getColourMaps(): """Returns a list containing the names of all available colour maps.""" return list(_cmaps.keys())
[docs]def getColourMap(key): """Returns the colour map instance of the specified key.""" return _caseInsensitiveLookup(_cmaps, key).mapObj
[docs]def getColourMapLabel(key): """Returns a label/display name for the specified colour map. """ return _caseInsensitiveLookup(_cmaps, key).name
[docs]def getColourMapFile(key): """Returns the file associated with the specified colour map, or ``None`` if the colour map is registered but not installed. """ return _cmaps[key].mapFile
[docs]def getLookupTableFile(key): """Returns the file associated with the specified lookup table, or ``None`` if the lookup table is registered but not installed. """ return _luts[key].mapFile
[docs]def isColourMapRegistered(key): """Returns ``True`` if the specified colourmap is registered, ``False`` otherwise. """ return key in _cmaps
[docs]def isLookupTableRegistered(key): """Returns ``True`` if the specified lookup table is registered, ``False`` otherwise. """ return key in _luts
[docs]def isColourMapInstalled(key): """Returns ``True`` if the specified colourmap is installed, ``False`` otherwise. A :exc:`KeyError` is raised if the colourmap is not registered. """ return _cmaps[key].installed
[docs]def isLookupTableInstalled(key): """Returns ``True`` if the specified loolup table is installed, ``False`` otherwise. A :exc:`KeyError` is raised if the lookup tabler is not registered. """ return _luts[key].installed
[docs]def installColourMap(key): """Attempts to install a previously registered colourmap into the ``[settingsbase]/colourmaps/`` directory. """ # keyerror if not registered cmap = _cmaps[key] # TODO I think the colors attribute is only # available on ListedColormap instances, # so if you ever start using different # mpl types, you might need to revisit # this. data = cmap.mapObj.colors destFile = op.join('colourmaps', '{}.cmap'.format(key)) log.debug('Installing colour map {} to {}'.format(key, destFile)) # Numpy under python 3 will break if # we give it a file opened with mode='wt'. with fslsettings.writeFile(destFile, mode='b') as f: np.savetxt(f, data, '%0.6f') # Update user-added settings cmapNames = fslsettings.read('fsleyes.colourmaps', OrderedDict()) cmapNames[key] = cmap.name fslsettings.write('fsleyes.colourmaps', cmapNames) cmap.installed = True
[docs]def installLookupTable(key): """Attempts to install/save a previously registered lookup table into the ``[settingsbase]/luts`` directory. """ # keyerror if not registered lut = _luts[key] destFile = op.join('luts', '{}.lut'.format(key)) destFile = fslsettings.filePath(destFile) destDir = op.dirname(destFile) log.debug('Installing lookup table {} to {}'.format(key, destFile)) if not op.exists(destDir): os.makedirs(destDir) lut.mapObj.save(destFile) # Update user-added settings lutNames = fslsettings.read('fsleyes.luts', OrderedDict()) lutNames[key] = lut.name fslsettings.write('fsleyes.luts', lutNames) lut.mapFile = destFile lut.installed = True
########## # File I/O ##########
[docs]def fileType(fname): """Attempts to guess the type of ``fname``. ``fname`` is assumed to be a FSLeyes colour map or lookup table file, or a FSLView-style VEST lookup table file. A ``ValueError`` is raised if the file type cannot be determined. :arg fname: Name of file to check :returns: One of ``'vest'``, ``'cmap'``, or ``'lut'``, depending on what the contents of ``fname`` look like. """ if vest.looksLikeVestLutFile(fname): return 'vest' with open(fname, 'rt') as f: for line in f: line = f.readline().strip() if (line != '') and (not line.startswith('#')): break tkns = list(line.split()) # cmap file if len(tkns) == 3: try: [float(t) for t in tkns] return 'cmap' except ValueError: pass # lut file elif len(tkns) >= 4: try: [float(t) for t in tkns[:4]] return 'lut' except ValueError: pass raise ValueError('Cannot determine type of {}'.format(fname))
[docs]def loadColourMapFile(fname, aslut=False): """Load the given file, assumed to be a colour map. :arg fname: FSLeyes or FSLView (VEST) colour map file :arg aslut: If ``True``, the returned array will contain a label for each colour, ranging from ``1`` to ``N``, where ``N`` is the number of colours in the file. :returns: A ``numpy`` array of shape ``(N, 3)`` containing the RGB values for ``N`` colours. Or, if ``aslut is True``, A ``numpy`` array of shape ``(N, 4)`` containing a label, and the RGB values for ``N`` colours. """ # The file could be a FSLView style VEST LUT if vest.looksLikeVestLutFile(fname): data = vest.loadVestLutFile(fname, normalise=False) # Or just a plain 2D text array else: data = np.loadtxt(fname) if aslut: lbls = np.arange(1, data.shape[0] + 1).reshape(-1, 1) data = np.hstack((lbls, data)) return data
[docs]def loadLookupTableFile(fname): """Loads the given file, assumed to be a lookup table. :arg fname: Name of a FSLeyes lookup table file. :returns: A tuple containing: - A ``numpy`` array of shape ``(N, 4)`` containing the label and RGB values for ``N`` colours. - A list containin the name for each label .. note:: The provided file may also be a colour map file (see :func:`loadColourMapFile`), in which case the labels will range from ``1`` to ``N``, and the names will be strings containing the label values. """ # Accept cmap file, auto-generate labels/names if fileType(fname) in ('vest', 'cmap'): lut = loadColourMapFile(fname, aslut=True) names = ['{}'.format(int(l)) for l in lut[:, 0]] return lut, names # Otherwise assume a FSLeyes lut file with open(fname, 'rt') as f: # Read the LUT label/colours on # a first pass through the file struclut = np.genfromtxt( f, usecols=(0, 1, 2, 3), dtype={ 'formats' : (np.int, np.float, np.float, np.float), 'names' : ('label', 'r', 'g', 'b')}) # Save the label ordering - # we'll use it below order = struclut.argsort(order='label') # Convert from a structured # array into a regular array lut = np.zeros((len(struclut), 4), dtype=np.float32) lut[:, 0] = struclut['label'] lut[:, 1] = struclut['r'] lut[:, 2] = struclut['g'] lut[:, 3] = struclut['b'] # Read the names on a second pass f.seek(0) names = [] for i, line in enumerate(f): tkns = line.split(None, 4) if len(tkns) < 5: name = '{}'.format(int(lut[i, 0])) else: name = tkns[4].strip() names.append(name) # Sort by ascending label value lut = lut[order, :] names = [names[o] for o in order] return lut, names
############### # Miscellaneous ###############
[docs]def briconToScaleOffset(brightness, contrast, drange): """Used by the :func:`briconToDisplayRange` and the :func:`applyBricon` functions. Calculates a scale and offset which can be used to transform a display range of the given size so that the given brightness/contrast settings are applied. :arg brightness: Brightness, between 0.0 and 1.0. :arg contrast: Contrast, between 0.0 and 1.0. :arg drange: Data range. """ # The brightness is applied as a linear offset, # with 0.5 equivalent to an offset of 0.0. offset = (brightness * 2 - 1) * drange # If the contrast lies between 0.0 and 0.5, it is # applied to the colour as a linear scaling factor. if contrast <= 0.5: scale = contrast * 2 # If the contrast lies between 0.5 and 1, it # is applied as an exponential scaling factor, # so lower values (closer to 0.5) have less of # an effect than higher values (closer to 1.0). else: scale = 20 * contrast ** 4 - 0.25 return scale, offset
[docs]def briconToDisplayRange(dataRange, brightness, contrast): """Converts the given brightness/contrast values to a display range, given the data range. :arg dataRange: The full range of the data being displayed, a (min, max) tuple. :arg brightness: A brightness value between 0 and 1. :arg contrast: A contrast value between 0 and 1. """ # Turn the given bricon values into # values between 1 and 0 (inverted) brightness = 1.0 - brightness contrast = 1.0 - contrast dmin, dmax = dataRange drange = dmax - dmin dmid = dmin + 0.5 * drange scale, offset = briconToScaleOffset(brightness, contrast, drange) # Calculate the new display range, keeping it # centered in the middle of the data range # (but offset according to the brightness) dlo = (dmid + offset) - 0.5 * drange * scale dhi = (dmid + offset) + 0.5 * drange * scale return dlo, dhi
[docs]def displayRangeToBricon(dataRange, displayRange): """Converts the given brightness/contrast values to a display range, given the data range. :arg dataRange: The full range of the data being displayed, a (min, max) tuple. :arg displayRange: A (min, max) tuple containing the display range. """ dmin, dmax = dataRange dlo, dhi = displayRange drange = dmax - dmin dmid = dmin + 0.5 * drange if drange == 0: return 0, 0 # These are inversions of the equations in # the briconToScaleOffset function above, # which calculate the display ranges from # the bricon offset/scale offset = dlo + 0.5 * (dhi - dlo) - dmid scale = (dhi - dlo) / drange brightness = 0.5 * (offset / drange + 1) if scale <= 1: contrast = scale / 2.0 else: contrast = ((scale + 0.25) / 20.0) ** 0.25 brightness = 1.0 - brightness contrast = 1.0 - contrast return brightness, contrast
[docs]def applyBricon(rgb, brightness, contrast): """Applies the given ``brightness`` and ``contrast`` levels to the given ``rgb`` colour(s). Passing in ``0.5`` for both the ``brightness`` and ``contrast`` will result in the colour being returned unchanged. :arg rgb: A sequence of three or four floating point numbers in the range ``[0, 1]`` specifying an RGB(A) value, or a :mod:`numpy` array of shape ``(n, 3)`` or ``(n, 4)`` specifying ``n`` colours. If alpha values are passed in, they are returned unchanged. :arg brightness: A brightness level in the range ``[0, 1]``. :arg contrast: A contrast level in the range ``[0, 1]``. """ rgb = np.array(rgb) oneColour = len(rgb.shape) == 1 rgb = rgb.reshape(-1, rgb.shape[-1]) scale, offset = briconToScaleOffset(brightness, contrast, 1) # The contrast factor scales the existing colour # range, but keeps the new range centred at 0.5. rgb[:, :3] += offset rgb[:, :3] = np.clip(rgb[:, :3], 0.0, 1.0) rgb[:, :3] = (rgb[:, :3] - 0.5) * scale + 0.5 rgb[:, :3] = np.clip(rgb[:, :3], 0.0, 1.0) if oneColour: return rgb[0] else: return rgb
[docs]def randomColour(): """Generates a random RGB colour. """ values = [randomColour.random.rand() for i in range(3)] return np.array(values)
# The randomColour function uses a generator # with a fixed seed for reproducibility randomColour.random = np.random.RandomState(seed=1)
[docs]def randomBrightColour(): """Generates a random saturated RGB colour. """ colour = randomColour() colour[colour.argmax()] = 1 colour[colour.argmin()] = 0 randomColour.random.shuffle(colour) return colour
[docs]def randomDarkColour(): """Generates a random saturated and darkened RGB colour.""" return applyBricon(randomBrightColour(), 0.35, 0.5)
[docs]def complementaryColour(rgb): """Generate a colour which can be used as a complement/opposite to the given colour. If the given ``rgb`` sequence contains four values, the fourth value (e.g. alpha) is returned unchanged. """ if len(rgb) >= 4: a = list(rgb[3:]) rgb = list(rgb[:3]) else: a = [] rgb = list(rgb) h, l, s = colorsys.rgb_to_hls(*rgb) # My ad-hoc complementary colour calculation: # create a new colour with the opposite hue # and opposite lightness, but the same saturation. nh = 1.0 - h nl = 1.0 - l ns = s # If the two colours have similar lightness # (according to some arbitrary threshold), # force the new one to have a different # lightness if abs(nl - l) < 0.3: if l > 0.5: nl = 0.0 else: nl = 1.0 nr, ng, nb = colorsys.hls_to_rgb(nh, nl, ns) return [nr, ng, nb] + a
[docs]def _caseInsensitiveLookup(d, k, default=None): """Performs a case-insensitive lookup on the dictionary ``d``, with the key ``k``. This function is used to allow case-insensitive retrieval of colour maps and lookup tables. """ v = d.get(k, None) if v is not None: return v keys = list(d.keys()) lKeys = [k.lower() for k in keys] try: idx = lKeys.index(k.lower()) except Exception: if default is not None: return default else: raise KeyError(k) return d[keys[idx]]
[docs]class _Map(object): """A little class for storing details on registered colour maps and lookup tables. This class is only used internally. """
[docs] def __init__(self, key, name, mapObj, mapFile, installed): """Create a ``_Map``. :arg key: The identifier name of the colour map/lookup table, which must be passed to the :func:`getColourMap` and :func:`getLookupTable` functions to look up this map object. :arg name: The display name of the colour map/lookup table. :arg mapObj: The colourmap/lut object, either a :class:`matplotlib.colors..Colormap`, or a :class:`LookupTable` instance. :arg mapFile: The file from which this map was loaded, or ``None`` if this cmap/lookup table only exists in memory, or is a built in :mod:`matplotlib` colourmap. :arg installed: ``True`` if this is a built in :mod:`matplotlib` colourmap or is installed in the ``fsleyes/colourmaps/`` or ``fsleyes/luts/`` directory, ``False`` otherwise. """ self.key = key self.name = name self.mapObj = mapObj self.mapFile = mapFile self.installed = installed
[docs] def __str__(self): """Returns a string representation of this ``_Map``. """ if self.mapFile is not None: return self.mapFile else: return self.key
[docs] def __repr__(self): """Returns a string representation of this ``_Map``. """ return self.__str__()
[docs]class LutLabel(props.HasProperties): """This class represents a mapping from a value to a colour and name. ``LutLabel`` instances are created and managed by :class:`LookupTable` instances. Listeners may be registered on the :attr:`name`, :attr:`colour`, and :attr:`enabled` properties to be notified when they change. """ name = props.String(default='Label') """The display name for this label. Internally (for comparison), the :meth:`internalName` is used, which is simply this name, converted to lower case. """ colour = props.Colour(default=(0, 0, 0)) """The colour for this label. """ enabled = props.Boolean(default=True) """Whether this label is currently enabled or disabled. """
[docs] def __init__(self, value, name=None, colour=None, enabled=None): """Create a ``LutLabel``. :arg value: The label value. :arg name: The label name. :arg colour: The label colour. :arg enabled: Whether the label is enabled/disabled. """ if value is None: raise ValueError('LutLabel value cannot be None') if name is None: name = LutLabel.getProp('name').getAttribute(None, 'default') if colour is None: colour = LutLabel.getProp('colour').getAttribute(None, 'default') if enabled is None: enabled = LutLabel.getProp('enabled').getAttribute(None, 'default') self.__value = value self.name = name self.colour = colour self.enabled = enabled
@property def value(self): """Returns the value of this ``LutLabel``. """ return self.__value @property def internalName(self): """Returns the *internal* name of this ``LutLabel``, which is just its :attr:`name`, converted to lower-case. This is used by :meth:`__eq__` and :meth:`__hash__`, and by the :class:`LookupTable` class. """ return self.name.lower()
[docs] def __eq__(self, other): """Equality operator - returns ``True`` if this ``LutLabel`` has the same value as the given one. """ return self.value == other.value
[docs] def __lt__(self, other): """Less-than operator - compares two ``LutLabel`` instances based on their value. """ return self.value < other.value
[docs] def __hash__(self): """The hash of a ``LutLabel`` is a combination of its value, name, and colour, but not its enabled state. """ return hash( self.value) ^ \ hash( self.internalName) ^ \ hash(tuple(self.colour))
[docs] def __str__(self): """Returns a string representation of this ``LutLabel``.""" return '{}: {} / {} ({})'.format(self.value, self.internalName, self.colour, self.enabled)
[docs] def __repr__(self): """Returns a string representation of this ``LutLabel``.""" return self.__str__()
[docs]class LookupTable(notifier.Notifier): """A ``LookupTable`` encapsulates a list of label values and associated colours and names, defining a lookup table to be used for colouring label images. A label value typically corresponds to an anatomical region (as in e.g. atlas images), or a classification (as in e.g. white/grey matter/csf segmentations). The label values, and their associated names/colours, in a ``LookupTable`` are stored in ``LutLabel`` instances, ordered by their value in ascending order. These are accessible by label value via the :meth:`get` method, by index, by directly indexing the ``LookupTable`` instance, or by name, via the :meth:`getByName` method. New label values can be added via the :meth:`insert` and :meth:`new` methods. Label values can be removed via the meth:`delete` method. *Notifications* The ``LookupTable`` class implements the :class:`.Notifier` interface. If you need to be notified when a ``LookupTable`` changes, you may register to be notified on the following topics: =========== ==================================================== *Topic* *Meaning* ``label`` The properties of a :class:`.LutLabel` have changed. ``saved`` The saved state of this ``LookupTable`` has changed. ``added`` A new ``LutLabel`` has been added. ``removed`` A ``LutLabel`` has been removed. =========== ==================================================== """
[docs] def __init__(self, key, name, lutFile=None): """Create a ``LookupTable``. :arg key: The identifier for this ``LookupTable``. Must be a valid key (see :func:`isValidMapKey`). :arg name: The display name for this ``LookupTable``. :arg lutFile: A file to load lookup table label values, names, and colours from. If ``None``, this ``LookupTable`` will be empty - labels can be added with the :meth:`new` or :meth:`insert` methods. """ if not isValidMapKey(key): raise ValueError('{} is not a valid lut identifier'.format(key)) self.key = key self.name = name self.__labels = [] self.__name = 'LookupTable({})_{}'.format(self.name, id(self)) # The LUT is loaded now, but parsed # lazily on first access self.__saved = False self.__parsed = False self.__toParse = None if lutFile is not None: self.__toParse = loadLookupTableFile(lutFile) self.__saved = True
[docs] def lazyparse(func): """Decorator which is used to lazy-parse the LUT file only when it is first needed. """ def wrapper(self, *args, **kwargs): if not self.__parsed and self.__toParse is not None: self.__parse(*self.__toParse) self.__toParse = None self.__parsed = True return func(self, *args, **kwargs) return ft.update_wrapper(wrapper, func)
[docs] def __str__(self): """Returns the name of this ``LookupTable``. """ return self.name
[docs] def __repr__(self): """Returns the name of this ``LookupTable``. """ return self.name
[docs] @lazyparse def __len__(self): """Returns the number of labels in this ``LookupTable``. """ return len(self.__labels)
[docs] @lazyparse def __getitem__(self, i): """Access the ``LutLabel`` at index ``i``. Use the :meth:`get` method to determine the index of a ``LutLabel`` from its value. """ return self.__labels[i]
[docs] @lazyparse def max(self): """Returns the maximum current label value in this ``LookupTable``. """ if len(self.__labels) == 0: return 0 else: return self.__labels[-1].value
@property def saved(self): """Returns ``True`` if this ``LookupTable`` is registered and saved, ``False`` if it is not registered, or has been modified. """ return self.__saved @saved.setter def saved(self, val): """Change the saved state of this ``LookupTable``, and trigger notification on the ``saved`` topic. This property should not be set outside of this module. """ self.__saved = val self.notify(topic='saved')
[docs] @lazyparse def index(self, value): """Returns the index in this ``LookupTable`` of the ``LutLabel`` with the specified value. Raises a :exc:`ValueError` if no ``LutLabel`` with this value is present. .. note:: The ``value`` which is passed in can be either an integer specifying the label value, or a ``LutLabel`` instance. """ if not isinstance(value, LutLabel): value = LutLabel(value) return self.__labels.index(value)
[docs] @lazyparse def labels(self): """Returns an iterator over all :class:`LutLabel` instances in this ``LookupTable``. """ return iter(self.__labels)
[docs] @lazyparse def get(self, value): """Returns the :class:`LutLabel` instance associated with the given ``value``, or ``None`` if there is no label. """ try: return self.__labels[self.index(value)] except ValueError: return None
[docs] @lazyparse def getByName(self, name): """Returns the :class:`LutLabel` instance associated with the given ``name``, or ``None`` if there is no ``LutLabel``. The name comparison is case-insensitive. """ name = name.lower() for i, ll in enumerate(self.__labels): if ll.internalName == name: return ll return None
[docs] @lazyparse def new(self, name=None, colour=None, enabled=None): """Add a new :class:`LutLabel` with value ``max() + 1``, and add it to this ``LookupTable``. """ return self.insert(self.max() + 1, name, colour, enabled)
[docs] @lazyparse def insert(self, value, name=None, colour=None, enabled=None): """Create a new :class:`LutLabel` associated with the given ``value`` and insert it into this ``LookupTable``. Internally, the labels are stored in ascending (by value) order. :returns: The newly created ``LutLabel`` instance. """ if not isinstance(value, six.integer_types + (np.integer,)) or \ value < 0 or value > 65535: raise ValueError('Lookup table values must be ' '16 bit unsigned integers ({}: {}).'.format( type(value), value)) if self.get(value) is not None: raise ValueError('Value {} is already in ' 'lookup table'.format(value)) label = LutLabel(value, name, colour, enabled) label.addGlobalListener(self.__name, self.__labelChanged) idx = bisect.bisect(self.__labels, label) self.__labels.insert(idx, label) self.saved = False self.notify(topic='added', value=(label, idx)) return label
[docs] @lazyparse def delete(self, value): """Removes the label with the given value from the lookup table. Raises a :exc:`ValueError` if no label with the given value is present. """ idx = self.index(value) label = self.__labels.pop(idx) label.removeGlobalListener(self.__name) self.notify(topic='removed', value=(label, idx)) self.saved = False
[docs] @lazyparse def save(self, lutFile): """Saves this ``LookupTable`` instance to the specified ``lutFile``. """ with open(lutFile, 'wt') as f: for label in self: value = label.value colour = label.colour name = label.name tkns = [value, colour[0], colour[1], colour[2], name] line = ' '.join(map(str, tkns)) f.write('{}\n'.format(line)) self.saved = True
def __parse(self, lut, names): """Parses ``lut``, a numpy array containing a LUT. """ labels = [LutLabel(int(l), name, (r, g, b), l > 0) for ((l, r, g, b), name) in zip(lut, names)] self.__labels = labels for label in labels: label.addGlobalListener(self.__name, self.__labelChanged) def __labelChanged(self, value, valid, label, propName): """Called when the properties of any ``LutLabel`` change. Triggers notification on the ``label`` topic. """ if propName in ('name', 'colour'): self.saved = False self.notify(topic='label', value=(label, self.index(label)))