Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ROI Data Classes #106

Merged
merged 7 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions pytools/HedwigZarrImage.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@

import SimpleITK as sitk
import zarr
from typing import Tuple, Dict, List, Optional
from typing import Tuple, Dict, List, Optional, Iterable
from pytools.utils import OMEInfo
import logging
import math
import re
import dask
from pytools.utils.histogram import DaskHistogramHelper, histogram_robust_stats, histogram_stats, weighted_quantile
from pytools.data import OMEROIModel


logger = logging.getLogger(__name__)

Expand All @@ -38,6 +40,20 @@ def __init__(self, zarr_grp: zarr.Group, _ome_info: OMEInfo, _ome_idx: Optional[

assert "multiscales" in self.zarr_group.attrs

def ome_roi_model(self) -> Iterable[OMEROIModel]:
"""
Get the OME ROI models for the current image, if they exist.

Parses the OME-XML file and returns the ROI models. The OME ROI model is generically union of annotations such
as labels and rectangles.

:return: The ROI model as an iterable of OMEROIModels, which may be empty.
"""
if self.ome_info is None:
yield from ()
else:
yield from self.ome_info.roi(self.ome_idx)

@property
def path(self) -> Path:
"""
Expand All @@ -58,22 +74,30 @@ def dims(self) -> str:
return "".join(dims[::-1])

@property
def shape(self) -> Tuple[int]:
def shape(self) -> Tuple[int, ...]:
"""The size of the dimensions of the full resolution image.

This is in numpy/zarr/dask order.
"""
return self._ome_ngff_multiscale_get_array(0).shape

@property
def spacing(self) -> Tuple[float]:
def spacing(self) -> Tuple[float, ...]:
"""The size of the dimensions of the full resolution image.

This is in numpy/zarr/dask order.
"""

return self._ome_ngff_multiscales(idx=0)["datasets"][0]["coordinateTransformations"][0]["scale"]

@property
def units(self) -> Tuple[str, ...]:
"""The units of the dimensions of the full resolution image.

This is in numpy/zarr/dask order.
"""
return tuple(str(ax["unit"]) if "unit" in ax else "" for ax in self._ome_ngff_multiscales(idx=0)["axes"])

def rechunk(self, chunk_size: int, compressor=None, *, in_memory=False) -> None:
"""
Change the chunk size of each ZARR array inplace in the pyramid.
Expand Down
22 changes: 13 additions & 9 deletions pytools/HedwigZarrImages.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,18 +87,22 @@ def group(self, name: str) -> HedwigZarrImage:
return HedwigZarrImage(self.zarr_root[name], self.ome_info, k_idx)

def __getitem__(self, item: Union[str, int]) -> HedwigZarrImage:
"""
Returns a HedwigZarrImage from the given the OME series name or a ZARR index.
"""

for k_idx, k in enumerate(self.get_series_keys()):
if item == k and "OME" in self.zarr_root.group_keys():
ome_index_to_zarr_group = self.zarr_root["OME"].attrs["series"]

if len(ome_index_to_zarr_group) != len(set(ome_index_to_zarr_group)):
raise RuntimeError(f'The OME "series" contains duplicated paths: f{ome_index_to_zarr_group}')
if "OME" not in self.zarr_root.group_keys():
return HedwigZarrImage(self.zarr_root[item], self.ome_info, 404)

zarr_idx = ome_index_to_zarr_group[k_idx]
return HedwigZarrImage(self.zarr_root[zarr_idx], self.ome_info, k_idx)
elif isinstance(item, int):
return HedwigZarrImage(self.zarr_root[item], self.ome_info, item)

return HedwigZarrImage(self.zarr_root[item], None, 404)
elif isinstance(item, str):
ome_index_to_zarr_group = self.zarr_root["OME"].attrs["series"]
for ome_idx, k in enumerate(self.get_series_keys()):
if k == item:
return HedwigZarrImage(self.zarr_root[ome_index_to_zarr_group[ome_idx]], self.ome_info, ome_idx)
raise KeyError(f"Series name {item} not found: {list(self.get_series_keys())}! ")

def series(self) -> Iterable[Tuple[str, HedwigZarrImage]]:
"""An Iterable of key and HedwigZarrImages stored in the ZARR structure."""
Expand Down
73 changes: 73 additions & 0 deletions pytools/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from dataclasses import dataclass, field
from typing import Optional, List, Union, Tuple


@dataclass
class OMEDataObject:
"""
Generic base object for OME data defined in the OME XML.
"""

pass


@dataclass
class ROIRectangle(OMEDataObject):
"""
Represents a rectangle ROI in the OME XML

Coordinates are in pixels.
"""

x: float
y: float
width: float
height: float

the_z: Optional[float] = None
the_t: Optional[float] = None
the_c: Optional[float] = None

@property
def point_a(self) -> Tuple[float, float]:
"""
Returns the top left point of the rectangle.
"""
return self.x, self.y

@property
def point_b(self) -> Tuple[float, float]:
"""
Returns the bottom right point of the rectangle.
"""
return self.x + self.width, self.y + self.height


@dataclass
class ROILabel(OMEDataObject):
"""
A spacial text label.

Coordinates are in pixels.
"""

x: float
y: float
text: str


@dataclass
class OMEROIModel(OMEDataObject):
"""
Represents the OME ROI model. Which contains a union/list of annotations.

See https://docs.openmicroscopy.org/ome-model/5.6.3/developers/roi.html for more information.

The OME.TIFF files generated in spacial-omics microscopy have the label followed by the rectangle, so
that the label could be used as the description of the Neuroglancer annotation.
"""

id: str
name: Optional[str] = None
description: Optional[str] = None
union: List[Union[ROIRectangle, ROILabel]] = field(default_factory=list)
113 changes: 79 additions & 34 deletions pytools/ng/viz.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@
import neuroglancer
from pathlib import Path
import jinja2
import xml.etree.ElementTree as ET
from pytools import HedwigZarrImages
from typing import Union
from pytools.utils import OMEInfo
from pytools.data import ROIRectangle, ROILabel


_rgb_shader_template = """
Expand Down Expand Up @@ -66,6 +67,41 @@
_shader_parameter_cache = {}


def _convert_si_units_from_long_to_abbr(s: str) -> str:
"""
Convert a string with long SI units to the abbreviated form.

:param s: The string to convert.
:return: The string with the long SI units converted to the abbreviated form.
"""

# A map of long SI units to their abbreviated form.
si_units = {
"yoctometer": "ym",
"zeptometer": "zm",
"attometer": "am",
"femtometer": "fm",
"picometer": "pm",
"nanometer": "nm",
"micrometer": "µm",
"millimeter": "mm",
"centimeter": "cm",
"decimeter": "dm",
"meter": "m",
"decameter": "da",
"hectometer": "hm",
"kilometer": "km",
"megameter": "Mm",
"gigameter": "Gm",
"terameter": "Tm",
"petameter": "Pm",
"exagram": "Em",
"zettameter": "Zm",
"yottameter": "Ym",
}
return si_units.get(s, s)


def _homogeneous_identity(ndim: int) -> np.array:
"""
Create a homogeneous identity matrix of dimension ndim rows by ndim+1 columns.
Expand Down Expand Up @@ -188,59 +224,68 @@ def add_roi_annotations(viewer_txn, ome_xml_filename, *, layername="roi annotati
The OME-XML specifications for ROI models is here:
https://docs.openmicroscopy.org/ome-model/5.6.3/developers/roi.html

The ROI is specified in the image coordinate space and the image meta-data is needed to convert to the physical
space. If the reference_zarr is provided, then the image meta-data is extracted from the zarr file. Otherwise,
the image meta-data is extracted from the OME-XML file.

:param viewer_txn: The neuroglancer viewer transaction object.
:param ome_xml_filename: The path to the OME-XML file.
:param layername: The name of the annotation layer in the viewer.
:param reference_zarr: The path to the reference zarr file. The ROI is specified in the image coordinate space and
the image meta-data is needed to convert to the physical space. NOTE: This could come from the OME-XML description
for an image but currently does not.
:param reference_zarr: If specified the image meta-data is extracted from the zarr file. Otherwise, the image
meta-data is extracted from the OME-XML file.

"""

if reference_zarr:
xml_path = Path(ome_xml_filename)

ome_idx = 0

with open(xml_path, "r") as fp:
data = fp.read()
ome_info = OMEInfo(data)

if reference_zarr is None:
assert ome_info.dimension_order(ome_idx) == "XYZCT"
scales = ome_info.spacing(ome_idx)[:2]
units = ome_info.units(ome_idx)[:2]

else:
# Coordinate for the ROI rectangles are in the space of an image. The dimensions/CoordinateSpace map the
# index space to physical space and the "scales" from the reference image are needed to map the space.
zarr_root = Path(reference_zarr).parent
zarr_key = Path(reference_zarr).name
hwz_images = HedwigZarrImages(zarr_root)
hwz_image = hwz_images.group(zarr_key)
spacing_tczyx = hwz_image.spacing
scales = spacing_tczyx[:2:-1]

xml_path = Path(ome_xml_filename)
# Convert TCZYX to XY
scales = hwz_image.spacing[:2:-1]
units = hwz_image.units[:2:-1]

ns = {"OME": "http://www.openmicroscopy.org/Schemas/OME/2016-06"}
with open(xml_path, "r") as fp:
data = fp.read()
xml_root = ET.fromstring(data)
units = [_convert_si_units_from_long_to_abbr(u) for u in units]

layer = neuroglancer.LocalAnnotationLayer(
dimensions=neuroglancer.CoordinateSpace(
names=["x", "y"],
units="µm",
units=units,
scales=scales,
),
)
)

viewer_txn.layers[layername] = layer

for roi in xml_root.iterfind("OME:ROI", ns):
text = roi.attrib["ID"]
if "Name" in roi.attrib:
text = roi.attrib["Name"]
for r in roi.iterfind("./OME:Union/OME:Rectangle", ns):
height = float(r.attrib["Height"])
width = float(r.attrib["Width"])
x = float(r.attrib["X"])
y = float(r.attrib["Y"])

a = (x, y)
b = (x + width, y + height)
for label in roi.iterfind("./OME:Union/OME:Label", ns):
text = label.attrib["Text"]

layer.annotations.append(
neuroglancer.AxisAlignedBoundingBoxAnnotation(
description=text, id=neuroglancer.random_token.make_random_token(), point_a=a, point_b=b
)
)
for roi_model in ome_info.roi(ome_idx):
label = roi_model.id
for roi in roi_model.union:
if isinstance(roi, ROILabel):
# Assuming that label is followed by the associated rectangle in the OME-XML file.
label = roi.text
elif isinstance(roi, ROIRectangle):
layer.annotations.append(
neuroglancer.AxisAlignedBoundingBoxAnnotation(
description=label,
id=neuroglancer.random_token.make_random_token(),
point_a=roi.point_a,
point_b=roi.point_b,
)
)
label = "unknown"
44 changes: 44 additions & 0 deletions pytools/utils/OMEInfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@

import xml.etree.ElementTree as ET
from typing import Iterable
import logging

from pytools.data import ROILabel, ROIRectangle, OMEROIModel

logging.getLogger(__name__)


class OMEInfo:
Expand Down Expand Up @@ -77,3 +82,42 @@ def units(self, image_index):
attr = "PhysicalSize{}Unit".format(d)
units.append(px_element.get(attr, default))
return units

def roi(self, image_index) -> Iterable[OMEROIModel]:
"""
Extracts the ROI referenced by an image in the OME XML data.

The OME-XML specifications for ROI models is here:
https://docs.openmicroscopy.org/ome-model/5.6.3/developers/roi.html

"""

for roiref_el in self._image_element(image_index).iterfind("OME:ROIRef", self._ome_ns):
roi_id = roiref_el.attrib["ID"]
for roi_el in self._root_element.iterfind(f".//OME:ROI[@ID='{roi_id}']", self._ome_ns):
# The ROI element may have and ID and Name attribute may be useful context if the label is not
# sufficient. This is not currently used in the implementation.
roi_model = OMEROIModel(
id=roi_id, name=roi_el.attrib.get("Name", None), description=roi_el.attrib.get("Description", None)
)
# iterate over all child elements of the ROI/Unions
for union_el in roi_el.findall(".//OME:Union", self._ome_ns):
for child_el in union_el:
if child_el.tag == f"{{{self._ome_ns['OME']}}}Rectangle":
roi_model.union.append(
ROIRectangle(
x=float(child_el.attrib["X"]),
y=float(child_el.attrib["Y"]),
width=float(child_el.attrib["Width"]),
height=float(child_el.attrib["Height"]),
)
)
elif child_el.tag == f"{{{self._ome_ns['OME']}}}Label":
roi_model.union.append(
ROILabel(
x=float(child_el.attrib["X"]),
y=float(child_el.attrib["Y"]),
text=child_el.attrib["Text"],
)
)
yield roi_model
3 changes: 3 additions & 0 deletions test/data/IA_P2_S1.ome.xml
Git LFS file not shown
Loading