Skip to content

Commit 2584b23

Browse files
Merge pull request #521 from effigies/ap_reshape
MRG: ArrayProxy reshape Add ability for main ArrayProxy instance to do reshape without reading the data.
2 parents 4bc9344 + 37405c3 commit 2584b23

File tree

6 files changed

+125
-46
lines changed

6 files changed

+125
-46
lines changed

Changelog

+6
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ Enhancements
4343
(pr/495); function to concatenate multiple ArraySequence objects (pr/494)
4444
* Support for numpy 1.12 (pr/500, pr/502) (MC, MB)
4545
* Allow dtype specifiers as fileslice input (pr/485) (MB)
46+
* Support "headerless" ArrayProxy specification, enabling memory-efficient
47+
ArrayProxy reshaping (pr/521) (CM)
4648

4749
Bug fixes
4850
---------
@@ -60,6 +62,10 @@ Maintenance
6062
API changes and deprecations
6163
----------------------------
6264

65+
* ``header`` argument to ``ArrayProxy.__init__`` is renamed to ``spec``
66+
* Deprecation of ``header`` property of ``ArrayProxy`` object, for removal in
67+
3.0
68+
6369

6470
2.1 (Monday 22 August 2016)
6571
===========================

nibabel/arrayproxy.py

+65-17
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,9 @@
2525
2626
See :mod:`nibabel.tests.test_proxy_api` for proxy API conformance checks.
2727
"""
28-
import warnings
29-
3028
import numpy as np
3129

30+
from .deprecated import deprecate_with_version
3231
from .volumeutils import array_from_file, apply_read_scaling
3332
from .fileslice import fileslice
3433
from .keywordonly import kw_only_meth
@@ -45,14 +44,17 @@ class ArrayProxy(object):
4544
of the numpy dtypes, starting at a given file position ``offset`` with
4645
single ``slope`` and ``intercept`` scaling to produce output values.
4746
48-
The class ``__init__`` requires a ``header`` object with methods:
47+
The class ``__init__`` requires a spec which defines how the data will be
48+
read and rescaled. The spec may be a tuple of length 2 - 5, containing the
49+
shape, storage dtype, offset, slope and intercept, or a ``header`` object
50+
with methods:
4951
5052
* get_data_shape
5153
* get_data_dtype
5254
* get_data_offset
5355
* get_slope_inter
5456
55-
The header should also have a 'copy' method. This requirement will go away
57+
A header should also have a 'copy' method. This requirement will go away
5658
when the deprecated 'header' propoerty goes away.
5759
5860
This implementation allows us to deal with Analyze and its variants,
@@ -64,17 +66,32 @@ class ArrayProxy(object):
6466
"""
6567
# Assume Fortran array memory layout
6668
order = 'F'
69+
_header = None
6770

