/
coordinate_system.py
506 lines (411 loc) · 16.3 KB
/
coordinate_system.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
# vi: set ft=python sts=4 ts=4 sw=4 et:
"""
CoordinateSystems are used to represent the space in which the image resides.
A CoordinateSystem contains named coordinates, one for each dimension
and a coordinate dtype. The purpose of the CoordinateSystem is to
specify the name and order of the coordinate axes for a particular
space. This allows one to compare two CoordinateSystems to determine
if they are equal.
"""
from __future__ import print_function
from __future__ import absolute_import
__docformat__ = 'restructuredtext'
import numpy as np
class CoordinateSystemError(Exception):
pass
class CoordinateSystem(object):
"""An ordered sequence of named coordinates of a specified dtype.
A coordinate system is defined by the names of the coordinates,
(attribute ``coord_names``) and the numpy dtype of each coordinate
value (attribute ``coord_dtype``). The coordinate system can also
have a name.
>>> names = ['first', 'second', 'third']
>>> cs = CoordinateSystem(names, 'a coordinate system', np.float)
>>> cs.coord_names
('first', 'second', 'third')
>>> cs.name
'a coordinate system'
>>> cs.coord_dtype
dtype('float64')
The coordinate system also has a ``dtype`` which is the composite
numpy dtype, made from the (``names``, ``coord_dtype``).
>>> dtype_template = [(name, np.float) for name in cs.coord_names]
>>> dtype_should_be = np.dtype(dtype_template)
>>> cs.dtype == dtype_should_be
True
Two CoordinateSystems are equal if they have the same dtype
and the same names and the same name.
>>> another_cs = CoordinateSystem(names, 'not irrelevant', np.float)
>>> cs == another_cs
False
>>> cs.dtype == another_cs.dtype
True
>>> cs.name == another_cs.name
False
"""
_doc = {}
name = 'world-LPI'
_doc['name'] = 'Name describing the CoordinateSystem'
coord_names = ('x', 'y', 'z')
_doc['coord_names'] = 'Tuple of names describing each coordinate.'
coord_dtype = np.float64
_doc['coord_dtype'] = 'The builtin, scalar, dtype of each coordinate.'
ndim = 3
_doc['ndim'] = 'The number of dimensions'
dtype = np.dtype([('x', np.float),
('y', np.float),
('z', np.float)])
_doc['dtype'] = 'The composite dtype of the CoordinateSystem, ' + \
'expresses the fact that there are three numbers, the' + \
'first one corresponds to "x" and the second to "y".'
def __init__(self, coord_names, name='', coord_dtype=np.float):
"""Create a coordinate system with a given name and coordinate names.
The CoordinateSystem has two dtype attributes:
#. self.coord_dtype is the dtype of the individual coordinate values
#. self.dtype is the recarray dtype for the CoordinateSystem
which combines the coord_names and the coord_dtype. This
functions as the description of the CoordinateSystem.
Parameters
----------
coord_names : iterable
A sequence of coordinate names.
name : string, optional
The name of the coordinate system
coord_dtype : np.dtype, optional
The dtype of the coord_names. This should be a built-in
numpy scalar dtype. (default is np.float). The value can
by anything that can be passed to the np.dtype constructor.
For example ``np.float``, ``np.dtype(np.float)`` or ``f8``
all result in the same ``coord_dtype``.
Examples
--------
>>> c = CoordinateSystem('ij', name='input')
>>> print(c)
CoordinateSystem(coord_names=('i', 'j'), name='input', coord_dtype=float64)
>>> c.coord_dtype
dtype('float64')
"""
# this allows coord_names to be an iterator and have a length
coord_names = tuple(coord_names)
# Make sure each coordinate is unique
if len(set(coord_names)) != len(coord_names):
raise ValueError('coord_names must have distinct names')
# verify that the dtype is coord_dtype for sanity
sctypes = (np.sctypes['int'] + np.sctypes['float'] +
np.sctypes['complex'] + np.sctypes['uint'] + [object])
coord_dtype = np.dtype(coord_dtype)
if coord_dtype not in sctypes:
raise ValueError('Coordinate dtype should be one of %s' % sctypes)
# Set all the attributes
self.name = name
self.coord_names = coord_names
self.coord_dtype = coord_dtype
self.ndim = len(coord_names)
self.dtype = np.dtype([(name, self.coord_dtype)
for name in self.coord_names])
# All attributes are read only
def __setattr__(self, key, value):
if key in self.__dict__:
raise AttributeError('the value of %s has already been set and all attributes are read-only' % key)
object.__setattr__(self, key, value)
def index(self, coord_name):
"""Return the index of a given named coordinate.
>>> c = CoordinateSystem('ij', name='input')
>>> c.index('i')
0
>>> c.index('j')
1
"""
return list(self.coord_names).index(coord_name)
def __ne__(self, other):
return not self.__eq__(other)
def __eq__(self, other):
"""Equality is defined by self.dtype and self.name
Parameters
----------
other : :class:`CoordinateSystem`
The object to be compared with
Returns
-------
tf: bool
"""
return (self.dtype == other.dtype) and (self.name == other.name)
def similar_to(self, other):
"""Similarity is defined by self.dtype, ignoring name
Parameters
----------
other : :class:`CoordinateSystem`
The object to be compared with
Returns
-------
tf: bool
"""
return (self.dtype == other.dtype)
def __repr__(self):
"""Create a string representation of the coordinate system
Returns
-------
s : string
"""
return ("CoordinateSystem(coord_names=%s, name='%s', coord_dtype=%s)" %
(self.coord_names, self.name, self.coord_dtype))
def _checked_values(self, arr):
''' Check ``arr`` for valid dtype and shape as coordinate values.
Raise Errors for failed checks.
The dtype of ``arr`` has to be castable (without loss of precision) to
``self.coord_dtype``. We use numpy ``can_cast`` for this check.
The last (or only) axis of ``arr`` should be of length ``self.ndim``.
Parameters
----------
arr : array-like
array to check
Returns
-------
checked_arr : array
Possibly reshaped array
Examples
--------
>>> cs = CoordinateSystem('ijk', coord_dtype=np.float32)
>>> arr = np.array([1, 2, 3], dtype=np.int16)
>>> cs._checked_values(arr) # 1D is OK with matching dimensions
array([[1, 2, 3]], dtype=int16)
>>> cs._checked_values(arr.reshape(1,3)) # as is 1 by N
array([[1, 2, 3]], dtype=int16)
This next is the wrong shape:
>>> cs._checked_values(arr.reshape(3,1)) #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
CoordinateSystemError: Array shape[-1] (1) must match CoordinateSystem ndim (3).
CoordinateSystem(coord_names=('i', 'j', 'k'), name='', coord_dtype=float32)
Wrong length:
>>> cs._checked_values(arr[0:2]) #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
CoordinateSystemError: Array shape[-1] (2) must match CoordinateSystem ndim (3).
CoordinateSystem(coord_names=('i', 'j', 'k'), name='', coord_dtype=float32)
The dtype has to be castable:
>>> cs._checked_values(np.array([1, 2, 3], dtype=np.float64)) #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
CoordinateSystemError: Cannot cast array dtype float64 to CoordinateSystem coord_dtype float32.
CoordinateSystem(coord_names=('i', 'j', 'k'), name='', coord_dtype=float32)
The input array is unchanged, even if a reshape has
occurred. The returned array points to the same data.
>>> checked = cs._checked_values(arr)
>>> checked.shape == arr.shape
False
>>> checked is arr
False
>>> arr[0]
1
>>> checked[0,0] = 10
>>> arr[0]
10
For a 1D CoordinateSystem, passing a 1D vector length N could be a
mistake (you were expecting an N-dimensional coordinate system), or it
could be N points in 1D. Because it is ambiguous, this is an error.
>>> cs = CoordinateSystem('x')
>>> cs._checked_values(1)
array([[1]])
>>> cs._checked_values([1, 2]) #doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
CoordinateSystemError: Array shape[-1] (2) must match CoordinateSystem ndim (1).
CoordinateSystem(coord_names=('x',), name='', coord_dtype=float64)
But of course 2D, N by 1 is OK
>>> cs._checked_values(np.array([1,2,3]).reshape(3, 1))
array([[1],
[2],
[3]])
'''
arr = np.atleast_2d(arr)
if arr.shape[-1] != self.ndim:
raise CoordinateSystemError('Array shape[-1] (%s) must match '
'CoordinateSystem ndim (%d).\n %s'
% (arr.shape[-1], self.ndim, str(self)))
if not np.can_cast(arr.dtype, self.coord_dtype):
raise CoordinateSystemError('Cannot cast array dtype %s to '
'CoordinateSystem coord_dtype %s.\n %s' %
(arr.dtype, self.coord_dtype, str(self)))
return arr.reshape((-1, self.ndim))
def is_coordsys(obj):
""" Test if `obj` has the CoordinateSystem API
Parameters
----------
obj : object
Object to test
Returns
-------
tf : bool
True if `obj` has the coordinate system API
Examples
--------
>>> is_coordsys(CoordinateSystem('xyz'))
True
>>> is_coordsys(CoordSysMaker('ikj'))
False
"""
if not hasattr(obj, 'coord_names'):
return False
if not hasattr(obj, 'name'):
return False
if not hasattr(obj, 'coord_dtype'):
return False
# Distinguish from CoordSysMaker
return not callable(obj)
def safe_dtype(*dtypes):
"""Determine a dtype to safely cast all of the given dtypes to.
Safe dtypes are valid numpy dtypes or python types which can be
cast to numpy dtypes. See numpy.sctypes for a list of valid
dtypes. Composite dtypes and string dtypes are not safe dtypes.
Parameters
----------
dtypes : sequence of ``np.dtype``
Returns
-------
dtype : np.dtype
Examples
--------
>>> c1 = CoordinateSystem('ij', 'input', coord_dtype=np.float32)
>>> c2 = CoordinateSystem('kl', 'input', coord_dtype=np.complex)
>>> safe_dtype(c1.coord_dtype, c2.coord_dtype)
dtype('complex128')
>>> # Strings are invalid dtypes
>>> safe_dtype(type('foo'))
Traceback (most recent call last):
...
TypeError: dtype must be valid numpy dtype int, uint, float, complex or object
>>> # Check for a valid dtype
>>> myarr = np.zeros(2, np.float32)
>>> myarr.dtype.isbuiltin
1
>>> # Composite dtypes are invalid
>>> mydtype = np.dtype([('name', 'S32'), ('age', 'i4')])
>>> myarr = np.zeros(2, mydtype)
>>> myarr.dtype.isbuiltin
0
>>> safe_dtype(mydtype)
Traceback (most recent call last):
...
TypeError: dtype must be valid numpy dtype int, uint, float, complex or object
"""
arrays = [np.zeros(2, dtype) for dtype in dtypes]
kinds = [a.dtype.kind for a in arrays]
if not set(kinds).issubset('iubfcO'):
raise TypeError('dtype must be valid numpy dtype '
'int, uint, float, complex or object')
return np.array(arrays).dtype
def product(*coord_systems, **kwargs):
r"""Create the product of a sequence of CoordinateSystems.
The coord_dtype of the result will be determined by ``safe_dtype``.
Parameters
----------
\*coord_systems : sequence of :class:`CoordinateSystem`
name : str
Name of ouptut coordinate system
Returns
-------
product_coord_system : :class:`CoordinateSystem`
Examples
--------
>>> c1 = CoordinateSystem('ij', 'input', coord_dtype=np.float32)
>>> c2 = CoordinateSystem('kl', 'input', coord_dtype=np.complex)
>>> c3 = CoordinateSystem('ik', 'in3')
>>> print(product(c1, c2))
CoordinateSystem(coord_names=('i', 'j', 'k', 'l'), name='product', coord_dtype=complex128)
>>> print(product(c1, c2, name='another name'))
CoordinateSystem(coord_names=('i', 'j', 'k', 'l'), name='another name', coord_dtype=complex128)
>>> product(c2, c3)
Traceback (most recent call last):
...
ValueError: coord_names must have distinct names
"""
name = kwargs.pop('name', 'product')
if kwargs:
raise TypeError('Unexpected kwargs %s' % kwargs)
coords = []
for c in coord_systems:
coords += c.coord_names
dtype = safe_dtype(*[c.coord_dtype for c in coord_systems])
return CoordinateSystem(coords, name, coord_dtype=dtype)
class CoordSysMakerError(Exception):
pass
class CoordSysMaker(object):
""" Class to create similar coordinate maps of different dimensions
"""
coord_sys_klass = CoordinateSystem
def __init__(self, coord_names, name='', coord_dtype=np.float):
"""Create a coordsys maker with given axis `coord_names`
Parameters
----------
coord_names : iterable
A sequence of coordinate names.
name : string, optional
The name of the coordinate system
coord_dtype : np.dtype, optional
The dtype of the coord_names. This should be a built-in
numpy scalar dtype. (default is np.float). The value can
by anything that can be passed to the np.dtype constructor.
For example ``np.float``, ``np.dtype(np.float)`` or ``f8``
all result in the same ``coord_dtype``.
Examples
--------
>>> cmkr = CoordSysMaker('ijk', 'a name')
>>> print(cmkr(2))
CoordinateSystem(coord_names=('i', 'j'), name='a name', coord_dtype=float64)
>>> print(cmkr(3))
CoordinateSystem(coord_names=('i', 'j', 'k'), name='a name', coord_dtype=float64)
"""
self.coord_names = tuple(coord_names)
self.name = name
self.coord_dtype = coord_dtype
def __call__(self, N, name=None, coord_dtype=None):
""" Create coordinate system of length `N`
Parameters
----------
N : int
length of coordinate map
name : None or str, optional
Name of coordinate map. Default is ``self.name``
coord_dtype : None or dtype
``coord_dtype`` of returned coordinate system. Default is
``self.coord_dtype``
Returns
-------
csys : coordinate system
"""
if name is None:
name = self.name
if coord_dtype is None:
coord_dtype = self.coord_dtype
if N > len(self.coord_names):
raise CoordSysMakerError('Not enough axis names (have %d, '
'you asked for %d)' %
(len(self.coord_names), N))
return self.coord_sys_klass(self.coord_names[:N], name, coord_dtype)
def is_coordsys_maker(obj):
""" Test if `obj` has the CoordSysMaker API
Parameters
----------
obj : object
Object to test
Returns
-------
tf : bool
True if `obj` has the coordinate system API
Examples
--------
>>> is_coordsys_maker(CoordSysMaker('ikj'))
True
>>> is_coordsys_maker(CoordinateSystem('xyz'))
False
"""
if not hasattr(obj, 'coord_names'):
return False
if not hasattr(obj, 'name'):
return False
if not hasattr(obj, 'coord_dtype'):
return False
# Distinguish from CoordinateSystem
return callable(obj)