large-image-source-zarr 1.29.3.dev39__py3-none-any.whl → 1.33.6a188__py3-none-any.whl

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.
@@ -1,3 +1,6 @@
1
+ import contextlib
2
+ import importlib.metadata
3
+ import json
1
4
  import math
2
5
  import multiprocessing
3
6
  import os
@@ -6,8 +9,6 @@ import tempfile
6
9
  import threading
7
10
  import uuid
8
11
  import warnings
9
- from importlib.metadata import PackageNotFoundError
10
- from importlib.metadata import version as _importlib_version
11
12
  from pathlib import Path
12
13
 
13
14
  import numpy as np
@@ -21,15 +22,10 @@ from large_image.tilesource import FileTileSource
21
22
  from large_image.tilesource.resample import ResampleMethod, downsampleTileHalfRes
22
23
  from large_image.tilesource.utilities import _imageToNumpy, nearPowerOfTwo
23
24
 
24
- try:
25
- __version__ = _importlib_version(__name__)
26
- except PackageNotFoundError:
27
- # package is not installed
28
- pass
25
+ with contextlib.suppress(importlib.metadata.PackageNotFoundError):
26
+ __version__ = importlib.metadata.version(__name__)
29
27
 
30
28
 
31
- warnings.filterwarnings('ignore', category=FutureWarning, module='zarr')
32
-
33
29
  zarr = None
34
30
 
35
31
 
@@ -43,6 +39,8 @@ def _lazyImport():
43
39
  if zarr is None:
44
40
  try:
45
41
  import zarr
42
+
43
+ warnings.filterwarnings('ignore', category=FutureWarning, module='.*zarr.*')
46
44
  except ImportError:
47
45
  msg = 'zarr module not found.'
48
46
  raise TileSourceError(msg)
@@ -92,12 +90,19 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
92
90
  self._initNew(path, **kwargs)
93
91
  else:
94
92
  self._initOpen(**kwargs)
93
+ internal = self.getInternalMetadata().get('zarr', {}).get('base', {})
94
+ multiscale = internal.get('multiscales', [None])[0]
95
+ if multiscale is not None:
96
+ self._imageDescription = multiscale.get('metadata', {}).get('description')
95
97
  self._tileLock = threading.RLock()
96
98
 
97
99
  def _initOpen(self, **kwargs):
98
100
  self._largeImagePath = str(self._getLargeImagePath())
99
101
  self._zarr = None
100
102
  self._editable = False
103
+ self._frameValues = None
104
+ self._frameAxes = None
105
+ self._frameUnits = None
101
106
  if not os.path.isfile(self._largeImagePath) and '//:' not in self._largeImagePath:
102
107
  raise TileSourceFileNotFoundError(self._largeImagePath) from None
103
108
  try:
@@ -107,10 +112,8 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
107
112
  self._zarr = zarr.open(self._largeImagePath, mode='r')
108
113
  except Exception:
109
114
  if os.path.basename(self._largeImagePath) in {'.zgroup', '.zattrs', '.zarray'}:
110
- try:
115
+ with contextlib.suppress(Exception):
111
116
  self._zarr = zarr.open(os.path.dirname(self._largeImagePath), mode='r')
112
- except Exception:
113
- pass
114
117
  if self._zarr is None:
115
118
  if not os.path.isfile(self._largeImagePath):
116
119
  raise TileSourceFileNotFoundError(self._largeImagePath) from None
@@ -145,6 +148,8 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
145
148
  self._threadLock = threading.RLock()
146
149
  self._processLock = multiprocessing.Lock()
147
150
  self._framecount = 0
151
+ self._minWidth = None
152
+ self._minHeight = None
148
153
  self._mm_x = 0
149
154
  self._mm_y = 0
150
155
  self._channelNames = []
@@ -152,13 +157,19 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
152
157
  self._imageDescription = None
153
158
  self._levels = []
154
159
  self._associatedImages = {}
160
+ self._frameValues = None
161
+ self._frameAxes = None
162
+ self._frameUnits = None
163
+ self._projection = None
164
+ self._gcps = None
165
+ if not self._created:
166
+ with contextlib.suppress(Exception):
167
+ self._validateZarr()
155
168
 
156
169
  def __del__(self):
157
170
  if not hasattr(self, '_derivedSource'):
158
- try:
171
+ with contextlib.suppress(Exception):
159
172
  self._zarr.close()
160
- except Exception:
161
- pass
162
173
  try:
163
174
  if self._created:
164
175
  shutil.rmtree(self._tempdir)
@@ -218,10 +229,10 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
218
229
  tuple that is used to find the maximum size array, preferring ome
219
230
  arrays, then total pixels, then channels. 'is_ome' is a boolean.
220
231
  'series' is a list of the found groups and arrays that match the
