Skip to content

Commit 41529f9

Browse files
authored
feat: add roi parsing (#102)
* feat: add roi parsing * test: update test link * test: fix link
1 parent 5a66090 commit 41529f9

File tree

5 files changed

+246
-8
lines changed

5 files changed

+246
-8
lines changed

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ f.attributes # nd2.structures.Attributes
6666
f.metadata # nd2.structures.Metadata
6767
f.frame_metadata(0) # nd2.structures.FrameMetadata (frame-specific meta)
6868
f.experiment # List[nd2.structures.ExpLoop]
69+
f.rois # Dict[int, nd2.structures.ROI]
6970
f.voxel_size() # VoxelSize(x=0.65, y=0.65, z=1.0)
7071
f.text_info # dict of misc info
7172

@@ -230,6 +231,62 @@ Metadata(
230231

231232
</details>
232233

234+
235+
<details>
236+
237+
<summary><code>rois</code></summary>
238+
239+
ROIs found in the metadata are available at `ND2File.rois`, which is a
240+
`dict` of `nd2.structures.ROI` objects, keyed by the ROI ID:
241+
242+
```python
243+
{
244+
1: ROI(
245+
id=1,
246+
info=RoiInfo(
247+
shapeType=<RoiShapeType.Rectangle: 3>,
248+
interpType=<InterpType.StimulationROI: 4>,
249+
cookie=1,
250+
color=255,
251+
label='',
252+
stimulationGroup=0,
253+
scope=1,
254+
appData=0,
255+
multiFrame=False,
256+
locked=False,
257+
compCount=2,
258+
bpc=16,
259+
autodetected=False,
260+
gradientStimulation=False,
261+
gradientStimulationBitDepth=0,
262+
gradientStimulationLo=0.0,
263+
gradientStimulationHi=0.0
264+
),
265+
guid='{87190352-9B32-46E4-8297-C46621C1E1EF}',
266+
animParams=[
267+
AnimParam(
268+
timeMs=0.0,
269+
enabled=1,
270+
centerX=-0.4228425369685782,
271+
centerY=-0.5194951478743071,
272+
centerZ=0.0,
273+
rotationZ=0.0,
274+
boxShape=BoxShape(
275+
sizeX=0.21256931608133062,
276+
sizeY=0.21441774491682075,
277+
sizeZ=0.0
278+
),
279+
extrudedShape=ExtrudedShape(sizeZ=0, basePoints=[])
280+
)
281+
]
282+
),
283+
...
284+
}
285+
```
286+
287+
</details>
288+
289+
233290
<details>
234291

235292
<summary><code>text_info</code></summary>

scripts/download_samples.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import requests
77

88
TEST_DATA = str(Path(__file__).parent.parent / "tests" / "data")
9-
URL = "https://www.dropbox.com/s/shbuvnkheudt7d7/nd2_test_data.zip?dl=1"
9+
URL = "https://www.dropbox.com/s/q57orjfzzagzull/nd2_test_data.zip?dl=1"
1010

1111

1212
def main():
@@ -23,7 +23,7 @@ def main():
2323
dl += len(data)
2424
f.write(data)
2525
done = int(50 * dl / total_length)
26-
sys.stdout.write("\r[{}{}]".format("=" * done, " " * (50 - done)))
26+
sys.stdout.write(f'\r[{"=" * done}{" " * (50 - done)}]')
2727
sys.stdout.flush()
2828
with ZipFile(f) as zf:
2929
zf.extractall(TEST_DATA)

src/nd2/nd2file.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import numpy as np
2424

2525
from ._util import AXIS, VoxelSize, get_reader, is_supported_file
26-
from .structures import Attributes, ExpLoop, FrameMetadata, Metadata, XYPosLoop
26+
from .structures import ROI, Attributes, ExpLoop, FrameMetadata, Metadata, XYPosLoop
2727

2828
try:
2929
from functools import cached_property
@@ -44,6 +44,9 @@
4444

4545
Index = Union[int, slice]
4646

47+
ROI_METADATA = "CustomData|RoiMetadata_v1"
48+
IMG_METADATA = "ImageMetadataLV"
49+
4750

4851
class ReadMode(str, Enum):
4952
MMAP = "mmap"
@@ -155,6 +158,21 @@ def text_info(self) -> Dict[str, Any]:
155158
"""Misc text info."""
156159
return self._rdr.text_info()
157160

161+
@cached_property
162+
def rois(self) -> Dict[int, ROI]:
163+
"""Return dict of {id: ROI} for all ROIs found in the metadata."""
164+
if self.is_legacy or ROI_METADATA not in self._rdr._meta_map: # type: ignore
165+
return {}
166+
data = self.unstructured_metadata(include={ROI_METADATA})
167+
data = data.get(ROI_METADATA, {}).get("RoiMetadata_v1", {})
168+
data.pop("Global_Size", None)
169+
try:
170+
_rois = (ROI._from_meta_dict(d) for d in data.values())
171+
rois = {r.id: r for r in _rois}
172+
except Exception as e:
173+
raise ValueError(f"Could not parse ROI metadata: {e}") from e
174+
return rois
175+
158176
@cached_property
159177
def experiment(self) -> List[ExpLoop]:
160178
"""Loop information for each nd axis"""
@@ -164,16 +182,16 @@ def experiment(self) -> List[ExpLoop]:
164182
# the SDK doesn't always do a good job of pulling position names from metadata
165183
# here, we try to extract it manually. Might be error prone, so currently
166184
# we just ignore errors.
167-
if not self.is_legacy and "ImageMetadataLV" in self._rdr._meta_map: # type: ignore # noqa
185+
if not self.is_legacy and IMG_METADATA in self._rdr._meta_map: # type: ignore
168186
for n, item in enumerate(exp):
169187
if isinstance(item, XYPosLoop):
170188
names = {
171189
tuple(p.stagePositionUm): p.name for p in item.parameters.points
172190
}
173191
if not any(names.values()):
174192
_exp = self.unstructured_metadata(
175-
include={"ImageMetadataLV"}, unnest=True
176-
)["ImageMetadataLV"]
193+
include={IMG_METADATA}, unnest=True
194+
)[IMG_METADATA]
177195
if n >= len(_exp):
178196
continue
179197
with contextlib.suppress(Exception):
@@ -238,7 +256,7 @@ def unstructured_metadata(
238256
_keys: Set[str] = set()
239257
for i in include:
240258
if i not in keys:
241-
warnings.warn(f"include key {i!r} not found in metadata")
259+
warnings.warn(f"Key {i!r} not found in metadata")
242260
else:
243261
_keys.add(i)
244262
keys = _keys
@@ -253,7 +271,7 @@ def unstructured_metadata(
253271
decoded: Any = meta.decode("utf-8")
254272
else:
255273
decoded = decode_metadata(meta, strip_prefix=strip_prefix)
256-
if key == "ImageMetadataLV" and unnest:
274+
if key == IMG_METADATA and unnest:
257275
decoded = unnest_experiments(decoded)
258276
except Exception:
259277
decoded = meta

src/nd2/structures.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import builtins
34
from dataclasses import dataclass, field
45
from enum import Enum, IntEnum
56
from typing import List, NamedTuple, Optional, Tuple, Union
@@ -320,3 +321,153 @@ class Coordinate(NamedTuple):
320321
index: int
321322
type: str
322323
size: int
324+
325+
326+
def _lower0(x: str) -> str:
327+
return x[0].lower() + x[1:]
328+
329+
330+
class BoxShape(NamedTuple):
331+
sizeX: float = 0
332+
sizeY: float = 0
333+
sizeZ: float = 0
334+
335+
336+
class XYPoint(NamedTuple):
337+
x: float = 0
338+
y: float = 0
339+
340+
341+
class XYZPoint(NamedTuple):
342+
x: float = 0
343+
y: float = 0
344+
z: float = 0
345+
346+
347+
class ExtrudedShape(NamedTuple):
348+
sizeZ: float = 0
349+
basePoints: List[XYPoint] = []
350+
351+
@classmethod
352+
def _from_meta_dict(cls, val: dict) -> ExtrudedShape:
353+
return cls(
354+
sizeZ=val.get("SizeZ") or val.get("sizeZ") or 0,
355+
basePoints=[
356+
XYPoint(*val[f"BasePoints_{i}"].get("", []))
357+
for i in range(val.get("BasePoints_Size", 0))
358+
],
359+
)
360+
361+
362+
@dataclass
363+
class ROI:
364+
"""ROI object from NIS Elements."""
365+
366+
id: int
367+
info: RoiInfo
368+
guid: str
369+
animParams: List[AnimParam] = field(default_factory=list)
370+
371+
def __post_init__(self):
372+
self.info = RoiInfo(**self.info)
373+
self.animParams = [AnimParam(**i) for i in self.animParams]
374+
375+
@classmethod
376+
def _from_meta_dict(cls, val: dict) -> ROI:
377+
anim_params = [
378+
{_lower0(k): v for k, v in val[f"AnimParams_{i}"].items()}
379+
for i in range(val.pop("AnimParams_Size", 0))
380+
]
381+
return cls(
382+
id=val["Id"],
383+
info={_lower0(k): v for k, v in val["Info"].items()},
384+
guid=val.get("GUID", ""),
385+
animParams=anim_params,
386+
)
387+
388+
389+
@dataclass
390+
class AnimParam:
391+
"""Parameters of ROI position/shape."""
392+
393+
timeMs: float = 0
394+
enabled: bool = True
395+
centerX: float = 0
396+
centerY: float = 0
397+
centerZ: float = 0
398+
rotationZ: float = 0
399+
boxShape: BoxShape = BoxShape()
400+
extrudedShape: ExtrudedShape = ExtrudedShape()
401+
402+
def __post_init__(self):
403+
if isinstance(self.boxShape, dict):
404+
self.boxShape = BoxShape(
405+
**{_lower0(k): v for k, v in self.boxShape.items()}
406+
)
407+
if isinstance(self.extrudedShape, dict):
408+
self.extrudedShape = ExtrudedShape._from_meta_dict(self.extrudedShape)
409+
410+
@property
411+
def center(self) -> XYZPoint:
412+
"""Center point as a named tuple (x, y, z)."""
413+
return XYZPoint(self.centerX, self.centerY, self.centerZ)
414+
415+
416+
class RoiShapeType(IntEnum):
417+
"""The type of ROI shape."""
418+
419+
Raster = 1
420+
Unknown2 = 2
421+
Rectangle = 3
422+
Ellipse = 4
423+
Polygon = 5
424+
Bezier = 6
425+
Unknown7 = 7
426+
Unknown8 = 8
427+
Circle = 9
428+
Square = 10
429+
430+
431+
class InterpType(IntEnum):
432+
"""The role that the ROI plays."""
433+
434+
StandardROI = 1
435+
BackgroundROI = 2
436+
ReferenceROI = 3
437+
StimulationROI = 4
438+
439+
440+
@dataclass
441+
class RoiInfo:
442+
"""Info associated with an ROI."""
443+
444+
shapeType: RoiShapeType
445+
interpType: InterpType
446+
cookie: int = 0
447+
color: int = 255
448+
label: str = ""
449+
# everything will default to zero, EVEN if "use as stimulation" is not checked
450+
# use interpType to determine if it's a stimulation ROI
451+
stimulationGroup: int = 0
452+
scope: int = 1
453+
appData: int = 0
454+
multiFrame: bool = False
455+
locked: bool = False
456+
compCount: int = 2
457+
bpc: int = 16
458+
autodetected: bool = False
459+
gradientStimulation: bool = False
460+
gradientStimulationBitDepth: int = 0
461+
gradientStimulationLo: float = 0.0
462+
gradientStimulationHi: float = 0.0
463+
464+
def __post_init__(self):
465+
# coerce types
466+
for key, anno in self.__annotations__.items():
467+
if key == "shapeType":
468+
self.shapeType = RoiShapeType(self.shapeType)
469+
elif key == "interpType":
470+
self.interpType = InterpType(self.interpType)
471+
else:
472+
type_ = getattr(builtins, anno)
473+
setattr(self, key, type_(getattr(self, key)))

tests/test_rois.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from pathlib import Path
2+
3+
import nd2
4+
5+
DATA = Path(__file__).parent / "data"
6+
7+
8+
def test_rois():
9+
with nd2.ND2File(DATA / "rois.nd2") as f:
10+
rois = f.rois.values()
11+
assert len(rois) == 18
12+
assert [r.id for r in rois] == list(range(1, 19))

0 commit comments

Comments
 (0)