Skip to content
Snippets Groups Projects
Commit 99eb7f2b authored by Paul McCarthy's avatar Paul McCarthy :mountain_bicyclist:
Browse files

Merge branch 'enh/bitmap' into 'master'

Enh/bitmap

See merge request fsl/fslpy!130
parents 424d0e18 9c2bd334
No related branches found
No related tags found
No related merge requests found
Pipeline #3873 canceled
...@@ -2,14 +2,21 @@ This document contains the ``fslpy`` release history in reverse chronological ...@@ -2,14 +2,21 @@ This document contains the ``fslpy`` release history in reverse chronological
order. order.
2.3.0 (Under development) 2.3.0 (Tuesday June 25th 2019)
------------------------- ------------------------------
Added 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, * New :meth:`.LabelAtlas.get` and :meth:`ProbabilisticAtlas.get` methods,
which return an :class:`.Image` for a specific region. which return an :class:`.Image` for a specific region.
* The :meth:`.AtlasDescription.find` method also now a ``name`` parameter, * The :meth:`.AtlasDescription.find` method also now a ``name`` parameter,
...@@ -24,6 +31,7 @@ Fixed ...@@ -24,6 +31,7 @@ Fixed
* The :func:`.makeWriteable` function will always create a copy of an * The :func:`.makeWriteable` function will always create a copy of an
``array`` if its base is a ``bytes`` object. ``array`` if its base is a ``bytes`` object.
* Fixed a bug in the :meth:`.GitfitMesh.loadVertices` method.
2.2.0 (Wednesday May 8th 2019) 2.2.0 (Wednesday May 8th 2019)
......
...@@ -57,6 +57,9 @@ Some extra dependencies are listed in `requirements.txt ...@@ -57,6 +57,9 @@ Some extra dependencies are listed in `requirements.txt
class has some methods which use ``trimesh`` to perform geometric queries class has some methods which use ``trimesh`` to perform geometric queries
on the mesh. 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 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 not available on PyPI. Install wxPython like so, changing the URL for your
......
``fsl.data.bitmap``
===================
.. automodule:: fsl.data.bitmap
:members:
:undoc-members:
:show-inheritance:
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
:hidden: :hidden:
fsl.data.atlases fsl.data.atlases
fsl.data.bitmap
fsl.data.constants fsl.data.constants
fsl.data.dicom fsl.data.dicom
fsl.data.dtifit fsl.data.dtifit
......
#!/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)
...@@ -138,7 +138,10 @@ class GiftiMesh(fslmesh.Mesh): ...@@ -138,7 +138,10 @@ class GiftiMesh(fslmesh.Mesh):
surfimg, _, vertices, _ = loadGiftiMesh(infile) 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) self.setMeta(infile, surfimg)
......
...@@ -1025,6 +1025,18 @@ class Image(Nifti): ...@@ -1025,6 +1025,18 @@ class Image(Nifti):
return self[tuple(coords)].dtype 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 @Nifti.voxToWorldMat.setter
def voxToWorldMat(self, xform): def voxToWorldMat(self, xform):
"""Overrides the :meth:`Nifti.voxToWorldMat` property setter. """Overrides the :meth:`Nifti.voxToWorldMat` property setter.
......
...@@ -190,10 +190,10 @@ class ImageWrapper(notifier.Notifier): ...@@ -190,10 +190,10 @@ class ImageWrapper(notifier.Notifier):
if d == 1: self.__numRealDims -= 1 if d == 1: self.__numRealDims -= 1
else: break else: break
# Degenerate case - if every # Degenerate case - less
# dimension has length 1 # than three real dimensions
if self.__numRealDims == 0: if self.__numRealDims < 3:
self.__numRealDims = len(image.shape) self.__numRealDims = min(3, len(image.shape))
# And save the number of # And save the number of
# 'padding' dimensions too. # 'padding' dimensions too.
...@@ -303,9 +303,11 @@ class ImageWrapper(notifier.Notifier): ...@@ -303,9 +303,11 @@ class ImageWrapper(notifier.Notifier):
# data range for each volume/slice/vector # data range for each volume/slice/vector
# #
# We use nan as a placeholder, so the # 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() 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 dtype = np.float32
self.__volRanges = np.zeros((nvols, 2), self.__volRanges = np.zeros((nvols, 2),
dtype=dtype) dtype=dtype)
...@@ -703,7 +705,7 @@ class ImageWrapper(notifier.Notifier): ...@@ -703,7 +705,7 @@ class ImageWrapper(notifier.Notifier):
raise IndexError('Invalid assignment: [{}] = {}'.format( raise IndexError('Invalid assignment: [{}] = {}'.format(
sliceobj, len(values))) sliceobj, len(values)))
values = values[0] values = np.array(values).flatten()[0]
# Make sure that the values # Make sure that the values
# have a compatible shape. # have a compatible shape.
......
...@@ -27,6 +27,7 @@ def guessType(path): ...@@ -27,6 +27,7 @@ def guessType(path):
import fsl.data.gifti as fslgifti import fsl.data.gifti as fslgifti
import fsl.data.freesurfer as fslfs import fsl.data.freesurfer as fslfs
import fsl.data.mghimage as fslmgh import fsl.data.mghimage as fslmgh
import fsl.data.bitmap as fslbmp
import fsl.data.featimage as featimage import fsl.data.featimage as featimage
import fsl.data.melodicimage as melimage import fsl.data.melodicimage as melimage
import fsl.data.dtifit as dtifit import fsl.data.dtifit as dtifit
...@@ -48,14 +49,16 @@ def guessType(path): ...@@ -48,14 +49,16 @@ def guessType(path):
if op.isfile(path): if op.isfile(path):
# Some types are easy - just check the extensions # 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 return fslvtk.VTKMesh, path
elif fslpath.hasExt(path, fslgifti.ALLOWED_EXTENSIONS): elif fslpath.hasExt(path.lower(), fslgifti.ALLOWED_EXTENSIONS):
return fslgifti.GiftiMesh, path return fslgifti.GiftiMesh, path
elif fslfs.isGeometryFile(path): elif fslfs.isGeometryFile(path):
return fslfs.FreesurferMesh, path return fslfs.FreesurferMesh, path
elif fslpath.hasExt(path, fslmgh.ALLOWED_EXTENSIONS): elif fslpath.hasExt(path.lower(), fslmgh.ALLOWED_EXTENSIONS):
return fslmgh.MGHImage, path return fslmgh.MGHImage, path
elif fslpath.hasExt(path.lower(), fslbmp.BITMAP_EXTENSIONS):
return fslbmp.Bitmap, path
# Other specialised image types # Other specialised image types
elif melanalysis .isMelodicImage(path): elif melanalysis .isMelodicImage(path):
......
...@@ -23,6 +23,30 @@ def naninfrange(data): ...@@ -23,6 +23,30 @@ def naninfrange(data):
use an alternate approach to calculating the minimum/maximum. 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): if not np.issubdtype(data.dtype, np.floating):
return data.min(), data.max() return data.min(), data.max()
......
...@@ -47,7 +47,7 @@ import re ...@@ -47,7 +47,7 @@ import re
import string import string
__version__ = '2.3.0.dev0' __version__ = '2.4.0.dev0'
"""Current version number, as a string. """ """Current version number, as a string. """
......
...@@ -2,3 +2,4 @@ indexed_gzip>=0.7.0 ...@@ -2,3 +2,4 @@ indexed_gzip>=0.7.0
wxpython==4.* wxpython==4.*
trimesh>=2.37.29 trimesh>=2.37.29
rtree==0.8.3 rtree==0.8.3
Pillow>=3.2.0
...@@ -11,6 +11,7 @@ universal=1 ...@@ -11,6 +11,7 @@ universal=1
# - dicomtest: Requires dcm2niix # - dicomtest: Requires dcm2niix
# - meshtest: Requires trimesh and rtree # - meshtest: Requires trimesh and rtree
# - igziptest: Requires indexed_gzip # - igziptest: Requires indexed_gzip
# - piltest: Requires Pillow
# - noroottest: Need to be executed as # - noroottest: Need to be executed as
# non-root user (will fail # non-root user (will fail
# otherwise) # otherwise)
......
#!/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
...@@ -288,24 +288,38 @@ def test_GiftiMesh_multiple_vertices(): ...@@ -288,24 +288,38 @@ def test_GiftiMesh_multiple_vertices():
verts1 = TEST_VERT_ARRAY verts1 = TEST_VERT_ARRAY
verts2 = nib.gifti.GiftiDataArray( verts2 = nib.gifti.GiftiDataArray(
TEST_VERTS * 5, intent='NIFTI_INTENT_POINTSET') 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]) gimg = nib.gifti.GiftiImage(darrays=[verts1, verts2, tris])
gimg2 = nib.gifti.GiftiImage(darrays=[verts3, tris])
with tempdir(): with tempdir():
fname = op.abspath('test.gii') fname = op.abspath('test.gii')
gimg.to_filename(fname) fname2 = op.abspath('test2.gii')
gimg .to_filename(fname)
gimg2.to_filename(fname2)
surf = gifti.GiftiMesh(fname) surf = gifti.GiftiMesh(fname)
expvsets = [fname, expvsets = [fname, '{}_1'.format(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.vertices == TEST_VERTS)
assert np.all(surf.indices == TEST_IDXS) assert np.all(surf.indices == TEST_IDXS)
assert surf.vertexSets() == expvsets assert surf.vertexSets() == expvsets
assert np.all(np.isclose(surf.bounds, expbounds1))
surf.vertices = expvsets[1] surf.vertices = expvsets[1]
assert np.all(surf.vertices == TEST_VERTS * 5) 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(): def test_GiftiMesh_needsFixing():
......
...@@ -306,6 +306,7 @@ def _test_Image_atts(imgtype): ...@@ -306,6 +306,7 @@ def _test_Image_atts(imgtype):
assert tuple(i.nibImage.shape) == tuple(dims) assert tuple(i.nibImage.shape) == tuple(dims)
assert tuple(i.nibImage.header.get_zooms()) == tuple(pixdims) assert tuple(i.nibImage.header.get_zooms()) == tuple(pixdims)
assert i.nvals == 1
assert i.ndim == expndims assert i.ndim == expndims
assert i.dtype == dtype assert i.dtype == dtype
assert i.name == op.basename(path) assert i.name == op.basename(path)
...@@ -1147,3 +1148,28 @@ def _test_Image_init_xform(imgtype): ...@@ -1147,3 +1148,28 @@ def _test_Image_init_xform(imgtype):
del fimg del fimg
del img del img
img = None 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)
...@@ -1173,8 +1173,10 @@ def test_3D_indexing(shape=None, img=None): ...@@ -1173,8 +1173,10 @@ def test_3D_indexing(shape=None, img=None):
# Test that a 3D image looks like a 3D image # Test that a 3D image looks like a 3D image
if shape is None: shape = (21, 22, 23) if shape is None:
elif len(shape) == 2: shape = tuple(list(shape) + [1]) shape = (21, 22, 23)
elif len(shape) < 3:
shape = tuple(list(shape) + [1] * (3 - len(shape)))
if img is None: if img is None:
data = np.random.random(shape) data = np.random.random(shape)
...@@ -1264,8 +1266,10 @@ def test_3D_len_one_indexing(shape=None, img=None): ...@@ -1264,8 +1266,10 @@ def test_3D_len_one_indexing(shape=None, img=None):
# look like a 3D image, but should still # look like a 3D image, but should still
# accept (valid) 2D slicing. # accept (valid) 2D slicing.
if shape is None: shape = (20, 20, 1) if shape is None:
elif len(shape) < 3: shape = tuple(list(shape) + [1]) shape = (20, 20, 1)
elif len(shape) < 3:
shape = tuple(list(shape) + [1] * (3 - len(shape)))
if img is None: if img is None:
data = np.random.random(shape) data = np.random.random(shape)
...@@ -1305,6 +1309,20 @@ def test_2D_indexing(): ...@@ -1305,6 +1309,20 @@ def test_2D_indexing():
test_3D_len_one_indexing(shape, img) 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): def test_4D_indexing(shape=None, img=None):
if shape is None: if shape is None:
......
...@@ -50,3 +50,44 @@ def test_naninfrange(): ...@@ -50,3 +50,44 @@ def test_naninfrange():
if np.isfinite(expected[1]): assert result[1] == expected[1] if np.isfinite(expected[1]): assert result[1] == expected[1]
elif np.isnan( expected[1]): assert np.isnan(result[1]) elif np.isnan( expected[1]): assert np.isnan(result[1])
elif np.isinf( expected[1]): assert np.isinf(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)))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment