|
@ -2,6 +2,9 @@ |
|
|
|
|
|
|
|
|
import array |
|
|
import array |
|
|
from collections import namedtuple |
|
|
from collections import namedtuple |
|
|
|
|
|
from datetime import datetime |
|
|
|
|
|
import numpy as np |
|
|
|
|
|
import re |
|
|
import struct |
|
|
import struct |
|
|
from StringIO import StringIO |
|
|
from StringIO import StringIO |
|
|
|
|
|
|
|
@ -11,6 +14,7 @@ field_of_view = namedtuple('FOV', ['number', 'x', 'y', 'z', 'pfs_offset']) |
|
|
class Nd2Parser(object): |
|
|
class Nd2Parser(object): |
|
|
""" |
|
|
""" |
|
|
Reads .nd2 files, provides an interface to the metadata, and generates numpy arrays from the image data. |
|
|
Reads .nd2 files, provides an interface to the metadata, and generates numpy arrays from the image data. |
|
|
|
|
|
You should not ever need to instantiate this class manually unless you're a developer. |
|
|
|
|
|
|
|
|
""" |
|
|
""" |
|
|
CHUNK_HEADER = 0xabeceda |
|
|
CHUNK_HEADER = 0xabeceda |
|
@ -18,7 +22,6 @@ class Nd2Parser(object): |
|
|
CHUNK_MAP_END = "ND2 CHUNK MAP SIGNATURE 0000001!" |
|
|
CHUNK_MAP_END = "ND2 CHUNK MAP SIGNATURE 0000001!" |
|
|
|
|
|
|
|
|
def __init__(self, filename): |
|
|
def __init__(self, filename): |
|
|
self._absolute_start = None |
|
|
|
|
|
self._filename = filename |
|
|
self._filename = filename |
|
|
self._fh = None |
|
|
self._fh = None |
|
|
self._chunk_map_start_location = None |
|
|
self._chunk_map_start_location = None |
|
@ -35,8 +38,48 @@ class Nd2Parser(object): |
|
|
self._fh = open(self._filename, "rb") |
|
|
self._fh = open(self._filename, "rb") |
|
|
return self._fh |
|
|
return self._fh |
|
|
|
|
|
|
|
|
|
|
|
def _get_raw_image_data(self, image_group_number, channel_offset): |
|
|
|
|
|
""" |
|
|
|
|
|
Reads the raw bytes and the timestamp of an image. |
|
|
|
|
|
|
|
|
|
|
|
:param image_group_number: groups are made of images with the same time index, field of view and z-level. |
|
|
|
|
|
:type image_group_number: int |
|
|
|
|
|
:param channel_offset: the offset in the array where the bytes for this image are found. |
|
|
|
|
|
:type channel_offset: int |
|
|
|
|
|
|
|
|
|
|
|
:return: (int, array.array()) or None |
|
|
|
|
|
|
|
|
|
|
|
""" |
|
|
|
|
|
chunk = self._label_map["ImageDataSeq|%d!" % image_group_number] |
|
|
|
|
|
data = self._read_chunk(chunk) |
|
|
|
|
|
# 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 |
|
|
|
|
|
# seconds unless you're doing something super weird. |
|
|
|
|
|
timestamp = struct.unpack("d", data[:8])[0] |
|
|
|
|
|
image_group_data = array.array("H", data) |
|
|
|
|
|
image_data_start = 4 + channel_offset |
|
|
|
|
|
# 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] |
|
|
|
|
|
# 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 |
|
|
|
|
|
# them every cycle. |
|
|
|
|
|
if np.any(image_data): |
|
|
|
|
|
return timestamp, image_data |
|
|
|
|
|
return None |
|
|
|
|
|
|
|
|
@property |
|
|
@property |
|
|
def _dimensions(self): |
|
|
def _dimensions(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. |
|
|
|
|
|
|
|
|
|
|
|
:rtype: str |
|
|
|
|
|
|
|
|
|
|
|
""" |
|
|
if self._dimension_text is None: |
|
|
if self._dimension_text is None: |
|
|
for line in self.metadata['ImageTextInfo']['SLxImageTextInfo'].values(): |
|
|
for line in self.metadata['ImageTextInfo']['SLxImageTextInfo'].values(): |
|
|
if "Dimensions:" in line: |
|
|
if "Dimensions:" in line: |
|
@ -53,14 +96,162 @@ class Nd2Parser(object): |
|
|
return self._dimension_text |
|
|
return self._dimension_text |
|
|
|
|
|
|
|
|
@property |
|
|
@property |
|
|
def _image_count(self): |
|
|
|
|
|
return self.metadata['ImageAttributes']['SLxImageAttributes']['uiSequenceCount'] |
|
|
|
|
|
|
|
|
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['ImageMetadataSeq']['SLxPictureMetadata']['sPicturePlanes'] |
|
|
|
|
|
try: |
|
|
|
|
|
validity = self.metadata['ImageMetadata']['SLxExperiment']['ppNextLevelEx'][''][0]['ppNextLevelEx'][''][0]['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['sPlaneNew'].items()), validity): |
|
|
|
|
|
if not valid: |
|
|
|
|
|
continue |
|
|
|
|
|
yield chan['sDescription'] |
|
|
|
|
|
|
|
|
|
|
|
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. |
|
|
|
|
|
|
|
|
|
|
|
:type time_index: int |
|
|
|
|
|
:type fov: int |
|
|
|
|
|
:type z_level: int |
|
|
|
|
|
|
|
|
|
|
|
:rtype: int |
|
|
|
|
|
|
|
|
|
|
|
""" |
|
|
|
|
|
return time_index * self._field_of_view_count * self._z_level_count + (fov * self._z_level_count + z_level) |
|
|
|
|
|
|
|
|
|
|
|
@property |
|
|
|
|
|
def _channel_offset(self): |
|
|
|
|
|
""" |
|
|
|
|
|
Image data is interleaved for each image set. That is, if there are four images in a set, the first image |
|
|
|
|
|
will consist of pixels 1, 5, 9, etc, the second will be pixels 2, 6, 10, and so forth. |
|
|
|
|
|
|
|
|
|
|
|
:rtype: dict |
|
|
|
|
|
|
|
|
|
|
|
""" |
|
|
|
|
|
channel_offset = {} |
|
|
|
|
|
for n, channel in enumerate(self._channels): |
|
|
|
|
|
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['ImageTextInfo']['SLxImageTextInfo'].values(): |
|
|
|
|
|
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 ValueError: |
|
|
|
|
|
pass |
|
|
|
|
|
try: |
|
|
|
|
|
absolute_start_12 = datetime.strptime(line, "%m/%d/%Y %I:%M:%S %p") |
|
|
|
|
|
except 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 |
|
|
@property |
|
|
def _sequence_count(self): |
|
|
|
|
|
return self.metadata['ImageEvents']['RLxExperimentRecord']['uiCount'] |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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+)\).*?""" |
|
|
|
|
|
try: |
|
|
|
|
|
count = int(re.match(pattern, self._dimensions).group(1)) |
|
|
|
|
|
except AttributeError: |
|
|
|
|
|
return 1 |
|
|
|
|
|
else: |
|
|
|
|
|
return count |
|
|
|
|
|
|
|
|
|
|
|
@property |
|
|
|
|
|
def _image_count(self): |
|
|
|
|
|
""" |
|
|
|
|
|
The total number of images in the ND2. Warning: this may be inaccurate as it includes "gap" images. |
|
|
|
|
|
|
|
|
|
|
|
:rtype: int |
|
|
|
|
|
|
|
|
|
|
|
""" |
|
|
|
|
|
return self.metadata['ImageAttributes']['SLxImageAttributes']['uiSequenceCount'] |
|
|
|
|
|
|
|
|
def _parse_metadata(self): |
|
|
def _parse_metadata(self): |
|
|
|
|
|
""" |
|
|
|
|
|
Reads all metadata. |
|
|
|
|
|
|
|
|
|
|
|
""" |
|
|
for label in self._label_map.keys(): |
|
|
for label in self._label_map.keys(): |
|
|
if label.endswith("LV!") or "LV|" in label: |
|
|
if label.endswith("LV!") or "LV|" in label: |
|
|
data = self._read_chunk(self._label_map[label]) |
|
|
data = self._read_chunk(self._label_map[label]) |
|
@ -106,10 +297,6 @@ class Nd2Parser(object): |
|
|
self._file_handle.seek(chunk_location + 16 + relative_offset) |
|
|
self._file_handle.seek(chunk_location + 16 + relative_offset) |
|
|
return self._file_handle.read(data_length) |
|
|
return self._file_handle.read(data_length) |
|
|
|
|
|
|
|
|
def _z_level_count(self): |
|
|
|
|
|
st = self._read_chunk(self._label_map["CustomData|Z!"]) |
|
|
|
|
|
return len(array.array("d", st)) |
|
|
|
|
|
|
|
|
|
|
|
def _parse_unsigned_char(self, data): |
|
|
def _parse_unsigned_char(self, data): |
|
|
return struct.unpack("B", data.read(1))[0] |
|
|
return struct.unpack("B", data.read(1))[0] |
|
|
|
|
|
|
|
@ -134,6 +321,10 @@ class Nd2Parser(object): |
|
|
return array.array("B", data.read(array_length)) |
|
|
return array.array("B", data.read(array_length)) |
|
|
|
|
|
|
|
|
def _parse_metadata_item(self, data): |
|
|
def _parse_metadata_item(self, data): |
|
|
|
|
|
""" |
|
|
|
|
|
Reads hierarchical data, analogous to a Python dict. |
|
|
|
|
|
|
|
|
|
|
|
""" |
|
|
new_count, length = struct.unpack("<IQ", data.read(12)) |
|
|
new_count, length = struct.unpack("<IQ", data.read(12)) |
|
|
length -= data.tell() - self._cursor_position |
|
|
length -= data.tell() - self._cursor_position |
|
|
next_data_length = data.read(length) |
|
|
next_data_length = data.read(length) |
|
@ -143,6 +334,10 @@ class Nd2Parser(object): |
|
|
return value |
|
|
return value |
|
|
|
|
|
|
|
|
def _get_value(self, data, data_type): |
|
|
def _get_value(self, data, data_type): |
|
|
|
|
|
""" |
|
|
|
|
|
ND2s use various codes to indicate different data types, which we translate here. |
|
|
|
|
|
|
|
|
|
|
|
""" |
|
|
parser = {1: self._parse_unsigned_char, |
|
|
parser = {1: self._parse_unsigned_char, |
|
|
2: self._parse_unsigned_int, |
|
|
2: self._parse_unsigned_int, |
|
|
3: self._parse_unsigned_int, |
|
|
3: self._parse_unsigned_int, |
|
@ -154,12 +349,17 @@ class Nd2Parser(object): |
|
|
return parser[data_type](data) |
|
|
return parser[data_type](data) |
|
|
|
|
|
|
|
|
def _read_metadata(self, data, count): |
|
|
def _read_metadata(self, data, count): |
|
|
|
|
|
""" |
|
|
|
|
|
Iterates over each element some section of the metadata and parses it. |
|
|
|
|
|
|
|
|
|
|
|
""" |
|
|
data = StringIO(data) |
|
|
data = StringIO(data) |
|
|
metadata = {} |
|
|
metadata = {} |
|
|
for _ in xrange(count): |
|
|
for _ in xrange(count): |
|
|
self._cursor_position = data.tell() |
|
|
self._cursor_position = data.tell() |
|
|
header = data.read(2) |
|
|
header = data.read(2) |
|
|
if not header: |
|
|
if not header: |
|
|
|
|
|
# We've reached the end of some hierarchy of data |
|
|
break |
|
|
break |
|
|
data_type, name_length = map(ord, header) |
|
|
data_type, name_length = map(ord, header) |
|
|
name = data.read(name_length * 2).decode("utf16")[:-1].encode("utf8") |
|
|
name = data.read(name_length * 2).decode("utf16")[:-1].encode("utf8") |
|
|