6871
@kw_only_meth(2)
69-
def __init__(self, file_like, header, mmap=True):
72+
def __init__(self, file_like, spec, mmap=True):
7073
""" Initialize array proxy instance
7174
7275
Parameters
7376
----------
7477
file_like : object
7578
File-like object or filename. If file-like object, should implement
7679
at least ``read`` and ``seek``.
77-
header : object
80+
spec : object or tuple
81+
Tuple must have length 2-5, with the following values.
82+
- shape : tuple
83+
tuple of ints describing shape of data
84+
- storage_dtype : dtype specifier
85+
dtype of array inside proxied file, or input to ``numpy.dtype``
86+
to specify array dtype
87+
- offset : int
88+
Offset, in bytes, of data array from start of file
89+
(default: 0)
90+
- slope : float
91+
Scaling factor for resulting data (default: 1.0)
92+
- inter : float
93+
Intercept for rescaled data (default: 0.0)
94+
OR
7895
Header object implementing ``get_data_shape``, ``get_data_dtype``,
7996
``get_data_offset``, ``get_slope_inter``
8097
mmap : {True, False, 'c', 'r'}, optional, keyword only
@@ -90,22 +107,30 @@ def __init__(self, file_like, header, mmap=True):
90107
if mmap not in (True, False, 'c', 'r'):
91108
raise ValueError("mmap should be one of {True, False, 'c', 'r'}")
92109
self.file_like = file_like
110+
if hasattr(spec, 'get_data_shape'):
111+
slope, inter = spec.get_slope_inter()
112+
par = (spec.get_data_shape(),
113+
spec.get_data_dtype(),
114+
spec.get_data_offset(),
115+
1. if slope is None else slope,
116+
0. if inter is None else inter)
117+
# Reference to original header; we will remove this soon
118+
self._header = spec.copy()
119+
elif 2 <= len(spec) <= 5:
120+
optional = (0, 1., 0.)
121+
par = spec + optional[len(spec) - 2:]
122+
else:
123+
raise TypeError('spec must be tuple of length 2-5 or header object')
124+
93125
# Copies of values needed to read array
94-
self._shape = header.get_data_shape()
95-
self._dtype = header.get_data_dtype()
96-
self._offset = header.get_data_offset()
97-
self._slope, self._inter = header.get_slope_inter()
98-
self._slope = 1.0 if self._slope is None else self._slope
99-
self._inter = 0.0 if self._inter is None else self._inter
126+
self._shape, self._dtype, self._offset, self._slope, self._inter = par
127+
# Permit any specifier that can be interpreted as a numpy dtype
128+
self._dtype = np.dtype(self._dtype)
100129
self._mmap = mmap
101-
# Reference to original header; we will remove this soon
102-
self._header = header.copy()
103130

104131
@property
132+
@deprecate_with_version('ArrayProxy.header deprecated', '2.2', '3.0')
105133
def header(self):
106-
warnings.warn('We will remove the header property from proxies soon',
107-
FutureWarning,
108-
stacklevel=2)
109134
return self._header
110135

111136
@property
@@ -162,6 +187,29 @@ def __getitem__(self, slicer):
162187
# Upcast as necessary for big slopes, intercepts
163188
return apply_read_scaling(raw_data, self._slope, self._inter)
164189

190+
def reshape(self, shape):
191+
''' Return an ArrayProxy with a new shape, without modifying data '''
192+
size = np.prod(self._shape)
193+
194+
# Calculate new shape if not fully specified
195+
from operator import mul
196+
from functools import reduce
197+
n_unknowns = len([e for e in shape if e == -1])
198+
if n_unknowns > 1:
199+
raise ValueError("can only specify one unknown dimension")
200+
elif n_unknowns == 1:
201+
known_size = reduce(mul, shape, -1)
202+
unknown_size = size // known_size
203+
shape = tuple(unknown_size if e == -1 else e for e in shape)
204+
205+
if np.prod(shape) != size:
206+
raise ValueError("cannot reshape array of size {:d} into shape "
207+
"{!s}".format(size, shape))
208+
return self.__class__(file_like=self.file_like,
209+
spec=(shape, self._dtype, self._offset,
210+
self._slope, self._inter),
211+
mmap=self._mmap)
212+
165213

166214
def is_proxy(obj):
167215
""" Return True if `obj` is an array proxy

nibabel/cifti2/parse_cifti2.py

+1-25
Original file line numberDiff line numberDiff line change
@@ -121,33 +121,9 @@ def _chk_pixdims(hdr, fix=False):
121121

122122

123123
class _Cifti2AsNiftiImage(Nifti2Image):
124+
""" Load a NIfTI2 image with a Cifti2 header """
124125
header_class = _Cifti2AsNiftiHeader
125-
files_types = (('image', '.nii'),)
126-
valid_exts = ('.nii',)
127126
makeable = False
128-
rw = True
129-
130-
def __init__(self, dataobj, affine, header=None,
131-
extra=None, file_map=None):
132-
"""Convert NIFTI-2 file to CIFTI2"""
133-
super(_Cifti2AsNiftiImage, self).__init__(dataobj=dataobj,
134-
affine=affine,
135-
header=header,
136-
extra=extra,
137-
file_map=file_map)
138-
139-
# Get cifti header from extension
140-
for extension in self.header.extensions:
141-
if isinstance(extension, Cifti2Extension):
142-
self.cifti_img = extension
143-
break
144-
else:
145-
self.cifti_img = None
146-
147-
if self.cifti_img is None:
148-
raise ValueError('Nifti2 header does not contain a CIFTI2 '
149-
'extension')
150-
self.cifti_img.data = self.get_data()
151127

152128

153129
class Cifti2Parser(xml.XmlParser):

