msr-reader 0.2.1.dev0__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.
Files changed (34) hide show
  1. msr_reader-0.3.0/.gitignore +6 -0
  2. {msr_reader-0.2.1.dev0 → msr_reader-0.3.0}/PKG-INFO +19 -2
  3. {msr_reader-0.2.1.dev0 → msr_reader-0.3.0}/README.md +17 -0
  4. {msr_reader-0.2.1.dev0 → msr_reader-0.3.0}/pyproject.toml +1 -1
  5. msr_reader-0.3.0/src/msr_reader/__init__.py +2 -0
  6. {msr_reader-0.2.1.dev0 → msr_reader-0.3.0}/src/msr_reader/obffile.py +259 -18
  7. msr_reader-0.3.0/test.ipynb +140 -0
  8. msr_reader-0.3.0/tests/__init__.py +6 -0
  9. msr_reader-0.3.0/tests/data/20261019tests_4d.msr +0 -0
  10. msr_reader-0.3.0/tests/data/20261019tests_incomplete_measurement.msr +0 -0
  11. msr_reader-0.3.0/tests/data/20261019tests_lightbox.obf +0 -0
  12. msr_reader-0.3.0/tests/data/20261019tests_overwritten_measurement1.msr +0 -0
  13. msr_reader-0.3.0/tests/data/2889b245416cd9783c627f413caa45c9_overview_1.msr +0 -0
  14. msr_reader-0.3.0/tests/data/bad44489_field_0.msr +0 -0
  15. msr_reader-0.3.0/tests/data/bad44489_field_0_sted_0.msr +0 -0
  16. msr_reader-0.3.0/tests/data/conf01.msr +0 -0
  17. msr_reader-0.3.0/tests/data/groupa_cell01.msr +0 -0
  18. msr_reader-0.3.0/tests/data/mesc_yoyo2_img_001.msr +0 -0
  19. msr_reader-0.3.0/tests/synthetic_obf.py +204 -0
  20. msr_reader-0.3.0/tests/test_real_files.py +221 -0
  21. msr_reader-0.3.0/tests/test_synthetic_format.py +236 -0
  22. msr_reader-0.3.0/tests/test_synthetic_grouping.py +157 -0
  23. msr_reader-0.2.1.dev0/.gitignore +0 -2
  24. msr_reader-0.2.1.dev0/.idea/.gitignore +0 -8
  25. msr_reader-0.2.1.dev0/.idea/inspectionProfiles/profiles_settings.xml +0 -6
  26. msr_reader-0.2.1.dev0/.idea/misc.xml +0 -4
  27. msr_reader-0.2.1.dev0/.idea/modules.xml +0 -8
  28. msr_reader-0.2.1.dev0/.idea/msr-reader.iml +0 -12
  29. msr_reader-0.2.1.dev0/.idea/vcs.xml +0 -6
  30. msr_reader-0.2.1.dev0/.idea/workspace.xml +0 -62
  31. msr_reader-0.2.1.dev0/src/msr_reader/__init__.py +0 -2
  32. msr_reader-0.2.1.dev0/test.ipynb +0 -114
  33. {msr_reader-0.2.1.dev0 → msr_reader-0.3.0}/LICENSE +0 -0
  34. {msr_reader-0.2.1.dev0 → msr_reader-0.3.0}/src/msr_reader/xml_metadata.py +0 -0
@@ -0,0 +1,6 @@
1
+ *.egg-info
2
+ dist
3
+ .idea
4
+ *__pycache__
5
+ msr-test-files
6
+ .DS_Store
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: msr-reader
3
- Version: 0.2.1.dev0
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)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "msr-reader"
7
- version = "0.2.1-dev"
7
+ version = "0.3.0"
8
8
  authors = [
9
9
  { name="David Hörl", email="hoerldavid@gmail.com" },
10
10
  ]
@@ -0,0 +1,2 @@
1
+ from .obffile import OBFFile, OBFHeader, OBFStackHeader, OBFStackFooter, OBFSIFraction, OBFSIUnit, StackGroup, StackSizes
2
+ from .xml_metadata import imspector_xml_to_dict
@@ -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 = namedtuple('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
- OBFSIFraction = namedtuple('OBFSIFraction', ['numerator', 'denominator'])
19
- OBFSIUnit = namedtuple('OBFSIUnit', ['meters', 'kilograms', 'seconds', 'amperes', 'kelvin', 'moles',
20
- 'candela', 'radian', 'steradian', 'scale_factor'])
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
- OBFStackFooter = namedtuple('OBFStackFooter', ['size', 'has_col_positions', 'has_col_labels', 'metadata', 'si_value', 'si_dimensions',
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, _, data_len, next_stack_pos = struct.unpack(stack_header_fmt2, fd.read(struct.calcsize(stack_header_fmt2)))
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 col_positions, col_labels, chunked storage
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
- if 'ome_xml' in self.main_header.metadata:
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 = ''