221
- best criteria. 'axes' and 'channels' are from the best array.
222
- 'associated' is a list of all groups and arrays that might be
223
- associated images. These have to be culled for the actual groups
224
- used in the series.
232
+ best criteria. 'axes', 'axes_values', 'axes_units', and 'channels'
233
+ are from the best array. 'associated' is a list of all groups and
234
+ arrays that might be associated images. These have to be culled
235
+ for the actual groups used in the series.
225
236
  """
226
237
  attrs = group.attrs.asdict() if group is not None else {}
227
238
  min_version = packaging.version.Version('0.4')
@@ -233,9 +244,19 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
233
244
  all(packaging.version.Version(m['version']) >= min_version
234
245
  for m in attrs['multiscales'] if 'version' in m))
235
246
  channels = None
247
+ axes_values = None
248
+ axes_units = None
236
249
  if is_ome:
237
250
  axes = {axis['name']: idx for idx, axis in enumerate(
238
251
  attrs['multiscales'][0]['axes'])}
252
+ axes_values = {
253
+ axis['name']: axis.get('values')
254
+ for axis in attrs['multiscales'][0]['axes']
255
+ }
256
+ axes_units = {
257
+ axis['name']: axis.get('unit')
258
+ for axis in attrs['multiscales'][0]['axes']
259
+ }
239
260
  if isinstance(attrs['omero'].get('channels'), list):
240
261
  channels = [channel['label'] for channel in attrs['omero']['channels']]
241
262
  if all(channel.startswith('Channel ') for channel in channels):
@@ -254,11 +275,13 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
254
275
  results['series'] = [(group, arr)]
255
276
  results['is_ome'] = is_ome
256
277
  results['axes'] = axes
278
+ results['axes_values'] = axes_values
279
+ results['axes_units'] = axes_units
257
280
  results['channels'] = channels
258
281
  elif check == results['best']:
259
282
  results['series'].append((group, arr))
260
283
  if not any(group is g for g, _ in results['associated']):
261
- axes = {k: v for k, v in axes.items() if arr.shape[axes[k]] > 1}
284
+ axes = {k: v for k, v in axes.items() if arr.shape[axes[k]] > 1 or k in {'x', 'y'}}
262
285
  if (len(axes) <= 3 and
263
286
  self._minAssociatedImageSize <= arr.shape[axes['x']] <=
264
287
  self._maxAssociatedImageSize and
@@ -333,6 +356,46 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
333
356
  except Exception:
334
357
  pass
335
358
 
359
+ def _readFrameValues(self, found, baseArray):
360
+ """
361
+ Populate frame values and frame units from image metadata.
362
+ """
363
+ axes_values = found.get('axes_values')
364
+ axes_units = found.get('axes_units')
365
+ if isinstance(axes_values, dict):
366
+ self._frameAxes = [
367
+ a for a, i in sorted(self._axes.items(), key=lambda x: x[1])
368
+ if axes_values.get(a) is not None
369
+ ]
370
+ self._frameUnits = {k: axes_units.get(k) for k in self.frameAxes if k in axes_units}
371
+ self._frameValues = None
372
+ frame_values_shape = [baseArray.shape[self._axes[a]] for a in self.frameAxes]
373
+ frame_values_shape.append(len(frame_values_shape))
374
+ frame_values = np.zeros(frame_values_shape, dtype=object)
375
+ all_frame_specs = self.getMetadata().get('frames')
376
+ for axis, values in axes_values.items():
377
+ if axis in self.frameAxes:
378
+ slicing = [slice(None) for i in range(len(frame_values_shape))]
379
+ axis_index = self.frameAxes.index(axis)
380
+ slicing[-1] = axis_index
381
+ if len(values) == frame_values_shape[axis_index]:
382
+ # uniform values have same length as axis
383
+ for i, value in enumerate(values):
384
+ slicing[axis_index] = i
385
+ frame_values[tuple(slicing)] = value
386
+ elif len(values) == self._framecount:
387
+ # non-uniform values have a value for every frame
388
+ for i, value in enumerate(values):
389
+ if all_frame_specs and len(all_frame_specs) > i:
390
+ for k, j in all_frame_specs[i].items():
391
+ if 'Index' in k:
392
+ name = k.replace('Index', '').lower()
393
+ if name:
394
+ slicing[self._frameAxes.index(name)] = j
395
+ frame_values[tuple(slicing)] = value
396
+ if frame_values.size > 0:
397
+ self._frameValues = frame_values
398
+
336
399
  def _validateZarr(self):
337
400
  """
338
401
  Validate that we can read tiles from the zarr parent group in
@@ -347,7 +410,11 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
347
410
  self._series = found['series']
348
411
  baseGroup, baseArray = self._series[0]
349
412
  self._is_ome = found['is_ome']
350
- self._axes = {k.lower(): v for k, v in found['axes'].items() if baseArray.shape[v] > 1}
413
+ self._axes = {
414
+ k.lower(): v
415
+ for k, v in found['axes'].items()
416
+ if baseArray.shape[v] > 1 or k in found.get('axes_values', {})
417
+ }
351
418
  if len(self._series) > 1 and 'xy' in self._axes:
352
419
  msg = 'Conflicting xy axis data.'
353
420
  raise TileSourceError(msg)
@@ -373,7 +440,7 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
373
440
  # self.tileWidth = self.tileHeight = baseArray.chunks[self._axes['x']]
374
441
  self.levels = int(max(1, math.ceil(math.log(max(
375
442
  self.sizeX / self.tileWidth, self.sizeY / self.tileHeight)) / math.log(2)) + 1))
376
- self._dtype = baseArray.dtype
443
+ self._dtype = np.dtype(baseArray.dtype)
377
444
  self._bandCount = 1
378
445
  if ('c' in self._axes and 's' not in self._axes and not self._channels and
379
446
  baseArray.shape[self._axes.get('c')] in {1, 3, 4}):
@@ -386,8 +453,16 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
386
453
  stride = 1
387
454
  self._strides = {}
388
455
  self._axisCounts = {}
389
- for _, k in sorted((-'tzc'.index(k) if k in 'tzc' else 1, k)
390
- for k in self._axes if k not in 'xys'):
456
+ # If we aren't in editable mode, prefer the channel axis to have a
457
+ # stride of 1, then the z axis, then the t axis, then sorted by the
458
+ # axis name.
459
+ axisOrder = ((-'tzc'.index(k) if k in 'tzc' else 1, k)
460
+ for k in self._axes if k not in {'x', 'y', 's'})
461
+ # In editable mode, prefer the order that the axes are being written.
462
+ if self._editable:
463
+ axisOrder = ((-self._axes.get(k, 'tzc'.index(k) if k in 'tzc' else -1), k)
464
+ for k in self._axes if k not in {'x', 'y', 's'})
465
+ for _, k in sorted(axisOrder):
391
466
  self._strides[k] = stride
392
467
  self._axisCounts[k] = baseArray.shape[self._axes[k]]
