Skip to content

Commit a5b3caf

Browse files
feat: add exposure time and objective info into ome metadata (#303)
Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
1 parent aaccfbd commit a5b3caf

2 files changed

Lines changed: 60 additions & 5 deletions

File tree

src/nd2/_ome.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,16 @@ def nd2_ome_metadata(
6969
(x for x in DimensionOrder if x.value.startswith(dims)), DimensionOrder.XYCZT
7070
)
7171

72+
# Exposure time and detectors come from raw metadata
73+
raw_meta = rdr._cached_raw_metadata()
74+
sample_settings = raw_meta.get("sPicturePlanes", {}).get("sSampleSetting", {})
75+
7276
# if sizes.get(AXIS.CHANNEL, 1) > 1 and sizes.get(AXIS.RGB, 1) > 1:
7377
# warn("multi-channel RGB images are not well supported in nd2 OME metadata.")
7478

7579
instrument = m.Instrument(
7680
id="Instrument:0",
77-
detectors=ome_detectors(rdr._cached_raw_metadata()),
81+
detectors=ome_detectors(raw_meta),
7882
# TODO:
7983
# dichroics: List[Dichroic]
8084
# filter_sets: List[FilterSet]
@@ -84,6 +88,13 @@ def nd2_ome_metadata(
8488
)
8589

8690
ch0 = next(iter(meta.channels or ()), None)
91+
objective_settings = (
92+
None
93+
if ch0 is None
94+
else m.ObjectiveSettings(
95+
id="Objective:0", refractive_index=ch0.microscope.immersionRefractiveIndex
96+
)
97+
)
8798
channels = []
8899
for c_idx, ch in enumerate(meta.channels or ()):
89100
channel = m.Channel(
@@ -143,13 +154,24 @@ def nd2_ome_metadata(
143154
# TODO: i think RGB might actually need to be 3 planes with 1 spp
144155
z_idx = loop_idx.get(AXIS.Z, 0)
145156
t_idx = loop_idx.get(AXIS.TIME, 0)
157+
158+
# sSampleSetting has one entry per acquisition (not per channel)
159+
# Laser-scanning confocals use a single entry governing all channels
160+
# Entry count is always either 1 or SizeC
161+
ch_setting = sample_settings.get(f"a{c_idx}")
162+
if ch_setting is None and len(sample_settings) == 1:
163+
ch_setting = next(iter(sample_settings.values()))
164+
165+
# 0.0 is a placeholder value so set that as None
166+
exposure_time = (ch_setting or {}).get("dExposureTime") or None
167+
146168
planes.append(
147169
m.Plane(
148170
the_z=z_idx,
149171
the_t=t_idx,
150172
the_c=c_idx,
151-
# exposure_time=...,
152-
# exposure_time_unit=...,
173+
exposure_time=exposure_time,
174+
exposure_time_unit=UnitsTime.MILLISECOND,
153175
delta_t=round(fm_ch.time.relativeTimeMs, 6),
154176
delta_t_unit=UnitsTime.MILLISECOND, # default is "s"
155177
position_x=round(fm_ch.position.stagePositionUm.x, 6),
@@ -200,7 +222,7 @@ def nd2_ome_metadata(
200222
images.append(
201223
m.Image(
202224
instrument_ref=m.InstrumentRef(id=instrument.id),
203-
# objective_settings=...
225+
objective_settings=objective_settings,
204226
id=f"Image:{p}",
205227
name=name,
206228
pixels=pixels,
@@ -215,7 +237,7 @@ def nd2_ome_metadata(
215237
id="Objective:0",
216238
nominal_magnification=scope.objectiveMagnification,
217239
lens_na=scope.objectiveNumericalAperture,
218-
# model=... # something like "Plan Fluor 10x Ph1 DLL"
240+
model=scope.objectiveName,
219241
# immersion=scope.ome_objective_immersion(),
220242
)
221243
)

tests/test_ome.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
from __future__ import annotations
2+
13
from pathlib import Path
24

35
import nd2
46
import pytest
57

8+
DATA = Path(__file__).parent / "data"
9+
610

711
def test_ome_meta(new_nd2: Path) -> None:
812
ome = pytest.importorskip("ome_types")
@@ -15,3 +19,32 @@ def test_ome_meta(new_nd2: Path) -> None:
1519
if new_nd2.name == "dims_p4z5t3c2y32x32.nd2":
1620
names = [img.name for img in meta.images]
1721
assert names == ["point name 1", "point name 2", "point name 3", "point name 4"]
22+
23+
24+
def test_ome_exposure_invariants(new_nd2: Path) -> None:
25+
pytest.importorskip("ome_types")
26+
with nd2.ND2File(new_nd2) as f:
27+
meta = f.ome_metadata(include_unstructured=False)
28+
29+
for img in meta.images:
30+
for plane in img.pixels.planes:
31+
assert plane.exposure_time_unit.value == "ms"
32+
assert plane.exposure_time is None or plane.exposure_time > 0
33+
34+
35+
@pytest.mark.parametrize(
36+
"name, expected",
37+
[
38+
("10ms_2xbin_100xmag.nd2", [10.0]),
39+
("Exp3_9.8.21_Mouse1_DiI_4x_2x2-Slide1-1_B12.nd2", [150.0, 100.0]),
40+
("cluster.nd2", [996.9975547008216] * 2),
41+
("ML_06_72_ni_all8-MaxIP.nd2", [None] * 5),
42+
],
43+
)
44+
def test_ome_exposure_vals(name: str, expected: list[float | None]) -> None:
45+
pytest.importorskip("ome_types")
46+
with nd2.ND2File(DATA / name) as f:
47+
meta = f.ome_metadata(include_unstructured=False)
48+
49+
by_channel = {p.the_c: p.exposure_time for p in meta.images[0].pixels.planes}
50+
assert [by_channel[channel] for channel in range(len(expected))] == expected

0 commit comments

Comments
 (0)