diff --git a/fsl/data/imagewrapper.py b/fsl/data/imagewrapper.py index ad71e91035928912cd3eb190f7f2a5b746b90dd7..3d9e5010821d5ee7cbae1a0b1bd6995948188d51 100644 --- a/fsl/data/imagewrapper.py +++ b/fsl/data/imagewrapper.py @@ -58,6 +58,18 @@ class ImageWrapper(notifier.Notifier): self.__image = image self.__name = name + # Save the number of 'real' dimensions, + # that is the number of dimensions minus + # any trailing dimensions of length 1 + self.__numRealDims = len(image.shape) + for d in reversed(image.shape): + if d == 1: self.__numRealDims -= 1 + else: break + + # And save the number of + # 'padding' dimensions too. + self.__numPadDims = len(image.shape) - self.__numRealDims + hdr = image.get_header() # The current known image data range. This @@ -65,16 +77,26 @@ class ImageWrapper(notifier.Notifier): # We default to whatever is stored in the # header (which may or may not contain useful # values). - self.__range = (float(hdr['cal_min']), - float(hdr['cal_max'])) - - # We record the portions of the image that have + self.__range = (float(hdr['cal_min']), float(hdr['cal_max'])) + + # For each entry in the last (real) dimension of + # the image (slice for 3D or volume for 4D), we + # record the portions of the image that have # been included in the data range calculation, so # we do not unnecessarily re-calculate ranges on - # the same part of the image. This is a list of - # (low, high) pairs, one pair for each dimension - # in the image data. - self.__sliceCoverage = [[None, None] for i in range(len(image.shape))] + # the same part of the image. + self.__sliceCoverage = [] + + # This is a list of lists of (low, high) pairs, + # one list for each entry in the last dimension + # (e.g. one list per 2D slice or 3D volume), and + # one pair for each dimension in the entry (e.g. + # row/column for each slice, or row/column/depth + # for each volume). + for i in range(image.shape[self.__numRealDims - 1]): + + cov = [[None, None] for i in range(self.__numRealDims - 1)] + self.__sliceCoverage.append(cov) if loadData: self.loadData() @@ -101,79 +123,26 @@ class ImageWrapper(notifier.Notifier): self.__image.get_data() - def __sanitiseSlices(self, slices): - """Turns an array slice object into a tuple of (low, high) index - pairs, one pair for each dimension in the image data. - """ - - indices = [] - - for dim, s in enumerate(slices): - - # each element in the slices tuple should - # be a slice object or an integer - if isinstance(s, slice): i = [s.start, s.stop] - else: i = [s, s + 1] - - if i[0] is None: i[0] = 0 - if i[1] is None: i[1] = self.__image.shape[dim] - - indices.append(tuple(i)) - - return tuple(indices) - - - def __sliceCovered(self, slices): - """Returns ``True`` if the portion of the image data calculated by - the given ``slices` has already been calculated, ``False`` otherwise. + def __getData(self, sliceobj, isTuple=False): """ - - if self.__range == (None, None): - return False - - # TODO You could adjust the slice so that - # it only spans the portion of the - # image that has not yet been covered, - # and return it to minimise the portion - # of the image over which the range is - # updated. - for dim, size in enumerate(self.__image.shape): - - lowCover, highCover = self.__sliceCoverage[dim] - - if lowCover is None or highCover is None: - return False - - lowSlice, highSlice = slices[dim] - - if lowSlice is None: lowSlice = 0 - if highSlice is None: highSlice = self.__image.shape[dim] - - if lowSlice < lowCover: return False - if highSlice > highCover: return False - - return True - - - def __updateSliceCoverage(self, slices): - """Updates the known portion of the image (with respect to the image - data range) according to the given set of slice indices. - - :arg slices: A sequence of ``(low, high)`` index pairs, one for each - dimension in the image. """ - for dim, (lowSlc, highSlc) in enumerate(slices): + if isTuple: + sliceobj = sliceTupletoSliceObj(sliceobj) - lowCov, highCov = self.__sliceCoverage[dim] - - if lowSlc is None: lowSlc = 0 - if highSlc is None: highSlc = self.__image.shape[dim] - - if lowCov is None or lowSlc < lowCov: lowCov = lowSlc - if highCov is None or highSlc > highCov: highCov = highSlc - - self.__sliceCoverage[dim] = [lowCov, highCov] + # If the image has not been loaded + # into memory, we can use the nibabel + # ArrayProxy. Otheriwse if it is in + # memory, we can access it directly. + # + # Furthermore, if it is in memory and + # has been modified, the ArrayProxy + # will give us out-of-date values (as + # the ArrayProxy reads from disk). So + # we have to read from the in-memory + # array to get changed values. + if self.__image.in_memory: return self.__image.get_data()[sliceobj] + else: return self.__image.dataobj[ sliceobj] @memoize.Instanceify(memoize.memoize(args=[0])) @@ -187,6 +156,9 @@ class ImageWrapper(notifier.Notifier): dimension in the image. Tuples are used instead of ``slice`` objects, because this method is memoized (and ``slice`` objects are unhashable). + + :arg data: The image data at the given ``slices`` (as a ``numpy`` + array). """ oldmin, oldmax = self.__range @@ -198,24 +170,42 @@ class ImageWrapper(notifier.Notifier): self.__range[1], self.__sliceCoverage)) - dmin = float(np.nanmin(data)) - dmax = float(np.nanmax(data)) + volumes, expansions = calcSliceExpansion(slices, + self.__sliceCoverage, + self.__numRealDims, + self.__numPadDims) + + newmin = oldmin + newmax = oldmax - if oldmin is None or dmin < oldmin: newmin = dmin - else: newmin = oldmin + for vol, exp in zip(volumes, expansions): - if oldmax is None or dmax > oldmax: newmax = dmax - else: newmax = oldmax + data = self.__getData(exp, isTuple=True) + dmin = float(np.nanmin(data)) + dmax = float(np.nanmax(data)) + + if newmin is None or dmin < newmin: newmin = dmin + if newmax is None or dmax > newmax: newmax = dmax self.__range = (newmin, newmax) - self.__updateSliceCoverage(slices) + for vol, exp in zip(volumes, expansions): + self.__sliceCoverage[vol] = adjustSliceCoverage( + self.__sliceCoverage[vol], + exp, + self.__numRealDims) + + # TODO floating point error if newmin != oldmin or newmax != oldmax: - log.debug('Image {} range changed: [{}, {}]'.format( - self.__name, self.__range[0], self.__range[1])) + log.debug('Image {} range changed: [{}, {}] -> [{}, {}]'.format( + self.__name, + oldmin, + oldmax, + newmin, + newmax)) self.notify() - + def __getitem__(self, sliceobj): """Returns the image data for the given ``sliceobj``, and updates the known image data range if necessary. @@ -236,23 +226,141 @@ class ImageWrapper(notifier.Notifier): # TODO Cache 3D images for large 4D volumes, # so you don't have to hit the disk? - # If the image has not been loaded - # into memory, we can use the nibabel - # ArrayProxy. Otheriwse if it is in - # memory, we can access it directly. - # - # Furthermore, if it is in memory and - # has been modified, the ArrayProxy - # will give us out-of-date values (as - # the ArrayProxy reads from disk). So - # we have to read from the in-memory - # array to get changed values. - if self.__image.in_memory: data = self.__image.get_data()[sliceobj] - else: data = self.__image.dataobj[ sliceobj] + data = self.__getData(sliceobj) + + # TODO If full range is + # known, return now. - slices = self.__sanitiseSlices(sliceobj) + slices = sliceObjToSliceTuple(sliceobj, self.__image.shape) - if not self.__sliceCovered(slices): + if not sliceCovered(slices, + self.__sliceCoverage, + self.__image.shape, + self.__numRealDims): + self.__updateDataRangeOnRead(slices, data) return data + + + +def sliceObjToSliceTuple(sliceobj, shape): + """Turns an array slice object into a tuple of (low, high) index + pairs, one pair for each dimension in the given shape + """ + + indices = [] + + for dim, s in enumerate(sliceobj): + + # each element in the slices tuple should + # be a slice object or an integer + if isinstance(s, slice): i = [s.start, s.stop] + else: i = [s, s + 1] + + if i[0] is None: i[0] = 0 + if i[1] is None: i[1] = shape[dim] + + indices.append(tuple(i)) + + return tuple(indices) + + +def sliceTupletoSliceObj(slices): + """ + """ + + sliceobj = [] + + for lo, hi in slices: + sliceobj.append(slice(lo, hi, 1)) + + return tuple(sliceobj) + + + +def sliceCovered(slices, sliceCoverage, shape, realDims): + """Returns ``True`` if the portion of the image data calculated by + the given ``slices` has already been calculated, ``False`` otherwise. + """ + + lowVol, highVol = slices[realDims - 1] + shape = shape[:realDims - 1] + + for vol in range(lowVol, highVol): + + coverage = sliceCoverage[vol] + + for dim, size in enumerate(shape): + + lowCover, highCover = coverage[dim] + + if lowCover is None or highCover is None: + return False + + lowSlice, highSlice = slices[dim] + + if lowSlice is None: lowSlice = 0 + if highSlice is None: highSlice = size + + if lowSlice < lowCover: return False + if highSlice > highCover: return False + + return True + + +def calcSliceExpansion(slices, sliceCoverage, realDims, padDims): + """ + """ + + # One per volume + lowVol, highVol = slices[realDims - 1] + + expansions = [] + volumes = list(range(lowVol, highVol)) + + # TODO Reduced slice duplication. + # You know what this means. + + for vol in volumes: + + coverage = sliceCoverage[vol] + expansion = [] + + for dim in range(realDims - 1): + + lowCover, highCover = coverage[dim] + lowSlice, highSlice = slices[ dim] + + if lowCover is None: lowCover = lowSlice + if highCover is None: highCover = highSlice + + expansion.append((min(lowCover, lowSlice), + max(highCover, highSlice))) + + expansion.append((vol, vol + 1)) + for i in range(padDims): + expansion.append((0, 1)) + + expansions.append(expansion) + + return volumes, expansions + + +def adjustSliceCoverage(oldCoverage, slices, realDims): + """ + """ + + newCoverage = [] + + for dim in range(realDims - 1): + + low, high = slices[ dim] + lowCover, highCover = oldCoverage[dim] + + if lowCover is None or low < lowCover: lowCover = low + if highCover is None or high < highCover: highCover = high + + newCoverage.append((lowCover, highCover)) + + return newCoverage