393
468
  stride *= baseArray.shape[self._axes[k]]
@@ -396,6 +471,7 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
396
471
  self._axisCounts['xy'] = len(self._series)
397
472
  stride *= len(self._series)
398
473
  self._framecount = stride
474
+ self._readFrameValues(found, baseArray)
399
475
 
400
476
  def _nonemptyLevelsList(self, frame=0):
401
477
  """
@@ -447,6 +523,16 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
447
523
  for axis in self._strides:
448
524
  frame['Index' + axis.upper()] = (
449
525
  idx // self._strides[axis]) % self._axisCounts[axis]
526
+ if self.frameValues is not None and self.frameAxes is not None:
527
+ current_frame_slice = tuple(
528
+ frame['Index' + axis.upper()] for axis in self.frameAxes
529
+ )
530
+ current_frame_values = self.frameValues[current_frame_slice]
531
+ for i, axis in enumerate(self.frameAxes):
532
+ value = current_frame_values[i]
533
+ # ensure that values are returned as native python types
534
+ native_value = getattr(value, 'tolist', lambda v=value: v)()
535
+ frame['Value' + axis.upper()] = native_value
450
536
  frames.append(frame)
451
537
  self._addMetadataFrameInformation(result, getattr(self, '_channels', None))
452
538
  return result
@@ -465,10 +551,8 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
465
551
  result['zarr'] = {
466
552
  'base': self._zarr.attrs.asdict(),
467
553
  }
468
- try:
554
+ with contextlib.suppress(Exception):
469
555
  result['zarr']['main'] = self._series[0][0].attrs.asdict()
470
- except Exception:
471
- pass
472
556
  return result
473
557
 
474
558
  def getAssociatedImagesList(self):
@@ -487,7 +571,7 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
487
571
  :return: the image in PIL format or None.
488
572
  """
489
573
  if imageKey not in self._associatedImages:
490
- return
574
+ return None
491
575
  group, arr = self._associatedImages[imageKey]
492
576
  axes = self._getGeneralAxes(arr)
493
577
  trans = [idx for idx in range(len(arr.shape))
@@ -520,7 +604,7 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
520
604
  y1 //= scale
521
605
  step //= scale
522
606
  if step > 2 ** self._maxSkippedLevels:
523
- tile = self._getTileFromEmptyLevel(x, y, z, **kwargs)
607
+ tile, _format = self._getTileFromEmptyLevel(x, y, z, **kwargs)
524
608
  tile = large_image.tilesource.base._imageToNumpy(tile)[0]
525
609
  else:
526
610
  idx = [slice(None) for _ in arr.shape]
@@ -549,7 +633,7 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
549
633
 
550
634
  def _validateNewTile(self, tile, mask, placement, axes):
551
635
  if not isinstance(tile, np.ndarray) or axes is None:
552
- axes = 'yxs'
636
+ axes = self._axes if hasattr(self, '_axes') else 'yxs'
553
637
  tile, mode = _imageToNumpy(tile)
554
638
  elif not isinstance(axes, str) and not isinstance(axes, list):
555
639
  err = 'Invalid type for axes. Must be str or list[str].'
@@ -557,6 +641,7 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
557
641
  axes = [x.lower() for x in axes]
558
642
  if axes[-1] != 's':
559
643
  axes.append('s')
644
+ tile = tile[..., np.newaxis]
560
645
  if mask is not None and len(axes) - 1 == len(mask.shape):
561
646
  mask = mask[:, :, np.newaxis]
562
647
  if 'x' not in axes or 'y' not in axes:
@@ -572,6 +657,52 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
572
657
 
573
658
  return tile, mask, placement, axes
574
659
 
660
+ def _updateFrameValues(self, frame_values, placement, axes, new_axes, new_dims):
661
+ self._frameAxes = [
662
+ a for a in axes
663
+ if a in frame_values or
664
+ (self.frameAxes is not None and a in self.frameAxes)
665
+ ]
666
+ frames_shape = [new_dims[a] for a in self.frameAxes]
667
+ frames_shape.append(len(frames_shape))
668
+ if self.frameValues is None:
669
+ self._frameValues = np.empty(frames_shape, dtype=object)
670
+ elif self.frameValues.shape != frames_shape:
671
+ if len(new_axes):
672
+ for i in new_axes.values():
673
+ self._frameValues = np.expand_dims(self._frameValues, axis=i)
674
+ frame_padding = [
675
+ (0, s - self.frameValues.shape[i])
676
+ for i, s in enumerate(frames_shape)
677
+ ]
678
+ frame_padding[-1] = (0, 0)
679
+ self._frameValues = np.pad(self._frameValues, frame_padding)
680
+ for i in new_axes.values():
681
+ self._frameValues = np.insert(
682
+ self._frameValues, i, 0, axis=len(frames_shape) - 1,
683
+ )
684
+ current_frame_slice = tuple(placement.get(a) for a in self.frameAxes)
685
+ for i, k in enumerate(self.frameAxes):
686
+ self.frameValues[(*current_frame_slice, i)] = frame_values.get(k)
687
+
688
+ def _resizeImage(self, arr, new_shape, new_axes, chunking):
689
+ if new_shape != arr.shape:
690
+ if len(new_axes):
691
+ for i in new_axes.values():
692
+ arr = np.expand_dims(arr, axis=i)
693
+ arr = np.pad(
694
+ arr,
695
+ [(0, s - arr.shape[i]) for i, s in enumerate(new_shape)],
696
+ )
697
+ new_arr = zarr.zeros(
698
+ new_shape, chunks=chunking, dtype=arr.dtype,
699
+ write_empty_chunks=False)
700
+ new_arr[:] = arr[:]
701
+ arr = new_arr
702
+ else:
703
+ arr.resize(*new_shape)
704
+ return arr
705
+
575
706
  def addTile(self, tile, x=0, y=0, mask=None, axes=None, **kwargs):
576
707
  """
577
708
  Add a numpy or image tile to the image, expanding the image as needed
@@ -592,18 +723,29 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
592
723
  ``level`` is a reserved word and not permitted for an axis name.
593
724
  """
594
725
  self._checkEditable()
726
+ with contextlib.suppress(TileSourceError):
727
+ # read any info written by other processes
728
+ self._validateZarr()
729
+ updateMetadata = False
595
730
  store_path = str(kwargs.pop('level', 0))
596
731
  placement = {
597
732
  'x': x,
598
733
  'y': y,
599
- **kwargs,
734
+ **{k: v for k, v in kwargs.items() if not k.endswith('_value')},
735
+ }
736
+ frame_values = {
737
+ k.replace('_value', ''): v for k, v in kwargs.items()
738
+ if k not in placement
600
739
  }
601
740
  tile, mask, placement, axes = self._validateNewTile(tile, mask, placement, axes)
602
741
 
603
742
  with self._threadLock and self._processLock:
743
+ old_axes = self._axes if hasattr(self, '_axes') else {}
604
744
  self._axes = {k: i for i, k in enumerate(axes)}
745
+ new_axes = {k: i for k, i in self._axes.items() if k not in old_axes}
605
746
  new_dims = {
606
747
  a: max(
748
+ self._axisCounts.get(a, 0) if hasattr(self, '_axisCounts') else 0,
607
749
  self._dims.get(store_path, {}).get(a, 0),
608
750
  placement.get(a, 0) + tile.shape[i],
609
751
  )
@@ -615,6 +757,11 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
615
757
  for i, a in enumerate(axes)
616
758
  ])
