diff --git a/nd2reader/__init__.py b/nd2reader/__init__.py index befc5ae..754c6ef 100644 --- a/nd2reader/__init__.py +++ b/nd2reader/__init__.py @@ -18,10 +18,10 @@ class Nd2(Nd2Parser): return "\n".join(["" % self._filename, "Created: %s" % self._absolute_start.strftime("%Y-%m-%d %H:%M:%S"), "Image size: %sx%s (HxW)" % (self.height, self.width), - "Image cycles: %s" % self._time_index_count, - "Channels: %s" % ", ".join(["'%s'" % str(channel) for channel in self._channels]), - "Fields of View: %s" % self._field_of_view_count, - "Z-Levels: %s" % self._z_level_count + "Image cycles: %s" % len(self.time_indexes), + "Channels: %s" % ", ".join(["'%s'" % str(channel) for channel in self.channels]), + "Fields of View: %s" % len(self.fields_of_view), + "Z-Levels: %s" % len(self.z_levels) ]) def __len__(self): @@ -34,6 +34,41 @@ class Nd2(Nd2Parser): """ return self._image_count * self._channel_count + def __getitem__(self, frame_number): + if isinstance(frame_number, int): + try: + channel_offset = frame_number % len(self.channels) + fov = self._calculate_field_of_view(frame_number) + channel = self._calculate_channel(frame_number) + z_level = self._calculate_z_level(frame_number) + timestamp, raw_image_data = self._get_raw_image_data(frame_number, channel_offset) + image = Image(timestamp, raw_image_data, fov, channel, z_level, self.height, self.width) + except (TypeError, ValueError): + return None + else: + return image + + @property + def image_sets(self): + """ + Iterates over groups of related images. This is useful if your ND2 contains multiple fields of view. + A typical use case might be that you have, say, four areas of interest that you're monitoring, and every + minute you take a bright field and GFP image of each one. For each cycle, this method would produce four + ImageSet objects, each containing one bright field and one GFP image. + + :return: model.ImageSet() + + """ + for time_index in self.time_indexes: + image_set = ImageSet() + for fov in self.fields_of_view: + for channel_name in self.channels: + for z_level in self.z_levels: + image = self.get_image(time_index, fov, channel_name, z_level) + if image is not None: + image_set.add(image) + yield image_set + @property def height(self): """ @@ -52,41 +87,15 @@ class Nd2(Nd2Parser): """ return self.metadata[six.b('ImageAttributes')][six.b('SLxImageAttributes')][six.b('uiWidth')] - def __iter__(self): - """ - Iterates over every image, in the order they were taken. - - :return: model.Image() + def _calculate_field_of_view(self, frame_number): + images_per_cycle = len(self.z_levels) * len(self.channels) + return int((frame_number - (frame_number % images_per_cycle)) / images_per_cycle) % len(self.fields_of_view) - """ - for i in range(self._image_count): - for fov in range(self._field_of_view_count): - for z_level in range(self._z_level_count): - for channel_name in self._channels: - image = self.get_image(i, fov, channel_name, z_level) - if image is not None: - yield image + def _calculate_channel(self, frame_number): + return self._channels[frame_number % len(self.channels)] - @property - def image_sets(self): - """ - Iterates over groups of related images. This is useful if your ND2 contains multiple fields of view. - A typical use case might be that you have, say, four areas of interest that you're monitoring, and every - minute you take a bright field and GFP image of each one. For each cycle, this method would produce four - ImageSet objects, each containing one bright field and one GFP image. - - :return: model.ImageSet() - - """ - for time_index in range(self._time_index_count): - image_set = ImageSet() - for fov in range(self._field_of_view_count): - for channel_name in self._channels: - for z_level in range(self._z_level_count): - image = self.get_image(time_index, fov, channel_name, z_level) - if image is not None: - image_set.add(image) - yield image_set + def _calculate_z_level(self, frame_number): + return self.z_levels[int(((frame_number - (frame_number % len(self.channels))) / len(self.channels)) % len(self.z_levels))] def get_image(self, time_index, field_of_view, channel_name, z_level): """ @@ -105,9 +114,9 @@ class Nd2(Nd2Parser): :rtype: nd2reader.model.Image() or None """ - image_set_number = self._calculate_image_group_number(time_index, field_of_view, z_level) + image_group_number = self._calculate_image_group_number(time_index, field_of_view, z_level) try: - timestamp, raw_image_data = self._get_raw_image_data(image_set_number, self._channel_offset[channel_name]) + timestamp, raw_image_data = self._get_raw_image_data(image_group_number, self._channel_offset[channel_name]) image = Image(timestamp, raw_image_data, field_of_view, channel_name, z_level, self.height, self.width) except TypeError: return None diff --git a/nd2reader/parser.py b/nd2reader/parser.py index a5961bc..5aabcaa 100644 --- a/nd2reader/parser.py +++ b/nd2reader/parser.py @@ -21,13 +21,18 @@ class Nd2Parser(object): def __init__(self, filename): self._filename = filename self._fh = None + self._channels = None + self._channel_count = None self._chunk_map_start_location = None self._cursor_position = 0 self._dimension_text = None + self._fields_of_view = None self._label_map = {} self.metadata = {} self._read_map() + self._time_indexes = None self._parse_metadata() + self._z_levels = None @property def _file_handle(self): @@ -58,7 +63,7 @@ class Nd2Parser(object): # The images for the various channels are interleaved within the same array. For example, the second image # of a four image group will be composed of bytes 2, 6, 10, etc. If you understand why someone would design # a data structure that way, please send the author of this library a message. - image_data = image_group_data[image_data_start::self._channel_count] + image_data = image_group_data[image_data_start::len(self.channels)] # Skip images that are all zeros! This is important, since NIS Elements creates blank "gap" images if you # don't have the same number of images each cycle. We discovered this because we only took GFP images every # other cycle to reduce phototoxicity, but NIS Elements still allocated memory as if we were going to take @@ -90,12 +95,10 @@ class Nd2Parser(object): break else: raise ValueError("Could not parse metadata dimensions!") - if six.PY3: - return self._dimension_text.decode("utf8") return self._dimension_text @property - def _channels(self): + def channels(self): """ These are labels created by the NIS Elements user. Typically they may a short description of the filter cube used (e.g. "bright field", "GFP", etc.) @@ -103,19 +106,22 @@ class Nd2Parser(object): :rtype: str """ - metadata = self.metadata[six.b('ImageMetadataSeq')][six.b('SLxPictureMetadata')][six.b('sPicturePlanes')] - try: - validity = self.metadata[six.b('ImageMetadata')][six.b('SLxExperiment')][six.b('ppNextLevelEx')][six.b('')][0][six.b('ppNextLevelEx')][six.b('')][0][six.b('pItemValid')] - except KeyError: - # If none of the channels have been deleted, there is no validity list, so we just make one - validity = [True for _ in metadata] - # Channel information is contained in dictionaries with the keys a0, a1...an where the number - # indicates the order in which the channel is stored. So by sorting the dicts alphabetically - # we get the correct order. - for (label, chan), valid in zip(sorted(metadata[six.b('sPlaneNew')].items()), validity): - if not valid: - continue - yield chan[six.b('sDescription')].decode("utf8") + if not self._channels: + self._channels = [] + metadata = self.metadata[six.b('ImageMetadataSeq')][six.b('SLxPictureMetadata')][six.b('sPicturePlanes')] + try: + validity = self.metadata[six.b('ImageMetadata')][six.b('SLxExperiment')][six.b('ppNextLevelEx')][six.b('')][0][six.b('ppNextLevelEx')][six.b('')][0][six.b('pItemValid')] + except KeyError: + # If none of the channels have been deleted, there is no validity list, so we just make one + validity = [True for _ in metadata] + # Channel information is contained in dictionaries with the keys a0, a1...an where the number + # indicates the order in which the channel is stored. So by sorting the dicts alphabetically + # we get the correct order. + for (label, chan), valid in zip(sorted(metadata[six.b('sPlaneNew')].items()), validity): + if not valid: + continue + self._channels.append(chan[six.b('sDescription')].decode("utf8")) + return self._channels def _calculate_image_group_number(self, time_index, fov, z_level): """ @@ -128,7 +134,7 @@ class Nd2Parser(object): :rtype: int """ - return time_index * self._field_of_view_count * self._z_level_count + (fov * self._z_level_count + z_level) + return time_index * len(self.fields_of_view) * len(self.z_levels) + (fov * len(self.z_levels) + z_level) @property def _channel_offset(self): @@ -170,24 +176,19 @@ class Nd2Parser(object): return absolute_start_12 if absolute_start_12 else absolute_start_24 raise ValueError("This ND2 has no recorded start time. This is probably a bug.") - @property - def _channel_count(self): - """ - The number of different channels used, including bright field. - - :rtype: int - - """ - pattern = r""".*?λ\((\d+)\).*?""" + def _parse_dimension_text(self, pattern): try: count = int(re.match(pattern, self._dimensions).group(1)) - except AttributeError as e: - return 1 + except AttributeError: + return [0] + except TypeError: + count = int(re.match(pattern, self._dimensions.decode("utf8")).group(1)) + return list(range(count)) else: - return count + return list(range(count)) @property - def _field_of_view_count(self): + def fields_of_view(self): """ The metadata contains information about fields of view, but it contains it even if some fields of view were cropped. We can't find anything that states which fields of view are actually @@ -197,45 +198,33 @@ class Nd2Parser(object): :rtype: int """ - pattern = r""".*?XY\((\d+)\).*?""" - try: - count = int(re.match(pattern, self._dimensions).group(1)) - except AttributeError: - return 1 - else: - return count + if self._fields_of_view is None: + self._fields_of_view = self._parse_dimension_text(r""".*?XY\((\d+)\).*?""") + return self._fields_of_view @property - def _time_index_count(self): + def time_indexes(self): """ The number of cycles. :rtype: int """ - pattern = r""".*?T'\((\d+)\).*?""" - try: - count = int(re.match(pattern, self._dimensions).group(1)) - except AttributeError: - return 1 - else: - return count + if self._time_indexes is None: + self._time_indexes = self._parse_dimension_text(r""".*?T'\((\d+)\).*?""") + return self._time_indexes @property - def _z_level_count(self): + def z_levels(self): """ - The number of different levels in the Z-plane. + The different levels in the Z-plane. Just a sequence from 0 to n. :rtype: int """ - pattern = r""".*?Z\((\d+)\).*?""" - try: - count = int(re.match(pattern, self._dimensions).group(1)) - except AttributeError: - return 1 - else: - return count + if self._z_levels is None: + self._z_levels = self._parse_dimension_text(r""".*?Z\((\d+)\).*?""") + return self._z_levels @property def _image_count(self): @@ -376,4 +365,4 @@ class Nd2Parser(object): # We've encountered this key before so we're guaranteed to be dealing with a list. Thus we append # the value to the already-existing list. metadata[name].append(value) - return metadata \ No newline at end of file + return metadata diff --git a/setup.py b/setup.py index d250eec..ee88e1d 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from distutils.core import setup -VERSION = "1.0.1" +VERSION = "1.1.0" setup( name="nd2reader",