From cb2c06f9a592fe421a831862f35c97281742e897 Mon Sep 17 00:00:00 2001 From: Ruben Verweij Date: Fri, 17 Feb 2017 11:19:14 +0100 Subject: [PATCH] Complete refactor - use only one parser for v3 files - merge some classes - return metadata as raw metadata and dictionary --- nd2reader/{common/v3.py => common.py} | 70 ++++ nd2reader/common/__init__.py | 0 nd2reader/driver/__init__.py | 0 nd2reader/{model/label.py => label_map.py} | 0 nd2reader/legacy.py | 236 ----------- nd2reader/model/__init__.py | 1 - nd2reader/model/image.py | 135 ------ nd2reader/model/metadata.py | 106 ----- nd2reader/model/roi.py | 81 ---- nd2reader/{driver/v3.py => nd2parser.py} | 158 +++++-- nd2reader/nd2reader.py | 83 ++-- nd2reader/parser/__init__.py | 1 - nd2reader/parser/base.py | 17 - nd2reader/parser/parser.py | 55 --- nd2reader/{parser/v3.py => raw_metadata.py} | 430 ++++++++++---------- test.py | 2 +- tests/driver/__init__.py | 0 tests/model/__init__.py | 0 tests/model/image.py | 42 -- tests/{driver/version.py => test_common.py} | 5 +- tests/{version.py => test_version.py} | 2 +- 21 files changed, 441 insertions(+), 983 deletions(-) rename nd2reader/{common/v3.py => common.py} (67%) delete mode 100644 nd2reader/common/__init__.py delete mode 100644 nd2reader/driver/__init__.py rename nd2reader/{model/label.py => label_map.py} (100%) delete mode 100644 nd2reader/legacy.py delete mode 100644 nd2reader/model/__init__.py delete mode 100644 nd2reader/model/image.py delete mode 100644 nd2reader/model/metadata.py delete mode 100644 nd2reader/model/roi.py rename nd2reader/{driver/v3.py => nd2parser.py} (51%) delete mode 100644 nd2reader/parser/__init__.py delete mode 100644 nd2reader/parser/base.py delete mode 100644 nd2reader/parser/parser.py rename nd2reader/{parser/v3.py => raw_metadata.py} (54%) delete mode 100644 tests/driver/__init__.py delete mode 100644 tests/model/__init__.py delete mode 100644 tests/model/image.py rename tests/{driver/version.py => test_common.py} (83%) rename tests/{version.py => test_version.py} (89%) diff --git a/nd2reader/common/v3.py b/nd2reader/common.py similarity index 67% rename from nd2reader/common/v3.py rename to nd2reader/common.py index 65222c3..5ac2010 100644 --- a/nd2reader/common/v3.py +++ b/nd2reader/common.py @@ -1,6 +1,42 @@ import struct import array +from datetime import datetime import six +import re +from nd2reader.exc import InvalidVersionError + + +def get_version(fh): + """ + Determines what version the ND2 is. + + :param fh: an open file handle to the ND2 + :type fh: file + + """ + # the first 16 bytes seem to have no meaning, so we skip them + fh.seek(16) + + # the next 38 bytes contain the string that we want to parse. Unlike most of the ND2, this is in UTF-8 + data = fh.read(38).decode("utf8") + return parse_version(data) + + +def parse_version(data): + """ + Parses a string with the version data in it. + + :param data: the 19th through 54th byte of the ND2, representing the version + :type data: unicode + + """ + match = re.search(r"""^ND2 FILE SIGNATURE CHUNK NAME01!Ver(?P\d)\.(?P\d)$""", data) + + if match: + # We haven't seen a lot of ND2s but the ones we have seen conform to this + return int(match.group('major')), int(match.group('minor')) + + raise InvalidVersionError("The version of the ND2 you specified is not supported.") def read_chunk(fh, chunk_location): @@ -69,6 +105,40 @@ def _parse_char_array(data): return array.array("B", data.read(array_length)) +def parse_date(text_info): + """ + The date and time when acquisition began. + + :rtype: datetime.datetime() or None + + """ + for line in text_info.values(): + line = line.decode("utf8") + # ND2s seem to randomly switch between 12- and 24-hour representations. + absolute_start_24 = _parse_date_24h(line) + absolute_start_12 = _parse_date_12h(line) + if not absolute_start_12 and not absolute_start_24: + continue + return absolute_start_12 if absolute_start_12 else absolute_start_24 + return None + + +def _parse_date_12h(line): + try: + absolute_start_12 = datetime.strptime(line, "%m/%d/%Y %I:%M:%S %p") + return absolute_start_12 + except (TypeError, ValueError): + return None + + +def _parse_date_24h(line): + try: + absolute_start_24 = datetime.strptime(line, "%m/%d/%Y %H:%M:%S") + return absolute_start_24 + except (TypeError, ValueError): + return None + + def _parse_metadata_item(data, cursor_position): """ Reads hierarchical data, analogous to a Python dict. diff --git a/nd2reader/common/__init__.py b/nd2reader/common/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/nd2reader/driver/__init__.py b/nd2reader/driver/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/nd2reader/model/label.py b/nd2reader/label_map.py similarity index 100% rename from nd2reader/model/label.py rename to nd2reader/label_map.py diff --git a/nd2reader/legacy.py b/nd2reader/legacy.py deleted file mode 100644 index 2273161..0000000 --- a/nd2reader/legacy.py +++ /dev/null @@ -1,236 +0,0 @@ -# -*- coding: utf-8 -*- - -from nd2reader.parser import get_parser, get_version -import six - - -class Nd2(object): - """ Allows easy access to NIS Elements .nd2 image files. """ - - def __init__(self, filename): - self._filename = filename - self._fh = open(filename, "rb") - major_version, minor_version = get_version(self._fh) - self._parser = get_parser(self._fh, major_version, minor_version) - self._metadata = self._parser.metadata - self._roi_metadata = self._parser.roi_metadata - - def __repr__(self): - return "\n".join(["" % self._filename, - "Created: %s" % (self.date if self.date is not None else "Unknown"), - "Image size: %sx%s (HxW)" % (self.height, self.width), - "Frames: %s" % len(self.frames), - "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 __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if self._fh is not None: - self._fh.close() - - def __len__(self): - """ - This should be the total number of images in the ND2, but it may be inaccurate. If the ND2 contains a - different number of images in a cycle (i.e. there are "gap" images) it will be higher than reality. - - :rtype: int - - """ - return self._metadata.total_images_per_channel * len(self.channels) - - def __getitem__(self, item): - """ - Allows slicing ND2s. - - :type item: int or slice - :rtype: nd2reader.model.Image() or generator - - """ - if isinstance(item, int): - try: - image = self._parser.driver.get_image(item) - except KeyError: - raise IndexError - else: - return image - elif isinstance(item, slice): - return self._slice(item.start, item.stop, item.step) - raise IndexError - - def select(self, fields_of_view=None, channels=None, z_levels=None, start=0, stop=None): - """ - Iterates over images matching the given criteria. This can be 2-10 times faster than manually iterating over - the Nd2 and checking the attributes of each image, as this method skips disk reads for any images that don't - meet the criteria. - - :type fields_of_view: int or tuple or list - :type channels: str or tuple or list - :type z_levels: int or tuple or list - :type start: int - :type stop: int - - """ - fields_of_view = self._to_tuple(fields_of_view, self.fields_of_view) - channels = self._to_tuple(channels, self.channels) - z_levels = self._to_tuple(z_levels, self.z_levels) - - # By default, we stop after the last image. Otherwise we make sure the user-provided value is valid - stop = len(self) if stop is None else max(0, min(stop, len(self))) - for frame in range(start, stop): - field_of_view, channel, z_level = self._parser.driver.calculate_image_properties(frame) - if field_of_view in fields_of_view and channel in channels and z_level in z_levels: - image = self._parser.driver.get_image(frame) - if image is not None: - yield image - - @property - def height(self): - """ - The height of each image in pixels. - - :rtype: int - - """ - return self._metadata.height - - @property - def width(self): - """ - The width of each image in pixels. - - :rtype: int - - """ - return self._metadata.width - - @property - def z_levels(self): - """ - A list of integers that represent the different levels on the Z-axis that images were taken. Currently this is - just a list of numbers from 0 to N. For example, an ND2 where images were taken at -3µm, 0µm, and +5µm from a - set position would be represented by 0, 1 and 2, respectively. ND2s do store the actual offset of each image - in micrometers and in the future this will hopefully be available. For now, however, you will have to match up - the order yourself. - - :return: list of int - - """ - return self._metadata.z_levels - - @property - def fields_of_view(self): - """ - A list of integers representing the various stage locations, in the order they were taken in the first round - of acquisition. - - :return: list of int - - """ - return self._metadata.fields_of_view - - @property - def channels(self): - """ - A list of channel (i.e. wavelength) names. These are set by the user in NIS Elements. - - :return: list of str - - """ - return self._metadata.channels - - @property - def frames(self): - """ - A list of integers representing groups of images. ND2s consider images to be part of the same frame if they - are in the same field of view and don't have the same channel. So if you take a bright field and GFP image at - four different fields of view over and over again, you'll have 8 images and 4 frames per cycle. - - :return: list of int - - """ - return self._metadata.frames - - @property - def date(self): - """ - The date and time that the acquisition began. Not guaranteed to have been recorded. - - :rtype: datetime.datetime() or None - - """ - return self._metadata.date - - @property - def pixel_microns(self): - """ - The width of a pixel in microns. Note that the user can override this in NIS Elements so it may not reflect reality. - - :rtype: float - - """ - return self._metadata.pixel_microns - - def get_image(self, frame_number, field_of_view, channel_name, z_level): - """ - Attempts to return the image with the unique combination of given attributes. None will be returned if a match - is not found. - - :type frame_number: int - :param field_of_view: the label for the place in the XY-plane where this image was taken. - :type field_of_view: int - :param channel_name: the name of the color of this image - :type channel_name: str - :param z_level: the label for the location in the Z-plane where this image was taken. - :type z_level: int - - :rtype: nd2reader.model.Image() or None - - """ - return self._parser.driver.get_image_by_attributes(frame_number, - field_of_view, - channel_name, - z_level, - self.height, - self.width) - - def close(self): - """ - Closes the file handle to the image. This actually sometimes will prevent problems so it's good to do this or - use Nd2 as a context manager. - - """ - self._fh.close() - - def _slice(self, start, stop, step): - """ - Allows for iteration over a selection of the entire dataset. - - :type start: int - :type stop: int - :type step: int - :rtype: nd2reader.model.Image() - - """ - start = start if start is not None else 0 - step = step if step is not None else 1 - stop = stop if stop is not None else len(self) - # This weird thing with the step allows you to iterate backwards over the images - for i in range(start, stop)[::step]: - yield self[i] - - def _to_tuple(self, value, default): - """ - Idempotently converts a value to a tuple. This allows users to pass in scalar values and iterables to - select(), which is more ergonomic than having to remember to pass in single-member lists - - :type value: int or str or tuple or list - :type default: tuple or list - :rtype: tuple - - """ - value = default if value is None else value - return (value,) if isinstance(value, int) or isinstance(value, six.string_types) else tuple(value) diff --git a/nd2reader/model/__init__.py b/nd2reader/model/__init__.py deleted file mode 100644 index e6548e2..0000000 --- a/nd2reader/model/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from nd2reader.model.image import Image diff --git a/nd2reader/model/image.py b/nd2reader/model/image.py deleted file mode 100644 index eea244e..0000000 --- a/nd2reader/model/image.py +++ /dev/null @@ -1,135 +0,0 @@ -# -*- coding: utf-8 -*- - -import numpy as np - - -class Image(np.ndarray): - """ - Holds the raw pixel data of an image and provides access to some metadata. - - """ - def __new__(cls, array): - return np.asarray(array).view(cls) - - def __init__(self, array): - self._index = None - self._timestamp = None - self._frame_number = None - self._field_of_view = None - self._channel = None - self._z_level = None - - def __array_wrap__(self, obj, *args): - if len(obj.shape) == 0: - return obj[()] - else: - return obj - - def add_params(self, index, timestamp, frame_number, field_of_view, channel, z_level): - """ - :param index: The integer that can be used to directly index this image - :type index: int - :param timestamp: The number of milliseconds after the beginning of the acquisition that this image was taken. - :type timestamp: float - :param frame_number: The order in which this image was taken, with images of different channels/z-levels - at the same field of view treated as being in the same frame. - :type frame_number: int - :param field_of_view: The label for the place in the XY-plane where this image was taken. - :type field_of_view: int - :param channel: The name of the color of this image - :type channel: str - :param z_level: The label for the location in the Z-plane where this image was taken. - :type z_level: int - - """ - self._index = index - self._timestamp = timestamp - self._frame_number = int(frame_number) - self._field_of_view = field_of_view - self._channel = channel - self._z_level = z_level - - @property - def index(self): - return self._index - - @property - def height(self): - """ - The height in pixels. - - :rtype: int - - """ - return self.shape[0] - - @property - def width(self): - """ - The width in pixels. - - :rtype: int - - """ - return self.shape[1] - - @property - def field_of_view(self): - """ - The index of the stage location where this image was acquired. - - :rtype: int - - """ - return self._field_of_view - - @property - def timestamp(self): - """ - The number of seconds after the beginning of the acquisition that the image was taken. Note that for a given - field of view and z-level offset, if you have images of multiple channels, they will all be given the same - timestamp. That's just how ND2s are structured, so if your experiment depends on millisecond accuracy, - you need to find an alternative imaging system. - - :rtype: float - - """ - # data is actually stored in milliseconds - return self._timestamp / 1000.0 - - @property - def frame_number(self): - """ - The index of the group of images taken sequentially that all have the same group number and field of view. - - :rtype: int - - """ - return self._frame_number - - @property - def channel(self): - """ - The name of the filter used to acquire this image. These are user-supplied in NIS Elements. - - :rtype: str - - """ - return self._channel - - @property - def z_level(self): - """ - The vertical offset of the image. These are simple integers starting from 0, where the 0 is the lowest - z-level and each subsequent level incremented by 1. - - For example, if you acquired images at -3 µm, 0 µm, and +3 µm, your z-levels would be: - - -3 µm: 0 - 0 µm: 1 - +3 µm: 2 - - :rtype: int - - """ - return self._z_level diff --git a/nd2reader/model/metadata.py b/nd2reader/model/metadata.py deleted file mode 100644 index ba10150..0000000 --- a/nd2reader/model/metadata.py +++ /dev/null @@ -1,106 +0,0 @@ -class Metadata(object): - """ A simple container for ND2 metadata. """ - def __init__(self, height, width, channels, date, fields_of_view, frames, z_levels, total_images_per_channel, pixel_microns): - self._height = height - self._width = width - self._channels = channels - self._date = date - self._fields_of_view = fields_of_view - self._frames = frames - self._z_levels = z_levels - self._total_images_per_channel = total_images_per_channel - self._pixel_microns = pixel_microns - - @property - def height(self): - """ - The image height in pixels. - - :rtype: int - - """ - return self._height - - @property - def width(self): - """ - The image width in pixels. - - :rtype: int - - """ - return self._width - - @property - def date(self): - """ - The date and time when acquisition began. - - :rtype: datetime.datetime() or None - - """ - return self._date - - @property - 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.) - - :rtype: list - - """ - return self._channels - - @property - 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 - in the image data, so we have to calculate it. There probably is something somewhere, since - NIS Elements can figure it out, but we haven't found it yet. - - :rtype: list - - """ - return self._fields_of_view - - @property - def frames(self): - """ - The number of cycles. - - :rtype: list - - """ - return self._frames - - @property - def z_levels(self): - """ - The different levels in the Z-plane. Just a sequence from 0 to n. - - :rtype: list - - """ - return self._z_levels - - @property - def total_images_per_channel(self): - """ - The total number of images of a particular channel (wavelength, filter, etc) in the entire ND2. - - :rtype: int - - """ - return self._total_images_per_channel - - @property - def pixel_microns(self): - """ - The width of a pixel in microns. - - :rtype: float - - """ - return self._pixel_microns diff --git a/nd2reader/model/roi.py b/nd2reader/model/roi.py deleted file mode 100644 index 175da67..0000000 --- a/nd2reader/model/roi.py +++ /dev/null @@ -1,81 +0,0 @@ -import six -import numpy as np - - -class Roi(object): - """ - A ND2 ROI representation. - Coordinates are the center coordinates of the ROI in (x, y, z) order in micron. - Sizes are the sizes of the ROI in (x, y, z) order in micron. - Shapes are represented by numbers, defined by constants in this class. - All these properties can be set for multiple time points (in ms). - """ - SHAPE_RECTANGLE = 3 - SHAPE_CIRCLE = 9 - - TYPE_BACKGROUND = 2 - - def __init__(self, raw_roi_dict, metadata): - """ - - :param raw_roi_dict: - :param metadata - """ - self.timepoints = [] - self.positions = [] - self.sizes = [] - self.shape = self.SHAPE_CIRCLE - self.type = self.TYPE_BACKGROUND - - self._img_width_micron = metadata.width * metadata.pixel_microns - self._img_height_micron = metadata.height * metadata.pixel_microns - self._pixel_microns = metadata.pixel_microns - - self._extract_vect_anims(raw_roi_dict) - - def _extract_vect_anims(self, raw_roi_dict): - """ - Extract the vector animation parameters from the ROI. - This includes the position and size at the given timepoints. - :param raw_roi_dict: - :return: - """ - number_of_timepoints = raw_roi_dict[six.b('m_vectAnimParams_Size')] - - for i in range(number_of_timepoints): - self._parse_vect_anim(raw_roi_dict[six.b('m_vectAnimParams_%d') % i]) - - self.shape = raw_roi_dict[six.b('m_sInfo')][six.b('m_uiShapeType')] - self.type = raw_roi_dict[six.b('m_sInfo')][six.b('m_uiInterpType')] - - # convert to NumPy arrays - self.timepoints = np.array(self.timepoints, dtype=np.float) - self.positions = np.array(self.positions, dtype=np.float) - self.sizes = np.array(self.sizes, dtype=np.float) - - def _parse_vect_anim(self, animation_dict): - """ - Parses a ROI vector animation object and adds it to the global list of timepoints and positions. - :param animation_dict: - :return: - """ - self.timepoints.append(animation_dict[six.b('m_dTimeMs')]) - - # positions are taken from the center of the image as a fraction of the half width/height of the image - position = np.array((0.5 * self._img_width_micron * (1 + animation_dict[six.b('m_dCenterX')]), - 0.5 * self._img_height_micron * (1 + animation_dict[six.b('m_dCenterY')]), - animation_dict[six.b('m_dCenterZ')])) - self.positions.append(position) - - size_dict = animation_dict[six.b('m_sBoxShape')] - - # sizes are fractions of the half width/height of the image - self.sizes.append((size_dict[six.b('m_dSizeX')] * 0.25 * self._img_width_micron, - size_dict[six.b('m_dSizeY')] * 0.25 * self._img_height_micron, - size_dict[six.b('m_dSizeZ')])) - - def is_circle(self): - return self.shape == self.SHAPE_CIRCLE - - def is_rectangle(self): - return self.shape == self.SHAPE_RECTANGLE diff --git a/nd2reader/driver/v3.py b/nd2reader/nd2parser.py similarity index 51% rename from nd2reader/driver/v3.py rename to nd2reader/nd2parser.py index bf36a79..93f7743 100644 --- a/nd2reader/driver/v3.py +++ b/nd2reader/nd2parser.py @@ -1,30 +1,47 @@ # -*- coding: utf-8 -*- +import struct import array +import six +from pims import Frame import numpy as np -import struct -from nd2reader.model.image import Image -from nd2reader.common.v3 import read_chunk -from nd2reader.exc import NoImageError +from nd2reader.common import get_version, read_chunk +from nd2reader.exc import InvalidVersionError, NoImageError +from nd2reader.label_map import LabelMap +from nd2reader.raw_metadata import RawMetadata + + +class ND2Parser(object): + """ Parses ND2 files and creates a Metadata and driver object. """ + CHUNK_HEADER = 0xabeceda + CHUNK_MAP_START = six.b("ND2 FILEMAP SIGNATURE NAME 0001!") + CHUNK_MAP_END = six.b("ND2 CHUNK MAP SIGNATURE 0000001!") -class V3Driver(object): - """ - Accesses images from ND2 files made with NIS Elements 4.x. Confusingly, files of this type have a version number of 3.0+. + supported_file_versions = {(3, None): True} - """ - def __init__(self, metadata, label_map, file_handle): + def __init__(self, fh): """ - :param metadata: a Metadata object - :param label_map: a raw dictionary of pointers to image locations - :param file_handle: an open file handle to the ND2 + :type fh: file """ - self._metadata = metadata - self._label_map = label_map - self._file_handle = file_handle + self._fh = fh + self._label_map = None + self._raw_metadata = None + self.metadata = None + + # First check the file version + self._check_version_supported() + + # Parse the metadata + self._parse_metadata() def calculate_image_properties(self, index): + """ + Calculate FOV, channels and z_levels + :param index: + :return: + """ field_of_view = self._calculate_field_of_view(index) channel = self._calculate_channel(index) z_level = self._calculate_z_level(index) @@ -32,21 +49,24 @@ class V3Driver(object): def get_image(self, index): """ - Creates an Image object and adds its metadata, based on the index (which is simply the order in which the image was acquired). May return None if the ND2 contains - multiple channels and not all were taken in each cycle (for example, if you take bright field images every minute, and GFP images every five minutes, there will be some - indexes that do not contain an image. The reason for this is complicated, but suffice it to say that we hope to eliminate this possibility in future releases. For now, - you'll need to check if your image is None if you're doing anything out of the ordinary. + Creates an Image object and adds its metadata, based on the index (which is simply the order in which the image + was acquired). May return None if the ND2 contains multiple channels and not all were taken in each cycle (for + example, if you take bright field images every minute, and GFP images every five minutes, there will be some + indexes that do not contain an image. The reason for this is complicated, but suffice it to say that we hope to + eliminate this possibility in future releases. For now, you'll need to check if your image is None if you're + doing anything out of the ordinary. :type index: int :rtype: Image or None """ field_of_view, channel, z_level = self.calculate_image_properties(index) - channel_offset = index % len(self._metadata.channels) - image_group_number = int(index / len(self._metadata.channels)) + channel_offset = index % len(self.metadata["channels"]) + image_group_number = int(index / len(self.metadata["channels"])) frame_number = self._calculate_frame_number(image_group_number, field_of_view, z_level) try: - timestamp, image = self._get_raw_image_data(image_group_number, channel_offset, self._metadata.height, self._metadata.width) + timestamp, image = self._get_raw_image_data(image_group_number, channel_offset, self.metadata["height"], + self.metadata["width"]) except NoImageError: return None else: @@ -68,17 +88,67 @@ class V3Driver(object): """ image_group_number = self._calculate_image_group_number(frame_number, field_of_view, z_level) try: - timestamp, raw_image_data = self._get_raw_image_data(image_group_number, - self._channel_offset[channel_name], - height, - width) - image = Image(raw_image_data) - image.add_params(image_group_number, timestamp, frame_number, field_of_view, channel_name, z_level) + timestamp, raw_image_data = self._get_raw_image_data(image_group_number, self._channel_offset[channel_name], + height, width) + image = Frame(raw_image_data, frame_no=frame_number, metadata=self._get_frame_metadata()) except (TypeError, NoImageError): return None else: return image + def get_dtype_from_metadata(self): + """ + Determine the data type from the metadata. + :return: + """ + bit_depth = self._raw_metadata.image_attributes[six.b('SLxImageAttributes')][six.b('uiBpcInMemory')] + dtype = np.float64 + if bit_depth <= 16: + dtype = np.float16 + elif bit_depth <= 32: + dtype = np.float32 + + return dtype + + def _check_version_supported(self): + """ + Checks if the ND2 file version is supported by this reader. + :return: + """ + major_version, minor_version = get_version(self._fh) + supported = self.supported_file_versions.get((major_version, minor_version)) or \ + self.supported_file_versions.get((major_version, None)) + + if not supported: + raise InvalidVersionError("No parser is available for that version.") + + return supported + + def _parse_metadata(self): + """ + Reads all metadata and instantiates the Metadata object. + + """ + # Retrieve raw metadata from the label mapping + self._label_map = self._build_label_map() + self._raw_metadata = RawMetadata(self._fh, self._label_map) + self.metadata = self._raw_metadata.__dict__ + + def _build_label_map(self): + """ + Every label ends with an exclamation point, however, we can't directly search for those to find all the labels + as some of the bytes contain the value 33, which is the ASCII code for "!". So we iteratively find each label, + grab the subsequent data (always 16 bytes long), advance to the next label and repeat. + + :rtype: LabelMap + + """ + self._fh.seek(-8, 2) + chunk_map_start_location = struct.unpack("Q", self._fh.read(8))[0] + self._fh.seek(chunk_map_start_location) + raw_text = self._fh.read(-1) + return LabelMap(raw_text) + def _calculate_field_of_view(self, index): """ Determines what field of view was being imaged for a given image. @@ -87,8 +157,8 @@ class V3Driver(object): :rtype: int """ - images_per_cycle = len(self._metadata.z_levels) * len(self._metadata.channels) - return int((index - (index % images_per_cycle)) / images_per_cycle) % len(self._metadata.fields_of_view) + images_per_cycle = len(self.metadata["z_levels"]) * len(self.metadata["channels"]) + return int((index - (index % images_per_cycle)) / images_per_cycle) % len(self.metadata["fields_of_view"]) def _calculate_channel(self, index): """ @@ -98,16 +168,19 @@ class V3Driver(object): :rtype: str """ - return self._metadata.channels[index % len(self._metadata.channels)] + return self.metadata["channels"][index % len(self.metadata["channels"])] def _calculate_z_level(self, index): """ - Determines the plane in the z-axis a given image was taken in. In the future, this will be replaced with the actual offset in micrometers. + Determines the plane in the z-axis a given image was taken in. In the future, this will be replaced with the + actual offset in micrometers. :type index: int :rtype: int """ - return self._metadata.z_levels[int(((index - (index % len(self._metadata.channels))) / len(self._metadata.channels)) % len(self._metadata.z_levels))] + return self.metadata["z_levels"][int( + ((index - (index % len(self.metadata["channels"]))) / len(self.metadata["channels"])) % len( + self.metadata["z_levels"]))] def _calculate_image_group_number(self, frame_number, fov, z_level): """ @@ -120,7 +193,8 @@ class V3Driver(object): :rtype: int """ - return frame_number * len(self._metadata.fields_of_view) * len(self._metadata.z_levels) + (fov * len(self._metadata.z_levels) + z_level) + return frame_number * len(self.metadata["fields_of_view"]) * len(self.metadata["z_levels"]) + ( + fov * len(self.metadata["z_levels"]) + z_level) def _calculate_frame_number(self, image_group_number, field_of_view, z_level): """ @@ -133,7 +207,8 @@ class V3Driver(object): :rtype: int """ - return (image_group_number - (field_of_view * len(self._metadata.z_levels) + z_level)) / (len(self._metadata.fields_of_view) * len(self._metadata.z_levels)) + return (image_group_number - (field_of_view * len(self.metadata["z_levels"]) + z_level)) / ( + len(self.metadata["fields_of_view"]) * len(self.metadata["z_levels"])) @property def _channel_offset(self): @@ -144,7 +219,7 @@ class V3Driver(object): :rtype: dict """ - return {channel: n for n, channel in enumerate(self._metadata.channels)} + return {channel: n for n, channel in enumerate(self.metadata["channels"])} def _get_raw_image_data(self, image_group_number, channel_offset, height, width): """ @@ -160,7 +235,7 @@ class V3Driver(object): """ chunk = self._label_map.get_image_data_location(image_group_number) - data = read_chunk(self._file_handle, chunk) + data = read_chunk(self._fh, chunk) # print("data", data, "that was data") # All images in the same image group share the same timestamp! So if you have complicated image data, # your timestamps may not be entirely accurate. Practically speaking though, they'll only be off by a few @@ -180,5 +255,12 @@ class V3Driver(object): # other cycle to reduce phototoxicity, but NIS Elements still allocated memory as if we were going to take # them every cycle. if np.any(image_data): - return timestamp, Image(image_data) + return timestamp, Frame(image_data) raise NoImageError + + def _get_frame_metadata(self): + """ + Get the metadata for one frame + :return: + """ + return self.metadata diff --git a/nd2reader/nd2reader.py b/nd2reader/nd2reader.py index a2bf1ca..dae0d6e 100644 --- a/nd2reader/nd2reader.py +++ b/nd2reader/nd2reader.py @@ -1,14 +1,11 @@ from pims import FramesSequenceND, Frame -import numpy as np - from nd2reader.exc import NoImageError -from nd2reader.parser import get_parser -import six +from nd2reader.nd2parser import ND2Parser class ND2Reader(FramesSequenceND): """ - PIMS wrapper for the ND2 reader + PIMS wrapper for the ND2 parser """ def __init__(self, filename): @@ -16,43 +13,30 @@ class ND2Reader(FramesSequenceND): # first use the parser to parse the file self._fh = open(filename, "rb") - self._parser = get_parser(self._fh) - self._metadata = self._parser.metadata - self._roi_metadata = self._parser.roi_metadata + self._parser = ND2Parser(self._fh) + + # Setup metadata + self.metadata = self._parser.metadata # Set data type - self._dtype = self._get_dtype_from_metadata() + self._dtype = self._parser.get_dtype_from_metadata() # Setup the axes - self._init_axis('x', self._metadata.width) - self._init_axis('y', self._metadata.height) - self._init_axis('c', len(self._metadata.channels)) - self._init_axis('t', len(self._metadata.frames)) - self._init_axis('z', len(self._metadata.z_levels)) + self._setup_axes() - # provide the default - self.iter_axes = 't' - - def _get_dtype_from_metadata(self): + @classmethod + def class_exts(cls): """ - Determine the data type from the metadata. + So PIMS open function can use this reader for opening .nd2 files :return: """ - bit_depth = self._parser.raw_metadata.image_attributes[six.b('SLxImageAttributes')][six.b('uiBpcInMemory')] - if bit_depth <= 16: - self._dtype = np.float16 - elif bit_depth <= 32: - self._dtype = np.float32 - else: - self._dtype = np.float64 - - return self._dtype - - @classmethod - def class_exts(cls): return {'nd2'} | super(ND2Reader, cls).class_exts() def close(self): + """ + Correctly close the file handle + :return: + """ if self._fh is not None: self._fh.close() @@ -64,30 +48,33 @@ class ND2Reader(FramesSequenceND): :param z: :return: """ - c_name = self._metadata.channels[c] + c_name = self.metadata["channels"][c] try: - image = self._parser.driver.get_image_by_attributes(t, 0, c_name, z, self._metadata.width, - self._metadata.height) + image = self._parser.get_image_by_attributes(t, 0, c_name, z, self.metadata["width"], + self.metadata["height"]) except (TypeError, NoImageError): return Frame([]) else: - return Frame(image, frame_no=image.frame_number, metadata=self._get_frame_metadata()) + return image - def _get_frame_metadata(self): + @property + def pixel_type(self): """ - Get the metadata for one frame + Return the pixel data type :return: """ - frame_metadata = { - "height": self._metadata.height, - "width": self._metadata.width, - "date": self._metadata.date, - "pixel_microns": self._metadata.pixel_microns, - "rois": self._roi_metadata - } + return self._dtype - return frame_metadata + def _setup_axes(self): + """ + Setup the xyctz axes, iterate over t axis by default + :return: + """ + self._init_axis('x', self.metadata["width"]) + self._init_axis('y', self.metadata["height"]) + self._init_axis('c', len(self.metadata["channels"])) + self._init_axis('t', len(self.metadata["frames"])) + self._init_axis('z', len(self.metadata["z_levels"])) - @property - def pixel_type(self): - return self._dtype + # provide the default + self.iter_axes = 't' diff --git a/nd2reader/parser/__init__.py b/nd2reader/parser/__init__.py deleted file mode 100644 index 738a615..0000000 --- a/nd2reader/parser/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . parser import get_parser, get_version, parse_version diff --git a/nd2reader/parser/base.py b/nd2reader/parser/base.py deleted file mode 100644 index 52186ff..0000000 --- a/nd2reader/parser/base.py +++ /dev/null @@ -1,17 +0,0 @@ -from abc import abstractproperty - - -class BaseParser(object): - def __init__(self, fh): - self._fh = fh - self.camera_metadata = None - self.metadata = None - self.roi_metadata = None - - @abstractproperty - def driver(self): - """ - Must return an object that can look up and read images. - - """ - raise NotImplementedError diff --git a/nd2reader/parser/parser.py b/nd2reader/parser/parser.py deleted file mode 100644 index 0cd79ae..0000000 --- a/nd2reader/parser/parser.py +++ /dev/null @@ -1,55 +0,0 @@ -from nd2reader.parser.v3 import V3Parser -import re -from nd2reader.exc import InvalidVersionError - - -def get_parser(fh): - """ - Picks the appropriate parser based on the ND2 version. - - :type fh: file - :type major_version: int - :type minor_version: int - - :rtype: a parser object - - """ - major_version, minor_version = get_version(fh) - parsers = {(3, None): V3Parser} - parser = parsers.get((major_version, minor_version)) or parsers.get((major_version, None)) - if not parser: - raise InvalidVersionError("No parser is available for that version.") - return parser(fh) - - -def get_version(fh): - """ - Determines what version the ND2 is. - - :param fh: an open file handle to the ND2 - :type fh: file - - """ - # the first 16 bytes seem to have no meaning, so we skip them - fh.seek(16) - - # the next 38 bytes contain the string that we want to parse. Unlike most of the ND2, this is in UTF-8 - data = fh.read(38).decode("utf8") - return parse_version(data) - - -def parse_version(data): - """ - Parses a string with the version data in it. - - :param data: the 19th through 54th byte of the ND2, representing the version - :type data: unicode - - """ - match = re.search(r"""^ND2 FILE SIGNATURE CHUNK NAME01!Ver(?P\d)\.(?P\d)$""", data) - - if match: - # We haven't seen a lot of ND2s but the ones we have seen conform to this - return int(match.group('major')), int(match.group('minor')) - - raise InvalidVersionError("The version of the ND2 you specified is not supported.") diff --git a/nd2reader/parser/v3.py b/nd2reader/raw_metadata.py similarity index 54% rename from nd2reader/parser/v3.py rename to nd2reader/raw_metadata.py index bfdc8b8..4e73196 100644 --- a/nd2reader/parser/v3.py +++ b/nd2reader/raw_metadata.py @@ -1,16 +1,9 @@ -# -*- coding: utf-8 -*- - -from datetime import datetime -from nd2reader.model.metadata import Metadata -from nd2reader.model.label import LabelMap -from nd2reader.model.roi import Roi -from nd2reader.parser.base import BaseParser -from nd2reader.driver.v3 import V3Driver -from nd2reader.common.v3 import read_chunk, read_array, read_metadata import re -import six -import struct + +from nd2reader.common import read_chunk, read_array, read_metadata, parse_date import xmltodict +import six +import numpy as np def ignore_missing(func): @@ -23,202 +16,57 @@ def ignore_missing(func): return wrapper -class V3RawMetadata(object): +class RawMetadata(object): def __init__(self, fh, label_map): self._fh = fh self._label_map = label_map + self._metadata_parsed = None @property - @ignore_missing - def image_text_info(self): - return read_metadata(read_chunk(self._fh, self._label_map.image_text_info), 1) - - @property - @ignore_missing - def image_metadata_sequence(self): - return read_metadata(read_chunk(self._fh, self._label_map.image_metadata_sequence), 1) - - @property - @ignore_missing - def image_calibration(self): - return read_metadata(read_chunk(self._fh, self._label_map.image_calibration), 1) - - @property - @ignore_missing - def image_attributes(self): - return read_metadata(read_chunk(self._fh, self._label_map.image_attributes), 1) - - @property - @ignore_missing - def x_data(self): - return read_array(self._fh, 'double', self._label_map.x_data) - - @property - @ignore_missing - def y_data(self): - return read_array(self._fh, 'double', self._label_map.y_data) - - @property - @ignore_missing - def z_data(self): - return read_array(self._fh, 'double', self._label_map.z_data) - - @property - @ignore_missing - def roi_metadata(self): - return read_metadata(read_chunk(self._fh, self._label_map.roi_metadata), 1) - - @property - @ignore_missing - def pfs_status(self): - return read_array(self._fh, 'int', self._label_map.pfs_status) - - @property - @ignore_missing - def pfs_offset(self): - return read_array(self._fh, 'int', self._label_map.pfs_offset) - - @property - @ignore_missing - def camera_exposure_time(self): - return read_array(self._fh, 'double', self._label_map.camera_exposure_time) - - @property - @ignore_missing - def lut_data(self): - return xmltodict.parse(read_chunk(self._fh, self._label_map.lut_data)) - - @property - @ignore_missing - def grabber_settings(self): - return xmltodict.parse(read_chunk(self._fh, self._label_map.grabber_settings)) - - @property - @ignore_missing - def custom_data(self): - return xmltodict.parse(read_chunk(self._fh, self._label_map.custom_data)) - - @property - @ignore_missing - def app_info(self): - return xmltodict.parse(read_chunk(self._fh, self._label_map.app_info)) - - @property - @ignore_missing - def camera_temp(self): - camera_temp = read_array(self._fh, 'double', self._label_map.camera_temp) - if camera_temp: - for temp in map(lambda x: round(x * 100.0, 2), camera_temp): - yield temp - - @property - @ignore_missing - def acquisition_times(self): - acquisition_times = read_array(self._fh, 'double', self._label_map.acquisition_times) - if acquisition_times: - for acquisition_time in map(lambda x: x / 1000.0, acquisition_times): - yield acquisition_time - - @property - @ignore_missing - def image_metadata(self): - if self._label_map.image_metadata: - return read_metadata(read_chunk(self._fh, self._label_map.image_metadata), 1) - - -class V3Parser(BaseParser): - """ Parses ND2 files and creates a Metadata and driver object. """ - CHUNK_HEADER = 0xabeceda - CHUNK_MAP_START = six.b("ND2 FILEMAP SIGNATURE NAME 0001!") - CHUNK_MAP_END = six.b("ND2 CHUNK MAP SIGNATURE 0000001!") - - def __init__(self, fh): - """ - :type fh: file - - """ - if six.PY3: - super().__init__(fh) - else: - super(V3Parser, self).__init__(fh) - self._label_map = self._build_label_map() - self.raw_metadata = V3RawMetadata(self._fh, self._label_map) - self._parse_metadata() - self._parse_roi_metadata() - - @property - def driver(self): - """ - Provides an object that knows how to look up and read images based on an index. - + def __dict__(self): """ - return V3Driver(self.metadata, self._label_map, self._fh) - - def _parse_metadata(self): + Returns the parsed metadata in dictionary form + :return: """ - Reads all metadata and instantiates the Metadata object. + return self.get_parsed_metadata() + def get_parsed_metadata(self): """ - height = self.raw_metadata.image_attributes[six.b('SLxImageAttributes')][six.b('uiHeight')] - width = self.raw_metadata.image_attributes[six.b('SLxImageAttributes')][six.b('uiWidth')] - date = self._parse_date(self.raw_metadata) - fields_of_view = self._parse_fields_of_view(self.raw_metadata) - frames = self._parse_frames(self.raw_metadata) - z_levels = self._parse_z_levels(self.raw_metadata) - total_images_per_channel = self._parse_total_images_per_channel(self.raw_metadata) - channels = self._parse_channels(self.raw_metadata) - pixel_microns = self.raw_metadata.image_calibration.get(six.b('SLxCalibration'), {}).get(six.b('dCalibration')) - self.metadata = Metadata(height, width, channels, date, fields_of_view, frames, z_levels, - total_images_per_channel, pixel_microns) - - def _parse_date(self, raw_metadata): + Returns the parsed metadata in dictionary form + :return: """ - The date and time when acquisition began. - :type raw_metadata: V3RawMetadata - :rtype: datetime.datetime() or None + if self._metadata_parsed is not None: + return self._metadata_parsed + + self._metadata_parsed = { + "height": self.image_attributes[six.b('SLxImageAttributes')][six.b('uiHeight')], + "width": self.image_attributes[six.b('SLxImageAttributes')][six.b('uiWidth')], + "date": parse_date(self.image_text_info[six.b('SLxImageTextInfo')]), + "fields_of_view": self._parse_fields_of_view(), + "frames": self._parse_frames(), + "z_levels": self._parse_z_levels(), + "total_images_per_channel": self._parse_total_images_per_channel(), + "channels": self._parse_channels(), + "pixel_microns": self.image_calibration.get(six.b('SLxCalibration'), {}).get(six.b('dCalibration')), + } - """ - for line in raw_metadata.image_text_info[six.b('SLxImageTextInfo')].values(): - line = line.decode("utf8") - # ND2s seem to randomly switch between 12- and 24-hour representations. - absolute_start_24 = self._parse_date_24h(line) - absolute_start_12 = self._parse_date_12h(line) - if not absolute_start_12 and not absolute_start_24: - continue - return absolute_start_12 if absolute_start_12 else absolute_start_24 - return None - - @staticmethod - def _parse_date_12h(line): - try: - absolute_start_12 = datetime.strptime(line, "%m/%d/%Y %I:%M:%S %p") - return absolute_start_12 - except (TypeError, ValueError): - return None + self._parse_roi_metadata() - @staticmethod - def _parse_date_24h(line): - try: - absolute_start_24 = datetime.strptime(line, "%m/%d/%Y %H:%M:%S") - return absolute_start_24 - except (TypeError, ValueError): - return None + return self._metadata_parsed - @staticmethod - def _parse_channels(raw_metadata): + def _parse_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.) - :type raw_metadata: V3RawMetadata :rtype: list """ channels = [] - metadata = raw_metadata.image_metadata_sequence[six.b('SLxPictureMetadata')][six.b('sPicturePlanes')] + metadata = self.image_metadata_sequence[six.b('SLxPictureMetadata')][six.b('sPicturePlanes')] try: - validity = raw_metadata.image_metadata[six.b('SLxExperiment')][six.b('ppNextLevelEx')][six.b('')][0][ + validity = self.image_metadata[six.b('SLxExperiment')][six.b('ppNextLevelEx')][six.b('')][0][ six.b('ppNextLevelEx')][six.b('')][0][six.b('pItemValid')] except (KeyError, TypeError): # If none of the channels have been deleted, there is no validity list, so we just make one @@ -232,52 +80,47 @@ class V3Parser(BaseParser): channels.append(chan[six.b('sDescription')].decode("utf8")) return channels - def _parse_fields_of_view(self, raw_metadata): + def _parse_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 in the image data, so we have to calculate it. There probably is something somewhere, since NIS Elements can figure it out, but we haven't found it yet. - :type raw_metadata: V3RawMetadata :rtype: list """ - return self._parse_dimension(r""".*?XY\((\d+)\).*?""", raw_metadata) + return self._parse_dimension(r""".*?XY\((\d+)\).*?""") - def _parse_frames(self, raw_metadata): + def _parse_frames(self): """ The number of cycles. - :type raw_metadata: V3RawMetadata :rtype: list """ - return self._parse_dimension(r""".*?T'?\((\d+)\).*?""", raw_metadata) + return self._parse_dimension(r""".*?T'?\((\d+)\).*?""") - def _parse_z_levels(self, raw_metadata): + def _parse_z_levels(self): """ The different levels in the Z-plane. Just a sequence from 0 to n. - :type raw_metadata: V3RawMetadata :rtype: list """ - return self._parse_dimension(r""".*?Z\((\d+)\).*?""", raw_metadata) + return self._parse_dimension(r""".*?Z\((\d+)\).*?""") - @staticmethod - def _parse_dimension_text(raw_metadata): + def _parse_dimension_text(self): """ While there are metadata values that represent a lot of what we want to capture, they seem to be unreliable. Sometimes certain elements don't exist, or change their data type randomly. However, the human-readable text is always there and in the same exact format, so we just parse that instead. - :type raw_metadata: V3RawMetadata :rtype: str """ dimension_text = six.b("") - textinfo = raw_metadata.image_text_info[six.b('SLxImageTextInfo')].values() + textinfo = self.image_text_info[six.b('SLxImageTextInfo')].values() for line in textinfo: if six.b("Dimensions:") in line: @@ -288,16 +131,15 @@ class V3Parser(BaseParser): return dimension_text - def _parse_dimension(self, pattern, raw_metadata): + def _parse_dimension(self, pattern): """ :param pattern: a valid regex pattern :type pattern: str - :type raw_metadata: V3RawMetadata :rtype: list of int """ - dimension_text = self._parse_dimension_text(raw_metadata) + dimension_text = self._parse_dimension_text() if six.PY3: dimension_text = dimension_text.decode("utf8") match = re.match(pattern, dimension_text) @@ -306,48 +148,198 @@ class V3Parser(BaseParser): count = int(match.group(1)) return list(range(count)) - @staticmethod - def _parse_total_images_per_channel(raw_metadata): + def _parse_total_images_per_channel(self): """ The total number of images per channel. Warning: this may be inaccurate as it includes "gap" images. - :type raw_metadata: V3RawMetadata :rtype: int """ - return raw_metadata.image_attributes[six.b('SLxImageAttributes')][six.b('uiSequenceCount')] + return self.image_attributes[six.b('SLxImageAttributes')][six.b('uiSequenceCount')] def _parse_roi_metadata(self): """ Parse the raw ROI metadata. :return: """ - if not six.b('RoiMetadata_v1') in self.raw_metadata.roi_metadata: - self.roi_metadata = None + if not six.b('RoiMetadata_v1') in self.roi_metadata: return - raw_roi_data = self.raw_metadata.roi_metadata[six.b('RoiMetadata_v1')] + raw_roi_data = self.roi_metadata[six.b('RoiMetadata_v1')] number_of_rois = raw_roi_data[six.b('m_vectGlobal_Size')] roi_objects = [] for i in range(number_of_rois): current_roi = raw_roi_data[six.b('m_vectGlobal_%d' % i)] - roi_objects.append(Roi(current_roi, self.metadata)) + roi_objects.append(self._parse_roi(current_roi)) - self.roi_metadata = roi_objects + self._metadata_parsed['rois'] = roi_objects - def _build_label_map(self): + def _parse_roi(self, raw_roi_dict): + """ + Extract the vector animation parameters from the ROI. + This includes the position and size at the given timepoints. + :param raw_roi_dict: + :return: """ - Every label ends with an exclamation point, however, we can't directly search for those to find all the labels - as some of the bytes contain the value 33, which is the ASCII code for "!". So we iteratively find each label, - grab the subsequent data (always 16 bytes long), advance to the next label and repeat. + number_of_timepoints = raw_roi_dict[six.b('m_vectAnimParams_Size')] + + roi_dict = { + "timepoints": [], + "positions": [], + "sizes": [], + "shape": self._parse_roi_shape(raw_roi_dict[six.b('m_sInfo')][six.b('m_uiShapeType')]), + "type": self._parse_roi_type(raw_roi_dict[six.b('m_sInfo')][six.b('m_uiInterpType')]) + } + for i in range(number_of_timepoints): + roi_dict = self._parse_vect_anim(roi_dict, raw_roi_dict[six.b('m_vectAnimParams_%d') % i]) + + # convert to NumPy arrays + roi_dict["timepoints"] = np.array(roi_dict["timepoints"], dtype=np.float) + roi_dict["positions"] = np.array(roi_dict["positions"], dtype=np.float) + roi_dict["sizes"] = np.array(roi_dict["sizes"], dtype=np.float) + + return roi_dict + + @staticmethod + def _parse_roi_shape(shape): + if shape == 3: + return 'rectangle' + elif shape == 9: + return 'circle' + + return None + + @staticmethod + def _parse_roi_type(type_no): + if type_no == 4: + return 'stimulation' + elif type_no == 3: + return 'reference' + elif type_no == 2: + return 'background' - :rtype: LabelMap + return None + def _parse_vect_anim(self, roi_dict, animation_dict): + """ + Parses a ROI vector animation object and adds it to the global list of timepoints and positions. + :param animation_dict: + :return: """ - self._fh.seek(-8, 2) - chunk_map_start_location = struct.unpack("Q", self._fh.read(8))[0] - self._fh.seek(chunk_map_start_location) - raw_text = self._fh.read(-1) - return LabelMap(raw_text) + roi_dict["timepoints"].append(animation_dict[six.b('m_dTimeMs')]) + + image_width = self._metadata_parsed["width"] * self._metadata_parsed["pixel_microns"] + image_height = self._metadata_parsed["height"] * self._metadata_parsed["pixel_microns"] + + # positions are taken from the center of the image as a fraction of the half width/height of the image + position = np.array((0.5 * image_width * (1 + animation_dict[six.b('m_dCenterX')]), + 0.5 * image_height * (1 + animation_dict[six.b('m_dCenterY')]), + animation_dict[six.b('m_dCenterZ')])) + roi_dict["positions"].append(position) + + size_dict = animation_dict[six.b('m_sBoxShape')] + + # sizes are fractions of the half width/height of the image + roi_dict["sizes"].append((size_dict[six.b('m_dSizeX')] * 0.25 * image_width, + size_dict[six.b('m_dSizeY')] * 0.25 * image_height, + size_dict[six.b('m_dSizeZ')])) + return roi_dict + + @property + @ignore_missing + def image_text_info(self): + return read_metadata(read_chunk(self._fh, self._label_map.image_text_info), 1) + + @property + @ignore_missing + def image_metadata_sequence(self): + return read_metadata(read_chunk(self._fh, self._label_map.image_metadata_sequence), 1) + + @property + @ignore_missing + def image_calibration(self): + return read_metadata(read_chunk(self._fh, self._label_map.image_calibration), 1) + + @property + @ignore_missing + def image_attributes(self): + return read_metadata(read_chunk(self._fh, self._label_map.image_attributes), 1) + + @property + @ignore_missing + def x_data(self): + return read_array(self._fh, 'double', self._label_map.x_data) + + @property + @ignore_missing + def y_data(self): + return read_array(self._fh, 'double', self._label_map.y_data) + + @property + @ignore_missing + def z_data(self): + return read_array(self._fh, 'double', self._label_map.z_data) + + @property + @ignore_missing + def roi_metadata(self): + return read_metadata(read_chunk(self._fh, self._label_map.roi_metadata), 1) + + @property + @ignore_missing + def pfs_status(self): + return read_array(self._fh, 'int', self._label_map.pfs_status) + + @property + @ignore_missing + def pfs_offset(self): + return read_array(self._fh, 'int', self._label_map.pfs_offset) + + @property + @ignore_missing + def camera_exposure_time(self): + return read_array(self._fh, 'double', self._label_map.camera_exposure_time) + + @property + @ignore_missing + def lut_data(self): + return xmltodict.parse(read_chunk(self._fh, self._label_map.lut_data)) + + @property + @ignore_missing + def grabber_settings(self): + return xmltodict.parse(read_chunk(self._fh, self._label_map.grabber_settings)) + + @property + @ignore_missing + def custom_data(self): + return xmltodict.parse(read_chunk(self._fh, self._label_map.custom_data)) + + @property + @ignore_missing + def app_info(self): + return xmltodict.parse(read_chunk(self._fh, self._label_map.app_info)) + + @property + @ignore_missing + def camera_temp(self): + camera_temp = read_array(self._fh, 'double', self._label_map.camera_temp) + if camera_temp: + for temp in map(lambda x: round(x * 100.0, 2), camera_temp): + yield temp + + @property + @ignore_missing + def acquisition_times(self): + acquisition_times = read_array(self._fh, 'double', self._label_map.acquisition_times) + if acquisition_times: + for acquisition_time in map(lambda x: x / 1000.0, acquisition_times): + yield acquisition_time + + @property + @ignore_missing + def image_metadata(self): + if self._label_map.image_metadata: + return read_metadata(read_chunk(self._fh, self._label_map.image_metadata), 1) diff --git a/test.py b/test.py index 02a87e0..05aa018 100644 --- a/test.py +++ b/test.py @@ -1,5 +1,5 @@ import unittest loader = unittest.TestLoader() -tests = loader.discover('tests', pattern='*.py', top_level_dir='.') +tests = loader.discover('tests', pattern='test_*.py', top_level_dir='.') testRunner = unittest.TextTestRunner() testRunner.run(tests) diff --git a/tests/driver/__init__.py b/tests/driver/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/model/__init__.py b/tests/model/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/model/image.py b/tests/model/image.py deleted file mode 100644 index 08531d7..0000000 --- a/tests/model/image.py +++ /dev/null @@ -1,42 +0,0 @@ -from nd2reader.model.image import Image -import numpy as np -import unittest - - -class ImageTests(unittest.TestCase): - """ - Basically just tests that the Image API works and that Images act as Numpy arrays. There's very little going on - here other than simply storing data. - - """ - def setUp(self): - array = np.array([[0, 1, 254], - [45, 12, 9], - [12, 12, 99]]) - self.image = Image(array) - self.image.add_params(1, 1200.314, 17, 2, 'GFP', 1) - - def test_size(self): - self.assertEqual(self.image.height, 3) - self.assertEqual(self.image.width, 3) - - def test_timestamp(self): - self.assertEqual(self.image.timestamp, 1.200314) - - def test_frame_number(self): - self.assertEqual(self.image.frame_number, 17) - - def test_fov(self): - self.assertEqual(self.image.field_of_view, 2) - - def test_channel(self): - self.assertEqual(self.image.channel, 'GFP') - - def test_z_level(self): - self.assertEqual(self.image.z_level, 1) - - def test_slice(self): - subimage = self.image[:2, :2] - expected = np.array([[0, 1], - [45, 12]]) - self.assertTrue(np.array_equal(subimage, expected)) diff --git a/tests/driver/version.py b/tests/test_common.py similarity index 83% rename from tests/driver/version.py rename to tests/test_common.py index 787e8df..ae5099d 100644 --- a/tests/driver/version.py +++ b/tests/test_common.py @@ -1,8 +1,9 @@ import unittest -from nd2reader.parser import parse_version +from nd2reader.common import parse_version -class VersionTests(unittest.TestCase): + +class TestCommon(unittest.TestCase): def test_parse_version_2(self): data = 'ND2 FILE SIGNATURE CHUNK NAME01!Ver2.2' actual = parse_version(data) diff --git a/tests/version.py b/tests/test_version.py similarity index 89% rename from tests/version.py rename to tests/test_version.py index 9d26e37..fedda9a 100644 --- a/tests/version.py +++ b/tests/test_version.py @@ -3,7 +3,7 @@ import unittest from setup import VERSION -class VersionTests(unittest.TestCase): +class TestVersion(unittest.TestCase): def test_module_version_type(self): # just make sure the version number exists and is the type we expect self.assertEqual(type(nd2reader.__version__), str)