617
759
 
760
+ if len(frame_values.keys()) > 0:
761
+ # update self.frameValues
762
+ updateMetadata = True
763
+ self._updateFrameValues(frame_values, placement, axes, new_axes, new_dims)
764
+
618
765
  current_arrays = dict(self._zarr.arrays())
619
766
  if store_path == '0':
620
767
  # if writing to base data, invalidate generated levels
@@ -623,22 +770,36 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
623
770
  self._zarr_store.rmdir(path)
624
771
  chunking = None
625
772
  if store_path not in current_arrays:
626
- arr = np.empty(tuple(new_dims.values()), dtype=tile.dtype)
627
773
  chunking = tuple([
628
774
  self._tileSize if a in ['x', 'y'] else
629
775
  new_dims.get('s') if a == 's' else 1
630
776
  for a in axes
631
777
  ])
778
+ # If we have to create the array, do so with the desired store
779
+ # and chunking so we don't have to immediately rechunk it
780
+ arr = zarr.zeros(
781
+ tuple(new_dims.values()),
782
+ dtype=tile.dtype,
783
+ chunks=chunking,
784
+ store=self._zarr_store,
785
+ path=store_path,
786
+ write_empty_chunks=False,
787
+ )
788
+ chunking = None
632
789
  else:
633
790
  arr = current_arrays[store_path]
634
- arr.resize(*tuple(new_dims.values()))
635
- if arr.chunks[-1] != new_dims.get('s'):
636
- # rechunk if length of samples axis changes
791
+ new_shape = tuple(
792
+ max(v, arr.shape[old_axes[k]] if k in old_axes else 0)
793
+ for k, v in new_dims.items()
794
+ )
795
+ if arr.chunks[-1] != new_dims.get('s') or len(new_axes):
796
+ # rechunk if length of samples axis changed or any new axis added
637
797
  chunking = tuple([
638
798
  self._tileSize if a in ['x', 'y'] else
639
799
  new_dims.get('s') if a == 's' else 1
640
800
  for a in axes
641
801
  ])
802
+ arr = self._resizeImage(arr, new_shape, new_axes, chunking)
642
803
 
643
804
  if mask is not None:
644
805
  arr[placement_slices] = np.where(mask, tile, arr[placement_slices])
@@ -655,11 +816,11 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
655
816
 
656
817
  # If base data changed, update large_image attributes
657
818
  if store_path == '0':
658
- self._dtype = tile.dtype
819
+ self._dtype = np.dtype(tile.dtype)
659
820
  self._bandCount = new_dims.get(axes[-1]) # last axis is assumed to be bands
660
821
  self.sizeX = new_dims.get('x')
661
822
  self.sizeY = new_dims.get('y')
