diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 230bf25f33ff3e7070dfed5bbf7363ac8fc5489b..704338c9a69de70461a80f80655ed27201bc0518 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,14 +2,21 @@ This document contains the ``fslpy`` release history in reverse chronological order. -2.3.0 (Under development) -------------------------- +2.3.0 (Tuesday June 25th 2019) +------------------------------ Added ^^^^^ +* New :class:`.Bitmap` class, for loading bitmap images. The + :meth:`.Bitmap.asImage` method can be used to convert a ``Bitmap`` into + an :class:`.Image`. +* The :class:`.Image` class now has support for the ``RGB24`` and ``RGBA32`` + NIfTI data types. +* New :attr:`.Image.nvals` property, for use with ``RGB24``/``RGBA32`` + images. * New :meth:`.LabelAtlas.get` and :meth:`ProbabilisticAtlas.get` methods, which return an :class:`.Image` for a specific region. * The :meth:`.AtlasDescription.find` method also now a ``name`` parameter, @@ -24,6 +31,7 @@ Fixed * The :func:`.makeWriteable` function will always create a copy of an ``array`` if its base is a ``bytes`` object. +* Fixed a bug in the :meth:`.GitfitMesh.loadVertices` method. 2.2.0 (Wednesday May 8th 2019) diff --git a/README.rst b/README.rst index f4e6d3c31cc50228c8b81c0b09831fef81b1c73c..fb803e03f98b36837d7a739513a37cd432f98476 100644 --- a/README.rst +++ b/README.rst @@ -57,6 +57,9 @@ Some extra dependencies are listed in `requirements.txt class has some methods which use ``trimesh`` to perform geometric queries on the mesh. +- ``Pillow``: The `fsl.data.bitmap.Bitmap <fsl/data/bitmap.py`_ class uses + ``Pillow`` to load image files. + If you are using Linux, you need to install wxPython first, as binaries are not available on PyPI. Install wxPython like so, changing the URL for your diff --git a/doc/fsl.data.bitmap.rst b/doc/fsl.data.bitmap.rst new file mode 100644 index 0000000000000000000000000000000000000000..bf736530fde52bff10d228cd8076953b4956e402 --- /dev/null +++ b/doc/fsl.data.bitmap.rst @@ -0,0 +1,7 @@ +``fsl.data.bitmap`` +=================== + +.. automodule:: fsl.data.bitmap + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/fsl.data.rst b/doc/fsl.data.rst index 576f8f4e46fe24a5bd5a79adeae440388d08d720..ec1e73eda4a17296f19b067444e62d9630f7a17b 100644 --- a/doc/fsl.data.rst +++ b/doc/fsl.data.rst @@ -5,6 +5,7 @@ :hidden: fsl.data.atlases + fsl.data.bitmap fsl.data.constants fsl.data.dicom fsl.data.dtifit diff --git a/fsl/data/bitmap.py b/fsl/data/bitmap.py new file mode 100644 index 0000000000000000000000000000000000000000..ec7b6b1fe1c9e12c5fa8e52c6e52589f1be3281c --- /dev/null +++ b/fsl/data/bitmap.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python +# +# bitmap.py - The Bitmap class +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# +"""This module contains the :class:`Bitmap` class, for loading bitmap image +files. Pillow is required to use the ``Bitmap`` class. +""" + + +import os.path as op +import logging +import six + +import numpy as np + +from . import image as fslimage + + +log = logging.getLogger(__name__) + + +BITMAP_EXTENSIONS = ['.bmp', '.png', '.jpg', '.jpeg', + '.tif', '.tiff', '.gif', '.rgba'] +"""File extensions we understand. """ + + +BITMAP_DESCRIPTIONS = [ + 'Bitmap', + 'Portable Network Graphics', + 'JPEG', + 'JPEG', + 'TIFF', + 'TIFF', + 'Graphics Interchange Format', + 'Raw RGBA'] +"""A description for each :attr:`BITMAP_EXTENSION`. """ + + +class Bitmap(object): + """The ``Bitmap`` class can be used to load a bitmap image. The + :meth:`asImage` method will convert the bitmap into an :class:`.Image` + instance. + """ + + def __init__(self, bmp): + """Create a ``Bitmap``. + + :arg bmp: File name of an image, or a ``numpy`` array containing image + data. + """ + + try: + import PIL.Image as Image + except ImportError: + raise RuntimeError('Install Pillow to use the Bitmap class') + + if isinstance(bmp, six.string_types): + source = bmp + data = np.array(Image.open(source)) + + elif isinstance(bmp, np.ndarray): + source = 'array' + data = np.copy(bmp) + + else: + raise ValueError('unknown bitmap: {}'.format(bmp)) + + # Make the array (w, h, c) + data = np.fliplr(data.transpose((1, 0, 2))) + data = np.array(data, dtype=np.uint8, order='C') + w, h = data.shape[:2] + + self.__data = data + self.__dataSource = source + self.__name = op.basename(source) + + + def __hash__(self): + """Returns a number which uniquely idenfities this ``Bitmap`` instance + (the result of ``id(self)``). + """ + return id(self) + + + def __str__(self): + """Return a string representation of this ``Bitmap`` instance.""" + return '{}({}, {})'.format(self.__class__.__name__, + self.dataSource, + self.shape) + + + def __repr__(self): + """See the :meth:`__str__` method. """ + return self.__str__() + + + @property + def name(self): + """Returns the name of this ``Bitmap``, typically the base name of the + file. + """ + return self.__name + + + @property + def dataSource(self): + """Returns the bitmap data source - typically the file name. """ + return self.__dataSource + + + @property + def data(self): + """Convenience method which returns the bitmap data as a ``(w, h, c)`` + array, where ``c`` is either 3 or 4. + """ + return self.__data + + + @property + def shape(self): + """Returns the bitmap shape - ``(width, height, nchannels)``. """ + return self.__data.shape + + + def asImage(self): + """Convert this ``Bitmap`` into an :class:`.Image` instance. """ + + width, height, nchannels = self.shape + + if nchannels == 1: + dtype = np.uint8 + + + elif nchannels == 3: + dtype = np.dtype([('R', 'uint8'), + ('G', 'uint8'), + ('B', 'uint8')]) + + elif nchannels == 4: + dtype = np.dtype([('R', 'uint8'), + ('G', 'uint8'), + ('B', 'uint8'), + ('A', 'uint8')]) + + else: + raise ValueError('Cannot convert bitmap with {} ' + 'channels into nifti image'.format(nchannels)) + + if nchannels == 1: + data = self.data.reshape((width, height)) + + else: + data = np.zeros((width, height), dtype=dtype) + for ci, ch in enumerate(dtype.names): + data[ch] = self.data[..., ci] + + data = np.array(data, order='F', copy=False) + + return fslimage.Image(data, name=self.name) diff --git a/fsl/data/gifti.py b/fsl/data/gifti.py index aa8b20ed7b8cedf7a168c901458f0aa85e8d770e..2d1f3856d5ddae797b271a4cf79c4b14a12faf8b 100644 --- a/fsl/data/gifti.py +++ b/fsl/data/gifti.py @@ -138,7 +138,10 @@ class GiftiMesh(fslmesh.Mesh): surfimg, _, vertices, _ = loadGiftiMesh(infile) - vertices = self.addVertices(vertices, key, *args, **kwargs) + for i, v in enumerate(vertices): + if i == 0: key = infile + else: key = '{}_{}'.format(infile, i) + vertices[i] = self.addVertices(v, key, *args, **kwargs) self.setMeta(infile, surfimg) diff --git a/fsl/data/image.py b/fsl/data/image.py index 651992c4dbc3401a5af75ee3e4b59ea2f153fc8e..417a0445ceccd64f6bcc28d7abc1d2214c2de32d 100644 --- a/fsl/data/image.py +++ b/fsl/data/image.py @@ -1025,6 +1025,18 @@ class Image(Nifti): return self[tuple(coords)].dtype + @property + def nvals(self): + """Returns the number of values per voxel in this image. This will + usually be 1, but may be 3 or 4, for images of type + ``NIFTI_TYPE_RGB24`` or ``NIFTI_TYPE_RGBA32``. + """ + + nvals = len(self.dtype) + if nvals == 0: return 1 + else: return nvals + + @Nifti.voxToWorldMat.setter def voxToWorldMat(self, xform): """Overrides the :meth:`Nifti.voxToWorldMat` property setter. diff --git a/fsl/data/imagewrapper.py b/fsl/data/imagewrapper.py index 2b49e4af594268f49a48a5dc280c323bc45d8eda..8afcccacf7ad3e9b4e6ce035aabffa6393d4cf70 100644 --- a/fsl/data/imagewrapper.py +++ b/fsl/data/imagewrapper.py @@ -190,10 +190,10 @@ class ImageWrapper(notifier.Notifier): if d == 1: self.__numRealDims -= 1 else: break - # Degenerate case - if every - # dimension has length 1 - if self.__numRealDims == 0: - self.__numRealDims = len(image.shape) + # Degenerate case - less + # than three real dimensions + if self.__numRealDims < 3: + self.__numRealDims = min(3, len(image.shape)) # And save the number of # 'padding' dimensions too. @@ -303,9 +303,11 @@ class ImageWrapper(notifier.Notifier): # data range for each volume/slice/vector # # We use nan as a placeholder, so the - # dtype must be non-integral + # dtype must be non-integral. The + # len(dtype) check takes into account + # structured data (e.g. RGB) dtype = self.__image.get_data_dtype() - if np.issubdtype(dtype, np.integer): + if np.issubdtype(dtype, np.integer) or len(dtype) > 0: dtype = np.float32 self.__volRanges = np.zeros((nvols, 2), dtype=dtype) @@ -703,7 +705,7 @@ class ImageWrapper(notifier.Notifier): raise IndexError('Invalid assignment: [{}] = {}'.format( sliceobj, len(values))) - values = values[0] + values = np.array(values).flatten()[0] # Make sure that the values # have a compatible shape. diff --git a/fsl/data/utils.py b/fsl/data/utils.py index 13aae313b3ea4d1dc927d7b1fffd029ccaf7f83c..690a1ac5f7be488ba09eec913b09d5ee88148c75 100644 --- a/fsl/data/utils.py +++ b/fsl/data/utils.py @@ -27,6 +27,7 @@ def guessType(path): import fsl.data.gifti as fslgifti import fsl.data.freesurfer as fslfs import fsl.data.mghimage as fslmgh + import fsl.data.bitmap as fslbmp import fsl.data.featimage as featimage import fsl.data.melodicimage as melimage import fsl.data.dtifit as dtifit @@ -48,14 +49,16 @@ def guessType(path): if op.isfile(path): # Some types are easy - just check the extensions - if fslpath.hasExt(path, fslvtk.ALLOWED_EXTENSIONS): + if fslpath.hasExt(path.lower(), fslvtk.ALLOWED_EXTENSIONS): return fslvtk.VTKMesh, path - elif fslpath.hasExt(path, fslgifti.ALLOWED_EXTENSIONS): + elif fslpath.hasExt(path.lower(), fslgifti.ALLOWED_EXTENSIONS): return fslgifti.GiftiMesh, path elif fslfs.isGeometryFile(path): return fslfs.FreesurferMesh, path - elif fslpath.hasExt(path, fslmgh.ALLOWED_EXTENSIONS): + elif fslpath.hasExt(path.lower(), fslmgh.ALLOWED_EXTENSIONS): return fslmgh.MGHImage, path + elif fslpath.hasExt(path.lower(), fslbmp.BITMAP_EXTENSIONS): + return fslbmp.Bitmap, path # Other specialised image types elif melanalysis .isMelodicImage(path): diff --git a/fsl/utils/naninfrange.py b/fsl/utils/naninfrange.py index c8437c8d9b10ec8b69b6d6bb54d5292314907c17..bcdf5b12cffa1cc80bc1b3d09d8cc7f141640bef 100644 --- a/fsl/utils/naninfrange.py +++ b/fsl/utils/naninfrange.py @@ -23,6 +23,30 @@ def naninfrange(data): use an alternate approach to calculating the minimum/maximum. """ + # For structured arrays, we assume that + # all fields have the same dtype, and we + # simply take the range across all fields + if len(data.dtype) > 0: + + # Avoid inducing a data copy if + # at all possible. np.ndarray + # doesn't preserve the underlying + # order, so let's set that. Also, + # we're forced to make a copy if + # the array is not contiguous, + # otherwise ndarray will complain + if data.flags['C_CONTIGUOUS']: order = 'C' + elif data.flags['F_CONTIGUOUS']: order = 'F' + else: + data = np.ascontiguousarray(data) + order = 'C' + + shape = [len(data.dtype)] + list(data.shape) + data = np.ndarray(buffer=data.data, + shape=shape, + order=order, + dtype=data.dtype[0]) + if not np.issubdtype(data.dtype, np.floating): return data.min(), data.max() diff --git a/fsl/version.py b/fsl/version.py index 520dc6191809b5bf4f618a4cb41b79d43f1f958a..f8e6092135fbacea0d5a152c82de9ab93650fde5 100644 --- a/fsl/version.py +++ b/fsl/version.py @@ -47,7 +47,7 @@ import re import string -__version__ = '2.3.0.dev0' +__version__ = '2.4.0.dev0' """Current version number, as a string. """ diff --git a/requirements-extra.txt b/requirements-extra.txt index 774efb03f31ee244da45acd703238d60c02191b8..333cde7a982fd7502eec2af60e26b87b332dac0f 100644 --- a/requirements-extra.txt +++ b/requirements-extra.txt @@ -2,3 +2,4 @@ indexed_gzip>=0.7.0 wxpython==4.* trimesh>=2.37.29 rtree==0.8.3 +Pillow>=3.2.0 diff --git a/setup.cfg b/setup.cfg index e82f3b4445a47f24679fc30bd1f339fdcb5e4832..0c604f57e04cd74faf4721accfb0353964f80ebf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,6 +11,7 @@ universal=1 # - dicomtest: Requires dcm2niix # - meshtest: Requires trimesh and rtree # - igziptest: Requires indexed_gzip +# - piltest: Requires Pillow # - noroottest: Need to be executed as # non-root user (will fail # otherwise) diff --git a/tests/test_bitmap.py b/tests/test_bitmap.py new file mode 100644 index 0000000000000000000000000000000000000000..b59c4083e2e822400f19ccc6895d500179fb4149 --- /dev/null +++ b/tests/test_bitmap.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +# +# test_bitmap.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + + +import numpy as np + +import pytest + +import fsl.utils.tempdir as tempdir +import fsl.data.bitmap as fslbmp + + +@pytest.mark.piltest +def test_bitmap(): + + from PIL import Image + + with tempdir.tempdir(): + data = np.random.randint(0, 255, (100, 200, 4), dtype=np.uint8) + img = Image.fromarray(data, mode='RGBA') + + img.save('image.png') + + bmp = fslbmp.Bitmap('image.png') + + assert bmp.name == 'image.png' + assert bmp.dataSource == 'image.png' + assert bmp.shape == (200, 100, 4) + + repr(bmp) + hash(bmp) + + assert np.all(bmp.data == np.fliplr(data.transpose(1, 0, 2))) + + +@pytest.mark.piltest +def test_bitmap_asImage(): + from PIL import Image + + with tempdir.tempdir(): + d3 = np.random.randint(0, 255, (100, 200, 3), dtype=np.uint8) + d4 = np.random.randint(0, 255, (100, 200, 4), dtype=np.uint8) + + img3 = Image.fromarray(d3, mode='RGB') + img4 = Image.fromarray(d4, mode='RGBA') + + img3.save('rgb.png') + img4.save('rgba.png') + + bmp3 = fslbmp.Bitmap('rgb.png') + bmp4 = fslbmp.Bitmap('rgba.png') + + i3 = bmp3.asImage() + i4 = bmp4.asImage() + + assert i3.shape == (200, 100, 1) + assert i4.shape == (200, 100, 1) + assert i3.nvals == 3 + assert i4.nvals == 4 diff --git a/tests/test_gifti.py b/tests/test_gifti.py index 7a8ff6175658ed377ee4bc2a9ea08030f96a8834..835655b1e7da240484101ec7aa4a2b44dbf7490e 100644 --- a/tests/test_gifti.py +++ b/tests/test_gifti.py @@ -288,24 +288,38 @@ def test_GiftiMesh_multiple_vertices(): verts1 = TEST_VERT_ARRAY verts2 = nib.gifti.GiftiDataArray( TEST_VERTS * 5, intent='NIFTI_INTENT_POINTSET') + verts3 = nib.gifti.GiftiDataArray( + TEST_VERTS * 10, intent='NIFTI_INTENT_POINTSET') gimg = nib.gifti.GiftiImage(darrays=[verts1, verts2, tris]) + gimg2 = nib.gifti.GiftiImage(darrays=[verts3, tris]) with tempdir(): - fname = op.abspath('test.gii') - gimg.to_filename(fname) + fname = op.abspath('test.gii') + fname2 = op.abspath('test2.gii') + gimg .to_filename(fname) + gimg2.to_filename(fname2) + surf = gifti.GiftiMesh(fname) - expvsets = [fname, - '{}_1'.format(fname)] + expvsets = [fname, '{}_1'.format(fname)] + + expbounds1 = np.min(verts1.data, axis=0), np.max(verts1.data, axis=0) + expbounds2 = np.min(verts2.data, axis=0), np.max(verts2.data, axis=0) + expbounds3 = np.min(verts3.data, axis=0), np.max(verts3.data, axis=0) assert np.all(surf.vertices == TEST_VERTS) assert np.all(surf.indices == TEST_IDXS) assert surf.vertexSets() == expvsets + assert np.all(np.isclose(surf.bounds, expbounds1)) surf.vertices = expvsets[1] - assert np.all(surf.vertices == TEST_VERTS * 5) + assert np.all(np.isclose(surf.bounds, expbounds2)) + + surf.loadVertices(fname2, select=True) + assert np.all(surf.vertices == TEST_VERTS * 10) + assert np.all(np.isclose(surf.bounds, expbounds3)) def test_GiftiMesh_needsFixing(): diff --git a/tests/test_image.py b/tests/test_image.py index 85dde7c66d44dd76bc137856f78de72163df2d39..5b1ba4c7ff15c2e7573fd2f577108fc7399fc23e 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -306,6 +306,7 @@ def _test_Image_atts(imgtype): assert tuple(i.nibImage.shape) == tuple(dims) assert tuple(i.nibImage.header.get_zooms()) == tuple(pixdims) + assert i.nvals == 1 assert i.ndim == expndims assert i.dtype == dtype assert i.name == op.basename(path) @@ -1147,3 +1148,28 @@ def _test_Image_init_xform(imgtype): del fimg del img img = None + + +def test_rgb_image(): + with tempdir(): + + dtype = np.dtype([('R', 'uint8'), + ('G', 'uint8'), + ('B', 'uint8')]) + data = np.zeros((20, 20, 20), dtype=dtype) + + for i in np.ndindex(data.shape): + data['R'][i] = np.random.randint(0, 100) + data['G'][i] = np.random.randint(100, 200) + data['B'][i] = np.random.randint(200, 256) + + # fix the data limits + data['R'][0, 0, 0] = 0 + data['B'][0, 0, 0] = 255 + + nib.Nifti1Image(data, np.eye(4)).to_filename('rgb.nii') + + img = fslimage.Image('rgb.nii') + + assert img.nvals == 3 + assert img.dataRange == (0, 255) diff --git a/tests/test_imagewrapper.py b/tests/test_imagewrapper.py index 65c790ce3f41300b13cc1dfec07b6f93c8a2e0dd..fc78da8460f3f1d7ed20364206d3ef12cbae0934 100644 --- a/tests/test_imagewrapper.py +++ b/tests/test_imagewrapper.py @@ -1173,8 +1173,10 @@ def test_3D_indexing(shape=None, img=None): # Test that a 3D image looks like a 3D image - if shape is None: shape = (21, 22, 23) - elif len(shape) == 2: shape = tuple(list(shape) + [1]) + if shape is None: + shape = (21, 22, 23) + elif len(shape) < 3: + shape = tuple(list(shape) + [1] * (3 - len(shape))) if img is None: data = np.random.random(shape) @@ -1264,8 +1266,10 @@ def test_3D_len_one_indexing(shape=None, img=None): # look like a 3D image, but should still # accept (valid) 2D slicing. - if shape is None: shape = (20, 20, 1) - elif len(shape) < 3: shape = tuple(list(shape) + [1]) + if shape is None: + shape = (20, 20, 1) + elif len(shape) < 3: + shape = tuple(list(shape) + [1] * (3 - len(shape))) if img is None: data = np.random.random(shape) @@ -1305,6 +1309,20 @@ def test_2D_indexing(): test_3D_len_one_indexing(shape, img) +def test_1D_indexing(): + + # Testing ImageWrapper for a 1D image - + # it should look just like a 3D image + # (the same as is tested above). + + shape = (20,) + data = np.random.random(shape) + nibImg = nib.Nifti1Image(data, np.eye(4)) + img = imagewrap.ImageWrapper(nibImg, loadData=True) + + test_3D_len_one_indexing(shape, img) + + def test_4D_indexing(shape=None, img=None): if shape is None: diff --git a/tests/test_naninfrange.py b/tests/test_naninfrange.py index dee79948a1a4021ab6f0e3fa72a84e64319e5b4d..0c232d0085f65378a70dce3c37ee95147280034d 100644 --- a/tests/test_naninfrange.py +++ b/tests/test_naninfrange.py @@ -50,3 +50,44 @@ def test_naninfrange(): if np.isfinite(expected[1]): assert result[1] == expected[1] elif np.isnan( expected[1]): assert np.isnan(result[1]) elif np.isinf( expected[1]): assert np.isinf(result[1]) + + +def test_naninfrange_structured_ordered_contiguous(): + + data = np.random.random((4, 5, 6)) + + cdata = data.copy(order='C') + fdata = data.copy(order='F') + + sdtype = np.dtype([('R', 'float64'), ('G', 'float64'), ('B', 'float64')]) + sdata = np.zeros(data.shape, dtype=sdtype) + sdata['R'] = data + sdata['G'] = data + sdata['B'] = data + + csdata = sdata.copy(order='C') + fsdata = sdata.copy(order='F') + + tests = [ + cdata, + cdata.transpose(1, 0, 2), + cdata[2:4, 1:3, 0:4], + fdata, + fdata.transpose(1, 0, 2), + fdata[2:4, 1:3, 0:4], + csdata, + csdata.transpose(1, 0, 2), + csdata[2:4, 1:3, 0:4], + fsdata, + fsdata.transpose(1, 0, 2), + fsdata[2:4, 1:3, 0:4] + ] + + for t in tests: + if len(t.dtype) > 0: + expmin = np.min([t[n].min() for n in t.dtype.names]) + expmax = np.max([t[n].max() for n in t.dtype.names]) + else: + expmin, expmax = np.min(t), np.max(t) + result = naninfrange.naninfrange(t) + assert np.all(np.isclose(result, (expmin, expmax)))