diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cdd0791 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +## [1.1.0] - 2015-06-03 +### ADDED +- Indexing and slicing of images +- Python 3 support +- Dockerfile support for Python 3.4 +- Makefile commands for convenient testing in Docker +- Unit tests + +### CHANGED +- Switched to setuptools to automatically install missing dependencies +- Made the interface for most metadata public +- Refactored some poorly-named things + +## [1.0.0] - 2015-05-23 +### Added +- First stable release! diff --git a/Dockerfile b/Dockerfile index 37f37ee..969c5b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,20 @@ -FROM ubuntu +FROM ubuntu:15.04 MAINTAINER Jim Rybarski RUN mkdir -p /var/nds2 -RUN apt-get update && apt-get install -y \ - python-numpy +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libatlas3-base \ + liblapack-dev \ + libblas-dev \ + python \ + python3 \ + python-dev \ + python3-dev \ + python-pip \ + python3-pip COPY . /opt/nd2reader WORKDIR /opt/nd2reader RUN python setup.py install -WORKDIR /var/nd2s - -CMD /usr/bin/python2.7 +RUN python3 setup.py install diff --git a/Makefile b/Makefile index 2438069..8a6a193 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,14 @@ -.PHONY: build shell +.PHONY: build py2shell py3shell test build: docker build -t jimrybarski/nd2reader . -shell: - docker run --rm -v ~/Documents/nd2s:/var/nd2s -it jimrybarski/nd2reader +py2shell: + docker run --rm -v ~/Documents/nd2s:/var/nd2s -it jimrybarski/nd2reader python2.7 + +py3shell: + docker run --rm -v ~/Documents/nd2s:/var/nd2s -it jimrybarski/nd2reader python3.4 + +test: build + docker run --rm -it jimrybarski/nd2reader python3.4 /opt/nd2reader/tests.py + diff --git a/README.md b/README.md index d17653f..fa0dc03 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,19 @@ ### Installation -Just use pip (`numpy` is required): +Dependencies will automatically be installed if you don't have them. That said, for optimal performance, you should +install the following packages before installing nd2reader: -`pip install numpy nd2reader` +#### Ubuntu +`apt-get install python-numpy python-six` (Python 2.x) +`apt-get install python3-numpy python3-six` (Python 3.x) -If you want to install via git, clone the repo and run: +#### Other operating systems +These have not been tested yet. -``` -pip install numpy -python setup.py install -``` +nd2reader is compatible with both Python 2.x and 3.x. I recommend installing using pip: + +`pip install nd2reader` (Python 2.x) or `pip3 install nd2reader` (Python 3.x) ### ND2s @@ -37,9 +40,58 @@ Fields of View: 8 Z-Levels: 3 ``` -### Simple Iteration +You can also get some metadata about the nd2 programatically: -For most cases, you'll just want to iterate over each image: +```python +>>> nd2.height +1280 +>>> nd2.width +800 +>>> len(nd2) +30528 +``` + +### Images + +Every method returns an `Image` object, which contains some metadata about the image as well as the +raw pixel data itself. Images are always a 16-bit grayscale image. The `data` attribute holds the numpy array +with the image data: + +```python +>>> image = nd2[20] +>>> print(image.data) +array([[1894, 1949, 1941, ..., 2104, 2135, 2114], + [1825, 1846, 1848, ..., 1994, 2149, 2064], + [1909, 1820, 1821, ..., 1995, 1952, 2062], + ..., + [3487, 3512, 3594, ..., 3603, 3643, 3492], + [3642, 3475, 3525, ..., 3712, 3682, 3609], + [3687, 3777, 3738, ..., 3784, 3870, 4008]], dtype=uint16) +``` + +You can get a quick summary of image data by examining the `Image` object: + +```python +>>> image + +1280x800 (HxW) +Timestamp: 1699.79478134 +Field of View: 2 +Channel: GFP +Z-Level: 1 +``` + +Or you can access it programmatically: + +```python +image = nd2[0] +print(image.timestamp) +print(image.field_of_view) +print(image.channel) +print(image.z_level) +``` + +Often, you may want to just iterate over each image: ```python import nd2reader @@ -48,10 +100,42 @@ for image in nd2: do_something(image.data) ``` +You can also get an image directly by indexing. Here, we look at the 38th image: + +```python +>>> nd2[37] + +1280x800 (HxW) +Timestamp: 1699.79478134 +Field of View: 2 +Channel: GFP +Z-Level: 1 +``` + +Slicing is also supported and is extremely memory efficient, as images are only read when directly accessed: + +```python +my_subset = nd2[50:433] +for image in my_subset: + do_something(image.data) +``` + +Step sizes are also accepted: + +```python +for image in nd2[:100:2]: + # gets every other image in the first 100 images + do_something(image.data) + +for image in nd2[::-1]: + # iterate backwards over every image, if you're into that kind of thing + do_something_image.data) +``` + ### Image Sets If you have complicated hierarchical data, it may be easier to use image sets, which groups images together if they -share the same time index and field of view: +share the same time index (not timestamp!) and field of view: ```python import nd2reader @@ -59,55 +143,32 @@ nd2 = nd2reader.Nd2("/path/to/my_complicated_images.nd2") for image_set in nd2.image_sets: # you can select images by channel gfp_image = image_set.get("GFP") - do_something_gfp_related(gfp_image) + do_something_gfp_related(gfp_image.data) # you can also specify the z-level. this defaults to 0 if not given out_of_focus_image = image_set.get("Bright Field", z_level=1) - do_something_out_of_focus_related(out_of_focus_image) + do_something_out_of_focus_related(out_of_focus_image.data) ``` -### Direct Image Access - -There is a method, `get_image`, which allows random access to images. This might not always return an image, however, -if you acquired different numbers of images in each cycle of a program. For example, if you acquire GFP images every -other minute, but acquire bright field images every minute, `get_image` will return `None` at certain time indexes. +To get an image from an image set, you must specify a channel. It defaults to the 0th z-level, so if you have +more than one z-level you will need to specify it when using `get`: -### Images +```python +image = image_set.get("YFP") +image = image_set.get("YFP", z_level=2) +``` -`Image` objects provide several pieces of useful data. +You can also see how many images are in your image set: ```python ->>> import nd2reader ->>> nd2 = nd2reader.Nd2("/path/to/my_images.nd2") ->>> image = nd2.get_image(14, 2, "GFP", 1) ->>> image.data -array([[1809, 1783, 1830, ..., 1923, 1920, 1914], - [1687, 1855, 1792, ..., 1986, 1903, 1889], - [1758, 1901, 1849, ..., 1911, 2010, 1954], - ..., - [3363, 3370, 3570, ..., 3565, 3601, 3459], - [3480, 3428, 3328, ..., 3542, 3461, 3575], - [3497, 3666, 3635, ..., 3817, 3867, 3779]]) ->>> image.channel -'GFP' ->>> image.timestamp -1699.7947813408175 ->>> image.field_of_view -2 ->>> image.z_level -1 - -# You can also get a quick summary of image data: - ->>> image - -1280x800 (HxW) -Timestamp: 1699.79478134 -Field of View: 2 -Channel: GFP -Z-Level: 1 +>>> len(image_set) +7 ``` +### Protips + +nd2reader is about 14 times faster under Python 3.4 compared to Python 2.7. If you know why, please get in touch! + ### Bug Reports and Features If this fails to work exactly as expected, please open a Github issue. If you get an unhandled exception, please diff --git a/nd2reader/__init__.py b/nd2reader/__init__.py index befc5ae..2337034 100644 --- a/nd2reader/__init__.py +++ b/nd2reader/__init__.py @@ -16,12 +16,12 @@ class Nd2(Nd2Parser): def __repr__(self): return "\n".join(["" % self._filename, - "Created: %s" % self._absolute_start.strftime("%Y-%m-%d %H:%M:%S"), + "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): @@ -32,40 +32,59 @@ class Nd2(Nd2Parser): :rtype: int """ - return self._image_count * self._channel_count + return self._total_images_per_channel * len(self.channels) - @property - def height(self): + def __getitem__(self, item): """ - :return: height of each image, in pixels - :rtype: int + Allows slicing ND2s. - """ - return self.metadata[six.b('ImageAttributes')][six.b('SLxImageAttributes')][six.b('uiHeight')] + >>> nd2 = Nd2("my_images.nd2") + >>> image = nd2[16] # gets 17th frame + >>> for image in nd2[100:200]: # iterate over the 100th to 200th images + >>> do_something(image.data) + >>> for image in nd2[::-1]: # iterate backwards + >>> do_something(image.data) + >>> for image in nd2[37:422:17]: # do something super weird if you really want to + >>> do_something(image.data) - @property - def width(self): - """ - :return: width of each image, in pixels - :rtype: int + :type item: int or slice + :rtype: nd2reader.model.Image() or generator """ - return self.metadata[six.b('ImageAttributes')][six.b('SLxImageAttributes')][six.b('uiWidth')] - - def __iter__(self): + if isinstance(item, int): + try: + channel_offset = item % len(self.channels) + fov = self._calculate_field_of_view(item) + channel = self._calculate_channel(item) + z_level = self._calculate_z_level(item) + timestamp, raw_image_data = self._get_raw_image_data(item, channel_offset) + image = Image(timestamp, raw_image_data, fov, channel, z_level, self.height, self.width) + except (TypeError, ValueError): + return None + except KeyError: + raise IndexError("Invalid frame number.") + else: + return image + elif isinstance(item, slice): + return self._slice(item.start, item.stop, item.step) + raise IndexError + + def _slice(self, start, stop, step): """ - Iterates over every image, in the order they were taken. + Allows for iteration over a selection of the entire dataset. - :return: model.Image() + :type start: int + :type stop: int + :type step: int + :rtype: nd2reader.model.Image() or None """ - 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 + 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] @property def image_sets(self): @@ -78,21 +97,38 @@ class Nd2(Nd2Parser): :return: model.ImageSet() """ - for time_index in range(self._time_index_count): + for time_index in self.time_indexes: 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): + 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): + """ + :return: height of each image, in pixels + :rtype: int + + """ + return self.metadata[six.b('ImageAttributes')][six.b('SLxImageAttributes')][six.b('uiHeight')] + + @property + def width(self): + """ + :return: width of each image, in pixels + :rtype: int + + """ + return self.metadata[six.b('ImageAttributes')][six.b('SLxImageAttributes')][six.b('uiWidth')] + def get_image(self, time_index, field_of_view, channel_name, z_level): """ Returns an Image if data exists for the given parameters, otherwise returns None. In general, you should avoid - using this method unless you're very familiar with the structure of ND2 files. If you have a use case that - cannot be met by the `__iter__` or `image_sets` methods above, please create an issue on Github. + using this method unless you're very familiar with the structure of ND2 files. :param time_index: the frame number :type time_index: int @@ -105,9 +141,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..139e2fb 100644 --- a/nd2reader/parser.py +++ b/nd2reader/parser.py @@ -19,15 +19,124 @@ class Nd2Parser(object): CHUNK_MAP_END = six.b("ND2 CHUNK MAP SIGNATURE 0000001!") def __init__(self, filename): + self._absolute_start = None 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 absolute_start(self): + """ + The date and time when acquisition began. + + :rtype: datetime.datetime() + + """ + if self._absolute_start is None: + for line in self.metadata[six.b('ImageTextInfo')][six.b('SLxImageTextInfo')].values(): + line = line.decode("utf8") + absolute_start_12 = None + absolute_start_24 = None + # ND2s seem to randomly switch between 12- and 24-hour representations. + try: + absolute_start_24 = datetime.strptime(line, "%m/%d/%Y %H:%M:%S") + except (TypeError, ValueError): + pass + try: + absolute_start_12 = datetime.strptime(line, "%m/%d/%Y %I:%M:%S %p") + except (TypeError, ValueError): + pass + if not absolute_start_12 and not absolute_start_24: + continue + 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.") + return self._absolute_start + + @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: str + + """ + 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 + + @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: int + + """ + 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_indexes(self): + """ + The number of cycles. + + :rtype: int + + """ + if self._time_indexes is None: + self._time_indexes = self._parse_dimension_text(r""".*?T'\((\d+)\).*?""") + return self._time_indexes + + @property + def z_levels(self): + """ + The different levels in the Z-plane. Just a sequence from 0 to n. + + :rtype: int + + """ + if self._z_levels is None: + self._z_levels = self._parse_dimension_text(r""".*?Z\((\d+)\).*?""") + return self._z_levels + + 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) + + def _calculate_channel(self, frame_number): + return self.channels[frame_number % len(self.channels)] + + 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))] @property def _file_handle(self): @@ -58,7 +167,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,33 +199,8 @@ 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): - """ - 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: 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") - def _calculate_image_group_number(self, time_index, fov, z_level): """ Images are grouped together if they share the same time index, field of view, and z-level. @@ -128,7 +212,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): @@ -144,103 +228,21 @@ class Nd2Parser(object): channel_offset[channel] = n return channel_offset - @property - def _absolute_start(self): - """ - The date and time when acquisition began. - - :rtype: datetime.datetime() - - """ - for line in self.metadata[six.b('ImageTextInfo')][six.b('SLxImageTextInfo')].values(): - line = line.decode("utf8") - absolute_start_12 = None - absolute_start_24 = None - # ND2s seem to randomly switch between 12- and 24-hour representations. - try: - absolute_start_24 = datetime.strptime(line, "%m/%d/%Y %H:%M:%S") - except (TypeError, ValueError): - pass - try: - absolute_start_12 = datetime.strptime(line, "%m/%d/%Y %I:%M:%S %p") - except (TypeError, ValueError): - pass - if not absolute_start_12 and not absolute_start_24: - continue - 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+)\).*?""" - try: - count = int(re.match(pattern, self._dimensions).group(1)) - except AttributeError as e: - return 1 - else: - return count - - @property - def _field_of_view_count(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: int - - """ - pattern = r""".*?XY\((\d+)\).*?""" - try: - count = int(re.match(pattern, self._dimensions).group(1)) - except AttributeError: - return 1 - else: - return count - - @property - def _time_index_count(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 - - @property - def _z_level_count(self): - """ - The number of different levels in the Z-plane. - - :rtype: int - - """ - pattern = r""".*?Z\((\d+)\).*?""" + def _parse_dimension_text(self, pattern): try: count = int(re.match(pattern, self._dimensions).group(1)) except AttributeError: - return 1 + 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 _image_count(self): + def _total_images_per_channel(self): """ - The total number of images in the ND2. Warning: this may be inaccurate as it includes "gap" images. + The total number of images per channel. Warning: this may be inaccurate as it includes "gap" images. :rtype: int @@ -376,4 +378,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/requirements.txt b/requirements.txt index ed8da63..b8e18f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ numpy>=1.9.2 -six +six>=1.4 diff --git a/setup.py b/setup.py index d250eec..ee1b087 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,14 @@ -from distutils.core import setup +from setuptools import setup -VERSION = "1.0.1" +VERSION = "1.1.0" setup( name="nd2reader", packages=['nd2reader', 'nd2reader.model'], + install_requires=[ + 'numpy>=1.6.2, <2.0', + 'six>=1.4, <2.0' + ], version=VERSION, description='A tool for reading ND2 files produced by NIS Elements', author='Jim Rybarski', diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..49b4387 --- /dev/null +++ b/tests.py @@ -0,0 +1,5 @@ +import unittest +loader = unittest.TestLoader() +tests = loader.discover('tests', pattern='*.py', top_level_dir='.') +testRunner = unittest.TextTestRunner() +testRunner.run(tests) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..0a3def9 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,183 @@ +from nd2reader.parser import Nd2Parser +import unittest + + +class MockNd2Parser(object): + def __init__(self, channels, fields_of_view, z_levels): + self.channels = channels + self.fields_of_view = fields_of_view + self.z_levels = z_levels + + +class TestNd2Parser(unittest.TestCase): + def test_calculate_field_of_view_simple(self): + """ With a single field of view, the field of view should always be the same number (0). """ + nd2 = MockNd2Parser([''], [0], [0]) + for frame_number in range(1000): + result = Nd2Parser._calculate_field_of_view(nd2, frame_number) + self.assertEqual(result, 0) + + def test_calculate_field_of_view_two_channels(self): + nd2 = MockNd2Parser(['', 'GFP'], [0], [0]) + for frame_number in range(1000): + result = Nd2Parser._calculate_field_of_view(nd2, frame_number) + self.assertEqual(result, 0) + + def test_calculate_field_of_view_three_channels(self): + nd2 = MockNd2Parser(['', 'GFP', 'dsRed'], [0], [0]) + for frame_number in range(1000): + result = Nd2Parser._calculate_field_of_view(nd2, frame_number) + self.assertEqual(result, 0) + + def test_calculate_field_of_view_two_fovs(self): + nd2 = MockNd2Parser([''], [0, 1], [0]) + for frame_number in range(1000): + result = Nd2Parser._calculate_field_of_view(nd2, frame_number) + self.assertEqual(result, frame_number % 2) + + def test_calculate_field_of_view_two_fovs_two_zlevels(self): + nd2 = MockNd2Parser([''], [0, 1], [0, 1]) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 0), 0) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 1), 0) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 2), 1) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 3), 1) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 4), 0) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 5), 0) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 6), 1) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 7), 1) + + def test_calculate_field_of_view_two_everything(self): + nd2 = MockNd2Parser(['', 'GFP'], [0, 1], [0, 1]) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 0), 0) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 1), 0) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 2), 0) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 3), 0) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 4), 1) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 5), 1) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 6), 1) + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, 7), 1) + + def test_calculate_field_of_view_7c2f2z(self): + nd2 = MockNd2Parser(['', 'GFP', 'dsRed', 'dTomato', 'lulzBlue', 'jimbotronPurple', 'orange'], [0, 1], [0, 1]) + for i in range(14): + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, i), 0) + for i in range(14, 28): + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, i), 1) + for i in range(28, 42): + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, i), 0) + for i in range(42, 56): + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, i), 1) + for i in range(56, 70): + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, i), 0) + for i in range(70, 84): + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, i), 1) + + def test_calculate_field_of_view_2c3f5z(self): + """ All prime numbers to elucidate any errors that won't show up when numbers are multiples of each other """ + nd2 = MockNd2Parser(['', 'GFP'], [0, 1, 2], [0, 1, 2, 3, 4]) + for i in range(10): + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, i), 0) + for i in range(10, 20): + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, i), 1) + for i in range(20, 30): + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, i), 2) + for i in range(30, 40): + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, i), 0) + for i in range(40, 50): + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, i), 1) + for i in range(50, 60): + self.assertEqual(Nd2Parser._calculate_field_of_view(nd2, i), 2) + + def test_calculate_channel_simple(self): + nd2 = MockNd2Parser(['GFP'], [0], [0]) + for i in range(1000): + self.assertEqual(Nd2Parser._calculate_channel(nd2, i), 'GFP') + + def test_calculate_channel(self): + nd2 = MockNd2Parser(['', 'GFP', 'dsRed', 'dTomato', 'lulzBlue', 'jimbotronPurple', 'orange'], [0], [0]) + for i in range(1000): + for n, channel in enumerate(['', 'GFP', 'dsRed', 'dTomato', 'lulzBlue', 'jimbotronPurple', 'orange'], start=i*7): + self.assertEqual(Nd2Parser._calculate_channel(nd2, n), channel) + + def test_calculate_channel_7c2fov1z(self): + nd2 = MockNd2Parser(['', 'GFP', 'dsRed', 'dTomato', 'lulzBlue', 'jimbotronPurple', 'orange'], [0, 1], [0]) + for i in range(1000): + for n, channel in enumerate(['', 'GFP', 'dsRed', 'dTomato', 'lulzBlue', 'jimbotronPurple', 'orange'], start=i*7): + self.assertEqual(Nd2Parser._calculate_channel(nd2, n), channel) + + def test_calculate_channel_ludicrous_values(self): + nd2 = MockNd2Parser(['', 'GFP', 'dsRed', 'dTomato', 'lulzBlue', 'jimbotronPurple', 'orange'], list(range(31)), list(range(17))) + for i in range(10000): + for n, channel in enumerate(['', 'GFP', 'dsRed', 'dTomato', 'lulzBlue', 'jimbotronPurple', 'orange'], start=i*7): + self.assertEqual(Nd2Parser._calculate_channel(nd2, n), channel) + + def test_calculate_z_level(self): + nd2 = MockNd2Parser([''], [0], [0]) + for frame_number in range(1000): + result = Nd2Parser._calculate_z_level(nd2, frame_number) + self.assertEqual(result, 0) + + def test_calculate_z_level_1c1f2z(self): + nd2 = MockNd2Parser([''], [0], [0, 1]) + for frame_number in range(1000): + result = Nd2Parser._calculate_z_level(nd2, frame_number) + self.assertEqual(result, frame_number % 2) + + def test_calculate_z_level_31c17f1z(self): + nd2 = MockNd2Parser(list(range(31)), list(range(17)), [0]) + for frame_number in range(1000): + result = Nd2Parser._calculate_z_level(nd2, frame_number) + self.assertEqual(result, 0) + + def test_calculate_z_level_2c1f2z(self): + nd2 = MockNd2Parser(['', 'GFP'], [0], [0, 1]) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 0), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 1), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 2), 1) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 3), 1) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 4), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 5), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 6), 1) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 7), 1) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 8), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 9), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 10), 1) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 11), 1) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 12), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 13), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 14), 1) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 15), 1) + + def test_calculate_z_level_2c3f5z(self): + nd2 = MockNd2Parser(['', 'GFP'], [0, 1, 2], [0, 1, 2, 3, 4]) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 0), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 1), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 2), 1) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 3), 1) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 4), 2) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 5), 2) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 6), 3) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 7), 3) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 8), 4) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 9), 4) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 10), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 11), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 12), 1) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 13), 1) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 14), 2) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 15), 2) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 16), 3) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 17), 3) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 18), 4) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 19), 4) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 20), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 21), 0) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 22), 1) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 23), 1) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 24), 2) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 25), 2) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 26), 3) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 27), 3) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 28), 4) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 29), 4) + self.assertEqual(Nd2Parser._calculate_z_level(nd2, 30), 0) \ No newline at end of file