nibabel/cifti2/tests/test_cifti2io.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,12 @@ def test_read_and_proxies():
6363
assert_true(isinstance(img2.header, ci.Cifti2Header))
6464
assert_equal(img2.shape, (1, 91282))
6565
# While we cannot reshape arrayproxies, all images are in-memory
66-
assert_true(img2.in_memory)
66+
assert_true(not img2.in_memory)
6767
data = img2.get_data()
68-
assert_true(data is img2.dataobj)
68+
assert_true(data is not img2.dataobj)
6969
# Uncaching has no effect, images are always array images
7070
img2.uncache()
71-
assert_true(data is img2.get_data())
71+
assert_true(data is not img2.get_data())
7272

7373

7474
@needs_nibabel_data('nitest-cifti2')

nibabel/tests/test_arrayproxy.py

+49
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,41 @@ def test_init():
8181
bio.write(arr.tostring(order='C'))
8282
ap = CArrayProxy(bio, FunkyHeader((2, 3, 4)))
8383
assert_array_equal(np.asarray(ap), arr)
84+
# Illegal init
85+
assert_raises(TypeError, ArrayProxy, bio, object())
86+
87+
88+
def test_tuplespec():
89+
bio = BytesIO()
90+
shape = [2, 3, 4]
91+
dtype = np.int32
92+
arr = np.arange(24, dtype=dtype).reshape(shape)
93+
bio.seek(16)
94+
bio.write(arr.tostring(order='F'))
95+
# Create equivalent header and tuple specs
96+
hdr = FunkyHeader(shape)
97+
tuple_spec = (hdr.get_data_shape(), hdr.get_data_dtype(),
98+
hdr.get_data_offset(), 1., 0.)
99+
ap_header = ArrayProxy(bio, hdr)
100+
ap_tuple = ArrayProxy(bio, tuple_spec)
101+
# Header and tuple specs produce identical behavior
102+
for prop in ('shape', 'dtype', 'offset', 'slope', 'inter', 'is_proxy'):
103+
assert_equal(getattr(ap_header, prop), getattr(ap_tuple, prop))
104+
for method, args in (('get_unscaled', ()), ('__array__', ()),
105+
('__getitem__', ((0, 2, 1), ))
106+
):
107+
assert_array_equal(getattr(ap_header, method)(*args),
108+
getattr(ap_tuple, method)(*args))
109+
# Tuple-defined ArrayProxies have no header to store
110+
with warnings.catch_warnings():
111+
assert_true(ap_tuple.header is None)
112+
# Partial tuples of length 2-4 are also valid
113+
for n in range(2, 5):
114+
ArrayProxy(bio, tuple_spec[:n])
115+
# Bad tuple lengths
116+
assert_raises(TypeError, ArrayProxy, bio, ())
117+
assert_raises(TypeError, ArrayProxy, bio, tuple_spec[:1])
118+
assert_raises(TypeError, ArrayProxy, bio, tuple_spec + ('error',))
84119

85120

86121
def write_raw_data(arr, hdr, fileobj):
@@ -185,6 +220,20 @@ def __array__(self):
185220
assert_equal(arr.shape, shape)
186221

187222

223+
def test_reshaped_is_proxy():
224+
shape = (1, 2, 3, 4)
225+
hdr = FunkyHeader(shape)
226+
bio = BytesIO()
227+
prox = ArrayProxy(bio, hdr)
228+
assert_true(isinstance(prox.reshape((2, 3, 4)), ArrayProxy))
229+
minus1 = prox.reshape((2, -1, 4))
230+
assert_true(isinstance(minus1, ArrayProxy))
231+
assert_equal(minus1.shape, (2, 3, 4))
232+
assert_raises(ValueError, prox.reshape, (-1, -1, 4))
233+
assert_raises(ValueError, prox.reshape, (2, 3, 5))
234+
assert_raises(ValueError, prox.reshape, (2, -1, 5))
235+
236+
188237
def test_get_unscaled():
189238
# Test fetch of raw array
190239
class FunkyHeader2(FunkyHeader):

nibabel/tests/test_proxy_api.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ def validate_deprecated_header(self, pmaker, params):
288288
# Header is a copy of original
289289
assert_false(prox.header is hdr)
290290
assert_equal(prox.header, hdr)
291-
assert_equal(warns.pop(0).category, FutureWarning)
291+
assert_equal(warns.pop(0).category, DeprecationWarning)
292292

293293

294294
class TestSpm99AnalyzeProxyAPI(TestAnalyzeProxyAPI):

0 commit comments

Comments
 (0)