msr-reader 0.2.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msr_reader-0.3.0/.gitignore +6 -0
- {msr_reader-0.2.1 → msr_reader-0.3.0}/PKG-INFO +19 -2
- {msr_reader-0.2.1 → msr_reader-0.3.0}/README.md +17 -0
- {msr_reader-0.2.1 → msr_reader-0.3.0}/pyproject.toml +1 -1
- msr_reader-0.3.0/src/msr_reader/__init__.py +2 -0
- {msr_reader-0.2.1 → msr_reader-0.3.0}/src/msr_reader/obffile.py +259 -18
- msr_reader-0.3.0/test.ipynb +140 -0
- msr_reader-0.3.0/tests/__init__.py +6 -0
- msr_reader-0.3.0/tests/data/20261019tests_4d.msr +0 -0
- msr_reader-0.3.0/tests/data/20261019tests_incomplete_measurement.msr +0 -0
- msr_reader-0.3.0/tests/data/20261019tests_lightbox.obf +0 -0
- msr_reader-0.3.0/tests/data/20261019tests_overwritten_measurement1.msr +0 -0
- msr_reader-0.3.0/tests/data/2889b245416cd9783c627f413caa45c9_overview_1.msr +0 -0
- msr_reader-0.3.0/tests/data/bad44489_field_0.msr +0 -0
- msr_reader-0.3.0/tests/data/bad44489_field_0_sted_0.msr +0 -0
- msr_reader-0.3.0/tests/data/conf01.msr +0 -0
- msr_reader-0.3.0/tests/data/groupa_cell01.msr +0 -0
- msr_reader-0.3.0/tests/data/mesc_yoyo2_img_001.msr +0 -0
- msr_reader-0.3.0/tests/synthetic_obf.py +204 -0
- msr_reader-0.3.0/tests/test_real_files.py +221 -0
- msr_reader-0.3.0/tests/test_synthetic_format.py +236 -0
- msr_reader-0.3.0/tests/test_synthetic_grouping.py +157 -0
- msr_reader-0.2.1/.gitignore +0 -2
- msr_reader-0.2.1/.idea/.gitignore +0 -8
- msr_reader-0.2.1/.idea/inspectionProfiles/profiles_settings.xml +0 -6
- msr_reader-0.2.1/.idea/misc.xml +0 -4
- msr_reader-0.2.1/.idea/modules.xml +0 -8
- msr_reader-0.2.1/.idea/msr-reader.iml +0 -12
- msr_reader-0.2.1/.idea/vcs.xml +0 -6
- msr_reader-0.2.1/.idea/workspace.xml +0 -62
- msr_reader-0.2.1/src/msr_reader/__init__.py +0 -2
- msr_reader-0.2.1/test.ipynb +0 -114
- {msr_reader-0.2.1 → msr_reader-0.3.0}/LICENSE +0 -0
- {msr_reader-0.2.1 → msr_reader-0.3.0}/src/msr_reader/xml_metadata.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: msr-reader
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Pure Python reader for MSR/OBF (Imspector) image data
|
|
5
5
|
Project-URL: Homepage, https://github.com/hoerlteam/msr-reader
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/hoerlteam/msr-reader/issues
|
|
@@ -41,6 +41,23 @@ with OBFFile('file_path.msr') as f:
|
|
|
41
41
|
idx = 0
|
|
42
42
|
img = f.read_stack(idx) # read stack with index idx into numpy array
|
|
43
43
|
|
|
44
|
+
# multi-channel (or confocal + STED) data is acquired into separate stacks by Imspector.
|
|
45
|
+
# stack_groups groups stacks with same series number in the stack name and identical geometry.
|
|
46
|
+
# every stack belongs to exactly one group, unmatched stacks
|
|
47
|
+
# (e.g. line profiles, rescue_info maps) form single-member groups.
|
|
48
|
+
groups = f.stack_groups # list of StackGroup
|
|
49
|
+
img = f.read_stack_group(0) # read all "channels", stacked along a new first axis: e.g. (C, Z, Y, X)
|
|
50
|
+
|
|
51
|
+
# stacks without names (sometimes found in old files) can be grouped by geometry alone:
|
|
52
|
+
# with OBFFile('file_path.msr', group_unnamed_stacks=True) as f:
|
|
53
|
+
|
|
54
|
+
# Lightbox files use hierarchical stack names ('Overview 1/Image 1/14:03:01/STAR RED'):
|
|
55
|
+
# for .obf files, grouping by the path up to the last component (the
|
|
56
|
+
# acquisition) and the last component (the channel name) is enabled by
|
|
57
|
+
# default. for .msr files (Imspector), enable it explicitly:
|
|
58
|
+
# with OBFFile('file_path.msr', group_hierarchical_stacks=True) as f:
|
|
59
|
+
# pass group_hierarchical_stacks=False to disable it for .obf files.
|
|
60
|
+
|
|
44
61
|
# metadata
|
|
45
62
|
stack_shapes = f.shapes # list of stack shapes, including stack and dimension names
|
|
46
63
|
pixel_sizes = f.pixel_sizes # like shapes, but with pixel sizes (unit: meters)
|
|
@@ -25,6 +25,23 @@ with OBFFile('file_path.msr') as f:
|
|
|
25
25
|
idx = 0
|
|
26
26
|
img = f.read_stack(idx) # read stack with index idx into numpy array
|
|
27
27
|
|
|
28
|
+
# multi-channel (or confocal + STED) data is acquired into separate stacks by Imspector.
|
|
29
|
+
# stack_groups groups stacks with same series number in the stack name and identical geometry.
|
|
30
|
+
# every stack belongs to exactly one group, unmatched stacks
|
|
31
|
+
# (e.g. line profiles, rescue_info maps) form single-member groups.
|
|
32
|
+
groups = f.stack_groups # list of StackGroup
|
|
33
|
+
img = f.read_stack_group(0) # read all "channels", stacked along a new first axis: e.g. (C, Z, Y, X)
|
|
34
|
+
|
|
35
|
+
# stacks without names (sometimes found in old files) can be grouped by geometry alone:
|
|
36
|
+
# with OBFFile('file_path.msr', group_unnamed_stacks=True) as f:
|
|
37
|
+
|
|
38
|
+
# Lightbox files use hierarchical stack names ('Overview 1/Image 1/14:03:01/STAR RED'):
|
|
39
|
+
# for .obf files, grouping by the path up to the last component (the
|
|
40
|
+
# acquisition) and the last component (the channel name) is enabled by
|
|
41
|
+
# default. for .msr files (Imspector), enable it explicitly:
|
|
42
|
+
# with OBFFile('file_path.msr', group_hierarchical_stacks=True) as f:
|
|
43
|
+
# pass group_hierarchical_stacks=False to disable it for .obf files.
|
|
44
|
+
|
|
28
45
|
# metadata
|
|
29
46
|
stack_shapes = f.shapes # list of stack shapes, including stack and dimension names
|
|
30
47
|
pixel_sizes = f.pixel_sizes # like shapes, but with pixel sizes (unit: meters)
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
1
3
|
import struct
|
|
4
|
+
import warnings
|
|
2
5
|
from collections import namedtuple
|
|
3
6
|
from io import BytesIO
|
|
4
7
|
from zlib import decompress
|
|
@@ -11,21 +14,64 @@ MAGIC_STACK_HEADER = b'OMAS_BF_STACK\n\xff\xff'
|
|
|
11
14
|
|
|
12
15
|
OBFHeader = namedtuple('OBFHeader', ['format_version', 'description', 'first_stack_position', 'metadata_position', 'metadata'])
|
|
13
16
|
|
|
14
|
-
OBFStackHeader
|
|
17
|
+
class OBFStackHeader(namedtuple('OBFStackHeader',
|
|
15
18
|
['stack_version', 'size', 'length', 'offset', 'dtype', 'compressed', 'name',
|
|
16
|
-
'description', 'data_position', 'data_length', 'next_stack_position'])
|
|
19
|
+
'description', 'data_position', 'reserved', 'data_length', 'next_stack_position'])):
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
@property
|
|
22
|
+
def numpy_dtype(self):
|
|
23
|
+
'''
|
|
24
|
+
numpy dtype (np.dtype instance, like arr.dtype) of the stack data. note
|
|
25
|
+
that dtype holds the raw OMAS_DT flag from the file (including the
|
|
26
|
+
complex bit and, for RGB types, the number of samples per pixel), which
|
|
27
|
+
is not fully captured by a numpy dtype. raises ValueError for
|
|
28
|
+
unsupported flags.
|
|
29
|
+
'''
|
|
30
|
+
return np.dtype(_parse_dtype(self.dtype)[0])
|
|
21
31
|
|
|
22
|
-
|
|
32
|
+
OBFSIFraction = namedtuple('OBFSIFraction', ['numerator', 'denominator'])
|
|
33
|
+
class OBFSIUnit(namedtuple('OBFSIUnit', ['meters', 'kilograms', 'seconds', 'amperes', 'kelvin', 'moles',
|
|
34
|
+
'candela', 'radian', 'steradian', 'scale_factor'])):
|
|
35
|
+
'''
|
|
36
|
+
an SI unit: the 9 base/supplementary units with rational exponents
|
|
37
|
+
(OBFSIFraction each, numerator 0 = unused), multiplied by scale_factor,
|
|
38
|
+
i.e. unit = scale_factor * m^p1 * kg^p2 * s^p3 * ...
|
|
39
|
+
'''
|
|
40
|
+
|
|
41
|
+
# symbols matching the exponent field order above
|
|
42
|
+
_SYMBOLS = ('m', 'kg', 's', 'A', 'K', 'mol', 'cd', 'rad', 'sr')
|
|
43
|
+
|
|
44
|
+
def __str__(self):
|
|
45
|
+
parts = []
|
|
46
|
+
for symbol, fraction in zip(self._SYMBOLS, self[:9]):
|
|
47
|
+
if fraction.numerator == 0:
|
|
48
|
+
continue
|
|
49
|
+
if fraction.denominator == 1:
|
|
50
|
+
parts.append(symbol if fraction.numerator == 1 else f'{symbol}^{fraction.numerator}')
|
|
51
|
+
else:
|
|
52
|
+
parts.append(f'{symbol}^({fraction.numerator}/{fraction.denominator})')
|
|
53
|
+
unit = ' * '.join(parts) if parts else 'dimensionless'
|
|
54
|
+
if self.scale_factor != 1:
|
|
55
|
+
unit += f' * {self.scale_factor:g}'
|
|
56
|
+
return unit
|
|
57
|
+
|
|
58
|
+
# make notebooks show the pretty form, not the namedtuple field wall
|
|
59
|
+
__repr__ = __str__
|
|
60
|
+
|
|
61
|
+
OBFStackFooter = namedtuple('OBFStackFooter', ['size', 'has_col_positions', 'has_col_labels', 'col_positions', 'col_labels', 'metadata', 'si_value', 'si_dimensions',
|
|
23
62
|
'flush_positions', 'flush_block_size', 'tag_dictionary', 'stack_end_disk',
|
|
24
63
|
'min_format_version', 'stack_end_used_disk', 'samples_written', 'num_chunk_positions',
|
|
25
64
|
'dimension_labels'])
|
|
26
65
|
|
|
27
66
|
StackSizes = namedtuple('StackSizes', ['name', 'sizes', 'dimension_names'])
|
|
28
67
|
|
|
68
|
+
# pattern of default Imspector stack names: a user-settable channel name
|
|
69
|
+
# followed by the series number of the acquisition, e.g. 'STAR RED_CONF {0}'
|
|
70
|
+
STACK_NAME_PATTERN = re.compile(r'^(?P<base>.+) \{(?P<series>\d+)\}$')
|
|
71
|
+
|
|
72
|
+
StackGroup = namedtuple('StackGroup', ['series', 'channel_names', 'indices',
|
|
73
|
+
'shape', 'dtype', 'pixel_sizes', 'dimension_names'])
|
|
74
|
+
|
|
29
75
|
def _read_file_header(fd):
|
|
30
76
|
header_fmt = '<10sLQL'
|
|
31
77
|
magic_header, format_version, first_stack_pos, descr_len = struct.unpack(header_fmt, fd.read(struct.calcsize(header_fmt)))
|
|
@@ -59,14 +105,14 @@ def _read_stack_header(fd, position):
|
|
|
59
105
|
offset = struct.unpack(f'<{MAXIMAL_NUMBER_OF_DIMENSIONS}d', fd.read(struct.calcsize(f'<{MAXIMAL_NUMBER_OF_DIMENSIONS}d')))[:rank]
|
|
60
106
|
|
|
61
107
|
stack_header_fmt2 = '<LLLLLQQQ'
|
|
62
|
-
data_type, compression_type, compression_level, name_len, descr_len,
|
|
63
|
-
|
|
108
|
+
data_type, compression_type, compression_level, name_len, descr_len, reserved, data_len, next_stack_pos = struct.unpack(stack_header_fmt2, fd.read(struct.calcsize(stack_header_fmt2)))
|
|
109
|
+
|
|
64
110
|
name = fd.read(name_len).decode('utf-8')
|
|
65
111
|
descr = fd.read(descr_len).decode('utf-8')
|
|
66
112
|
|
|
67
113
|
data_pos = fd.tell()
|
|
68
114
|
|
|
69
|
-
return OBFStackHeader(version, size, length, offset, data_type, compression_type>0, name, descr, data_pos, data_len, next_stack_pos)
|
|
115
|
+
return OBFStackHeader(version, size, length, offset, data_type, compression_type>0, name, descr, data_pos, reserved, data_len, next_stack_pos)
|
|
70
116
|
|
|
71
117
|
def _parse_dtype(dtype_flags: int):
|
|
72
118
|
|
|
@@ -116,7 +162,7 @@ def _parse_dtype(dtype_flags: int):
|
|
|
116
162
|
|
|
117
163
|
return dtype, samples_per_pixel
|
|
118
164
|
|
|
119
|
-
def _read_stack(fd, stack_header: OBFStackHeader):
|
|
165
|
+
def _read_stack(fd, stack_header: OBFStackHeader, samples_written=None):
|
|
120
166
|
fd.seek(stack_header.data_position)
|
|
121
167
|
buffer = fd.read(stack_header.data_length)
|
|
122
168
|
|
|
@@ -126,6 +172,26 @@ def _read_stack(fd, stack_header: OBFStackHeader):
|
|
|
126
172
|
|
|
127
173
|
dtype, samples_per_pixel = _parse_dtype(stack_header.dtype)
|
|
128
174
|
|
|
175
|
+
# reserved == 0 marks a data-less stack: no data on disk, the array is
|
|
176
|
+
# filled with zeros by convention (see file format spec)
|
|
177
|
+
if stack_header.reserved == 0:
|
|
178
|
+
warnings.warn(f'stack {stack_header.name!r} is data-less, the returned data is all zeros')
|
|
179
|
+
|
|
180
|
+
# a measurement ended early only writes samples_written samples (footer V6+),
|
|
181
|
+
# by convention the missing data is zero (see file format spec), so pad short
|
|
182
|
+
# buffers up to the full stack size (and drop any excess padding)
|
|
183
|
+
num_samples = int(np.prod(stack_header.size)) * samples_per_pixel
|
|
184
|
+
expected_bytes = num_samples * np.dtype(dtype).itemsize
|
|
185
|
+
if len(buffer) < expected_bytes:
|
|
186
|
+
buffer += b'\x00' * (expected_bytes - len(buffer))
|
|
187
|
+
elif len(buffer) > expected_bytes:
|
|
188
|
+
buffer = buffer[:expected_bytes]
|
|
189
|
+
|
|
190
|
+
# samples_written == 0 means the full stack is on disk (see spec)
|
|
191
|
+
if samples_written and samples_written < num_samples:
|
|
192
|
+
warnings.warn(f'stack {stack_header.name!r} was truncated, only {samples_written} of '
|
|
193
|
+
f'{num_samples} samples were written, the remainder is zero-padded')
|
|
194
|
+
|
|
129
195
|
return np.frombuffer(buffer, dtype=dtype).reshape(stack_header.size[::-1] + (samples_per_pixel, )).squeeze()
|
|
130
196
|
|
|
131
197
|
def _read_tag_dict(buffer):
|
|
@@ -151,8 +217,26 @@ def _to_si_unit(unpack_output):
|
|
|
151
217
|
return OBFSIUnit(*unit_fractions, unpack_output[-1])
|
|
152
218
|
|
|
153
219
|
|
|
220
|
+
def _empty_stack_footer(stack_header: OBFStackHeader):
|
|
221
|
+
# stack footers only exist from stack version 1 on. version-0 stacks carry no
|
|
222
|
+
# footer, labels or tag dictionary on disk, so return empty/default values.
|
|
223
|
+
# dimension_labels are padded to the stack rank so they stay aligned with
|
|
224
|
+
# the stack axes (they are zipped together in OBFFile.shapes/pixel_sizes).
|
|
225
|
+
return OBFStackFooter(
|
|
226
|
+
size=None, has_col_positions=None, has_col_labels=None,
|
|
227
|
+
col_positions=[None] * len(stack_header.size), col_labels=[None] * len(stack_header.size),
|
|
228
|
+
metadata='',
|
|
229
|
+
si_value=None, si_dimensions=None, flush_positions=[], flush_block_size=None,
|
|
230
|
+
tag_dictionary={}, stack_end_disk=None, min_format_version=None,
|
|
231
|
+
stack_end_used_disk=None, samples_written=None, num_chunk_positions=None,
|
|
232
|
+
dimension_labels=[''] * len(stack_header.size))
|
|
233
|
+
|
|
154
234
|
def _read_stack_footer(fd, stack_header: OBFStackHeader):
|
|
155
235
|
|
|
236
|
+
# version-0 stacks are written without any footer
|
|
237
|
+
if stack_header.stack_version < 1:
|
|
238
|
+
return _empty_stack_footer(stack_header)
|
|
239
|
+
|
|
156
240
|
# seek to footer start (after binary data)
|
|
157
241
|
fd.seek(stack_header.data_position + stack_header.data_length)
|
|
158
242
|
|
|
@@ -212,12 +296,36 @@ def _read_stack_footer(fd, stack_header: OBFStackHeader):
|
|
|
212
296
|
labels.append(s.decode('utf8'))
|
|
213
297
|
|
|
214
298
|
if stack_header.stack_version >= 1:
|
|
215
|
-
# TODO: handle
|
|
216
|
-
if any(has_col_labels) or any(has_col_positions):
|
|
217
|
-
raise ValueError('col_label and col_position parsing not implemented yet')
|
|
299
|
+
# TODO: handle chunked storage
|
|
218
300
|
if num_chunk_positions is not None and num_chunk_positions > 0:
|
|
219
301
|
raise ValueError('chunked storage not implemented yet')
|
|
220
302
|
|
|
303
|
+
# column positions: for each axis with has_col_positions[i] != 0 an array
|
|
304
|
+
# of res[i] float64 values follows.
|
|
305
|
+
# entries are None for axes without positions.
|
|
306
|
+
# NOTE: we assume all position arrays come before the column label arrays
|
|
307
|
+
# TODO: needs testing on actual data!
|
|
308
|
+
col_positions = []
|
|
309
|
+
for i, has_pos in enumerate(has_col_positions):
|
|
310
|
+
if has_pos:
|
|
311
|
+
col_positions.append(np.frombuffer(fd.read(8 * stack_header.size[i]), dtype='<f8'))
|
|
312
|
+
else:
|
|
313
|
+
col_positions.append(None)
|
|
314
|
+
|
|
315
|
+
# column labels: for each axis with has_col_labels[i] != 0 an array of
|
|
316
|
+
# res[i] strings follows (same encoding as the dimension labels above).
|
|
317
|
+
# entries are None for axes without column labels.
|
|
318
|
+
col_labels = []
|
|
319
|
+
for i, has_lab in enumerate(has_col_labels):
|
|
320
|
+
if has_lab:
|
|
321
|
+
labels_i = []
|
|
322
|
+
for _ in range(stack_header.size[i]):
|
|
323
|
+
(n,) = struct.unpack(f'<L', fd.read(struct.calcsize(f'<L')))
|
|
324
|
+
labels_i.append(fd.read(n).decode('utf8'))
|
|
325
|
+
col_labels.append(labels_i)
|
|
326
|
+
else:
|
|
327
|
+
col_labels.append(None)
|
|
328
|
+
|
|
221
329
|
metadata = fd.read(metadata_length).decode('utf-8')
|
|
222
330
|
else:
|
|
223
331
|
metadata = ""
|
|
@@ -234,7 +342,7 @@ def _read_stack_footer(fd, stack_header: OBFStackHeader):
|
|
|
234
342
|
else:
|
|
235
343
|
tag_dict = {}
|
|
236
344
|
|
|
237
|
-
return OBFStackFooter(size, has_col_positions, has_col_labels, metadata, si_value, si_dimensions, flush_positions,
|
|
345
|
+
return OBFStackFooter(size, has_col_positions, has_col_labels, col_positions, col_labels, metadata, si_value, si_dimensions, flush_positions,
|
|
238
346
|
flush_block_size, tag_dict, stack_end_disk, min_format_version, stack_end_used_disk,
|
|
239
347
|
samples_written, num_chunk_positions, labels)
|
|
240
348
|
|
|
@@ -246,10 +354,18 @@ class OBFFile:
|
|
|
246
354
|
https://imspectordocs.readthedocs.io/en/latest/fileformat.html
|
|
247
355
|
"""
|
|
248
356
|
|
|
249
|
-
def __init__(self, path):
|
|
357
|
+
def __init__(self, path, group_unnamed_stacks=False, group_hierarchical_stacks=None):
|
|
250
358
|
self.fd = open(path, 'rb')
|
|
359
|
+
self._group_unnamed_stacks = group_unnamed_stacks
|
|
360
|
+
if group_hierarchical_stacks is None:
|
|
361
|
+
# auto by file extension: Lightbox writes .obf files with
|
|
362
|
+
# hierarchical stack names, Imspector writes .msr files (OBF plus
|
|
363
|
+
# some undocumented extras like GUI state)
|
|
364
|
+
group_hierarchical_stacks = os.path.splitext(str(path))[1].lower() == '.obf'
|
|
365
|
+
self._group_hierarchical_stacks = group_hierarchical_stacks
|
|
251
366
|
self._parse_headers()
|
|
252
367
|
self.stack_names = [s.name for s in self.stack_headers]
|
|
368
|
+
self._stack_groups = None
|
|
253
369
|
|
|
254
370
|
def _parse_headers(self):
|
|
255
371
|
self.main_header = _read_file_header(self.fd)
|
|
@@ -265,7 +381,7 @@ class OBFFile:
|
|
|
265
381
|
self.stack_footers.append(stack_footer)
|
|
266
382
|
|
|
267
383
|
def read_stack(self, idx):
|
|
268
|
-
return _read_stack(self.fd, self.stack_headers[idx])
|
|
384
|
+
return _read_stack(self.fd, self.stack_headers[idx], self.stack_footers[idx].samples_written)
|
|
269
385
|
|
|
270
386
|
@property
|
|
271
387
|
def num_stacks(self):
|
|
@@ -316,6 +432,130 @@ class OBFFile:
|
|
|
316
432
|
"""
|
|
317
433
|
return self.pixel_sizes[stack_idx].sizes
|
|
318
434
|
|
|
435
|
+
def _group_key(self, stack_idx):
|
|
436
|
+
# geometry part of the stack grouping key: shape, data type, pixel
|
|
437
|
+
# sizes and dimension labels.
|
|
438
|
+
header_i = self.stack_headers[stack_idx]
|
|
439
|
+
shapes_i = self.shapes[stack_idx]
|
|
440
|
+
pixel_sizes_i = self.pixel_sizes[stack_idx]
|
|
441
|
+
# round pixel sizes to a fixed number of significant digits to make the
|
|
442
|
+
# comparison robust against float noise: scientific notation rounds with
|
|
443
|
+
# relative precision, unlike round()/np.round() which round to decimal
|
|
444
|
+
# places (absolute precision) and would merge distinct pixel sizes
|
|
445
|
+
# around the nanometer scale (or collapse tiny values onto each other)
|
|
446
|
+
pixel_sizes_rounded = tuple(float(f'{p:.9e}') for p in pixel_sizes_i.sizes)
|
|
447
|
+
return (tuple(shapes_i.sizes), header_i.dtype, pixel_sizes_rounded, tuple(shapes_i.dimension_names))
|
|
448
|
+
|
|
449
|
+
@property
|
|
450
|
+
def stack_groups(self):
|
|
451
|
+
"""
|
|
452
|
+
stacks grouped into channels: stacks acquired in the same series
|
|
453
|
+
(the number in curly braces in the stack name) with identical geometry
|
|
454
|
+
(shape, data type, pixel sizes, dimension labels) are considered
|
|
455
|
+
channels of the same acquisition. stacks that fit nowhere (e.g.
|
|
456
|
+
auxiliary stacks like rescue_info maps, or stacks without a parseable
|
|
457
|
+
name) form single-member groups. every stack is part of exactly one
|
|
458
|
+
group, so the groups are always a partition of all stacks.
|
|
459
|
+
|
|
460
|
+
with group_hierarchical_stacks enabled, names containing '/' (as
|
|
461
|
+
written by the Lightbox software, e.g. 'Overview 1/Image 1/14:03:01/
|
|
462
|
+
STAR RED') are grouped by the path up to the last component instead:
|
|
463
|
+
the path identifies the acquisition (the series), the last component
|
|
464
|
+
is the channel name. enabled by default for .obf files (Lightbox),
|
|
465
|
+
disabled for .msr files (Imspector); pass group_hierarchical_stacks
|
|
466
|
+
True/False to override the extension-based default.
|
|
467
|
+
"""
|
|
468
|
+
if self._stack_groups is None:
|
|
469
|
+
self._stack_groups = self._compute_stack_groups()
|
|
470
|
+
return self._stack_groups
|
|
471
|
+
|
|
472
|
+
def _parse_stack_name(self, idx):
|
|
473
|
+
"""
|
|
474
|
+
(series, channel name) parsed from the stack name, (None, None) for
|
|
475
|
+
stacks that are never treated as channels (auxiliary data like
|
|
476
|
+
rescue_info maps, or names that match no convention).
|
|
477
|
+
|
|
478
|
+
series is the acquisition identifier: the number in curly braces for
|
|
479
|
+
default Imspector names ('STAR RED_CONF {0}'), or the path up to the
|
|
480
|
+
last '/' component for hierarchical names if group_hierarchical_stacks
|
|
481
|
+
is enabled.
|
|
482
|
+
"""
|
|
483
|
+
name = self.stack_names[idx]
|
|
484
|
+
|
|
485
|
+
# auxiliary data like ResCue switch-off maps is never treated as a channel
|
|
486
|
+
if 'rescue_info' in name:
|
|
487
|
+
return None, None
|
|
488
|
+
|
|
489
|
+
match = STACK_NAME_PATTERN.match(name)
|
|
490
|
+
if match is not None:
|
|
491
|
+
return int(match.group('series')), match.group('base')
|
|
492
|
+
|
|
493
|
+
if self._group_hierarchical_stacks:
|
|
494
|
+
path, _, channel = name.rpartition('/')
|
|
495
|
+
if path and channel:
|
|
496
|
+
return path, channel
|
|
497
|
+
|
|
498
|
+
return None, None
|
|
499
|
+
|
|
500
|
+
def _compute_stack_groups(self):
|
|
501
|
+
groups = []
|
|
502
|
+
key_to_group = {}
|
|
503
|
+
for idx in range(self.num_stacks):
|
|
504
|
+
name = self.stack_names[idx]
|
|
505
|
+
series, channel = self._parse_stack_name(idx)
|
|
506
|
+
|
|
507
|
+
if channel is not None:
|
|
508
|
+
key = (series,) + self._group_key(idx)
|
|
509
|
+
elif name == '' and self._group_unnamed_stacks:
|
|
510
|
+
# opt-in: group unnamed stacks by geometry alone (series unknown)
|
|
511
|
+
key = (None,) + self._group_key(idx)
|
|
512
|
+
else:
|
|
513
|
+
key = None
|
|
514
|
+
|
|
515
|
+
if key is None:
|
|
516
|
+
groups.append([idx])
|
|
517
|
+
elif key in key_to_group:
|
|
518
|
+
groups[key_to_group[key]].append(idx)
|
|
519
|
+
else:
|
|
520
|
+
key_to_group[key] = len(groups)
|
|
521
|
+
groups.append([idx])
|
|
522
|
+
|
|
523
|
+
return [self._make_stack_group(indices) for indices in groups]
|
|
524
|
+
|
|
525
|
+
def _make_stack_group(self, indices):
|
|
526
|
+
header_i = self.stack_headers[indices[0]]
|
|
527
|
+
shapes_i = self.shapes[indices[0]]
|
|
528
|
+
pixel_sizes_i = self.pixel_sizes[indices[0]]
|
|
529
|
+
|
|
530
|
+
series = None
|
|
531
|
+
channel_names = []
|
|
532
|
+
for idx in indices:
|
|
533
|
+
name = self.stack_names[idx]
|
|
534
|
+
s, channel = self._parse_stack_name(idx)
|
|
535
|
+
if channel is not None:
|
|
536
|
+
series = s
|
|
537
|
+
channel_names.append(channel)
|
|
538
|
+
elif name:
|
|
539
|
+
channel_names.append(name)
|
|
540
|
+
else:
|
|
541
|
+
channel_names.append(f'stack {idx}')
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
dtype = np.dtype(_parse_dtype(header_i.dtype)[0])
|
|
545
|
+
except ValueError:
|
|
546
|
+
dtype = None
|
|
547
|
+
|
|
548
|
+
return StackGroup(series, channel_names, indices, tuple(shapes_i.sizes), dtype,
|
|
549
|
+
list(pixel_sizes_i.sizes), list(shapes_i.dimension_names))
|
|
550
|
+
|
|
551
|
+
def read_stack_group(self, group_idx, axis=0):
|
|
552
|
+
"""
|
|
553
|
+
read all stacks of a group, stacked along a new axis (default: first,
|
|
554
|
+
i.e. (C, Z, Y, X)). single-member groups get an axis of length one.
|
|
555
|
+
"""
|
|
556
|
+
group = self.stack_groups[group_idx]
|
|
557
|
+
return np.stack([self.read_stack(i) for i in group.indices], axis=axis)
|
|
558
|
+
|
|
319
559
|
def get_imspector_xml_metadata(self, stack_idx):
|
|
320
560
|
"""
|
|
321
561
|
get XML metadata string of stack with index stack_idx
|
|
@@ -338,8 +578,9 @@ class OBFFile:
|
|
|
338
578
|
get OME-XML metadata string
|
|
339
579
|
"""
|
|
340
580
|
|
|
341
|
-
# check that we have metadata (will be available through main header
|
|
342
|
-
|
|
581
|
+
# check that we have metadata (will be available through main header,
|
|
582
|
+
# but only from file format version 2 on)
|
|
583
|
+
if self.main_header.metadata and 'ome_xml' in self.main_header.metadata:
|
|
343
584
|
ome_xml_metadata = self.main_header.metadata['ome_xml']
|
|
344
585
|
else:
|
|
345
586
|
ome_xml_metadata = ''
|