662
- self._framecount = np.prod([
823
+ self._framecount = math.prod([
663
824
  length
664
825
  for axis, length in new_dims.items()
665
826
  if axis in axes[:-3]
@@ -668,6 +829,9 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
668
829
  self._levels = None
669
830
  self.levels = int(max(1, math.ceil(math.log(max(
670
831
  self.sizeX / self.tileWidth, self.sizeY / self.tileHeight)) / math.log(2)) + 1))
832
+ updateMetadata = True
833
+ if updateMetadata:
834
+ self._writeInternalMetadata()
671
835
 
672
836
  def addAssociatedImage(self, image, imageKey=None):
673
837
  """
@@ -690,42 +854,79 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
690
854
  )
691
855
  self._associatedImages[imageKey] = (group, arr)
692
856
 
857
+ def _getAxisInternalMetadata(self, axis_name):
858
+ """
859
+ Get the metadata structure describing an axis in the image.
860
+ This will be written to the image metadata.
861
+
862
+ :param axis_name: a string corresponding to an axis in the image
863
+ :returns: a dictionary describing the target axis
864
+ """
865
+ axis_metadata = {'name': axis_name}
866
+ if axis_name in ['x', 'y']:
867
+ axis_metadata['type'] = 'space'
868
+ axis_metadata['unit'] = 'millimeter'
869
+ elif axis_name in ['s', 'c']:
870
+ axis_metadata['type'] = 'channel'
871
+ if self.frameAxes is not None:
872
+ axis_index = self.frameAxes.index(axis_name) if axis_name in self.frameAxes else None
873
+ if axis_index is not None and self.frameValues is not None:
874
+ all_frame_values = self.frameValues[..., axis_index]
875
+ split = np.split(
876
+ all_frame_values,
877
+ all_frame_values.shape[axis_index],
878
+ axis=axis_index,
879
+ )
880
+ uniform = all(len(np.unique(
881
+ # convert all values to strings for mixed type comparison with np.unique
882
+ (a[np.not_equal(a, None)]).astype(str),
883
+ )) == 1 for a in split)
884
+ if uniform:
885
+ values = [a.flat[0] for a in split]
886
+ else:
887
+ values = all_frame_values.flatten().tolist()
888
+ axis_metadata['values'] = values
889
+ unit = self.frameUnits.get(axis_name) if self.frameUnits is not None else None
890
+ if unit is not None:
891
+ axis_metadata['unit'] = unit
892
+ return axis_metadata
893
+
693
894
  def _writeInternalMetadata(self):
895
+ """
896
+ Write metadata to Zarr attributes.
897
+ Metadata structure adheres to OME schema from https://ngff.openmicroscopy.org/latest/
898
+ """
694
899
  self._checkEditable()
695
900
  with self._threadLock and self._processLock:
696
901
  name = str(self._tempdir.name).split('/')[-1]
697
902
  arrays = dict(self._zarr.arrays())
698
- channel_axis = self._axes.get('c', self._axes.get('s'))
699
903
  datasets = []
700
904
  axes = []
701
905
  channels = []
702
- rdefs = {'model': 'color' if len(self._channelColors) else 'greyscale'}
703
- sorted_axes = [a[0] for a in sorted(self._axes.items(), key=lambda item: item[1])]
704
- for arr_name in arrays:
705
- level = int(arr_name)
706
- scale = [1.0 for a in sorted_axes]
707
- scale[self._axes.get('x')] = (self._mm_x or 0) * (2 ** level)
708
- scale[self._axes.get('y')] = (self._mm_y or 0) * (2 ** level)
709
- dataset_metadata = {
710
- 'path': arr_name,
711
- 'coordinateTransformations': [{
712
- 'type': 'scale',
713
- 'scale': scale,
714
- }],
715
- }
716
- datasets.append(dataset_metadata)
717
- for a in sorted_axes:
718
- axis_metadata = {'name': a}
719
- if a in ['x', 'y']:
720
- axis_metadata['type'] = 'space'
721
- axis_metadata['unit'] = 'millimeter'
722
- elif a in ['s', 'c']:
723
- axis_metadata['type'] = 'channel'
724
- elif a == 't':
725
- rdefs['defaultT'] = 0
726
- elif a == 'z':
727
- rdefs['defaultZ'] = 0
728
- axes.append(axis_metadata)
906
+ channel_axis = rdefs = None
907
+ if hasattr(self, '_axes'):
908
+ channel_axis = self._axes.get('c', self._axes.get('s'))
909
+ rdefs = {'model': 'color' if len(self._channelColors) else 'greyscale'}
910
+ sorted_axes = [a[0] for a in sorted(self._axes.items(), key=lambda item: item[1])]
911
+ for arr_name in arrays:
912
+ level = int(arr_name)
913
+ scale = [1.0 for a in sorted_axes]
914
+ scale[self._axes.get('x')] = (self._mm_x or 0) * (2 ** level)
915
+ scale[self._axes.get('y')] = (self._mm_y or 0) * (2 ** level)
916
+ dataset_metadata = {
917
+ 'path': arr_name,
918
+ 'coordinateTransformations': [{
919
+ 'type': 'scale',
920
+ 'scale': scale,
921
+ }],
922
+ }
923
+ datasets.append(dataset_metadata)
924
+ for a in sorted_axes:
925
+ if a == 't':
926
+ rdefs['defaultT'] = 0
927
+ elif a == 'z':
928
+ rdefs['defaultZ'] = 0
929
+ axes.append(self._getAxisInternalMetadata(a))
729
930
  if channel_axis is not None and len(arrays) > 0:
730
931
  base_array = list(arrays.values())[0]
731
932
  base_shape = base_array.shape
@@ -738,21 +939,21 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
738
939
  'inverted': False,
739
940
  'label': f'Band {c + 1}',
740
941
  }
741
- slicing = tuple(
742
- slice(None)
743
- if k != ('c' if 'c' in self._axes else 's')
744
- else c
745
- for k, v in self._axes.items()
746
- )
747
- channel_data = base_array[slicing]
748
- channel_min = np.min(channel_data)
749
- channel_max = np.max(channel_data)
750
- channel_metadata['window'] = {
751
- 'end': channel_max,
752
- 'max': channel_max,
753
- 'min': channel_min,
754
- 'start': channel_min,
755
- }
942
+ # slicing = tuple(
943
+ # slice(None)
944
+ # if k != ('c' if 'c' in self._axes else 's')
945
+ # else c
946
+ # for k, v in self._axes.items()
947
+ # )
948
+ # channel_data = base_array[slicing]
949
+ # channel_min = np.min(channel_data)
950
+ # channel_max = np.max(channel_data)
951
+ # channel_metadata['window'] = {
952
+ # 'end': channel_max,
953
+ # 'max': channel_max,
954
+ # 'min': channel_min,
955
+ # 'start': channel_min,
956
+ # }
756
957
  if len(self._channelNames) > c:
757
958
  channel_metadata['label'] = self._channelNames[c]
758
959
  if len(self._channelColors) > c:
@@ -807,6 +1008,32 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
807
1008
  raise TileSourceError(msg)
808
1009
  self._crop = (x, y, w, h)
809
1010
 
1011
+ @property
1012
+ def minWidth(self):
1013
+ return self._minWidth
1014
+
1015
+ @minWidth.setter
1016
+ def minWidth(self, value):
1017
+ self._checkEditable()
1018
+ value = int(value) if value is not None else None
1019
+ if value is not None and value <= 0:
1020
+ msg = 'minWidth must be positive or None'
1021
+ raise TileSourceError(msg)
1022
+ self._minWidth = value
1023
+
1024
+ @property
1025
+ def minHeight(self):
1026
+ return self._minHeight
1027
+
1028
+ @minHeight.setter
1029
+ def minHeight(self, value):
1030
+ self._checkEditable()
1031
+ value = int(value) if value is not None else None
1032
+ if value is not None and value <= 0:
1033
+ msg = 'minHeight must be positive or None'
1034
+ raise TileSourceError(msg)
1035
+ self._minHeight = value
1036
+
810
1037
  @property
811
1038
  def mm_x(self):
812
1039
  return self._mm_x
@@ -835,21 +1062,65 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
835
1062
 
836
1063
  @property
837
1064
  def imageDescription(self):
1065
+ if not hasattr(self, '_imageDescription'):
1066
+ return None
1067
+ if isinstance(self._imageDescription, dict):
1068
+ return self._imageDescription.get('description')
838
1069
  return self._imageDescription
839
1070
 
840
1071
  @imageDescription.setter
841
1072
  def imageDescription(self, description):
842
1073
  self._checkEditable()
843
- self._imageDescription = description
1074
+ try:
1075
+ json.dumps(description)
1076
+ except TypeError:
1077
+ msg = 'Description must be JSON serializable'
1078
+ raise TileSourceError(msg)
1079
+ if (
1080
+ hasattr(self, '_imageDescription') and
1081
+ isinstance(self._imageDescription, dict)
1082
+ ):
1083
+ self._imageDescription['description'] = description
1084
+ else:
1085
+ self._imageDescription = description
1086
+
1087
+ @property
1088
+ def additionalMetadata(self):
1089
+ if not hasattr(self, '_imageDescription'):
1090
+ return None
1091
+ if isinstance(self._imageDescription, dict):
1092
+ return self._imageDescription.get('additionalMetadata')
1093
+ return None
1094
+
1095
+ @additionalMetadata.setter
1096
+ def additionalMetadata(self, data):
1097
+ self._checkEditable()
1098
+ try:
1099
+ json.dumps(data)
1100
+ except TypeError:
1101
+ msg = 'Metadata must be JSON serializable'
1102
+ raise TileSourceError(msg)
1103
+ if (
1104
+ hasattr(self, '_imageDescription') and
1105
+ isinstance(self._imageDescription, dict)
1106
+ ):
1107
+ self._imageDescription['additionalMetadata'] = data
1108
+ else:
1109
+ self.imageDescription = dict(
1110
+ description=self._imageDescription,
1111
+ additionalMetadata=data,
1112
+ )
844
1113
 
845
1114
  @property
846
1115
  def channelNames(self):
847
- return self._channelNames
1116
+ if hasattr(self, '_channelNames'):
1117
+ return self._channelNames or None
1118
+ return super().channelNames
848
1119
 
849
1120
  @channelNames.setter
850
1121
  def channelNames(self, names):
851
1122
  self._checkEditable()
852
- self._channelNames = names
1123
+ self._channelNames = names or []
853
1124
 
854
1125
  @property
855
1126
  def channelColors(self):
@@ -860,6 +1131,119 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
860
1131
  self._checkEditable()
861
1132
  self._channelColors = colors
862
1133
 
1134
+ @property
1135
+ def frameAxes(self):
1136
+ return self._frameAxes
1137
+
1138
+ @frameAxes.setter
1139
+ def frameAxes(self, axes):
1140
+ self._checkEditable()
1141
+ self._frameAxes = axes
1142
+ self._writeInternalMetadata()
1143
+
1144
+ @property
1145
+ def frameUnits(self):
1146
+ return self._frameUnits
1147
+
1148
+ @frameUnits.setter
1149
+ def frameUnits(self, units):
1150
+ self._checkEditable()
1151
+ if self.frameAxes is None:
1152
+ err = 'frameAxes must be set first with a list of frame axis names.'
1153
+ raise ValueError(err)
1154
+ if not isinstance(units, dict) or not all(
1155
+ k in self.frameAxes for k in units
1156
+ ):
1157
+ err = 'frameUnits must be a dictionary with keys that exist in frameAxes.'
1158
+ raise ValueError(err)
1159
+ self._frameUnits = units
1160
+
1161
+ @property
1162
+ def frameValues(self):
1163
+ return self._frameValues
1164
+
1165
+ @frameValues.setter
1166
+ def frameValues(self, a):
1167
+ self._checkEditable()
1168
+ if self.frameAxes is None:
1169
+ err = 'frameAxes must be set first with a list of frame axis names.'
1170
+ raise ValueError(err)
1171
+ if len(a.shape) != len(self.frameAxes) + 1:
1172
+ err = f'frameValues must have {len(self.frameAxes) + 1} dimensions.'
1173
+ raise ValueError(err)
1174
+ self._frameValues = a
1175
+ self._writeInternalMetadata()
1176
+
1177
+ @property
1178
+ def projection(self):
1179
+ return self._projection
1180
+
1181
+ @projection.setter
1182
+ def projection(self, proj):
1183
+ import pyproj
1184
+
1185
+ self._checkEditable()
1186
+ try:
1187
+ pyproj.CRS.from_string(str(proj))
1188
+ except Exception as e:
1189
+ msg = f'Invalid projection value. Cannot be interpreted by pyproj: {str(e)}'
1190
+ raise TileSourceError(msg)
1191
+ self._projection = proj
1192
+ self._writeInternalMetadata()
1193
+
1194
+ @property
1195
+ def gcps(self):
1196
+ return self._gcps
1197
+
1198
+ @gcps.setter
1199
+ def gcps(self, gcps):
1200
+ self._checkEditable()
1201
+ if isinstance(gcps, str):
1202
+ gcps = list(zip(*[iter(gcps.split())] * 4, strict=False))
1203
+ if (isinstance(gcps, (list, tuple))):
1204
+ gcps = [
1205
+ [float(v) for v in gcp.split()]
1206
+ if isinstance(gcp, str) else gcp
1207
+ for gcp in gcps
1208
+ ]
1209
+ if any(
1210
+ not isinstance(gcp, list) and not isinstance(gcp, tuple)
1211
+ for gcp in gcps
1212
+ ):
1213
+ msg = 'Each GCP must be specified as a list, tuple, or space-separated string.'
1214
+ raise TileSourceError(msg)
1215
+ if any(len(gcp) != 4 for gcp in gcps):
1216
+ msg = (
1217
+ 'Each GCP must contain four values: '
1218
+ '[projected_x, projected_y, pixel_x, pixel_y].'
1219
+ )
1220
+ raise TileSourceError(msg)
1221
+ unique_gcps = []
1222
+ dup_gcps = []
1223
+ for gcp in gcps:
1224
+ if not any(
1225
+ u[0:2] == gcp[0:2] or u[2:4] == gcp[2:4]
1226
+ for u in unique_gcps
1227
+ ):
1228
+ unique_gcps.append(gcp)
1229
+ else:
1230
+ dup_gcps.append(gcp)
1231
+ gcps = unique_gcps
1232
+ if len(dup_gcps):
1233
+ msg = (
1234
+ f'Removed duplicate GCPs {dup_gcps} from list; shared projected '
1235
+ 'coordinate or pixel coordinate with another GCP.'
1236
+ )
1237
+ warnings.warn(msg, stacklevel=2)
1238
+ if len(gcps) < 2:
1239
+ msg = 'Must specify at least 2 unique GCPs.'
1240
+ raise TileSourceError(msg)
1241
+ else:
1242
+ msg = 'GCPs must be specified as a list, tuple, or space-separated string.'
1243
+ raise TileSourceError(msg)
1244
+ self._gcps = gcps
1245
+ self._writeInternalMetadata()
1246
+
863
1247
  def _generateDownsampledLevels(self, resample_method):
864
1248
  self._checkEditable()
865
1249
  current_arrays = dict(self._zarr.arrays())
@@ -903,7 +1287,10 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
903
1287
  output=iterator_output,
904
1288
  resample=False, # TODO: incorporate resampling in core
905
1289
  ):
906
- new_tile = downsampleTileHalfRes(tile['tile'], resample_method)
1290
+ tile_data = tile['tile']
1291
+ if tile_data.shape[-1] == 1:
1292
+ tile_data = np.squeeze(tile['tile'], axis=(-1,))
1293
+ new_tile = downsampleTileHalfRes(tile_data, resample_method)
907
1294
  overlap = {k: int(v / 2) for k, v in tile['tile_overlap'].items()}
908
1295
  new_tile = new_tile[
909
1296
  slice(overlap['top'], new_tile.shape[0] - overlap['bottom']),
@@ -923,6 +1310,23 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
923
1310
  )
924
1311
  self._validateZarr() # refresh self._levels before continuing
925
1312
 
1313
+ def _applyGeoReferencing(self, path):
1314
+ if self.projection and not self.gcps:
1315
+ msg = (
1316
+ 'Projection was specified but GCPs were not specified. '
1317
+ 'Cannot write georeferenced file.'
1318
+ )
1319
+ raise TileSourceError(msg)
1320
+ import tifftools
1321
+
1322
+ setlist = []
1323
+ if self.projection is not None:
1324
+ setlist.append(('projection', self.projection))
1325
+ if self.gcps is not None and len(self.gcps):
1326
+ setlist.append(('gcps', self.gcps))
1327
+
1328
+ tifftools.tiff_set(path, setlist=setlist, overwrite=True)
1329
+
926
1330
  def write(
927
1331
  self,
928
1332
  path,
@@ -930,6 +1334,7 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
930
1334
  alpha=True,
931
1335
  overwriteAllowed=True,
932
1336
  resample=None,
1337
+ **converterParams,
933
1338
  ):
934
1339
  """
935
1340
  Output the current image to a file.
@@ -942,6 +1347,8 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
942
1347
  :param resample: one of the ``ResampleMethod`` enum values. Defaults
943
1348
  to ``NP_NEAREST`` for lossless and non-uint8 data and to
944
1349
  ``PIL_LANCZOS`` for lossy uint8 data.
1350
+ :param converterParams: options to pass to the large_image_converter if
1351
+ the output is not a zarr variant.
945
1352
  """
946
1353
  if os.path.exists(path):
947
1354
  if overwriteAllowed:
@@ -978,6 +1385,21 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
978
1385
  **frame_position,
979
1386
  )
980
1387
 
1388
+ if self.minWidth or self.minHeight:
1389
+ old_axes = self._axes if hasattr(self, '_axes') else {}
1390
+ current_arrays = dict(self._zarr.arrays())
1391
+ arr = current_arrays['0']
1392
+ new_shape = tuple(
1393
+ max(
1394
+ v,
1395
+ self.minWidth if self.minWidth is not None and k == 'x' else
1396
+ self.minHeight if self.minHeight is not None and k == 'y' else
1397
+ arr.shape[old_axes[k]],
1398
+ )
1399
+ for k, v in old_axes.items()
1400
+ )
1401
+ self._resizeImage(arr, new_shape, {}, None)
1402
+
981
1403
  source._validateZarr()
982
1404
 
983
1405
  if suffix in ['.zarr', '.db', '.sqlite', '.zip']:
@@ -1008,7 +1430,11 @@ class ZarrFileTileSource(FileTileSource, metaclass=LruCacheMetaclass):
1008
1430
  params = {}
1009
1431
  if lossy and self.dtype == np.uint8:
1010
1432
  params['compression'] = 'jpeg'
1011
- convert(str(attrs_path), path, overwrite=overwriteAllowed, **params)
1433
+ params['overwrite'] = overwriteAllowed
1434
+ params.update(converterParams)
1435
+ convert(str(attrs_path), path, **params)
1436
+
1437
+ self._applyGeoReferencing(path)
1012
1438
 
1013
1439
 
1014
1440
  def open(*args, **kwargs):
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: large-image-source-zarr
3
+ Version: 1.33.6a188
4
+ Summary: A OME Zarr tilesource for large_image.
5
+ Home-page: https://github.com/girder/large_image
6
+ Author: Kitware, Inc.
7
+ Author-email: kitware@kitware.com
8
+ License: Apache-2.0
9
+ Keywords: large_image,tile source
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/x-rst
19
+ License-File: LICENSE
20
+ Requires-Dist: large-image>=1.33.6.a188
21
+ Requires-Dist: zarr<3
22
+ Requires-Dist: imagecodecs
23
+ Requires-Dist: numcodecs<0.16
24
+ Requires-Dist: imagecodecs-numcodecs!=2024.9.22
25
+ Provides-Extra: girder
26
+ Requires-Dist: girder-large-image>=1.33.6.a188; extra == "girder"
27
+ Provides-Extra: all
28
+ Requires-Dist: large-image-converter; extra == "all"
29
+ Dynamic: author
30
+ Dynamic: author-email
31
+ Dynamic: classifier
32
+ Dynamic: description
33
+ Dynamic: description-content-type
34
+ Dynamic: home-page
35
+ Dynamic: keywords
36
+ Dynamic: license
37
+ Dynamic: license-file
38
+ Dynamic: provides-extra
39
+ Dynamic: requires-dist
40
+ Dynamic: requires-python
41
+ Dynamic: summary
42
+
43
+ A OME Zarr tilesource for large_image.
44
+
45
+ See the large-image package for more details.
@@ -0,0 +1,8 @@
1
+ large_image_source_zarr/__init__.py,sha256=BJq47bX_ZR0M0laIJWlI_7RbmUIZgyHwq-A7zDzitH0,58921
2
+ large_image_source_zarr/girder_source.py,sha256=RljxQ49-2PyTPZ4xxTipyyaXiwLAC7npdwWVx7XEhLE,342
3
+ large_image_source_zarr-1.33.6a188.dist-info/licenses/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
4
+ large_image_source_zarr-1.33.6a188.dist-info/METADATA,sha256=TKAqpuCo2XwIikJU-vyJWl7TcQGJ9npiLMhSMBCuXcA,1415
5
+ large_image_source_zarr-1.33.6a188.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ large_image_source_zarr-1.33.6a188.dist-info/entry_points.txt,sha256=VakBZBTOHxWMsYsOO8Egale1mPhLTlLruMfhSBUOwkk,166
7
+ large_image_source_zarr-1.33.6a188.dist-info/top_level.txt,sha256=cMYOehfHJ55_mkRAFS3h2lsO0Pe4jYvMrNKbEznImZI,24
8
+ large_image_source_zarr-1.33.6a188.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (70.3.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,30 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: large-image-source-zarr
3
- Version: 1.29.3.dev39
4
- Summary: A OME Zarr tilesource for large_image.
5
- Home-page: https://github.com/girder/large_image
6
- Author: Kitware, Inc.
7
- Author-email: kitware@kitware.com
8
- License: Apache Software License 2.0
9
- Keywords: large_image,tile source
10
- Classifier: Development Status :: 5 - Production/Stable
11
- Classifier: License :: OSI Approved :: Apache Software License
12
- Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.8
14
- Classifier: Programming Language :: Python :: 3.9
15
- Classifier: Programming Language :: Python :: 3.10
16
- Classifier: Programming Language :: Python :: 3.11
17
- Classifier: Programming Language :: Python :: 3.12
18
- Requires-Python: >=3.6
19
- License-File: LICENSE
20
- Requires-Dist: large-image >=1.29.3.dev39
21
- Requires-Dist: zarr
22
- Requires-Dist: imagecodecs-numcodecs
23
- Provides-Extra: all
24
- Requires-Dist: large-image-converter ; extra == 'all'
25
- Provides-Extra: girder
26
- Requires-Dist: girder-large-image >=1.29.3.dev39 ; extra == 'girder'
27
-
28
- A OME Zarr tilesource for large_image.
29
-
30
- See the large-image package for more details.
@@ -1,8 +0,0 @@
1
- large_image_source_zarr/__init__.py,sha256=bXXS7sr6wTDrY_hLjRv9o4I-CWmqsTpWaFz8VG2PFjs,41105
2
- large_image_source_zarr/girder_source.py,sha256=RljxQ49-2PyTPZ4xxTipyyaXiwLAC7npdwWVx7XEhLE,342
3
- large_image_source_zarr-1.29.3.dev39.dist-info/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
4
- large_image_source_zarr-1.29.3.dev39.dist-info/METADATA,sha256=0ddv37nnqLKFK4u2fgDdBJH5CBfPMAei9nhk7hXXQqE,1115
5
- large_image_source_zarr-1.29.3.dev39.dist-info/WHEEL,sha256=Z4pYXqR_rTB7OWNDYFOm1qRk0RX6GFP2o8LgvP453Hk,91
6
- large_image_source_zarr-1.29.3.dev39.dist-info/entry_points.txt,sha256=VakBZBTOHxWMsYsOO8Egale1mPhLTlLruMfhSBUOwkk,166
7
- large_image_source_zarr-1.29.3.dev39.dist-info/top_level.txt,sha256=cMYOehfHJ55_mkRAFS3h2lsO0Pe4jYvMrNKbEznImZI,24
8
- large_image_source_zarr-1.29.3.dev39.dist-info/RECORD,,